"""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_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/', 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('/', 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('/', 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('/', 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/', 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 result['employeename'] = n.employeename 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 [] return [ notification_to_shopfloor(n, { 'sso': sso, 'name': names[i] if i < len(names) else 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(), })