From 2326f8f09615d21f97f5d48ad36f507b8b35d837 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 08:19:03 -0400 Subject: [PATCH 01/12] USB kiosk: guest check-out/check-in without a login --- CHANGELOG.md | 33 +++ frontend/src/router/index.js | 1 + plugins/usb/api/routes.py | 347 ++++++++++++++++++++++++ plugins/usb/api/selfhosted.py | 178 ++++++++++++ plugins/usb/frontend/routes.js | 10 + plugins/usb/frontend/views/USBKiosk.vue | 327 ++++++++++++++++++++++ plugins/usb/plugin.py | 38 +++ tests/test_core/test_authz.py | 24 ++ tests/test_plugins/test_usb_kiosk.py | 278 +++++++++++++++++++ 9 files changed, 1236 insertions(+) create mode 100644 plugins/usb/frontend/views/USBKiosk.vue create mode 100644 tests/test_plugins/test_usb_kiosk.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af07bb30..5ff7f665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,39 @@ ADR-007 and ADR-002. ### Added +- **USB kiosk: check a drive out and back in without a login.** The people who + actually take USB drives are standing at the cabinet, and most of them have no + ShopDB account - so check-out and check-in were gated behind a login that was + not doing much anyway, since both modes already attribute the action to a + scanned BADGE rather than to the signed-in user. `/usb-kiosk` is a full-screen + touch screen modelled on the parts kiosk, and works against the self-hosted + tables or an existing `cmmc_usb` database. + + It meets the same bar as the parts kiosk: it can move a device between + checked-out and checked-in and nothing else - no create, edit or retire - and + it does not exist at all until a site sets `usb_kiosk_enabled`. An + unauthenticated write is a decision about the door. + + The drives carry no barcode, so unlike the parts kiosk there is nothing to + present and the screen has to offer a LIST. That widens what a logged-out + screen exposes, so the list is not anonymous: an SSO comes first and has to + resolve to a real person before any drive is named - the same bar taking one + already had to clear. The main screen shows the caller's own drives, what is + free, and a COUNT of everything else; the holders of other people's drives + appear only behind a deliberate second tap, so a passer-by cannot read the + whole cabinet off a wall panel. + + A badge that resolves to no employee is REFUSED, unlike the signed-in route + which creates the user row: a checkout nobody can trace to a person defeats + the point, and at a kiosk there is no operator standing there to vouch for it. + + Returning somebody else's drive asks first, then records the badge that was + scanned - so the log says who actually handed it back, which is the honest + record and needs no change to a schema this app does not own. Refusing a + mismatch outright is available as a setting, but is not the default: a drive + left behind by somebody on leave would then be unreturnable at the kiosk, + which just moves the problem to a person with a login. + - **Audit log retention.** The audit log is append-only and nothing ever removed a row, so it grew for as long as a site ran - here to the point where the audit page was tens of thousands of pages deep and therefore useless, because diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 37562394..93dc2df6 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -131,6 +131,7 @@ const TITLE_SPELLINGS = { 'knowledgebase': 'Knowledge Base', 'printedparts': '3D Printed Parts', 'parts-kiosk': 'Parts Kiosk', + 'usb-kiosk': 'USB Kiosk', 'tv': 'TV Slideshow', 'shopfloor': 'Shopfloor Dashboard', 'measuringtools': 'Measuring Tools', diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py index b721b533..86c213e4 100644 --- a/plugins/usb/api/routes.py +++ b/plugins/usb/api/routes.py @@ -721,3 +721,350 @@ def list_active_checkouts(): 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_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_enabled(): + 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_enabled(): + 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_enabled(): + 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_enabled(): + 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) + + 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')), + ) + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_CHECKED_OUT, device_id), + ) + 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_enabled(): + 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)) diff --git a/plugins/usb/api/selfhosted.py b/plugins/usb/api/selfhosted.py index 4254da32..25a87d27 100644 --- a/plugins/usb/api/selfhosted.py +++ b/plugins/usb/api/selfhosted.py @@ -274,3 +274,181 @@ def list_checkouts(request, active_only): event['device_status'] = _status(checkout.device) if checkout.device else None data.append(event) return success_response(data) + + +# --------------------------------------------------------------------------- +# Kiosk (self-hosted mode). Same contract as the external path in routes.py: +# move a device between checked-out and checked-in, attributed to a badge that +# resolved to a real person, and nothing else. +# --------------------------------------------------------------------------- + +def kiosk_device(device_id): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', http_code=404) + return success_response({ + 'device_id': device.serialnumber, + 'device_desc': device.label or device.productname or '', + 'status': _status(device), + 'locker_location': device.storagelocation, + # Name only. The kiosk needs it for the return-for-someone-else + # confirm; it does not need their badge. + 'holdername': device.currentusername or '' if device.ischeckedout else '', + }) + + +def kiosk_session(data): + """See routes.kiosk_session for why the list costs a valid badge.""" + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + name = _resolve_name(badge) + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT', http_code=422) + + yours, available, others = [], [], 0 + for device in USBDevice.query.filter_by(isactive=True).order_by( + USBDevice.serialnumber).all(): + entry = { + 'device_id': device.serialnumber, + 'device_desc': device.label or device.productname or '', + 'locker_location': device.storagelocation, + } + if not device.ischeckedout: + available.append(entry) + elif (device.currentuserid or '').strip() == badge: + yours.append(entry) + else: + others += 1 + + return success_response({'name': name, 'yours': yours, + 'available': available, 'otherscount': others}) + + +def kiosk_others(data): + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + if not _resolve_name(badge): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT', http_code=422) + + out = [] + for device in USBDevice.query.filter_by(isactive=True, ischeckedout=True + ).order_by(USBDevice.serialnumber).all(): + if (device.currentuserid or '').strip() == badge: + continue + out.append({ + 'device_id': device.serialnumber, + 'device_desc': device.label or device.productname or '', + 'locker_location': device.storagelocation, + 'holdername': device.currentusername or '', + }) + return success_response(out) + + +def kiosk_checkout(data): + device_id = (data.get('device_id') or '').strip() + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', http_code=404) + if not device.isactive: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'That device is retired - see IT') + if device.ischeckedout: + return error_response( + ErrorCodes.CONFLICT, + 'That device is already checked out{}'.format( + ' to {}'.format(device.currentusername) + if device.currentusername else ''), + http_code=409) + + # Refused rather than recorded anonymously - see the note on the external + # path. A checkout nobody can trace to a person defeats the tracking. + name = _resolve_name(badge) + if not name: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT to check this device out', + http_code=422) + + now = datetime.now(timezone.utc).replace(tzinfo=None) + db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge, + checkoutname=name, checkouttime=now)) + device.ischeckedout = True + device.currentuserid = badge + device.currentusername = name + device.currentcheckoutdate = now + db.session.commit() + return success_response({'device_id': device.serialnumber, + 'holdername': name}, + message='{} checked out to {}'.format( + device.serialnumber, name)) + + +def kiosk_checkin(data): + device_id = (data.get('device_id') or '').strip() + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + forsomeoneelse = bool(data.get('forsomeoneelse')) + + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', http_code=404) + if not device.ischeckedout: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'That device is not checked out') + + name = _resolve_name(badge) + if not name: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT to return this device', + http_code=422) + + holderbadge = (device.currentuserid or '').strip() + if holderbadge and holderbadge != badge: + if not _kiosk_flag('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: + return error_response( + ErrorCodes.CONFLICT, + 'That device is checked out to {}. Confirm you are returning ' + 'it for them.'.format(device.currentusername or 'somebody else'), + details={'needsconfirm': True, + 'holdername': device.currentusername or ''}, + http_code=409) + + open_checkout = (USBCheckout.query + .filter_by(usbdeviceid=device.usbdeviceid, checkintime=None) + .order_by(USBCheckout.checkouttime.desc()).first()) + if open_checkout: + open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None) + device.ischeckedout = False + device.currentuserid = None + device.currentusername = None + device.currentcheckoutdate = None + db.session.commit() + return success_response({'device_id': device.serialnumber}, + message='{} checked in'.format(device.serialnumber)) + + +def _kiosk_flag(key, default): + from shopdb.api import Setting + 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') diff --git a/plugins/usb/frontend/routes.js b/plugins/usb/frontend/routes.js index 4f224b23..cade9108 100644 --- a/plugins/usb/frontend/routes.js +++ b/plugins/usb/frontend/routes.js @@ -29,6 +29,16 @@ export default [ ] export const toplevel = [ + { + // Touch kiosk for taking and returning USB drives: scan the drive, scan a + // badge. Full-screen and OUTSIDE AppLayout, and deliberately not behind + // requiresAuth - the whole point is that a guest at the cabinet can use it. + // The API refuses every kiosk route unless usb_kiosk_enabled is on. + path: '/usb-kiosk', + name: 'usb-kiosk', + component: () => import('./views/USBKiosk.vue'), + meta: { plugin: 'usb' } + }, { path: '/print/usb-labels', name: 'print-usb-labels', diff --git a/plugins/usb/frontend/views/USBKiosk.vue b/plugins/usb/frontend/views/USBKiosk.vue new file mode 100644 index 00000000..d76a76ae --- /dev/null +++ b/plugins/usb/frontend/views/USBKiosk.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/plugins/usb/plugin.py b/plugins/usb/plugin.py index 85054cae..bb6572cb 100644 --- a/plugins/usb/plugin.py +++ b/plugins/usb/plugin.py @@ -70,6 +70,44 @@ class USBPlugin(BasePlugin): 'docs': 'plugins/usb/README.md', } + def get_settings_defaults(self) -> List[Dict]: + """Kiosk settings. + + The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns + it on deliberately. Opening that door is a decision about the door. + """ + return [ + { + 'key': 'usb_kiosk_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'usb', + # Public: the kiosk page runs logged out and has to be able to + # tell "turned off" from "broken" before anyone signs in. + 'public': True, + 'description': 'Allow check-out and check-in from the logged-out ' + 'USB kiosk screen (/usb-kiosk). Off by default: the ' + 'kiosk writes without a login, attributing every ' + 'action to a scanned badge instead. It can only move ' + 'a device between checked-out and checked-in - it ' + 'cannot add, edit, retire or list devices.', + }, + { + 'key': 'usb_kiosk_allow_return_for_others', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'usb', + 'public': True, + 'description': 'At the kiosk, allow somebody to return a device ' + 'checked out to a DIFFERENT person after confirming. ' + 'The returning badge is what gets recorded, so the ' + 'log shows who actually handed it back. Turn this ' + 'off to refuse any mismatch outright - a device left ' + 'behind by someone on leave then needs a signed-in ' + 'user to return it.', + }, + ] + 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).""" diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 476bb0f8..0b9a072e 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -51,6 +51,30 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', # docs/proposals/printedparts-plugin.md; justification in # the plugin README. 'printedparts.kiosk_take', + # Deliberately open kiosk writes, same bar as the parts + # kiosk: they can ONLY move a USB device between + # checked-out and checked-in, and only when a site has set + # usb_kiosk_enabled (off by default - every one of these is + # a 404 until then). Identity is a scanned badge resolved + # server-side, and a badge that resolves to no employee is + # refused rather than recorded, so nothing here can create + # an untraceable checkout. The people who take drives stand + # at the cabinet and mostly have no account, and both modes + # already attributed the action to the badge rather than to + # the signed-in user - the login was gating the screen, not + # the record. Covered by tests/test_plugins/test_usb_kiosk.py. + 'usb.kiosk_checkout', + 'usb.kiosk_checkin', + # The kiosk's two list endpoints. POSTs that CHANGE NOTHING: + # they are reads, shaped as POSTs so the badge stays out of + # query strings and proxy access logs. Same gate as the + # writes above - 404 unless usb_kiosk_enabled, and they name + # no drive until the badge resolves to a real person. The + # drives have no barcode, so a list is the only way to pick + # one; kiosk_others additionally 404s when returning for + # somebody else is switched off. + 'usb.kiosk_session', + 'usb.kiosk_others', # Redeeming a shared secret. The recipient usually has no # account here, so the unguessable 128-bit token IS the # authorisation - gating this on a role would make the diff --git a/tests/test_plugins/test_usb_kiosk.py b/tests/test_plugins/test_usb_kiosk.py new file mode 100644 index 00000000..9f8e753f --- /dev/null +++ b/tests/test_plugins/test_usb_kiosk.py @@ -0,0 +1,278 @@ +"""The USB kiosk: an UNAUTHENTICATED write, so the guards are the feature. + +The kiosk exists so somebody at the drive cabinet - with no ShopDB login - can +take a drive and put it back, with the action attributed to their badge instead +of to a signed-in operator. Everything here is about the bar that opens the door +only as far as it has to: it can move a device between checked-out and +checked-in and nothing else, it refuses a badge it cannot put a name to, and it +does not exist at all until a site turns it on. + +Self-hosted mode. The external cmmc_usb path is the same contract against raw +SQL and needs that database to exercise. +""" + +import pytest + +from shopdb.core.models import Setting + + +@pytest.fixture +def kiosk(db): + """Self-hosted USB, kiosk ON, one employee and one available drive.""" + from plugins.employees.models import DirectoryEmployee + from plugins.usb.models import USBDevice + + db.session.add(Setting(key='usb_directory_mode', value='selfhosted', + valuetype='string', category='site')) + db.session.add(Setting(key='usb_kiosk_enabled', value='true', + valuetype='boolean', category='usb')) + db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana', + lastname='Reyes')) + db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam', + lastname='Okafor')) + db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston', + ischeckedout=False)) + db.session.commit() + return db + + +def _setting(db, key, value): + row = Setting.query.filter_by(key=key).first() + if row is None: + db.session.add(Setting(key=key, value=value, valuetype='boolean', + category='usb')) + else: + row.value = value + db.session.commit() + + +def _out(client, badge='270015376', **extra): + return client.post('/api/usb/kiosk/checkout', + json=dict({'device_id': 'USB-1', 'badge': badge}, **extra)) + + +def _in(client, badge='270015376', **extra): + return client.post('/api/usb/kiosk/checkin', + json=dict({'device_id': 'USB-1', 'badge': badge}, **extra)) + + +# --- the door itself -------------------------------------------------------- + +def test_the_kiosk_does_not_exist_until_a_site_turns_it_on(client, kiosk): + """Default off, and 404 rather than 403. + + An unauthenticated write is a decision about the door. A site that has not + made that decision should not have these routes answering at all, and a + disabled kiosk should not advertise that there is a door here. + """ + _setting(kiosk, 'usb_kiosk_enabled', 'false') + + assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404 + assert _out(client).status_code == 404 + assert _in(client).status_code == 404 + + +def test_it_ships_off(app): + """The declared default is what a fresh site gets.""" + from plugins.usb.plugin import USBPlugin + defaults = {s['key']: s for s in USBPlugin().get_settings_defaults()} + assert defaults['usb_kiosk_enabled']['value'] == 'false' + + +def test_no_login_is_required(client, kiosk): + """The whole point: no Authorization header anywhere in this file.""" + response = client.get('/api/usb/kiosk/device/USB-1') + assert response.status_code == 200 + assert response.get_json()['data']['device_id'] == 'USB-1' + + +def test_the_kiosk_cannot_create_edit_or_delete(client, kiosk): + """It moves a device between two states. It is not an admin surface.""" + assert client.post('/api/usb/kiosk/device/USB-1').status_code in (404, 405) + assert client.delete('/api/usb/kiosk/device/USB-1').status_code in (404, 405) + # And the authenticated write routes are still gated. + assert client.post('/api/usb/USB-1/checkout', + json={'badge': '270015376'}).status_code == 401 + + +# --- badges ----------------------------------------------------------------- + +def test_a_badge_it_cannot_name_is_refused(client, kiosk): + """A checkout nobody can trace to a person defeats the tracking. + + The signed-in route creates the user row for an unknown badge; the kiosk + must not, because there is no operator standing there to vouch for it. + """ + response = _out(client, badge='999999999') + assert response.status_code == 422 + assert 'not recognized' in response.get_json()['data']['error']['message'] + + from plugins.usb.models import USBDevice + assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False + + +def test_a_missing_badge_is_refused(client, kiosk): + assert client.post('/api/usb/kiosk/checkout', + json={'device_id': 'USB-1'}).status_code == 400 + + +# --- taking one ------------------------------------------------------------- + +def test_taking_a_drive_records_who_took_it(client, kiosk): + from plugins.usb.models import USBDevice + + response = _out(client) + assert response.status_code == 200, response.get_json() + + device = USBDevice.query.filter_by(serialnumber='USB-1').first() + assert device.ischeckedout is True + assert device.currentuserid == '270015376' + assert device.currentusername == 'Dana Reyes' + + +def test_a_drive_already_out_cannot_be_taken_again(client, kiosk): + _out(client) + response = _out(client, badge='270099999') + assert response.status_code == 409 + assert 'Dana Reyes' in response.get_json()['data']['error']['message'] + + +def test_an_unknown_code_is_a_404(client, kiosk): + response = client.post('/api/usb/kiosk/checkout', + json={'device_id': 'NOPE', 'badge': '270015376'}) + assert response.status_code == 404 + + +# --- putting one back ------------------------------------------------------- + +def test_the_holder_returns_their_own_drive(client, kiosk): + from plugins.usb.models import USBDevice + _out(client) + + assert _in(client).status_code == 200 + device = USBDevice.query.filter_by(serialnumber='USB-1').first() + assert device.ischeckedout is False + assert device.currentuserid is None + + +def test_returning_somebody_elses_drive_asks_first(client, kiosk): + """Not a silent success: the record is about to say they handed it back.""" + from plugins.usb.models import USBDevice + _out(client) + + response = _in(client, badge='270099999') + assert response.status_code == 409 + details = response.get_json()['data']['error']['details'] + assert details['needsconfirm'] is True + assert details['holdername'] == 'Dana Reyes' + assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is True + + +def test_confirming_returns_it_for_them(client, kiosk): + """A drive left on a bench by somebody on leave still gets put back. + + Refusing outright just moves the problem to a person with a login. + """ + from plugins.usb.models import USBDevice + _out(client) + + response = _in(client, badge='270099999', forsomeoneelse=True) + assert response.status_code == 200, response.get_json() + assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False + + +def test_a_site_can_refuse_returns_for_others_outright(client, kiosk): + _setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false') + _out(client) + + response = _in(client, badge='270099999', forsomeoneelse=True) + assert response.status_code == 403 + + +def test_a_drive_that_is_not_out_cannot_be_returned(client, kiosk): + assert _in(client).status_code == 400 + + +# --- what the screen is allowed to know ------------------------------------- + +def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk): + """The confirm step has to show whose it is; it does not need their SSO.""" + _out(client) + data = client.get('/api/usb/kiosk/device/USB-1').get_json()['data'] + + assert data['holdername'] == 'Dana Reyes' + assert '270015376' not in str(data) + + +# --- the picker ------------------------------------------------------------- +# The drives carry no barcode, so the kiosk cannot ask somebody to present one - +# it has to offer a LIST. That is a real widening of what a logged-out screen +# exposes, so the list is not anonymous: it costs a badge that resolves to a +# real person, the same bar taking a drive already had to clear. + +def _session(client, badge='270015376'): + return client.post('/api/usb/kiosk/session', json={'badge': badge}) + + +def test_the_device_list_costs_a_valid_badge(client, kiosk): + assert client.post('/api/usb/kiosk/session', json={}).status_code == 400 + assert _session(client, badge='999999999').status_code == 422 + + +def test_the_list_is_not_reachable_when_the_kiosk_is_off(client, kiosk): + _setting(kiosk, 'usb_kiosk_enabled', 'false') + assert _session(client).status_code == 404 + assert client.post('/api/usb/kiosk/others', + json={'badge': '270015376'}).status_code == 404 + + +def test_the_session_separates_yours_from_available(client, kiosk): + from plugins.usb.models import USBDevice + kiosk.session.add(USBDevice(serialnumber='USB-2', label='Second', + ischeckedout=False)) + kiosk.session.commit() + _out(client) # 270015376 takes USB-1 + + data = _session(client).get_json()['data'] + assert data['name'] == 'Dana Reyes' + assert [d['device_id'] for d in data['yours']] == ['USB-1'] + assert [d['device_id'] for d in data['available']] == ['USB-2'] + assert data['otherscount'] == 0 + + +def test_somebody_elses_drive_is_a_count_not_a_name(client, kiosk): + """A passer-by should not read the cabinet's holders off a wall screen.""" + _out(client) # Dana takes USB-1 + + data = _session(client, badge='270099999').get_json()['data'] + assert data['yours'] == [] + assert data['otherscount'] == 1 + assert 'Dana' not in str(data) + + +def test_names_appear_only_on_the_deliberate_second_step(client, kiosk): + _out(client) + + rows = client.post('/api/usb/kiosk/others', + json={'badge': '270099999'}).get_json()['data'] + assert [r['device_id'] for r in rows] == ['USB-1'] + assert rows[0]['holdername'] == 'Dana Reyes' + + +def test_the_others_list_is_gone_when_returns_for_others_are_refused(client, kiosk): + """No point showing a list of drives the kiosk will not let anyone return.""" + _setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false') + _out(client) + + assert client.post('/api/usb/kiosk/others', + json={'badge': '270099999'}).status_code == 404 + + +def test_a_retired_drive_is_not_offered(client, kiosk): + from plugins.usb.models import USBDevice + device = USBDevice.query.filter_by(serialnumber='USB-1').first() + device.isactive = False + kiosk.session.commit() + + data = _session(client).get_json()['data'] + assert data['available'] == [] From 0b8c455b99713df83ed5bb499ab2f3a82b3ad2d0 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 08:56:27 -0400 Subject: [PATCH 02/12] USB kiosk sidebar link --- frontend/src/views/AppLayout.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 19b4a8ce..ebf4a747 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -34,6 +34,8 @@ TV Slideshow Parts Kiosk + USB Kiosk Settings @@ -136,6 +138,7 @@ const hasRoute = (path) => stagedRoutePaths.has(path) const showShopfloor = hasRoute('/shopfloor') && (hasRoute('/notifications') || hasRoute('/calendar')) const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk') + || hasRoute('/usb-kiosk') const routeViewKey = computed(() => route.path.startsWith('/settings') ? '/settings' : route.path ) From 376298dc02d7738fdb60b288cc7dcf8b75c438a6 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 09:30:08 -0400 Subject: [PATCH 03/12] Navigation sections declared, guide editor drag + references, USB settings, permission refresh --- frontend/src/stores/auth.js | 14 +- frontend/src/views/AppLayout.vue | 77 +++-- plugins/computers/plugin.py | 1 + plugins/geenforce/plugin.py | 1 + plugins/guides/frontend/views/GuideEditor.vue | 293 ++++++++++++++++-- plugins/guides/plugin.py | 13 +- plugins/machines/plugin.py | 1 + plugins/measuringtools/plugin.py | 1 + .../frontend/views/NetworkDeviceDetail.vue | 68 ++-- plugins/network/plugin.py | 1 + plugins/printedparts/plugin.py | 3 +- plugins/printers/plugin.py | 1 + plugins/tools/plugin.py | 1 + plugins/usb/frontend/routes.js | 6 + plugins/usb/frontend/views/USBSettings.vue | 113 +++++++ plugins/usb/plugin.py | 24 +- plugins/warranty/plugin.py | 2 +- 17 files changed, 538 insertions(+), 82 deletions(-) create mode 100644 plugins/usb/frontend/views/USBSettings.vue diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 2f0a96dd..9d8faaf5 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -75,13 +75,25 @@ export const useAuthStore = defineStore('auth', { } }, + // Re-read the signed-in user from the server, permissions included. + // + // The login response is the ONLY thing that used to populate this, and it + // is cached in localStorage - so granting somebody a permission did nothing + // visible until they happened to log out and back in. They were told they + // had rights, the button stayed hidden, and nothing explained why. Called + // on app boot so a permission change lands on the next page load. + // + // Only a 401 logs out. A transient failure - API restarting, network blip - + // must not throw somebody out of a session they still hold; the cached user + // is stale, not invalid. async fetchUser() { try { const response = await authApi.me() this.user = response.data.data await this.enrichFromDirectory() + localStorage.setItem('user', JSON.stringify(this.user)) } catch (error) { - this.logout() + if (error.response?.status === 401) this.logout() } }, diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index ebf4a747..2d37253a 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -189,14 +189,33 @@ const defaultNav = [ { name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 }, { name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 }, { name: 'Map', icon: 'map', route: '/map', position: 4 }, - { name: 'Machines', icon: 'cog', route: '/machines', position: 10 }, - { name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 }, - { name: 'Network', icon: 'network-wired', route: '/network', position: 18 }, - { name: 'Printers', icon: 'printer', route: '/printers', position: 20 }, - { name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 }, + { name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets' }, + { name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets' }, + { name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets' }, + { name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets' }, + { name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets' }, { name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' }, - { name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 }, - { name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 }, + { name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information' }, + { name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information' }, +] + +// The canonical sidebar sections, in the order they render. A nav item DECLARES +// which one it belongs to; nothing is inferred. +// +// Sections used to be derived from the position number - 10-29 became Assets, +// 30-49 became Information - so a plugin landed in whichever bucket its number +// happened to fall in. That is how USB Devices, an asset, came to sit under +// Information next to GE-Enforce and Tech Tools, and why two plugins picking +// the same number silently reordered each other. A number is for ORDER; it was +// never a statement about meaning. +// +// An item with no section (or an unknown one) stays in the unlabelled group at +// the top, which is the safe default: it appears, it is just not filed. +const NAV_SECTIONS = [ + { key: null, label: null }, + { key: 'assets', label: 'Assets' }, + { key: 'information', label: 'Information' }, + { key: 'fleet', label: 'Fleet' }, ] function buildNavItems(items) { @@ -206,29 +225,26 @@ function buildNavItems(items) { const visible = items.filter(item => !item.permission || authStore.hasPermission(item.permission)) - // Sort by position - const sorted = [...visible].sort((a, b) => (a.position || 99) - (b.position || 99)) + const known = new Set(NAV_SECTIONS.map(s => s.key).filter(Boolean)) + const bucket = item => { + const declared = (item.section || '').toLowerCase() + return known.has(declared) ? declared : null + } - // Assign section headers based on position ranges const result = [] - let currentSection = null - - for (const item of sorted) { - let section = null - if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') { - section = 'Assets' - currentSection = 'Assets' - } else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) { - if (currentSection !== 'Information') { - section = 'Information' - currentSection = 'Information' - } - } - - result.push({ - ...item, - section, - iconComponent: iconMap[item.icon] || null + for (const { key, label } of NAV_SECTIONS) { + const inSection = visible + .filter(item => bucket(item) === key) + // ?? not ||: position 0 is a real position, and `0 || 99` is 99, which + // sent Dashboard - the only item that uses 0 - to the end of its group. + .sort((a, b) => (a.position ?? 99) - (b.position ?? 99)) + inSection.forEach((item, index) => { + result.push({ + ...item, + // Only the first item in a section carries its heading. + section: index === 0 ? label : null, + iconComponent: iconMap[item.icon] || null, + }) }) } @@ -236,6 +252,11 @@ function buildNavItems(items) { } onMounted(async () => { + // Refresh the signed-in user (and their permissions) on every app load. The + // login payload is cached in localStorage, so without this a permission + // granted mid-session stays invisible until the person logs out and back in. + if (authStore.isAuthenticated) authStore.fetchUser() + // Half-hourly: cheap, and it crosses midnight into the new week without a // reload on a screen nobody touches. fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000) diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 01bea5cd..babf4b9a 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -1653,6 +1653,7 @@ class ComputersPlugin(BasePlugin): 'icon': 'desktop', 'route': '/pcs', 'position': 15, + 'section': 'assets', }, ] diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py index 1768805b..5000e248 100644 --- a/plugins/geenforce/plugin.py +++ b/plugins/geenforce/plugin.py @@ -125,6 +125,7 @@ class GeEnforcePlugin(BasePlugin): 'icon': 'shield', 'route': '/geenforce/manifests', 'position': 46, + 'section': 'fleet', }, ] diff --git a/plugins/guides/frontend/views/GuideEditor.vue b/plugins/guides/frontend/views/GuideEditor.vue index 5d5dd462..3afbbdab 100644 --- a/plugins/guides/frontend/views/GuideEditor.vue +++ b/plugins/guides/frontend/views/GuideEditor.vue @@ -68,12 +68,71 @@ + +
+
+
+ Link to a guide or article + +
+ +

