Let a site choose the order its board rows run in

Rows ran in display-style order and then alphabetically, so what led the screen
was an accident of styling and the alphabet - a new type called Awareness
landed above Recertification for no better reason than the letter A.

Each type now carries a board position, lowest first, set on the Notification
Types page. The migration seeds Recognition at 10 and Recertification at 20 and
leaves everything else at 100, so an existing board keeps the order sites
already expect. Steps of ten leave room to slot a row in without renumbering
the rest.

A row shared by several types sits wherever its earliest-ordered type puts it,
so a category moves as a unit.
This commit is contained in:
cproudlock
2026-08-08 14:20:35 -04:00
parent adb8542018
commit 442a631557
6 changed files with 208 additions and 3 deletions

View File

@@ -140,6 +140,10 @@ def _apply_expiry_fields(t, data):
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
# Ceiling on a type's board position. Wide enough to leave gaps between rows
# (10, 20, 30 ...) so inserting one later needs no renumbering.
_MAX_BOARD_ORDER = 999
# Ceiling on the per-type post-expiry tail. A day is already far longer than
# "recently ended"; anything more is an end time that should have been later.
_MAX_GRACE_MINUTES = 1440
@@ -164,6 +168,16 @@ def _apply_display_fields(t, data):
# 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 'boardorder' in data:
raw = data.get('boardorder')
raw = 100 if raw in (None, '') else raw
try:
order = int(raw)
except (TypeError, ValueError):
return "boardorder must be a whole number"
if order < 0 or order > _MAX_BOARD_ORDER:
return "boardorder must be between 0 and %d" % _MAX_BOARD_ORDER
t.boardorder = order
if 'gracewindowminutes' in data:
raw = data.get('gracewindowminutes')
raw = 0 if raw in (None, '') else raw
@@ -185,11 +199,12 @@ 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|%s|%d|%d|%s|%s|%s|%d|%d|%s|%d" % (
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), t.boardcategory or '',
int(t.boardorder if t.boardorder is not None else 100),
)
for t in types
]
@@ -791,6 +806,7 @@ def get_shopfloor_notifications():
# 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 '',
'boardorder': int(ntype.boardorder if ntype and ntype.boardorder is not None else 100),
}
# Employee info (photo only when the type wants it)

View File

@@ -33,6 +33,7 @@
<span class="mono">{{ t.typecolor }}</span>
</td>
<td>
<span class="badge">#{{ t.boardorder ?? 100 }}</span>
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
</td>
@@ -90,6 +91,17 @@
</select>
</label>
<label class="field">
<span>Board order</span>
<input v-model.number="form.boardorder" type="number" min="0" max="999" step="10" />
<small class="muted">
Where this type's row sits on the shopfloor board, lowest first
(Recognition 10, Recertification 20, everything else 100). Leave
gaps so a new row can be slotted in without renumbering. Types
sharing a category take the lowest order among them.
</small>
</label>
<label class="field">
<span>Board category (optional)</span>
<input v-model="form.boardcategory" type="text" maxlength="50"
@@ -230,6 +242,7 @@ function openNew() {
expiryhour: null,
gracewindowminutes: 0,
boardcategory: '',
boardorder: 100,
isactive: true
}
editing.value = true
@@ -250,6 +263,7 @@ function openEdit(t) {
expiryhour: t.expiryhour ?? null,
gracewindowminutes: t.gracewindowminutes ?? 0,
boardcategory: t.boardcategory || '',
boardorder: t.boardorder ?? 100,
isactive: t.isactive !== false
}
editing.value = true

View File

@@ -0,0 +1,61 @@
"""Add notificationtypes.boardorder (row order on the shopfloor board).
Rows were ordered by display style and then type name, so what led the screen
was an accident of styling and the alphabet. A site decides its own running
order now: low first, and rows sharing a category sort by the lowest order
among their types.
Existing rows default to 100. Recognition and Recertification are seeded ahead
of that (10 and 20) so the out-of-the-box board keeps the order sites already
expect, without pinning anything a site has since renamed.
Idempotent; downgrade drops the column.
Revision ID: notifications0005boardorder
Revises: notifications0004category
"""
from alembic import op
import sqlalchemy as sa
revision = 'notifications0005boardorder'
down_revision = 'notifications0004category'
branch_labels = None
depends_on = None
_TABLE = 'notificationtypes'
_COLUMN = 'boardorder'
# Only the two types every site starts with; anything else keeps the default.
_SEEDED_ORDER = (('Recognition', 10), ('Recertification', 20))
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 in _column_names(insp, _TABLE):
return
op.add_column(_TABLE, sa.Column(_COLUMN, sa.Integer(), nullable=False,
server_default='100'))
# Seed the shipped types' order. Guarded on the default so a site that has
# already set an order (re-running after a manual add) is left alone.
for typename, order in _SEEDED_ORDER:
bind.execute(
sa.text('UPDATE notificationtypes SET boardorder = :order '
'WHERE typename = :typename AND boardorder = 100'),
{'order': order, 'typename': typename})
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

