Let an end time mean the card leaves the board

The shopfloor feed kept every ended notification up for a hardcoded 30 minutes,
flagged resolved. A card with an 8:00 end time was still on the board at 8:29,
which reads as an expiry that did not work - and in the carousel, grid and
banner sections it read that way with no visual sign at all, since only the
standard cards render the resolved state.

The tail is now notificationtypes.gracewindowminutes, set per type on the
Notification Types page and defaulting to 0, so an end time means what it says.
A type whose cards are worth acknowledging after they clear - an incident, say
- opts into a tail, and only that type's cards get one.

The feed widens its query to the largest configured tail and then holds each
row to its own type's window. That keeps one portable query rather than a
per-type interval expression in SQL, and with every type at 0 it collapses to
"still showing".

Also fixes resolved serializing as null rather than false for a card with no
end time, which the and-chain produced.
This commit is contained in:
cproudlock
2026-08-07 09:22:34 -04:00
parent 536a8f0825
commit fef5e28952
6 changed files with 259 additions and 13 deletions

View File

@@ -0,0 +1,141 @@
"""Tests for the per-type post-expiry window on the shopfloor feed.
The board used to keep every ended notification for a hardcoded 30 minutes,
flagged resolved. A card with an 8:00 end time was therefore still up at 8:29,
and in the carousel/grid/banner sections nothing marked it as ended at all. The
tail is now notificationtypes.gracewindowminutes, default 0: an end time means
the card leaves the board then, and a type opts in if its cards are worth
acknowledging afterwards.
"""
from datetime import datetime, timedelta, timezone
from plugins.notifications.models import Notification, NotificationType
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _make_type(db, typename, grace=0, displaystyle='standard'):
t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
displaystyle=displaystyle, gracewindowminutes=grace)
db.session.add(t)
db.session.commit()
return t
def _make_note(db, ntype, ended_minutes_ago=None, text='msg'):
"""Shopfloor note that ended N minutes ago (None = still running)."""
now = _utcnow()
n = Notification(
notificationtypeid=ntype.notificationtypeid,
notification=text,
businessunitid=None,
isactive=True,
isshopfloor=True,
starttime=now - timedelta(hours=2),
endtime=None if ended_minutes_ago is None
else now - timedelta(minutes=ended_minutes_ago),
)
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_default_type_drops_the_card_at_its_end_time(client, db):
"""The reported bug: an 8:00 end time meant gone at 8:30."""
ntype = _make_type(db, 'General')
_make_note(db, ntype, ended_minutes_ago=1)
assert _current(client) == []
def test_new_type_defaults_to_no_tail(client, db):
"""A type created without naming the field gets 0, not a surprise tail."""
ntype = NotificationType(typename='Fresh', typecolor='#17a2b8', isactive=True)
db.session.add(ntype)
db.session.commit()
assert (ntype.gracewindowminutes or 0) == 0
def test_running_card_is_unaffected(client, db):
ntype = _make_type(db, 'General')
_make_note(db, ntype)
current = _current(client)
assert len(current) == 1
assert current[0]['resolved'] is False
def test_type_with_a_tail_keeps_the_card_marked_resolved(client, db):
ntype = _make_type(db, 'Incident', grace=30)
_make_note(db, ntype, ended_minutes_ago=10)
current = _current(client)
assert len(current) == 1
assert current[0]['resolved'] is True
def test_card_leaves_once_past_its_own_tail(client, db):
ntype = _make_type(db, 'Incident', grace=30)
_make_note(db, ntype, ended_minutes_ago=31)
assert _current(client) == []
def test_each_type_is_held_to_its_own_window(client, db):
"""The widest tail must not leak onto a type that asked for none.
The query widens to the largest configured window, so without the per-row
check the no-tail type would ride along on the incident type's 60 minutes.
"""
lingering = _make_type(db, 'Incident', grace=60)
immediate = _make_type(db, 'General', grace=0)
_make_note(db, lingering, ended_minutes_ago=20, text='incident')
_make_note(db, immediate, ended_minutes_ago=20, text='general')
texts = {c['notification'] for c in _current(client)}
assert texts == {'incident'}
def test_carousel_type_obeys_the_window_too(client, db):
"""Recognition is a carousel card, where nothing renders `resolved`."""
ntype = _make_type(db, 'Recognition', grace=0, displaystyle='carousel')
_make_note(db, ntype, ended_minutes_ago=5)
assert _current(client) == []
def test_api_rejects_a_negative_window(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'gracewindowminutes': -5},
headers=auth_headers)
assert resp.status_code == 400
assert 'gracewindowminutes' in resp.get_data(as_text=True)
def test_api_round_trips_the_window(client, db, auth_headers):
create = client.post('/api/notifications/types',
json={'typename': 'Incident', 'gracewindowminutes': 45},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
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['gracewindowminutes'] == 45
update = client.put(f'/api/notifications/types/{typeid}',
json={'gracewindowminutes': 0}, headers=auth_headers)
assert update.status_code == 200, update.get_json()
assert update.get_json()['data']['gracewindowminutes'] == 0