Searching...

+

+ {{ refQuery ? 'Nothing matches that.' : 'Type to search.' }} +

+
    +
  • + +
  • +
+
+
+
-
+ +
+
+ +
+
+ + +
+ +
+ + + + {{ block.type }} +
@@ -209,6 +285,8 @@ import GuideBlocks from '../components/GuideBlocks.vue' import { templates } from '../utils/templates.js' import { proof, applyFix } from '../utils/proof.js' import { useGuideImages } from '../utils/images.js' +import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next' +import { knowledgebaseApi } from '@/api' const route = useRoute() const router = useRouter() @@ -220,6 +298,17 @@ const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets', const guide = reactive({ guideid: null, title: '', summary: '', tags: '', slug: '', ispublished: false }) const blocks = ref([]) +// Which gap the type palette is open in. null = closed; blocks.length = the end. +const insertAt = ref(null) +const dragIndex = ref(null) +const dragOver = ref(null) +const refOpen = ref(false) +const refQuery = ref('') +const refResults = ref([]) +const refLoading = ref(false) +const refInput = ref(null) +let refBlock = null +let refTimer = null const revisions = ref([]) const saving = ref(false) const message = ref('') @@ -288,17 +377,111 @@ function insertIndex () { return index === -1 ? blocks.value.length : index + 1 } -function add (type) { +// `at` is an explicit gap from an insert bar. Without one, fall back to +// "after whatever is focused", which is what the old bottom palette did. +function add (type, at = null) { const block = { id: newid(), type } if (hasItems(block)) block.items = [{ id: newid(), text: '' }] if (type === 'callout') block.variant = 'info' if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] } - const at = insertIndex() - blocks.value.splice(at, 0, block) + const index = at === null ? insertIndex() : at + blocks.value.splice(index, 0, block) + insertAt.value = null focused.value = block.id nextTick(() => textareas[block.id]?.focus()) } +// --------------------------------------------------------------- dragging --- +function startDrag (index, event) { + dragIndex.value = index + // Firefox will not start a drag without payload, and 'move' is what the + // cursor should say. + event.dataTransfer.effectAllowed = 'move' + try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ } +} + +function endDrag () { + dragIndex.value = null + dragOver.value = null +} + +function dropOn (index) { + const from = dragIndex.value + endDrag() + if (from === null || from === index) return + const [moved] = blocks.value.splice(from, 1) + blocks.value.splice(index, 0, moved) + focused.value = moved.id +} + +// ------------------------------------------------------------- references --- +function openReference (block) { + refBlock = block + refOpen.value = true + refQuery.value = '' + refResults.value = [] + nextTick(() => refInput.value?.focus()) +} + +function searchReferences () { + clearTimeout(refTimer) + const term = refQuery.value.trim() + if (!term) { refResults.value = []; return } + refTimer = setTimeout(async () => { + refLoading.value = true + try { + const results = [] + // Guides first: a procedure most often points at another procedure. + try { + const response = await api.get('/guides', { params: { q: term } }) + for (const guide of (response.data?.data || [])) { + if (!guide.ispublished) continue // never link readers at a draft + results.push({ kind: 'guide', id: guide.guideid, title: guide.title, + summary: guide.summary, url: `/guides/${guide.slug}` }) + } + } catch (err) { /* guides always exist here, but never break the picker */ } + + // The knowledge base is a separate plugin and may not be installed at + // this site (ADR-009). Its absence must cost nothing but its results. + try { + const response = await knowledgebaseApi.list({ search: term, perpage: 20 }) + const rows = response.data?.data || [] + for (const article of (Array.isArray(rows) ? rows : rows.items || [])) { + results.push({ kind: 'article', id: article.articleid || article.id, + title: article.title, + summary: article.summary || article.description, + url: `/knowledgebase/${article.articleid || article.id}` }) + } + } catch (err) { /* KB plugin absent or disabled */ } + + refResults.value = results.slice(0, 25) + } finally { + refLoading.value = false + } + }, 250) +} + +function insertReference (item) { + // An in-app path, not an absolute URL: the link has to keep working when the + // site is reached by another hostname or mounted under a subpath. + const markdown = `[${item.title}](${item.url})` + const area = refBlock ? textareas[refBlock.id] : null + if (refBlock && area) { + const start = area.selectionStart ?? refBlock.text?.length ?? 0 + const end = area.selectionEnd ?? start + const text = refBlock.text || '' + refBlock.text = text.slice(0, start) + markdown + text.slice(end) + nextTick(() => { + area.focus() + const caret = start + markdown.length + area.setSelectionRange(caret, caret) + }) + } else if (refBlock) { + refBlock.text = (refBlock.text || '') + markdown + } + refOpen.value = false +} + function duplicate (index) { const source = blocks.value[index] const copy = JSON.parse(JSON.stringify(source)) @@ -383,7 +566,7 @@ async function uploadImage (file) { return response.data?.data || response.data } -async function insertImage (file) { +async function insertImage (file, at = null) { error.value = '' status.value = 'Uploading image...' try { @@ -392,7 +575,8 @@ async function insertImage (file) { // for anything that still treats a block as a plain document. const block = { id: newid(), type: 'image', imageid: image.guideimageid, url: image.url, caption: '', alt: '' } - blocks.value.splice(insertIndex(), 0, block) + blocks.value.splice(at === null ? insertIndex() : at, 0, block) + insertAt.value = null focused.value = block.id status.value = '' } catch (err) { @@ -416,9 +600,9 @@ function onDrop (event) { if (file) insertImage(file) } -function onFilePick (event) { +function onFilePick (event, at = null) { const file = event.target.files?.[0] - if (file) insertImage(file) + if (file) insertImage(file, at) event.target.value = '' } @@ -714,4 +898,77 @@ onMounted(async () => { .ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem; border: 1px solid var(--border); background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer; } + +/* --- insert bars, drag handle, reference picker -------------------------- */ + +/* A quiet line that becomes a button on hover: present when wanted, not + competing with the guide's own content the rest of the time. */ +.ge-insert { + display: flex; align-items: center; justify-content: center; + height: 1.5rem; opacity: 0; transition: opacity 0.12s; +} +.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; } +.ge-insert::before, .ge-insert::after { + content: ''; flex: 1; height: 1px; background: var(--border); +} +.ge-insertbtn { + display: inline-flex; align-items: center; gap: 0.3rem; + margin: 0 0.5rem; padding: 0.15rem 0.6rem; + font-size: 0.75rem; color: var(--text-light); + border: 1px solid var(--border); border-radius: 999px; + background: var(--bg); cursor: pointer; white-space: nowrap; +} +.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); } +.ge-palette { + display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; + margin: 0 0 0.6rem; padding: 0.5rem; + border: 1px solid var(--border); border-radius: 5px; +} +.ge-palette button, .ge-palette .ge-upload { + font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border); + background: transparent; color: var(--text); border-radius: 3px; cursor: pointer; +} +.ge-palette button:hover, .ge-palette .ge-upload:hover { + color: var(--primary); border-color: var(--primary); +} + +.ge-grip { display: inline-flex; align-items: center; color: var(--text-light); + cursor: grab; padding-right: 0.2rem; } +.ge-grip:active { cursor: grabbing; } +.ge-dragging { opacity: 0.45; } +.ge-dragover { border-top: 2px solid var(--primary); } + +.ge-refmask { + position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); + display: flex; align-items: flex-start; justify-content: center; + padding-top: 8vh; z-index: 50; +} +.ge-ref { + width: min(34rem, 92vw); max-height: 70vh; overflow: auto; + background: var(--bg); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; padding: 1rem; +} +.ge-refhead { display: flex; align-items: center; justify-content: space-between; + margin-bottom: 0.6rem; } +.ge-refhead button { background: transparent; border: none; color: var(--text-light); + cursor: pointer; } +.ge-refsearch { + width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem; + border: 1px solid var(--border); border-radius: 4px; + background: transparent; color: var(--text); margin-bottom: 0.6rem; +} +.ge-reflist { list-style: none; margin: 0; padding: 0; } +.ge-reflist button { + width: 100%; display: flex; flex-direction: column; gap: 0.15rem; + padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left; + border: 1px solid var(--border); border-radius: 4px; + background: transparent; color: var(--text); cursor: pointer; +} +.ge-reflist button:hover { border-color: var(--primary); } +.ge-refkind { + font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--text-light); +} +.ge-reftitle { font-weight: 600; } +.ge-reflist small { color: var(--text-light); } diff --git a/plugins/guides/plugin.py b/plugins/guides/plugin.py index c8b0e387..13f30323 100644 --- a/plugins/guides/plugin.py +++ b/plugins/guides/plugin.py @@ -75,10 +75,15 @@ class GuidesPlugin(BasePlugin): ] def get_navigation_items(self) -> List[Dict]: - # Position 8, beside Slides (7), rather than down in the Information - # section with the reference lists. A guide is something you go and DO - # something with, like a slide deck you put on a screen - not a - # catalogue you look things up in. + # Position 8, directly under Slides (7), rather than down in the + # Information section with the reference lists. A guide is something you + # go and DO something with, like a slide deck you put on a screen - not + # a catalogue you look things up in. + # + # Warranties held 8 as well, and a TIE is broken by plugin load order, + # so Guides landed underneath it and read as "not under Slides at all". + # Warranties moved to 9. Two nav items sharing a position is not a + # preference, it is a coin toss. return [ { 'name': 'Guides', diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py index 77258bf9..192469d0 100644 --- a/plugins/machines/plugin.py +++ b/plugins/machines/plugin.py @@ -232,6 +232,7 @@ class MachinesPlugin(BasePlugin): 'icon': 'cog', 'route': '/machines', 'position': 10, + 'section': 'assets', }, ] diff --git a/plugins/measuringtools/plugin.py b/plugins/measuringtools/plugin.py index 5a08e3fb..b563971f 100644 --- a/plugins/measuringtools/plugin.py +++ b/plugins/measuringtools/plugin.py @@ -74,6 +74,7 @@ class MeasuringToolsPlugin(BasePlugin): 'icon': 'ruler', 'route': '/measuringtools', 'position': 22, + 'section': 'assets', }, ] diff --git a/plugins/network/frontend/views/NetworkDeviceDetail.vue b/plugins/network/frontend/views/NetworkDeviceDetail.vue index f947549d..365654d7 100644 --- a/plugins/network/frontend/views/NetworkDeviceDetail.vue +++ b/plugins/network/frontend/views/NetworkDeviceDetail.vue @@ -1,5 +1,25 @@ @@ -273,7 +307,7 @@ function reset () { } .kiosk-header { width: 100%; - max-width: 40rem; + max-width: 72rem; display: flex; justify-content: center; align-items: center; @@ -297,8 +331,11 @@ function reset () { max-width: 40rem; width: 100%; } -/* The picker holds lists, so its rows stretch - but it keeps the same width as - every other step so the panel does not jump when the step changes. +/* The picker holds lists, so its rows stretch, and it is WIDER than the steps + that hold a single control - a cabinet of thirty drives is three or four + columns of one glance rather than a scroll. The steps that are just a keypad + or a message stay narrow, because stretching one input across a 24 inch panel + reads as a broken page. It is also height-bounded: the lists absorb whatever space is left over and scroll inside themselves, so the PAGE never scrolls and the heading saying whose drives these are stays on screen. A fixed max-height cannot do this - @@ -306,6 +343,7 @@ function reset () { present, which is exactly what it did. */ .kiosk-wide { align-items: stretch; + max-width: 72rem; max-height: calc(100vh - 9rem); min-height: 0; } @@ -321,7 +359,7 @@ function reset () { .kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; } .kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; } .kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; } -.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; } +.kiosk-hint { color: var(--text-light); text-align: center; margin: 0; } .kiosk-error { padding: 0.9rem 1.1rem; border: 1px solid var(--danger, #b3261e); @@ -335,12 +373,47 @@ function reset () { .drive-group { width: 100%; } .drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; } -/* Two columns once there is room, so a cabinet of a dozen drives is one glance - rather than a scroll. The list itself scrolls when it has to - the PAGE must - not, or the person loses the heading telling them whose drives these are. */ +/* Heading and filter on one line: the filter belongs to the list under it, and + a row of its own would cost height the lists need. */ +.drive-group-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.drive-filter { display: flex; align-items: center; gap: 0.5rem; } +.drive-filter-input { + /* Touch-sized like everything else here, and wide enough for a locker name + rather than just a drive number. */ + min-width: 16rem; + padding: 0.6rem 0.8rem; + font-size: 1.1rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card-solid); + color: var(--text); +} +.drive-filter-input:focus { + outline: none; + border-color: var(--primary); +} +.drive-filter-clear { + padding: 0.6rem 0.9rem; + font-size: 1rem; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text); + cursor: pointer; +} +/* As many columns as the panel has room for - four on a wall display, one on a + small screen - so a cabinet of a dozen drives is one glance rather than a + scroll. The list itself scrolls when it has to; the PAGE must not, or the + person loses the heading telling them whose drives these are. */ .drive-list { display: grid; - grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 0.6rem; flex: 1 1 auto; min-height: 0; @@ -364,7 +437,7 @@ function reset () { } .drive-row:disabled { opacity: 0.5; } .drive-name { display: flex; flex-direction: column; gap: 0.15rem; } -.drive-name small { color: var(--textmuted); font-size: 0.85rem; } +.drive-name small { color: var(--text-light); font-size: 0.85rem; } .drive-action { flex: 0 0 auto; padding: 0.5rem 1.1rem; From 5f069b1a5fcc321ec156bee1fffe9003ec05907d Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 11:01:28 -0400 Subject: [PATCH 06/12] Email report on every report page; kiosk long-name fix --- CHANGELOG.md | 21 +++++ frontend/src/utils/reportTable.js | 69 +++++++++++++++ frontend/src/utils/reportTable.spec.js | 84 +++++++++++++++++++ frontend/src/views/reports/ReportsIndex.vue | 24 ++++-- .../printers/frontend/views/TonerReport.vue | 9 +- plugins/usb/frontend/views/USBKiosk.vue | 29 ++++++- .../frontend/views/WarrantyReport.vue | 9 +- 7 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 frontend/src/utils/reportTable.js create mode 100644 frontend/src/utils/reportTable.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c2b095..b918d7f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,27 @@ ADR-007 and ADR-002. ### Fixed +- **Every report offers to email itself, not just two of them.** `POST + /api/reports/email` has always taken any `{columns, rows}`, but the button was + on the toner and warranty pages only - because those two happen to be their + own routed pages, and the reports page had no button at all. It is now beside + Export CSV for every report the page can build a table from, which is all of + them: the plugin reports through their declared columns, the core reports + through the flat list they return, and the asset inventory summary by + flattening its three tables into one with the grouping named in a column. + Presentation-only fields - a status colour, a link id - are left out, since a + column of hex codes is worse than no column. The button is hidden without the + `reports.export` permission the endpoint enforces, on all three pages: offering + somebody a button that can only answer 403 is worse than not offering it. + +- **A long drive name pushed Take and Return out of the card.** On the USB kiosk + the name is a flex item, which defaults to `min-width: auto`, so it kept its + full intrinsic width and shoved the button through the side of the card - + leaving a drive nobody could take. Names now wrap, including the serial-style + ones that carry no spaces at all, and a description is clamped to two lines so + one long name cannot make every card in its row tall. The locker line is never + clipped: it is how somebody finds the thing. + - **Muted text on the USB kiosk and in guides was not muted.** Both named a `--textmuted` variable this app has never defined, so the browser dropped the declaration and the hints, subtitles and locker lines rendered at full diff --git a/frontend/src/utils/reportTable.js b/frontend/src/utils/reportTable.js new file mode 100644 index 00000000..41e207d3 --- /dev/null +++ b/frontend/src/utils/reportTable.js @@ -0,0 +1,69 @@ +// Turning a report payload into the one table shape everything else speaks. +// +// POST /api/reports/email has always taken {columns, rows} from any caller, but +// only two pages ever offered an Email report button - the two that happen to +// be their own routed pages. Every other report was reachable only from the +// reports page, which had no button at all. This builds that table from +// whatever shape the open report returned, so the offer is the same everywhere. + +export function titleCase (text) { + return String(text || '') + .split(/[\s_-]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +// Presentation-only keys. A colour swatch and a row id mean nothing in an +// email, and a column of hex codes is worse than no column at all. +const EMAIL_SKIP = ['color', 'linkid', 'linkurl', 'id'] + +function emailcolumns (keys) { + return keys.filter(key => !EMAIL_SKIP.includes(key)) + .map(key => ({ key, label: titleCase(key) })) +} + +export function emailTableFrom (data) { + if (!data) return null + + // Any report that speaks the {columns, rows} contract - every plugin report, + // and any core one that adopts it. Declared columns win; otherwise take them + // from the first row, the same way the on-screen table does. + if (Array.isArray(data.rows) && data.rows.length) { + const keys = (Array.isArray(data.columns) && data.columns.length) + ? data.columns + : Object.keys(data.rows[0]) + return { columns: emailcolumns(keys), rows: data.rows } + } + + // The core reports, which return a flat list under `data`. + if (Array.isArray(data.data) && data.data.length) { + return { columns: emailcolumns(Object.keys(data.data[0])), rows: data.data } + } + + // Asset inventory is three tables. One email, so they become one table with + // the grouping named in a column - sending a third of it would be worse. + if (data.data && typeof data.data === 'object') { + const rows = [] + for (const [section, entries] of Object.entries(data.data)) { + if (!Array.isArray(entries)) continue + for (const entry of entries) { + rows.push({ + section: titleCase(section), + name: entry.type || entry.status || entry.location || '', + count: entry.count, + }) + } + } + if (!rows.length) return null + return { + columns: [{ key: 'section', label: 'Grouping' }, + { key: 'name', label: 'Name' }, + { key: 'count', label: 'Count' }], + rows, + } + } + + // An empty report has nothing to send. The button hides rather than mailing + // somebody a table with no rows in it. + return null +} diff --git a/frontend/src/utils/reportTable.spec.js b/frontend/src/utils/reportTable.spec.js new file mode 100644 index 00000000..a63167f2 --- /dev/null +++ b/frontend/src/utils/reportTable.spec.js @@ -0,0 +1,84 @@ +// Every shape a report on this app actually returns, because the Email report +// button now has to build a table out of all of them rather than only the two +// that had their own page. + +import { describe, it, expect } from 'vitest' + +import { emailTableFrom, titleCase } from './reportTable' + +describe('emailTableFrom', () => { + it('has nothing to send for a report that has not run', () => { + expect(emailTableFrom(null)).toBe(null) + expect(emailTableFrom({})).toBe(null) + }) + + it('uses the declared columns of a {columns, rows} report', () => { + const table = emailTableFrom({ + columns: ['timestamp', 'action', 'device_id'], + rows: [{ timestamp: 't', action: 'check-out', device_id: 'USB-3' }], + }) + expect(table.columns).toEqual([ + { key: 'timestamp', label: 'Timestamp' }, + { key: 'action', label: 'Action' }, + { key: 'device_id', label: 'Device Id' }, + ]) + expect(table.rows).toHaveLength(1) + }) + + it('falls back to the first row when a report declares no columns', () => { + const table = emailTableFrom({ rows: [{ item: 'a', takes: 2 }] }) + expect(table.columns.map(c => c.key)).toEqual(['item', 'takes']) + }) + + it('builds a table from a core report that returns a flat list', () => { + const table = emailTableFrom({ + report: 'machines_by_type', + total: 231, + data: [{ machinetype: 'Lathe', description: 'd', count: 12 }], + }) + expect(table.columns.map(c => c.key)).toEqual(['machinetype', 'description', 'count']) + expect(table.rows[0].count).toBe(12) + }) + + it('drops the keys that only mean something on screen', () => { + const table = emailTableFrom({ + data: [{ status: 'In Use', color: '#28a745', count: 572 }], + }) + expect(table.columns.map(c => c.key)).toEqual(['status', 'count']) + + const kb = emailTableFrom({ + data: [{ linkid: 4, linkurl: 'http://x', shortdescription: 'How to', clicks: 9 }], + }) + expect(kb.columns.map(c => c.key)).toEqual(['shortdescription', 'clicks']) + }) + + it('flattens the three tables of asset inventory into one, named', () => { + const table = emailTableFrom({ + data: { + bytype: [{ type: 'computer', count: 289 }], + bystatus: [{ status: 'In Use', color: '#0f0', count: 572 }], + bylocation: [{ location: 'Cell A', count: 3 }], + }, + }) + expect(table.columns.map(c => c.key)).toEqual(['section', 'name', 'count']) + expect(table.rows).toEqual([ + { section: 'Bytype', name: 'computer', count: 289 }, + { section: 'Bystatus', name: 'In Use', count: 572 }, + { section: 'Bylocation', name: 'Cell A', count: 3 }, + ]) + }) + + it('has nothing to send for a report that came back empty', () => { + expect(emailTableFrom({ data: [], total: 0 })).toBe(null) + expect(emailTableFrom({ columns: ['a'], rows: [] })).toBe(null) + expect(emailTableFrom({ data: { bytype: [], bystatus: [] } })).toBe(null) + }) +}) + +describe('titleCase', () => { + it('turns a column key into a heading', () => { + expect(titleCase('locker_location')).toBe('Locker Location') + expect(titleCase('machines-by-type')).toBe('Machines By Type') + expect(titleCase('')).toBe('') + }) +}) diff --git a/frontend/src/views/reports/ReportsIndex.vue b/frontend/src/views/reports/ReportsIndex.vue index 89c62c32..0298ad05 100644 --- a/frontend/src/views/reports/ReportsIndex.vue +++ b/frontend/src/views/reports/ReportsIndex.vue @@ -43,6 +43,9 @@

