Plugin reports render; USB check-in/out audit report; kiosk drive lists

This commit is contained in:
2026-08-27 10:20:59 -04:00
parent 376298dc02
commit 60c340b07f
7 changed files with 609 additions and 59 deletions

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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']}),
}

View File

@@ -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

View File

@@ -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')">
@@ -53,6 +54,7 @@
</span> </span>
<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">
@@ -60,6 +62,7 @@
<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')">
@@ -70,6 +73,7 @@
</span> </span>
<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
@@ -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>

View File

@@ -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.

View 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'