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()
+ }
+}