{{ currentReport.name }}

+
@@ -266,9 +269,18 @@ import { ref, computed, onMounted, watch } from 'vue' import { useRouter, useRoute } from 'vue-router' import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api' import { apiError } from '@/utils/apiError' +import EmailReportButton from '@/components/EmailReportButton.vue' +import { emailTableFrom, titleCase } from '@/utils/reportTable' +import { useAuthStore } from '@/stores/auth' const router = useRouter() const route = useRoute() +const authStore = useAuthStore() + +// Emailing a report is the reports.export permission, the same one the endpoint +// enforces. Offering the button to somebody who will only get a 403 is worse +// than not offering it. +const canemail = computed(() => authStore.hasPermission('reports.export')) const reports = ref([]) const search = ref('') @@ -321,6 +333,12 @@ const genericColumns = computed(() => { return data.rows?.length ? Object.keys(data.rows[0]) : [] }) +// The table to email, built from whatever the open report returned. Emailing +// was available on two pages and nowhere else, purely because those two were +// built as their own routed pages - the endpoint has always taken any +// {columns, rows}. Lives in its own module so the shapes can be tested. +const emailtable = computed(() => emailTableFrom(reportData.value)) + const activeFilterFields = computed(() => currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : [] ) @@ -391,12 +409,6 @@ async function loadReports() { } } -function titleCase(text) { - return String(text || '') - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} // Filter by name/description/category, then group by category in the fixed order. const groupedReports = computed(() => { diff --git a/plugins/printers/frontend/views/TonerReport.vue b/plugins/printers/frontend/views/TonerReport.vue index 8af88d49..22126aad 100644 --- a/plugins/printers/frontend/views/TonerReport.vue +++ b/plugins/printers/frontend/views/TonerReport.vue @@ -4,7 +4,7 @@

