Files
shopdb-flask/plugins/notifications/api/routes.py
cproudlock 76c184fe91 Give every notification type its own row on the board
The shopfloor board grouped cards by display style alone, so every type set to
grid landed inside the Recertification row and every carousel type inside
Recognition's - under a heading naming somebody else's type. Setting Awareness
to grid put awareness messages under "Recertification Required".

Each type now gets a row of its own, titled by its own name, and rotation state
is per row: two carousel rows advance on their own indexes instead of sharing
one counter, and two grid rows page independently.

For the other direction there is notificationtypes.boardcategory. Types sharing
a category share one row under the category name, so Change, Awareness and
Incident can sit together while Recognition and Recertification keep their own.
Blank - the default - means a row of its own. The category is part of the
grouping key along with the display style, since a category cannot merge a
banner with a row of tiles.

A card that names no employee now renders its message as the tile or card,
rather than a placeholder face above a blank name, which is what a grid type
like Awareness looked like before.

The layout fingerprint that makes open kiosks reload now covers the category
and the grace window, so a re-grouped board reaches screens that are already up.
2026-08-07 10:30:28 -04:00

853 lines
31 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 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 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 '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" % (
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 '',
)
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,
'starttime': n.starttime.isoformat() if n.starttime else None,
'endtime': n.endtime.isoformat() if n.endtime else None,
'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 '',
}
# 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(),
})