Every time on the board was wrong, from two faults stacked. The shopfloor feed serialised starttime/endtime with a bare isoformat(). Those columns are stored NAIVE but hold UTC, so an untagged string is read by the browser as LOCAL and every card shifted by the tz offset. The model's to_dict already learned this - its _utc_iso helper documents the exact symptom, a 14:34 notification showing 18:34 - but the feed had not, so the feed now uses it too. The dashboard then formatted with toLocaleString, i.e. the VIEWER's zone. A board hangs on a wall in the plant: it has to read plant time whatever the machine driving it is set to, and a kiosk with a wrong system timezone would otherwise show wrong times to the floor with nothing to reveal it. It now loads site_timezone and formats through formatInZone, the wall clock included - a header disagreeing with the cards beneath it is worse than either being wrong alone. startsWhen was worse still: it decided TODAY/TOMORROW from browser-local calendar days, so the wording itself could differ between the board and a remote admin looking at the same card. That arithmetic now runs on the site's calendar day. Separately, the type chip carried a margin-bottom while the state chip beside it did not. .chip-row centres each item's MARGIN box, so that margin lifted the type chip about 4px and left "Starts Thu, Aug 13 8:00 PM" looking low. The row already provides the spacing, so the chip's own margin is gone.
874 lines
32 KiB
Python
874 lines
32 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(),
|
|
})
|