@@ -89,6 +89,13 @@ class NotificationType(db.Model):
# displaystyle - a banner and a tile row cannot be the same row.
boardcategory = db.Column(db.String(50), nullable=True)
# Where this type's row sits on the shopfloor board, low first. A site
# decides what leads the screen - recognition above recertification above
# the rest - rather than inheriting an alphabetical or by-style accident.
# Rows sharing a category sort by the lowest order among their types.
boardorder = db.Column(db.Integer, nullable=False,
server_default='100', default=100)
def __repr__(self):
return f"<NotificationType {self.typename}>"
@@ -107,7 +114,8 @@ class NotificationType(db.Model):
'showemployeephoto': bool(self.showemployeephoto),
'displaystyle': self.displaystyle or 'standard',
'gracewindowminutes': int(self.gracewindowminutes or 0),
'boardcategory': self.boardcategory or ''
'boardcategory': self.boardcategory or '',
'boardorder': int(self.boardorder if self.boardorder is not None else 100)
}

View File

@@ -68,7 +68,7 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# notifications indexes businessunitid, then adds the per-type grace window and
# the shared board category.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0004category'
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -0,0 +1,106 @@
"""Tests for notificationtypes.boardorder (row order on the shopfloor board).
Rows used to run in display-style order and then alphabetically, so what led
the screen was an accident of styling and the alphabet. Each type now carries a
position, low first, and a shared category row sits wherever its
earliest-ordered type puts it.
The sort itself is frontend (ShopfloorDashboard.vue); pinned here is the
contract it sorts on - the feed carries boardorder per card, the type API
round-trips and validates it, and the migration seeds the shipped order.
"""
from plugins.notifications.models import Notification, NotificationType
def _make_type(db, typename, boardorder=100, displaystyle='grid', boardcategory=None):
t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
displaystyle=displaystyle, boardcategory=boardcategory,
boardorder=boardorder)
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_order_per_card(client, db):
ntype = _make_type(db, 'Recognition', boardorder=10)
_make_note(db, ntype, 'ten years')
assert _current(client)[0]['boardorder'] == 10
def test_a_type_defaults_to_the_back(client, db):
"""A new type lands behind the seeded rows rather than jumping the queue."""
ntype = NotificationType(typename='Fresh', typecolor='#17a2b8', isactive=True)
db.session.add(ntype)
db.session.commit()
assert ntype.boardorder == 100
def test_api_round_trips_the_order(client, db, auth_headers):
create = client.post('/api/notifications/types',
json={'typename': 'Awareness', 'boardorder': 30},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
assert create.get_json()['data']['boardorder'] == 30
updated = client.put(f'/api/notifications/types/{typeid}',
json={'boardorder': 40}, headers=auth_headers)
assert updated.get_json()['data']['boardorder'] == 40
def test_api_rejects_a_nonsense_order(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'boardorder': 'first'},
headers=auth_headers)
assert resp.status_code == 400
assert 'boardorder' in resp.get_data(as_text=True)
def test_api_rejects_an_out_of_range_order(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'boardorder': 1000},
headers=auth_headers)
assert resp.status_code == 400
def test_order_change_moves_the_config_version(client, db, auth_headers):
"""Open kiosks reload on a layout change; a re-ordered 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={'boardorder': 30}, headers=auth_headers)
after = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
assert before != after
def test_types_in_one_category_can_carry_different_orders(client, db):
"""The row takes the lowest; the feed still reports each type's own."""
change = _make_type(db, 'Change', boardorder=30, boardcategory='Current Events')
incident = _make_type(db, 'Incident', boardorder=50, boardcategory='Current Events')
_make_note(db, change, 'retooling')
_make_note(db, incident, 'press down')
orders = {c['typename']: c['boardorder'] for c in _current(client)}
assert orders == {'Change': 30, 'Incident': 50}