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.
244 lines
9.8 KiB
Python
244 lines
9.8 KiB
Python
"""Notifications plugin models - adapted to existing database schema."""
|
|
|
|
from datetime import datetime, timezone
|
|
from zoneinfo import ZoneInfo
|
|
from shopdb.api import db
|
|
|
|
|
|
_DEFAULT_TZ = 'America/New_York'
|
|
|
|
|
|
def _site_zone():
|
|
"""Site-configured IANA zone (settings key site_timezone) for calendar day
|
|
placement. Imported lazily to avoid a circular import at model load."""
|
|
from shopdb.api import Setting
|
|
row = Setting.query.filter_by(key='site_timezone').first()
|
|
name = row.value if row and row.value else _DEFAULT_TZ
|
|
try:
|
|
return ZoneInfo(name)
|
|
except Exception:
|
|
return ZoneInfo(_DEFAULT_TZ)
|
|
|
|
|
|
def _site_date(dt):
|
|
"""The calendar day (YYYY-MM-DD) a stored UTC datetime falls on in the site
|
|
zone. allDay events must key off the site-local day, not the UTC day, or a
|
|
late-evening notification lands on the wrong date for western sites."""
|
|
if not dt:
|
|
return None
|
|
return dt.replace(tzinfo=timezone.utc).astimezone(_site_zone()).date().isoformat()
|
|
|
|
|
|
def _utc_iso(dt):
|
|
"""Serialize a stored datetime as an explicit-UTC ISO string.
|
|
|
|
starttime/endtime are stored NAIVE but always hold UTC wall-clock (the
|
|
create/update parse normalizes to UTC). Emitting a bare naive isoformat let
|
|
the browser read it as LOCAL time, shifting displays by the tz offset (a
|
|
14:34 EDT notification showed 18:34). Tag it UTC so new Date() parses the
|
|
real instant and renders in the viewer's zone.
|
|
"""
|
|
if not dt:
|
|
return None
|
|
return dt.replace(tzinfo=timezone.utc).isoformat()
|
|
|
|
|
|
class NotificationType(db.Model):
|
|
"""
|
|
Notification type classification.
|
|
Matches existing notificationtypes table.
|
|
"""
|
|
__tablename__ = 'notificationtypes'
|
|
|
|
notificationtypeid = db.Column(db.Integer, primary_key=True)
|
|
typename = db.Column(db.String(50), nullable=False)
|
|
typedescription = db.Column(db.Text)
|
|
typecolor = db.Column(db.String(20), default='#17a2b8')
|
|
isactive = db.Column(db.Boolean, default=True)
|
|
|
|
# Auto-expiry rule: when a notification of this type has no explicit end time,
|
|
# how long it stays up on the shopfloor board.
|
|
# 'none' -> indefinite (never auto-expires)
|
|
# 'duration' -> starttime + expirydays days
|
|
# 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
|
|
expirymode = db.Column(db.String(20), default='none')
|
|
expirydays = db.Column(db.Integer, nullable=True)
|
|
expiryhour = db.Column(db.SmallInteger, nullable=True)
|
|
expiryminute = db.Column(db.SmallInteger, nullable=True, default=0)
|
|
|
|
# Shopfloor display behavior (data-driven; replaces hardcoded per-type logic).
|
|
# splitperemployee -> one card per listed employee SSO
|
|
# showemployeephoto -> resolve + show each employee's photo + name
|
|
# displaystyle -> 'standard' (rows) | 'carousel' (rotating photo card)
|
|
# | 'grid' (cycling row of tiles) | 'banner'
|
|
splitperemployee = db.Column(db.Boolean, default=False)
|
|
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}>"
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'notificationtypeid': self.notificationtypeid,
|
|
'typename': self.typename,
|
|
'typedescription': self.typedescription,
|
|
'typecolor': self.typecolor,
|
|
'isactive': self.isactive,
|
|
'expirymode': self.expirymode or 'none',
|
|
'expirydays': self.expirydays,
|
|
'expiryhour': self.expiryhour,
|
|
'expiryminute': self.expiryminute if self.expiryminute is not None else 0,
|
|
'splitperemployee': bool(self.splitperemployee),
|
|
'showemployeephoto': bool(self.showemployeephoto),
|
|
'displaystyle': self.displaystyle or 'standard',
|
|
'gracewindowminutes': int(self.gracewindowminutes or 0)
|
|
}
|
|
|
|
|
|
class Notification(db.Model):
|
|
"""
|
|
Notification/announcement model.
|
|
Matches existing notifications table schema.
|
|
"""
|
|
__tablename__ = 'notifications'
|
|
|
|
notificationid = db.Column(db.Integer, primary_key=True)
|
|
notificationtypeid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('notificationtypes.notificationtypeid'),
|
|
nullable=True
|
|
)
|
|
businessunitid = db.Column(db.Integer, nullable=True, index=True)
|
|
appid = db.Column(db.Integer, nullable=True)
|
|
notification = db.Column(db.Text, nullable=False, comment='The message content')
|
|
starttime = db.Column(db.DateTime, nullable=True)
|
|
endtime = db.Column(db.DateTime, nullable=True)
|
|
ticketnumber = db.Column(db.String(50), nullable=True)
|
|
link = db.Column(db.String(500), nullable=True)
|
|
isactive = db.Column(db.Boolean, default=True)
|
|
isshopfloor = db.Column(db.Boolean, default=False)
|
|
# TEXT (not VARCHAR): recognition/recertification notifications comma-join
|
|
# every employee's SSO/name into one field, which overflows 100 chars once
|
|
# ~11 people are listed.
|
|
employeesso = db.Column(db.Text, nullable=True)
|
|
employeename = db.Column(db.Text, nullable=True)
|
|
|
|
# Relationships
|
|
notificationtype = db.relationship('NotificationType', backref='notifications')
|
|
|
|
def __repr__(self):
|
|
return f"<Notification {self.notificationid}>"
|
|
|
|
@property
|
|
def is_current(self):
|
|
"""Check if notification is currently active based on dates."""
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
if not self.isactive:
|
|
return False
|
|
if self.starttime and now < self.starttime:
|
|
return False
|
|
if self.endtime and now > self.endtime:
|
|
return False
|
|
return True
|
|
|
|
@property
|
|
def title(self):
|
|
"""Get title - first line or first 100 chars of notification."""
|
|
if not self.notification:
|
|
return ''
|
|
lines = self.notification.split('\n')
|
|
return lines[0][:100] if lines else self.notification[:100]
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary with related data."""
|
|
result = {
|
|
'notificationid': self.notificationid,
|
|
'notificationtypeid': self.notificationtypeid,
|
|
'businessunitid': self.businessunitid,
|
|
'appid': self.appid,
|
|
'notification': self.notification,
|
|
'title': self.title,
|
|
'message': self.notification,
|
|
'starttime': _utc_iso(self.starttime),
|
|
'endtime': _utc_iso(self.endtime),
|
|
'startdate': _utc_iso(self.starttime),
|
|
'enddate': _utc_iso(self.endtime),
|
|
'ticketnumber': self.ticketnumber,
|
|
'link': self.link,
|
|
'linkurl': self.link,
|
|
'isactive': bool(self.isactive) if self.isactive is not None else True,
|
|
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
|
|
'employeesso': self.employeesso,
|
|
'employeename': self.employeename,
|
|
'iscurrent': self.is_current
|
|
}
|
|
|
|
# Add type info
|
|
if self.notificationtype:
|
|
result['typename'] = self.notificationtype.typename
|
|
result['typecolor'] = self.notificationtype.typecolor
|
|
|
|
return result
|
|
|
|
def to_calendar_event(self):
|
|
"""Convert to FullCalendar event format."""
|
|
# Color is data-driven: types store a hex typecolor. Only the legacy
|
|
# Bootstrap color-name aliases still need translating; hex passes through.
|
|
color_aliases = {
|
|
'success': '#04b962',
|
|
'warning': '#ff8800',
|
|
'danger': '#f5365c',
|
|
'info': '#14abef',
|
|
'primary': '#7934f3',
|
|
'secondary': '#94614f',
|
|
}
|
|
|
|
ntype = self.notificationtype
|
|
raw_color = ntype.typecolor if ntype else '#14abef'
|
|
color = color_aliases.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
|
|
show_photo = bool(ntype and getattr(ntype, 'showemployeephoto', False))
|
|
|
|
# Employee-photo types prefix the card with the person's name/SSO. A
|
|
# multi-person recognition/training lists many people, so summarize as
|
|
# "First Person +N" to keep the calendar title short (the detail popup
|
|
# still shows the full employeename).
|
|
title = self.title
|
|
if show_photo:
|
|
employee_display = self.employeename or self.employeesso
|
|
if employee_display:
|
|
people = [p.strip() for p in employee_display.split(',') if p.strip()]
|
|
if len(people) > 1:
|
|
employee_display = f"{people[0]} +{len(people) - 1}"
|
|
title = f"{employee_display}: {title}"
|
|
|
|
return {
|
|
'id': self.notificationid,
|
|
'title': title,
|
|
'start': _site_date(self.starttime),
|
|
'end': _site_date(self.endtime),
|
|
'allDay': True,
|
|
'backgroundColor': color,
|
|
'borderColor': color,
|
|
'extendedProps': {
|
|
'notificationid': self.notificationid,
|
|
'message': self.notification,
|
|
'typename': ntype.typename if ntype else None,
|
|
'typecolor': raw_color,
|
|
'showemployeephoto': show_photo,
|
|
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
|
'linkurl': self.link,
|
|
'ticketnumber': self.ticketnumber,
|
|
'employeename': self.employeename,
|
|
'employeesso': self.employeesso,
|
|
}
|
|
}
|