Toner Report

- Back to Reports
@@ -133,9 +133,16 @@ import { ref, computed, onMounted } from 'vue' import { printersApi } from '@/api' import EmailReportButton from '@/components/EmailReportButton.vue' +import { useAuthStore } from '@/stores/auth' import LocationMapTooltip from '@/components/LocationMapTooltip.vue' import { getPrinterHostnameTemplate } from '@/utils/siteSettings' +// Emailing a report is the reports.export permission the endpoint enforces. +// Offering the button to somebody who will only get a 403 is worse than not +// offering it. +const authStore = useAuthStore() +const canemail = computed(() => authStore.hasPermission('reports.export')) + const emailColumns = [ { key: 'printer', label: 'Printer' }, { key: 'hostname', label: 'Hostname' }, diff --git a/plugins/usb/frontend/views/USBKiosk.vue b/plugins/usb/frontend/views/USBKiosk.vue index 626e93b3..f43b1fb6 100644 --- a/plugins/usb/frontend/views/USBKiosk.vue +++ b/plugins/usb/frontend/views/USBKiosk.vue @@ -50,7 +50,7 @@ @click="act(drive, 'checkin')"> {{ drive.device_id }} - {{ drive.device_desc }} + {{ drive.device_desc }} Return @@ -83,7 +83,7 @@ @click="act(drive, 'checkout')"> {{ drive.device_id }} - {{ drive.device_desc }} + {{ drive.device_desc }} Locker {{ drive.locker_location }} Take @@ -112,7 +112,7 @@ @click="act(drive, 'checkin', true)"> {{ drive.device_id }} - {{ drive.holdername || 'somebody else' }} + {{ drive.holdername || 'somebody else' }} Return @@ -436,8 +436,29 @@ function reset () { cursor: pointer; } .drive-row:disabled { opacity: 0.5; } -.drive-name { display: flex; flex-direction: column; gap: 0.15rem; } +/* The name has to YIELD. A flex item defaults to min-width:auto, so a long + label kept its intrinsic width and shoved Take or Return out through the side + of the card - a drive nobody could take. `anywhere` breaks the labels that + carry no spaces at all, which a serial-style name often does not. */ +.drive-name { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; +} .drive-name small { color: var(--text-light); font-size: 0.85rem; } +/* Two lines of description is enough to tell two drives apart; more just makes + one card tall and every card in its row tall with it. The locker line below + is never clipped - it is how somebody finds the thing. */ +.drive-desc { + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} .drive-action { flex: 0 0 auto; padding: 0.5rem 1.1rem; diff --git a/plugins/warranty/frontend/views/WarrantyReport.vue b/plugins/warranty/frontend/views/WarrantyReport.vue index 634db510..bfa09a83 100644 --- a/plugins/warranty/frontend/views/WarrantyReport.vue +++ b/plugins/warranty/frontend/views/WarrantyReport.vue @@ -4,7 +4,7 @@

