From c674000ccc33824c9e23cdd525ff1365039df22a Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 6 Aug 2026 18:17:56 -0400 Subject: [PATCH] Show the kiosk label prefix, and let a plugin declare the settings it owns --- CHANGELOG.md | 29 + .../frontend/views/CalendarView.vue | 794 +++++++++--------- .../frontend/views/NotificationForm.vue | 7 +- plugins/printedparts/manifest.json | 2 +- plugins/printedparts/plugin.py | 113 ++- plugins/printers/manifest.json | 2 +- plugins/printers/plugin.py | 87 +- .../slides/frontend/views/SlideManager.vue | 147 +++- plugins/slides/frontend/views/TVDashboard.vue | 31 + shopdb/__init__.py | 8 +- shopdb/core/api/settings.py | 47 +- shopdb/plugins/__init__.py | 103 +++ shopdb/plugins/base.py | 22 + shopdb/plugins/cli.py | 5 +- .../test_settings_plugin_declared.py | 148 ++++ 15 files changed, 1070 insertions(+), 475 deletions(-) create mode 100644 tests/test_core/test_settings_plugin_declared.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26011d45..18184b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,35 @@ ADR-007 and ADR-002. ## [Unreleased] +### Fixed + +- The 3D parts kiosk label prefix never appeared on the kiosk. The kiosk runs + logged out, and an unauthenticated read of a setting is limited to an + allowlist the key was not on, so the kiosk got a 404 and fell back to no + prefix. An admin previewing the same page while logged in saw it, which is + why it looked like it worked. A plugin now declares which of its settings a + logged-out page may read. +- The same setting also looked like it would not save. The row did not exist on + a site that installed the plugin before the setting was added, so the first + save created it - under a placeholder category, where the settings page, which + lists by category, could no longer see it. The value was in the database the + whole time. A first-time save now files the key under the category its owner + declares. Any row already misfiled is repaired in place on upgrade, value + untouched. The 3D parts alert email and the employee directory host were in + that state. +- Settings added in a later plugin version never reached a site that installed + an earlier one. The seeding ran from `on_install` / `on_enable`, which fire + only on a state transition, so neither ran again on an upgrade - and the + comment claiming enable ran every upgrade cycle was wrong. + `flask plugin upgrade-all` now seeds missing declared settings, without + touching values a site has configured. + +### Added + +- Plugin contract 0.16.0: `get_settings_defaults()` lets a plugin declare the + settings it owns (key, default, type, category, description, and whether an + unauthenticated caller may read it). See `docs/PLUGIN-HOOKS.md`. + ## [0.8.1] - 2026-08-05 Everything here shipped after v0.8.0 was tagged the same morning, driven by two diff --git a/plugins/notifications/frontend/views/CalendarView.vue b/plugins/notifications/frontend/views/CalendarView.vue index 7bdde034..d7badbbe 100644 --- a/plugins/notifications/frontend/views/CalendarView.vue +++ b/plugins/notifications/frontend/views/CalendarView.vue @@ -1,384 +1,410 @@ - - - - - + + + + + diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index 9087a222..a48fff23 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -275,7 +275,12 @@ const form = ref({ starttime: '', endtime: '', isactive: true, - isshopfloor: false, + // Ticked for a NEW notification: the shopfloor board is where these are + // meant to be seen, and leaving it off by default meant most were written + // and then never appeared there. Editing an EXISTING notification still + // loads its own stored value (see loadNotification), so this never flips a + // setting somebody deliberately turned off. + isshopfloor: true, employeesso: '' }) diff --git a/plugins/printedparts/manifest.json b/plugins/printedparts/manifest.json index dfb02311..1343ef2f 100644 --- a/plugins/printedparts/manifest.json +++ b/plugins/printedparts/manifest.json @@ -5,7 +5,7 @@ "display_name": "3D Printed Parts", "author": "", "dependencies": ["employees"], - "core_version": ">=0.12.0,<1.0.0", + "core_version": ">=0.16.0,<1.0.0", "api_prefix": "/api/printedparts", "default_enabled": false } diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index b3473bca..1ceaa15d 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -14,7 +14,6 @@ from typing import List, Optional, Type from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.api import db, Setting from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile from .api import printedparts_bp @@ -112,43 +111,79 @@ class PrintedpartsPlugin(BasePlugin): ] def on_install(self, app: Flask) -> None: - with app.app_context(): - self._seed_settings() - logger.info('Printedparts plugin installed') + logger.info('Printedparts plugin installed') - def on_enable(self, app: Flask) -> None: - # Idempotent re-seed so settings added in later versions reach sites - # that installed earlier (enable runs on every upgrade cycle). - with app.app_context(): - self._seed_settings() - - def _seed_settings(self) -> None: - defaults = [ - ('printedparts_code_prefix', '3DP', 'string', - 'Prefix for generated item codes'), - ('printedparts_label_prefix', '', 'string', - 'Leading text on the physical gage-lab labels, shown at the kiosk ' - 'before the number box so operators type only the digits. Empty ' - 'shows no prefix. Site-specific: West Jefferson labels read WJ'), - ('printedparts_default_threshold', '5', 'integer', - 'Default low-stock threshold for new items'), - ('printedparts_unknown_badge', 'deny', 'string', - 'Kiosk policy when a badge resolves to no employee: allow or deny'), - ('printedparts_alert_email', '', 'string', - 'Comma-separated low-stock alert recipients; empty uses the ' - 'site alert_recipients'), - ('printedparts_alert_userids', '', 'string', - 'Comma-separated shopdb user ids whose account emails receive ' - 'low-stock alerts'), - ('printedparts_alert_roleids', '', 'string', - 'Comma-separated role ids; every active member of these roles ' - 'receives low-stock alerts'), - ('printedparts_alert_supportteamid', '', 'string', - 'Support team whose webhook receives low-stock alerts; empty uses ' - 'the site alert_webhook_url'), + def get_settings_defaults(self) -> List[dict]: + # The framework seeds these at install, at enable, and on every + # `flask plugin upgrade-all`, so a key added in a later version reaches + # a site that installed an earlier one. + return [ + { + 'key': 'printedparts_code_prefix', + 'value': '3DP', + 'valuetype': 'string', + 'category': 'printedparts', + 'description': 'Prefix for generated item codes', + }, + { + 'key': 'printedparts_label_prefix', + 'value': '', + 'valuetype': 'string', + 'category': 'printedparts', + # Public: the parts kiosk runs logged out, so the anonymous + # read has to be allowed or the prefix never renders there. + 'public': True, + 'description': 'Leading text on the physical gage-lab labels, ' + 'shown at the kiosk before the number box so ' + 'operators type only the digits. Empty shows no ' + 'prefix. Site-specific: West Jefferson labels ' + 'read WJ', + }, + { + 'key': 'printedparts_default_threshold', + 'value': '5', + 'valuetype': 'integer', + 'category': 'printedparts', + 'description': 'Default low-stock threshold for new items', + }, + { + 'key': 'printedparts_unknown_badge', + 'value': 'deny', + 'valuetype': 'string', + 'category': 'printedparts', + 'description': 'Kiosk policy when a badge resolves to no ' + 'employee: allow or deny', + }, + { + 'key': 'printedparts_alert_email', + 'value': '', + 'valuetype': 'string', + 'category': 'printedparts', + 'description': 'Comma-separated low-stock alert recipients; ' + 'empty uses the site alert_recipients', + }, + { + 'key': 'printedparts_alert_userids', + 'value': '', + 'valuetype': 'string', + 'category': 'printedparts', + 'description': 'Comma-separated shopdb user ids whose account ' + 'emails receive low-stock alerts', + }, + { + 'key': 'printedparts_alert_roleids', + 'value': '', + 'valuetype': 'string', + 'category': 'printedparts', + 'description': 'Comma-separated role ids; every active member ' + 'of these roles receives low-stock alerts', + }, + { + 'key': 'printedparts_alert_supportteamid', + 'value': '', + 'valuetype': 'string', + 'category': 'printedparts', + 'description': 'Support team whose webhook receives low-stock ' + 'alerts; empty uses the site alert_webhook_url', + }, ] - for key, value, valuetype, description in defaults: - if Setting.get(key) is None: - Setting.set(key, value, valuetype=valuetype, - category='printedparts', description=description) - db.session.commit() diff --git a/plugins/printers/manifest.json b/plugins/printers/manifest.json index bb043cca..9d45007b 100644 --- a/plugins/printers/manifest.json +++ b/plugins/printers/manifest.json @@ -4,7 +4,7 @@ "description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes", "author": "ShopDB Team", "dependencies": [], - "core_version": ">=0.1.0,<1.0.0", + "core_version": ">=0.16.0,<1.0.0", "api_prefix": "/api/printers", "provides": { "machine_category": "Printer", diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 153fa7ae..fdabfd6b 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -9,7 +9,7 @@ from flask import Flask, Blueprint import click from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.api import db, AssetType, Setting +from shopdb.api import db, AssetType from .models import ( Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert @@ -106,40 +106,65 @@ class PrintersPlugin(BasePlugin): with app.app_context(): self._ensure_asset_type() self._ensure_printer_types() - self._seed_settings() logger.info("Printers plugin installed") - def on_enable(self, app: Flask) -> None: - # Idempotent re-seed so settings added in later versions reach sites - # that installed earlier (enable runs on every upgrade cycle). - with app.app_context(): - self._seed_settings() + def get_settings_defaults(self) -> List[dict]: + """Low-toner alert settings. - def _seed_settings(self) -> None: - """Seed low-toner alert recipient settings (idempotent).""" - defaults = [ - ('printers_alert_email', '', 'string', - 'Comma-separated low-toner alert recipients; empty uses the ' - 'site alert_recipients'), - ('printers_alert_userids', '', 'string', - 'Comma-separated shopdb user ids whose account emails receive ' - 'low-toner alerts'), - ('printers_alert_roleids', '', 'string', - 'Comma-separated role ids; every active member of these roles ' - 'receives low-toner alerts'), - ('printers_alert_supportteamid', '', 'string', - 'Support team whose webhook receives low-toner alerts; empty ' - 'uses the site alert_webhook_url'), - ('printers_alert_warning_threshold', '5', 'integer', - 'Toner percent remaining at or below which a warning email fires'), - ('printers_alert_critical_threshold', '0', 'integer', - 'Toner percent remaining at or below which a critical email fires'), + The framework seeds these at install, at enable, and on every + `flask plugin upgrade-all`, so a key added in a later version reaches a + site that installed an earlier one. + """ + return [ + { + 'key': 'printers_alert_email', + 'value': '', + 'valuetype': 'string', + 'category': 'printers', + 'description': 'Comma-separated low-toner alert recipients; ' + 'empty uses the site alert_recipients', + }, + { + 'key': 'printers_alert_userids', + 'value': '', + 'valuetype': 'string', + 'category': 'printers', + 'description': 'Comma-separated shopdb user ids whose account ' + 'emails receive low-toner alerts', + }, + { + 'key': 'printers_alert_roleids', + 'value': '', + 'valuetype': 'string', + 'category': 'printers', + 'description': 'Comma-separated role ids; every active member ' + 'of these roles receives low-toner alerts', + }, + { + 'key': 'printers_alert_supportteamid', + 'value': '', + 'valuetype': 'string', + 'category': 'printers', + 'description': 'Support team whose webhook receives low-toner ' + 'alerts; empty uses the site alert_webhook_url', + }, + { + 'key': 'printers_alert_warning_threshold', + 'value': '5', + 'valuetype': 'integer', + 'category': 'printers', + 'description': 'Toner percent remaining at or below which a ' + 'warning email fires', + }, + { + 'key': 'printers_alert_critical_threshold', + 'value': '0', + 'valuetype': 'integer', + 'category': 'printers', + 'description': 'Toner percent remaining at or below which a ' + 'critical email fires', + }, ] - for key, value, valuetype, description in defaults: - if Setting.get(key) is None: - Setting.set(key, value, valuetype=valuetype, - category='printers', description=description) - db.session.commit() def _ensure_asset_type(self) -> None: """Ensure printer asset type exists.""" diff --git a/plugins/slides/frontend/views/SlideManager.vue b/plugins/slides/frontend/views/SlideManager.vue index 25568d19..e6a3903c 100644 --- a/plugins/slides/frontend/views/SlideManager.vue +++ b/plugins/slides/frontend/views/SlideManager.vue @@ -33,10 +33,19 @@
No slides yet. Upload some images.
-
+
+ ⋮⋮ {{ idx + 1 }} - + {{ slide.filename }}
@@ -45,6 +54,18 @@
+ + +
+ +
{{ previewSlide.filename }}
+
@@ -53,6 +74,60 @@ import { ref, computed, onMounted } from 'vue' import { slidesApi } from '@/api' import { withBase } from '@/utils/basePath' +// Slide currently hovered, shown enlarged. Null when the cursor is elsewhere. +const previewSlide = ref(null) + +// Drag reordering. dragIndex is the row being carried, dropIndex the row it is +// currently over. Both null when no drag is in progress. +const dragIndex = ref(null) +const dropIndex = ref(null) + +function onDragStart(idx, event) { + dragIndex.value = idx + // The hover preview would otherwise sit over the list for the whole drag. + previewSlide.value = null + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + // Firefox starts no drag at all unless some data is set. + event.dataTransfer.setData('text/plain', String(idx)) + } +} + +function onDragLeave(idx) { + // Only clear when leaving the row that is actually marked, or the row being + // entered next would clear its own highlight. + if (dropIndex.value === idx) dropIndex.value = null +} + +function onDragEnd() { + dragIndex.value = null + dropIndex.value = null +} + +async function onDrop(target) { + const from = dragIndex.value + onDragEnd() + if (from === null || from === target) return + await reorderTo(from, target) +} + +// Shared by the drag handler and the up/down buttons, so both persist the same +// way and a failure recovers the same way. +async function reorderTo(from, to) { + const arr = slides.value.slice() + const [item] = arr.splice(from, 1) + arr.splice(to, 0, item) + slides.value = arr + try { + await slidesApi.reorder(surface.value, arr.map(s => s.filename)) + } catch (err) { + console.error('Reorder failed:', err) + // The list on screen no longer matches the server; reload rather than + // leave an order that looks saved and is not. + await load() + } +} + const surfaces = [ { key: 'lobby', label: 'Lobby Display' }, { key: 'shopfloor', label: 'Shopfloor Screensaver' } @@ -104,16 +179,7 @@ async function onUpload(event) { async function move(idx, delta) { const target = idx + delta if (target < 0 || target >= slides.value.length) return - const arr = slides.value.slice() - const [item] = arr.splice(idx, 1) - arr.splice(target, 0, item) - slides.value = arr - try { - await slidesApi.reorder(surface.value, arr.map(s => s.filename)) - } catch (err) { - console.error('Reorder failed:', err) - await load() - } + await reorderTo(idx, target) } async function deleteSelected() { @@ -184,6 +250,28 @@ onMounted(load) font-weight: 700; color: var(--text-light); } +.grip { + cursor: grab; + color: var(--text-light); + letter-spacing: -3px; + user-select: none; + padding: 0 0.25rem; +} + +.slide-row { + transition: background 0.12s, opacity 0.12s; +} + +.slide-row.dragging { + opacity: 0.4; +} + +/* A line on the edge being dropped against, rather than moving rows around + under the cursor - that reads as the list fighting the drag. */ +.slide-row.drop-target { + box-shadow: inset 0 2px 0 0 var(--primary); +} + .thumb { width: 120px; height: 68px; @@ -191,6 +279,41 @@ onMounted(load) object-fit: cover; border-radius: 4px; background: #000; + cursor: zoom-in; +} + +.slide-preview { + position: fixed; + top: 50%; + right: 2rem; + transform: translateY(-50%); + z-index: 1000; + padding: 0.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + pointer-events: none; +} + +.slide-preview img { + display: block; + /* Bounded by the viewport, not the image: a 3300x2550 slide would otherwise + fill the screen. object-fit contain keeps the whole slide visible, since + the point is reading it rather than filling the box. */ + max-width: min(46vw, 900px); + max-height: 76vh; + object-fit: contain; + background: #000; + border-radius: 4px; +} + +.slide-preview-name { + margin-top: 0.4rem; + font-size: 0.85rem; + color: var(--text-light); + text-align: center; + word-break: break-all; } .fname { flex: 1; diff --git a/plugins/slides/frontend/views/TVDashboard.vue b/plugins/slides/frontend/views/TVDashboard.vue index 6a2ad27a..dd503d05 100644 --- a/plugins/slides/frontend/views/TVDashboard.vue +++ b/plugins/slides/frontend/views/TVDashboard.vue @@ -67,9 +67,14 @@ onMounted(async () => { // Refresh slide list every 60 seconds refreshTimer = setInterval(fetchSlides, 60000) + + // On window, not on an element: the display has nothing focused, and a + // presenter remote sends the same keys as a keyboard. + window.addEventListener('keydown', onKeydown) }) onUnmounted(() => { + window.removeEventListener('keydown', onKeydown) if (slideTimer) clearTimeout(slideTimer) if (progressTimer) clearTimeout(progressTimer) if (refreshTimer) clearInterval(refreshTimer) @@ -139,6 +144,32 @@ function nextSlide() { if (slides.value.length === 0) return currentSlide.value = (currentSlide.value + 1) % slides.value.length } + +function previousSlide() { + if (slides.value.length === 0) return + const count = slides.value.length + currentSlide.value = (currentSlide.value - 1 + count) % count +} + +// Left and right arrows step through the slides by hand. Someone standing at +// the display wanting to hold a slide, or go back to one that has just passed, +// otherwise has to wait for the whole rotation to come round again. +// +// Stepping RESTARTS the timer rather than leaving it running: advancing +// manually and then having it move again a second later, because the existing +// timer was most of the way through, reads as the display ignoring you. +function onKeydown(event) { + if (slides.value.length < 2) return + if (event.key === 'ArrowRight' || event.key === 'PageDown') { + event.preventDefault() + nextSlide() + scheduleNextSlide() + } else if (event.key === 'ArrowLeft' || event.key === 'PageUp') { + event.preventDefault() + previousSlide() + scheduleNextSlide() + } +}