Per-type expiry tail on the shopfloor board, and a 2x2 recertification block
This commit is contained in:
@@ -252,9 +252,10 @@ const recertDescriptions = computed(() => {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
// Recertification shows as a single rotating row: one page of tiles at a time
|
// Recertification shows a rotating 2x2 block: one page of tiles at a time so it
|
||||||
// so it stays compact on any screen, cycling through everyone due.
|
// stays compact on any screen, cycling through everyone due. Eight across left
|
||||||
const RECERT_PAGE_SIZE = 8
|
// each tile too narrow to read at floor distance.
|
||||||
|
const RECERT_PAGE_SIZE = 4
|
||||||
const currentRecertPage = ref(0)
|
const currentRecertPage = ref(0)
|
||||||
const recertPageCount = computed(() =>
|
const recertPageCount = computed(() =>
|
||||||
Math.max(1, Math.ceil(recertifications.value.length / RECERT_PAGE_SIZE))
|
Math.max(1, Math.ceil(recertifications.value.length / RECERT_PAGE_SIZE))
|
||||||
@@ -577,26 +578,29 @@ function handlePhotoError(e) {
|
|||||||
margin: -4px 0 14px;
|
margin: -4px 0 14px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
/* Single row that cycles through pages of employees */
|
/* 2x2 block that cycles through pages of employees */
|
||||||
.recert-row {
|
.recert-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.recert-tile {
|
.recert-tile {
|
||||||
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
|
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
|
||||||
border: 2px solid #0d6efd;
|
border: 2px solid #0d6efd;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 14px 10px;
|
padding: 14px 18px;
|
||||||
|
/* Photo beside the name rather than above it: two rows of stacked tiles
|
||||||
|
would double the section's height, and the whole board is scaled to fit
|
||||||
|
one screen, so height here shrinks everything else. */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 18px;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.recert-photo {
|
.recert-photo {
|
||||||
width: 90px;
|
width: 120px;
|
||||||
height: 90px;
|
height: 120px;
|
||||||
|
flex: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 3px solid #0d6efd;
|
border: 3px solid #0d6efd;
|
||||||
@@ -608,7 +612,9 @@ function handlePhotoError(e) {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
.recert-name {
|
.recert-name {
|
||||||
font-size: 20px;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 26px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
/* Always reserve two lines so a name that wraps does not make its tile
|
/* Always reserve two lines so a name that wraps does not make its tile
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ def _apply_expiry_fields(t, data):
|
|||||||
|
|
||||||
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
|
_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):
|
def _apply_display_fields(t, data):
|
||||||
"""Set shopfloor display-behavior columns on a NotificationType from request
|
"""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:
|
if ds not in _DISPLAY_STYLES:
|
||||||
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
||||||
t.displaystyle = ds
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -163,10 +178,11 @@ def _config_version():
|
|||||||
reach pages that are already open."""
|
reach pages that are already open."""
|
||||||
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
||||||
parts = [
|
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,
|
t.notificationtypeid, t.typecolor, t.displaystyle,
|
||||||
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
||||||
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
||||||
|
int(t.gracewindowminutes or 0),
|
||||||
)
|
)
|
||||||
for t in types
|
for t in types
|
||||||
]
|
]
|
||||||
@@ -698,8 +714,15 @@ def get_shopfloor_notifications():
|
|||||||
# All units: only show notifications with NULL businessunitid
|
# All units: only show notifications with NULL businessunitid
|
||||||
base_query = base_query.filter(Notification.businessunitid.is_(None))
|
base_query = base_query.filter(Notification.businessunitid.is_(None))
|
||||||
|
|
||||||
# Current notifications (active now or ended within 30 minutes)
|
# Current notifications: showing now, plus anything whose type asks to keep
|
||||||
thirty_min_ago = now - timedelta(minutes=30)
|
# 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(
|
current_query = base_query.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
# Active and currently showing
|
# Active and currently showing
|
||||||
@@ -708,16 +731,23 @@ def get_shopfloor_notifications():
|
|||||||
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
|
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
|
||||||
db.or_(Notification.endtime.is_(None), Notification.endtime >= 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_(
|
db.and_(
|
||||||
Notification.endtime.isnot(None),
|
Notification.endtime.isnot(None),
|
||||||
Notification.endtime >= thirty_min_ago,
|
Notification.endtime >= widest_grace_start,
|
||||||
Notification.endtime < now
|
Notification.endtime < now
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).order_by(Notification.notificationid.desc())
|
).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)
|
# Upcoming notifications (starts within next 5 days)
|
||||||
five_days = now + timedelta(days=5)
|
five_days = now + timedelta(days=5)
|
||||||
@@ -731,7 +761,9 @@ def get_shopfloor_notifications():
|
|||||||
|
|
||||||
def notification_to_shopfloor(n, employee_override=None):
|
def notification_to_shopfloor(n, employee_override=None):
|
||||||
"""Convert notification to shopfloor format."""
|
"""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
|
ntype = n.notificationtype
|
||||||
show_photo = bool(ntype and ntype.showemployeephoto)
|
show_photo = bool(ntype and ntype.showemployeephoto)
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="field checkbox">
|
||||||
<input v-model="form.splitperemployee" type="checkbox" />
|
<input v-model="form.splitperemployee" type="checkbox" />
|
||||||
<span>Split one card per employee</span>
|
<span>Split one card per employee</span>
|
||||||
@@ -170,9 +182,12 @@ function swatchColor(c) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expiryLabel(t) {
|
function expiryLabel(t) {
|
||||||
if (t.expirymode === 'duration' && t.expirydays) return `${t.expirydays} day(s)`
|
const grace = t.gracewindowminutes ?? 0
|
||||||
if (t.expirymode === 'dailytime') return `daily @ ${String(t.expiryhour ?? 8).padStart(2, '0')}:00 ET`
|
// The tail only means something next to the rule that ends the card.
|
||||||
return 'none'
|
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() {
|
async function load() {
|
||||||
@@ -199,6 +214,7 @@ function openNew() {
|
|||||||
expirymode: 'none',
|
expirymode: 'none',
|
||||||
expirydays: null,
|
expirydays: null,
|
||||||
expiryhour: null,
|
expiryhour: null,
|
||||||
|
gracewindowminutes: 0,
|
||||||
isactive: true
|
isactive: true
|
||||||
}
|
}
|
||||||
editing.value = true
|
editing.value = true
|
||||||
@@ -217,6 +233,7 @@ function openEdit(t) {
|
|||||||
expirymode: t.expirymode || 'none',
|
expirymode: t.expirymode || 'none',
|
||||||
expirydays: t.expirydays ?? null,
|
expirydays: t.expirydays ?? null,
|
||||||
expiryhour: t.expiryhour ?? null,
|
expiryhour: t.expiryhour ?? null,
|
||||||
|
gracewindowminutes: t.gracewindowminutes ?? 0,
|
||||||
isactive: t.isactive !== false
|
isactive: t.isactive !== false
|
||||||
}
|
}
|
||||||
editing.value = true
|
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)
|
showemployeephoto = db.Column(db.Boolean, default=False)
|
||||||
displaystyle = db.Column(db.String(20), default='standard')
|
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):
|
def __repr__(self):
|
||||||
return f"<NotificationType {self.typename}>"
|
return f"<NotificationType {self.typename}>"
|
||||||
|
|
||||||
@@ -91,7 +99,8 @@ class NotificationType(db.Model):
|
|||||||
'expiryminute': self.expiryminute if self.expiryminute is not None else 0,
|
'expiryminute': self.expiryminute if self.expiryminute is not None else 0,
|
||||||
'splitperemployee': bool(self.splitperemployee),
|
'splitperemployee': bool(self.splitperemployee),
|
||||||
'showemployeephoto': bool(self.showemployeephoto),
|
'showemployeephoto': bool(self.showemployeephoto),
|
||||||
'displaystyle': self.displaystyle or 'standard'
|
'displaystyle': self.displaystyle or 'standard',
|
||||||
|
'gracewindowminutes': int(self.gracewindowminutes or 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
|
|||||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||||
# the per-transaction revision column.
|
# the per-transaction revision column.
|
||||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||||
# notifications indexes businessunitid on top of its anchor.
|
# notifications indexes businessunitid, then adds the per-type grace window.
|
||||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0003grace'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
141
tests/test_plugins/test_shopfloor_grace_window.py
Normal file
141
tests/test_plugins/test_shopfloor_grace_window.py
Normal 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
|
||||||
Reference in New Issue
Block a user