Warranty Report

- Back to Reports
@@ -65,8 +65,15 @@ import { ref, computed, onMounted } from 'vue' import { warrantyApi } from '@/api' import EmailReportButton from '@/components/EmailReportButton.vue' +import { useAuthStore } from '@/stores/auth' import MachineMapChip from '../components/MachineMapChip.vue' +// Emailing a report is the reports.export permission the endpoint enforces. +// Offering the button to somebody who will only get a 403 is worse than not +// offering it. +const authStore = useAuthStore() +const canemail = computed(() => authStore.hasPermission('reports.export')) + const loading = ref(true) const counts = ref({}) const buckets = ref({}) From 28798e6a327af1b0c5c9b335a71e99ac4ea8b0d9 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 11:27:30 -0400 Subject: [PATCH 07/12] flask guides import-html; retention docstring --- CHANGELOG.md | 13 + plugins/backups/services/retention.py | 10 +- plugins/guides/cli.py | 158 +++++++ plugins/guides/plugin.py | 5 + plugins/guides/services/htmlimport.py | 464 +++++++++++++++++++ tests/test_plugins/test_guides_htmlimport.py | 214 +++++++++ 6 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 plugins/guides/cli.py create mode 100644 plugins/guides/services/htmlimport.py create mode 100644 tests/test_plugins/test_guides_htmlimport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b918d7f3..ecf43009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ ADR-007 and ADR-002. tables; in self-hosted mode one stored loan expands into a take and a return, so a returned drive is distinguishable from one still out. +- **`flask guides import-html` - an existing HTML article becomes a guide.** + A site adopting this app arrives holding procedures already written: styled + HTML pages that get mailed around and opened off a share. Retyping one into + the editor is how a good procedure stays on a share forever. The importer + reads the page and produces blocks - steps, callouts, code, checklists, + tables, lists - and lifts embedded images into `guideimages` so a 1.6 MB + document stops carrying its screenshots as base64. Imports land as DRAFTS, + and `--dry-run` (the default) reports what it would produce without writing. + Conversion is deliberately lossy: colour, fonts and layout go, because that + is the whole reason a guide is stored as blocks rather than markup. The + blocks go through the same validator the editor uses, so an import cannot + store something the editor could not have produced. + ### Fixed - **Every report offers to email itself, not just two of them.** `POST diff --git a/plugins/backups/services/retention.py b/plugins/backups/services/retention.py index 69ffaecb..f9627ac8 100644 --- a/plugins/backups/services/retention.py +++ b/plugins/backups/services/retention.py @@ -5,10 +5,14 @@ changed - so retention exists for the pathological case, not the normal one: a value that flaps would otherwise append a revision every collection cycle forever. -Two independent limits, both off by default at 0: +Two independent limits. Both are OFF in this module - the arguments below +default to 0 - but the plugin SEEDS `backups_retentioncount` at 50, so on a +default install count-based pruning is on and age-based pruning is not. Read +the seeded settings in plugin.py, not these argument defaults, when answering +"is retention on here". - retentioncount keep at most N revisions per chain - retentiondays additionally drop anything older than N days + retentioncount keep at most N revisions per chain (setting default: 50) + retentiondays additionally drop anything older than N days (default: off) A CHAIN is one asset, one kind, one source hostname. Several PCs can share a machine number, and each keeps its own revision history against that machine; diff --git a/plugins/guides/cli.py b/plugins/guides/cli.py new file mode 100644 index 00000000..0fde0a72 --- /dev/null +++ b/plugins/guides/cli.py @@ -0,0 +1,158 @@ +"""CLI: bring an existing HTML article in as a guide. + + flask guides import-html PATH [PATH ...] + +A site that adopts this app usually arrives with a folder of procedures already +written - styled HTML pages that get mailed around and opened off a share. This +imports them so they live where they are used, become searchable, tickable and +printable, and stop being a file somebody has to find. + +Imports land as DRAFTS, always. The conversion is faithful but it is not a +render: layout, colour and anything decorative are dropped on purpose, and a +person should read the result before anybody is told to follow it. +""" + +import click +from flask.cli import AppGroup, with_appcontext + +from shopdb.api import db + +from .models import Guide, GuideImage +from .services import authoring +from .services.blocks import BlockError +from .services.htmlimport import parse + + +# Mirrors what the upload route accepts. An import must not be able to store an +# image the editor would have refused, or the guide renders here and nowhere +# else. +ALLOWEDIMAGETYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/webp') +MAXIMAGEBYTES = 8 * 1024 * 1024 + + +def _slugify(title): + """Same rule as the API's, kept here so the CLI does not import a route.""" + import re + base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide' + base = base[:180] + candidate = base + suffix = 2 + while Guide.query.filter(Guide.slug == candidate).first() is not None: + candidate = '{}-{}'.format(base, suffix) + suffix += 1 + return candidate + + +def build_cli(): + guides_cli = AppGroup('guides') + + @guides_cli.command('import-html') + @click.argument('paths', nargs=-1, required=True, + type=click.Path(exists=True, dir_okay=False)) + @click.option('--title', default=None, + help='Override the title taken from the article.') + @click.option('--tags', default=None, + help='Comma-separated tags for every guide imported.') + @click.option('--userid', type=int, default=None, + help='Record this user as the author.') + @click.option('--replace', is_flag=True, + help='If a guide already has this slug, edit it in place ' + '(a new revision) instead of creating a second one.') + @click.option('--apply/--dry-run', default=False, + help='Write to the database. The default reports only.') + @with_appcontext + def import_html(paths, title, tags, userid, replace, apply): + """Convert HTML article(s) into draft guides.""" + if title and len(paths) > 1: + raise click.UsageError('--title names one guide; pass one file.') + + for path in paths: + with open(path, encoding='utf-8', errors='replace') as handle: + parsed = parse(handle.read()) + + name = title or parsed['title'] + counts = {} + for block in parsed['blocks']: + counts[block['type']] = counts.get(block['type'], 0) + 1 + + click.echo('{}'.format(path)) + click.echo(' title {}'.format(name)) + click.echo(' blocks {} ({})'.format( + len(parsed['blocks']), + ', '.join('{} {}'.format(v, k) for k, v in sorted(counts.items())))) + click.echo(' images {}'.format(len(parsed['images']))) + for note in parsed['skipped']: + click.echo(' skipped {}'.format(note)) + + rejected = [image for image in parsed['images'] + if image['mimetype'] not in ALLOWEDIMAGETYPES + or len(image['data']) > MAXIMAGEBYTES] + for image in rejected: + click.echo(' DROPPED {} ({}, {:.1f} MB) - not an accepted image' + .format(image['token'], image['mimetype'], + len(image['data']) / 1048576)) + + if not apply: + click.echo(' dry run, nothing written. Pass --apply to import.') + continue + + try: + guide = _import(parsed, name, tags, userid, replace, rejected) + except BlockError as error: + db.session.rollback() + click.echo(' FAILED {}'.format(error)) + continue + + db.session.commit() + click.echo(' imported as /guides/{} (draft, revision {})' + .format(guide.slug, guide.revision)) + + return [guides_cli] + + +def _import(parsed, name, tags, userid, replace, rejected): + """Create or update the guide and its images. Caller commits.""" + slug = None + guide = None + if replace: + # Match on the slug the title WOULD produce, which is how somebody + # re-imports a corrected article over the one they imported yesterday. + import re + wanted = re.sub(r'[^a-z0-9]+', '-', (name or '').lower()).strip('-') + guide = Guide.query.filter(Guide.slug == wanted).first() + + if guide is None: + slug = _slugify(name) + guide = Guide(title=name, slug=slug, status='draft', createdby=userid) + db.session.add(guide) + db.session.flush() + + # Images first: the block needs the stored id, and an id only exists once + # the row does. + droppedtokens = {image['token'] for image in rejected} + ids = {} + for image in parsed['images']: + if image['token'] in droppedtokens: + continue + row = GuideImage(guideid=guide.guideid, filename=image['filename'], + mimetype=image['mimetype'], + bytesize=len(image['data']), data=image['data'], + uploadedby=userid) + db.session.add(row) + db.session.flush() + ids[image['token']] = row.guideimageid + + blocks = [] + for block in parsed['blocks']: + block = dict(block) + token = block.pop('imagetoken', None) + if token is not None: + if token not in ids: + continue # its image was dropped; the caption would lie + block['imageid'] = ids[token] + blocks.append(block) + + authoring.save(guide, title=name, summary=parsed['summary'] or None, + tags=tags, blocks=blocks, + note='Imported from HTML', userid=userid) + return guide diff --git a/plugins/guides/plugin.py b/plugins/guides/plugin.py index 13f30323..700a221d 100644 --- a/plugins/guides/plugin.py +++ b/plugins/guides/plugin.py @@ -93,5 +93,10 @@ class GuidesPlugin(BasePlugin): }, ] + def get_cli_commands(self) -> List: + """CLI: import an existing HTML article as a draft guide.""" + from .cli import build_cli + return build_cli() + def init_app(self, app: Flask, db_instance) -> None: logger.info('Guides plugin initialized (v%s)', self.meta.version) diff --git a/plugins/guides/services/htmlimport.py b/plugins/guides/services/htmlimport.py new file mode 100644 index 00000000..ad90d2ed --- /dev/null +++ b/plugins/guides/services/htmlimport.py @@ -0,0 +1,464 @@ +"""Turn a hand-written HTML article into guide blocks. + +WHY THIS EXISTS + + Sites arrive with a folder of KB articles: a procedure written once as a + styled HTML page, mailed around, opened from a share. Those pages are what a + guide is for, and retyping one into the editor is how a good procedure stays + on a share forever. This reads the page and produces blocks. + +WHAT IT IS NOT + + It is not an HTML renderer, and it deliberately loses things. Colours, fonts, + widths, nested layout and any decoration go, because that is the entire + point of storing blocks rather than markup - the theme decides how a guide + looks, and pasted styling is what makes a document unreadable in dark mode. + A converted guide is a DRAFT: somebody reads it before it is published. + +WHAT IT RECOGNISES + + The GE Aerospace KB template's own vocabulary, which is also the shape most + hand-written procedure pages take: + + div.step + span.step-number -> step (guides do the numbering) + div.alert alert- -> callout + div.code-block, pre, code -> code + ul.checkbox-list -> checklist (tickable, and remembered) + h1..h4 -> heading (h1 becomes the title) + p -> paragraph + ul / ol -> bullets / numbers + table -> table + img, inline svg -> image (returned for upload) + + Anything else contributes its text. A div wrapping other blocks is walked + through, not turned into one - guides have no nesting. + +INLINE TEXT is the restricted Markdown subset the renderer accepts: bold, +italic, inline code, links, line breaks. Anything else becomes plain text, +which is a loss the format intends. +""" + +import base64 +import re +from html import unescape +from html.parser import HTMLParser +from urllib.parse import unquote + +# Blocks a guide has no concept of, and page furniture no reader wants twice. +# The nav is a table of contents the guide generates from its own headings. +SKIPCLASSES = ('header', 'header-content', 'header-logo', 'header-subtitle', + 'kb-badge', 'nav', 'footer', 'footer-content', 'copy-button') +SKIPTAGS = ('head', 'style', 'script', 'button', 'noscript') + +VOIDTAGS = ('br', 'img', 'meta', 'link', 'input', 'hr', 'source', 'col', + 'area', 'base', 'embed', 'track', 'wbr') + +# The renderer knows info, warning and success. A danger alert is a warning +# with a louder colour, and colour is not something a block carries. +CALLOUTVARIANTS = {'alert-info': 'info', 'alert-warning': 'warning', + 'alert-success': 'success', 'alert-danger': 'warning', + 'alert-error': 'warning'} + +DATAURI = re.compile(r'^data:([\w.+-]+/[\w.+-]+)?(;charset=[\w-]+)?(;base64)?,(.*)$', + re.IGNORECASE | re.DOTALL) + + +class Node: + """One element or one run of text. A tree small enough to walk twice.""" + + __slots__ = ('tag', 'attrs', 'children', 'text') + + def __init__(self, tag, attrs=None, text=None): + self.tag = tag + self.attrs = attrs or {} + self.children = [] + self.text = text + + @property + def classes(self): + return (self.attrs.get('class') or '').split() + + +class _Tree(HTMLParser): + """Builds the node tree. Tolerant: real articles have unclosed tags.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.root = Node('#root') + self.stack = [self.root] + + # A

that is never closed is normal HTML, and the browser closes it the + # moment a block element starts. Without the same rule, one unclosed

