Give every notification type its own row on the board

The shopfloor board grouped cards by display style alone, so every type set to
grid landed inside the Recertification row and every carousel type inside
Recognition's - under a heading naming somebody else's type. Setting Awareness
to grid put awareness messages under "Recertification Required".

Each type now gets a row of its own, titled by its own name, and rotation state
is per row: two carousel rows advance on their own indexes instead of sharing
one counter, and two grid rows page independently.

For the other direction there is notificationtypes.boardcategory. Types sharing
a category share one row under the category name, so Change, Awareness and
Incident can sit together while Recognition and Recertification keep their own.
Blank - the default - means a row of its own. The category is part of the
grouping key along with the display style, since a category cannot merge a
banner with a row of tiles.

A card that names no employee now renders its message as the tile or card,
rather than a placeholder face above a blank name, which is what a grid type
like Awareness looked like before.

The layout fingerprint that makes open kiosks reload now covers the category
and the grace window, so a re-grouped board reaches screens that are already up.
This commit is contained in:
cproudlock
2026-08-07 10:30:28 -04:00
parent a52e192501
commit 76c184fe91
7 changed files with 1081 additions and 826 deletions

View File

@@ -23,10 +23,18 @@
</header> </header>
<main class="dashboard-content"> <main class="dashboard-content">
<!-- Banner - single prominent full-width message --> <!--
<section v-if="banners.length" class="banner-section"> One section per TYPE, in style order (banner, carousel, grid). Grouping
by display style alone merged every grid type into Recertification's
row and every carousel type into Recognition's, under that type's
heading. Each type now carries its own row titled by its own name.
-->
<template v-for="group in styledGroups" :key="group.key">
<!-- Banner - full-width strips, one per message -->
<section v-if="group.displaystyle === 'banner'" class="banner-section">
<div <div
v-for="n in banners" v-for="n in group.items"
:key="n.notificationid" :key="n.notificationid"
class="banner-strip" class="banner-strip"
:style="{ backgroundColor: getTypeColor(n.typecolor) }" :style="{ backgroundColor: getTypeColor(n.typecolor) }"
@@ -35,17 +43,19 @@
</div> </div>
</section> </section>
<!-- Recognition Carousel --> <!-- Carousel - one card at a time, rotating -->
<section v-if="recognitions.length" class="recognition-section"> <section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
<div class="section-title recognition">Employee Recognition</div> <div class="section-title recognition">{{ group.typename }}</div>
<div class="recognition-carousel"> <div class="recognition-carousel">
<div <div
v-for="(rec, idx) in recognitions" v-for="(rec, idx) in group.items"
:key="`${rec.notificationid}-${rec.employeesso}`" :key="`${rec.notificationid}-${rec.employeesso}`"
class="recognition-card" class="recognition-card"
:class="{ active: idx === currentRecognition }" :class="{ active: idx === carouselIndex(group) }"
> >
<div class="recognition-photo-container"> <!-- A type that names no employee is a message, not a person:
no photo frame, no empty name line. -->
<div v-if="hasEmployee(rec)" class="recognition-photo-container">
<img <img
v-if="rec.employeepicture" v-if="rec.employeepicture"
:src="rec.employeepicture" :src="rec.employeepicture"
@@ -61,7 +71,7 @@
/> />
</div> </div>
<div class="recognition-content"> <div class="recognition-content">
<div class="recognition-header"> <div v-if="hasEmployee(rec)" class="recognition-header">
<div class="recognition-name">{{ rec.employeename }}</div> <div class="recognition-name">{{ rec.employeename }}</div>
</div> </div>
<div class="recognition-message">{{ rec.notification }}</div> <div class="recognition-message">{{ rec.notification }}</div>
@@ -70,15 +80,14 @@
</div> </div>
</section> </section>
<!-- Recertification grid - everyone due shown at once, so nobody has to <!-- Grid - a row of tiles, cycling a page at a time -->
wait for a carousel to rotate to their name --> <section v-else class="recert-section">
<section v-if="recertifications.length" class="recert-section">
<div class="section-title recert-title"> <div class="section-title recert-title">
<span>Recertification Required ({{ recertifications.length }})</span> <span>{{ group.typename }} ({{ group.items.length }})</span>
<span v-if="recertRangeLabel" class="recert-range">{{ recertRangeLabel }}</span> <span v-if="gridRangeLabel(group)" class="recert-range">{{ gridRangeLabel(group) }}</span>
</div> </div>
<div <div
v-for="msg in recertDescriptions" v-for="msg in groupDescriptions(group)"
:key="msg" :key="msg"
class="recert-description" class="recert-description"
> >
@@ -86,10 +95,12 @@
</div> </div>
<div class="recert-row"> <div class="recert-row">
<div <div
v-for="rec in recertPage" v-for="rec in gridPageItems(group)"
:key="`recert-${rec.notificationid}-${rec.employeesso}`" :key="`grid-${rec.notificationid}-${rec.employeesso}`"
class="recert-tile" class="recert-tile"
:class="{ 'message-tile': !hasEmployee(rec) }"
> >
<template v-if="hasEmployee(rec)">
<img <img
v-if="rec.employeepicture" v-if="rec.employeepicture"
:src="rec.employeepicture" :src="rec.employeepicture"
@@ -104,10 +115,16 @@
class="recert-photo ge-logo-fallback" class="recert-photo ge-logo-fallback"
/> />
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div> <div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
</template>
<!-- No employee: the message IS the tile, so a grid type like
Awareness does not render rows of blank placeholder faces. -->
<div v-else class="recert-message">{{ rec.notification }}</div>
</div> </div>
</div> </div>
</section> </section>
</template>
<!-- Current Notifications --> <!-- Current Notifications -->
<section v-if="currentNotifications.length" class="notifications-section"> <section v-if="currentNotifications.length" class="notifications-section">
<div class="section-title" :class="getSectionClass(currentNotifications)"> <div class="section-title" :class="getSectionClass(currentNotifications)">
@@ -170,7 +187,7 @@
</section> </section>
<!-- No notifications --> <!-- No notifications -->
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length && !recertifications.length && !banners.length" class="no-events"> <div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !styledGroups.length" class="no-events">
No active notifications No active notifications
</div> </div>
@@ -202,7 +219,6 @@ const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
const businessUnit = ref('') const businessUnit = ref('')
const businessUnits = ref([]) const businessUnits = ref([])
const notifications = ref({ current: [], upcoming: [] }) const notifications = ref({ current: [], upcoming: [] })
const currentRecognition = ref(0)
// Layout-config fingerprint from the feed; when it changes the kiosk reloads. // Layout-config fingerprint from the feed; when it changes the kiosk reloads.
const loadedConfigVersion = ref(null) const loadedConfigVersion = ref(null)
@@ -228,49 +244,81 @@ function fitToScreen() {
// type set to carousel/grid/banner renders that way - not just the built-ins. // type set to carousel/grid/banner renders that way - not just the built-ins.
const SPECIAL_STYLES = ['carousel', 'grid', 'banner'] const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
const recognitions = computed(() => // Sections render in style order, so the board keeps its shape as types come
notifications.value.current.filter(n => n.displaystyle === 'carousel') // and go: strips at the top, then rotating cards, then tile rows.
) const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
const recertifications = computed(() => // One row per TYPE, unless types opt into a shared boardcategory - Change,
notifications.value.current.filter(n => n.displaystyle === 'grid') // Awareness and Incident under one "Alerts" heading, say, while Recognition and
) // Recertification keep rows of their own. Grouping by display style alone
// folded every grid type into one row under someone else's name.
// The style is part of the key: a category cannot merge a banner with a tile
// row, so same-category types of different styles still render separately.
const styledGroups = computed(() => {
const groups = new Map()
for (const n of notifications.value.current) {
if (!SPECIAL_STYLES.includes(n.displaystyle)) continue
const heading = (n.boardcategory || '').trim() || n.typename || n.displaystyle
const key = `${n.displaystyle}|${heading}`
if (!groups.has(key)) {
groups.set(key, {
key,
typename: heading,
displaystyle: n.displaystyle,
items: []
})
}
groups.get(key).items.push(n)
}
return [...groups.values()].sort((a, b) =>
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
a.typename.localeCompare(b.typename)
)
})
const banners = computed(() => // A card belongs to a person only if it names one; a plain message type set to
notifications.value.current.filter(n => n.displaystyle === 'banner') // carousel or grid has no SSO and must not render a placeholder face.
) function hasEmployee(n) {
return !!(n.employeesso || n.employeename || n.employeepicture)
}
// Distinct training descriptions shown once above the grid (the per-person // Distinct messages shown once above a grid row (the per-person tiles carry
// tiles only carry photo + name). // only photo + name, so the message would otherwise have nowhere to go).
const recertDescriptions = computed(() => { function groupDescriptions(group) {
if (!group.items.some(hasEmployee)) return []
const seen = new Set() const seen = new Set()
const out = [] const out = []
for (const r of recertifications.value) { for (const item of group.items) {
const msg = (r.notification || '').trim() const msg = (item.notification || '').trim()
if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) } if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) }
} }
return out return out
}) }
// Recertification shows as a single rotating row: one page of tiles at a time // Rotation state is per group, keyed by type: two grid rows page independently
// so it stays compact on any screen, cycling through everyone due. // instead of sharing one index.
const RECERT_PAGE_SIZE = 8 const GRID_PAGE_SIZE = 8
const currentRecertPage = ref(0) const carouselIndexes = ref({})
const recertPageCount = computed(() => const gridPages = ref({})
Math.max(1, Math.ceil(recertifications.value.length / RECERT_PAGE_SIZE))
) function carouselIndex(group) {
const recertPage = computed(() => { return (carouselIndexes.value[group.key] || 0) % Math.max(1, group.items.length)
const page = currentRecertPage.value % recertPageCount.value }
const start = page * RECERT_PAGE_SIZE function gridPageCount(group) {
return recertifications.value.slice(start, start + RECERT_PAGE_SIZE) return Math.max(1, Math.ceil(group.items.length / GRID_PAGE_SIZE))
}) }
const recertRangeLabel = computed(() => { function gridPageItems(group) {
if (recertPageCount.value <= 1) return '' const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
const page = currentRecertPage.value % recertPageCount.value const start = page * GRID_PAGE_SIZE
const start = page * RECERT_PAGE_SIZE return group.items.slice(start, start + GRID_PAGE_SIZE)
const end = Math.min(start + RECERT_PAGE_SIZE, recertifications.value.length) }
return `${start + 1}-${end} of ${recertifications.value.length}` function gridRangeLabel(group) {
}) if (gridPageCount(group) <= 1) return ''
const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
const start = page * GRID_PAGE_SIZE
const end = Math.min(start + GRID_PAGE_SIZE, group.items.length)
return `${start + 1}-${end} of ${group.items.length}`
}
const currentNotifications = computed(() => const currentNotifications = computed(() =>
notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle)) notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
@@ -325,17 +373,23 @@ onMounted(async () => {
// Auto-refresh every 30 seconds // Auto-refresh every 30 seconds
refreshInterval = setInterval(loadData, 30000) refreshInterval = setInterval(loadData, 30000)
// Rotate recognition carousel every 8 seconds // Advance every carousel row every 8 seconds. Each row keeps its own index,
// so a row of three cards does not drag a row of ten along with it.
recognitionInterval = setInterval(() => { recognitionInterval = setInterval(() => {
if (recognitions.value.length > 1) { for (const group of styledGroups.value) {
currentRecognition.value = (currentRecognition.value + 1) % recognitions.value.length if (group.displaystyle !== 'carousel' || group.items.length < 2) continue
const next = (carouselIndexes.value[group.key] || 0) + 1
carouselIndexes.value[group.key] = next % group.items.length
} }
}, 8000) }, 8000)
// Cycle the recertification row through pages of employees every 7 seconds. // Page every grid row every 7 seconds, each on its own page count.
recertPageInterval = setInterval(() => { recertPageInterval = setInterval(() => {
if (recertPageCount.value > 1) { for (const group of styledGroups.value) {
currentRecertPage.value = (currentRecertPage.value + 1) % recertPageCount.value if (group.displaystyle !== 'grid') continue
const pages = gridPageCount(group)
if (pages < 2) continue
gridPages.value[group.key] = ((gridPages.value[group.key] || 0) + 1) % pages
} }
}, 7000) }, 7000)

View File

@@ -157,6 +157,13 @@ def _apply_display_fields(t, data):
if ds not in _DISPLAY_STYLES: if ds not in _DISPLAY_STYLES:
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES) return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
t.displaystyle = ds t.displaystyle = ds
if 'boardcategory' in data:
category = (data.get('boardcategory') or '').strip()
if len(category) > 50:
return "boardcategory must be 50 characters or fewer"
# Blank stores as NULL: "no category" is the absence of one, not the
# empty-string category that every uncategorised type would share.
t.boardcategory = category or None
if 'gracewindowminutes' in data: if 'gracewindowminutes' in data:
raw = data.get('gracewindowminutes') raw = data.get('gracewindowminutes')
raw = 0 if raw in (None, '') else raw raw = 0 if raw in (None, '') else raw
@@ -178,11 +185,11 @@ def _config_version():
reach pages that are already open.""" reach pages that are already open."""
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all() types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
parts = [ parts = [
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d" % ( "%s|%s|%s|%d|%d|%s|%s|%s|%d|%d|%s" % (
t.notificationtypeid, t.typecolor, t.displaystyle, t.notificationtypeid, t.typecolor, t.displaystyle,
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)), int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)), t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
int(t.gracewindowminutes or 0), int(t.gracewindowminutes or 0), t.boardcategory or '',
) )
for t in types for t in types
] ]
@@ -779,8 +786,11 @@ def get_shopfloor_notifications():
'resolved': is_resolved, 'resolved': is_resolved,
'typename': ntype.typename if ntype else None, 'typename': ntype.typename if ntype else None,
'typecolor': ntype.typecolor if ntype else None, 'typecolor': ntype.typecolor if ntype else None,
# Per-type display behavior the dashboard groups/renders by. # Per-type display behavior the dashboard groups/renders by. A
# boardcategory puts several types in one row under that name;
# blank gives the type a row of its own.
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard', 'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
'boardcategory': (ntype.boardcategory or '') if ntype else '',
} }
# Employee info (photo only when the type wants it) # Employee info (photo only when the type wants it)

