Files
shopdb-flask/plugins/notifications/models/notification.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

314 lines
13 KiB
Python

"""Notifications plugin models - adapted to existing database schema."""
import json
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import Text
from sqlalchemy.types import TypeDecorator
from shopdb.api import db
class JSONText(TypeDecorator):
"""A dict on the Python side, TEXT in the database.
MySQL 5.6 has no JSON type and a site still runs it, so every JSON-bearing
column in this product is TEXT. Declared here rather than imported from the
computers plugin: notifications declares no dependency on computers, and a
cross-plugin import would be a hidden one.
"""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return json.dumps(value, separators=(',', ':'))
def process_result_value(self, value, dialect):
if value is None or value == '':
return None
try:
return json.loads(value)
except (TypeError, ValueError):
# Hand-edited or written before this column held JSON. Return the
# raw text rather than raising: a scope that cannot be parsed
# should be visible and fixable, and the endpoints matcher already
# treats a non-dict scope as matching nothing.
return value
_DEFAULT_TZ = 'America/New_York'
# Keys a fleet alert's alertscope may use. Must stay equal to the set the
# endpoints matcher (_ALERT_SCOPE_KEYS in plugins/endpoints/api/routes.py)
# understands; a test pins the two together.
ALERT_SCOPE_KEYS = frozenset({'hostname', 'assettype', 'computertype',
'location', 'businessunit'})
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)
# Optional shared heading on the shopfloor board. Blank (the default) gives
# the type a row of its own under its own name; types sharing a category
# share one row under that category name, provided they also share a
# 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}>"
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),
'boardcategory': self.boardcategory or '',
'boardorder': int(self.boardorder if self.boardorder is not None else 100)
}
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)
# Matches migration 0006: NOT NULL with server defaults.
alertfleet = db.Column(db.Boolean, nullable=False, default=False,
server_default=db.false())
# JSONText, not db.JSON: migration 0006 ships this column as TEXT because a
# site still runs MySQL 5.6, which has no JSON type. db.JSON here meant
# create-all built a JSON column the migration path never produces, so the
# model and the shipped schema disagreed about the same column.
alertscope = db.Column(JSONText, nullable=True)
alerttimeout = db.Column(db.Integer, nullable=False, default=0,
server_default='0')
# 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,
'alertfleet': bool(self.alertfleet) if self.alertfleet is not None else False,
'alertscope': self.alertscope,
'alerttimeout': self.alerttimeout
}
# 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,
}
}