The shopfloor feed kept every ended notification up for a hardcoded 30 minutes, flagged resolved. A card with an 8:00 end time was still on the board at 8:29, which reads as an expiry that did not work - and in the carousel, grid and banner sections it read that way with no visual sign at all, since only the standard cards render the resolved state. The tail is now notificationtypes.gracewindowminutes, set per type on the Notification Types page and defaulting to 0, so an end time means what it says. A type whose cards are worth acknowledging after they clear - an incident, say - opts into a tail, and only that type's cards get one. The feed widens its query to the largest configured tail and then holds each row to its own type's window. That keeps one portable query rather than a per-type interval expression in SQL, and with every type at 0 it collapses to "still showing". Also fixes resolved serializing as null rather than false for a card with no end time, which the and-chain produced.
843 lines
31 KiB
Python
843 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 '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" % (
|
|
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),
|
|
)
|
|
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.
|
|
'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(),
|
|
})
|