Merge origin/main into wilm: dashboard/fleet console, vuln upgrades, colours

This commit is contained in:
benjamin.baker
2026-08-30 14:41:56 -04:00
53 changed files with 3671 additions and 483 deletions

View File

@@ -237,6 +237,7 @@ class BackupsPlugin(BasePlugin):
{
'id': 'backups-stale',
'title': 'Backups stopped',
'shortname': 'Backups',
'endpoint': '/api/backups/dashboard/stale',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -1934,6 +1934,7 @@ class ComputersPlugin(BasePlugin):
'id': 'computers-quiet',
'viewall': '/pcs',
'title': 'PCs gone silent',
'shortname': 'PC silent',
'endpoint': '/api/computers/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
@@ -1952,6 +1953,7 @@ class ComputersPlugin(BasePlugin):
'id': 'computers-sharedmachines',
'viewall': '/machines',
'title': 'Machine numbers on two PCs',
'shortname': 'Duplicate machine no.',
'endpoint': '/api/computers/dashboard/sharedmachines',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -47,6 +47,17 @@ def _service_running(entry):
return raw in _RUNNING_STATES
# Paused, plus the four transitional states Windows reports while a service is
# on its way somewhere.
_TRANSITIONAL_STATES = ('paused', 'start pending', 'stop pending',
'continue pending', 'pause pending', 'starting',
'stopping', 'pausing')
def _service_transitional(raw):
return (raw or '').strip().lower() in _TRANSITIONAL_STATES
def _service_name(entry):
return entry.get('name') or entry.get('service') or 'Unknown'
@@ -178,7 +189,7 @@ def list_endpoints():
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
row['status'] = ('never', 'quiet', 'reporting')[rank]
row['machines'] = machines.get(state.assetid, [])
row['health'] = _health_for(state, critical.get(state.assetid, 0))
row['health'] = _health_for(state, critical.get(state.assetid))
data.append(row)
return paginated_response(data, page, per_page, total)
@@ -261,30 +272,48 @@ def _machines_for_assets(assetids):
def _critical_counts_for_assets(assetids):
"""{assetid: count} of critical events in the stability window."""
"""{assetid: (total, dominant_eventid)} of critical events in the window.
Broken down BY EVENT ID, not just totalled. A bare count says "418
critical", which tells an operator nothing about what to do; naming the
event that dominates - service timeout, application crash - is the
difference between a number and a lead.
"""
if not assetids:
return {}
cutoff = _naive_utcnow() - timedelta(hours=48)
rows = (db.session.query(EndpointEvent.assetid,
rows = (db.session.query(EndpointEvent.assetid, EndpointEvent.eventid,
db.func.count(EndpointEvent.endpointeventid))
.filter(EndpointEvent.assetid.in_(assetids),
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
EndpointEvent.eventtime.isnot(None),
EndpointEvent.eventtime >= cutoff)
.group_by(EndpointEvent.assetid)
.group_by(EndpointEvent.assetid, EndpointEvent.eventid)
.all())
return dict(rows)
perasset = {}
for assetid, eventid, count in rows:
total, dominant, best = perasset.get(assetid, (0, None, 0))
total += count
if count > best:
dominant, best = eventid, count
perasset[assetid] = (total, dominant, best)
return {assetid: (total, dominant)
for assetid, (total, dominant, _best) in perasset.items()}
def _health_for(state, criticalcount):
def _health_for(state, critical):
"""Worst-of rollup: a stopped watched service, or a recent critical event.
Deliberately a PHRASE, not a score. "eDNC stopped" tells an operator what
to do; a percentage does not.
to do; a percentage does not - and neither does "418 critical", which is
why the dominant event is named rather than merely counted.
"""
if criticalcount:
return {'level': 'critical',
'label': f'{criticalcount} critical' if criticalcount > 1 else '1 critical'}
if critical:
total, dominant = critical
title = event_title(dominant) if dominant else 'critical'
label = title if total == 1 else '{} x{}'.format(title, total)
return {'level': 'critical', 'label': label}
stopped = []
try:
@@ -450,7 +479,14 @@ def panel_services(asset_id: int):
'name': name,
'kind': (kind or '').lower() or None,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
'statecolor': 'green' if running else ('red' if raw else 'gray'),
# Amber for paused and the four Windows transitional states. A
# service stuck in Start Pending is failing to start on a retry
# cycle - which is what raises event 7011 - and a paused one was
# paused deliberately. Painting either the same red as Stopped
# loses the distinction that tells you which to chase.
'statecolor': ('green' if running
else 'yellow' if _service_transitional(raw)
else 'red' if raw else 'gray'),
}
# Only offer Start/Stop to a caller who could actually run it. The
# emission never consulted the caller, so a read-only viewer was shown
@@ -520,6 +556,20 @@ def panel_activity(asset_id: int):
.limit(15)
.all())
# The newest 15 of ANY severity can be entirely routine logons while the
# health rollup is shouting about a burst of crashes underneath them - the
# page then contradicts itself for no visible reason. Pull the newest
# criticals separately and merge, so what health is reporting is always
# present in the feed that is supposed to explain it.
criticals = (EndpointEvent.query
.filter(EndpointEvent.assetid == asset_id,
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS))
.order_by(EndpointEvent.eventtime.desc())
.limit(5)
.all())
seen = {e.endpointeventid for e in events}
events.extend(e for e in criticals if e.endpointeventid not in seen)
commands = (EndpointCommand.query
.filter_by(assetid=asset_id)
.order_by(EndpointCommand.createdat.desc())
@@ -563,9 +613,24 @@ def panel_activity(asset_id: int):
# Undated rows sort last rather than crashing the comparison.
rows.sort(key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True)
# Trim to the cap without dropping the criticals that were fetched on
# purpose: they keep their place in time, but they survive the cut.
if len(rows) > 15:
keep = [r for r in rows if r['severity'] == 'critical'][:5]
rest = [r for r in rows if r not in keep]
rows = sorted(keep + rest[:15 - len(keep)],
key=lambda r: (r['eventtime'] is not None, r['eventtime']),
reverse=True)
# The list renderer reads a badge colour from a ROW FIELD, so without this
# every badge falls back to grey and the feed loses the one signal it is
# there to carry.
colours = {'critical': 'red', 'warning': 'yellow', 'info': 'gray'}
return success_response([{
'title': r['title'],
'severity': r['severity'],
'severitycolor': colours.get(r['severity'], 'gray'),
'detail': r['detail'],
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
} for r in rows[:15]])

View File

@@ -281,6 +281,18 @@ class EndpointsPlugin(BasePlugin):
'render': 'list',
'collapsible': True,
'filter': {'key': 'kind', 'equals': 'service'},
# Facet on the STATE, not the status colour. statecolor is only
# ever green, red or grey here, so chips derived from it would
# read "OK" and "Critical" - not how anybody talks about a
# service. statelabel carries the real vocabulary: Running,
# Stopped, Paused, and the Windows transitional states. Any
# value not listed still gets a chip, appended after these.
'facets': {
'key': 'statelabel',
'order': ['Running', 'Stopped', 'Paused', 'Start Pending',
'Stop Pending', 'Continue Pending', 'Pause Pending',
'Unknown'],
},
'status': {
'key': 'statecolor',
'levels': {
@@ -307,6 +319,18 @@ class EndpointsPlugin(BasePlugin):
'render': 'list',
'collapsible': True,
'filter': {'key': 'kind', 'equals': 'process'},
# Facet on the STATE, not the status colour. statecolor is only
# ever green, red or grey here, so chips derived from it would
# read "OK" and "Critical" - not how anybody talks about a
# service. statelabel carries the real vocabulary: Running,
# Stopped, Paused, and the Windows transitional states. Any
# value not listed still gets a chip, appended after these.
'facets': {
'key': 'statelabel',
'order': ['Running', 'Stopped', 'Paused', 'Start Pending',
'Stop Pending', 'Continue Pending', 'Pause Pending',
'Unknown'],
},
'status': {
'key': 'statecolor',
'levels': {
@@ -350,6 +374,10 @@ class EndpointsPlugin(BasePlugin):
'title': 'title',
'badge': {
'label': 'severity',
# The renderer reads the colour from a row field; with
# no colour declared every badge renders grey, which is
# what the feed spent its first version doing.
'color': 'severitycolor',
'labelmap': {
'critical': 'Critical',
'warning': 'Warning',
@@ -416,6 +444,7 @@ class EndpointsPlugin(BasePlugin):
'id': 'endpoints-quiet',
'viewall': '/pcs',
'title': 'Endpoints gone quiet',
'shortname': 'Agent quiet',
'endpoint': '/api/endpoints/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
@@ -434,6 +463,7 @@ class EndpointsPlugin(BasePlugin):
'id': 'endpoints-stability',
'viewall': '/pcs',
'title': 'Endpoints with crashes or shutdowns',
'shortname': 'Crashes',
'endpoint': '/api/endpoints/dashboard/stability',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -86,6 +86,7 @@ class GeEnforcePlugin(BasePlugin):
'id': 'geenforce-failures',
'viewall': '/geenforce',
'title': 'Enforcement failures',
'shortname': 'Enforcement',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -20,16 +20,6 @@
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine">
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
<div v-if="machine.dualpathpartner" class="dualpath-banner">
Dual-bay machine - sibling bay:
<router-link
v-if="machine.dualpathpartner.machineid"
:to="`/machines/${machine.dualpathpartner.machineid}`"
>{{ machine.dualpathpartner.assetnumber }}</router-link>
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
</div>
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="machine.machine?.imageurl">
@@ -70,6 +60,18 @@
<span class="hero-detail-value">{{ machine.locationname }}</span>
</div>
</div>
<!-- The 6am band, machine side: which PCs drive this machine. The
same fact as a PC's "Serves machine", read from the other end -
and the end somebody is standing at when the machine has
stopped. It lived only in the relationships card near the
bottom of the page. -->
<AssetTriageBand
:assetid="machine.assetid"
counterparttype="computer"
counterpartlabel="Controlled by"
:extraitems="triageextras"
/>
</div>
</div>
@@ -248,11 +250,12 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
@@ -269,6 +272,25 @@ const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const machine = ref(null)
// The dual-bay sibling, as a triage chip. It had its own banner above the hero
// until the band existed; two strips of chrome answering two halves of the same
// "what am I looking at" read as clutter, and the sibling belongs beside the
// PC that drives the machine rather than above it.
const triageextras = computed(() => {
const partner = machine.value?.dualpathpartner
if (!partner) return []
return [{
// Says what the pair MEANS, not just that a link exists: a dualpath pair is
// one physical machine in two bays, which is why the old banner spelled it
// out rather than showing a bare number.
label: 'Dual-bay sibling',
links: [{
text: partner.assetnumber,
to: partner.machineid ? `/machines/${partner.machineid}` : null
}]
}]
})
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
// relationships render via the shared AssetRelationships card
@@ -322,21 +344,6 @@ async function confirmRetire() {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.dualpath-banner {
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 6px;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
color: var(--text);
font-size: 0.9rem;
}
.dualpath-banner a {
color: var(--link);
font-weight: 600;
}
.feature-tag {
display: inline-block;

View File

@@ -209,6 +209,7 @@ class MachinesPlugin(BasePlugin):
'id': 'machines-outofservice',
'viewall': '/machines',
'title': 'Machines out of service',
'shortname': 'Out of service',
'endpoint': '/api/machines/dashboard/outofservice',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -46,6 +46,22 @@
<span class="hero-detail-value">{{ tool.locationname }}</span>
</div>
</div>
<!-- The 6am band: which PC drives this instrument.
A metrology tool is reached through the PC that runs it - the
import synthesizes one measuring-tool asset per metrology PC and
a 'controls' edge PC -> tool (stage_metrology), so the
counterpart here is a COMPUTER, exactly as on the machine page.
This was first written as counterparttype="machine" labelled
"Used on", which filtered for a counterpart that cannot exist
and so rendered nothing on every gage that had a PC. A hand-held
gage genuinely has no such edge, and the band correctly shows
nothing for it. -->
<AssetTriageBand
:assetid="tool.assetid"
counterparttype="computer"
counterpartlabel="Controlled by"
/>
</div>
</div>
@@ -169,6 +185,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'

View File

@@ -68,6 +68,14 @@
<span class="hero-detail-value">{{ device.businessunitname }}</span>
</div>
</div>
<!-- The 6am band: what stops if this switch or AP goes down. A network
device is rarely interesting in itself; it matters because of what
sits behind it. -->
<AssetTriageBand
:assetid="device.assetid"
counterparttype="machine"
/>
<div class="hero-features" v-if="device.networkdevice">
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
@@ -227,6 +235,7 @@ import { useAuthStore } from '@/stores/auth'
import { networkApi } from '@/api'
import { withBase } from '@/utils/basePath'
import AssetRelationships from '@/components/AssetRelationships.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'

View File

@@ -206,6 +206,7 @@ class NotificationsPlugin(BasePlugin):
'id': 'notifications-active',
'viewall': '/notifications',
'title': 'Active notifications',
'shortname': 'Notification',
'endpoint': '/api/notifications/dashboard/active',
'render': 'list',
'severity': 'info',

View File

@@ -2124,8 +2124,21 @@ def _get_low_supplies_data():
return cached
service = ZabbixService()
if not service.isconfigured or not service.isreachable:
# "No printer is low" and "we cannot tell whether any printer is low" are
# different answers, and this used to give the same empty one for both. The
# dashboard then showed Low Supplies 0 while the monitoring was dead, which
# is not a missing feature but a wrong statement: it reads as reassurance.
if not service.isconfigured:
# Enabled plus a URL plus a token. Absent any of those the site has not
# asked for supply monitoring, and a permanent "not configured" line
# would be a card reporting nothing wrong every day - which is how a
# board teaches people to stop reading it. Nothing to say: stay hidden.
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
if not service.isreachable:
# Configured and unanswerable is a different thing: something meant to
# work is not working, and the cartridges are NOT known to be fine.
return {'printers': [], 'unavailable': 'Supply monitoring is not reachable',
'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
# active printers with an IP, with vendor and model for waste/part rules
rows = (
@@ -2655,7 +2668,10 @@ def dashboard_supplies():
})
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
return success_response(rows)
# Envelope rather than a bare list, so the card can say WHY it has no rows.
# An empty card and a blind card must not look the same.
return success_response({'rows': rows,
'unavailable': data.get('unavailable')})
# =============================================================================

View File

@@ -66,6 +66,16 @@
<span class="hero-detail-value mono">{{ ipAddress }}</span>
</div>
</div>
<!-- The 6am band: which machine stops if this printer is down.
A shop-floor printer is often a label or tag printer feeding one
machine, and that link was only visible in the relationships
card near the bottom of the page. -->
<AssetTriageBand
:assetid="printer.assetid"
counterparttype="machine"
:relationshiptypes="['usesprinter', 'defaultprinter']"
/>
</div>
</div>
@@ -301,6 +311,7 @@ import { useRoute, useRouter } from 'vue-router'
import { printersApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'

View File

@@ -354,6 +354,7 @@ class PrintersPlugin(BasePlugin):
'id': 'printers-supplies',
'viewall': '/reports/toner',
'title': 'Printer supplies',
'shortname': 'Supplies',
'endpoint': '/api/printers/dashboard/supplies',
'render': 'exceptions',
'severity': 'warning',

View File

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

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

View File

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

View File

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

View File

@@ -128,6 +128,7 @@ class WarrantyPlugin(BasePlugin):
'id': 'warranty-expiring',
'viewall': '/reports/warranty',
'title': 'Warranties expiring',
'shortname': 'Warranty',
'endpoint': '/api/warranty/dashboard/expiring',
'render': 'exceptions',
'severity': 'info',