Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

105
plugins/usb/README.md Normal file
View File

@@ -0,0 +1,105 @@
# USB plugin - CMMC USB check-in/out database contract
The USB plugin tracks removable-media check-in/out for CMMC compliance. Device
and log state live in a **separate, read-write** MySQL database (`cmmc_usb`),
reached with parameterized pymysql via `cmmc_usb_connection()`. Names of people
are resolved from the HR employee directory (see the employees plugin).
The plugin's own reference tables (`usbdevicetypes`, `usbdevices`,
`usbcheckouts`) live in the main app database; only the live check-in/out data
is in `cmmc_usb`.
> **The schema is standardized across sites** - the `cmmc_usb` check-in/out
> solution is the same deployment everywhere, so the tables below match as-is
> and no schema adaptation is needed. The **database name may differ per site**,
> though - set `cmmc_usb_db_name` (default `cmmc_usb`) to match the local name.
> The view recipe at the end is only a fallback for a site that somehow differs.
> (Contrast the employee directory, which genuinely varies per site.)
## Connection
Credentials resolve **settings-first, then environment**, except the password
which is env-only (never stored in the app database).
| Field | Setting key (editable in the setup wizard) | Env var (fallback) |
| -------- | ------------------------------------------ | ----------------------- |
| Host | `cmmc_usb_db_host` | `CMMC_USB_DB_HOST` |
| Database | `cmmc_usb_db_name` | `CMMC_USB_DB_NAME` |
| User | `cmmc_usb_db_user` | `CMMC_USB_DB_USER` |
| Password | (not stored) | `CMMC_USB_DB_PASSWORD` |
Set the password in `.env`; the setup wizard's Features step shows the exact
line to paste. This database is **read-write** - the app account needs
`SELECT, INSERT, UPDATE`. Engine is MySQL/MariaDB (pymysql).
## Required schema
The plugin runs raw SQL against these tables (or **views** - see below).
### `devices`
| Column | Type | Notes |
| ----------------- | ------- | -------------------------------------- |
| `device_id` | VARCHAR | Primary key; the device serial/tag |
| `device_desc` | VARCHAR | Description |
| `device_owner` | VARCHAR | Owner |
| `status` | VARCHAR | Check-in/out state |
| `locker_location` | VARCHAR | Where the device is stored |
Operations: `SELECT` (list + by id), `INSERT` (register device), `UPDATE`
(edit fields, change status).
### `checkinoutlog`
| Column | Type | Notes |
| ----------------- | --------- | ---------------------------------- |
| `log_id` | INT (PK) | Auto id |
| `badge_number` | VARCHAR | Person's badge |
| `device_id` | VARCHAR | FK to `devices.device_id` |
| `action` | VARCHAR | check-in / check-out |
| `timestamp` | DATETIME | When it happened |
| `scanned_viruses` | (text/int)| Scan result |
| `locker_location` | VARCHAR | Locker at time of event |
| `sanitized` | (bool/int)| Sanitization flag |
Operations: `SELECT` (history per device), `INSERT` (log an event).
### `users`
| Column | Type | Notes |
| -------------- | ------- | ------------------------------------- |
| `badge_number` | VARCHAR | Primary key; the scanned badge |
| `first_name` | VARCHAR | Given name |
| `last_name` | VARCHAR | Surname |
Operations: `SELECT` by badge, `INSERT` (auto-add a badge on first scan).
## Employee directory dependency
To turn a scanned badge into a name, the plugin also reads the HR `employees`
directory (via the employees plugin's connection). A badge shaped `0<digits>BZ`
carries a PayNo (the digits); lookups try `employees.SSO` and `employees.PayNo`.
See `plugins/employees/README.md` for that schema and connection.
## Adapting a different site schema (recommended: views)
Sites whose USB-tracking database uses different table/column names should
create read-only/updatable **views** named `devices`, `checkinoutlog`, and
`users` that map local columns to the names above. Example:
```sql
CREATE VIEW devices AS
SELECT
asset_tag AS device_id,
description AS device_desc,
owner AS device_owner,
state AS status,
storage_bay AS locker_location
FROM usb_assets;
```
Because the plugin writes to `devices`/`checkinoutlog`/`users`, either make the
views updatable (single-table views usually are) or expose real tables with
these column names. Grant the app account `SELECT, INSERT, UPDATE`.
Notes:
- `device_id` and `badge_number` are the natural keys the plugin matches on.
- If `cmmc_usb` is unreachable, USB endpoints return an error and the rest of
the app keeps working (the feature degrades, it does not crash the app).

5
plugins/usb/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""USB device checkout plugin."""
from .plugin import USBPlugin
__all__ = ['USBPlugin']

View File

@@ -0,0 +1,5 @@
"""USB plugin API."""
from .routes import usb_bp
__all__ = ['usb_bp']

723
plugins/usb/api/routes.py Normal file
View File

@@ -0,0 +1,723 @@
"""USB plugin API endpoints.
Two modes, chosen by the usb_directory_mode setting:
- 'selfhosted' (default): the app-owned tables (usbdevices/usbcheckouts/
usbdevicetypes) via SQLAlchemy - see selfhosted.py. Each route below
delegates when _usb_selfhosted() is true.
- 'external': a separate MySQL database (cmmc_usb) reached with raw
parameterized pymysql via cmmc_usb_connection(), for a site that already
runs the cmmc_usb solution. That is the raw-SQL code in this file.
Both modes return the same response shape so the frontend is identical.
cmmc_usb schema used here:
devices(device_id PK, device_desc, device_owner, status, locker_location)
status is one of 'checked-in' | 'checked-out' | 'retired'
users(badge_number PK, first_name, last_name)
checkinoutlog(log_id PK auto, badge_number, device_id, action, timestamp,
scanned_viruses, locker_location, sanitized)
action is one of 'check-in' | 'check-out'; timestamp is a
'YYYY-MM-DD HH:MM:SS' string; scanned_viruses/sanitized are '0'|'1'|null.
"""
import logging
import re
from datetime import datetime
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import (
cmmc_usb_connection,
employee_connection,
success_response,
error_response,
paginated_response,
ErrorCodes,
get_pagination_params,
require_permission,
)
from shopdb.api import Setting
from . import selfhosted
logger = logging.getLogger(__name__)
usb_bp = Blueprint('usb', __name__)
def _usb_selfhosted():
"""True when USB uses the app-owned tables, not the external cmmc_usb DB."""
row = Setting.query.filter_by(key='usb_directory_mode').first()
return (row.value if row and row.value else 'external').lower() == 'selfhosted'
# public status strings on the devices row
_STATUS_CHECKED_IN = 'checked-in'
_STATUS_CHECKED_OUT = 'checked-out'
_STATUS_RETIRED = 'retired'
# map the ?status= query values to the stored devices.status strings
_STATUS_FILTER = {
'available': _STATUS_CHECKED_IN,
'checkedout': _STATUS_CHECKED_OUT,
'retired': _STATUS_RETIRED,
}
# columns pulled for a devices row
_DEVICE_COLS = 'device_id, device_desc, device_owner, status, locker_location'
# columns pulled for a checkinoutlog row
_LOG_COLS = (
'log_id, badge_number, device_id, action, timestamp, '
'scanned_viruses, locker_location, sanitized'
)
# a badge like 0123456BZ carries a PayNo (the digits) wrapped in 0...BZ
_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
# =============================================================================
# Module helpers
# =============================================================================
def _now():
"""Current local time as a 'YYYY-MM-DD HH:MM:SS' string for the log."""
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def _flag(value):
"""Coerce a truthy/falsy input to a '1'/'0' string, or None when unset."""
if value is None:
return None
if isinstance(value, bool):
return '1' if value else '0'
text = str(value).strip().lower()
if text in ('1', 'true', 'yes', 'y'):
return '1'
if text in ('0', 'false', 'no', 'n', ''):
return '0'
return None
def _lookup_employee_name(badge):
"""Look up "First Last" in the HR directory for a badge. Empty on any miss.
A numeric badge is an SSO. A 0<digits>BZ badge carries a PayNo (the middle
digits). Any other shape has no HR mapping so we return "".
"""
badge = (badge or '').strip()
if not badge:
return ''
try:
conn = employee_connection()
except Exception:
logger.exception('HR directory connection failed for badge %s', badge)
return ''
try:
with conn.cursor() as ecur:
if badge.isdigit():
ecur.execute(
'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s',
(badge,),
)
else:
match = _PAYNO_BADGE.match(badge)
if not match:
return ''
ecur.execute(
'SELECT First_Name, Last_Name FROM employees WHERE PayNo = %s',
(match.group(1),),
)
emp = ecur.fetchone()
except Exception:
logger.exception('HR directory lookup failed for badge %s', badge)
return ''
finally:
conn.close()
if not emp:
return ''
first = (emp.get('First_Name') or '').strip()
last = (emp.get('Last_Name') or '').strip()
return (first + ' ' + last).strip()
def _resolve_badge_name(cur, badge):
"""Resolve a badge to "First Last". Try cmmc_usb users, then HR directory.
cur is an open cmmc_usb cursor. Returns "" when the badge cannot be
resolved anywhere.
"""
badge = (badge or '').strip() if badge is not None else ''
if not badge:
return ''
cur.execute(
'SELECT first_name, last_name FROM users WHERE badge_number = %s',
(badge,),
)
row = cur.fetchone()
if row:
first = (row.get('first_name') or '').strip()
last = (row.get('last_name') or '').strip()
name = (first + ' ' + last).strip()
if name:
return name
return _lookup_employee_name(badge)
def _ensure_user(cur, badge, name):
"""Insert a badge into cmmc_usb users if absent. Splits name to first/last."""
badge = (badge or '').strip() if badge is not None else ''
if not badge:
return
cur.execute('SELECT badge_number FROM users WHERE badge_number = %s', (badge,))
if cur.fetchone():
return
first, last = '', ''
if name:
parts = name.split()
if parts:
first = parts[0]
last = ' '.join(parts[1:])
cur.execute(
'INSERT INTO users (badge_number, first_name, last_name) VALUES (%s, %s, %s)',
(badge, first, last),
)
def _fetch_device(cur, device_id):
"""Return the devices row for device_id, or None."""
cur.execute(
'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE device_id = %s',
(device_id,),
)
return cur.fetchone()
def _device_to_dict(cur, row):
"""Build the device response dict, resolving owner + current holder names."""
device = {
'device_id': row.get('device_id'),
'device_desc': row.get('device_desc'),
'device_owner': row.get('device_owner'),
'owner_name': _resolve_badge_name(cur, row.get('device_owner')),
'status': row.get('status'),
'locker_location': row.get('locker_location'),
'current_holder': None,
'current_holder_name': None,
'checkout_time': None,
}
if row.get('status') == _STATUS_CHECKED_OUT:
cur.execute(
"SELECT badge_number, timestamp FROM checkinoutlog "
"WHERE device_id = %s AND action = 'check-out' "
"ORDER BY timestamp DESC, log_id DESC LIMIT 1",
(row.get('device_id'),),
)
log = cur.fetchone()
if log:
device['current_holder'] = log.get('badge_number')
device['current_holder_name'] = _resolve_badge_name(
cur, log.get('badge_number')
)
device['checkout_time'] = log.get('timestamp')
return device
def _log_to_dict(cur, row):
"""Build a checkinoutlog response dict with the resolved badge name."""
result = {
'log_id': row.get('log_id'),
'badge_number': row.get('badge_number'),
'badge_name': _resolve_badge_name(cur, row.get('badge_number')),
'device_id': row.get('device_id'),
'action': row.get('action'),
'timestamp': row.get('timestamp'),
'scanned_viruses': row.get('scanned_viruses'),
'locker_location': row.get('locker_location'),
'sanitized': row.get('sanitized'),
}
# /checkouts joins in the current device status; pass it through when present
if 'device_status' in row:
result['device_status'] = row.get('device_status')
return result
# =============================================================================
# USB Devices
# =============================================================================
@usb_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_usb_devices():
"""List USB devices with checkout status.
Query parameters:
- page, per_page: pagination
- status: available | checkedout | retired
- search: match on device_id or device_desc
"""
if _usb_selfhosted():
return selfhosted.list_devices(request)
page, per_page = get_pagination_params(request)
where = []
params = []
status_arg = request.args.get('status', '').strip().lower()
if status_arg in _STATUS_FILTER:
where.append('status = %s')
params.append(_STATUS_FILTER[status_arg])
search = request.args.get('search', '').strip()
if search:
where.append('(device_id LIKE %s OR device_desc LIKE %s)')
like = '%' + search + '%'
params.extend([like, like])
where_sql = (' WHERE ' + ' AND '.join(where)) if where else ''
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
cur.execute('SELECT COUNT(*) AS total FROM devices' + where_sql, params)
total = cur.fetchone()['total']
cur.execute(
'SELECT ' + _DEVICE_COLS + ' FROM devices' + where_sql
+ ' ORDER BY device_id LIMIT %s OFFSET %s',
params + [per_page, (page - 1) * per_page],
)
rows = cur.fetchall()
data = [_device_to_dict(cur, row) for row in rows]
finally:
conn.close()
return paginated_response(data, page, per_page, total)
@usb_bp.route('/<device_id>', methods=['GET'])
@jwt_required(optional=True)
def get_usb_device(device_id):
"""Get a single device plus its last 20 check-in/out log rows."""
if _usb_selfhosted():
return selfhosted.get_device(device_id)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
if not row:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device {device_id} not found',
http_code=404,
)
result = _device_to_dict(cur, row)
cur.execute(
'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s '
'ORDER BY timestamp DESC, log_id DESC LIMIT 20',
(device_id,),
)
logs = cur.fetchall()
result['checkinoutlog'] = [_log_to_dict(cur, log) for log in logs]
finally:
conn.close()
return success_response(result)
@usb_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('usb.create')
def create_usb_device():
"""Add a device (starts checked-in)."""
if _usb_selfhosted():
return selfhosted.create_device(request.get_json() or {})
data = request.get_json() or {}
device_id = (data.get('device_id') or '').strip()
if not device_id:
return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required')
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT device_id FROM devices WHERE device_id = %s', (device_id,)
)
if cur.fetchone():
return error_response(
ErrorCodes.CONFLICT, 'device_id already exists', http_code=409
)
cur.execute(
'INSERT INTO devices '
'(device_id, device_desc, device_owner, status, locker_location) '
'VALUES (%s, %s, %s, %s, %s)',
(
device_id,
data.get('device_desc'),
data.get('device_owner'),
_STATUS_CHECKED_IN,
data.get('locker_location'),
),
)
conn.commit()
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
result = _device_to_dict(cur, row)
finally:
conn.close()
return success_response(result, message='Device created', http_code=201)
@usb_bp.route('/<device_id>', methods=['PUT'])
@jwt_required()
@require_permission('usb.edit')
def update_usb_device(device_id):
"""Edit device_desc / device_owner / locker_location / status."""
if _usb_selfhosted():
return selfhosted.update_device(device_id, request.get_json() or {})
data = request.get_json() or {}
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
if not _fetch_device(cur, device_id):
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device {device_id} not found',
http_code=404,
)
fields = []
params = []
for col in ('device_desc', 'device_owner', 'locker_location', 'status'):
if col in data:
fields.append(col + ' = %s')
params.append(data[col])
if fields:
params.append(device_id)
cur.execute(
'UPDATE devices SET ' + ', '.join(fields) + ' WHERE device_id = %s',
params,
)
conn.commit()
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
result = _device_to_dict(cur, row)
finally:
conn.close()
return success_response(result, message='Device updated')
@usb_bp.route('/<device_id>/retire', methods=['POST'])
@jwt_required()
@require_permission('usb.edit')
def retire_usb_device(device_id):
"""Retire a device (status -> retired)."""
if _usb_selfhosted():
return selfhosted.retire_device(device_id)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
if not _fetch_device(cur, device_id):
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device {device_id} not found',
http_code=404,
)
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_RETIRED, device_id),
)
conn.commit()
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
result = _device_to_dict(cur, row)
finally:
conn.close()
return success_response(result, message='Device retired')
# =============================================================================
# Check-in / check-out operations
# =============================================================================
@usb_bp.route('/<device_id>/checkout', methods=['POST'])
@jwt_required()
@require_permission('usb.create')
def checkout_device(device_id):
"""Check a device out to a badge."""
if _usb_selfhosted():
return selfhosted.checkout_device(device_id, request.get_json() or {})
data = request.get_json() or {}
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required')
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device {device_id} not found',
http_code=404,
)
if device.get('status') == _STATUS_CHECKED_OUT:
return error_response(
ErrorCodes.CONFLICT,
'Device is already checked out',
http_code=409,
)
name = _resolve_badge_name(cur, badge)
_ensure_user(cur, badge, name)
locker = data.get('locker_location')
log_locker = locker if locker is not None else device.get('locker_location')
cur.execute(
'INSERT INTO checkinoutlog '
'(badge_number, device_id, action, timestamp, locker_location) '
"VALUES (%s, %s, 'check-out', %s, %s)",
(badge, device_id, _now(), log_locker),
)
if locker is not None:
cur.execute(
'UPDATE devices SET status = %s, locker_location = %s '
'WHERE device_id = %s',
(_STATUS_CHECKED_OUT, locker, device_id),
)
else:
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_CHECKED_OUT, device_id),
)
conn.commit()
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
result = _device_to_dict(cur, row)
finally:
conn.close()
return success_response(result, message='Device checked out', http_code=201)
@usb_bp.route('/<device_id>/checkin', methods=['POST'])
@jwt_required()
@require_permission('usb.create')
def checkin_device(device_id):
"""Check a device back in from a badge."""
if _usb_selfhosted():
return selfhosted.checkin_device(device_id, request.get_json() or {})
data = request.get_json() or {}
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required')
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device {device_id} not found',
http_code=404,
)
if device.get('status') != _STATUS_CHECKED_OUT:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Device is not currently checked out',
http_code=400,
)
name = _resolve_badge_name(cur, badge)
_ensure_user(cur, badge, name)
locker = data.get('locker_location')
log_locker = locker if locker is not None else device.get('locker_location')
sanitized = _flag(data.get('sanitized'))
scanned_viruses = _flag(data.get('scanned_viruses'))
cur.execute(
'INSERT INTO checkinoutlog '
'(badge_number, device_id, action, timestamp, scanned_viruses, '
'locker_location, sanitized) '
"VALUES (%s, %s, 'check-in', %s, %s, %s, %s)",
(badge, device_id, _now(), scanned_viruses, log_locker, sanitized),
)
if locker is not None:
cur.execute(
'UPDATE devices SET status = %s, locker_location = %s '
'WHERE device_id = %s',
(_STATUS_CHECKED_IN, locker, device_id),
)
else:
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_CHECKED_IN, device_id),
)
conn.commit()
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
result = _device_to_dict(cur, row)
finally:
conn.close()
return success_response(result, message='Device checked in')
# =============================================================================
# Check-in/out log queries
# =============================================================================
@usb_bp.route('/<device_id>/history', methods=['GET'])
@jwt_required(optional=True)
def get_device_history(device_id):
"""Paginated check-in/out log for one device."""
if _usb_selfhosted():
return selfhosted.device_history(device_id, request)
page, per_page = get_pagination_params(request)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT COUNT(*) AS total FROM checkinoutlog WHERE device_id = %s',
(device_id,),
)
total = cur.fetchone()['total']
cur.execute(
'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s '
'ORDER BY timestamp DESC, log_id DESC LIMIT %s OFFSET %s',
(device_id, per_page, (page - 1) * per_page),
)
rows = cur.fetchall()
data = [_log_to_dict(cur, row) for row in rows]
finally:
conn.close()
return paginated_response(data, page, per_page, total)
@usb_bp.route('/checkouts', methods=['GET'])
@jwt_required(optional=True)
def list_all_checkouts():
"""List check-out log rows.
Query parameters:
- active: true -> only rows whose device is currently checked out
- badge: filter by badge_number
"""
if _usb_selfhosted():
return selfhosted.list_checkouts(request, active_only=False)
page, per_page = get_pagination_params(request)
where = ["l.action = 'check-out'"]
params = []
if request.args.get('active', '').lower() == 'true':
where.append("d.status = 'checked-out'")
badge = request.args.get('badge', '').strip()
if badge:
where.append('l.badge_number = %s')
params.append(badge)
where_sql = ' WHERE ' + ' AND '.join(where)
base = (
'FROM checkinoutlog l JOIN devices d ON l.device_id = d.device_id'
+ where_sql
)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
cur.execute('SELECT COUNT(*) AS total ' + base, params)
total = cur.fetchone()['total']
cur.execute(
'SELECT l.log_id, l.badge_number, l.device_id, l.action, '
'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, '
'd.status AS device_status ' + base
+ ' ORDER BY l.timestamp DESC, l.log_id DESC LIMIT %s OFFSET %s',
params + [per_page, (page - 1) * per_page],
)
rows = cur.fetchall()
data = [_log_to_dict(cur, row) for row in rows]
finally:
conn.close()
return paginated_response(data, page, per_page, total)
@usb_bp.route('/checkouts/active', methods=['GET'])
@jwt_required(optional=True)
def list_active_checkouts():
"""List the latest check-out log row for every currently checked-out device.
Query parameters:
- badge: filter by badge_number
"""
if _usb_selfhosted():
return selfhosted.list_checkouts(request, active_only=True)
where_badge = ''
params = []
badge = request.args.get('badge', '').strip()
if badge:
where_badge = 'AND l.badge_number = %s '
params.append(badge)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT l.log_id, l.badge_number, l.device_id, l.action, '
'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, '
'd.status AS device_status '
'FROM devices d '
'JOIN checkinoutlog l ON l.device_id = d.device_id '
"AND l.action = 'check-out' "
"WHERE d.status = 'checked-out' "
+ where_badge +
'AND l.log_id = ('
' SELECT MAX(l2.log_id) FROM checkinoutlog l2 '
" WHERE l2.device_id = d.device_id AND l2.action = 'check-out'"
') '
'ORDER BY l.timestamp DESC, l.log_id DESC',
params,
)
rows = cur.fetchall()
data = [_log_to_dict(cur, row) for row in rows]
finally:
conn.close()
return success_response(data)

View File

@@ -0,0 +1,276 @@
"""Self-hosted USB check-in/out, backed by the app-owned tables.
Used when usb_directory_mode=selfhosted: the USB routes call these functions
instead of the external cmmc_usb database. Data lives in the app tables
(usbdevices, usbcheckouts, usbdevicetypes); responses match the same shape the
external path returns so the frontend is identical in both modes.
Field mapping (response contract <- app model):
device_id <- USBDevice.serialnumber
device_desc <- USBDevice.label
status <- derived: retired (not isactive) / checked-out / checked-in
locker_location <- USBDevice.storagelocation
current_holder <- USBDevice.currentuserid (SSO)
checkout_time <- USBDevice.currentcheckoutdate
History rows are synthesized from usbcheckouts (each row = a check-out event and,
if returned, a check-in event).
"""
from datetime import datetime, timezone
from shopdb.api import (
db, success_response, error_response, ErrorCodes,
get_pagination_params, paginated_response,
apply_import_timestamps, import_mode_active, parse_import_datetime,
)
from ..models import USBDevice, USBCheckout
STATUS_CHECKED_IN = 'checked-in'
STATUS_CHECKED_OUT = 'checked-out'
STATUS_RETIRED = 'retired'
_FILTER = {'available': STATUS_CHECKED_IN, 'checkedout': STATUS_CHECKED_OUT, 'retired': STATUS_RETIRED}
def _resolve_name(sso):
"""Best-effort name from the self-hosted employee directory."""
try:
from plugins.employees.models import DirectoryEmployee
if sso and str(sso).isdigit():
emp = db.session.get(DirectoryEmployee, int(sso))
if emp:
return f'{emp.firstname} {emp.lastname}'.strip()
except Exception:
pass
return None
def _status(device):
if not device.isactive:
return STATUS_RETIRED
return STATUS_CHECKED_OUT if device.ischeckedout else STATUS_CHECKED_IN
def _device_dict(device):
return {
'device_id': device.serialnumber,
'device_desc': device.label,
'device_owner': None,
'owner_name': None,
'status': _status(device),
'locker_location': device.storagelocation,
'current_holder': device.currentuserid,
'current_holder_name': device.currentusername,
'checkout_time': device.currentcheckoutdate.isoformat() + 'Z' if device.currentcheckoutdate else None,
}
def _checkout_events(checkout):
"""One usbcheckouts row -> its check-out event (+ check-in event if returned)."""
name = checkout.checkoutname or _resolve_name(checkout.sso)
events = [{
'log_id': checkout.checkoutid * 2,
'badge_number': checkout.sso,
'badge_name': name,
'device_id': checkout.device.serialnumber if checkout.device else None,
'action': 'check-out',
'timestamp': checkout.checkouttime.isoformat() + 'Z' if checkout.checkouttime else None,
'scanned_viruses': None,
'locker_location': None,
'sanitized': None,
}]
if checkout.checkintime:
events.append({
'log_id': checkout.checkoutid * 2 + 1,
'badge_number': checkout.sso,
'badge_name': name,
'device_id': checkout.device.serialnumber if checkout.device else None,
'action': 'check-in',
'timestamp': checkout.checkintime.isoformat() + 'Z',
'scanned_viruses': None,
'locker_location': None,
'sanitized': checkout.waswiped,
})
return events
def _find(device_id):
return USBDevice.query.filter_by(serialnumber=device_id).first()
# ---------------------------------------------------------------------------
def list_devices(request):
page, per_page = get_pagination_params(request)
query = USBDevice.query
status_arg = request.args.get('status', '').strip().lower()
if status_arg in _FILTER:
want = _FILTER[status_arg]
if want == STATUS_RETIRED:
query = query.filter_by(isactive=False)
elif want == STATUS_CHECKED_OUT:
query = query.filter_by(isactive=True, ischeckedout=True)
else:
query = query.filter_by(isactive=True, ischeckedout=False)
search = request.args.get('search', '').strip()
if search:
like = f'%{search}%'
query = query.filter(db.or_(USBDevice.serialnumber.ilike(like),
USBDevice.label.ilike(like)))
total = query.count()
rows = (query.order_by(USBDevice.serialnumber)
.limit(per_page).offset((page - 1) * per_page).all())
return paginated_response([_device_dict(d) for d in rows], page, per_page, total)
def get_device(device_id):
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404)
result = _device_dict(device)
checkouts = (USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid)
.order_by(USBCheckout.checkouttime.desc()).limit(20).all())
logs = []
for checkout in checkouts:
logs.extend(_checkout_events(checkout))
logs.sort(key=lambda item: item['timestamp'] or '', reverse=True)
result['checkinoutlog'] = logs[:20]
return success_response(result)
def create_device(data):
device_id = (data.get('device_id') or '').strip()
if not device_id:
return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required')
if _find(device_id):
return error_response(ErrorCodes.CONFLICT, 'device_id already exists', http_code=409)
device = USBDevice(serialnumber=device_id, label=data.get('device_desc'),
storagelocation=data.get('locker_location'),
ischeckedout=False, isactive=True)
db.session.add(device)
apply_import_timestamps(device, data)
db.session.commit()
return success_response(_device_dict(device), message='Device created', http_code=201)
def update_device(device_id, data):
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404)
if 'device_desc' in data:
device.label = data['device_desc']
if 'locker_location' in data:
device.storagelocation = data['locker_location']
if 'status' in data:
if data['status'] == STATUS_RETIRED:
device.isactive = False
elif data['status'] in (STATUS_CHECKED_IN, STATUS_CHECKED_OUT):
device.isactive = True
device.ischeckedout = data['status'] == STATUS_CHECKED_OUT
db.session.commit()
return success_response(_device_dict(device), message='Device updated')
def retire_device(device_id):
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404)
device.isactive = False
db.session.commit()
return success_response(_device_dict(device), message='Device retired')
def checkout_device(device_id, data):
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required')
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404)
if device.ischeckedout:
return error_response(ErrorCodes.CONFLICT, 'Device is already checked out', http_code=409)
name = _resolve_name(badge)
now = datetime.now(timezone.utc).replace(tzinfo=None)
# Backdated import: an admin import request may pass the historical
# checkouttime so migrated usbcheckouts rows keep their real event time.
eventtime = now
if import_mode_active():
override = parse_import_datetime(data.get('checkouttime'))
if override is not None:
eventtime = override
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge,
checkoutname=name, checkouttime=eventtime,
checkoutreason=data.get('reason')))
device.ischeckedout = True
device.currentuserid = badge
device.currentusername = name
device.currentcheckoutdate = eventtime
if data.get('locker_location'):
device.storagelocation = data['locker_location']
db.session.commit()
return success_response(_device_dict(device), message='Device checked out', http_code=201)
def checkin_device(device_id, data):
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required')
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404)
if not device.ischeckedout:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Device is not checked out')
open_checkout = (USBCheckout.query
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
.order_by(USBCheckout.checkouttime.desc()).first())
if open_checkout:
checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
# Backdated import: accept the historical checkintime from an admin
# import request so returned checkouts keep their real return time.
if import_mode_active():
override = parse_import_datetime(data.get('checkintime'))
if override is not None:
checkintime = override
open_checkout.checkintime = checkintime
open_checkout.waswiped = bool(data.get('sanitized'))
open_checkout.checkinnotes = data.get('notes')
device.ischeckedout = False
device.currentuserid = None
device.currentusername = None
device.currentcheckoutdate = None
if data.get('locker_location'):
device.storagelocation = data['locker_location']
db.session.commit()
return success_response(_device_dict(device), message='Device checked in')
def device_history(device_id, request):
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404)
page, per_page = get_pagination_params(request)
checkouts = (USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid)
.order_by(USBCheckout.checkouttime.desc()).all())
logs = []
for checkout in checkouts:
logs.extend(_checkout_events(checkout))
logs.sort(key=lambda item: item['timestamp'] or '', reverse=True)
total = len(logs)
start = (page - 1) * per_page
return paginated_response(logs[start:start + per_page], page, per_page, total)
def list_checkouts(request, active_only):
query = USBCheckout.query
if active_only:
query = query.filter_by(checkintime=None)
badge = request.args.get('badge', '').strip()
if badge:
query = query.filter_by(sso=badge)
rows = query.order_by(USBCheckout.checkouttime.desc()).all()
data = []
for checkout in rows:
event = _checkout_events(checkout)[0]
event['device_status'] = _status(checkout.device) if checkout.device else None
data.append(event)
return success_response(data)

11
plugins/usb/manifest.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "usb",
"display_name": "USB",
"version": "1.0.0",
"description": "USB device checkout management",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/usb",
"default_enabled": false
}

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the usb plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_usb. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'usb'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""usb plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_usb. From this
anchor forward, new usb schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'usb0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,42 @@
"""Drop the dead usbcheckouts.machineid column (ADR-001: Machine retired).
machineid was a NOT NULL soft-reference to the retired machines table; every
new checkout stored the sentinel 0. It has no FK, no meaning under the asset
model, and no reader. Drop it.
Idempotent (skips a missing table/column). Downgrade restores it NOT NULL with
server_default 0 so any existing rows stay valid.
Revision ID: usb0002dropmachineid
Revises: usb0001anchor
"""
from alembic import op
import sqlalchemy as sa
revision = 'usb0002dropmachineid'
down_revision = 'usb0001anchor'
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 'machineid' in cols:
op.drop_column('usbcheckouts', 'machineid')
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 'machineid' not in cols:
op.add_column('usbcheckouts',
sa.Column('machineid', sa.Integer(), nullable=False,
server_default='0'))

View File

@@ -0,0 +1,5 @@
"""USB plugin models."""
from .usb_device import USBDevice, USBDeviceType, USBCheckout
__all__ = ['USBDevice', 'USBDeviceType', 'USBCheckout']

View File

@@ -0,0 +1,167 @@
"""USB device plugin models."""
from datetime import datetime, timezone
from shopdb.api import db, BaseModel, AuditMixin
def _utcnow():
# naive UTC for DB columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
class USBDeviceType(BaseModel):
"""
USB device type classification.
Examples: Flash Drive, External HDD, External SSD, Card Reader
"""
__tablename__ = 'usbdevicetypes'
usbdevicetypeid = db.Column(db.Integer, primary_key=True)
typename = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
def __repr__(self):
return f"<USBDeviceType {self.typename}>"
class USBDevice(BaseModel, AuditMixin):
"""
USB device model.
Tracks USB storage devices that can be checked out by users.
"""
__tablename__ = 'usbdevices'
usbdeviceid = db.Column(db.Integer, primary_key=True)
# Identification
serialnumber = db.Column(db.String(100), unique=True, nullable=False)
label = db.Column(db.String(100), nullable=True, comment='Human-readable label')
assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
# Classification
usbdevicetypeid = db.Column(
db.Integer,
db.ForeignKey('usbdevicetypes.usbdevicetypeid'),
nullable=True
)
# Specifications
capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB')
vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)')
productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)')
manufacturer = db.Column(db.String(100), nullable=True)
productname = db.Column(db.String(100), nullable=True)
# Current status
ischeckedout = db.Column(db.Boolean, default=False)
currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user')
currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user')
currentcheckoutdate = db.Column(db.DateTime, nullable=True)
# Location
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
# Security
pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
# Notes
notes = db.Column(db.Text, nullable=True)
# Relationships
devicetype = db.relationship('USBDeviceType', backref='devices')
# Indexes
__table_args__ = (
db.Index('idx_usb_checkedout', 'ischeckedout'),
db.Index('idx_usb_type', 'usbdevicetypeid'),
db.Index('idx_usb_currentuser', 'currentuserid'),
)
def __repr__(self):
return f"<USBDevice {self.label or self.serialnumber}>"
@property
def display_name(self):
"""Get display name (label if set, otherwise serial number)."""
return self.label or self.serialnumber
def to_dict(self):
"""Convert to dictionary with related data."""
result = super().to_dict()
# Add type info
if self.devicetype:
result['typename'] = self.devicetype.typename
result['typeicon'] = self.devicetype.icon
# Add computed property
result['displayname'] = self.display_name
return result
class USBCheckout(BaseModel):
"""
USB device checkout history.
Tracks when devices are checked out and returned.
Maps to existing usbcheckouts table from classic ShopDB.
"""
__tablename__ = 'usbcheckouts'
checkoutid = db.Column(db.Integer, primary_key=True)
# Device reference (new column linking to usbdevices table)
usbdeviceid = db.Column(
db.Integer,
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
nullable=True
)
# User info
sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details
checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow)
checkintime = db.Column(db.DateTime, nullable=True)
# Metadata
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')
# Relationships
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
def __repr__(self):
return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
@property
def is_active(self):
"""Check if this checkout is currently active (not returned)."""
return self.checkintime is None
@property
def duration_days(self):
"""Get duration of checkout in days."""
end = self.checkintime or datetime.now(timezone.utc).replace(tzinfo=None)
delta = end - self.checkouttime
return delta.days
def to_dict(self):
"""Convert to dictionary with computed fields."""
result = super().to_dict()
result['isactivecheckout'] = self.is_active
result['durationdays'] = self.duration_days
# Add device info if loaded
if self.device:
result['devicelabel'] = self.device.label
result['deviceserialnumber'] = self.device.serialnumber
return result

120
plugins/usb/plugin.py Normal file
View File

@@ -0,0 +1,120 @@
"""USB plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .models import USBDevice, USBDeviceType, USBCheckout
from .api import usb_bp
logger = logging.getLogger(__name__)
class USBPlugin(BasePlugin):
"""
USB plugin - manages USB device tracking and checkouts.
Standalone plugin for tracking USB flash drives, external drives,
and other portable storage devices with checkout/checkin functionality.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'usb'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'USB device checkout management'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/usb'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return usb_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [USBDeviceType, USBDevice, USBCheckout]
def get_provisioning_note(self) -> Optional[Dict]:
return {
'mode_setting': 'usb_directory_mode',
'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'],
'note': ('Enabling this creates USB tracking tables in the shopdb '
'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC '
'removable-media check-in/out. Devices, check-in/out events, '
'and per-event log ids live here. Planned build-out: a USB-ID '
'standard, overdue-device email reminders, and DLP/Fabric '
'approval integration (see the captured design notes). A site '
'with an existing cmmc_usb database can use external mode '
'instead.'),
'docs': 'plugins/usb/README.md',
}
def get_config_schema(self) -> List[Dict]:
"""CMMC USB check-in/out database connection. Host/name/user are settings
the wizard can edit; the password stays in .env (emitted, not stored)."""
return [
{'key': 'cmmc_usb_db_host', 'label': 'USB DB host', 'type': 'text',
'secret': False, 'default': 'localhost',
'help': 'This DB must expose devices / checkinoutlog / users tables '
'(or views). See plugins/usb/README.md.'},
{'key': 'cmmc_usb_db_name', 'label': 'USB DB name', 'type': 'text',
'secret': False, 'default': 'cmmc_usb'},
{'key': 'cmmc_usb_db_user', 'label': 'USB DB user', 'type': 'text',
'secret': False},
{'key': 'cmmc_usb_db_password', 'label': 'USB DB password', 'type': 'password',
'secret': True, 'envvar': 'CMMC_USB_DB_PASSWORD',
'help': 'Stored in .env, not the database. The wizard shows the line to paste.'},
]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"USB plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
logger.info("USB plugin installed")
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("USB plugin uninstalled")
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'USB Devices',
'icon': 'usb',
'route': '/usb',
'position': 45,
},
]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('usb.view', 'View USB devices', 'usb'),
('usb.create', 'Create USB devices', 'usb'),
('usb.edit', 'Edit USB devices', 'usb'),
('usb.delete', 'Delete USB devices', 'usb'),
]