View File

@@ -32,7 +32,10 @@
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span> <span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
<span class="mono">{{ t.typecolor }}</span> <span class="mono">{{ t.typecolor }}</span>
</td> </td>
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td> <td>
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
</td>
<td> <td>
<span v-if="t.splitperemployee" class="badge badge-success">split</span> <span v-if="t.splitperemployee" class="badge badge-success">split</span>
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span> <span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
@@ -87,6 +90,17 @@
</select> </select>
</label> </label>
<label class="field">
<span>Board category (optional)</span>
<input v-model="form.boardcategory" type="text" maxlength="50"
placeholder="its own row" />
<small class="muted">
Types sharing a category share one row on the shopfloor board,
under the category name. Blank gives this type a row of its own.
Only types with the same display style can share a row.
</small>
</label>
<label class="field"> <label class="field">
<span>Keep showing after end (minutes)</span> <span>Keep showing after end (minutes)</span>
<input v-model.number="form.gracewindowminutes" type="number" <input v-model.number="form.gracewindowminutes" type="number"
@@ -215,6 +229,7 @@ function openNew() {
expirydays: null, expirydays: null,
expiryhour: null, expiryhour: null,
gracewindowminutes: 0, gracewindowminutes: 0,
boardcategory: '',
isactive: true isactive: true
} }
editing.value = true editing.value = true
@@ -234,6 +249,7 @@ function openEdit(t) {
expirydays: t.expirydays ?? null, expirydays: t.expirydays ?? null,
expiryhour: t.expiryhour ?? null, expiryhour: t.expiryhour ?? null,
gracewindowminutes: t.gracewindowminutes ?? 0, gracewindowminutes: t.gracewindowminutes ?? 0,
boardcategory: t.boardcategory || '',
isactive: t.isactive !== false isactive: t.isactive !== false
} }
editing.value = true editing.value = true
@@ -300,7 +316,10 @@ onMounted(load)
z-index: 1000; z-index: 1000;
} }
.modal-panel { .modal-panel {
background: var(--bg-card); /* Solid, not var(--bg-card): the card variable is translucent in dark mode
(rgba(0,0,61,0.4)) so cards glass over the page, which left this dialog
see-through with the table legible behind it. */
background: var(--bg-card-solid);
color: var(--text); color: var(--text);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;

View File

@@ -0,0 +1,47 @@
"""Add notificationtypes.boardcategory (shared heading on the shopfloor board).
The board grouped cards by display style alone, so every type set to grid landed
inside the Recertification row and every carousel type inside Recognition's,
under a heading that named someone else's type. Each type now gets a row of its
own; this column is the opt-in for the other direction - several types that
belong together (Change, Awareness, Incident) share one row under a category
name instead of taking three.
Idempotent; downgrade drops the column.
Revision ID: notifications0004category
Revises: notifications0003grace
"""
from alembic import op
import sqlalchemy as sa
revision = 'notifications0004category'
down_revision = 'notifications0003grace'
branch_labels = None
depends_on = None
_TABLE = 'notificationtypes'
_COLUMN = 'boardcategory'
def _column_names(insp, table):
return {c['name'] for c in insp.get_columns(table)}
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
if _COLUMN not in _column_names(insp, _TABLE):
op.add_column(_TABLE, sa.Column(_COLUMN, sa.String(50), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
if _COLUMN in _column_names(insp, _TABLE):
op.drop_column(_TABLE, _COLUMN)

View File

@@ -83,6 +83,12 @@ class NotificationType(db.Model):
gracewindowminutes = db.Column(db.Integer, nullable=False, gracewindowminutes = db.Column(db.Integer, nullable=False,
server_default='0', default=0) server_default='0', default=0)
# Optional shared heading on the shopfloor board. Blank (the default) gives
# the type a row of its own under its own name; types sharing a category
# share one row under that category name, provided they also share a
# displaystyle - a banner and a tile row cannot be the same row.
boardcategory = db.Column(db.String(50), nullable=True)
def __repr__(self): def __repr__(self):
return f"<NotificationType {self.typename}>" return f"<NotificationType {self.typename}>"
@@ -100,7 +106,8 @@ class NotificationType(db.Model):
'splitperemployee': bool(self.splitperemployee), 'splitperemployee': bool(self.splitperemployee),
'showemployeephoto': bool(self.showemployeephoto), 'showemployeephoto': bool(self.showemployeephoto),
'displaystyle': self.displaystyle or 'standard', 'displaystyle': self.displaystyle or 'standard',
'gracewindowminutes': int(self.gracewindowminutes or 0) 'gracewindowminutes': int(self.gracewindowminutes or 0),
'boardcategory': self.boardcategory or ''
} }

View File

@@ -64,8 +64,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds # printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
# the per-transaction revision column. # the per-transaction revision column.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev' EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# notifications indexes businessunitid, then adds the per-type grace window. # notifications indexes businessunitid, then adds the per-type grace window and
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0003grace' # the shared board category.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0004category'
# Plugins built after the cutover: their 0001 baseline really creates tables the # Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned. # core chain never owned.

View File

@@ -0,0 +1,117 @@
"""Tests for notificationtypes.boardcategory (shared row on the shopfloor board).
The board grouped cards by display style alone, so every type set to grid landed
in the Recertification row and every carousel type in Recognition's, under a
heading naming someone else's type. Each type now gets a row of its own, and
this column is the opt-in for the other direction: Change, Awareness and
Incident share one row under a category name.
The grouping itself is frontend (ShopfloorDashboard.vue); what is pinned here is
the contract it groups on - the feed must carry boardcategory per card, and the
type API must round-trip it.
"""
from plugins.notifications.models import Notification, NotificationType
def _make_type(db, typename, displaystyle='grid', boardcategory=None):
t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
displaystyle=displaystyle, boardcategory=boardcategory)
db.session.add(t)
db.session.commit()
return t
def _make_note(db, ntype, text):
n = Notification(notificationtypeid=ntype.notificationtypeid,
notification=text, businessunitid=None,
isactive=True, isshopfloor=True)
db.session.add(n)
db.session.commit()
return n
def _current(client):
resp = client.get('/api/notifications/shopfloor')
assert resp.status_code == 200, resp.get_json()
return resp.get_json()['data']['current']
def test_feed_carries_the_category_per_card(client, db):
"""The dashboard groups on this field, so it has to reach the card."""
ntype = _make_type(db, 'Awareness', boardcategory='Alerts')
_make_note(db, ntype, 'watch your step')
card = _current(client)[0]
assert card['boardcategory'] == 'Alerts'
assert card['typename'] == 'Awareness'
assert card['displaystyle'] == 'grid'
def test_uncategorised_type_reports_an_empty_category(client, db):
"""Blank, not null: the dashboard falls back to the type name for a row."""
ntype = _make_type(db, 'Recertification')
_make_note(db, ntype, 'forklift cert due')
assert _current(client)[0]['boardcategory'] == ''
def test_types_sharing_a_category_keep_their_own_names(client, db):
"""Grouping is the dashboard's job; the feed must not flatten the types."""
change = _make_type(db, 'Change', boardcategory='Alerts')
incident = _make_type(db, 'Incident', boardcategory='Alerts')
_make_note(db, change, 'line 3 retooling')
_make_note(db, incident, 'press 12 down')
cards = _current(client)
assert {c['typename'] for c in cards} == {'Change', 'Incident'}
assert {c['boardcategory'] for c in cards} == {'Alerts'}
def test_api_round_trips_the_category(client, db, auth_headers):
create = client.post('/api/notifications/types',
json={'typename': 'Awareness', 'displaystyle': 'grid',
'boardcategory': 'Alerts'},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
assert create.get_json()['data']['boardcategory'] == 'Alerts'
listed = client.get('/api/notifications/types', headers=auth_headers)
row = next(t for t in listed.get_json()['data']
if t['notificationtypeid'] == typeid)
assert row['boardcategory'] == 'Alerts'
def test_blank_category_stores_as_null_not_an_empty_group(client, db, auth_headers):
"""Every uncategorised type sharing the empty string would be one big row."""
create = client.post('/api/notifications/types',
json={'typename': 'Solo', 'boardcategory': ' '},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
stored = db.session.get(NotificationType, typeid)
assert stored.boardcategory is None
assert create.get_json()['data']['boardcategory'] == ''
def test_api_rejects_an_overlong_category(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'boardcategory': 'x' * 51},
headers=auth_headers)
assert resp.status_code == 400
assert 'boardcategory' in resp.get_data(as_text=True)
def test_category_change_moves_the_config_version(client, db, auth_headers):
"""Open kiosks reload on a layout change; a re-grouped board is one."""
ntype = _make_type(db, 'Awareness')
before = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
client.put(f'/api/notifications/types/{ntype.notificationtypeid}',
json={'boardcategory': 'Alerts'}, headers=auth_headers)
after = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
assert before != after