Files
shopdb-flask/plugins/notifications/api/routes.py
cproudlock 42c050a9f4
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
dashboard: tighten the printer, warranty and notification cards
PRINTER CARD is now supplies at or below 5 percent, a new
printers_dashboardpercent setting. The report and the card want different
scopes: the report lists anything the thresholds call low, which is right for
planning an order, while the dashboard is asking what to walk out and change
today - and a cartridge at 18 percent is not that. Lowest first.

PRINTER LOCATION WAS ALWAYS EMPTY. The lookup went through db.session.get on
locationid and produced nothing even where a location is set; the printers list
has always read it through the asset relationship, so the card does too now.

WARRANTY ROWS are identified the way the floor identifies them: the PC's
hostname and the MACHINE it drives, reusing the same lookup behind the warranty
page's machine column so the board and the report cannot disagree about which
bay a PC belongs to. No dates - expired or expiring is the whole decision when
scanning a board, and the exact day belongs on the report you order from.

NOTIFICATIONS are stacked: the type in full on one line, the message beneath,
trimmed to 100 characters with the rest on hover. Inline, the type was
truncated to make room for prose that was then truncated anyway, and neither
read. A tooltip is omitted when the text was not trimmed, because one repeating
what is already on screen is noise.

Two general additions: layout: 'stacked' on a card, and map.detailtooltip.
2026-08-11 15:57:14 -04:00

913 lines
34 KiB
Python

