Show the kiosk label prefix, and let a plugin declare the settings it owns
This commit is contained in:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -10,6 +10,35 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.8.1] - 2026-08-05
|
||||||
|
|
||||||
Everything here shipped after v0.8.0 was tagged the same morning, driven by two
|
Everything here shipped after v0.8.0 was tagged the same morning, driven by two
|
||||||
|
|||||||
@@ -1,384 +1,410 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Calendar</h1>
|
<h1>Calendar</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calendar-container card">
|
<div class="calendar-container card">
|
||||||
<FullCalendar :options="calendarOptions" />
|
<FullCalendar :options="calendarOptions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- More events tooltip -->
|
<!-- More events tooltip -->
|
||||||
<div
|
<div
|
||||||
v-if="moreTooltipData.length"
|
v-if="moreTooltipData.length"
|
||||||
ref="moreTooltip"
|
ref="moreTooltip"
|
||||||
class="fc-more-tooltip"
|
class="fc-more-tooltip"
|
||||||
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
|
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
|
||||||
@mouseleave="hideMoreTooltip"
|
@mouseleave="hideMoreTooltip"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(evt, idx) in moreTooltipData"
|
v-for="(evt, idx) in moreTooltipData"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="fc-more-tooltip-event"
|
class="fc-more-tooltip-event"
|
||||||
:style="{ borderLeftColor: evt.color }"
|
:style="{ borderLeftColor: evt.color }"
|
||||||
@click="openEventFromTooltip(evt)"
|
@click="openEventFromTooltip(evt)"
|
||||||
>
|
>
|
||||||
{{ evt.title }}
|
{{ evt.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event details modal -->
|
<!-- Event details modal -->
|
||||||
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
|
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<!-- Employee-photo event (recognition/recertification) with highlight -->
|
<!-- Employee-photo event (recognition/recertification) with highlight -->
|
||||||
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
|
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
|
||||||
<div class="recognition-badge">
|
<div class="recognition-badge">
|
||||||
<span class="recognition-icon"><Trophy :size="24" /></span>
|
<span class="recognition-icon"><Trophy :size="24" /></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="recognition-info">
|
<div class="recognition-info">
|
||||||
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
|
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
|
||||||
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
|
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
|
||||||
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
|
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
|
||||||
<span class="employee-icon"><User :size="16" /></span>
|
<span class="employee-icon"><User :size="16" /></span>
|
||||||
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
|
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Regular event header -->
|
<!-- Regular event header -->
|
||||||
<h2 v-else>{{ selectedEvent.title }}</h2>
|
<h2 v-else>{{ selectedEvent.title }}</h2>
|
||||||
|
|
||||||
<div class="event-details">
|
<div class="event-details">
|
||||||
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
|
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
|
||||||
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
|
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
|
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="selectedEvent.end">
|
<p v-if="selectedEvent.end">
|
||||||
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
|
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
|
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
|
||||||
<strong>Details:</strong>
|
<strong>Details:</strong>
|
||||||
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
|
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="selectedEvent.extendedProps?.ticketnumber">
|
<p v-if="selectedEvent.extendedProps?.ticketnumber">
|
||||||
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
|
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="selectedEvent.extendedProps?.linkurl">
|
<p v-if="selectedEvent.extendedProps?.linkurl">
|
||||||
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
|
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
|
||||||
More Info
|
More Info
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="selectedEvent.extendedProps?.notificationid"
|
v-if="selectedEvent.extendedProps?.notificationid"
|
||||||
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
|
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
>
|
>
|
||||||
View Notification
|
View Notification
|
||||||
</router-link>
|
</router-link>
|
||||||
<button class="btn btn-primary" @click="closeEventModal">Close</button>
|
<button class="btn btn-primary" @click="closeEventModal">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import FullCalendar from '@fullcalendar/vue3'
|
import FullCalendar from '@fullcalendar/vue3'
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import { Trophy, User } from 'lucide-vue-next'
|
import { Trophy, User } from 'lucide-vue-next'
|
||||||
import { notificationsApi } from '@/api'
|
import { notificationsApi } from '@/api'
|
||||||
|
|
||||||
const events = ref([])
|
const events = ref([])
|
||||||
const selectedEvent = ref(null)
|
const selectedEvent = ref(null)
|
||||||
const calendarRef = ref(null)
|
const calendarRef = ref(null)
|
||||||
const moreTooltip = ref(null)
|
const moreTooltip = ref(null)
|
||||||
const moreTooltipData = ref([])
|
const moreTooltipData = ref([])
|
||||||
const tooltipPosition = ref({ left: 0, top: 0 })
|
const tooltipPosition = ref({ left: 0, top: 0 })
|
||||||
|
|
||||||
// Store events by date for hover lookup
|
// Store events by date for hover lookup
|
||||||
const eventsByDate = ref({})
|
const eventsByDate = ref({})
|
||||||
|
|
||||||
const calendarOptions = ref({
|
const calendarOptions = ref({
|
||||||
plugins: [dayGridPlugin],
|
plugins: [dayGridPlugin],
|
||||||
initialView: 'dayGridMonth',
|
initialView: 'dayGridMonth',
|
||||||
events: [],
|
events: [],
|
||||||
eventClick: (info) => {
|
eventClick: (info) => {
|
||||||
selectedEvent.value = info.event
|
selectedEvent.value = info.event
|
||||||
},
|
},
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'dayGridMonth,dayGridWeek'
|
right: 'dayGridMonth,dayGridWeek'
|
||||||
},
|
},
|
||||||
height: 'auto',
|
// 'auto' sized every row to its own content, so a month of mostly empty days
|
||||||
dayMaxEvents: 3,
|
// collapsed into thin strips and the grid looked squashed. A viewport-relative
|
||||||
moreLinkClick: () => 'none' // Disable click, we use hover
|
// 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.
|
||||||
function hideMoreTooltip() {
|
height: 'calc(100vh - 230px)',
|
||||||
moreTooltipData.value = []
|
expandRows: true,
|
||||||
}
|
dayMaxEvents: 3,
|
||||||
|
moreLinkClick: () => 'none' // Disable click, we use hover
|
||||||
function handleMoreLinkHover(e) {
|
})
|
||||||
const moreLink = e.target.closest('.fc-daygrid-more-link')
|
|
||||||
if (!moreLink) {
|
function hideMoreTooltip() {
|
||||||
return
|
moreTooltipData.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the day cell and get its date
|
function handleMoreLinkHover(e) {
|
||||||
const dayCell = moreLink.closest('.fc-daygrid-day')
|
const moreLink = e.target.closest('.fc-daygrid-more-link')
|
||||||
if (!dayCell) return
|
if (!moreLink) {
|
||||||
|
return
|
||||||
const dateStr = dayCell.getAttribute('data-date')
|
}
|
||||||
if (!dateStr || !eventsByDate.value[dateStr]) return
|
|
||||||
|
// Find the day cell and get its date
|
||||||
// Get events for this date that would be hidden (after first 3)
|
const dayCell = moreLink.closest('.fc-daygrid-day')
|
||||||
const dayEvents = eventsByDate.value[dateStr]
|
if (!dayCell) return
|
||||||
const hiddenEvents = dayEvents.slice(3).map(evt => ({
|
|
||||||
title: evt.title,
|
const dateStr = dayCell.getAttribute('data-date')
|
||||||
color: evt.backgroundColor || '#14abef',
|
if (!dateStr || !eventsByDate.value[dateStr]) return
|
||||||
// Include full event data for modal
|
|
||||||
start: evt.start,
|
// Get events for this date that would be hidden (after first 3)
|
||||||
end: evt.end,
|
const dayEvents = eventsByDate.value[dateStr]
|
||||||
extendedProps: evt.extendedProps || {}
|
const hiddenEvents = dayEvents.slice(3).map(evt => ({
|
||||||
}))
|
title: evt.title,
|
||||||
|
color: evt.backgroundColor || '#14abef',
|
||||||
if (hiddenEvents.length === 0) return
|
// Include full event data for modal
|
||||||
|
start: evt.start,
|
||||||
moreTooltipData.value = hiddenEvents
|
end: evt.end,
|
||||||
|
extendedProps: evt.extendedProps || {}
|
||||||
// Position tooltip
|
}))
|
||||||
const rect = moreLink.getBoundingClientRect()
|
|
||||||
tooltipPosition.value = {
|
if (hiddenEvents.length === 0) return
|
||||||
left: rect.left,
|
|
||||||
top: rect.bottom + 5
|
moreTooltipData.value = hiddenEvents
|
||||||
}
|
|
||||||
}
|
// Position tooltip
|
||||||
|
const rect = moreLink.getBoundingClientRect()
|
||||||
function handleMoreLinkLeave(e) {
|
tooltipPosition.value = {
|
||||||
const related = e.relatedTarget
|
left: rect.left,
|
||||||
// Don't hide if moving to the tooltip itself
|
top: rect.bottom + 5
|
||||||
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
|
}
|
||||||
return
|
}
|
||||||
}
|
|
||||||
hideMoreTooltip()
|
function handleMoreLinkLeave(e) {
|
||||||
}
|
const related = e.relatedTarget
|
||||||
|
// Don't hide if moving to the tooltip itself
|
||||||
function buildEventsByDate() {
|
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
|
||||||
const byDate = {}
|
return
|
||||||
for (const evt of events.value) {
|
}
|
||||||
const dateStr = evt.start ? evt.start.split('T')[0] : null
|
hideMoreTooltip()
|
||||||
if (dateStr) {
|
}
|
||||||
if (!byDate[dateStr]) byDate[dateStr] = []
|
|
||||||
byDate[dateStr].push(evt)
|
function buildEventsByDate() {
|
||||||
}
|
const byDate = {}
|
||||||
}
|
for (const evt of events.value) {
|
||||||
eventsByDate.value = byDate
|
const dateStr = evt.start ? evt.start.split('T')[0] : null
|
||||||
}
|
if (dateStr) {
|
||||||
|
if (!byDate[dateStr]) byDate[dateStr] = []
|
||||||
onMounted(async () => {
|
byDate[dateStr].push(evt)
|
||||||
await loadEvents()
|
}
|
||||||
|
}
|
||||||
// Add event delegation for more links
|
eventsByDate.value = byDate
|
||||||
await nextTick()
|
}
|
||||||
const container = document.querySelector('.calendar-container')
|
|
||||||
if (container) {
|
onMounted(async () => {
|
||||||
container.addEventListener('mouseenter', handleMoreLinkHover, true)
|
await loadEvents()
|
||||||
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
|
|
||||||
}
|
// Add event delegation for more links
|
||||||
})
|
await nextTick()
|
||||||
|
const container = document.querySelector('.calendar-container')
|
||||||
onUnmounted(() => {
|
if (container) {
|
||||||
const container = document.querySelector('.calendar-container')
|
container.addEventListener('mouseenter', handleMoreLinkHover, true)
|
||||||
if (container) {
|
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
|
||||||
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
|
}
|
||||||
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
|
})
|
||||||
}
|
|
||||||
})
|
onUnmounted(() => {
|
||||||
|
const container = document.querySelector('.calendar-container')
|
||||||
onMounted(async () => {
|
if (container) {
|
||||||
await loadEvents()
|
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
|
||||||
})
|
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
|
||||||
|
}
|
||||||
async function loadEvents() {
|
})
|
||||||
try {
|
|
||||||
const response = await notificationsApi.getCalendar()
|
onMounted(async () => {
|
||||||
events.value = response.data.data
|
await loadEvents()
|
||||||
calendarOptions.value.events = events.value
|
})
|
||||||
buildEventsByDate()
|
|
||||||
} catch (error) {
|
async function loadEvents() {
|
||||||
console.error('Error loading calendar events:', error)
|
try {
|
||||||
}
|
const response = await notificationsApi.getCalendar()
|
||||||
}
|
events.value = response.data.data
|
||||||
|
calendarOptions.value.events = events.value
|
||||||
function closeEventModal() {
|
buildEventsByDate()
|
||||||
selectedEvent.value = null
|
} catch (error) {
|
||||||
}
|
console.error('Error loading calendar events:', error)
|
||||||
|
}
|
||||||
function openEventFromTooltip(evt) {
|
}
|
||||||
// Create an event-like object that matches FullCalendar's event structure
|
|
||||||
selectedEvent.value = {
|
function closeEventModal() {
|
||||||
title: evt.title,
|
selectedEvent.value = null
|
||||||
start: evt.start,
|
}
|
||||||
end: evt.end,
|
|
||||||
extendedProps: evt.extendedProps
|
function openEventFromTooltip(evt) {
|
||||||
}
|
// Create an event-like object that matches FullCalendar's event structure
|
||||||
hideMoreTooltip()
|
selectedEvent.value = {
|
||||||
}
|
title: evt.title,
|
||||||
|
start: evt.start,
|
||||||
function formatDate(dateStr) {
|
end: evt.end,
|
||||||
if (!dateStr) return ''
|
extendedProps: evt.extendedProps
|
||||||
// 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.
|
hideMoreTooltip()
|
||||||
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]))
|
function formatDate(dateStr) {
|
||||||
: new Date(dateStr)
|
if (!dateStr) return ''
|
||||||
return date.toLocaleDateString('en-US', {
|
// allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
|
||||||
weekday: 'short',
|
// Date from local parts so it is not shifted a day by UTC-midnight parsing.
|
||||||
year: 'numeric',
|
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
|
||||||
month: 'short',
|
const date = parts
|
||||||
day: 'numeric'
|
? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
|
||||||
})
|
: new Date(dateStr)
|
||||||
}
|
return date.toLocaleDateString('en-US', {
|
||||||
</script>
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
<style scoped>
|
month: 'short',
|
||||||
.calendar-container {
|
day: 'numeric'
|
||||||
min-height: 500px;
|
})
|
||||||
}
|
}
|
||||||
|
</script>
|
||||||
.modal {
|
|
||||||
padding: 1.5rem;
|
<style scoped>
|
||||||
}
|
.calendar-container {
|
||||||
|
min-height: 640px;
|
||||||
.modal h2 {
|
}
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-size: 18px;
|
/* :deep, because these are FullCalendar's own elements and scoped styles do not
|
||||||
color: var(--text);
|
reach them. A floor under each day cell keeps the grid readable on a short
|
||||||
padding-bottom: 0.75rem;
|
window, where the viewport-relative height above would otherwise squeeze the
|
||||||
border-bottom: 1px solid var(--border);
|
rows back down. */
|
||||||
}
|
:deep(.fc-daygrid-day-frame) {
|
||||||
|
min-height: 110px;
|
||||||
.event-details {
|
}
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
/* 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
|
||||||
.event-details p {
|
smaller than the grid needs. This stops it undercutting the day cells. */
|
||||||
margin-bottom: 0.75rem;
|
:deep(.fc) {
|
||||||
font-size: 14px;
|
min-height: 620px;
|
||||||
line-height: 1.5;
|
}
|
||||||
}
|
|
||||||
|
/* The day number was tight against the top edge once the cells grew. */
|
||||||
.event-details p:last-child {
|
:deep(.fc-daygrid-day-top) {
|
||||||
margin-bottom: 0;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-details strong {
|
.modal {
|
||||||
color: var(--text-light);
|
padding: 1.5rem;
|
||||||
display: inline-block;
|
}
|
||||||
min-width: 70px;
|
|
||||||
}
|
.modal h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
.message-block {
|
font-size: 18px;
|
||||||
display: flex;
|
color: var(--text);
|
||||||
flex-direction: column;
|
padding-bottom: 0.75rem;
|
||||||
gap: 0.25rem;
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-block strong {
|
.event-details {
|
||||||
min-width: auto;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-text {
|
.event-details p {
|
||||||
display: block;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.5rem 0.75rem;
|
font-size: 14px;
|
||||||
background: var(--bg);
|
line-height: 1.5;
|
||||||
border-radius: 0.25rem;
|
}
|
||||||
white-space: pre-wrap;
|
|
||||||
line-height: 1.5;
|
.event-details p:last-child {
|
||||||
}
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
.event-details strong {
|
||||||
gap: 0.5rem;
|
color: var(--text-light);
|
||||||
justify-content: flex-end;
|
display: inline-block;
|
||||||
margin-top: 1.25rem;
|
min-width: 70px;
|
||||||
padding-top: 1rem;
|
}
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
.message-block {
|
||||||
|
display: flex;
|
||||||
/* Recognition event styling */
|
flex-direction: column;
|
||||||
.recognition-header {
|
gap: 0.25rem;
|
||||||
display: flex;
|
}
|
||||||
gap: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
.message-block strong {
|
||||||
margin-bottom: 1rem;
|
min-width: auto;
|
||||||
border-bottom: 1px solid var(--border);
|
}
|
||||||
}
|
|
||||||
|
.message-text {
|
||||||
.recognition-badge {
|
display: block;
|
||||||
flex-shrink: 0;
|
padding: 0.5rem 0.75rem;
|
||||||
width: 60px;
|
background: var(--bg);
|
||||||
height: 60px;
|
border-radius: 0.25rem;
|
||||||
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
|
white-space: pre-wrap;
|
||||||
border-radius: 50%;
|
line-height: 1.5;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
.modal-actions {
|
||||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
display: flex;
|
||||||
}
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
.recognition-icon {
|
margin-top: 1.25rem;
|
||||||
font-size: 28px;
|
padding-top: 1rem;
|
||||||
}
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
.recognition-info {
|
|
||||||
flex: 1;
|
/* Recognition event styling */
|
||||||
display: flex;
|
.recognition-header {
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
justify-content: center;
|
gap: 1rem;
|
||||||
}
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
.recognition-label {
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 11px;
|
}
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
.recognition-badge {
|
||||||
color: var(--primary);
|
flex-shrink: 0;
|
||||||
font-weight: 600;
|
width: 60px;
|
||||||
margin-bottom: 0.25rem;
|
height: 60px;
|
||||||
}
|
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
|
||||||
|
border-radius: 50%;
|
||||||
.recognition-title {
|
display: flex;
|
||||||
margin: 0 !important;
|
align-items: center;
|
||||||
padding: 0 !important;
|
justify-content: center;
|
||||||
border: none !important;
|
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
||||||
font-size: 16px !important;
|
}
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--text) !important;
|
.recognition-icon {
|
||||||
}
|
font-size: 28px;
|
||||||
|
}
|
||||||
.recognition-employee {
|
|
||||||
display: flex;
|
.recognition-info {
|
||||||
align-items: center;
|
flex: 1;
|
||||||
gap: 0.5rem;
|
display: flex;
|
||||||
margin-top: 0.5rem;
|
flex-direction: column;
|
||||||
padding: 0.5rem 0.75rem;
|
justify-content: center;
|
||||||
background: rgba(65, 129, 255, 0.1);
|
}
|
||||||
border-radius: 0.25rem;
|
|
||||||
border-left: 3px solid var(--primary);
|
.recognition-label {
|
||||||
}
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
.employee-icon {
|
letter-spacing: 1px;
|
||||||
font-size: 18px;
|
color: var(--primary);
|
||||||
}
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
.employee-name {
|
}
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
.recognition-title {
|
||||||
font-size: 15px;
|
margin: 0 !important;
|
||||||
}
|
padding: 0 !important;
|
||||||
</style>
|
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>
|
||||||
|
|||||||
@@ -275,7 +275,12 @@ const form = ref({
|
|||||||
starttime: '',
|
starttime: '',
|
||||||
endtime: '',
|
endtime: '',
|
||||||
isactive: true,
|
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: ''
|
employeesso: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"display_name": "3D Printed Parts",
|
"display_name": "3D Printed Parts",
|
||||||
"author": "",
|
"author": "",
|
||||||
"dependencies": ["employees"],
|
"dependencies": ["employees"],
|
||||||
"core_version": ">=0.12.0,<1.0.0",
|
"core_version": ">=0.16.0,<1.0.0",
|
||||||
"api_prefix": "/api/printedparts",
|
"api_prefix": "/api/printedparts",
|
||||||
"default_enabled": false
|
"default_enabled": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from typing import List, Optional, Type
|
|||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||||
from shopdb.api import db, Setting
|
|
||||||
|
|
||||||
from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
|
from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
|
||||||
from .api import printedparts_bp
|
from .api import printedparts_bp
|
||||||
@@ -112,43 +111,79 @@ class PrintedpartsPlugin(BasePlugin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def on_install(self, app: Flask) -> None:
|
def on_install(self, app: Flask) -> None:
|
||||||
with app.app_context():
|
logger.info('Printedparts plugin installed')
|
||||||
self._seed_settings()
|
|
||||||
logger.info('Printedparts plugin installed')
|
|
||||||
|
|
||||||
def on_enable(self, app: Flask) -> None:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
# Idempotent re-seed so settings added in later versions reach sites
|
# The framework seeds these at install, at enable, and on every
|
||||||
# that installed earlier (enable runs on every upgrade cycle).
|
# `flask plugin upgrade-all`, so a key added in a later version reaches
|
||||||
with app.app_context():
|
# a site that installed an earlier one.
|
||||||
self._seed_settings()
|
return [
|
||||||
|
{
|
||||||
def _seed_settings(self) -> None:
|
'key': 'printedparts_code_prefix',
|
||||||
defaults = [
|
'value': '3DP',
|
||||||
('printedparts_code_prefix', '3DP', 'string',
|
'valuetype': 'string',
|
||||||
'Prefix for generated item codes'),
|
'category': 'printedparts',
|
||||||
('printedparts_label_prefix', '', 'string',
|
'description': 'Prefix for generated item codes',
|
||||||
'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_label_prefix',
|
||||||
('printedparts_default_threshold', '5', 'integer',
|
'value': '',
|
||||||
'Default low-stock threshold for new items'),
|
'valuetype': 'string',
|
||||||
('printedparts_unknown_badge', 'deny', 'string',
|
'category': 'printedparts',
|
||||||
'Kiosk policy when a badge resolves to no employee: allow or deny'),
|
# Public: the parts kiosk runs logged out, so the anonymous
|
||||||
('printedparts_alert_email', '', 'string',
|
# read has to be allowed or the prefix never renders there.
|
||||||
'Comma-separated low-stock alert recipients; empty uses the '
|
'public': True,
|
||||||
'site alert_recipients'),
|
'description': 'Leading text on the physical gage-lab labels, '
|
||||||
('printedparts_alert_userids', '', 'string',
|
'shown at the kiosk before the number box so '
|
||||||
'Comma-separated shopdb user ids whose account emails receive '
|
'operators type only the digits. Empty shows no '
|
||||||
'low-stock alerts'),
|
'prefix. Site-specific: West Jefferson labels '
|
||||||
('printedparts_alert_roleids', '', 'string',
|
'read WJ',
|
||||||
'Comma-separated role ids; every active member of these roles '
|
},
|
||||||
'receives low-stock alerts'),
|
{
|
||||||
('printedparts_alert_supportteamid', '', 'string',
|
'key': 'printedparts_default_threshold',
|
||||||
'Support team whose webhook receives low-stock alerts; empty uses '
|
'value': '5',
|
||||||
'the site alert_webhook_url'),
|
'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()
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.16.0,<1.0.0",
|
||||||
"api_prefix": "/api/printers",
|
"api_prefix": "/api/printers",
|
||||||
"provides": {
|
"provides": {
|
||||||
"machine_category": "Printer",
|
"machine_category": "Printer",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from flask import Flask, Blueprint
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||||
from shopdb.api import db, AssetType, Setting
|
from shopdb.api import db, AssetType
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
|
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
|
||||||
@@ -106,40 +106,65 @@ class PrintersPlugin(BasePlugin):
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
self._ensure_asset_type()
|
self._ensure_asset_type()
|
||||||
self._ensure_printer_types()
|
self._ensure_printer_types()
|
||||||
self._seed_settings()
|
|
||||||
logger.info("Printers plugin installed")
|
logger.info("Printers plugin installed")
|
||||||
|
|
||||||
def on_enable(self, app: Flask) -> None:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
# Idempotent re-seed so settings added in later versions reach sites
|
"""Low-toner alert settings.
|
||||||
# that installed earlier (enable runs on every upgrade cycle).
|
|
||||||
with app.app_context():
|
|
||||||
self._seed_settings()
|
|
||||||
|
|
||||||
def _seed_settings(self) -> None:
|
The framework seeds these at install, at enable, and on every
|
||||||
"""Seed low-toner alert recipient settings (idempotent)."""
|
`flask plugin upgrade-all`, so a key added in a later version reaches a
|
||||||
defaults = [
|
site that installed an earlier one.
|
||||||
('printers_alert_email', '', 'string',
|
"""
|
||||||
'Comma-separated low-toner alert recipients; empty uses the '
|
return [
|
||||||
'site alert_recipients'),
|
{
|
||||||
('printers_alert_userids', '', 'string',
|
'key': 'printers_alert_email',
|
||||||
'Comma-separated shopdb user ids whose account emails receive '
|
'value': '',
|
||||||
'low-toner alerts'),
|
'valuetype': 'string',
|
||||||
('printers_alert_roleids', '', 'string',
|
'category': 'printers',
|
||||||
'Comma-separated role ids; every active member of these roles '
|
'description': 'Comma-separated low-toner alert recipients; '
|
||||||
'receives low-toner alerts'),
|
'empty uses the site alert_recipients',
|
||||||
('printers_alert_supportteamid', '', 'string',
|
},
|
||||||
'Support team whose webhook receives low-toner alerts; empty '
|
{
|
||||||
'uses the site alert_webhook_url'),
|
'key': 'printers_alert_userids',
|
||||||
('printers_alert_warning_threshold', '5', 'integer',
|
'value': '',
|
||||||
'Toner percent remaining at or below which a warning email fires'),
|
'valuetype': 'string',
|
||||||
('printers_alert_critical_threshold', '0', 'integer',
|
'category': 'printers',
|
||||||
'Toner percent remaining at or below which a critical email fires'),
|
'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:
|
def _ensure_asset_type(self) -> None:
|
||||||
"""Ensure printer asset type exists."""
|
"""Ensure printer asset type exists."""
|
||||||
|
|||||||
@@ -33,10 +33,19 @@
|
|||||||
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
|
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
|
||||||
|
|
||||||
<div v-else class="slide-list">
|
<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" />
|
<input type="checkbox" class="pick" :value="slide.filename" v-model="selected" />
|
||||||
|
<span class="grip" title="Drag to reorder">⋮⋮</span>
|
||||||
<span class="order-num">{{ idx + 1 }}</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>
|
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
|
||||||
<div class="move">
|
<div class="move">
|
||||||
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">↑</button>
|
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">↑</button>
|
||||||
@@ -45,6 +54,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -53,6 +74,60 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { slidesApi } from '@/api'
|
import { slidesApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
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 = [
|
const surfaces = [
|
||||||
{ key: 'lobby', label: 'Lobby Display' },
|
{ key: 'lobby', label: 'Lobby Display' },
|
||||||
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
||||||
@@ -104,16 +179,7 @@ async function onUpload(event) {
|
|||||||
async function move(idx, delta) {
|
async function move(idx, delta) {
|
||||||
const target = idx + delta
|
const target = idx + delta
|
||||||
if (target < 0 || target >= slides.value.length) return
|
if (target < 0 || target >= slides.value.length) return
|
||||||
const arr = slides.value.slice()
|
await reorderTo(idx, target)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
@@ -184,6 +250,28 @@ onMounted(load)
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-light);
|
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 {
|
.thumb {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 68px;
|
height: 68px;
|
||||||
@@ -191,6 +279,41 @@ onMounted(load)
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #000;
|
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 {
|
.fname {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -67,9 +67,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Refresh slide list every 60 seconds
|
// Refresh slide list every 60 seconds
|
||||||
refreshTimer = setInterval(fetchSlides, 60000)
|
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(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
if (slideTimer) clearTimeout(slideTimer)
|
if (slideTimer) clearTimeout(slideTimer)
|
||||||
if (progressTimer) clearTimeout(progressTimer)
|
if (progressTimer) clearTimeout(progressTimer)
|
||||||
if (refreshTimer) clearInterval(refreshTimer)
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
@@ -139,6 +144,32 @@ function nextSlide() {
|
|||||||
if (slides.value.length === 0) return
|
if (slides.value.length === 0) return
|
||||||
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ from .plugins import plugin_manager
|
|||||||
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
||||||
# managed service token without importing core token internals. Additive name
|
# managed service token without importing core token internals. Additive name
|
||||||
# on the import surface, minor bump.
|
# 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
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ SECRET_MASK = '********'
|
|||||||
# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before
|
# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before
|
||||||
# login. Whole categories that are purely presentation are allowed wholesale;
|
# 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.
|
# 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_CATEGORIES = {'branding', 'map'}
|
||||||
PUBLIC_SETTING_KEYS = {
|
PUBLIC_SETTING_KEYS = {
|
||||||
'site_base_url', 'facility_name', 'printer_hostname_template',
|
'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:
|
def _is_public_setting(setting) -> bool:
|
||||||
return (setting.category in PUBLIC_SETTING_CATEGORIES
|
# A plugin declares its own public keys (get_settings_defaults, public=True)
|
||||||
or setting.key in PUBLIC_SETTING_KEYS)
|
# 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.
|
# 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
|
# Drives per-type seed keys and the Settings matrix UI. The asset type names
|
||||||
@@ -92,6 +110,17 @@ SEARCH_DOMAINS = {
|
|||||||
'subnet': 'Subnets',
|
'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:
|
def _is_secret(key: str) -> bool:
|
||||||
return 'password' in key or 'token' in key or 'secret' in key
|
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()
|
setting = Setting.query.filter_by(key=key).first()
|
||||||
|
|
||||||
# Upsert: create the row on first write (e.g. plugin config keys the setup
|
# 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:
|
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)
|
db.session.add(setting)
|
||||||
|
|
||||||
# Track old value for audit
|
# Track old value for audit
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ class PluginManager:
|
|||||||
per-plugin migrations extend that chain. See ADR-008. Idempotent.
|
per-plugin migrations extend that chain. See ADR-008. Idempotent.
|
||||||
"""
|
"""
|
||||||
results: Dict[str, str] = {}
|
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:
|
if not self.migration_manager:
|
||||||
return results
|
return results
|
||||||
# Only ADOPTED plugins (those in the registry) get migrated. A plugin
|
# Only ADOPTED plugins (those in the registry) get migrated. A plugin
|
||||||
@@ -134,6 +139,50 @@ class PluginManager:
|
|||||||
results[name] = f'error: {ex}'
|
results[name] = f'error: {ex}'
|
||||||
return results
|
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:
|
def _register_plugin_components(self, plugin: BasePlugin) -> None:
|
||||||
"""Register plugin's blueprint, models, CLI commands, etc."""
|
"""Register plugin's blueprint, models, CLI commands, etc."""
|
||||||
# Register blueprint
|
# Register blueprint
|
||||||
@@ -260,6 +309,7 @@ class PluginManager:
|
|||||||
if plugin:
|
if plugin:
|
||||||
self._register_plugin_components(plugin)
|
self._register_plugin_components(plugin)
|
||||||
self._seed_plugin_permissions(plugin)
|
self._seed_plugin_permissions(plugin)
|
||||||
|
self._seed_plugin_settings(plugin)
|
||||||
plugin.on_install(self._app)
|
plugin.on_install(self._app)
|
||||||
|
|
||||||
logger.info(f"Installed plugin: {name} v{manifest_version}")
|
logger.info(f"Installed plugin: {name} v{manifest_version}")
|
||||||
@@ -288,6 +338,58 @@ class PluginManager:
|
|||||||
"Seeded %d permission(s) for plugin %s",
|
"Seeded %d permission(s) for plugin %s",
|
||||||
created, plugin.meta.name)
|
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:
|
def _is_core_tier(self, name: str) -> bool:
|
||||||
"""True when the plugin's manifest marks it tier=core (mandatory).
|
"""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)
|
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||||
if plugin:
|
if plugin:
|
||||||
self._seed_plugin_permissions(plugin)
|
self._seed_plugin_permissions(plugin)
|
||||||
|
self._seed_plugin_settings(plugin)
|
||||||
plugin.on_enable(self._app)
|
plugin.on_enable(self._app)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"on_enable hook failed for plugin {name}")
|
logger.exception(f"on_enable hook failed for plugin {name}")
|
||||||
|
|||||||
@@ -104,6 +104,28 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return []
|
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):
|
def get_setting(self, key: str, default=None):
|
||||||
"""Read a plugin-scoped setting from the core Setting store.
|
"""Read a plugin-scoped setting from the core Setting store.
|
||||||
|
|
||||||
|
|||||||
@@ -869,7 +869,10 @@ def upgrade_all_plugins():
|
|||||||
Idempotent. Run this after `flask db upgrade` on every deploy and
|
Idempotent. Run this after `flask db upgrade` on every deploy and
|
||||||
upgrade. It stamps each bundled plugin's anchor revision into
|
upgrade. It stamps each bundled plugin's anchor revision into
|
||||||
alembic_version_<plugin> and applies any per-plugin migrations added
|
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')
|
pm = current_app.extensions.get('plugin_manager')
|
||||||
if not pm:
|
if not pm:
|
||||||
|
|||||||
148
tests/test_core/test_settings_plugin_declared.py
Normal file
148
tests/test_core/test_settings_plugin_declared.py
Normal 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'
|
||||||
Reference in New Issue
Block a user