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:
File diff suppressed because it is too large
Load Diff
@@ -157,6 +157,13 @@ def _apply_display_fields(t, data):
|
||||
if ds not in _DISPLAY_STYLES:
|
||||
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
||||
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:
|
||||
raw = data.get('gracewindowminutes')
|
||||
raw = 0 if raw in (None, '') else raw
|
||||
@@ -178,11 +185,11 @@ def _config_version():
|
||||
reach pages that are already open."""
|
||||
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
||||
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,
|
||||
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
||||
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
|
||||
]
|
||||
@@ -779,8 +786,11 @@ def get_shopfloor_notifications():
|
||||
'resolved': is_resolved,
|
||||
'typename': ntype.typename 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',
|
||||
'boardcategory': (ntype.boardcategory or '') if ntype else '',
|
||||
}
|
||||
|
||||
# Employee info (photo only when the type wants it)
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
||||
<span class="mono">{{ t.typecolor }}</span>
|
||||
</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>
|
||||
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
||||
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
||||
@@ -87,6 +90,17 @@
|
||||
</select>
|
||||
</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">
|
||||
<span>Keep showing after end (minutes)</span>
|
||||
<input v-model.number="form.gracewindowminutes" type="number"
|
||||
@@ -215,6 +229,7 @@ function openNew() {
|
||||
expirydays: null,
|
||||
expiryhour: null,
|
||||
gracewindowminutes: 0,
|
||||
boardcategory: '',
|
||||
isactive: true
|
||||
}
|
||||
editing.value = true
|
||||
@@ -234,6 +249,7 @@ function openEdit(t) {
|
||||
expirydays: t.expirydays ?? null,
|
||||
expiryhour: t.expiryhour ?? null,
|
||||
gracewindowminutes: t.gracewindowminutes ?? 0,
|
||||
boardcategory: t.boardcategory || '',
|
||||
isactive: t.isactive !== false
|
||||
}
|
||||
editing.value = true
|
||||
@@ -300,7 +316,10 @@ onMounted(load)
|
||||
z-index: 1000;
|
||||
}
|
||||
.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);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -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)
|
||||
@@ -83,6 +83,12 @@ class NotificationType(db.Model):
|
||||
gracewindowminutes = db.Column(db.Integer, nullable=False,
|
||||
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):
|
||||
return f"<NotificationType {self.typename}>"
|
||||
|
||||
@@ -100,7 +106,8 @@ class NotificationType(db.Model):
|
||||
'splitperemployee': bool(self.splitperemployee),
|
||||
'showemployeephoto': bool(self.showemployeephoto),
|
||||
'displaystyle': self.displaystyle or 'standard',
|
||||
'gracewindowminutes': int(self.gracewindowminutes or 0)
|
||||
'gracewindowminutes': int(self.gracewindowminutes or 0),
|
||||
'boardcategory': self.boardcategory or ''
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
117
tests/test_plugins/test_board_category.py
Normal file
117
tests/test_plugins/test_board_category.py
Normal 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
|
||||
Reference in New Issue
Block a user