Turning "Shared key allowed" off refused the key on the endpoints plugin and left four core collector routes open to it. Anything still holding the old key could keep rewriting logged-in user, last boot time, serial number and installed app inventory for ANY hostname, while the admin believed the shared key was off. Data integrity only - no command execution, no credential issuance - but it is a hole in a boundary the product advertises as closed. It cannot simply be closed. Both GE-Enforce reporters still authenticate with that key, so refusing it fleet-wide would stop asset and printer reporting everywhere, and on an air-gapped site mid-migration that is the wrong order of operations. collector.legacysharedkeyrefused therefore DEFAULTS TO TODAY'S BEHAVIOUR, and its description names exactly what turning it on breaks and when it should be turned on: after the reporters move to per-PC credentials, not before. A legacy route used with the shared key now logs a warning, so a site can find out who still depends on the key before flipping anything. On a stock Windows deployment that warning may name the proxy rather than the caller; the token prefix in the same line is the identifier to go by. The switch is seeded with the core defaults and has a control on the authentication settings page. Undeclared, it was a row that did not exist, and this UI has no generic key/value editor, so seeding alone would have rendered nothing for an operator to click. Two contract declarations corrected while in here: notifications.alertscope was declared db.JSON in the model while the migration that creates it ships TEXT, and those migrations state the constraint in their own docstrings: no JSON columns. The model now matches the shipped column rather than the schema being bent to the model. usb and printedparts both import a name that arrived in contract 0.35.0 while declaring core_version floors of 0.1.0 and 0.16.0, so the loader would have accepted them on a core that cannot satisfy them - an ImportError at blueprint registration instead of a refusal with a reason. Both floors now say what the plugin actually needs.
314 lines
13 KiB
Python
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,
|
|
}
|
|
}
|