"""Notifications plugin API endpoints - adapted to existing schema."""
import hashlib
import os
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Notification, NotificationType
from ..models.notification import _utc_iso
from shopdb.api import require_permission, Setting
notifications_bp = Blueprint('notifications', __name__)
# Notification types whose multi-employee cards get split into one card per
# employee on the shopfloor dashboard. typecolor drives this (not typename), so
# recognition, training and recertification all fan out; every other type
# stays one card.
SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'})
# How long a card stays up on the shopfloor board when the creator does not set
# an explicit end time, by notification typecolor.
# recognition - clears at the next 8:00 AM Eastern (daily reset)
# recertification - stays up two weeks (employees have time to book the course)
_DEFAULT_TZ = 'America/New_York'
RECERTIFICATION_DAYS = 14
def _site_tz():
"""Site-configured IANA timezone (settings key site_timezone), used for the
daily-reset expiry math and any wall-clock computation. Falls back to
America/New_York if unset or invalid."""
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 _parse_utc(date_str):
"""Parse an incoming ISO datetime to NAIVE UTC (how starttime/endtime store).
The client submits new Date(local).toISOString(), i.e. UTC with a 'Z'. A
value without an offset is assumed already-UTC (not server-local) so the
stored instant is unambiguous."""
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def _next_site_time(after, hour, minute=0):
"""Next hour:minute in the site timezone strictly after `after` (naive UTC),
returned as naive UTC. Uses the tz database so it is correct across DST."""
tz = _site_tz()
after_local = after.replace(tzinfo=timezone.utc).astimezone(tz)
target = after_local.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
if target <= after_local:
target += timedelta(days=1)
return target.astimezone(timezone.utc).replace(tzinfo=None)
def _auto_endtime(ntype, starttime):
"""Default display window for a notification with no explicit end time, from
the notification type's configured expiry rule:
'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
'duration' -> starttime + expirydays days
'none' -> None (show indefinitely)
Falls back to the legacy typecolor rules when the expiry columns are unset,
so it is safe before/after the 7d03 migration."""
mode = getattr(ntype, 'expirymode', None) or 'none'
if mode == 'dailytime':
hour = ntype.expiryhour if ntype.expiryhour is not None else 8
return _next_site_time(starttime, hour, ntype.expiryminute or 0)
if mode == 'duration' and ntype.expirydays:
return starttime + timedelta(days=int(ntype.expirydays))
if mode == 'none':
# legacy fallback for rule-bearing types created before the expiry columns
if getattr(ntype, 'typecolor', None) == 'recognition':
return _next_site_time(starttime, 8, 0)
if getattr(ntype, 'typecolor', None) == 'recertification':
return starttime + timedelta(days=RECERTIFICATION_DAYS)
return None
_EXPIRY_MODES = ('none', 'duration', 'dailytime')
def _apply_expiry_fields(t, data):
"""Set expiry-rule columns on a NotificationType from request data. Only
touches fields that are present. Returns an error string, or None on ok."""
if 'expirymode' in data:
mode = data.get('expirymode') or 'none'
if mode not in _EXPIRY_MODES:
return "expirymode must be one of: %s" % ", ".join(_EXPIRY_MODES)
t.expirymode = mode
if 'expirydays' in data:
v = data.get('expirydays')
if v in (None, ''):
t.expirydays = None
else:
try:
t.expirydays = int(v)
except (TypeError, ValueError):
return "expirydays must be an integer"
if t.expirydays < 1:
return "expirydays must be >= 1"
if 'expiryhour' in data:
v = data.get('expiryhour')
if v in (None, ''):
t.expiryhour = None
else:
try:
t.expiryhour = int(v)
except (TypeError, ValueError):
return "expiryhour must be an integer"
if not (0 <= t.expiryhour <= 23):
return "expiryhour must be 0-23"
if 'expiryminute' in data:
v = data.get('expiryminute')
try:
t.expiryminute = int(v) if v not in (None, '') else 0
except (TypeError, ValueError):
return "expiryminute must be an integer"
if not (0 <= t.expiryminute <= 59):
return "expiryminute must be 0-59"
# cross-field consistency
mode = t.expirymode or 'none'
if mode == 'dailytime' and t.expiryhour is None:
t.expiryhour = 8
if mode == 'duration' and not t.expirydays:
return "duration expiry requires expirydays >= 1"
return None
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
# Ceiling on a type's board position. Wide enough to leave gaps between rows
# (10, 20, 30 ...) so inserting one later needs no renumbering.
_MAX_BOARD_ORDER = 999
# 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):
"""Set shopfloor display-behavior columns on a NotificationType from request
data. Only touches fields that are present. Returns an error string, or None."""
if 'splitperemployee' in data:
t.splitperemployee = bool(data.get('splitperemployee'))
if 'showemployeephoto' in data:
t.showemployeephoto = bool(data.get('showemployeephoto'))
if 'displaystyle' in data:
ds = data.get('displaystyle') or 'standard'
if ds not in _DISPLAY_STYLES:
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
t.displaystyle = ds
if 'boardcategory' in data:
category = (data.get('boardcategory') or '').strip()
if len(category) > 50:
return "boardcategory must be 50 characters or fewer"
# Blank stores as NULL: "no category" is the absence of one, not the
# empty-string category that every uncategorised type would share.
t.boardcategory = category or None
if 'boardorder' in data:
raw = data.get('boardorder')
raw = 100 if raw in (None, '') else raw
try:
order = int(raw)
except (TypeError, ValueError):
return "boardorder must be a whole number"
if order < 0 or order > _MAX_BOARD_ORDER:
return "boardorder must be between 0 and %d" % _MAX_BOARD_ORDER
t.boardorder = order
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
def _config_version():
"""Short hash of everything that changes the board's LAYOUT: per-type display
config plus an optional deploy stamp (SHOPFLOOR_BUILD env). The shopfloor
kiosks reload when this changes, so type/layout edits and frontend deploys
reach pages that are already open."""
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
parts = [
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d|%s|%d" % (
t.notificationtypeid, t.typecolor, t.displaystyle,
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
int(t.gracewindowminutes or 0), t.boardcategory or '',
int(t.boardorder if t.boardorder is not None else 100),
)
for t in types
]
parts.append(os.environ.get('SHOPFLOOR_BUILD', ''))
return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12]
def _employee_name(sso):
"""Live directory name for an SSO; None on any miss. Used as the fallback
when a notification has no stored employeename (see the shopfloor feed)."""
try:
from plugins.employees.api.routes import resolve_employee_display_name
return resolve_employee_display_name(sso)
except Exception:
return None
def _employee_picture(sso):
"""Resolved display photo URL for an SSO, via the shared employees-plugin
resolver so kiosk cards match EmployeeDetail in both directory modes
(self-hosted upload URL or external HR URL). None on any miss."""
try:
from plugins.employees.api.routes import resolve_employee_photo_url
return resolve_employee_photo_url(sso)
except Exception:
return None
# =============================================================================
# Notification Types
# =============================================================================
@notifications_bp.route('/types', methods=['GET'])
def list_notification_types():
"""List all notification types."""
page, per_page = get_pagination_params(request)
query = NotificationType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(NotificationType.isactive == True)
query = query.order_by(NotificationType.typename)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@notifications_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('notifications.create')
def create_notification_type():
"""Create a new notification type."""
data = request.get_json()
if not data or not data.get('typename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required')
if NotificationType.query.filter_by(typename=data['typename']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Notification type '{data['typename']}' already exists",
http_code=409
)
t = NotificationType(
typename=data['typename'],
typedescription=data.get('typedescription') or data.get('description'),
typecolor=data.get('typecolor') or data.get('color', '#17a2b8')
)
err = _apply_expiry_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
err = _apply_display_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Notification type created', http_code=201)
@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
@jwt_required()
@require_permission('notifications.create')
def update_notification_type(type_id: int):
"""Update a notification type, including its auto-expiry rule."""
t = db.session.get(NotificationType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404)
data = request.get_json() or {}
if data.get('typename'):
dup = NotificationType.query.filter(
NotificationType.typename == data['typename'],
NotificationType.notificationtypeid != type_id
).first()
if dup:
return error_response(ErrorCodes.CONFLICT,
f"Notification type '{data['typename']}' already exists", http_code=409)
t.typename = data['typename']
if 'typedescription' in data or 'description' in data:
t.typedescription = data.get('typedescription') or data.get('description')
if 'typecolor' in data or 'color' in data:
t.typecolor = data.get('typecolor') or data.get('color')
if 'isactive' in data:
t.isactive = bool(data['isactive'])
err = _apply_expiry_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
err = _apply_display_fields(t, data)
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
db.session.commit()
return success_response(t.to_dict(), message='Notification type updated')
# =============================================================================
# Notifications CRUD
# =============================================================================
@notifications_bp.route('', methods=['GET'])
def list_notifications():
"""
List all notifications with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status (default: true)
- type_id: Filter by notification type ID
- current: Filter to currently active notifications only
- search: Search in notification text
"""
page, per_page = get_pagination_params(request)
query = Notification.query
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Notification.isactive == True)
# Type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Notification.notificationtypeid == int(type_id))
# Exact-match lookup for idempotent import. Notifications have no strong
# natural key; ticketnumber is the best available when a ticket is set.
if exactticket := request.args.get('ticketnumber'):
query = query.filter(Notification.ticketnumber == exactticket)
# Current filter (active based on dates)
if request.args.get('current', 'false').lower() == 'true':
now = datetime.now(timezone.utc).replace(tzinfo=None)
query = query.filter(
Notification.starttime <= now,
db.or_(
Notification.endtime.is_(None),
Notification.endtime >= now
)
)
# Search filter
if search := request.args.get('search'):
query = query.filter(
Notification.notification.ilike(f'%{search}%')
)
# Sorting by start time (newest first)
query = query.order_by(Notification.starttime.desc())
items, total = paginate_query(query, page, per_page)
data = [n.to_dict() for n in items]
return paginated_response(data, page, per_page, total)
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
def get_notification(notification_id: int):
"""Get a single notification."""
n = db.session.get(Notification, notification_id)
if not n:
return error_response(
ErrorCodes.NOT_FOUND,
f'Notification with ID {notification_id} not found',
http_code=404
)
return success_response(n.to_dict())
@notifications_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('notifications.create')
def create_notification():
"""Create a new notification."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Validate required fields
notification_text = data.get('notification') or data.get('message')
if not notification_text:
return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required')
# Parse dates
starttime = datetime.now(timezone.utc).replace(tzinfo=None)
if data.get('starttime') or data.get('startdate'):
try:
date_str = data.get('starttime') or data.get('startdate')
starttime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
endtime = None
if data.get('endtime') or data.get('enddate'):
try:
date_str = data.get('endtime') or data.get('enddate')
endtime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
# No explicit end time: apply the per-type display window (recognition
# clears at the next 8 AM Eastern, recertification runs two weeks).
if endtime is None and data.get('notificationtypeid'):
ntype = db.session.get(NotificationType, data['notificationtypeid'])
if ntype:
endtime = _auto_endtime(ntype, starttime)
n = Notification(
notification=notification_text,
notificationtypeid=data.get('notificationtypeid'),
businessunitid=data.get('businessunitid'),
appid=data.get('appid'),
starttime=starttime,
endtime=endtime,
ticketnumber=data.get('ticketnumber'),
link=data.get('link') or data.get('linkurl'),
isactive=True,
isshopfloor=data.get('isshopfloor', False),
employeesso=data.get('employeesso'),
employeename=data.get('employeename')
)
db.session.add(n)
db.session.commit()
return success_response(n.to_dict(), message='Notification created', http_code=201)
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
@jwt_required()
@require_permission('notifications.edit')
def update_notification(notification_id: int):
"""Update a notification."""
n = db.session.get(Notification, notification_id)
if not n:
return error_response(
ErrorCodes.NOT_FOUND,
f'Notification with ID {notification_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Update text content
if 'notification' in data or 'message' in data:
n.notification = data.get('notification') or data.get('message')
# Update simple fields
if 'notificationtypeid' in data:
n.notificationtypeid = data['notificationtypeid']
if 'businessunitid' in data:
n.businessunitid = data['businessunitid']
if 'appid' in data:
n.appid = data['appid']
if 'ticketnumber' in data:
n.ticketnumber = data['ticketnumber']
if 'link' in data or 'linkurl' in data:
n.link = data.get('link') or data.get('linkurl')
if 'isactive' in data:
n.isactive = data['isactive']
if 'isshopfloor' in data:
n.isshopfloor = data['isshopfloor']
if 'employeesso' in data:
n.employeesso = data['employeesso']
if 'employeename' in data:
n.employeename = data['employeename']
# Parse and update dates
if 'starttime' in data or 'startdate' in data:
date_str = data.get('starttime') or data.get('startdate')
if date_str:
try:
n.starttime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
else:
n.starttime = datetime.now(timezone.utc).replace(tzinfo=None)
if 'endtime' in data or 'enddate' in data:
date_str = data.get('endtime') or data.get('enddate')
if date_str:
try:
n.endtime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
else:
n.endtime = None
db.session.commit()
return success_response(n.to_dict(), message='Notification updated')
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
@jwt_required()
@require_permission('notifications.delete')
def delete_notification(notification_id: int):
"""Delete (soft delete) a notification."""
n = db.session.get(Notification, notification_id)
if not n:
return error_response(
ErrorCodes.NOT_FOUND,
f'Notification with ID {notification_id} not found',
http_code=404
)
n.isactive = False
db.session.commit()
return success_response(message='Notification deleted')
# =============================================================================
# Special Endpoints
# =============================================================================
@notifications_bp.route('/active', methods=['GET'])
def get_active_notifications():
"""
Get currently active notifications for display.
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
from datetime import timedelta
lookahead = now + timedelta(days=10)
notifications = Notification.query.filter(
Notification.isactive == True,
db.or_(
Notification.starttime.is_(None),
Notification.starttime <= lookahead
),
db.or_(
Notification.endtime.is_(None),
Notification.endtime >= now
)
).order_by(Notification.starttime.asc()).all()
data = [n.to_dict() for n in notifications]
return success_response({
'notifications': data,
'total': len(data)
})
@notifications_bp.route('/calendar', methods=['GET'])
def get_calendar_events():
"""
Get notifications in FullCalendar event format.
Query parameters:
- start: Start date (ISO format)
- end: End date (ISO format)
"""
query = Notification.query.filter(Notification.isactive == True)
# Date range filter
if start := request.args.get('start'):
try:
start_date = datetime.fromisoformat(start.replace('Z', '+00:00'))
query = query.filter(
db.or_(
Notification.endtime >= start_date,
Notification.endtime.is_(None)
)
)
except ValueError:
pass
if end := request.args.get('end'):
try:
end_date = datetime.fromisoformat(end.replace('Z', '+00:00'))
query = query.filter(Notification.starttime <= end_date)
except ValueError:
pass
notifications = query.order_by(Notification.starttime).all()
events = [n.to_calendar_event() for n in notifications]
return success_response(events)
@notifications_bp.route('/dashboard/summary', methods=['GET'])
def dashboard_summary():
"""Get notifications dashboard summary."""
now = datetime.now(timezone.utc).replace(tzinfo=None)
# Total active notifications
total_active = Notification.query.filter(
Notification.isactive == True,
db.or_(
Notification.starttime.is_(None),
Notification.starttime <= now
),
db.or_(
Notification.endtime.is_(None),
Notification.endtime >= now
)
).count()
# By type
by_type = db.session.query(
NotificationType.typename,
NotificationType.typecolor,
db.func.count(Notification.notificationid)
).join(Notification
).filter(
Notification.isactive == True
).group_by(NotificationType.typename, NotificationType.typecolor
).all()
return success_response({
'active': total_active,
'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
})
@notifications_bp.route('/employee/<sso>', methods=['GET'])
def get_employee_recognitions(sso):
"""
Get recognitions for a specific employee by SSO.
Returns all recognition-type notifications where the employee is mentioned.
"""
if not sso or not sso.isdigit():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required')
# Find recognition type(s)
recognition_types = NotificationType.query.filter(
db.or_(
NotificationType.typecolor == 'recognition',
NotificationType.typename.ilike('%recognition%')
)
).all()
recognition_type_ids = [rt.notificationtypeid for rt in recognition_types]
# Find notifications where this employee is mentioned
# Check both exact match and comma-separated list
query = Notification.query.filter(
Notification.isactive == True,
db.or_(
Notification.employeesso == sso,
Notification.employeesso.like(f'{sso},%'),
Notification.employeesso.like(f'%,{sso}'),
Notification.employeesso.like(f'%,{sso},%')
)
)
# Optionally filter to recognition types only
if recognition_type_ids:
query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids))
query = query.order_by(Notification.starttime.desc())
notifications = query.all()
data = [n.to_dict() for n in notifications]
return success_response({
'recognitions': data,
'total': len(data)
})
@notifications_bp.route('/shopfloor', methods=['GET'])
def get_shopfloor_notifications():
"""
Get notifications for shopfloor TV dashboard.
Returns current and upcoming notifications with isshopfloor=1.
Splits multi-employee recognition and training into separate entries.
Query parameters:
- businessunit: Filter by business unit ID (null = all units)
"""
from datetime import timedelta
now = datetime.now(timezone.utc).replace(tzinfo=None)
business_unit = request.args.get('businessunit')
# Base query for shopfloor notifications
base_query = Notification.query.filter(Notification.isshopfloor == True)
# Business unit filter
if business_unit and business_unit.isdigit():
# Specific BU: show that BU's notifications AND null (all units)
base_query = base_query.filter(
db.or_(
Notification.businessunitid == int(business_unit),
Notification.businessunitid.is_(None)
)
)
else:
# All units: only show notifications with NULL businessunitid
base_query = base_query.filter(Notification.businessunitid.is_(None))
# Current notifications: showing now, plus anything whose type asks to keep
# 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(
db.or_(
# Active and currently showing
db.and_(
Notification.isactive == True,
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
db.or_(Notification.endtime.is_(None), Notification.endtime >= now)
),
# Ended, but possibly inside its type's tail - narrowed below
db.and_(
Notification.endtime.isnot(None),
Notification.endtime >= widest_grace_start,
Notification.endtime < now
)
)
).order_by(Notification.notificationid.desc())
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)
five_days = now + timedelta(days=5)
upcoming_query = base_query.filter(
Notification.isactive == True,
Notification.starttime > now,
Notification.starttime <= five_days
).order_by(Notification.starttime)
upcoming_notifications = upcoming_query.all()
def notification_to_shopfloor(n, employee_override=None):
"""Convert notification to shopfloor format."""
# 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
show_photo = bool(ntype and ntype.showemployeephoto)
result = {
'notificationid': n.notificationid,
'notification': n.notification,
# _utc_iso, not a bare isoformat: these are stored NAIVE but hold
# UTC, and an untagged string is read by the browser as LOCAL time,
# shifting every card on the board by the tz offset. The model's
# to_dict already learned this; the shopfloor feed had not.
'starttime': _utc_iso(n.starttime),
'endtime': _utc_iso(n.endtime),
'ticketnumber': n.ticketnumber,
'link': n.link,
'isactive': n.isactive,
'isshopfloor': True,
'resolved': is_resolved,
'typename': ntype.typename if ntype else None,
'typecolor': ntype.typecolor if ntype else None,
# Per-type display behavior the dashboard groups/renders by. A
# boardcategory puts several types in one row under that name;
# blank gives the type a row of its own.
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
'boardcategory': (ntype.boardcategory or '') if ntype else '',
'boardorder': int(ntype.boardorder if ntype and ntype.boardorder is not None else 100),
}
# Employee info (photo only when the type wants it)
if employee_override:
result['employeesso'] = employee_override.get('sso')
result['employeename'] = employee_override.get('name')
result['employeepicture'] = employee_override.get('picture')
else:
result['employeesso'] = n.employeesso
# Stored name first (import/manual entry), else resolve live from
# the directory. Also resolve when the stored "name" is just the bare
# SSO: WJ notifications were imported with SSOs and never converted to
# names, so a digits-only stored name must still be looked up.
name = n.employeename
if (n.employeesso and ',' not in n.employeesso
and (not name or name.strip().isdigit())):
name = _employee_name(n.employeesso) or name
result['employeename'] = name
result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None
return result
def expand(n):
"""One shopfloor card per notification, or one per employee when the
type is configured to split multi-employee lists."""
ntype = n.notificationtype
is_split = bool(ntype and ntype.splitperemployee)
if not (is_split and n.employeesso and ',' in n.employeesso):
return [notification_to_shopfloor(n)]
show_photo = bool(ntype and ntype.showemployeephoto)
ssos = [s.strip() for s in n.employeesso.split(',')]
names = n.employeename.split(', ') if n.employeename else []
def _name_for(i, sso):
stored = names[i].strip() if i < len(names) and names[i] else None
# A stored bare SSO (digits) is not a real name - look it up.
if stored and not stored.isdigit():
return stored
return _employee_name(sso) or stored or sso
return [
notification_to_shopfloor(n, {
'sso': sso,
'name': _name_for(i, sso),
'picture': _employee_picture(sso) if show_photo else None,
})
for i, sso in enumerate(ssos)
]
current_data = [card for n in current_notifications for card in expand(n)]
upcoming_data = [card for n in upcoming_notifications for card in expand(n)]
return success_response({
'timestamp': now.isoformat(),
'current': current_data,
'upcoming': upcoming_data,
'configversion': _config_version(),
})
@notifications_bp.route('/dashboard/active', methods=['GET'])
@jwt_required()
def dashboard_active():
"""Notifications currently showing on the floor.
The items, not a count. "4 active notifications" tells an admin nothing;
knowing WHICH message the shop is looking at right now is the point,
particularly when one is stale and nobody has taken it down.
A null start or end means open-ended, matching how the boards read them.
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
rows = (Notification.query
.filter(db.or_(Notification.starttime.is_(None),
Notification.starttime <= now),
db.or_(Notification.endtime.is_(None),
Notification.endtime >= now))
.order_by(Notification.starttime.desc())
.limit(25).all())
out = []
for row in rows:
notificationtype = getattr(row, 'notificationtype', None)
text = (row.notification or '').strip()
out.append({
'notificationid': row.notificationid,
# Trimmed for the card, whole text for the tooltip. A notification
# is prose written for the floor, so the first hundred characters
# are usually enough to recognise which one it is, and the rest is
# there when it is not.
'message': text[:100] + ('...' if len(text) > 100 else ''),
'fullmessage': text,
'typename': notificationtype.typename if notificationtype else 'Notification',
'endtime': row.endtime.isoformat() + 'Z' if row.endtime else None,
})
return success_response(out)