"""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, timedelta import hmac from flask import Blueprint, request, Response 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 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']) @require_permission('usb.view') 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('/', methods=['GET']) @require_permission('usb.view') 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']) @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('/', methods=['PUT']) @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('//retire', methods=['POST']) @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('//checkout', methods=['POST']) @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('//checkin', methods=['POST']) @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('//history', methods=['GET']) @require_permission('usb.view') 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']) @require_permission('usb.view') 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']) @require_permission('usb.view') 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) # ============================================================================= # Kiosk: UNAUTHENTICATED check-out / check-in # ============================================================================= # The bar these have to meet, same as the parts kiosk: they can move a device # between checked-out and checked-in and NOTHING else. No create, no edit, no # retire, no browsing the fleet, no reading who holds what without presenting # the device. Identity is the scanned badge, resolved server-side; the client # never asserts who it is. # # Off unless a site turns it on. An unauthenticated write is a decision about # the door, not about the person walking through it. def _kiosk_setting(key, default=False): row = Setting.query.filter_by(key=key).first() if row is None or row.value is None or row.value == '': return default return str(row.value).strip().lower() in ('true', '1', 'yes', 'on') def _kiosk_enabled(): return _kiosk_setting('usb_kiosk_enabled', False) def _kiosk_open(): """True when the kiosk is on for this site AND the caller has its token. The flag alone was the whole gate, and a flag is a decision, not a credential: with the kiosk on, anyone who could reach the app could move a device between checked-out and checked-in and have it recorded against a badge they guessed. A badge is a short numeric SSO, so it is attribution, never authorization - the same reasoning that closed the parts kiosk. The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so a token served inside the page would be readable by anyone who could load it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps it in that browser and sends it as X-Kiosk-Token. """ if not _kiosk_enabled(): return False expected = (Setting.get('usb_kiosk_token') or '').strip() presented = (request.headers.get('X-Kiosk-Token') or '').strip() if not expected or not presented: return False # compare_digest, not ==: == leaks the token's length and matching prefix # through timing. return hmac.compare_digest(presented, expected) def _kiosk_disabled_response(): # 404, not 403: when the kiosk is off these routes should not read as # "there is a door here and you may not use it". return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404) def _kiosk_badge(data): """The scanned badge, or an error response. Never trusts a client name.""" badge = (data.get('badge') or '').strip() if not badge: return None, error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') return badge, None @usb_bp.route('/kiosk/device/', methods=['GET']) def kiosk_device(device_id): """What the kiosk shows after a device is scanned. Deliberately narrow: the description, whether it is out, and the NAME of whoever holds it - which the return-for-someone-else confirm has to show, or the person standing there cannot tell whether they are doing the right thing. No badge number, no history, no contact details. """ if not _kiosk_open(): return _kiosk_disabled_response() if _usb_selfhosted(): return selfhosted.kiosk_device(device_id) 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, 'No device matches that code', http_code=404) holder = '' if device.get('status') == _STATUS_CHECKED_OUT: holder = _kiosk_holder_name(cur, device_id) finally: conn.close() return success_response({ 'device_id': device.get('device_id'), 'device_desc': device.get('device_desc'), 'status': device.get('status'), 'locker_location': device.get('locker_location'), 'holdername': holder, }) def _kiosk_holder_badge(cur, device_id): """Badge on the most recent check-out for this device, or ''.""" cur.execute( 'SELECT badge_number FROM checkinoutlog ' "WHERE device_id = %s AND action = 'check-out' " 'ORDER BY timestamp DESC, log_id DESC LIMIT 1', (device_id,), ) row = cur.fetchone() return (row.get('badge_number') or '').strip() if row else '' def _kiosk_holder_name(cur, device_id): badge = _kiosk_holder_badge(cur, device_id) return _resolve_badge_name(cur, badge) if badge else '' @usb_bp.route('/kiosk/session', methods=['POST']) def kiosk_session(): """What one badge may do right now. Body: {badge}. The drives here carry no barcode, so the kiosk cannot ask somebody to present one - it has to offer a LIST. That is a real widening: before, you had to already hold a drive to learn anything. So the list is not anonymous. It costs a badge that resolves to a real person, which is the same bar check-out itself has to clear, and it is a POST so the badge does not end up in a proxy access log the way a query string would. Returns the caller's own drives (to return), what is free (to take), and a COUNT of everything else - names for those are a separate, deliberate step, so a passer-by does not read the whole cabinet's holders off a wall screen. """ if not _kiosk_open(): return _kiosk_disabled_response() data = request.get_json() or {} if _usb_selfhosted(): return selfhosted.kiosk_session(data) badge, failure = _kiosk_badge(data) if failure: return failure conn = cmmc_usb_connection() try: with conn.cursor() as cur: name = _resolve_badge_name(cur, badge) if not name: return error_response( ErrorCodes.VALIDATION_ERROR, 'Badge not recognized - see IT', http_code=422) cur.execute( 'SELECT ' + _DEVICE_COLS + ' FROM devices ' 'WHERE status IN (%s, %s) ORDER BY device_id', (_STATUS_CHECKED_IN, _STATUS_CHECKED_OUT), ) rows = cur.fetchall() or [] yours, available, others = [], [], 0 for row in rows: entry = { 'device_id': row.get('device_id'), 'device_desc': row.get('device_desc'), 'locker_location': row.get('locker_location'), } if row.get('status') == _STATUS_CHECKED_IN: available.append(entry) elif _kiosk_holder_badge(cur, row.get('device_id')) == badge: yours.append(entry) else: others += 1 finally: conn.close() return success_response({'name': name, 'yours': yours, 'available': available, 'otherscount': others}) @usb_bp.route('/kiosk/others', methods=['POST']) def kiosk_others(): """Drives checked out to OTHER people, with holder names. Body: {badge}. Behind its own tap, and only when returning for somebody else is allowed at all. Somebody has to be able to put back a drive left on a bench by a person who is on leave - but the names that makes visible should not be the first thing on a shopfloor screen. """ if not _kiosk_open(): return _kiosk_disabled_response() if not _kiosk_setting('usb_kiosk_allow_return_for_others', True): return _kiosk_disabled_response() data = request.get_json() or {} if _usb_selfhosted(): return selfhosted.kiosk_others(data) badge, failure = _kiosk_badge(data) if failure: return failure conn = cmmc_usb_connection() try: with conn.cursor() as cur: if not _resolve_badge_name(cur, badge): return error_response(ErrorCodes.VALIDATION_ERROR, 'Badge not recognized - see IT', http_code=422) cur.execute( 'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE status = %s ' 'ORDER BY device_id', (_STATUS_CHECKED_OUT,), ) out = [] for row in cur.fetchall() or []: holderbadge = _kiosk_holder_badge(cur, row.get('device_id')) if holderbadge == badge: continue out.append({ 'device_id': row.get('device_id'), 'device_desc': row.get('device_desc'), 'locker_location': row.get('locker_location'), 'holdername': _resolve_badge_name(cur, holderbadge), }) finally: conn.close() return success_response(out) @usb_bp.route('/kiosk/checkout', methods=['POST']) def kiosk_checkout(): """Check a device out at the kiosk. Body: {device_id, badge}.""" if not _kiosk_open(): return _kiosk_disabled_response() data = request.get_json() or {} if _usb_selfhosted(): return selfhosted.kiosk_checkout(data) device_id = (data.get('device_id') or '').strip() badge, failure = _kiosk_badge(data) if failure: return failure 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, 'No device matches that code', http_code=404) if device.get('status') == _STATUS_RETIRED: return error_response(ErrorCodes.VALIDATION_ERROR, 'That device is retired - see IT') if device.get('status') == _STATUS_CHECKED_OUT: holder = _kiosk_holder_name(cur, device_id) return error_response( ErrorCodes.CONFLICT, 'That device is already checked out{}'.format( ' to {}'.format(holder) if holder else ''), http_code=409) # An unresolvable badge is REFUSED here, unlike the signed-in route # which creates the user row. A checkout nobody can trace to a # person is the one thing this tracking exists to prevent, and at a # kiosk there is no signed-in operator to vouch for it. name = _resolve_badge_name(cur, badge) if not name: return error_response( ErrorCodes.VALIDATION_ERROR, 'Badge not recognized - see IT to check this device out', http_code=422) # Claim the device FIRST, and let the status transition be the # guard: the read-then-write above cannot stop two scans of the # same drive that interleave, because both read 'available'. With # the status in the WHERE clause the second UPDATE matches no row # and there is no log entry to undo. cur.execute( 'UPDATE devices SET status = %s ' 'WHERE device_id = %s AND status <> %s', (_STATUS_CHECKED_OUT, device_id, _STATUS_CHECKED_OUT), ) if not cur.rowcount: holder = _kiosk_holder_name(cur, device_id) conn.rollback() return error_response( ErrorCodes.CONFLICT, 'That device is already checked out{}'.format( ' to {}'.format(holder) if holder else ''), http_code=409) cur.execute( 'INSERT INTO checkinoutlog ' '(badge_number, device_id, action, timestamp, locker_location) ' "VALUES (%s, %s, 'check-out', %s, %s)", (badge, device_id, _now(), device.get('locker_location')), ) conn.commit() finally: conn.close() return success_response({'device_id': device_id, 'holdername': name}, message='{} checked out to {}'.format( device_id, name)) @usb_bp.route('/kiosk/checkin', methods=['POST']) def kiosk_checkin(): """Check a device in at the kiosk. Body: {device_id, badge, forsomeoneelse}. Holder by default. A mismatch is refused with the holder's name and a flag the screen uses to offer "returning it for them", because the alternative - a drive whose owner is on leave that literally cannot be returned at the kiosk - just moves the problem to somebody with a login. The badge RECORDED is the one that was scanned: the log says who actually handed the device back, which is the honest record and needs no change to a schema this app does not own. """ if not _kiosk_open(): return _kiosk_disabled_response() data = request.get_json() or {} if _usb_selfhosted(): return selfhosted.kiosk_checkin(data) device_id = (data.get('device_id') or '').strip() badge, failure = _kiosk_badge(data) if failure: return failure forsomeoneelse = bool(data.get('forsomeoneelse')) 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, 'No device matches that code', http_code=404) if device.get('status') != _STATUS_CHECKED_OUT: return error_response(ErrorCodes.VALIDATION_ERROR, 'That device is not checked out') name = _resolve_badge_name(cur, badge) if not name: return error_response( ErrorCodes.VALIDATION_ERROR, 'Badge not recognized - see IT to return this device', http_code=422) holderbadge = _kiosk_holder_badge(cur, device_id) if holderbadge and holderbadge != badge: if not _kiosk_setting('usb_kiosk_allow_return_for_others', True): return error_response( ErrorCodes.FORBIDDEN, 'That device is checked out to somebody else - they ' 'need to return it, or see IT', http_code=403) if not forsomeoneelse: holdername = _resolve_badge_name(cur, holderbadge) return error_response( ErrorCodes.CONFLICT, 'That device is checked out to {}. Confirm you are ' 'returning it for them.'.format(holdername or 'somebody else'), details={'needsconfirm': True, 'holdername': holdername}, http_code=409) cur.execute( 'INSERT INTO checkinoutlog ' '(badge_number, device_id, action, timestamp, locker_location) ' "VALUES (%s, %s, 'check-in', %s, %s)", (badge, device_id, _now(), device.get('locker_location')), ) cur.execute( 'UPDATE devices SET status = %s WHERE device_id = %s', (_STATUS_CHECKED_IN, device_id), ) conn.commit() finally: conn.close() return success_response({'device_id': device_id}, message='{} checked in'.format(device_id)) # ============================================================================= # Reports # ============================================================================= # CMMC removable-media tracking is an audit obligation, and until now the only # way to answer "who had which drive, when" was to read the check-in/out log # page by page. This is the same question over a window: a day for "what # happened this shift", a month or six for a periodic review, a year or all time # for an audit. # The windows the UI offers, in days. 0 means everything ever recorded. AUDITPERIODS = {'day': 1, 'month': 30, 'sixmonths': 182, 'year': 365, 'all': 0} def _auditdays(): """Days of history to report. Accepts ?period= or a raw ?days=.""" period = (request.args.get('period') or '').strip().lower() if period in AUDITPERIODS: return AUDITPERIODS[period] days = request.args.get('days', type=int) return max(0, days) if days is not None else 30 def _auditcsv(rows, columns, filename): """CSV download; generate_csv is not on the contract surface.""" import csv import io output = io.StringIO() writer = csv.writer(output) writer.writerow(columns) for row in rows: writer.writerow([row.get(column, '') for column in columns]) return Response(output.getvalue(), mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename={}'.format(filename)}) AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge', 'name', 'locker_location'] @usb_bp.route('/reports/audit', methods=['GET']) @require_permission('usb.view') def report_audit(): """Every check-out and check-in in the window, newest first. Rows, not just totals: an audit asks who had a specific drive on a specific day, and a count cannot answer that. Summary figures ride along for the people who only want the shape of it. """ days = _auditdays() if _usb_selfhosted(): rows = selfhosted.audit_rows(days) return _auditresponse(rows, days) cutoff = None if days > 0: cutoff = (datetime.utcnow() - timedelta(days=days)).strftime( '%Y-%m-%d %H:%M:%S') conn = cmmc_usb_connection() try: with conn.cursor() as cur: sql = ('SELECT l.timestamp, l.action, l.device_id, l.badge_number, ' 'l.locker_location, d.device_desc, ' 'u.first_name, u.last_name ' 'FROM checkinoutlog l ' 'LEFT JOIN devices d ON d.device_id = l.device_id ' 'LEFT JOIN users u ON u.badge_number = l.badge_number ') params = [] if cutoff: sql += 'WHERE l.timestamp >= %s ' params.append(cutoff) # A total order: timestamp alone ties for two events in the same # second and the same query then returns them in a different order # each run, which reads as the log being unstable. sql += 'ORDER BY l.timestamp DESC, l.log_id DESC' cur.execute(sql, tuple(params)) rows = [] for row in cur.fetchall() or []: name = '{} {}'.format(row.get('first_name') or '', row.get('last_name') or '').strip() stamp = row.get('timestamp') rows.append({ 'timestamp': stamp.isoformat() if hasattr(stamp, 'isoformat') else (stamp or ''), 'action': row.get('action') or '', 'device_id': row.get('device_id') or '', 'device_desc': row.get('device_desc') or '', 'badge': row.get('badge_number') or '', 'name': name, 'locker_location': row.get('locker_location') or '', }) finally: conn.close() return _auditresponse(rows, days) def _auditresponse(rows, days): """One voice for both directory modes: same CSV, same JSON, same summary.""" if request.args.get('format') == 'csv': return _auditcsv(rows, AUDITCOLUMNS, 'usb-audit.csv') return success_response({ 'columns': AUDITCOLUMNS, 'rows': rows, 'days': days, 'summary': _auditsummary(rows), }) def _auditsummary(rows): checkouts = sum(1 for row in rows if row['action'] == 'check-out') return { 'events': len(rows), 'checkouts': checkouts, 'checkins': sum(1 for row in rows if row['action'] == 'check-in'), 'devices': len({row['device_id'] for row in rows if row['device_id']}), 'people': len({row['badge'] for row in rows if row['badge']}), }