USB external import, and filter chips on health-dot cards
This commit is contained in:
@@ -31,8 +31,25 @@
|
||||
|
||||
<!-- list: items each with a title, optional status badge, meta lines -->
|
||||
<div v-if="panel.render === 'list'" class="pap-list">
|
||||
<template v-if="rows(panel).length">
|
||||
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
|
||||
<!-- Filter chips for any list panel that shows a health dot. Client
|
||||
side over rows already fetched, so the counts cannot disagree with
|
||||
what is on screen. -->
|
||||
<div v-if="facets(panel).length > 1" class="pap-facets">
|
||||
<button
|
||||
v-for="facet in facets(panel)"
|
||||
:key="facet.value"
|
||||
type="button"
|
||||
class="pap-facet"
|
||||
:class="{ on: isFacetOn(panel, facet.value) }"
|
||||
@click="toggleFacet(panel, facet.value)"
|
||||
>{{ facet.label }} <span class="pap-facet-count">{{ facet.count }}</span></button>
|
||||
<button v-if="facetSelection[panel.id] && facetSelection[panel.id].length"
|
||||
type="button" class="pap-facet pap-facet-clear"
|
||||
@click="facetSelection[panel.id] = []">Clear</button>
|
||||
</div>
|
||||
|
||||
<template v-if="visibleRows(panel).length">
|
||||
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
|
||||
<div class="pap-item-top">
|
||||
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
||||
<span v-if="mapBadge(panel, item)" class="status-badge"
|
||||
@@ -59,6 +76,9 @@
|
||||
{{ actionError[panel.id] }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="rows(panel).length" class="pap-empty">
|
||||
Nothing matches that filter. {{ rows(panel).length }} row(s) hidden.
|
||||
</div>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
@@ -135,9 +155,32 @@ import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
rowAction, facets, facetedRows,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
// Which filter chips are active, per panel id. Deliberately NOT reset when the
|
||||
// data refreshes: a viewer who filtered to criticals and then hit refresh means
|
||||
// to still be looking at criticals.
|
||||
const facetSelection = ref({})
|
||||
|
||||
function isFacetOn(panel, value) {
|
||||
return (facetSelection.value[panel.id] || []).includes(value)
|
||||
}
|
||||
|
||||
function toggleFacet(panel, value) {
|
||||
const current = facetSelection.value[panel.id] || []
|
||||
facetSelection.value = {
|
||||
...facetSelection.value,
|
||||
[panel.id]: current.includes(value)
|
||||
? current.filter((v) => v !== value)
|
||||
: [...current, value],
|
||||
}
|
||||
}
|
||||
|
||||
function visibleRows(panel) {
|
||||
return facetedRows(panel, facetSelection.value[panel.id])
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
})
|
||||
@@ -302,6 +345,33 @@ defineExpose({ refresh: load })
|
||||
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
|
||||
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
|
||||
|
||||
.pap-facets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.pap-facet {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pap-facet:hover { border-color: var(--primary); }
|
||||
.pap-facet.on { border-color: var(--primary); color: var(--primary); }
|
||||
.pap-facet-clear { border-style: dashed; }
|
||||
|
||||
/* The count is the point: a red dot over a list of info rows is confusing
|
||||
until the chip says "Critical 2" and one click shows them. */
|
||||
.pap-facet-count { font-variant-numeric: tabular-nums; opacity: 0.75; }
|
||||
.pap-manage { font-size: 0.82rem; }
|
||||
/* Two aligned columns rather than space-between: a ragged right edge is hard
|
||||
to scan once a panel carries more than a handful of rows, and a long value
|
||||
|
||||
@@ -45,6 +45,73 @@ export function rows(panel) {
|
||||
return list
|
||||
}
|
||||
|
||||
// Optional in-card filter chips. A list panel declares facets: {key, order,
|
||||
// labels} and the renderer offers one chip per distinct value of that key,
|
||||
// with a count. Filtering is client-side over rows the panel already fetched -
|
||||
// no extra request, and the counts stay honest because they are computed from
|
||||
// the same array.
|
||||
//
|
||||
// Declared rather than hardcoded because the reason it exists is generic: the
|
||||
// activity feed mixes info, warning and critical, and somebody triaging wants
|
||||
// the criticals alone. Any list panel with a category column has that problem.
|
||||
// Colour names carry no meaning to a reader. A panel keyed on statecolor
|
||||
// (green/yellow/red) is labelled by the LEVEL those map to, while one keyed on
|
||||
// a word (info/warning/critical) is labelled by the word itself.
|
||||
const COLOUR_VALUES = ['green', 'yellow', 'red', 'grey', 'gray', 'blue']
|
||||
const LEVEL_ORDER = ['critical', 'warning', 'ok', 'unknown']
|
||||
|
||||
// A panel that shows a health dot has already declared what it is dotting on.
|
||||
// Reuse that instead of asking every panel to declare filters a second time:
|
||||
// any card with a status rollup gets chips, and the two cannot disagree.
|
||||
function facetSpec(panel) {
|
||||
if (panel.facets && panel.facets.key) return panel.facets
|
||||
const status = panel.status
|
||||
if (!status || !status.key) return null
|
||||
const levels = status.levels || {}
|
||||
const order = Object.keys(levels).sort(
|
||||
(a, b) => LEVEL_ORDER.indexOf(levels[a]) - LEVEL_ORDER.indexOf(levels[b])
|
||||
)
|
||||
const labels = {}
|
||||
Object.keys(levels).forEach((value) => {
|
||||
labels[value] = COLOUR_VALUES.includes(String(value).toLowerCase())
|
||||
? humanize(levels[value])
|
||||
: humanize(value)
|
||||
})
|
||||
return { key: status.key, order, labels }
|
||||
}
|
||||
|
||||
export function facets(panel) {
|
||||
const spec = facetSpec(panel)
|
||||
if (!spec || !spec.key) return []
|
||||
const all = rows(panel)
|
||||
const counts = new Map()
|
||||
all.forEach((item) => {
|
||||
const value = item[spec.key]
|
||||
if (value === null || value === undefined || value === '') return
|
||||
const key = String(value)
|
||||
counts.set(key, (counts.get(key) || 0) + 1)
|
||||
})
|
||||
// Declared order first, so severity reads critical-warning-info rather than
|
||||
// alphabetically; anything unexpected in the data still gets a chip.
|
||||
const declared = (spec.order || []).filter((value) => counts.has(value))
|
||||
const extra = [...counts.keys()].filter((value) => !declared.includes(value)).sort()
|
||||
return [...declared, ...extra].map((value) => ({
|
||||
value,
|
||||
label: (spec.labels && spec.labels[value]) || humanize(value),
|
||||
count: counts.get(value)
|
||||
}))
|
||||
}
|
||||
|
||||
// rows() after the viewer's chip selection. An empty selection means "no filter
|
||||
// applied", NOT "hide everything": a card that vanished when you deselected the
|
||||
// last chip would read as broken.
|
||||
export function facetedRows(panel, selected) {
|
||||
const all = rows(panel)
|
||||
const spec = facetSpec(panel)
|
||||
if (!spec || !spec.key || !selected || !selected.length) return all
|
||||
return all.filter((item) => selected.includes(String(item[spec.key])))
|
||||
}
|
||||
|
||||
export function mapTitle(panel, item) {
|
||||
const key = panel.map && panel.map.title
|
||||
return key ? item[key] : ''
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
rowAction, facets, facetedRows,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const warrantyPanel = {
|
||||
@@ -228,3 +228,68 @@ describe('rowAction (inline MTConnect toggle)', () => {
|
||||
expect(info.toggleLabel).toBe('Start')
|
||||
})
|
||||
})
|
||||
|
||||
describe('facet chips on a card that shows a health dot', () => {
|
||||
// The case that prompted this: the Activity card shows a RED dot because a
|
||||
// critical event is in the data, while the visible rows are all info because
|
||||
// the critical is older and sits further down. The dot and the list appear to
|
||||
// disagree. Chips resolve it: the count sits under the dot, and one click
|
||||
// brings the critical rows to the surface.
|
||||
const activity = {
|
||||
id: 'endpoints-activity',
|
||||
render: 'list',
|
||||
status: {
|
||||
key: 'severity',
|
||||
levels: { info: 'ok', warning: 'warning', critical: 'critical' },
|
||||
},
|
||||
_data: [
|
||||
{ title: 'Successful logon', severity: 'info' },
|
||||
{ title: 'Successful logon', severity: 'info' },
|
||||
{ title: 'Scheduled task created', severity: 'warning' },
|
||||
{ title: 'Unexpected shutdown', severity: 'critical' },
|
||||
],
|
||||
}
|
||||
|
||||
it('derives chips from the status declaration, so no panel declares filters twice', () => {
|
||||
const chips = facets(activity)
|
||||
expect(chips.map((c) => c.value)).toEqual(['critical', 'warning', 'info'])
|
||||
})
|
||||
|
||||
it('orders worst first rather than alphabetically', () => {
|
||||
expect(facets(activity)[0].value).toBe('critical')
|
||||
})
|
||||
|
||||
it('counts from the same rows the dot is computed from, so they cannot disagree', () => {
|
||||
const chips = facets(activity)
|
||||
expect(chips.find((c) => c.value === 'critical').count).toBe(1)
|
||||
expect(chips.find((c) => c.value === 'info').count).toBe(2)
|
||||
expect(rollupStatus(activity).level).toBe('critical')
|
||||
})
|
||||
|
||||
it('labels a colour-keyed panel by its level, not by the colour name', () => {
|
||||
const services = {
|
||||
id: 'svc',
|
||||
render: 'list',
|
||||
status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } },
|
||||
_data: [{ statecolor: 'green' }, { statecolor: 'red' }],
|
||||
}
|
||||
const labels = facets(services).map((c) => c.label)
|
||||
expect(labels).toContain('Critical')
|
||||
expect(labels).not.toContain('Red')
|
||||
})
|
||||
|
||||
it('shows everything when nothing is selected, rather than hiding the card', () => {
|
||||
expect(facetedRows(activity, []).length).toBe(4)
|
||||
expect(facetedRows(activity, undefined).length).toBe(4)
|
||||
})
|
||||
|
||||
it('filters to the selected severities, and allows more than one', () => {
|
||||
expect(facetedRows(activity, ['critical']).map((r) => r.title))
|
||||
.toEqual(['Unexpected shutdown'])
|
||||
expect(facetedRows(activity, ['critical', 'warning']).length).toBe(2)
|
||||
})
|
||||
|
||||
it('offers no chips for a panel with no status rollup', () => {
|
||||
expect(facets({ id: 'x', render: 'list', _data: [{ a: 1 }] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,7 +87,10 @@ def _checkout_events(checkout):
|
||||
'device_id': checkout.device.serialnumber if checkout.device else None,
|
||||
'action': 'check-in',
|
||||
'timestamp': checkout.checkintime.isoformat() + 'Z',
|
||||
'scanned_viruses': None,
|
||||
# Both read from the checkout row now. scanned_viruses was
|
||||
# hardcoded None: the shape promised the field and had nowhere to
|
||||
# read it from, so an imported history would have shown blank.
|
||||
'scanned_viruses': checkout.scannedviruses,
|
||||
'locker_location': None,
|
||||
'sanitized': checkout.waswiped,
|
||||
})
|
||||
@@ -233,6 +236,10 @@ def checkin_device(device_id, data):
|
||||
checkintime = override
|
||||
open_checkout.checkintime = checkintime
|
||||
open_checkout.waswiped = bool(data.get('sanitized'))
|
||||
# None, not False, when the caller said nothing: an unrecorded scan is
|
||||
# not a failed scan, and an audit reads those very differently.
|
||||
scanned = data.get('scanned_viruses')
|
||||
open_checkout.scannedviruses = None if scanned is None else bool(scanned)
|
||||
open_checkout.checkinnotes = data.get('notes')
|
||||
device.ischeckedout = False
|
||||
device.currentuserid = None
|
||||
|
||||
235
plugins/usb/cli.py
Normal file
235
plugins/usb/cli.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""CLI: move USB records off an external cmmc_usb database and into ShopDB.
|
||||
|
||||
flask usb import-external report what would happen, change nothing
|
||||
flask usb import-external --commit write it
|
||||
|
||||
A site running the external mode keeps its devices and its check-in/out history
|
||||
in a separate cmmc_usb database. This reads that database through the same
|
||||
connection settings the routes already use, converts it to the self-hosted
|
||||
tables, and leaves the source untouched. After a clean run, set
|
||||
usb_directory_mode to selfhosted and the external database can be retired.
|
||||
|
||||
TWO THINGS THIS HAS TO RECONCILE, and they are the reason it is a command
|
||||
rather than a few INSERTs:
|
||||
|
||||
The two schemas disagree about shape. External is an EVENT LOG - one row per
|
||||
action, check-out or check-in - while self-hosted keeps a PAIRED ROW, one
|
||||
checkout carrying both its times. Pairing is per device and in time order, and
|
||||
real logs are not tidy: a check-out with no return is an open checkout, but a
|
||||
return with no matching check-out, or two check-outs in a row, mean something
|
||||
was recorded wrong years ago. Those are reported rather than guessed at, and
|
||||
they never silently vanish.
|
||||
|
||||
Identity is the other one. usbdevices.serialnumber is unique and NOT NULL, and
|
||||
the external devices table has no serial column - only device_id. So device_id
|
||||
becomes the serial. A blank or duplicated device_id stops the import for that
|
||||
device instead of inventing a key.
|
||||
|
||||
Dry run by default. The point of the first run is the report.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import click
|
||||
from flask.cli import AppGroup, with_appcontext
|
||||
|
||||
from shopdb.api import db, cmmc_usb_connection
|
||||
|
||||
from .models import USBDevice, USBCheckout
|
||||
|
||||
|
||||
usb_cli = AppGroup('usb', help='USB device records.')
|
||||
|
||||
|
||||
# The external status vocabulary, mapped to the self-hosted boolean. Anything
|
||||
# unrecognised is treated as checked in and reported, rather than assumed.
|
||||
_CHECKED_OUT = {'checked-out', 'checked out', 'out', 'issued'}
|
||||
_RETIRED = {'retired', 'disposed', 'destroyed'}
|
||||
|
||||
|
||||
def _external_rows():
|
||||
"""(devices, log) from the external database, oldest log entry first."""
|
||||
conn = cmmc_usb_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT device_id, device_desc, device_owner, status, '
|
||||
'locker_location FROM devices')
|
||||
devices = cur.fetchall()
|
||||
cur.execute('SELECT log_id, badge_number, device_id, action, timestamp, '
|
||||
'scanned_viruses, locker_location, sanitized '
|
||||
'FROM checkinoutlog ORDER BY device_id, timestamp, log_id')
|
||||
log = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return devices, log
|
||||
|
||||
|
||||
def _is_checkout(action):
|
||||
return str(action or '').strip().lower().replace('_', '-') in (
|
||||
'check-out', 'checkout', 'out')
|
||||
|
||||
|
||||
def _is_checkin(action):
|
||||
return str(action or '').strip().lower().replace('_', '-') in (
|
||||
'check-in', 'checkin', 'in', 'return')
|
||||
|
||||
|
||||
def _pair(entries, problems, deviceid):
|
||||
"""Walk one device's log in time order and fold it into checkout pairs."""
|
||||
pairs, open_row = [], None
|
||||
for row in entries:
|
||||
action = row.get('action')
|
||||
if _is_checkout(action):
|
||||
if open_row is not None:
|
||||
# Two check-outs with no return between them. The first is
|
||||
# unclosed and stays that way; say so rather than inventing a
|
||||
# return time that nobody recorded.
|
||||
problems.append(
|
||||
'device %s: check-out at %s while %s was still out - first '
|
||||
'left open' % (deviceid, row.get('timestamp'),
|
||||
open_row.get('badge_number')))
|
||||
pairs.append((open_row, None))
|
||||
open_row = row
|
||||
elif _is_checkin(action):
|
||||
if open_row is None:
|
||||
problems.append(
|
||||
'device %s: check-in at %s with no matching check-out - '
|
||||
'skipped' % (deviceid, row.get('timestamp')))
|
||||
continue
|
||||
pairs.append((open_row, row))
|
||||
open_row = None
|
||||
else:
|
||||
problems.append('device %s: unrecognised action %r at %s - skipped'
|
||||
% (deviceid, action, row.get('timestamp')))
|
||||
if open_row is not None:
|
||||
pairs.append((open_row, None))
|
||||
return pairs
|
||||
|
||||
|
||||
@usb_cli.command('import-external')
|
||||
@click.option('--commit', is_flag=True, default=False,
|
||||
help='Write the records. Without this the command only reports.')
|
||||
@with_appcontext
|
||||
def import_external(commit):
|
||||
"""Copy devices and check-in/out history from the external cmmc_usb DB."""
|
||||
try:
|
||||
devices, log = _external_rows()
|
||||
except Exception as error:
|
||||
raise click.ClickException(
|
||||
'Could not read the external database: %s\n'
|
||||
'Check the cmmc_usb_db_* settings, and that this host can reach it.'
|
||||
% error)
|
||||
|
||||
problems = []
|
||||
seen_serials = set()
|
||||
made_devices = 0
|
||||
made_checkouts = 0
|
||||
open_checkouts = 0
|
||||
|
||||
by_device = defaultdict(list)
|
||||
for row in log:
|
||||
by_device[row.get('device_id')].append(row)
|
||||
|
||||
for source in devices:
|
||||
serial = (source.get('device_id') or '').strip()
|
||||
if not serial:
|
||||
problems.append('device with no device_id - skipped, it cannot be '
|
||||
'given a serial number')
|
||||
continue
|
||||
if serial in seen_serials:
|
||||
problems.append('device_id %r appears twice - only the first is '
|
||||
'imported' % serial)
|
||||
continue
|
||||
seen_serials.add(serial)
|
||||
|
||||
status = str(source.get('status') or '').strip().lower()
|
||||
if status and status not in _CHECKED_OUT and status not in _RETIRED \
|
||||
and status not in ('checked-in', 'checked in', 'in', 'available'):
|
||||
problems.append('device %s: unrecognised status %r - treated as '
|
||||
'checked in' % (serial, source.get('status')))
|
||||
|
||||
device = USBDevice.query.filter_by(serialnumber=serial).first()
|
||||
if device is None:
|
||||
device = USBDevice(serialnumber=serial)
|
||||
db.session.add(device)
|
||||
made_devices += 1
|
||||
device.label = source.get('device_desc') or device.label
|
||||
device.storagelocation = source.get('locker_location') or device.storagelocation
|
||||
device.notes = _append_owner(device.notes, source.get('device_owner'))
|
||||
device.ischeckedout = status in _CHECKED_OUT
|
||||
|
||||
db.session.flush()
|
||||
|
||||
for out_row, in_row in _pair(by_device.get(source.get('device_id'), []),
|
||||
problems, serial):
|
||||
checkout = USBCheckout(
|
||||
usbdeviceid=device.usbdeviceid,
|
||||
sso=str(out_row.get('badge_number') or '')[:20],
|
||||
checkouttime=out_row.get('timestamp'),
|
||||
checkintime=in_row.get('timestamp') if in_row else None,
|
||||
)
|
||||
if in_row:
|
||||
checkout.waswiped = _tri(in_row.get('sanitized'))
|
||||
checkout.scannedviruses = _tri(in_row.get('scanned_viruses'))
|
||||
else:
|
||||
open_checkouts += 1
|
||||
db.session.add(checkout)
|
||||
made_checkouts += 1
|
||||
|
||||
# The device's own flag and the history have to agree, or the kiosk
|
||||
# will offer a drive that the log says somebody still holds.
|
||||
if device.ischeckedout and not _has_open(device):
|
||||
problems.append('device %s: marked checked out, but its log has no '
|
||||
'open check-out' % serial)
|
||||
|
||||
click.echo('devices read from external : %d' % len(devices))
|
||||
click.echo('log rows read : %d' % len(log))
|
||||
click.echo('devices to create : %d' % made_devices)
|
||||
click.echo('checkouts to create : %d' % made_checkouts)
|
||||
click.echo(' of which still open : %d' % open_checkouts)
|
||||
|
||||
if problems:
|
||||
click.echo('')
|
||||
click.echo('%d row(s) need a human decision:' % len(problems))
|
||||
for line in problems:
|
||||
click.echo(' - %s' % line)
|
||||
|
||||
if not commit:
|
||||
db.session.rollback()
|
||||
click.echo('')
|
||||
click.echo('DRY RUN - nothing was written. Re-run with --commit when the '
|
||||
'report above looks right.')
|
||||
return
|
||||
|
||||
db.session.commit()
|
||||
click.echo('')
|
||||
click.echo('Written. Next: set usb_directory_mode to selfhosted in Settings, '
|
||||
'confirm the kiosk and the device list against the external '
|
||||
'database, and only then retire it.')
|
||||
|
||||
|
||||
def _tri(value):
|
||||
"""External flags are 0/1/NULL. Keep NULL as None: unrecorded is not false."""
|
||||
if value is None:
|
||||
return None
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _has_open(device):
|
||||
return USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid,
|
||||
checkintime=None).count() > 0
|
||||
|
||||
|
||||
def _append_owner(notes, owner):
|
||||
"""External device_owner has no column here, so keep it rather than lose it."""
|
||||
owner = (owner or '').strip()
|
||||
if not owner:
|
||||
return notes
|
||||
line = 'Imported owner: %s' % owner
|
||||
if notes and line in notes:
|
||||
return notes
|
||||
return (notes + '\n' + line) if notes else line
|
||||
|
||||
|
||||
def build_cli():
|
||||
return [usb_cli]
|
||||
49
plugins/usb/migrations/versions/0003_checkout_scan_fields.py
Normal file
49
plugins/usb/migrations/versions/0003_checkout_scan_fields.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Record whether a returned drive was virus-scanned.
|
||||
|
||||
The external cmmc_usb checkinoutlog carries scanned_viruses and sanitized on
|
||||
every row. The self-hosted usbcheckouts table already has waswiped, which is
|
||||
the same fact as sanitized under a different name, but nothing corresponds to
|
||||
scanned_viruses. That gap was harmless while the two backends were
|
||||
alternatives, and becomes data loss the moment a site imports its history and
|
||||
cuts over: whether a returned drive was scanned is precisely what a
|
||||
removable-media audit asks.
|
||||
|
||||
Nullable on purpose, and NOT defaulted to false. A checkout recorded before
|
||||
this column existed is not a checkout where the drive went unscanned; it is one
|
||||
where nobody wrote it down. False would assert something the data does not say.
|
||||
|
||||
Idempotent, so a re-run on an already-migrated database is a no-op.
|
||||
|
||||
Revision ID: usb0003scanfields
|
||||
Revises: usb0002dropmachineid
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'usb0003scanfields'
|
||||
down_revision = 'usb0002dropmachineid'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'usbcheckouts' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
|
||||
if 'scannedviruses' not in cols:
|
||||
op.add_column('usbcheckouts',
|
||||
sa.Column('scannedviruses', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'usbcheckouts' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
|
||||
if 'scannedviruses' in cols:
|
||||
op.drop_column('usbcheckouts', 'scannedviruses')
|
||||
@@ -133,6 +133,10 @@ class USBCheckout(BaseModel):
|
||||
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
|
||||
checkinnotes = db.Column(db.Text, nullable=True)
|
||||
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
|
||||
# Nullable, and never defaulted to False: a checkout with no recorded scan
|
||||
# is one where nobody wrote it down, not one where the drive went unscanned.
|
||||
scannedviruses = db.Column(db.Boolean, nullable=True,
|
||||
comment='Was the device virus-scanned on return')
|
||||
|
||||
# Relationships
|
||||
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
|
||||
|
||||
@@ -70,6 +70,11 @@ class USBPlugin(BasePlugin):
|
||||
'docs': 'plugins/usb/README.md',
|
||||
}
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""CLI: import records off an external cmmc_usb database."""
|
||||
from .cli import build_cli
|
||||
return build_cli()
|
||||
|
||||
def get_reports(self) -> List[Dict]:
|
||||
"""Removable-media audit.
|
||||
|
||||
|
||||
@@ -70,8 +70,10 @@ EXPECTED_HEAD_REVISION['printers'] = 'printers0005observedqueues'
|
||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
# employees adds the photofilename column on top of its cutover anchor.
|
||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor, then
|
||||
# adds scannedviruses so an imported external history keeps the scan result -
|
||||
# the field a removable-media audit actually asks about.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0003scanfields'
|
||||
# network links a device to a catalog model, which is where its photo comes
|
||||
# from - machines, PCs and printers already had that link.
|
||||
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
|
||||
|
||||
99
tests/test_plugins/test_usb_import_pairing.py
Normal file
99
tests/test_plugins/test_usb_import_pairing.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Folding an external check-in/out LOG into self-hosted checkout PAIRS.
|
||||
|
||||
The external cmmc_usb database records one row per action; the self-hosted
|
||||
usbcheckouts table records one row per checkout, carrying both times. The
|
||||
import has to convert between them, and the interesting cases are all the ways
|
||||
a real log is untidy after years of use: a drive still out, a return nobody
|
||||
has a matching issue for, two issues in a row.
|
||||
|
||||
The rule these tests pin down is that NOTHING is invented and NOTHING vanishes
|
||||
silently. An unclosed checkout stays unclosed; anything that cannot be paired
|
||||
is reported. A quieter import that guessed the missing halves would produce a
|
||||
tidier database and a false audit history, which for removable-media records is
|
||||
the worst of the available outcomes.
|
||||
"""
|
||||
|
||||
from plugins.usb.cli import _pair, _tri
|
||||
|
||||
|
||||
def _row(action, when, badge='B1'):
|
||||
return {'action': action, 'timestamp': when, 'badge_number': badge}
|
||||
|
||||
|
||||
def test_a_clean_out_and_in_becomes_one_closed_checkout():
|
||||
problems = []
|
||||
pairs = _pair([_row('check-out', 1), _row('check-in', 2)], problems, 'DEV1')
|
||||
|
||||
assert len(pairs) == 1
|
||||
out, back = pairs[0]
|
||||
assert out['timestamp'] == 1
|
||||
assert back['timestamp'] == 2
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_a_drive_still_out_stays_open_and_is_not_reported_as_a_problem():
|
||||
# This is the normal state of any drive currently issued to somebody, not
|
||||
# a data fault, so it must not appear in the problem list.
|
||||
problems = []
|
||||
pairs = _pair([_row('check-out', 1)], problems, 'DEV1')
|
||||
|
||||
assert len(pairs) == 1
|
||||
out, back = pairs[0]
|
||||
assert back is None
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_a_return_with_no_matching_issue_is_skipped_and_reported():
|
||||
problems = []
|
||||
pairs = _pair([_row('check-in', 5)], problems, 'DEV1')
|
||||
|
||||
assert pairs == []
|
||||
assert len(problems) == 1
|
||||
assert 'no matching check-out' in problems[0]
|
||||
assert 'DEV1' in problems[0]
|
||||
|
||||
|
||||
def test_two_issues_in_a_row_leave_the_first_open_rather_than_inventing_a_return():
|
||||
problems = []
|
||||
pairs = _pair([_row('check-out', 1, 'ALICE'),
|
||||
_row('check-out', 2, 'BOB'),
|
||||
_row('check-in', 3, 'BOB')], problems, 'DEV1')
|
||||
|
||||
assert len(pairs) == 2
|
||||
first, second = pairs
|
||||
assert first[0]['badge_number'] == 'ALICE'
|
||||
assert first[1] is None, 'the first checkout must not be given a return time'
|
||||
assert second[0]['badge_number'] == 'BOB'
|
||||
assert second[1]['timestamp'] == 3
|
||||
assert len(problems) == 1
|
||||
assert 'still out' in problems[0]
|
||||
|
||||
|
||||
def test_an_unrecognised_action_is_skipped_and_reported():
|
||||
problems = []
|
||||
pairs = _pair([_row('destroyed', 1)], problems, 'DEV1')
|
||||
|
||||
assert pairs == []
|
||||
assert len(problems) == 1
|
||||
assert 'unrecognised action' in problems[0]
|
||||
|
||||
|
||||
def test_action_spelling_variants_are_accepted():
|
||||
# Years of a hand-maintained log produce more than one spelling.
|
||||
for out_word, in_word in (('check-out', 'check-in'),
|
||||
('checkout', 'checkin'),
|
||||
('out', 'in'),
|
||||
('CHECK_OUT', 'RETURN')):
|
||||
problems = []
|
||||
pairs = _pair([_row(out_word, 1), _row(in_word, 2)], problems, 'DEV1')
|
||||
assert len(pairs) == 1, out_word
|
||||
assert pairs[0][1] is not None, out_word
|
||||
assert problems == [], out_word
|
||||
|
||||
|
||||
def test_unrecorded_flags_stay_unknown_rather_than_becoming_false():
|
||||
# NULL in the source means nobody wrote it down. False would assert that
|
||||
# the drive was checked and failed, which is a different claim entirely.
|
||||
assert _tri(None) is None
|
||||
assert _tri(0) is False
|
||||
assert _tri(1) is True
|
||||
Reference in New Issue
Block a user