Per-type expiry tail on the shopfloor board, and a 2x2 recertification block
This commit is contained in:
@@ -140,6 +140,10 @@ def _apply_expiry_fields(t, data):
|
||||
|
||||
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def _apply_display_fields(t, data):
|
||||
"""Set shopfloor display-behavior columns on a NotificationType from request
|
||||
@@ -153,6 +157,17 @@ 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 'gracewindowminutes' in data:
|
||||
raw = data.get('gracewindowminutes')
|
||||
raw = 0 if raw in (None, '') else raw
|
||||
try:
|
||||
minutes = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return "gracewindowminutes must be a whole number of minutes"
|
||||
if minutes < 0 or minutes > _MAX_GRACE_MINUTES:
|
||||
return ("gracewindowminutes must be between 0 and %d"
|
||||
% _MAX_GRACE_MINUTES)
|
||||
t.gracewindowminutes = minutes
|
||||
return None
|
||||
|
||||
|
||||
@@ -163,10 +178,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" % (
|
||||
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%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),
|
||||
)
|
||||
for t in types
|
||||
]
|
||||
@@ -698,8 +714,15 @@ def get_shopfloor_notifications():
|
||||
# All units: only show notifications with NULL businessunitid
|
||||
base_query = base_query.filter(Notification.businessunitid.is_(None))
|
||||
|
||||
# Current notifications (active now or ended within 30 minutes)
|
||||
thirty_min_ago = now - timedelta(minutes=30)
|
||||
# Current notifications: showing now, plus anything whose type asks to keep
|
||||
# ended cards up for a while (gracewindowminutes, 0 by default - an end time
|
||||
# means the card leaves the board then). The window is per type, so the SQL
|
||||
# only widens to the largest configured tail and each row is then held to
|
||||
# its own; that keeps this one portable query instead of a per-type interval
|
||||
# expression, and with every type at 0 it collapses to "still showing".
|
||||
widest_grace = db.session.query(
|
||||
db.func.max(NotificationType.gracewindowminutes)).scalar() or 0
|
||||
widest_grace_start = now - timedelta(minutes=int(widest_grace))
|
||||
current_query = base_query.filter(
|
||||
db.or_(
|
||||
# Active and currently showing
|
||||
@@ -708,16 +731,23 @@ def get_shopfloor_notifications():
|
||||
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
|
||||
db.or_(Notification.endtime.is_(None), Notification.endtime >= now)
|
||||
),
|
||||
# Recently ended (within 30 min) - show as resolved
|
||||
# Ended, but possibly inside its type's tail - narrowed below
|
||||
db.and_(
|
||||
Notification.endtime.isnot(None),
|
||||
Notification.endtime >= thirty_min_ago,
|
||||
Notification.endtime >= widest_grace_start,
|
||||
Notification.endtime < now
|
||||
)
|
||||
)
|
||||
).order_by(Notification.notificationid.desc())
|
||||
|
||||
current_notifications = current_query.all()
|
||||
def _within_grace(n):
|
||||
"""True unless the row ended outside its own type's tail."""
|
||||
if n.endtime is None or n.endtime >= now:
|
||||
return True
|
||||
grace = (n.notificationtype.gracewindowminutes or 0) if n.notificationtype else 0
|
||||
return n.endtime >= now - timedelta(minutes=int(grace))
|
||||
|
||||
current_notifications = [n for n in current_query.all() if _within_grace(n)]
|
||||
|
||||
# Upcoming notifications (starts within next 5 days)
|
||||
five_days = now + timedelta(days=5)
|
||||
@@ -731,7 +761,9 @@ def get_shopfloor_notifications():
|
||||
|
||||
def notification_to_shopfloor(n, employee_override=None):
|
||||
"""Convert notification to shopfloor format."""
|
||||
is_resolved = n.endtime and n.endtime < now
|
||||
# bool, not the datetime-or-None the and-chain yields: a card with no
|
||||
# end time was serializing resolved as null.
|
||||
is_resolved = bool(n.endtime and n.endtime < now)
|
||||
ntype = n.notificationtype
|
||||
show_photo = bool(ntype and ntype.showemployeephoto)
|
||||
|
||||
|
||||
@@ -87,6 +87,18 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Keep showing after end (minutes)</span>
|
||||
<input v-model.number="form.gracewindowminutes" type="number"
|
||||
min="0" max="1440" step="5" />
|
||||
<small class="muted">
|
||||
0 means a card leaves the shopfloor board the moment its end time
|
||||
passes. A higher number keeps it up that long afterwards, marked
|
||||
RESOLVED - useful for an incident worth acknowledging once it
|
||||
clears.
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.splitperemployee" type="checkbox" />
|
||||
<span>Split one card per employee</span>
|
||||
@@ -170,9 +182,12 @@ function swatchColor(c) {
|
||||
}
|
||||
|
||||
function expiryLabel(t) {
|
||||
if (t.expirymode === 'duration' && t.expirydays) return `${t.expirydays} day(s)`
|
||||
if (t.expirymode === 'dailytime') return `daily @ ${String(t.expiryhour ?? 8).padStart(2, '0')}:00 ET`
|
||||
return 'none'
|
||||
const grace = t.gracewindowminutes ?? 0
|
||||
// The tail only means something next to the rule that ends the card.
|
||||
const tail = grace > 0 ? ` (+${grace}m resolved)` : ''
|
||||
if (t.expirymode === 'duration' && t.expirydays) return `${t.expirydays} day(s)${tail}`
|
||||
if (t.expirymode === 'dailytime') return `daily @ ${String(t.expiryhour ?? 8).padStart(2, '0')}:00 ET${tail}`
|
||||
return grace > 0 ? `none${tail}` : 'none'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -199,6 +214,7 @@ function openNew() {
|
||||
expirymode: 'none',
|
||||
expirydays: null,
|
||||
expiryhour: null,
|
||||
gracewindowminutes: 0,
|
||||
isactive: true
|
||||
}
|
||||
editing.value = true
|
||||
@@ -217,6 +233,7 @@ function openEdit(t) {
|
||||
expirymode: t.expirymode || 'none',
|
||||
expirydays: t.expirydays ?? null,
|
||||
expiryhour: t.expiryhour ?? null,
|
||||
gracewindowminutes: t.gracewindowminutes ?? 0,
|
||||
isactive: t.isactive !== false
|
||||
}
|
||||
editing.value = true
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Add notificationtypes.gracewindowminutes (post-expiry tail on the board).
|
||||
|
||||
The shopfloor feed kept every ended notification on the board for a hardcoded
|
||||
30 minutes, flagged resolved. A card with an 8:00 end time therefore sat there
|
||||
until 8:30, and in the carousel/grid/banner sections it did so with no visual
|
||||
sign it had ended at all. The tail is now per type and defaults to 0, so an end
|
||||
time means what it says; a type that wants an acknowledged tail opts in.
|
||||
|
||||
Idempotent; downgrade drops the column.
|
||||
|
||||
Revision ID: notifications0003grace
|
||||
Revises: notifications0002buidx
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'notifications0003grace'
|
||||
down_revision = 'notifications0002buidx'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = 'notificationtypes'
|
||||
_COLUMN = 'gracewindowminutes'
|
||||
|
||||
|
||||
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.Integer(), nullable=False,
|
||||
server_default='0'))
|
||||
|
||||
|
||||
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)
|
||||
@@ -75,6 +75,14 @@ class NotificationType(db.Model):
|
||||
showemployeephoto = db.Column(db.Boolean, default=False)
|
||||
displaystyle = db.Column(db.String(20), default='standard')
|
||||
|
||||
# Minutes a card stays on the shopfloor board AFTER its end time, shown as
|
||||
# RESOLVED. 0 (the default) means it leaves the board the moment it ends,
|
||||
# which is what a reader expects from an end time. A type whose cards are
|
||||
# worth acknowledging after the fact - an incident that just cleared - can
|
||||
# opt into a tail.
|
||||
gracewindowminutes = db.Column(db.Integer, nullable=False,
|
||||
server_default='0', default=0)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotificationType {self.typename}>"
|
||||
|
||||
@@ -91,7 +99,8 @@ class NotificationType(db.Model):
|
||||
'expiryminute': self.expiryminute if self.expiryminute is not None else 0,
|
||||
'splitperemployee': bool(self.splitperemployee),
|
||||
'showemployeephoto': bool(self.showemployeephoto),
|
||||
'displaystyle': self.displaystyle or 'standard'
|
||||
'displaystyle': self.displaystyle or 'standard',
|
||||
'gracewindowminutes': int(self.gracewindowminutes or 0)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user