Add custom fields + warranty plugin, rework settings into two-pane shell

Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -1,58 +1,242 @@
"""USB plugin API endpoints."""
"""USB plugin API endpoints.
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, get_jwt_identity
Check-in/out state lives in a separate MySQL database (cmmc_usb), reached with
raw parameterized pymysql via cmmc_usb_connection(), mirroring the read-only
employee-directory helper pattern. The SQLAlchemy USBDevice/USBCheckout/
USBDeviceType models in ../models are NOT used by these routes anymore; they
are left in place for any legacy importers.
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 shopdb.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from ..models import USBDevice, USBDeviceType, USBCheckout
from shopdb.api import (
cmmc_usb_connection,
employee_connection,
success_response,
error_response,
paginated_response,
ErrorCodes,
get_pagination_params,
require_permission,
)
logger = logging.getLogger(__name__)
usb_bp = Blueprint('usb', __name__)
# 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)
# =============================================================================
# USB Device Types
# Module helpers
# =============================================================================
@usb_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_device_types():
"""List all USB device types."""
types = USBDeviceType.query.filter_by(isactive=True).order_by(USBDeviceType.typename).all()
return success_response([{
'usbdevicetypeid': t.usbdevicetypeid,
'typename': t.typename,
'description': t.description,
'icon': t.icon
} for t in types])
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')
@usb_bp.route('/types', methods=['POST'])
@jwt_required()
def create_device_type():
"""Create a new USB device type."""
data = request.get_json() or {}
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
if not data.get('typename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required')
if USBDeviceType.query.filter_by(typename=data['typename']).first():
return error_response(ErrorCodes.CONFLICT, 'Type name already exists', http_code=409)
def _lookup_employee_name(badge):
"""Look up "First Last" in the HR directory for a badge. Empty on any miss.
device_type = USBDeviceType(
typename=data['typename'],
description=data.get('description'),
icon=data.get('icon', 'usb')
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),
)
db.session.add(device_type)
db.session.commit()
return success_response({
'usbdevicetypeid': device_type.usbdevicetypeid,
'typename': device_type.typename
}, message='Device type created', http_code=201)
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
# =============================================================================
@@ -62,306 +246,356 @@ def create_device_type():
@usb_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_usb_devices():
"""
List all USB devices with checkout status.
"""List USB devices with checkout status.
Query parameters:
- page, per_page: Pagination
- search: Search by serial number, label, or asset number
- available: Filter to only available (not checked out) devices
- typeid: Filter by device type ID
- page, per_page: pagination
- status: available | checkedout | retired
- search: match on device_id or device_desc
"""
page, per_page = get_pagination_params(request)
query = USBDevice.query.filter_by(isactive=True)
where = []
params = []
# Filter by type
if type_id := request.args.get('typeid'):
query = query.filter_by(usbdevicetypeid=int(type_id))
status_arg = request.args.get('status', '').strip().lower()
if status_arg in _STATUS_FILTER:
where.append('status = %s')
params.append(_STATUS_FILTER[status_arg])
# Filter by checkout status
if request.args.get('available', '').lower() == 'true':
query = query.filter_by(ischeckedout=False)
elif request.args.get('checkedout', '').lower() == 'true':
query = query.filter_by(ischeckedout=True)
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])
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
USBDevice.serialnumber.ilike(f'%{search}%'),
USBDevice.label.ilike(f'%{search}%'),
USBDevice.assetnumber.ilike(f'%{search}%'),
USBDevice.manufacturer.ilike(f'%{search}%')
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],
)
)
query = query.order_by(USBDevice.label, USBDevice.serialnumber)
items, total = paginate_query(query, page, per_page)
data = [device.to_dict() for device in items]
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('', methods=['POST'])
@jwt_required()
def create_usb_device():
"""Create a new USB device."""
data = request.get_json() or {}
if not data.get('serialnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'serialnumber is required')
if USBDevice.query.filter_by(serialnumber=data['serialnumber']).first():
return error_response(ErrorCodes.CONFLICT, 'Serial number already exists', http_code=409)
device = USBDevice(
serialnumber=data['serialnumber'],
label=data.get('label'),
assetnumber=data.get('assetnumber'),
usbdevicetypeid=data.get('usbdevicetypeid'),
capacitygb=data.get('capacitygb'),
vendorid=data.get('vendorid'),
productid=data.get('productid'),
manufacturer=data.get('manufacturer'),
productname=data.get('productname'),
storagelocation=data.get('storagelocation'),
pin=data.get('pin'),
notes=data.get('notes'),
ischeckedout=False
)
db.session.add(device)
db.session.flush()
AuditLog.log('created', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber)
db.session.commit()
return success_response(device.to_dict(), message='Device created', http_code=201)
@usb_bp.route('/<int:device_id>', methods=['GET'])
@usb_bp.route('/<device_id>', methods=['GET'])
@jwt_required(optional=True)
def get_usb_device(device_id: int):
"""Get a single USB device with checkout history."""
device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
def get_usb_device(device_id):
"""Get a single device plus its last 20 check-in/out log rows."""
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,
)
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
result = _device_to_dict(cur, row)
# Get recent checkout history
checkouts = USBCheckout.query.filter_by(
usbdeviceid=device_id
).order_by(USBCheckout.checkouttime.desc()).limit(20).all()
result = device.to_dict()
result['checkouthistory'] = [c.to_dict() for c in checkouts]
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('/<int:device_id>', methods=['PUT'])
@usb_bp.route('', methods=['POST'])
@jwt_required()
def update_usb_device(device_id: int):
"""Update a USB device."""
device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
@require_permission('usb.create')
def create_usb_device():
"""Add a device (starts checked-in)."""
data = request.get_json() or {}
# Track changes for audit log
changes = {}
for field in ['label', 'assetnumber', 'usbdevicetypeid', 'capacitygb',
'vendorid', 'productid', 'manufacturer', 'productname',
'storagelocation', 'pin', 'notes']:
if field in data:
old_val = getattr(device, field)
new_val = data[field]
if old_val != new_val:
changes[field] = {'old': old_val, 'new': new_val}
setattr(device, field, data[field])
device_id = (data.get('device_id') or '').strip()
if not device_id:
return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required')
if changes:
AuditLog.log('updated', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber, changes=changes)
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
)
device.modifieddate = datetime.utcnow()
db.session.commit()
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()
return success_response(device.to_dict(), message='Device updated')
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('/<int:device_id>', methods=['DELETE'])
@usb_bp.route('/<device_id>', methods=['PUT'])
@jwt_required()
def delete_usb_device(device_id: int):
"""Soft delete a USB device."""
device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
if device.ischeckedout:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Cannot delete a device that is currently checked out',
http_code=400
)
device.isactive = False
device.modifieddate = datetime.utcnow()
AuditLog.log('deleted', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber)
db.session.commit()
return success_response(None, message='Device deleted')
# =============================================================================
# Checkout/Checkin Operations
# =============================================================================
@usb_bp.route('/<int:device_id>/checkout', methods=['POST'])
@jwt_required()
def checkout_device(device_id: int):
"""Check out a USB device."""
device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
if device.ischeckedout:
return error_response(
ErrorCodes.CONFLICT,
f'Device is already checked out to {device.currentusername or device.currentuserid}',
http_code=409
)
@require_permission('usb.edit')
def update_usb_device(device_id):
"""Edit device_desc / device_owner / locker_location / status."""
data = request.get_json() or {}
if not data.get('sso'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso is required')
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,
)
# Create checkout record
checkout = USBCheckout(
usbdeviceid=device_id,
machineid=0, # Legacy field, set to 0 for new checkouts
sso=data['sso'],
checkoutname=data.get('checkoutname'),
checkouttime=datetime.utcnow(),
checkoutreason=data.get('checkoutreason'),
waswiped=False
)
fields = []
params = []
for col in ('device_desc', 'device_owner', 'locker_location', 'status'):
if col in data:
fields.append(col + ' = %s')
params.append(data[col])
# Update device status
device.ischeckedout = True
device.currentuserid = data['sso']
device.currentusername = data.get('checkoutname')
device.currentcheckoutdate = datetime.utcnow()
device.modifieddate = datetime.utcnow()
if fields:
params.append(device_id)
cur.execute(
'UPDATE devices SET ' + ', '.join(fields) + ' WHERE device_id = %s',
params,
)
conn.commit()
db.session.add(checkout)
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
result = _device_to_dict(cur, row)
finally:
conn.close()
AuditLog.log('checked_out', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber,
changes={'checked_out_to': data['sso'], 'reason': data.get('checkoutreason')})
db.session.commit()
return success_response(checkout.to_dict(), message='Device checked out', http_code=201)
return success_response(result, message='Device updated')
@usb_bp.route('/<int:device_id>/checkin', methods=['POST'])
@usb_bp.route('/<device_id>/retire', methods=['POST'])
@jwt_required()
def checkin_device(device_id: int):
"""Check in a USB device."""
device = USBDevice.query.filter_by(usbdeviceid=device_id, isactive=True).first()
@require_permission('usb.edit')
def retire_usb_device(device_id):
"""Retire a device (status -> retired)."""
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()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
with conn.cursor() as cur:
row = _fetch_device(cur, device_id)
result = _device_to_dict(cur, row)
finally:
conn.close()
if not device.ischeckedout:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Device is not currently checked out',
http_code=400
)
return success_response(result, message='Device retired')
# Find active checkout
active_checkout = USBCheckout.query.filter_by(
usbdeviceid=device_id,
checkintime=None
).first()
# =============================================================================
# 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."""
data = request.get_json() or {}
if active_checkout:
active_checkout.checkintime = datetime.utcnow()
active_checkout.checkinnotes = data.get('checkinnotes', active_checkout.checkinnotes)
active_checkout.waswiped = data.get('waswiped', False)
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required')
# Update device status
previous_user = device.currentuserid
device.ischeckedout = False
device.currentuserid = None
device.currentusername = None
device.currentcheckoutdate = None
device.modifieddate = datetime.utcnow()
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,
)
AuditLog.log('checked_in', 'USBDevice', entityid=device.usbdeviceid,
entityname=device.label or device.serialnumber,
changes={'returned_by': previous_user, 'wiped': data.get('waswiped', False)})
name = _resolve_badge_name(cur, badge)
_ensure_user(cur, badge, name)
db.session.commit()
locker = data.get('locker_location')
log_locker = locker if locker is not None else device.get('locker_location')
return success_response(
active_checkout.to_dict() if active_checkout else None,
message='Device checked in'
)
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."""
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')
# =============================================================================
# Checkout History
# Check-in/out log queries
# =============================================================================
@usb_bp.route('/<int:device_id>/history', methods=['GET'])
@usb_bp.route('/<device_id>/history', methods=['GET'])
@jwt_required(optional=True)
def get_device_history(device_id: int):
"""Get checkout history for a USB device."""
def get_device_history(device_id):
"""Paginated check-in/out log for one device."""
page, per_page = get_pagination_params(request)
query = USBCheckout.query.filter_by(
usbdeviceid=device_id
).order_by(USBCheckout.checkouttime.desc())
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']
items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items]
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)
@@ -369,29 +603,48 @@ def get_device_history(device_id: int):
@usb_bp.route('/checkouts', methods=['GET'])
@jwt_required(optional=True)
def list_all_checkouts():
"""
List all checkouts (active and historical).
"""List check-out log rows.
Query parameters:
- active: Filter to only active (not returned) checkouts
- sso: Filter by user SSO
- active: true -> only rows whose device is currently checked out
- badge: filter by badge_number
"""
page, per_page = get_pagination_params(request)
query = USBCheckout.query
where = ["l.action = 'check-out'"]
params = []
# Filter by active only
if request.args.get('active', '').lower() == 'true':
query = query.filter(USBCheckout.checkintime == None)
where.append("d.status = 'checked-out'")
# Filter by user
if sso := request.args.get('sso'):
query = query.filter(USBCheckout.sso == sso)
badge = request.args.get('badge', '').strip()
if badge:
where.append('l.badge_number = %s')
params.append(badge)
query = query.order_by(USBCheckout.checkouttime.desc())
where_sql = ' WHERE ' + ' AND '.join(where)
base = (
'FROM checkinoutlog l JOIN devices d ON l.device_id = d.device_id'
+ where_sql
)
items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items]
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)
@@ -399,9 +652,27 @@ def list_all_checkouts():
@usb_bp.route('/checkouts/active', methods=['GET'])
@jwt_required(optional=True)
def list_active_checkouts():
"""List all currently active checkouts."""
checkouts = USBCheckout.query.filter(
USBCheckout.checkintime == None
).order_by(USBCheckout.checkouttime.desc()).all()
"""List the latest check-out log row for every currently checked-out device."""
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' "
'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',
)
rows = cur.fetchall()
data = [_log_to_dict(cur, row) for row in rows]
finally:
conn.close()
return success_response([c.to_dict() for c in checkouts])
return success_response(data)