"""Notifications plugin API endpoints - adapted to existing schema.""" from datetime import datetime from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.extensions import db from shopdb.utils.responses import ( success_response, error_response, paginated_response, ErrorCodes ) from shopdb.utils.pagination import get_pagination_params, paginate_query from ..models import Notification, NotificationType notifications_bp = Blueprint('notifications', __name__) # ============================================================================= # 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() 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') ) db.session.add(t) db.session.commit() return success_response(t.to_dict(), message='Notification type created', http_code=201) # ============================================================================= # 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('type_id'): query = query.filter(Notification.notificationtypeid == int(type_id)) # Current filter (active based on dates) if request.args.get('current', 'false').lower() == 'true': now = datetime.utcnow() 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 = Notification.query.get(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() 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.utcnow() 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') 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() def update_notification(notification_id: int): """Update a notification.""" n = Notification.query.get(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.utcnow() 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() def delete_notification(notification_id: int): """Delete (soft delete) a notification.""" n = Notification.query.get(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.utcnow() notifications = Notification.query.filter( Notification.isactive == True, db.or_( Notification.starttime.is_(None), Notification.starttime <= now ), db.or_( Notification.endtime.is_(None), Notification.endtime >= now ) ).order_by(Notification.starttime.desc()).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.utcnow() # 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, 'by_type': [{'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 into separate entries. Query parameters: - businessunit: Filter by business unit ID (null = all units) """ from datetime import timedelta now = datetime.utcnow() 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 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': n.notificationtype.typename if n.notificationtype else None, 'typecolor': n.notificationtype.typecolor if n.notificationtype else None, } # Employee info 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'] = None # Try to get picture from wjf_employees if n.employeesso and n.employeesso.isdigit(): try: import pymysql conn = pymysql.connect( host='localhost', user='root', password='rootpassword', database='wjf_employees', cursorclass=pymysql.cursors.DictCursor ) with conn.cursor() as cur: cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),)) emp = cur.fetchone() if emp and emp.get('Picture'): result['employeepicture'] = emp['Picture'] conn.close() except Exception: pass return result # Process current notifications (split multi-employee recognition) current_data = [] for n in current_notifications: is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition' if is_recognition and n.employeesso and ',' in n.employeesso: # Split into individual cards for each employee ssos = [s.strip() for s in n.employeesso.split(',')] names = n.employeename.split(', ') if n.employeename else [] for i, sso in enumerate(ssos): name = names[i] if i < len(names) else sso # Look up picture picture = None if sso.isdigit(): try: import pymysql conn = pymysql.connect( host='localhost', user='root', password='rootpassword', database='wjf_employees', cursorclass=pymysql.cursors.DictCursor ) with conn.cursor() as cur: cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) emp = cur.fetchone() if emp: picture = emp.get('Picture') conn.close() except Exception: pass current_data.append(notification_to_shopfloor(n, { 'sso': sso, 'name': name, 'picture': picture })) else: current_data.append(notification_to_shopfloor(n)) # Process upcoming notifications upcoming_data = [] for n in upcoming_notifications: is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition' if is_recognition and n.employeesso and ',' in n.employeesso: ssos = [s.strip() for s in n.employeesso.split(',')] names = n.employeename.split(', ') if n.employeename else [] for i, sso in enumerate(ssos): name = names[i] if i < len(names) else sso picture = None if sso.isdigit(): try: import pymysql conn = pymysql.connect( host='localhost', user='root', password='rootpassword', database='wjf_employees', cursorclass=pymysql.cursors.DictCursor ) with conn.cursor() as cur: cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) emp = cur.fetchone() if emp: picture = emp.get('Picture') conn.close() except Exception: pass upcoming_data.append(notification_to_shopfloor(n, { 'sso': sso, 'name': name, 'picture': picture })) else: upcoming_data.append(notification_to_shopfloor(n)) return success_response({ 'timestamp': now.isoformat(), 'current': current_data, 'upcoming': upcoming_data })