Show the kiosk label prefix, and let a plugin declare the settings it owns

This commit is contained in:
2026-08-06 18:17:56 -04:00
parent 307de925f1
commit c674000ccc
15 changed files with 1070 additions and 475 deletions

View File

@@ -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

View File

@@ -1,384 +1,410 @@
<template>
<div class="page-header">
<h1>Calendar</h1>
</div>
<div class="calendar-container card">
<FullCalendar :options="calendarOptions" />
</div>
<!-- More events tooltip -->
<div
v-if="moreTooltipData.length"
ref="moreTooltip"
class="fc-more-tooltip"
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
@mouseleave="hideMoreTooltip"
>
<div
v-for="(evt, idx) in moreTooltipData"
:key="idx"
class="fc-more-tooltip-event"
:style="{ borderLeftColor: evt.color }"
@click="openEventFromTooltip(evt)"
>
{{ evt.title }}
</div>
</div>
<!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal">
<!-- Employee-photo event (recognition/recertification) with highlight -->
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
<div class="recognition-badge">
<span class="recognition-icon"><Trophy :size="24" /></span>
</div>
<div class="recognition-info">
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon"><User :size="16" /></span>
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
</div>
</div>
</div>
<!-- Regular event header -->
<h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p>
<p>
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
</p>
<p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p>
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
<strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p>
<p v-if="selectedEvent.extendedProps?.ticketnumber">
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
</p>
<p v-if="selectedEvent.extendedProps?.linkurl">
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
More Info
</a>
</p>
</div>
<div class="modal-actions">
<router-link
v-if="selectedEvent.extendedProps?.notificationid"
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
class="btn btn-secondary"
>
View Notification
</router-link>
<button class="btn btn-primary" @click="closeEventModal">Close</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import { Trophy, User } from 'lucide-vue-next'
import { notificationsApi } from '@/api'
const events = ref([])
const selectedEvent = ref(null)
const calendarRef = ref(null)
const moreTooltip = ref(null)
const moreTooltipData = ref([])
const tooltipPosition = ref({ left: 0, top: 0 })
// Store events by date for hover lookup
const eventsByDate = ref({})
const calendarOptions = ref({
plugins: [dayGridPlugin],
initialView: 'dayGridMonth',
events: [],
eventClick: (info) => {
selectedEvent.value = info.event
},
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
height: 'auto',
dayMaxEvents: 3,
moreLinkClick: () => 'none' // Disable click, we use hover
})
function hideMoreTooltip() {
moreTooltipData.value = []
}
function handleMoreLinkHover(e) {
const moreLink = e.target.closest('.fc-daygrid-more-link')
if (!moreLink) {
return
}
// Find the day cell and get its date
const dayCell = moreLink.closest('.fc-daygrid-day')
if (!dayCell) return
const dateStr = dayCell.getAttribute('data-date')
if (!dateStr || !eventsByDate.value[dateStr]) return
// Get events for this date that would be hidden (after first 3)
const dayEvents = eventsByDate.value[dateStr]
const hiddenEvents = dayEvents.slice(3).map(evt => ({
title: evt.title,
color: evt.backgroundColor || '#14abef',
// Include full event data for modal
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps || {}
}))
if (hiddenEvents.length === 0) return
moreTooltipData.value = hiddenEvents
// Position tooltip
const rect = moreLink.getBoundingClientRect()
tooltipPosition.value = {
left: rect.left,
top: rect.bottom + 5
}
}
function handleMoreLinkLeave(e) {
const related = e.relatedTarget
// Don't hide if moving to the tooltip itself
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
return
}
hideMoreTooltip()
}
function buildEventsByDate() {
const byDate = {}
for (const evt of events.value) {
const dateStr = evt.start ? evt.start.split('T')[0] : null
if (dateStr) {
if (!byDate[dateStr]) byDate[dateStr] = []
byDate[dateStr].push(evt)
}
}
eventsByDate.value = byDate
}
onMounted(async () => {
await loadEvents()
// Add event delegation for more links
await nextTick()
const container = document.querySelector('.calendar-container')
if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true)
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onUnmounted(() => {
const container = document.querySelector('.calendar-container')
if (container) {
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onMounted(async () => {
await loadEvents()
})
async function loadEvents() {
try {
const response = await notificationsApi.getCalendar()
events.value = response.data.data
calendarOptions.value.events = events.value
buildEventsByDate()
} catch (error) {
console.error('Error loading calendar events:', error)
}
}
function closeEventModal() {
selectedEvent.value = null
}
function openEventFromTooltip(evt) {
// Create an event-like object that matches FullCalendar's event structure
selectedEvent.value = {
title: evt.title,
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps
}
hideMoreTooltip()
}
function formatDate(dateStr) {
if (!dateStr) return ''
// allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
// Date from local parts so it is not shifted a day by UTC-midnight parsing.
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
const date = parts
? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
: new Date(dateStr)
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
</script>
<style scoped>
.calendar-container {
min-height: 500px;
}
.modal {
padding: 1.5rem;
}
.modal h2 {
margin: 0 0 1rem 0;
font-size: 18px;
color: var(--text);
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.event-details {
padding: 0.5rem 0;
}
.event-details p {
margin-bottom: 0.75rem;
font-size: 14px;
line-height: 1.5;
}
.event-details p:last-child {
margin-bottom: 0;
}
.event-details strong {
color: var(--text-light);
display: inline-block;
min-width: 70px;
}
.message-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message-block strong {
min-width: auto;
}
.message-text {
display: block;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 0.25rem;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
/* Recognition event styling */
.recognition-header {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.recognition-badge {
flex-shrink: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
}
.recognition-icon {
font-size: 28px;
}
.recognition-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.recognition-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--primary);
font-weight: 600;
margin-bottom: 0.25rem;
}
.recognition-title {
margin: 0 !important;
padding: 0 !important;
border: none !important;
font-size: 16px !important;
line-height: 1.4;
color: var(--text) !important;
}
.recognition-employee {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(65, 129, 255, 0.1);
border-radius: 0.25rem;
border-left: 3px solid var(--primary);
}
.employee-icon {
font-size: 18px;
}
.employee-name {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
</style>
<template>
<div class="page-header">
<h1>Calendar</h1>
</div>
<div class="calendar-container card">
<FullCalendar :options="calendarOptions" />
</div>
<!-- More events tooltip -->
<div
v-if="moreTooltipData.length"
ref="moreTooltip"
class="fc-more-tooltip"
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
@mouseleave="hideMoreTooltip"
>
<div
v-for="(evt, idx) in moreTooltipData"
:key="idx"
class="fc-more-tooltip-event"
:style="{ borderLeftColor: evt.color }"
@click="openEventFromTooltip(evt)"
>
{{ evt.title }}
</div>
</div>
<!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal">
<!-- Employee-photo event (recognition/recertification) with highlight -->
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
<div class="recognition-badge">
<span class="recognition-icon"><Trophy :size="24" /></span>
</div>
<div class="recognition-info">
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon"><User :size="16" /></span>
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
</div>
</div>
</div>
<!-- Regular event header -->
<h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p>
<p>
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
</p>
<p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p>
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
<strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p>
<p v-if="selectedEvent.extendedProps?.ticketnumber">
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
</p>
<p v-if="selectedEvent.extendedProps?.linkurl">
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
More Info
</a>
</p>
</div>
<div class="modal-actions">
<router-link
v-if="selectedEvent.extendedProps?.notificationid"
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
class="btn btn-secondary"
>
View Notification
</router-link>
<button class="btn btn-primary" @click="closeEventModal">Close</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import { Trophy, User } from 'lucide-vue-next'
import { notificationsApi } from '@/api'
const events = ref([])
const selectedEvent = ref(null)
const calendarRef = ref(null)
const moreTooltip = ref(null)
const moreTooltipData = ref([])
const tooltipPosition = ref({ left: 0, top: 0 })
// Store events by date for hover lookup
const eventsByDate = ref({})
const calendarOptions = ref({
plugins: [dayGridPlugin],
initialView: 'dayGridMonth',
events: [],
eventClick: (info) => {
selectedEvent.value = info.event
},
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
// 'auto' sized every row to its own content, so a month of mostly empty days
// collapsed into thin strips and the grid looked squashed. A viewport-relative
// height makes the calendar fill the space it has been given, and expandRows
// shares that height evenly across the weeks rather than leaving a gap under
// the last one.
height: 'calc(100vh - 230px)',
expandRows: true,
dayMaxEvents: 3,
moreLinkClick: () => 'none' // Disable click, we use hover
})
function hideMoreTooltip() {
moreTooltipData.value = []
}
function handleMoreLinkHover(e) {
const moreLink = e.target.closest('.fc-daygrid-more-link')
if (!moreLink) {
return
}
// Find the day cell and get its date
const dayCell = moreLink.closest('.fc-daygrid-day')
if (!dayCell) return
const dateStr = dayCell.getAttribute('data-date')
if (!dateStr || !eventsByDate.value[dateStr]) return
// Get events for this date that would be hidden (after first 3)
const dayEvents = eventsByDate.value[dateStr]
const hiddenEvents = dayEvents.slice(3).map(evt => ({
title: evt.title,
color: evt.backgroundColor || '#14abef',
// Include full event data for modal
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps || {}
}))
if (hiddenEvents.length === 0) return
moreTooltipData.value = hiddenEvents
// Position tooltip
const rect = moreLink.getBoundingClientRect()
tooltipPosition.value = {
left: rect.left,
top: rect.bottom + 5
}
}
function handleMoreLinkLeave(e) {
const related = e.relatedTarget
// Don't hide if moving to the tooltip itself
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
return
}
hideMoreTooltip()
}
function buildEventsByDate() {
const byDate = {}
for (const evt of events.value) {
const dateStr = evt.start ? evt.start.split('T')[0] : null
if (dateStr) {
if (!byDate[dateStr]) byDate[dateStr] = []
byDate[dateStr].push(evt)
}
}
eventsByDate.value = byDate
}
onMounted(async () => {
await loadEvents()
// Add event delegation for more links
await nextTick()
const container = document.querySelector('.calendar-container')
if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true)
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onUnmounted(() => {
const container = document.querySelector('.calendar-container')
if (container) {
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onMounted(async () => {
await loadEvents()
})
async function loadEvents() {
try {
const response = await notificationsApi.getCalendar()
events.value = response.data.data
calendarOptions.value.events = events.value
buildEventsByDate()
} catch (error) {
console.error('Error loading calendar events:', error)
}
}
function closeEventModal() {
selectedEvent.value = null
}
function openEventFromTooltip(evt) {
// Create an event-like object that matches FullCalendar's event structure
selectedEvent.value = {
title: evt.title,
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps
}
hideMoreTooltip()
}
function formatDate(dateStr) {
if (!dateStr) return ''
// allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
// Date from local parts so it is not shifted a day by UTC-midnight parsing.
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
const date = parts
? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
: new Date(dateStr)
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
</script>
<style scoped>
.calendar-container {
min-height: 640px;
}
/* :deep, because these are FullCalendar's own elements and scoped styles do not
reach them. A floor under each day cell keeps the grid readable on a short
window, where the viewport-relative height above would otherwise squeeze the
rows back down. */
:deep(.fc-daygrid-day-frame) {
min-height: 110px;
}
/* The calc() height above is viewport-relative, so on a short window - a laptop
with a docked browser, or a shopfloor display in portrait - it can resolve
smaller than the grid needs. This stops it undercutting the day cells. */
:deep(.fc) {
min-height: 620px;
}
/* The day number was tight against the top edge once the cells grew. */
:deep(.fc-daygrid-day-top) {
padding: 2px 4px;
}
.modal {
padding: 1.5rem;
}
.modal h2 {
margin: 0 0 1rem 0;
font-size: 18px;
color: var(--text);
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.event-details {
padding: 0.5rem 0;
}
.event-details p {
margin-bottom: 0.75rem;
font-size: 14px;
line-height: 1.5;
}
.event-details p:last-child {
margin-bottom: 0;
}
.event-details strong {
color: var(--text-light);
display: inline-block;
min-width: 70px;
}
.message-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message-block strong {
min-width: auto;
}
.message-text {
display: block;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 0.25rem;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
/* Recognition event styling */
.recognition-header {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.recognition-badge {
flex-shrink: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
}
.recognition-icon {
font-size: 28px;
}
.recognition-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.recognition-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--primary);
font-weight: 600;
margin-bottom: 0.25rem;
}
.recognition-title {
margin: 0 !important;
padding: 0 !important;
border: none !important;
font-size: 16px !important;
line-height: 1.4;
color: var(--text) !important;
}
.recognition-employee {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(65, 129, 255, 0.1);
border-radius: 0.25rem;
border-left: 3px solid var(--primary);
}
.employee-icon {
font-size: 18px;
}
.employee-name {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
</style>

View File

@@ -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: ''
})

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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",

View File

@@ -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."""

View File

@@ -33,10 +33,19 @@
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
<div v-else class="slide-list">
<div v-for="(slide, idx) in slides" :key="slide.slideid" class="slide-row">
<div v-for="(slide, idx) in slides" :key="slide.slideid" class="slide-row"
:class="{ dragging: dragIndex === idx, 'drop-target': dropIndex === idx && dragIndex !== idx }"
draggable="true"
@dragstart="onDragStart(idx, $event)"
@dragover.prevent="dropIndex = idx"
@dragleave="onDragLeave(idx)"
@drop.prevent="onDrop(idx)"
@dragend="onDragEnd">
<input type="checkbox" class="pick" :value="slide.filename" v-model="selected" />
<span class="grip" title="Drag to reorder">&#8942;&#8942;</span>
<span class="order-num">{{ idx + 1 }}</span>
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb" />
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
<div class="move">
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">&uarr;</button>
@@ -45,6 +54,18 @@
</div>
</div>
</div>
<!--
Hover preview. A 120x68 thumbnail is cropped (object-fit: cover) and far
too small to read the text on a slide, so picking the right one meant
opening images by hand. Fixed position rather than inside the row: a
relatively-positioned parent would clip it, and the list scrolls.
Pointer-events none so it can never sit between the cursor and a button.
-->
<div v-if="previewSlide" class="slide-preview">
<img :src="withBase(previewSlide.url)" :alt="previewSlide.filename" />
<div class="slide-preview-name">{{ previewSlide.filename }}</div>
</div>
</div>
</template>
@@ -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;

View File

@@ -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()
}
}
</script>
<style scoped>

View File

@@ -36,7 +36,13 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
__contract_version__ = '0.15.0'
# 0.16.0: added the get_settings_defaults hook so a plugin declares the Setting
# rows it owns (key, value, type, category, description, public). The framework
# seeds them at install, at enable, and on `flask plugin upgrade-all`, files a
# first-time write under the declared category, and lets a plugin mark a key
# readable without auth for pages that run logged out. Additive optional hook,
# minor bump.
__contract_version__ = '0.16.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -52,6 +52,8 @@ SECRET_MASK = '********'
# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before
# login. Whole categories that are purely presentation are allowed wholesale;
# the rest are named keys so a new integration key does not leak by default.
# A plugin adds its own public keys by declaring public=True in
# get_settings_defaults - core does not carry a list of every plugin's keys.
PUBLIC_SETTING_CATEGORIES = {'branding', 'map'}
PUBLIC_SETTING_KEYS = {
'site_base_url', 'facility_name', 'printer_hostname_template',
@@ -60,9 +62,25 @@ PUBLIC_SETTING_KEYS = {
}
def _plugin_declared_settings() -> dict:
"""Declared settings ({key: entry}) of every loaded plugin, or {}."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return {}
try:
return pm.get_declared_plugin_settings()
except Exception:
current_app.logger.exception('Could not read plugin setting declarations')
return {}
def _is_public_setting(setting) -> bool:
return (setting.category in PUBLIC_SETTING_CATEGORIES
or setting.key in PUBLIC_SETTING_KEYS)
# A plugin declares its own public keys (get_settings_defaults, public=True)
# so a kiosk or print page that renders before login can read them without
# core carrying a list of every plugin's keys.
if setting.category in PUBLIC_SETTING_CATEGORIES or setting.key in PUBLIC_SETTING_KEYS:
return True
return bool(_plugin_declared_settings().get(setting.key, {}).get('public'))
# Optional asset identifiers and the asset types they can be toggled on.
# Drives per-type seed keys and the Settings matrix UI. The asset type names
@@ -92,6 +110,17 @@ SEARCH_DOMAINS = {
'subnet': 'Subnets',
}
def _declared_default(key: str) -> dict:
"""Return the declared default for a key (core defaults, then plugins).
Empty dict when nobody declares it - a genuinely ad-hoc key.
"""
for entry in build_default_settings():
if entry['key'] == key:
return entry
return _plugin_declared_settings().get(key, {})
def _is_secret(key: str) -> bool:
return 'password' in key or 'token' in key or 'secret' in key
@@ -256,9 +285,19 @@ def update_setting(key: str):
setting = Setting.query.filter_by(key=key).first()
# Upsert: create the row on first write (e.g. plugin config keys the setup
# wizard saves). New keys default to a plugin-scoped string setting.
# wizard saves). Take the category, type and description from whoever
# declares the key - core defaults or a plugin's get_settings_defaults.
# Filing a declared key under a placeholder category hid it from the
# settings page that had just written it, which read as "did not save".
if not setting:
setting = Setting(key=key, value='', valuetype='string', category='plugin')
declared = _declared_default(key)
setting = Setting(
key=key,
value='',
valuetype=declared.get('valuetype', 'string'),
category=declared.get('category', 'plugin'),
description=declared.get('description'),
)
db.session.add(setting)
# Track old value for audit

View File

@@ -114,6 +114,11 @@ class PluginManager:
per-plugin migrations extend that chain. See ADR-008. Idempotent.
"""
results: Dict[str, str] = {}
# Settings declared by a later plugin version reach an already-installed
# site here. on_install/on_enable fire only on a state transition, so an
# upgrade is the only moment left to seed them. Runs even with no
# migration manager - it is independent of the Alembic chains.
self.sync_all_plugin_settings()
if not self.migration_manager:
return results
# Only ADOPTED plugins (those in the registry) get migrated. A plugin
@@ -134,6 +139,50 @@ class PluginManager:
results[name] = f'error: {ex}'
return results
def sync_all_plugin_settings(self) -> int:
"""Seed every enabled plugin's declared settings. Idempotent.
Returns the number of plugins whose settings were touched. Best-effort
per plugin: one bad plugin must not abort a deploy's upgrade pass.
"""
touched = 0
for name in list(self.registry.get_all().keys()):
if not self.registry.is_enabled(name):
continue
try:
plugin = self.loader.load_plugin(name, self._app, self._db)
except Exception:
logger.exception("Could not load %s to sync its settings", name)
continue
if plugin and self._seed_plugin_settings(plugin):
touched += 1
return touched
def get_declared_plugin_settings(self) -> Dict[str, dict]:
"""Return {key: declared entry} across all loaded plugins.
The settings API uses this to file a first-time write under the owning
plugin's category instead of a placeholder, and to decide which keys an
unauthenticated caller may read.
"""
declared: Dict[str, dict] = {}
for name, plugin in self.loader.get_all_loaded().items():
try:
entries = plugin.get_settings_defaults() or []
except Exception:
logger.exception("get_settings_defaults failed for %s", name)
continue
for entry in entries:
key = entry.get('key')
if key:
declared[key] = entry
return declared
def get_public_setting_keys(self) -> set:
"""Setting keys plugins declare readable without authentication."""
return {key for key, entry in self.get_declared_plugin_settings().items()
if entry.get('public')}
def _register_plugin_components(self, plugin: BasePlugin) -> None:
"""Register plugin's blueprint, models, CLI commands, etc."""
# Register blueprint
@@ -260,6 +309,7 @@ class PluginManager:
if plugin:
self._register_plugin_components(plugin)
self._seed_plugin_permissions(plugin)
self._seed_plugin_settings(plugin)
plugin.on_install(self._app)
logger.info(f"Installed plugin: {name} v{manifest_version}")
@@ -288,6 +338,58 @@ class PluginManager:
"Seeded %d permission(s) for plugin %s",
created, plugin.meta.name)
def _seed_plugin_settings(self, plugin: BasePlugin) -> bool:
"""Idempotently create Setting rows for a plugin's declared settings.
Runs at install, at enable, and on `flask plugin upgrade-all`. Existing
values are never overwritten - only missing rows are created, and a row
whose category does not match the declaration is re-homed. That repair
matters: before a key was declared, the first save created it under the
settings API's placeholder category, where the owning plugin's settings
page (which filters by category) could not see it again, so the value
looked like it never saved.
Returns True if anything was created or repaired. Best-effort: a plugin
that raises must not abort the lifecycle.
"""
try:
entries = plugin.get_settings_defaults() or []
except Exception:
logger.exception(
"get_settings_defaults failed for %s", plugin.meta.name)
return False
if not entries:
return False
from shopdb.core.models import Setting
created = 0
repaired = 0
with self._app.app_context():
for entry in entries:
key = entry.get('key')
if not key:
continue
setting = Setting.query.filter_by(key=key).first()
if setting is None:
self._db.session.add(Setting(
key=key,
value=entry.get('value', ''),
valuetype=entry.get('valuetype', 'string'),
category=entry.get('category', 'plugin'),
description=entry.get('description'),
))
created += 1
continue
category = entry.get('category')
if category and setting.category != category:
setting.category = category
repaired += 1
if created or repaired:
self._db.session.commit()
logger.info(
"Plugin %s settings: %d created, %d re-homed",
plugin.meta.name, created, repaired)
return bool(created or repaired)
def _is_core_tier(self, name: str) -> bool:
"""True when the plugin's manifest marks it tier=core (mandatory).
@@ -406,6 +508,7 @@ class PluginManager:
plugin = self.loader.load_plugin(name, self._app, self._db)
if plugin:
self._seed_plugin_permissions(plugin)
self._seed_plugin_settings(plugin)
plugin.on_enable(self._app)
except Exception:
logger.exception(f"on_enable hook failed for plugin {name}")

View File

@@ -104,6 +104,28 @@ class BasePlugin(ABC):
"""
return []
def get_settings_defaults(self) -> List[Dict]:
"""Declare the Setting rows this plugin owns.
The framework seeds these at install, at enable, and on every
`flask plugin upgrade-all`, so a setting added in a later plugin
version reaches a site that installed an earlier one. Declaring a key
here is also what tells the settings API which category and type the
key belongs to, so a save never has to invent one.
Each entry is a dict:
key - the Setting key
value - default value (string form)
valuetype - 'string' | 'boolean' | 'integer' | 'json'
category - grouping the plugin's settings page filters on
description - what the setting does
public - True if an UNAUTHENTICATED caller may read it (kiosk
and print pages render before login); default False.
Never mark a credential or an integration URL public.
Return [] (default) if the plugin owns no settings.
"""
return []
def get_setting(self, key: str, default=None):
"""Read a plugin-scoped setting from the core Setting store.

View File

@@ -869,7 +869,10 @@ def upgrade_all_plugins():
Idempotent. Run this after `flask db upgrade` on every deploy and
upgrade. It stamps each bundled plugin's anchor revision into
alembic_version_<plugin> and applies any per-plugin migrations added
after the ownership cutover (ADR-008). Safe to re-run at head.
after the ownership cutover (ADR-008). It also seeds any settings a
plugin declares (get_settings_defaults) that this site is missing, so a
setting added in a later version reaches a site that installed earlier.
Safe to re-run at head.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:

View File

@@ -0,0 +1,148 @@
"""Tests for plugin-declared settings (contract 0.16.0).
Three regressions are pinned here, all found on the 3D-parts kiosk label
prefix:
1. The kiosk runs logged out, so a setting it renders must be readable by an
anonymous caller when the owning plugin declares it public - and only then.
2. A save that creates the row for the first time must file it under the
declared category, or the plugin's own settings page (which lists by
category) stops seeing the value and the save looks lost.
3. Settings declared by a later plugin version must reach a site that installed
an earlier one; on_install/on_enable fire only on a state transition, so the
upgrade pass has to seed them.
"""
import pytest
from shopdb.core.models import Setting
KIOSK_KEY = 'printedparts_label_prefix'
PRIVATE_KEY = 'printedparts_alert_email'
def _plugin(app, name='printedparts'):
pm = app.extensions.get('plugin_manager')
plugin = pm.loader.get_loaded_plugin(name) if pm else None
if plugin is None:
pytest.skip(f'{name} plugin not loaded in this build')
return pm, plugin
def test_plugin_declares_the_kiosk_prefix_public(app):
_, plugin = _plugin(app)
declared = {entry['key']: entry for entry in plugin.get_settings_defaults()}
assert declared[KIOSK_KEY]['public'] is True
assert declared[KIOSK_KEY]['category'] == 'printedparts'
# Everything else the plugin owns stays behind auth.
assert not any(entry.get('public') for key, entry in declared.items()
if key != KIOSK_KEY)
def test_anon_can_read_a_public_plugin_setting(client, db):
"""The kiosk reads this with no token; a 404 leaves the prefix blank."""
Setting.set(KIOSK_KEY, 'WJ', valuetype='string', category='printedparts')
resp = client.get(f'/api/settings/{KIOSK_KEY}')
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()['data']['value'] == 'WJ'
def test_anon_cannot_read_a_nonpublic_plugin_setting(client, db):
Setting.set(PRIVATE_KEY, 'lead@example.com', valuetype='string',
category='printedparts')
resp = client.get(f'/api/settings/{PRIVATE_KEY}')
assert resp.status_code == 404
body = resp.get_data(as_text=True)
assert 'lead@example.com' not in body
def test_anon_list_includes_public_plugin_settings_only(client, db):
Setting.set(KIOSK_KEY, 'WJ', valuetype='string', category='printedparts')
Setting.set(PRIVATE_KEY, 'lead@example.com', valuetype='string',
category='printedparts')
resp = client.get('/api/settings?category=printedparts')
assert resp.status_code == 200
keys = {row['key'] for row in resp.get_json()['data']}
assert keys == {KIOSK_KEY}
def test_first_save_files_the_key_under_its_declared_category(client, db,
auth_headers):
"""Save then reload, the way the settings page does it.
The row does not exist yet (the site installed before the key was added),
so the PUT creates it. Filed under a placeholder category it would vanish
from the page's category-filtered reload - the "it does not save" bug.
"""
assert Setting.query.filter_by(key=KIOSK_KEY).first() is None
resp = client.put(f'/api/settings/{KIOSK_KEY}', json={'value': 'WJ'},
headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
setting = Setting.query.filter_by(key=KIOSK_KEY).first()
assert setting.category == 'printedparts'
reload = client.get('/api/settings?category=printedparts',
headers=auth_headers)
values = {row['key']: row['value'] for row in reload.get_json()['data']}
assert values[KIOSK_KEY] == 'WJ'
def test_first_save_of_a_core_key_keeps_its_core_category(client, db,
auth_headers):
resp = client.put('/api/settings/facility_name',
json={'value': 'Test Plant'}, headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
assert Setting.query.filter_by(key='facility_name').first().category == 'site'
def test_undeclared_key_still_upserts(client, db, auth_headers):
"""An ad-hoc key nobody declares keeps the old placeholder behavior."""
resp = client.put('/api/settings/some_adhoc_key', json={'value': 'x'},
headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
assert Setting.query.filter_by(key='some_adhoc_key').first().category == 'plugin'
def test_upgrade_seeds_settings_missing_from_an_existing_site(app, db):
"""`flask plugin upgrade-all` reaches a site that installed earlier."""
pm, _ = _plugin(app)
assert Setting.query.filter_by(key=KIOSK_KEY).first() is None
pm.sync_all_plugin_settings()
setting = Setting.query.filter_by(key=KIOSK_KEY).first()
assert setting is not None
assert setting.category == 'printedparts'
assert setting.value == ''
def test_sync_never_overwrites_a_configured_value(app, db):
pm, _ = _plugin(app)
Setting.set(KIOSK_KEY, 'WJ', valuetype='string', category='printedparts')
pm.sync_all_plugin_settings()
assert Setting.query.filter_by(key=KIOSK_KEY).first().value == 'WJ'
def test_sync_rehomes_a_row_left_in_the_placeholder_category(app, db):
"""Repairs rows an earlier save created before the key was declared."""
pm, _ = _plugin(app)
Setting.set(PRIVATE_KEY, 'lead@example.com', valuetype='string',
category='plugin')
pm.sync_all_plugin_settings()
setting = Setting.query.filter_by(key=PRIVATE_KEY).first()
assert setting.category == 'printedparts'
assert setting.value == 'lead@example.com'