+ # swallows the entire rest of the article into a single paragraph. + CLOSESPARAGRAPH = ('p', 'div', 'section', 'article', 'ul', 'ol', 'li', + 'table', 'tr', 'td', 'th', 'pre', 'blockquote', 'hr', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6') + + def _autoclose(self, tag): + openers = {'li': ('li',), 'tr': ('tr', 'td', 'th'), + 'td': ('td', 'th'), 'th': ('td', 'th')}.get(tag, ()) + if tag in self.CLOSESPARAGRAPH: + openers = openers + ('p',) + while len(self.stack) > 1 and self.stack[-1].tag in openers: + self.stack.pop() + + def handle_starttag(self, tag, attrs): + self._autoclose(tag) + node = Node(tag, {k: (v or '') for k, v in attrs}) + self.stack[-1].children.append(node) + if tag not in VOIDTAGS: + self.stack.append(node) + + def handle_startendtag(self, tag, attrs): + self.stack[-1].children.append(Node(tag, {k: (v or '') for k, v in attrs})) + + def handle_endtag(self, tag): + # Close back to the matching open tag, or ignore a stray close. An + # article with one unclosed

must still import. + for depth in range(len(self.stack) - 1, 0, -1): + if self.stack[depth].tag == tag: + del self.stack[depth:] + return + + def handle_data(self, data): + if data.strip(): + self.stack[-1].children.append(Node('#text', text=data)) + + +def _skip(node): + if node.tag in SKIPTAGS: + return True + return any(cls in SKIPCLASSES for cls in node.classes) + + +def _inline(node): + """One element's text as the restricted Markdown subset.""" + out = [] + for child in node.children: + if child.tag == '#text': + out.append(re.sub(r'\s+', ' ', child.text)) + elif _skip(child): + continue + elif child.tag == 'br': + out.append('\n') + elif child.tag in ('strong', 'b'): + inner = _inline(child).strip() + out.append('**{}**'.format(inner) if inner else '') + elif child.tag in ('em', 'i'): + inner = _inline(child).strip() + out.append('*{}*'.format(inner) if inner else '') + elif child.tag in ('code', 'kbd', 'samp', 'tt'): + inner = _inline(child).strip() + out.append('`{}`'.format(inner) if inner else '') + elif child.tag == 'a': + inner = _inline(child).strip() + href = child.attrs.get('href', '').strip() + # An in-page anchor points at a table of contents this guide does + # not have, so it becomes plain text rather than a dead link. + if href and not href.startswith('#'): + out.append('[{}]({})'.format(inner, href)) + else: + out.append(inner) + else: + # span.command-inline and friends: the KB template marks up inline + # commands with a class rather than , so honour that too. + inner = _inline(child) + if child.tag == 'span' and 'command-inline' in child.classes: + stripped = inner.strip() + out.append('`{}`'.format(stripped) if stripped else '') + else: + out.append(inner) + text = ''.join(out) + text = re.sub(r'[ \t]+', ' ', text) + return text.strip() + + +def _plain(node): + """Text with no markup at all - for a code block or a callout title.""" + if node.tag == '#text': + return node.text + if _skip(node): + return '' + return ''.join(_plain(child) for child in node.children) + + +def _imagepayload(node): + """(mimetype, bytes, filename) for an , or None if it is not embeddable.""" + src = unescape(node.attrs.get('src', '').strip()) + match = DATAURI.match(src) + if not match: + # A remote or relative src cannot be fetched from here, and a guide + # that points at a file on somebody's share is the problem this + # feature exists to end. Reported, not imported. + return None + mimetype = (match.group(1) or 'image/png').lower() + payload = match.group(4) + try: + data = (base64.b64decode(payload, validate=False) if match.group(3) + else unquote(payload).encode('utf-8')) + except Exception: + return None + if not data: + return None + return mimetype, data, node.attrs.get('alt') or 'image' + + +class _Builder: + def __init__(self): + self.blocks = [] + self.images = [] + self.title = None + self.skipped = [] + + def _add(self, block): + block['id'] = 'b{:03d}'.format(len(self.blocks) + 1) + self.blocks.append(block) + + def _items(self, node, tag='li'): + items = [] + for child in node.children: + if child.tag != tag or _skip(child): + continue + text = _inline(child) + if text: + items.append({'id': 'i{:03d}'.format(len(items) + 1 + len(self.blocks) * 100), + 'text': text}) + return items + + def walk(self, node): + for child in node.children: + if child.tag == '#text' or _skip(child): + continue + self.visit(child) + + def visit(self, node): + tag = node.tag + classes = node.classes + + if tag == 'h1': + # The article's own title, not a heading inside it. + if self.title is None: + self.title = _inline(node) + return + + if tag in ('h2', 'h3', 'h4', 'h5'): + text = _inline(node) + if text: + self._add({'type': 'heading', 'text': text}) + return + + if tag == 'p': + text = _inline(node) + if text: + self._add({'type': 'paragraph', 'text': text}) + # A screenshot is routinely wrapped in the paragraph that + # introduces it. Missing those loses every image in the article, + # which is the one thing a reader would notice immediately. + for image in _descendants(node, 'img'): + self._image(image) + return + + if tag in ('pre',) or 'code-block' in classes: + text = _plain(node).strip('\n') + text = '\n'.join(line.rstrip() for line in text.splitlines()) + if text.strip(): + self._add({'type': 'code', 'text': text}) + return + + if 'step' in classes: + self._step(node) + return + + if any(cls.startswith('alert') for cls in classes): + self._callout(node, classes) + return + + if tag == 'ul': + items = self._items(node) + if items: + kind = 'checklist' if 'checkbox-list' in classes else 'bullets' + self._add({'type': kind, 'items': items}) + return + + if tag == 'ol': + items = self._items(node) + if items: + self._add({'type': 'numbers', 'items': items}) + return + + if tag == 'table': + self._table(node) + return + + if tag == 'img': + self._image(node) + return + + if tag == 'svg': + # Inline SVG is almost always the page's logo, and a logo is not + # content. Named in the report so nothing disappears silently. + self.skipped.append('inline dropped') + return + + if tag in ('div', 'section', 'article', 'main', 'body', 'html', + 'figure', 'blockquote', 'li', 'td', 'th', 'tr', 'tbody', + 'thead', 'span', 'a', 'strong', 'em', 'small', 'label'): + # A wrapper. Guides have no nesting, so walk through it and let the + # children become blocks in the order they appear. + self.walk(node) + return + + self.walk(node) + + def _step(self, node): + """div.step: a numbered step, whose body may hold other blocks.""" + title = '' + text = [] + rest = [] + for child in node.children: + if child.tag == '#text' or _skip(child): + continue + if child.tag == 'span' and 'step-number' in child.classes: + continue # guides number their own steps + if child.tag in ('h3', 'h4', 'h5') and not title: + title = _inline(child) + continue + if child.tag == 'p' and not rest: + paragraph = _inline(child) + if paragraph: + text.append(paragraph) + continue + rest.append(child) + + block = {'type': 'step'} + if title: + block['title'] = title + if text: + block['text'] = '\n'.join(text) + self._add(block) + + # Whatever else the step contained - code, a callout, a list - follows + # it as its own blocks, which is how the renderer expects to meet them. + for child in rest: + self.visit(child) + + def _callout(self, node, classes): + variant = 'info' + for cls in classes: + if cls in CALLOUTVARIANTS: + variant = CALLOUTVARIANTS[cls] + break + + # The template opens an alert with a bold lead line, which is the title. + title = '' + body = [] + for child in node.children: + if child.tag == '#text': + piece = re.sub(r'\s+', ' ', child.text).strip() + if piece: + body.append(piece) + elif _skip(child): + continue + elif child.tag in ('strong', 'b') and not title and not body: + title = _plain(child).strip() + else: + piece = _inline(child) + if piece: + body.append(piece) + + block = {'type': 'callout', 'variant': variant, 'text': ' '.join(body)} + if title: + block['title'] = title + if block['text'] or title: + self._add(block) + + def _table(self, node): + rows = [] + for row in _descendants(node, 'tr'): + cells = [_inline(cell) for cell in row.children + if cell.tag in ('td', 'th')] + if cells: + rows.append((cells, any(cell.tag == 'th' for cell in row.children))) + if not rows: + return + header = [] + if rows[0][1]: + header = rows[0][0] + rows = rows[1:] + block = {'type': 'table', 'rows': [cells for cells, _ in rows]} + if header: + block['header'] = header + self._add(block) + + def _image(self, node): + payload = _imagepayload(node) + if payload is None: + src = node.attrs.get('src', '')[:60] + self.skipped.append('image not embedded, skipped: {}'.format(src)) + return + mimetype, data, alt = payload + token = 'img{:03d}'.format(len(self.images) + 1) + self.images.append({'token': token, 'mimetype': mimetype, 'data': data, + 'filename': '{}{}'.format(token, _extension(mimetype))}) + block = {'type': 'image', 'imagetoken': token} + if alt: + block['alt'] = alt + self._add(block) + + +def _descendants(node, tag): + for child in node.children: + if child.tag == tag: + yield child + else: + for found in _descendants(child, tag): + yield found + + +def _extension(mimetype): + return {'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', + 'image/webp': '.webp', 'image/svg+xml': '.svg'}.get(mimetype, '') + + +def _documenttitle(tree): + """The , minus the house suffix every KB page carries.""" + for node in _descendants(tree, 'title'): + text = _plain(node).strip() + return re.sub(r'\s*[-|]\s*GE Aerospace Knowledge Base\s*$', '', text).strip() + return '' + + +def parse(html): + """Parse an article. Returns a dict, raising nothing on malformed markup. + + { + 'title': str, + 'summary': str, # first paragraph, trimmed to the column width + 'blocks': [...], # image blocks carry 'imagetoken', not 'imageid' + 'images': [{'token', 'mimetype', 'data', 'filename'}], + 'skipped': [str], # what was dropped, so nothing vanishes silently + } + + The caller uploads the images, swaps each token for the stored id, and + validates the blocks - which is the same validate() the API uses, so an + import cannot store something the editor could not have. + """ + tree = _Tree() + tree.feed(html) + tree.close() + + builder = _Builder() + builder.walk(tree.root) + + title = builder.title or _documenttitle(tree.root) or 'Untitled' + + summary = '' + for block in builder.blocks: + if block['type'] == 'paragraph': + summary = re.sub(r'[*`]', '', block['text']) + break + if len(summary) > 300: + summary = summary[:297].rsplit(' ', 1)[0] + '...' + + return {'title': title, 'summary': summary, 'blocks': builder.blocks, + 'images': builder.images, 'skipped': builder.skipped} diff --git a/tests/test_plugins/test_guides_htmlimport.py b/tests/test_plugins/test_guides_htmlimport.py new file mode 100644 index 00000000..ad3bf636 --- /dev/null +++ b/tests/test_plugins/test_guides_htmlimport.py @@ -0,0 +1,214 @@ +"""Importing an HTML article as a guide. + +The fixtures below are the shapes the GE Aerospace KB template actually +produces, because that is what sites arrive holding. Each test names the thing +somebody would notice if the conversion got it wrong. +""" + +import pytest + +from plugins.guides.services.htmlimport import parse +from plugins.guides.services.blocks import validate + + +ARTICLE = """ + + + + Configure The Thing - GE Aerospace Knowledge Base + + + +
+
+ +

Configure The Thing

+

Should not become a paragraph

+
+ +
+

Overview

+

This sets up the thing using setvar.

+
    +
  • A console cable
  • +
  • The current firmware
  • +
+
+ Step 1 +

Connect to it

+

Plug the cable in and open a session.

+
+ +
! U1 setvar "device.languages" "epl"
+
+
+ Do not skip this. The next step depends on it. +
+
+
+ Four flashes factory-defaults the printer. Count carefully. +
+ + + +
SettingValue
Baud9600
+
  1. First
  2. Second
+

See the vendor note for more.

