"""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"" 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"" @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, } }