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

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