Plugin reports render; USB check-in/out audit report; kiosk drive lists
This commit is contained in:
32
CHANGELOG.md
32
CHANGELOG.md
@@ -12,6 +12,38 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **USB check-in / check-out audit report.** CMMC removable-media tracking is an
|
||||||
|
audit obligation and there was no report for it - answering "who had this
|
||||||
|
drive, and when" meant paging through the check-in/out list by hand. The
|
||||||
|
window is named rather than a day count: today, last month, six months, a
|
||||||
|
year, or all time. Events rather than totals, because that is the question an
|
||||||
|
audit actually asks, with a CSV export for the people who want it in a
|
||||||
|
spreadsheet. Works against the external `cmmc_usb` log or the self-hosted
|
||||||
|
tables; in self-hosted mode one stored loan expands into a take and a return,
|
||||||
|
so a returned drive is distinguishable from one still out.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Plugin reports rendered a raw JSON dump.** The reports page ran a hardcoded
|
||||||
|
switch over five core report ids and fell through to
|
||||||
|
`console.error('Unknown report')`, and its results panel was a matching chain
|
||||||
|
of per-id templates ending in a `JSON.stringify`. So a plugin could register a
|
||||||
|
report, watch it appear in the catalogue, and get a wall of JSON when it was
|
||||||
|
opened - which is where the three printedparts reports have been since they
|
||||||
|
were added. Any report that declares an endpoint is now fetched from it, and
|
||||||
|
any report returning `{columns, rows}` renders as a table. A plugin needs no
|
||||||
|
core code to have a working report. CSV export follows the same endpoint
|
||||||
|
rather than assuming `/api/reports/<id>`, which does not exist for a plugin
|
||||||
|
report, and a report that fails now says so instead of showing an empty panel.
|
||||||
|
Export CSV is fetched through the api client rather than `window.open`: a new
|
||||||
|
window carries no Authorization header, so every report behind a real
|
||||||
|
`jwt_required` answered the export button with a 401. The core reports are
|
||||||
|
jwt-optional, which is the only reason the old way ever appeared to work.
|
||||||
|
|
||||||
|
- **Reports had no way to choose a time period.** Endpoints accepted a window;
|
||||||
|
the page offered no control for it, so `printedparts-consumption` was
|
||||||
|
permanently stuck on its 30-day default.
|
||||||
|
|
||||||
- **USB kiosk: check a drive out and back in without a login.** The people who
|
- **USB kiosk: check a drive out and back in without a login.** The people who
|
||||||
actually take USB drives are standing at the cabinet, and most of them have no
|
actually take USB drives are standing at the cabinet, and most of them have no
|
||||||
ShopDB account - so check-out and check-in were gated behind a login that was
|
ShopDB account - so check-out and check-in were gated behind a login that was
|
||||||
|
|||||||
@@ -47,8 +47,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reportError" class="error-message">{{ reportError }}</div>
|
||||||
|
|
||||||
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
||||||
<div v-if="activeFilterFields.length" class="filters">
|
<div v-if="activeFilterFields.length" class="filters">
|
||||||
|
<select
|
||||||
|
v-if="activeFilterFields.includes('period')"
|
||||||
|
v-model="filterState.period"
|
||||||
|
class="form-control"
|
||||||
|
@change="refreshReport"
|
||||||
|
>
|
||||||
|
<option v-for="option in PERIOD_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
<select
|
<select
|
||||||
v-if="activeFilterFields.includes('businessunit')"
|
v-if="activeFilterFields.includes('businessunit')"
|
||||||
v-model="filterState.businessunitid"
|
v-model="filterState.businessunitid"
|
||||||
@@ -209,6 +221,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generic fallback -->
|
<!-- Generic fallback -->
|
||||||
|
<!--
|
||||||
|
Any report that returns {columns, rows} renders as a table. Every core
|
||||||
|
report above has a hand-written block; a plugin report had none, so it
|
||||||
|
fell through to a raw JSON dump - which is what the printedparts reports
|
||||||
|
have been showing since they were added. The shape is already the
|
||||||
|
convention on the server side, so honouring it here means a plugin gets
|
||||||
|
a real report with no core code at all.
|
||||||
|
-->
|
||||||
|
<div v-else-if="genericRows" class="report-content">
|
||||||
|
<p v-if="reportData.summary" class="report-summary">
|
||||||
|
<span v-for="(value, key) in reportData.summary" :key="key" class="summary-figure">
|
||||||
|
<strong>{{ value }}</strong> {{ titleCase(key) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p v-else class="report-summary">{{ genericRows.length }} row(s)</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th v-for="column in genericColumns" :key="column">{{ titleCase(column) }}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in genericRows" :key="index">
|
||||||
|
<td v-for="column in genericColumns" :key="column">{{ row[column] }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-if="!genericRows.length" class="report-empty">
|
||||||
|
Nothing in this period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last resort: a shape nothing here knows. Better visible than blank. -->
|
||||||
<div v-else class="report-content">
|
<div v-else class="report-content">
|
||||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,8 +264,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { apiError } from '@/utils/apiError'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -240,18 +285,49 @@ const reportFilterFields = {
|
|||||||
'assets-by-status': ['assettype', 'businessunit'],
|
'assets-by-status': ['assettype', 'businessunit'],
|
||||||
'asset-inventory': ['businessunit', 'location'],
|
'asset-inventory': ['businessunit', 'location'],
|
||||||
'kb-popularity': ['limit'],
|
'kb-popularity': ['limit'],
|
||||||
'software-compliance': ['application']
|
'software-compliance': ['application'],
|
||||||
|
// Time-windowed reports. The endpoints already accepted a window; there was
|
||||||
|
// simply no way to choose one from the page.
|
||||||
|
'usb-audit': ['period'],
|
||||||
|
'printedparts-consumption': ['period'],
|
||||||
|
'printedparts-by-person': ['period']
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
|
// Named windows rather than a day count: the question is "this shift" or "this
|
||||||
|
// year", and making somebody work out that a year is 365 serves nobody.
|
||||||
|
const PERIOD_OPTIONS = [
|
||||||
|
{ value: 'day', label: 'Today' },
|
||||||
|
{ value: 'month', label: 'Last month' },
|
||||||
|
{ value: 'sixmonths', label: 'Last 6 months' },
|
||||||
|
{ value: 'year', label: 'Last year' },
|
||||||
|
{ value: 'all', label: 'All time' }
|
||||||
|
]
|
||||||
|
const PERIOD_DAYS = { day: 1, month: 30, sixmonths: 182, year: 365, all: 0 }
|
||||||
|
|
||||||
|
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' })
|
||||||
|
const reportError = ref('')
|
||||||
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
||||||
|
|
||||||
|
// A report is "generic" when it returns rows plus the columns to show them in.
|
||||||
|
const genericRows = computed(() => {
|
||||||
|
const data = reportData.value
|
||||||
|
return data && Array.isArray(data.rows) ? data.rows : null
|
||||||
|
})
|
||||||
|
const genericColumns = computed(() => {
|
||||||
|
const data = reportData.value
|
||||||
|
if (!data) return []
|
||||||
|
if (Array.isArray(data.columns) && data.columns.length) return data.columns
|
||||||
|
// No declared columns: take them from the first row so the table still works.
|
||||||
|
return data.rows?.length ? Object.keys(data.rows[0]) : []
|
||||||
|
})
|
||||||
|
|
||||||
const activeFilterFields = computed(() =>
|
const activeFilterFields = computed(() =>
|
||||||
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
||||||
)
|
)
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
|
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' }
|
||||||
|
reportError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch dropdown options once, only for the fields the open report needs
|
// fetch dropdown options once, only for the fields the open report needs
|
||||||
@@ -288,6 +364,11 @@ function filterParams() {
|
|||||||
if (state.locationid) params.locationid = state.locationid
|
if (state.locationid) params.locationid = state.locationid
|
||||||
if (state.appid) params.appid = state.appid
|
if (state.appid) params.appid = state.appid
|
||||||
if (state.limit) params.limit = state.limit
|
if (state.limit) params.limit = state.limit
|
||||||
|
if (activeFilterFields.value.includes('period') && state.period) {
|
||||||
|
params.period = state.period
|
||||||
|
// Sent as well so an endpoint that only knows ?days= still honours it.
|
||||||
|
params.days = PERIOD_DAYS[state.period]
|
||||||
|
}
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,44 +465,64 @@ function applyQuery(id) {
|
|||||||
async function runReport(report) {
|
async function runReport(report) {
|
||||||
currentReport.value = report
|
currentReport.value = report
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
reportError.value = ''
|
||||||
reportData.value = null
|
reportData.value = null
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
|
|
||||||
const params = filterParams()
|
const params = filterParams()
|
||||||
try {
|
try {
|
||||||
let response
|
// Any report that declares an endpoint is fetched from it.
|
||||||
switch (report.id) {
|
//
|
||||||
case 'machines-by-type':
|
// This used to be a switch over five hardcoded core ids with
|
||||||
response = await reportsApi.machinesByType(params)
|
// `default: console.error('Unknown report')`. A plugin could register a
|
||||||
break
|
// report, have it appear in the catalogue, and then render nothing at all
|
||||||
case 'assets-by-status':
|
// when opened - the three printedparts reports were in exactly that state.
|
||||||
response = await reportsApi.assetsByStatus(params)
|
// The endpoint is already on the contract; using it means a plugin never
|
||||||
break
|
// needs a line of core code to be runnable.
|
||||||
case 'kb-popularity':
|
const url = report.endpoint || `/api/reports/${report.id}`
|
||||||
response = await reportsApi.kbPopularity(params)
|
const response = await api.get(stripApiPrefix(url), { params })
|
||||||
break
|
|
||||||
case 'software-compliance':
|
|
||||||
response = await reportsApi.softwareCompliance(params)
|
|
||||||
break
|
|
||||||
case 'asset-inventory':
|
|
||||||
response = await reportsApi.assetInventory(params)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
console.error('Unknown report:', report.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reportData.value = response.data.data
|
reportData.value = response.data.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error running report:', error)
|
console.error('Error running report:', error)
|
||||||
|
reportError.value = apiError(error, 'Could not run this report')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCSV() {
|
// The api client is already mounted at /api, so an endpoint from the contract
|
||||||
|
// (which carries the prefix, because other consumers call it absolutely) has to
|
||||||
|
// have it removed rather than doubled.
|
||||||
|
function stripApiPrefix (url) {
|
||||||
|
return url.startsWith('/api/') ? url.slice(4) : url
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCSV() {
|
||||||
if (!currentReport.value) return
|
if (!currentReport.value) return
|
||||||
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
|
// Same endpoint the report itself came from - a plugin report used to export
|
||||||
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
|
// from /api/reports/<id>, which does not exist for it.
|
||||||
|
//
|
||||||
|
// Fetched through the api client, not window.open: a new window carries no
|
||||||
|
// Authorization header, so any report behind a real jwt_required (every
|
||||||
|
// plugin one) answered the export button with a 401 page. The core reports
|
||||||
|
// are jwt-optional, which is the only reason window.open ever appeared to
|
||||||
|
// work.
|
||||||
|
const url = currentReport.value.endpoint || `/api/reports/${currentReport.value.id}`
|
||||||
|
reportError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await api.get(stripApiPrefix(url), {
|
||||||
|
params: { format: 'csv', ...filterParams() },
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(response.data)
|
||||||
|
link.download = `${currentReport.value.id}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting report:', error)
|
||||||
|
reportError.value = apiError(error, 'Could not export this report')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearReport() {
|
function clearReport() {
|
||||||
@@ -431,6 +532,10 @@ function clearReport() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.summary-figure { margin-right: 1.25rem; }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
.report-empty { color: var(--text-light); margin-top: 0.75rem; }
|
||||||
|
|
||||||
.report-group {
|
.report-group {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ cmmc_usb schema used here:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, Response
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
@@ -1068,3 +1068,125 @@ def kiosk_checkin():
|
|||||||
|
|
||||||
return success_response({'device_id': device_id},
|
return success_response({'device_id': device_id},
|
||||||
message='{} checked in'.format(device_id))
|
message='{} checked in'.format(device_id))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Reports
|
||||||
|
# =============================================================================
|
||||||
|
# CMMC removable-media tracking is an audit obligation, and until now the only
|
||||||
|
# way to answer "who had which drive, when" was to read the check-in/out log
|
||||||
|
# page by page. This is the same question over a window: a day for "what
|
||||||
|
# happened this shift", a month or six for a periodic review, a year or all time
|
||||||
|
# for an audit.
|
||||||
|
|
||||||
|
# The windows the UI offers, in days. 0 means everything ever recorded.
|
||||||
|
AUDITPERIODS = {'day': 1, 'month': 30, 'sixmonths': 182, 'year': 365, 'all': 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _auditdays():
|
||||||
|
"""Days of history to report. Accepts ?period= or a raw ?days=."""
|
||||||
|
period = (request.args.get('period') or '').strip().lower()
|
||||||
|
if period in AUDITPERIODS:
|
||||||
|
return AUDITPERIODS[period]
|
||||||
|
days = request.args.get('days', type=int)
|
||||||
|
return max(0, days) if days is not None else 30
|
||||||
|
|
||||||
|
|
||||||
|
def _auditcsv(rows, columns, filename):
|
||||||
|
"""CSV download; generate_csv is not on the contract surface."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(columns)
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow([row.get(column, '') for column in columns])
|
||||||
|
return Response(output.getvalue(), mimetype='text/csv',
|
||||||
|
headers={'Content-Disposition':
|
||||||
|
'attachment; filename={}'.format(filename)})
|
||||||
|
|
||||||
|
|
||||||
|
AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge',
|
||||||
|
'name', 'locker_location']
|
||||||
|
|
||||||
|
|
||||||
|
@usb_bp.route('/reports/audit', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('usb.view')
|
||||||
|
def report_audit():
|
||||||
|
"""Every check-out and check-in in the window, newest first.
|
||||||
|
|
||||||
|
Rows, not just totals: an audit asks who had a specific drive on a specific
|
||||||
|
day, and a count cannot answer that. Summary figures ride along for the
|
||||||
|
people who only want the shape of it.
|
||||||
|
"""
|
||||||
|
days = _auditdays()
|
||||||
|
if _usb_selfhosted():
|
||||||
|
rows = selfhosted.audit_rows(days)
|
||||||
|
return _auditresponse(rows, days)
|
||||||
|
|
||||||
|
cutoff = None
|
||||||
|
if days > 0:
|
||||||
|
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime(
|
||||||
|
'%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
conn = cmmc_usb_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
sql = ('SELECT l.timestamp, l.action, l.device_id, l.badge_number, '
|
||||||
|
'l.locker_location, d.device_desc, '
|
||||||
|
'u.first_name, u.last_name '
|
||||||
|
'FROM checkinoutlog l '
|
||||||
|
'LEFT JOIN devices d ON d.device_id = l.device_id '
|
||||||
|
'LEFT JOIN users u ON u.badge_number = l.badge_number ')
|
||||||
|
params = []
|
||||||
|
if cutoff:
|
||||||
|
sql += 'WHERE l.timestamp >= %s '
|
||||||
|
params.append(cutoff)
|
||||||
|
# A total order: timestamp alone ties for two events in the same
|
||||||
|
# second and the same query then returns them in a different order
|
||||||
|
# each run, which reads as the log being unstable.
|
||||||
|
sql += 'ORDER BY l.timestamp DESC, l.log_id DESC'
|
||||||
|
cur.execute(sql, tuple(params))
|
||||||
|
rows = []
|
||||||
|
for row in cur.fetchall() or []:
|
||||||
|
name = '{} {}'.format(row.get('first_name') or '',
|
||||||
|
row.get('last_name') or '').strip()
|
||||||
|
stamp = row.get('timestamp')
|
||||||
|
rows.append({
|
||||||
|
'timestamp': stamp.isoformat() if hasattr(stamp, 'isoformat')
|
||||||
|
else (stamp or ''),
|
||||||
|
'action': row.get('action') or '',
|
||||||
|
'device_id': row.get('device_id') or '',
|
||||||
|
'device_desc': row.get('device_desc') or '',
|
||||||
|
'badge': row.get('badge_number') or '',
|
||||||
|
'name': name,
|
||||||
|
'locker_location': row.get('locker_location') or '',
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return _auditresponse(rows, days)
|
||||||
|
|
||||||
|
|
||||||
|
def _auditresponse(rows, days):
|
||||||
|
"""One voice for both directory modes: same CSV, same JSON, same summary."""
|
||||||
|
if request.args.get('format') == 'csv':
|
||||||
|
return _auditcsv(rows, AUDITCOLUMNS, 'usb-audit.csv')
|
||||||
|
return success_response({
|
||||||
|
'columns': AUDITCOLUMNS,
|
||||||
|
'rows': rows,
|
||||||
|
'days': days,
|
||||||
|
'summary': _auditsummary(rows),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _auditsummary(rows):
|
||||||
|
checkouts = sum(1 for row in rows if row['action'] == 'check-out')
|
||||||
|
return {
|
||||||
|
'events': len(rows),
|
||||||
|
'checkouts': checkouts,
|
||||||
|
'checkins': sum(1 for row in rows if row['action'] == 'check-in'),
|
||||||
|
'devices': len({row['device_id'] for row in rows if row['device_id']}),
|
||||||
|
'people': len({row['badge'] for row in rows if row['badge']}),
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ History rows are synthesized from usbcheckouts (each row = a check-out event and
|
|||||||
if returned, a check-in event).
|
if returned, a check-in event).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, success_response, error_response, ErrorCodes,
|
db, success_response, error_response, ErrorCodes,
|
||||||
@@ -452,3 +452,47 @@ def _kiosk_flag(key, default):
|
|||||||
if row is None or row.value is None or row.value == '':
|
if row is None or row.value is None or row.value == '':
|
||||||
return default
|
return default
|
||||||
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
|
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
|
||||||
|
def audit_rows(days):
|
||||||
|
"""Check-out / check-in events over a window (self-hosted mode).
|
||||||
|
|
||||||
|
One usbcheckouts row holds BOTH ends of a loan, so it expands into two
|
||||||
|
events - the take and, when it has come back, the return. Reporting the row
|
||||||
|
once would make a returned drive indistinguishable from one still out.
|
||||||
|
|
||||||
|
Rows only: the caller owns the CSV, the summary and the response shape, so
|
||||||
|
both directory modes answer in exactly one voice.
|
||||||
|
"""
|
||||||
|
cutoff = None
|
||||||
|
if days > 0:
|
||||||
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
||||||
|
|
||||||
|
query = (db.session.query(USBCheckout, USBDevice)
|
||||||
|
.join(USBDevice, USBDevice.usbdeviceid == USBCheckout.usbdeviceid))
|
||||||
|
if cutoff is not None:
|
||||||
|
# Either end of the loan can fall in the window: a drive taken before it
|
||||||
|
# and returned inside it is a check-in the window has to show.
|
||||||
|
query = query.filter(db.or_(USBCheckout.checkouttime >= cutoff,
|
||||||
|
USBCheckout.checkintime >= cutoff))
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for checkout, device in query.all():
|
||||||
|
base = {
|
||||||
|
'device_id': device.serialnumber,
|
||||||
|
'device_desc': device.label or device.productname or '',
|
||||||
|
'badge': checkout.sso or '',
|
||||||
|
'name': checkout.checkoutname or '',
|
||||||
|
'locker_location': device.storagelocation or '',
|
||||||
|
}
|
||||||
|
if checkout.checkouttime and (cutoff is None or checkout.checkouttime >= cutoff):
|
||||||
|
rows.append(dict(base, timestamp=checkout.checkouttime.isoformat(),
|
||||||
|
action='check-out'))
|
||||||
|
if checkout.checkintime and (cutoff is None or checkout.checkintime >= cutoff):
|
||||||
|
rows.append(dict(base, timestamp=checkout.checkintime.isoformat(),
|
||||||
|
action='check-in'))
|
||||||
|
|
||||||
|
# Newest first, and a total order so two events in the same second do not
|
||||||
|
# swap places between identical runs.
|
||||||
|
rows.sort(key=lambda row: (row['timestamp'], row['device_id']), reverse=True)
|
||||||
|
return rows
|
||||||
|
|||||||
@@ -42,8 +42,9 @@
|
|||||||
<section v-else-if="step === 'pick'" class="kiosk-step kiosk-wide">
|
<section v-else-if="step === 'pick'" class="kiosk-step kiosk-wide">
|
||||||
<p class="kiosk-greeting">{{ session.name }}</p>
|
<p class="kiosk-greeting">{{ session.name }}</p>
|
||||||
|
|
||||||
<div v-if="session.yours.length" class="drive-group">
|
<div v-if="session.yours.length" class="drive-group drive-group-yours">
|
||||||
<h2>Your drives</h2>
|
<h2>Your drives</h2>
|
||||||
|
<div class="drive-list">
|
||||||
<button v-for="drive in session.yours" :key="drive.device_id"
|
<button v-for="drive in session.yours" :key="drive.device_id"
|
||||||
class="drive-row" :disabled="busy"
|
class="drive-row" :disabled="busy"
|
||||||
@click="act(drive, 'checkin')">
|
@click="act(drive, 'checkin')">
|
||||||
@@ -54,12 +55,14 @@
|
|||||||
<span class="drive-action return">Return</span>
|
<span class="drive-action return">Return</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="drive-group">
|
<div class="drive-group">
|
||||||
<h2>Available</h2>
|
<h2>Available</h2>
|
||||||
<p v-if="!session.available.length" class="kiosk-hint">
|
<p v-if="!session.available.length" class="kiosk-hint">
|
||||||
No drives are free right now.
|
No drives are free right now.
|
||||||
</p>
|
</p>
|
||||||
|
<div v-else class="drive-list">
|
||||||
<button v-for="drive in session.available" :key="drive.device_id"
|
<button v-for="drive in session.available" :key="drive.device_id"
|
||||||
class="drive-row" :disabled="busy"
|
class="drive-row" :disabled="busy"
|
||||||
@click="act(drive, 'checkout')">
|
@click="act(drive, 'checkout')">
|
||||||
@@ -71,6 +74,7 @@
|
|||||||
<span class="drive-action take">Take</span>
|
<span class="drive-action take">Take</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Other people's drives are behind a tap, not on the main screen: a
|
<!-- Other people's drives are behind a tap, not on the main screen: a
|
||||||
passer-by should not read the whole cabinet's holders off a wall. -->
|
passer-by should not read the whole cabinet's holders off a wall. -->
|
||||||
@@ -87,6 +91,7 @@
|
|||||||
<p class="kiosk-hint">
|
<p class="kiosk-hint">
|
||||||
Returning one puts it back and records that YOU handed it in.
|
Returning one puts it back and records that YOU handed it in.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="drive-list">
|
||||||
<button v-for="drive in others" :key="drive.device_id"
|
<button v-for="drive in others" :key="drive.device_id"
|
||||||
class="drive-row" :disabled="busy"
|
class="drive-row" :disabled="busy"
|
||||||
@click="act(drive, 'checkin', true)">
|
@click="act(drive, 'checkin', true)">
|
||||||
@@ -96,6 +101,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="drive-action return">Return</span>
|
<span class="drive-action return">Return</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<button class="btn btn-secondary act-button" @click="step = 'pick'">
|
<button class="btn btn-secondary act-button" @click="step = 'pick'">
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@@ -149,6 +155,14 @@ function focusEntry (event) {
|
|||||||
nextTick(() => entryInput.value?.focus())
|
nextTick(() => entryInput.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
|
||||||
|
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
|
||||||
|
// plain text for ids that carry no digits.
|
||||||
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
function byDeviceId (a, b) {
|
||||||
|
return collator.compare(a.device_id || '', b.device_id || '')
|
||||||
|
}
|
||||||
|
|
||||||
function messageFrom (err, fallback) {
|
function messageFrom (err, fallback) {
|
||||||
return err.response?.data?.data?.error?.message || fallback
|
return err.response?.data?.data?.error?.message || fallback
|
||||||
}
|
}
|
||||||
@@ -160,7 +174,10 @@ async function startSession (value) {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/usb/kiosk/session', { badge: scanned })
|
const response = await api.post('/usb/kiosk/session', { badge: scanned })
|
||||||
session.value = response.data.data
|
const data = response.data.data
|
||||||
|
data.yours = (data.yours || []).sort(byDeviceId)
|
||||||
|
data.available = (data.available || []).sort(byDeviceId)
|
||||||
|
session.value = data
|
||||||
badge.value = scanned
|
badge.value = scanned
|
||||||
manualBadge.value = ''
|
manualBadge.value = ''
|
||||||
step.value = 'pick'
|
step.value = 'pick'
|
||||||
@@ -177,7 +194,7 @@ async function loadOthers () {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
|
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
|
||||||
others.value = response.data.data || []
|
others.value = (response.data.data || []).sort(byDeviceId)
|
||||||
step.value = 'others'
|
step.value = 'others'
|
||||||
} catch (othersError) {
|
} catch (othersError) {
|
||||||
error.value = messageFrom(othersError, 'Could not load those drives')
|
error.value = messageFrom(othersError, 'Could not load those drives')
|
||||||
@@ -211,7 +228,10 @@ async function act (drive, action, forsomeoneelse = false) {
|
|||||||
async function refresh () {
|
async function refresh () {
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
|
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
|
||||||
session.value = response.data.data
|
const data = response.data.data
|
||||||
|
data.yours = (data.yours || []).sort(byDeviceId)
|
||||||
|
data.available = (data.available || []).sort(byDeviceId)
|
||||||
|
session.value = data
|
||||||
step.value = 'pick'
|
step.value = 'pick'
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
reset()
|
reset()
|
||||||
@@ -232,34 +252,73 @@ function reset () {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Touch-sized throughout: a wall panel, often operated with gloves on. */
|
/* Touch-sized throughout: a wall panel, often operated with gloves on.
|
||||||
|
Laid out to match the parts kiosk beside it - two kiosks on the same floor
|
||||||
|
that sit differently on their screens read as two different products. */
|
||||||
.usb-kiosk {
|
.usb-kiosk {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 1.5rem;
|
padding: 2rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
align-items: center;
|
||||||
|
/* Centered as one block on a wall-mounted display rather than hugging the
|
||||||
|
top. `safe` so a short screen - or a long drive list - scrolls from the top
|
||||||
|
instead of clipping the header off the edge, which plain `center` does with
|
||||||
|
no way to scroll back up. */
|
||||||
|
justify-content: center;
|
||||||
|
justify-content: safe center;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
.kiosk-header {
|
.kiosk-header {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 40rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
/* Title centered over the panel; Done is pulled out of the flow so it keeps
|
||||||
gap: 1rem;
|
its top-right corner instead of pushing the title off-center on the steps
|
||||||
|
that show it. */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.kiosk-header .btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
.kiosk-header h1 { margin: 0; font-size: 2rem; }
|
.kiosk-header h1 { margin: 0; font-size: 2rem; }
|
||||||
.kiosk-step {
|
.kiosk-step {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1.25rem;
|
||||||
max-width: 34rem;
|
max-width: 40rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
.kiosk-wide { max-width: 46rem; align-items: stretch; }
|
/* The picker holds lists, so its rows stretch - but it keeps the same width as
|
||||||
|
every other step so the panel does not jump when the step changes.
|
||||||
|
It is also height-bounded: the lists absorb whatever space is left over and
|
||||||
|
scroll inside themselves, so the PAGE never scrolls and the heading saying
|
||||||
|
whose drives these are stays on screen. A fixed max-height cannot do this -
|
||||||
|
it either clips with room to spare or overflows once both groups are
|
||||||
|
present, which is exactly what it did. */
|
||||||
|
.kiosk-wide {
|
||||||
|
align-items: stretch;
|
||||||
|
max-height: calc(100vh - 9rem);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.kiosk-wide .drive-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
/* Your own drives size to their content - usually one or two - and the
|
||||||
|
available list absorbs what is left. Sharing the space evenly clipped a
|
||||||
|
single row of "Your drives" in half while the other list had room. */
|
||||||
|
.kiosk-wide .drive-group-yours { flex: 0 0 auto; }
|
||||||
|
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
|
||||||
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
|
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
|
||||||
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
|
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
|
||||||
.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; }
|
.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; }
|
||||||
@@ -276,19 +335,30 @@ function reset () {
|
|||||||
|
|
||||||
.drive-group { width: 100%; }
|
.drive-group { width: 100%; }
|
||||||
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
||||||
|
/* Two columns once there is room, so a cabinet of a dozen drives is one glance
|
||||||
|
rather than a scroll. The list itself scrolls when it has to - the PAGE must
|
||||||
|
not, or the person loses the heading telling them whose drives these are. */
|
||||||
|
.drive-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
.drive-row {
|
.drive-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem 1.2rem;
|
padding: 0.9rem 1rem;
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 0.75rem;
|
||||||
background: var(--surface, transparent);
|
background: var(--bg-card);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 1.2rem;
|
font-size: 1.05rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -307,21 +377,39 @@ function reset () {
|
|||||||
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
|
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
|
||||||
|
|
||||||
.entry-panel {
|
.entry-panel {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.9rem;
|
gap: 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
}
|
}
|
||||||
.entry-input {
|
.entry-input {
|
||||||
width: 100%;
|
width: 16.9rem;
|
||||||
padding: 0.8rem 1rem;
|
box-sizing: border-box;
|
||||||
font-size: 1.6rem;
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 0.75rem;
|
||||||
background: var(--surface, transparent);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.entry-input:focus { border-color: var(--primary); }
|
||||||
|
.entry-input::placeholder {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.act-button {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
width: 16.9rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
.act-button { width: 100%; padding: 1rem; font-size: 1.25rem; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -70,6 +70,26 @@ class USBPlugin(BasePlugin):
|
|||||||
'docs': 'plugins/usb/README.md',
|
'docs': 'plugins/usb/README.md',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_reports(self) -> List[Dict]:
|
||||||
|
"""Removable-media audit.
|
||||||
|
|
||||||
|
CMMC tracking is an audit obligation, and the only way to answer "who
|
||||||
|
had which drive, when" was to page through the check-in/out list by
|
||||||
|
hand. The window covers a shift, a periodic review, or everything ever
|
||||||
|
recorded.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': 'usb-audit',
|
||||||
|
'name': 'USB Check-in / Check-out Audit',
|
||||||
|
'description': 'Every take and return over a chosen window - '
|
||||||
|
'day, month, six months, year or all time - with '
|
||||||
|
'who, which drive and when',
|
||||||
|
'category': 'usage',
|
||||||
|
'endpoint': '/api/usb/reports/audit',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def get_settings_cards(self) -> List[Dict]:
|
def get_settings_cards(self) -> List[Dict]:
|
||||||
"""Somewhere to actually turn the kiosk on.
|
"""Somewhere to actually turn the kiosk on.
|
||||||
|
|
||||||
|
|||||||
139
tests/test_plugins/test_usb_reports.py
Normal file
139
tests/test_plugins/test_usb_reports.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""USB check-in / check-out audit report.
|
||||||
|
|
||||||
|
CMMC removable-media tracking is an audit obligation, and the question an audit
|
||||||
|
asks is "who had this drive, when" - which a total cannot answer. So this
|
||||||
|
reports EVENTS over a window, with the window named rather than expressed as a
|
||||||
|
day count nobody should have to work out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.core.models import Setting
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def usbdata(db):
|
||||||
|
"""Self-hosted USB with a loan returned today and one from long ago."""
|
||||||
|
from plugins.usb.models import USBDevice, USBCheckout
|
||||||
|
|
||||||
|
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||||
|
valuetype='string', category='site'))
|
||||||
|
recent = USBDevice(serialnumber='USB-1', label='Blue Kingston')
|
||||||
|
old = USBDevice(serialnumber='USB-2', label='Red SanDisk')
|
||||||
|
db.session.add_all([recent, old])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Taken and returned today: two events.
|
||||||
|
db.session.add(USBCheckout(usbdeviceid=recent.usbdeviceid, sso='270015376',
|
||||||
|
checkoutname='Dana Reyes',
|
||||||
|
checkouttime=_now() - timedelta(hours=3),
|
||||||
|
checkintime=_now() - timedelta(hours=1)))
|
||||||
|
# Taken 200 days ago and never returned: one event, outside most windows.
|
||||||
|
db.session.add(USBCheckout(usbdeviceid=old.usbdeviceid, sso='270099999',
|
||||||
|
checkoutname='Sam Okafor',
|
||||||
|
checkouttime=_now() - timedelta(days=200)))
|
||||||
|
db.session.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _report(client, headers, **params):
|
||||||
|
query = '&'.join(f'{k}={v}' for k, v in params.items())
|
||||||
|
response = client.get(f'/api/usb/reports/audit?{query}', headers=headers)
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
return response.get_json()['data']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_loan_produces_both_a_take_and_a_return(client, usbdata, auth_headers):
|
||||||
|
"""One stored row holds both ends of a loan.
|
||||||
|
|
||||||
|
Reporting it once would make a returned drive look identical to one still
|
||||||
|
out, which is the opposite of what the log is for.
|
||||||
|
"""
|
||||||
|
data = _report(client, auth_headers, period='month')
|
||||||
|
actions = sorted(row['action'] for row in data['rows'])
|
||||||
|
|
||||||
|
assert actions == ['check-in', 'check-out']
|
||||||
|
assert data['summary']['checkouts'] == 1
|
||||||
|
assert data['summary']['checkins'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_window_actually_bounds_the_rows(client, usbdata, auth_headers):
|
||||||
|
month = _report(client, auth_headers, period='month')
|
||||||
|
year = _report(client, auth_headers, period='year')
|
||||||
|
|
||||||
|
assert {row['device_id'] for row in month['rows']} == {'USB-1'}
|
||||||
|
assert {row['device_id'] for row in year['rows']} == {'USB-1', 'USB-2'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_time_keeps_everything(client, usbdata, auth_headers):
|
||||||
|
data = _report(client, auth_headers, period='all')
|
||||||
|
assert data['days'] == 0
|
||||||
|
assert data['summary']['events'] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_is_a_window_too(client, usbdata, auth_headers):
|
||||||
|
"""A shift handover asks what happened today, not this month."""
|
||||||
|
data = _report(client, auth_headers, period='day')
|
||||||
|
assert data['days'] == 1
|
||||||
|
assert data['summary']['events'] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('period,days', [('day', 1), ('month', 30),
|
||||||
|
('sixmonths', 182), ('year', 365),
|
||||||
|
('all', 0)])
|
||||||
|
def test_every_named_window_maps_to_a_day_count(client, usbdata, auth_headers,
|
||||||
|
period, days):
|
||||||
|
assert _report(client, auth_headers, period=period)['days'] == days
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_period_falls_back_rather_than_failing(client, usbdata,
|
||||||
|
auth_headers):
|
||||||
|
"""A bad query string should not 500 a report somebody is mid-audit on."""
|
||||||
|
assert _report(client, auth_headers, period='fortnight')['days'] == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_are_newest_first(client, usbdata, auth_headers):
|
||||||
|
rows = _report(client, auth_headers, period='all')['rows']
|
||||||
|
stamps = [row['timestamp'] for row in rows]
|
||||||
|
assert stamps == sorted(stamps, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_says_who_and_which_drive(client, usbdata, auth_headers):
|
||||||
|
rows = _report(client, auth_headers, period='month')['rows']
|
||||||
|
row = rows[0]
|
||||||
|
assert row['name'] == 'Dana Reyes'
|
||||||
|
assert row['badge'] == '270015376'
|
||||||
|
assert row['device_id'] == 'USB-1'
|
||||||
|
assert row['device_desc'] == 'Blue Kingston'
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_exports_as_csv(client, usbdata, auth_headers):
|
||||||
|
response = client.get('/api/usb/reports/audit?period=all&format=csv',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == 'text/csv'
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
assert body.splitlines()[0].startswith('timestamp,action,device_id')
|
||||||
|
assert 'Dana Reyes' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_reading_the_audit_needs_permission(client, usbdata, member_headers):
|
||||||
|
"""A role-less member cannot read who had which drive."""
|
||||||
|
response = client.get('/api/usb/reports/audit?period=all',
|
||||||
|
headers=member_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_report_is_registered_so_it_appears_in_the_catalogue(client, usbdata,
|
||||||
|
auth_headers):
|
||||||
|
from plugins.usb.plugin import USBPlugin
|
||||||
|
report = USBPlugin().get_reports()[0]
|
||||||
|
assert report['id'] == 'usb-audit'
|
||||||
|
# The endpoint is what the reports page fetches; a wrong one renders blank.
|
||||||
|
assert report['endpoint'] == '/api/usb/reports/audit'
|
||||||
Reference in New Issue
Block a user