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:
105
plugins/usb/README.md
Normal file
105
plugins/usb/README.md
Normal 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
5
plugins/usb/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""USB device checkout plugin."""
|
||||
|
||||
from .plugin import USBPlugin
|
||||
|
||||
__all__ = ['USBPlugin']
|
||||
5
plugins/usb/api/__init__.py
Normal file
5
plugins/usb/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""USB plugin API."""
|
||||
|
||||
from .routes import usb_bp
|
||||
|
||||
__all__ = ['usb_bp']
|
||||
723
plugins/usb/api/routes.py
Normal file
723
plugins/usb/api/routes.py
Normal 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)
|
||||
276
plugins/usb/api/selfhosted.py
Normal file
276
plugins/usb/api/selfhosted.py
Normal 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
11
plugins/usb/manifest.json
Normal 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
|
||||
}
|
||||
14
plugins/usb/migrations/env.py
Normal file
14
plugins/usb/migrations/env.py
Normal 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()
|
||||
24
plugins/usb/migrations/script.py.mako
Normal file
24
plugins/usb/migrations/script.py.mako
Normal 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"}
|
||||
28
plugins/usb/migrations/versions/0001_usb_anchor.py
Normal file
28
plugins/usb/migrations/versions/0001_usb_anchor.py
Normal 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
|
||||
@@ -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'))
|
||||
5
plugins/usb/models/__init__.py
Normal file
5
plugins/usb/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""USB plugin models."""
|
||||
|
||||
from .usb_device import USBDevice, USBDeviceType, USBCheckout
|
||||
|
||||
__all__ = ['USBDevice', 'USBDeviceType', 'USBCheckout']
|
||||
167
plugins/usb/models/usb_device.py
Normal file
167
plugins/usb/models/usb_device.py
Normal 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
120
plugins/usb/plugin.py
Normal 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'),
|
||||
]
|
||||
Reference in New Issue
Block a user