Files
shopdb-flask/plugins/notifications/api/routes.py
cproudlock 9a2d0ccebb dashboard: resolve employee names from directory/user, GE monogram photo fallback, kiosk sweep + label
- notifications shopfloor feed: resolve the employee name live when the stored
  value is a bare SSO (WJ notifications imported as SSOs, never converted), for
  both single and split-per-employee cards
- employee name resolver: after a directory miss, fall back to the shopdb User
  account (firstname/lastname, keyed by SSO username) so users from other
  locations still show a name
- shopfloor dashboard: employee photo falls back to the GE monogram (own asset,
  independent of the site_logo setting) with a loop-guarded onerror; recognition
  + recert tiles both covered
- shopfloor dashboard: 'All Business Units' filter label -> 'All Locations'
- geenforce display dispatcher: startup sweep also matches the imaging
  installers' 'GE Aerospace Dashboard/Lobby' shortcuts by name
2026-07-28 18:21:47 -04:00

786 lines
28 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
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)
EASTERN = ZoneInfo('America/New_York')
RECERTIFICATION_DAYS = 14
def _next_eastern_time(after, hour, minute=0):
"""Next hour:minute America/New_York strictly after `after` (naive UTC),
returned as naive UTC. Uses the tz database so it is correct across EST/EDT."""
after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN)
target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
if target <= after_east:
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_eastern_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_eastern_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')
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
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" % (
t.notificationtypeid, t.typecolor, t.displaystyle,
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
)
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 = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
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 = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
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 = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
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 = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
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 (active now or ended within 30 minutes)
thirty_min_ago = now - timedelta(minutes=30)
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)
),
# Recently ended (within 30 min) - show as resolved
db.and_(
Notification.endtime.isnot(None),
Notification.endtime >= thirty_min_ago,
Notification.endtime < now
)
)
).order_by(Notification.notificationid.desc())
current_notifications = current_query.all()
# 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."""
is_resolved = 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.
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
}
# 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(),
})