"""USB plugin API endpoints. 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 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, ) 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) # ============================================================================= # 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 0BZ 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 """ 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('/', 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.""" 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).""" 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('/', methods=['PUT']) @jwt_required() @require_permission('usb.edit') def update_usb_device(device_id): """Edit device_desc / device_owner / locker_location / status.""" 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('//retire', methods=['POST']) @jwt_required() @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() 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('//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 {} 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('//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') # ============================================================================= # Check-in/out log queries # ============================================================================= @usb_bp.route('//history', methods=['GET']) @jwt_required(optional=True) def get_device_history(device_id): """Paginated check-in/out log for one device.""" 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 """ 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.""" 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(data)