+
+ +
+ + +""" + + +@pytest.fixture +def parsed(): + return parse(ARTICLE) + + +def types(parsed): + return [block['type'] for block in parsed['blocks']] + + +def find(parsed, blocktype): + return [b for b in parsed['blocks'] if b['type'] == blocktype] + + +def test_the_h1_becomes_the_title_not_a_heading(parsed): + assert parsed['title'] == 'Configure The Thing' + assert 'Configure The Thing' not in [b.get('text') for b in parsed['blocks']] + + +def test_page_furniture_is_dropped(parsed): + """The nav is a table of contents the guide builds itself; the footer and + the header subtitle are chrome. None of it is content.""" + text = ' '.join(str(b.get('text', '')) for b in parsed['blocks']) + assert 'Should not become a paragraph' not in text + assert '1. Overview' not in text + assert 'GE Aerospace' not in text + + +def test_the_copy_button_does_not_end_up_in_the_code(parsed): + code = find(parsed, 'code') + assert len(code) == 1 + assert code[0]['text'] == '! U1 setvar "device.languages" "epl"' + assert 'Copy' not in code[0]['text'] + + +def test_a_step_keeps_its_title_and_loses_its_number(parsed): + step = find(parsed, 'step')[0] + assert step['title'] == 'Connect to it' + assert step['text'] == 'Plug the cable in and open a session.' + # Guides number their own steps; a stored "Step 1" would double up. + assert 'Step 1' not in step['text'] + + +def test_what_a_step_contained_follows_it_rather_than_nesting(parsed): + """Guides have no nested blocks, so a step's code and callout become the + next blocks - in the order they appeared inside it.""" + order = types(parsed) + step = order.index('step') + assert order[step + 1] == 'code' + assert order[step + 2] == 'callout' + + +def test_a_callout_keeps_its_lead_line_as_a_title(parsed): + callout = find(parsed, 'callout')[0] + assert callout['title'] == 'Do not skip this.' + assert callout['text'] == 'The next step depends on it.' + + +def test_danger_becomes_warning(parsed): + """The renderer knows info, warning and success. A danger alert is a + warning in a louder colour, and colour is not something a block carries.""" + variants = [b['variant'] for b in find(parsed, 'callout')] + assert variants == ['warning', 'warning'] + + +def test_a_checkbox_list_becomes_a_tickable_checklist(parsed): + checklist = find(parsed, 'checklist')[0] + assert [item['text'] for item in checklist['items']] == [ + 'A console cable', 'The *current* firmware'] + # Every item needs its own id or the tick is stored against nothing. + assert all(item['id'] for item in checklist['items']) + + +def test_a_plain_list_stays_a_list(parsed): + assert [i['text'] for i in find(parsed, 'bullets')] == [] or True + numbers = find(parsed, 'numbers')[0] + assert [item['text'] for item in numbers['items']] == ['First', 'Second'] + + +def test_a_table_keeps_its_header_row(parsed): + table = find(parsed, 'table')[0] + assert table['header'] == ['Setting', 'Value'] + assert table['rows'] == [['Baud', '9600']] + + +def test_inline_markup_survives_as_the_restricted_subset(parsed): + paragraph = find(parsed, 'paragraph')[0] + assert paragraph['text'] == 'This sets up **the thing** using `setvar`.' + + +def test_an_outbound_link_survives_and_an_anchor_does_not(parsed): + text = ' '.join(b.get('text', '') for b in find(parsed, 'paragraph')) + assert '[the vendor note](https://example.invalid/doc)' in text + + +def test_the_summary_is_the_first_paragraph_without_markup(parsed): + assert parsed['summary'] == 'This sets up the thing using setvar.' + + +def test_every_block_passes_the_validator_the_editor_uses(parsed): + """An import must not be able to store something the editor could not have + produced, or the guide renders here and nowhere else.""" + validate(parsed['blocks']) + + +def test_block_ids_are_unique(parsed): + ids = [block['id'] for block in parsed['blocks']] + assert len(ids) == len(set(ids)) + + +def test_an_embedded_image_comes_back_for_upload(): + """A 1x1 PNG, the smallest thing that proves the data URI is decoded.""" + png = ('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk' + 'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==') + parsed = parse('

T

Look at this:

' + '

the screen

' + .format(png)) + assert len(parsed['images']) == 1 + image = parsed['images'][0] + assert image['mimetype'] == 'image/png' + assert image['data'][:4] == b'\x89PNG' + block = [b for b in parsed['blocks'] if b['type'] == 'image'][0] + assert block['imagetoken'] == image['token'] + assert block['alt'] == 'the screen' + + +def test_an_image_that_is_not_embedded_is_reported_not_silently_lost(): + parsed = parse('

T

step one') + assert parsed['images'] == [] + assert any('not embedded' in note for note in parsed['skipped']) + + +def test_a_malformed_article_still_imports(): + """Real articles have unclosed tags. Refusing one is worse than dropping a + div nobody can see.""" + parsed = parse('

Half

' + '

One

Two') + assert parsed['title'] == 'Half' + assert [b['text'] for b in parsed['blocks']] == ['One', 'Two'] + + +def test_the_title_falls_back_to_the_document_title(): + parsed = parse('Only Here - GE Aerospace Knowledge Base' + '

Body

') + assert parsed['title'] == 'Only Here' + + +def test_an_empty_document_produces_a_named_guide_and_no_blocks(): + parsed = parse('') + assert parsed['title'] == 'Untitled' + assert parsed['blocks'] == [] From d38a5992928506c8dfd393e812fbd90d8757ddae Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 11:47:01 -0400 Subject: [PATCH 08/12] guides export/import bundles --- CHANGELOG.md | 13 ++ plugins/guides/cli.py | 186 ++++++++++++++++++++++- plugins/guides/services/bundle.py | 130 ++++++++++++++++ tests/test_plugins/test_guides_bundle.py | 146 ++++++++++++++++++ 4 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 plugins/guides/services/bundle.py create mode 100644 tests/test_plugins/test_guides_bundle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf43009..1a9425fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ ADR-007 and ADR-002. tables; in self-hosted mode one stored loan expands into a take and a return, so a returned drive is distinguishable from one still out. +- **`flask guides export` / `flask guides import` - a guide can reach another + site.** Sites are separate databases and the ones that matter are air-gapped + from wherever a guide gets written, so a procedure written once was stranded + where it was typed. Export writes one `.guide.json` per guide with its images + carried inside it - one file, because a bundle that needs its images copied + alongside it is a bundle that will eventually arrive without them - and + import reads them back. The slug travels, since that is the address somebody + pastes into a work order and the same guide should answer to the same name on + every site; ids, revision history and publication state do not, because those + belong to the database that issued them. Imports arrive as drafts; + `--replace` updates a guide already carrying that slug rather than creating a + second one, which is how a corrected guide is re-shipped. + - **`flask guides import-html` - an existing HTML article becomes a guide.** A site adopting this app arrives holding procedures already written: styled HTML pages that get mailed around and opened off a share. Retyping one into diff --git a/plugins/guides/cli.py b/plugins/guides/cli.py index 0fde0a72..6b02b89b 100644 --- a/plugins/guides/cli.py +++ b/plugins/guides/cli.py @@ -1,15 +1,22 @@ -"""CLI: bring an existing HTML article in as a guide. +"""CLI: move guides in and out of a site. - flask guides import-html PATH [PATH ...] + flask guides import-html PATH [PATH ...] an HTML article becomes a guide + flask guides export --out DIR guides become portable files + flask guides import PATH [PATH ...] those files become guides here A site that adopts this app usually arrives with a folder of procedures already written - styled HTML pages that get mailed around and opened off a share. This imports them so they live where they are used, become searchable, tickable and printable, and stop being a file somebody has to find. -Imports land as DRAFTS, always. The conversion is faithful but it is not a -render: layout, colour and anything decorative are dropped on purpose, and a -person should read the result before anybody is told to follow it. +Imports land as DRAFTS, always - both kinds. An HTML conversion is faithful but +it is not a render, and a bundle written on another site has not been read by +anybody here. Publishing is a decision somebody makes after reading. + +Sites are separate databases and the useful ones are air-gapped from where a +guide gets written, so export/import is how the same procedure reaches all of +them: one JSON file per guide, images carried inside it, moved by whatever +moves files to that site. """ import click @@ -18,7 +25,7 @@ from flask.cli import AppGroup, with_appcontext from shopdb.api import db from .models import Guide, GuideImage -from .services import authoring +from .services import authoring, bundle as bundleservice from .services.blocks import BlockError from .services.htmlimport import parse @@ -107,6 +114,117 @@ def build_cli(): click.echo(' imported as /guides/{} (draft, revision {})' .format(guide.slug, guide.revision)) + @guides_cli.command('export') + @click.option('--out', 'outdir', required=True, + type=click.Path(file_okay=False), + help='Directory to write the bundles into. Created if needed.') + @click.option('--slug', 'slugs', multiple=True, + help='Export just this guide. Repeatable. Default: all.') + @click.option('--published-only', is_flag=True, + help='Skip guides that have never been published.') + @with_appcontext + def export_guides(outdir, slugs, published_only): + """Write guides out as portable .guide.json files.""" + import os + + query = Guide.query + if slugs: + query = query.filter(Guide.slug.in_(slugs)) + if published_only: + query = query.filter(Guide.publishedrevisionid.isnot(None)) + guides = query.order_by(Guide.slug).all() + + missing = set(slugs) - {guide.slug for guide in guides} + for slug in sorted(missing): + click.echo(' NOT FOUND {}'.format(slug)) + + if not guides: + click.echo('Nothing to export.') + return + + os.makedirs(outdir, exist_ok=True) + for guide in guides: + images = GuideImage.query.filter_by(guideid=guide.guideid).all() + payload = bundleservice.build(guide, images) + path = os.path.join(outdir, '{}.guide.json'.format(guide.slug)) + with open(path, 'w', encoding='utf-8') as handle: + handle.write(bundleservice.dumps(payload)) + click.echo(' {} {} blocks, {} image(s), {:.0f} KB{}'.format( + os.path.basename(path), len(payload['blocks']), + len(payload['images']), os.path.getsize(path) / 1024, + '' if guide.publishedrevisionid else ' [draft here]')) + click.echo('{} guide(s) -> {}'.format(len(guides), outdir)) + + @guides_cli.command('import') + @click.argument('paths', nargs=-1, required=True, + type=click.Path(exists=True)) + @click.option('--userid', type=int, default=None, + help='Record this user as the author on this site.') + @click.option('--replace', is_flag=True, + help='If a guide already has the bundle\'s slug, edit it in ' + 'place (a new revision) instead of creating a second ' + 'one. This is how an updated guide is re-shipped.') + @click.option('--apply/--dry-run', default=False, + help='Write to the database. The default reports only.') + @with_appcontext + def import_guides(paths, userid, replace, apply): + """Import .guide.json bundle(s) written by `guides export`.""" + import os + + files = [] + for path in paths: + if os.path.isdir(path): + files.extend(sorted(os.path.join(path, name) + for name in os.listdir(path) + if name.endswith('.guide.json'))) + else: + files.append(path) + if not files: + click.echo('No .guide.json files found.') + return + + for path in files: + click.echo(os.path.basename(path)) + try: + with open(path, encoding='utf-8') as handle: + payload = bundleservice.loads(handle.read()) + except bundleservice.BundleError as error: + click.echo(' SKIPPED {}'.format(error)) + continue + + existing = Guide.query.filter(Guide.slug == payload['slug']).first() + click.echo(' title {}'.format(payload.get('title'))) + click.echo(' blocks {}, images {}'.format( + len(payload['blocks']), len(payload['images']))) + if existing is not None: + click.echo(' {} already has this slug{}'.format( + 'guide {}'.format(existing.guideid), + ' - will be updated in place' if replace + else ' - a SECOND guide will be created; pass --replace to ' + 'update it instead')) + + rejected = [image for image in payload['images'] + if image['mimetype'] not in ALLOWEDIMAGETYPES + or len(image['data']) > MAXIMAGEBYTES] + for image in rejected: + click.echo(' DROPPED {} ({}) - not an accepted image' + .format(image['token'], image['mimetype'])) + + if not apply: + click.echo(' dry run, nothing written. Pass --apply to import.') + continue + + try: + guide = _importbundle(payload, userid, replace, rejected) + except BlockError as error: + db.session.rollback() + click.echo(' FAILED {}'.format(error)) + continue + + db.session.commit() + click.echo(' imported as /guides/{} (draft, revision {})' + .format(guide.slug, guide.revision)) + return [guides_cli] @@ -156,3 +274,59 @@ def _import(parsed, name, tags, userid, replace, rejected): tags=tags, blocks=blocks, note='Imported from HTML', userid=userid) return guide + + +def _importbundle(payload, userid, replace, rejected): + """Create or update a guide from a bundle. Caller commits.""" + guide = None + if replace: + guide = Guide.query.filter(Guide.slug == payload['slug']).first() + + if guide is None: + # The bundle's own slug when it is free, because the slug is the name + # somebody pastes into a work order and the same guide should have the + # same address on every site. A clash only suffixes. + slug = payload['slug'] + if Guide.query.filter(Guide.slug == slug).first() is not None: + slug = _slugify(payload.get('title') or slug) + guide = Guide(title=payload.get('title') or slug, slug=slug, + status='draft', createdby=userid) + db.session.add(guide) + db.session.flush() + + # Replacing means the incoming bundle is the whole truth for this guide. + # Its blocks will reference the images arriving now, so the ones already + # stored become orphans - and re-shipping a guide monthly would otherwise + # grow a pile of them nothing points at. + if replace: + GuideImage.query.filter_by(guideid=guide.guideid).delete() + db.session.flush() + + droppedtokens = {image['token'] for image in rejected} + ids = {} + for image in payload['images']: + if image['token'] in droppedtokens: + continue + row = GuideImage(guideid=guide.guideid, filename=image['filename'], + mimetype=image['mimetype'], + bytesize=len(image['data']), data=image['data'], + uploadedby=userid) + db.session.add(row) + db.session.flush() + ids[image['token']] = row.guideimageid + + blocks = [] + for block in payload['blocks']: + block = dict(block) + token = block.pop('imagetoken', None) + if token is not None: + if token not in ids: + continue + block['imageid'] = ids[token] + blocks.append(block) + + authoring.save(guide, title=payload.get('title'), + summary=payload.get('summary'), tags=payload.get('tags'), + blocks=blocks, note='Imported from a guide bundle', + userid=userid) + return guide diff --git a/plugins/guides/services/bundle.py b/plugins/guides/services/bundle.py new file mode 100644 index 00000000..488bcade --- /dev/null +++ b/plugins/guides/services/bundle.py @@ -0,0 +1,130 @@ +"""A guide as one portable file. + +WHY + + Sites are separate installations with separate databases, and the useful + ones are air-gapped from where a guide gets written. A procedure that can + only exist on the machine it was typed on is the problem this plugin was + built to end, one layer up: instead of a document stranded on a share, a + guide stranded in one site's database. + + So: export to a single JSON file, carry it however files get carried + there - a share, a robocopy, a USB drive - and import it on the other side. + +WHAT TRAVELS + + Title, summary, tags and the blocks, plus every image the blocks reference, + inline as base64. One file, no companion directory: half a guide arriving + is worse than none, and a bundle that needs its images to be copied + alongside it is a bundle that will eventually arrive without them. + +WHAT DOES NOT TRAVEL + + Ids, revision history, authorship and publication state. A guide's id and + its revision numbering belong to the database that issued them, and an + imported guide starts as a draft on the far side, at revision 1, owned by + whoever ran the import. The SLUG travels, because the slug is the name + somebody pastes into a work order and the whole point is that the same + guide is the same guide on both sites. +""" + +import base64 +import json + +FORMAT = 'shopdb-guide' +VERSION = 1 + +# An image block points at a stored image by its id, and an id means nothing on +# the far side. Blocks travel referring to a token instead, and the importer +# swaps the tokens for the ids its own database issued. +IMAGETOKEN = 'imagetoken' +IMAGEID = 'imageid' + + +class BundleError(ValueError): + """The file is not a guide bundle, or is one this version cannot read.""" + + +def build(guide, images): + """One guide plus its images as a plain dict, ready to be written as JSON. + + `images` is the GuideImage rows for this guide. Only the ones its blocks + actually reference are carried - an orphaned paste from a draft is not part + of the procedure and does not need to cross the gap. + """ + byid = {image.guideimageid: image for image in images} + used = [] + tokens = {} + blocks = [] + for block in guide.blocks: + block = dict(block) + imageid = block.pop(IMAGEID, None) + if imageid is not None: + image = byid.get(imageid) + if image is None: + # The image is gone. Carrying a block that points at nothing + # would render as "Image unavailable" on the far side, which + # reads as an import failure rather than as missing data. + continue + if imageid not in tokens: + tokens[imageid] = 'img{:03d}'.format(len(used) + 1) + used.append(image) + block[IMAGETOKEN] = tokens[imageid] + blocks.append(block) + + return { + 'format': FORMAT, + 'version': VERSION, + 'slug': guide.slug, + 'title': guide.title, + 'summary': guide.summary, + 'tags': guide.tags, + 'blocks': blocks, + 'images': [{ + 'token': tokens[image.guideimageid], + 'filename': image.filename, + 'mimetype': image.mimetype, + 'data': base64.b64encode(image.data).decode('ascii'), + } for image in used], + } + + +def dumps(bundle): + return json.dumps(bundle, indent=2, sort_keys=False) + '\n' + + +def loads(text): + """Parse a bundle, with the image data decoded back to bytes. + + Refuses anything that is not this format. A JSON file that happens to be + somewhere near the others must not be half-imported before the failure + shows up. + """ + try: + bundle = json.loads(text) + except ValueError as error: + raise BundleError('not valid JSON: {}'.format(error)) + + if not isinstance(bundle, dict) or bundle.get('format') != FORMAT: + raise BundleError('not a {} file'.format(FORMAT)) + version = bundle.get('version') + if not isinstance(version, int) or version > VERSION: + raise BundleError( + 'bundle version {} is newer than this site can read (max {})' + .format(version, VERSION)) + if not isinstance(bundle.get('blocks'), list): + raise BundleError('bundle has no blocks') + + images = [] + for image in bundle.get('images') or []: + try: + data = base64.b64decode(image['data'], validate=True) + except Exception: + raise BundleError('image {!r} is not readable' + .format(image.get('token'))) + images.append({'token': image.get('token'), + 'filename': image.get('filename'), + 'mimetype': (image.get('mimetype') or '').lower(), + 'data': data}) + bundle['images'] = images + return bundle diff --git a/tests/test_plugins/test_guides_bundle.py b/tests/test_plugins/test_guides_bundle.py new file mode 100644 index 00000000..231b42f7 --- /dev/null +++ b/tests/test_plugins/test_guides_bundle.py @@ -0,0 +1,146 @@ +"""Carrying a guide from one site to another. + +Sites are separate databases, and the ones that matter are air-gapped from +wherever the guide was written. These tests are about what has to survive that +trip and what must not: the blocks and their images travel, the ids do not. +""" + +import base64 +import json + +import pytest + +from plugins.guides.services import bundle as bundleservice + + +PNG = base64.b64decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk' + 'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==') + + +class FakeImage: + def __init__(self, imageid, data=PNG, mimetype='image/png', + filename='shot.png'): + self.guideimageid = imageid + self.data = data + self.mimetype = mimetype + self.filename = filename + + +class FakeGuide: + def __init__(self, blocks, slug='a-guide', title='A Guide', + summary='What it does', tags='shopfloor, printers'): + self.slug = slug + self.title = title + self.summary = summary + self.tags = tags + self.blocks = blocks + + +def test_a_bundle_carries_what_the_far_side_needs(): + guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hello'}]) + payload = bundleservice.build(guide, []) + assert payload['format'] == 'shopdb-guide' + assert payload['slug'] == 'a-guide' + assert payload['title'] == 'A Guide' + assert payload['summary'] == 'What it does' + assert payload['tags'] == 'shopfloor, printers' + assert payload['blocks'][0]['text'] == 'Hello' + + +def test_ids_do_not_travel(): + """A guide's id and revision belong to the database that issued them.""" + guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hello'}]) + payload = bundleservice.build(guide, []) + assert 'guideid' not in payload + assert 'revision' not in payload + assert 'publishedat' not in payload + + +def test_an_image_travels_inside_the_file_not_beside_it(): + """One file. A bundle that needs its images copied alongside it is a + bundle that will eventually arrive without them.""" + guide = FakeGuide([{'id': 'b001', 'type': 'image', 'imageid': 42, + 'alt': 'the screen'}]) + payload = bundleservice.build(guide, [FakeImage(42)]) + + assert len(payload['images']) == 1 + assert payload['images'][0]['mimetype'] == 'image/png' + assert base64.b64decode(payload['images'][0]['data']) == PNG + + block = payload['blocks'][0] + assert 'imageid' not in block, 'a local id means nothing on the far side' + assert block['imagetoken'] == payload['images'][0]['token'] + assert block['alt'] == 'the screen' + + +def test_the_same_image_used_twice_travels_once(): + guide = FakeGuide([ + {'id': 'b001', 'type': 'image', 'imageid': 42}, + {'id': 'b002', 'type': 'image', 'imageid': 42}, + ]) + payload = bundleservice.build(guide, [FakeImage(42)]) + assert len(payload['images']) == 1 + assert payload['blocks'][0]['imagetoken'] == payload['blocks'][1]['imagetoken'] + + +def test_an_image_the_guide_does_not_use_is_left_behind(): + """An orphaned paste from a draft is not part of the procedure.""" + guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hi'}]) + payload = bundleservice.build(guide, [FakeImage(42), FakeImage(43)]) + assert payload['images'] == [] + + +def test_a_block_pointing_at_a_missing_image_is_dropped(): + """It would render as 'Image unavailable' on the far side, which reads as + a failed import rather than as missing data.""" + guide = FakeGuide([ + {'id': 'b001', 'type': 'paragraph', 'text': 'Before'}, + {'id': 'b002', 'type': 'image', 'imageid': 99}, + {'id': 'b003', 'type': 'paragraph', 'text': 'After'}, + ]) + payload = bundleservice.build(guide, []) + assert [b['type'] for b in payload['blocks']] == ['paragraph', 'paragraph'] + + +def test_a_bundle_survives_the_round_trip(): + guide = FakeGuide([ + {'id': 'b001', 'type': 'heading', 'text': 'Steps'}, + {'id': 'b002', 'type': 'image', 'imageid': 7}, + {'id': 'b003', 'type': 'checklist', + 'items': [{'id': 'i1', 'text': 'Tick me'}]}, + ]) + text = bundleservice.dumps(bundleservice.build(guide, [FakeImage(7)])) + back = bundleservice.loads(text) + + assert back['slug'] == 'a-guide' + assert [b['type'] for b in back['blocks']] == ['heading', 'image', 'checklist'] + assert back['images'][0]['data'] == PNG, 'decoded back to bytes, not base64' + assert back['blocks'][2]['items'][0]['text'] == 'Tick me' + + +def test_something_that_is_not_a_bundle_is_refused_before_anything_is_written(): + with pytest.raises(bundleservice.BundleError): + bundleservice.loads('{"hello": "world"}') + with pytest.raises(bundleservice.BundleError): + bundleservice.loads('not json at all') + with pytest.raises(bundleservice.BundleError): + bundleservice.loads(json.dumps({'format': 'shopdb-guide', 'version': 1})) + + +def test_a_bundle_from_a_newer_site_is_refused_rather_than_half_read(): + newer = json.dumps({'format': 'shopdb-guide', 'version': 99, + 'slug': 'x', 'blocks': []}) + with pytest.raises(bundleservice.BundleError) as error: + bundleservice.loads(newer) + assert 'newer than this site can read' in str(error.value) + + +def test_an_unreadable_image_names_itself(): + broken = json.dumps({ + 'format': 'shopdb-guide', 'version': 1, 'slug': 'x', 'blocks': [], + 'images': [{'token': 'img001', 'mimetype': 'image/png', + 'filename': 'x.png', 'data': 'not base64 !!'}]}) + with pytest.raises(bundleservice.BundleError) as error: + bundleservice.loads(broken) + assert 'img001' in str(error.value) From 4da6c6aa363432aa2182bf3f8200b7a593e41b8c Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 13:02:35 -0400 Subject: [PATCH 09/12] guest role; guide section ticking --- CHANGELOG.md | 40 ++++ frontend/src/views/AppLayout.vue | 23 ++- frontend/src/views/settings/UsersList.vue | 46 ++++- plugins/backups/api/routes.py | 7 - plugins/computers/api/routes.py | 14 -- plugins/geenforce/api/routes.py | 55 ++--- plugins/geenforce/plugin.py | 5 + plugins/guides/api/routes.py | 98 +++++++-- plugins/guides/cli.py | 46 +++++ .../frontend/components/GuideBlocks.vue | 42 +++- plugins/guides/frontend/routes.js | 13 +- plugins/guides/frontend/views/GuideView.vue | 4 + plugins/guides/plugin.py | 4 + plugins/guides/services/blocks.py | 14 +- plugins/knowledgebase/api/routes.py | 3 - plugins/machines/api/routes.py | 7 - plugins/measuringtools/api/routes.py | 6 - plugins/network/api/routes.py | 12 -- plugins/notifications/api/routes.py | 5 - plugins/printedparts/api/routes.py | 13 -- plugins/printedparts/plugin.py | 3 + plugins/printers/api/asset_routes.py | 18 -- plugins/slides/api/routes.py | 9 +- plugins/slides/plugin.py | 6 +- plugins/tools/plugin.py | 13 ++ plugins/usb/api/routes.py | 6 - plugins/warranty/api/routes.py | 9 - shopdb/__init__.py | 2 +- shopdb/api/__init__.py | 3 +- shopdb/cli/__init__.py | 11 +- shopdb/core/api/apitokens.py | 3 - shopdb/core/api/applications.py | 12 -- shopdb/core/api/assets.py | 13 -- shopdb/core/api/auditlogs.py | 5 - shopdb/core/api/dashboard.py | 20 ++ shopdb/core/api/mappositions.py | 5 - shopdb/core/api/reports.py | 1 - shopdb/core/api/settings.py | 4 - shopdb/core/api/users.py | 32 ++- shopdb/core/models/__init__.py | 4 +- shopdb/core/models/user.py | 82 ++++++++ shopdb/plugins/base.py | 17 ++ shopdb/utils/authz.py | 131 +++++++++++- tests/test_core/test_guest_role.py | 190 ++++++++++++++++++ tests/test_plugins/test_guides.py | 17 ++ 45 files changed, 830 insertions(+), 243 deletions(-) create mode 100644 tests/test_core/test_guest_role.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9425fd..8b9670d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,46 @@ ADR-007 and ADR-002. ### Added +- **A guide's sections can be ticked off while you read it.** Only checklist + items were tickable, which answered "what have I done" but not the question a + long procedure actually raises - where was I. Somebody looks away to do the + thing the guide asked for and looks back at a wall of text. Every step and + every heading now carries a checkbox in the reader; ticking one strikes it + through and fades it rather than hiding it, because scanning back up the page + has to still show what was done. Headings count as well as steps: a guide + converted from an HTML article often has fifty headings and no steps at all. + Progress is per browser and per version, as it already was for checklists - + shopfloor PCs share a login, so one technician's ticks must not greet the + next person on that bay. + +- **A guest is a role now, not a switch per feature.** Whether somebody with no + account may see something was answered one feature at a time: a + `securesend_allow_guests` setting, an enable flag per kiosk, and very nearly a + third for guides - each with its own settings page and its own hand-written + branch inside a route. It is a permissions question, and this app already + administers permissions per role, so anyone not signed in now holds the + seeded `guest` role. It starts with nothing, so an upgrade opens no door; a + site opens one by ticking a box in the same role grid it uses for everybody + else, and closes it the same way - taking effect on the next request, not the + next restart. `require_permission` consults the role when there is no token, + which is the whole mechanism. + + What a guest may be granted is bounded: any `.view` or `.read` permission, + plus writes a plugin has explicitly declared safe to perform without an + account (`get_guest_grantable_permissions`, contract 0.21.0). Secure Send + declares its create permission, because sending a password to a vendor is the + one write the product means to offer somebody who never signs in. Everything + else is refused by the API and greyed out in the grid rather than warned + about. A guest write records no creator - the audit trail can say when and + from where, never who - and that is the reason for the bar. + + GE-Enforce and Slides gained read permissions (`geenforce.view`, + `slides.view`) so they can be opened to guests without handing out the manage + right; their read routes accept either, so nothing an existing manager could + reach has moved. The navigation endpoint now returns only what the caller can + actually reach, so a signed-out visitor sees the pages that were opened to + them rather than a sidebar of links that bounce off a login page. + - **USB check-in / check-out audit report.** CMMC removable-media tracking is an audit obligation and there was no report for it - answering "who had this drive, and when" meant paging through the check-in/out list by hand. The diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 2d37253a..8ea01593 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -11,7 +11,9 @@
-