From 02edea7cb329c4b23dd1e172e3de042c436342b1 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 21 Dec 2025 14:11:48 +0100 Subject: [PATCH] feat: add admin notification system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AdminNotificationService for notification operations - Enhance notifications API with mark-read, mark-all-read endpoints - Add notifications.html page template - Add notifications.js Alpine component - Add implementation documentation πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/api/v1/admin/notifications.py | 248 ++++++- app/services/admin_notification_service.py | 701 ++++++++++++++++++ app/templates/admin/notifications.html | 361 +++++++++ .../admin-notification-system.md | 187 +++++ static/admin/js/notifications.js | 295 ++++++++ 5 files changed, 1759 insertions(+), 33 deletions(-) create mode 100644 app/services/admin_notification_service.py create mode 100644 app/templates/admin/notifications.html create mode 100644 docs/implementation/admin-notification-system.md create mode 100644 static/admin/js/notifications.js diff --git a/app/api/v1/admin/notifications.py b/app/api/v1/admin/notifications.py index af426167..0d8193d6 100644 --- a/app/api/v1/admin/notifications.py +++ b/app/api/v1/admin/notifications.py @@ -15,12 +15,19 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db +from app.services.admin_notification_service import ( + admin_notification_service, + platform_alert_service, +) from models.database.user import User from models.schema.admin import ( + AdminNotificationCreate, AdminNotificationListResponse, + AdminNotificationResponse, PlatformAlertCreate, PlatformAlertListResponse, PlatformAlertResolve, + PlatformAlertResponse, ) from models.schema.notification import ( AlertStatisticsResponse, @@ -40,27 +47,115 @@ logger = logging.getLogger(__name__) @router.get("", response_model=AdminNotificationListResponse) def get_notifications( priority: str | None = Query(None, description="Filter by priority"), + notification_type: str | None = Query(None, description="Filter by type"), is_read: bool | None = Query(None, description="Filter by read status"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> AdminNotificationListResponse: """Get admin notifications with filtering.""" - # TODO: Implement notification service - return AdminNotificationListResponse( - notifications=[], total=0, unread_count=0, skip=skip, limit=limit + notifications, total, unread_count = admin_notification_service.get_notifications( + db=db, + priority=priority, + is_read=is_read, + notification_type=notification_type, + skip=skip, + limit=limit, ) + return AdminNotificationListResponse( + notifications=[ + AdminNotificationResponse( + id=n.id, + type=n.type, + priority=n.priority, + title=n.title, + message=n.message, + is_read=n.is_read, + read_at=n.read_at, + read_by_user_id=n.read_by_user_id, + action_required=n.action_required, + action_url=n.action_url, + metadata=n.notification_metadata, + created_at=n.created_at, + ) + for n in notifications + ], + total=total, + unread_count=unread_count, + skip=skip, + limit=limit, + ) + + +@router.post("", response_model=AdminNotificationResponse) +def create_notification( + notification_data: AdminNotificationCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> AdminNotificationResponse: + """Create a new admin notification (manual).""" + notification = admin_notification_service.create_from_schema( + db=db, data=notification_data + ) + db.commit() + + logger.info(f"Admin {current_admin.username} created notification: {notification.title}") + + return AdminNotificationResponse( + id=notification.id, + type=notification.type, + priority=notification.priority, + title=notification.title, + message=notification.message, + is_read=notification.is_read, + read_at=notification.read_at, + read_by_user_id=notification.read_by_user_id, + action_required=notification.action_required, + action_url=notification.action_url, + metadata=notification.notification_metadata, + created_at=notification.created_at, + ) + + +@router.get("/recent") +def get_recent_notifications( + limit: int = Query(5, ge=1, le=10), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> dict: + """Get recent unread notifications for header dropdown.""" + notifications = admin_notification_service.get_recent_notifications( + db=db, limit=limit + ) + unread_count = admin_notification_service.get_unread_count(db) + + return { + "notifications": [ + { + "id": n.id, + "type": n.type, + "priority": n.priority, + "title": n.title, + "message": n.message[:100] + "..." if len(n.message) > 100 else n.message, + "action_url": n.action_url, + "created_at": n.created_at.isoformat(), + } + for n in notifications + ], + "unread_count": unread_count, + } + @router.get("/unread-count", response_model=UnreadCountResponse) def get_unread_count( db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> UnreadCountResponse: """Get count of unread notifications.""" - # TODO: Implement - return UnreadCountResponse(unread_count=0) + count = admin_notification_service.get_unread_count(db) + return UnreadCountResponse(unread_count=count) @router.put("/{notification_id}/read", response_model=MessageResponse) @@ -68,20 +163,48 @@ def mark_as_read( notification_id: int, db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> MessageResponse: """Mark notification as read.""" - # TODO: Implement - return MessageResponse(message="Notification marked as read") + notification = admin_notification_service.mark_as_read( + db=db, notification_id=notification_id, user_id=current_admin.id + ) + db.commit() + + if notification: + return MessageResponse(message="Notification marked as read") + return MessageResponse(message="Notification not found") @router.put("/mark-all-read", response_model=MessageResponse) def mark_all_as_read( db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> MessageResponse: """Mark all notifications as read.""" - # TODO: Implement - return MessageResponse(message="All notifications marked as read") + count = admin_notification_service.mark_all_as_read( + db=db, user_id=current_admin.id + ) + db.commit() + + return MessageResponse(message=f"Marked {count} notifications as read") + + +@router.delete("/{notification_id}", response_model=MessageResponse) +def delete_notification( + notification_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> MessageResponse: + """Delete a notification.""" + deleted = admin_notification_service.delete_notification( + db=db, notification_id=notification_id + ) + db.commit() + + if deleted: + return MessageResponse(message="Notification deleted") + + return MessageResponse(message="Notification not found") # ============================================================================ @@ -92,29 +215,83 @@ def mark_all_as_read( @router.get("/alerts", response_model=PlatformAlertListResponse) def get_platform_alerts( severity: str | None = Query(None, description="Filter by severity"), + alert_type: str | None = Query(None, description="Filter by alert type"), is_resolved: bool | None = Query(None, description="Filter by resolution status"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> PlatformAlertListResponse: """Get platform alerts with filtering.""" - # TODO: Implement alert service + alerts, total, active_count, critical_count = platform_alert_service.get_alerts( + db=db, + severity=severity, + alert_type=alert_type, + is_resolved=is_resolved, + skip=skip, + limit=limit, + ) + return PlatformAlertListResponse( - alerts=[], total=0, active_count=0, critical_count=0, skip=skip, limit=limit + alerts=[ + PlatformAlertResponse( + id=a.id, + alert_type=a.alert_type, + severity=a.severity, + title=a.title, + description=a.description, + affected_vendors=a.affected_vendors, + affected_systems=a.affected_systems, + is_resolved=a.is_resolved, + resolved_at=a.resolved_at, + resolved_by_user_id=a.resolved_by_user_id, + resolution_notes=a.resolution_notes, + auto_generated=a.auto_generated, + occurrence_count=a.occurrence_count, + first_occurred_at=a.first_occurred_at, + last_occurred_at=a.last_occurred_at, + created_at=a.created_at, + ) + for a in alerts + ], + total=total, + active_count=active_count, + critical_count=critical_count, + skip=skip, + limit=limit, ) -@router.post("/alerts", response_model=MessageResponse) +@router.post("/alerts", response_model=PlatformAlertResponse) def create_platform_alert( alert_data: PlatformAlertCreate, db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> PlatformAlertResponse: """Create new platform alert (manual).""" - # TODO: Implement - return PlatformAlertResponse when service is ready - logger.info(f"Admin {current_admin.username} created alert: {alert_data.title}") - return MessageResponse(message="Platform alert creation coming soon") + alert = platform_alert_service.create_from_schema(db=db, data=alert_data) + db.commit() + + logger.info(f"Admin {current_admin.username} created alert: {alert.title}") + + return PlatformAlertResponse( + id=alert.id, + alert_type=alert.alert_type, + severity=alert.severity, + title=alert.title, + description=alert.description, + affected_vendors=alert.affected_vendors, + affected_systems=alert.affected_systems, + is_resolved=alert.is_resolved, + resolved_at=alert.resolved_at, + resolved_by_user_id=alert.resolved_by_user_id, + resolution_notes=alert.resolution_notes, + auto_generated=alert.auto_generated, + occurrence_count=alert.occurrence_count, + first_occurred_at=alert.first_occurred_at, + last_occurred_at=alert.last_occurred_at, + created_at=alert.created_at, + ) @router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse) @@ -123,23 +300,28 @@ def resolve_platform_alert( resolve_data: PlatformAlertResolve, db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> MessageResponse: """Resolve platform alert.""" - # TODO: Implement - logger.info(f"Admin {current_admin.username} resolved alert {alert_id}") - return MessageResponse(message="Alert resolved successfully") + alert = platform_alert_service.resolve_alert( + db=db, + alert_id=alert_id, + user_id=current_admin.id, + resolution_notes=resolve_data.resolution_notes, + ) + db.commit() + + if alert: + logger.info(f"Admin {current_admin.username} resolved alert {alert_id}") + return MessageResponse(message="Alert resolved successfully") + + return MessageResponse(message="Alert not found or already resolved") @router.get("/alerts/stats", response_model=AlertStatisticsResponse) def get_alert_statistics( db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), -): +) -> AlertStatisticsResponse: """Get alert statistics for dashboard.""" - # TODO: Implement - return AlertStatisticsResponse( - total_alerts=0, - active_alerts=0, - critical_alerts=0, - resolved_today=0, - ) + stats = platform_alert_service.get_statistics(db) + return AlertStatisticsResponse(**stats) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py new file mode 100644 index 00000000..704669cf --- /dev/null +++ b/app/services/admin_notification_service.py @@ -0,0 +1,701 @@ +# app/services/admin_notification_service.py +""" +Admin notification service. + +Provides functionality for: +- Creating and managing admin notifications +- Managing platform alerts +- Notification statistics and queries +""" + +import logging +from datetime import datetime, timedelta +from typing import Any + +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session + +from models.database.admin import AdminNotification, PlatformAlert +from models.schema.admin import AdminNotificationCreate, PlatformAlertCreate + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# NOTIFICATION TYPES +# ============================================================================ + +class NotificationType: + """Notification type constants.""" + + SYSTEM_ALERT = "system_alert" + IMPORT_FAILURE = "import_failure" + EXPORT_FAILURE = "export_failure" + ORDER_SYNC_FAILURE = "order_sync_failure" + VENDOR_ISSUE = "vendor_issue" + CUSTOMER_MESSAGE = "customer_message" + VENDOR_MESSAGE = "vendor_message" + SECURITY_ALERT = "security_alert" + PERFORMANCE_ALERT = "performance_alert" + ORDER_EXCEPTION = "order_exception" + CRITICAL_ERROR = "critical_error" + + +class Priority: + """Priority level constants.""" + + LOW = "low" + NORMAL = "normal" + HIGH = "high" + CRITICAL = "critical" + + +class AlertType: + """Platform alert type constants.""" + + SECURITY = "security" + PERFORMANCE = "performance" + CAPACITY = "capacity" + INTEGRATION = "integration" + DATABASE = "database" + SYSTEM = "system" + + +class Severity: + """Alert severity constants.""" + + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + +# ============================================================================ +# ADMIN NOTIFICATION SERVICE +# ============================================================================ + + +class AdminNotificationService: + """Service for managing admin notifications.""" + + def create_notification( + self, + db: Session, + notification_type: str, + title: str, + message: str, + priority: str = Priority.NORMAL, + action_required: bool = False, + action_url: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> AdminNotification: + """ + Create a new admin notification. + + Args: + db: Database session + notification_type: Type of notification + title: Notification title + message: Notification message + priority: Priority level (low, normal, high, critical) + action_required: Whether action is required + action_url: URL to relevant admin page + metadata: Additional contextual data + + Returns: + Created AdminNotification + """ + notification = AdminNotification( + type=notification_type, + title=title, + message=message, + priority=priority, + action_required=action_required, + action_url=action_url, + notification_metadata=metadata, + ) + db.add(notification) + db.flush() + + logger.info( + f"Created notification: {notification_type} - {title} (priority: {priority})" + ) + + return notification + + def create_from_schema( + self, + db: Session, + data: AdminNotificationCreate, + ) -> AdminNotification: + """Create notification from Pydantic schema.""" + return self.create_notification( + db=db, + notification_type=data.type, + title=data.title, + message=data.message, + priority=data.priority, + action_required=data.action_required, + action_url=data.action_url, + metadata=data.metadata, + ) + + def get_notifications( + self, + db: Session, + priority: str | None = None, + is_read: bool | None = None, + notification_type: str | None = None, + skip: int = 0, + limit: int = 50, + ) -> tuple[list[AdminNotification], int, int]: + """ + Get paginated admin notifications. + + Returns: + Tuple of (notifications, total_count, unread_count) + """ + query = db.query(AdminNotification) + + # Apply filters + if priority: + query = query.filter(AdminNotification.priority == priority) + + if is_read is not None: + query = query.filter(AdminNotification.is_read == is_read) + + if notification_type: + query = query.filter(AdminNotification.type == notification_type) + + # Get counts + total = query.count() + unread_count = ( + db.query(AdminNotification) + .filter(AdminNotification.is_read == False) # noqa: E712 + .count() + ) + + # Get paginated results ordered by priority and date + priority_order = func.case( + (AdminNotification.priority == "critical", 1), + (AdminNotification.priority == "high", 2), + (AdminNotification.priority == "normal", 3), + (AdminNotification.priority == "low", 4), + else_=5, + ) + + notifications = ( + query.order_by( + AdminNotification.is_read, # Unread first + priority_order, + AdminNotification.created_at.desc(), + ) + .offset(skip) + .limit(limit) + .all() + ) + + return notifications, total, unread_count + + def get_unread_count(self, db: Session) -> int: + """Get count of unread notifications.""" + return ( + db.query(AdminNotification) + .filter(AdminNotification.is_read == False) # noqa: E712 + .count() + ) + + def get_recent_notifications( + self, + db: Session, + limit: int = 5, + ) -> list[AdminNotification]: + """Get recent unread notifications for header dropdown.""" + priority_order = func.case( + (AdminNotification.priority == "critical", 1), + (AdminNotification.priority == "high", 2), + (AdminNotification.priority == "normal", 3), + (AdminNotification.priority == "low", 4), + else_=5, + ) + + return ( + db.query(AdminNotification) + .filter(AdminNotification.is_read == False) # noqa: E712 + .order_by(priority_order, AdminNotification.created_at.desc()) + .limit(limit) + .all() + ) + + def mark_as_read( + self, + db: Session, + notification_id: int, + user_id: int, + ) -> AdminNotification | None: + """Mark a notification as read.""" + notification = ( + db.query(AdminNotification) + .filter(AdminNotification.id == notification_id) + .first() + ) + + if notification and not notification.is_read: + notification.is_read = True + notification.read_at = datetime.utcnow() + notification.read_by_user_id = user_id + db.flush() + + return notification + + def mark_all_as_read( + self, + db: Session, + user_id: int, + ) -> int: + """Mark all unread notifications as read. Returns count of updated.""" + now = datetime.utcnow() + count = ( + db.query(AdminNotification) + .filter(AdminNotification.is_read == False) # noqa: E712 + .update( + { + AdminNotification.is_read: True, + AdminNotification.read_at: now, + AdminNotification.read_by_user_id: user_id, + } + ) + ) + db.flush() + return count + + def delete_old_notifications( + self, + db: Session, + days: int = 30, + ) -> int: + """Delete notifications older than specified days.""" + cutoff = datetime.utcnow() - timedelta(days=days) + count = ( + db.query(AdminNotification) + .filter( + and_( + AdminNotification.is_read == True, # noqa: E712 + AdminNotification.created_at < cutoff, + ) + ) + .delete() + ) + db.flush() + return count + + def delete_notification( + self, + db: Session, + notification_id: int, + ) -> bool: + """ + Delete a notification by ID. + + Returns: + True if notification was deleted, False if not found. + """ + notification = ( + db.query(AdminNotification) + .filter(AdminNotification.id == notification_id) + .first() + ) + + if notification: + db.delete(notification) + db.flush() + logger.info(f"Deleted notification {notification_id}") + return True + + return False + + # ========================================================================= + # CONVENIENCE METHODS FOR CREATING SPECIFIC NOTIFICATION TYPES + # ========================================================================= + + def notify_import_failure( + self, + db: Session, + vendor_name: str, + job_id: int, + error_message: str, + vendor_id: int | None = None, + ) -> AdminNotification: + """Create notification for import job failure.""" + return self.create_notification( + db=db, + notification_type=NotificationType.IMPORT_FAILURE, + title=f"Import Failed: {vendor_name}", + message=error_message, + priority=Priority.HIGH, + action_required=True, + action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs" + if vendor_id + else "/admin/marketplace", + metadata={"vendor_name": vendor_name, "job_id": job_id, "vendor_id": vendor_id}, + ) + + def notify_order_sync_failure( + self, + db: Session, + vendor_name: str, + error_message: str, + vendor_id: int | None = None, + ) -> AdminNotification: + """Create notification for order sync failure.""" + return self.create_notification( + db=db, + notification_type=NotificationType.ORDER_SYNC_FAILURE, + title=f"Order Sync Failed: {vendor_name}", + message=error_message, + priority=Priority.HIGH, + action_required=True, + action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs" + if vendor_id + else "/admin/marketplace/letzshop", + metadata={"vendor_name": vendor_name, "vendor_id": vendor_id}, + ) + + def notify_order_exception( + self, + db: Session, + vendor_name: str, + order_number: str, + exception_count: int, + vendor_id: int | None = None, + ) -> AdminNotification: + """Create notification for order item exceptions.""" + return self.create_notification( + db=db, + notification_type=NotificationType.ORDER_EXCEPTION, + title=f"Order Exception: {order_number}", + message=f"{exception_count} item(s) need attention for order {order_number} ({vendor_name})", + priority=Priority.NORMAL, + action_required=True, + action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=exceptions" + if vendor_id + else "/admin/marketplace/letzshop", + metadata={ + "vendor_name": vendor_name, + "order_number": order_number, + "exception_count": exception_count, + "vendor_id": vendor_id, + }, + ) + + def notify_critical_error( + self, + db: Session, + error_type: str, + error_message: str, + details: dict[str, Any] | None = None, + ) -> AdminNotification: + """Create notification for critical application errors.""" + return self.create_notification( + db=db, + notification_type=NotificationType.CRITICAL_ERROR, + title=f"Critical Error: {error_type}", + message=error_message, + priority=Priority.CRITICAL, + action_required=True, + action_url="/admin/logs", + metadata=details, + ) + + def notify_vendor_issue( + self, + db: Session, + vendor_name: str, + issue_type: str, + message: str, + vendor_id: int | None = None, + ) -> AdminNotification: + """Create notification for vendor-related issues.""" + return self.create_notification( + db=db, + notification_type=NotificationType.VENDOR_ISSUE, + title=f"Vendor Issue: {vendor_name}", + message=message, + priority=Priority.HIGH, + action_required=True, + action_url=f"/admin/vendors/{vendor_id}" if vendor_id else "/admin/vendors", + metadata={ + "vendor_name": vendor_name, + "issue_type": issue_type, + "vendor_id": vendor_id, + }, + ) + + def notify_security_alert( + self, + db: Session, + title: str, + message: str, + details: dict[str, Any] | None = None, + ) -> AdminNotification: + """Create notification for security-related alerts.""" + return self.create_notification( + db=db, + notification_type=NotificationType.SECURITY_ALERT, + title=title, + message=message, + priority=Priority.CRITICAL, + action_required=True, + action_url="/admin/audit", + metadata=details, + ) + + +# ============================================================================ +# PLATFORM ALERT SERVICE +# ============================================================================ + + +class PlatformAlertService: + """Service for managing platform-wide alerts.""" + + def create_alert( + self, + db: Session, + alert_type: str, + severity: str, + title: str, + description: str | None = None, + affected_vendors: list[int] | None = None, + affected_systems: list[str] | None = None, + auto_generated: bool = True, + ) -> PlatformAlert: + """Create a new platform alert.""" + now = datetime.utcnow() + + alert = PlatformAlert( + alert_type=alert_type, + severity=severity, + title=title, + description=description, + affected_vendors=affected_vendors, + affected_systems=affected_systems, + auto_generated=auto_generated, + first_occurred_at=now, + last_occurred_at=now, + ) + db.add(alert) + db.flush() + + logger.info(f"Created platform alert: {alert_type} - {title} ({severity})") + + return alert + + def create_from_schema( + self, + db: Session, + data: PlatformAlertCreate, + ) -> PlatformAlert: + """Create alert from Pydantic schema.""" + return self.create_alert( + db=db, + alert_type=data.alert_type, + severity=data.severity, + title=data.title, + description=data.description, + affected_vendors=data.affected_vendors, + affected_systems=data.affected_systems, + auto_generated=data.auto_generated, + ) + + def get_alerts( + self, + db: Session, + severity: str | None = None, + alert_type: str | None = None, + is_resolved: bool | None = None, + skip: int = 0, + limit: int = 50, + ) -> tuple[list[PlatformAlert], int, int, int]: + """ + Get paginated platform alerts. + + Returns: + Tuple of (alerts, total_count, active_count, critical_count) + """ + query = db.query(PlatformAlert) + + # Apply filters + if severity: + query = query.filter(PlatformAlert.severity == severity) + + if alert_type: + query = query.filter(PlatformAlert.alert_type == alert_type) + + if is_resolved is not None: + query = query.filter(PlatformAlert.is_resolved == is_resolved) + + # Get counts + total = query.count() + active_count = ( + db.query(PlatformAlert) + .filter(PlatformAlert.is_resolved == False) # noqa: E712 + .count() + ) + critical_count = ( + db.query(PlatformAlert) + .filter( + and_( + PlatformAlert.is_resolved == False, # noqa: E712 + PlatformAlert.severity == Severity.CRITICAL, + ) + ) + .count() + ) + + # Get paginated results + severity_order = func.case( + (PlatformAlert.severity == "critical", 1), + (PlatformAlert.severity == "error", 2), + (PlatformAlert.severity == "warning", 3), + (PlatformAlert.severity == "info", 4), + else_=5, + ) + + alerts = ( + query.order_by( + PlatformAlert.is_resolved, # Unresolved first + severity_order, + PlatformAlert.last_occurred_at.desc(), + ) + .offset(skip) + .limit(limit) + .all() + ) + + return alerts, total, active_count, critical_count + + def resolve_alert( + self, + db: Session, + alert_id: int, + user_id: int, + resolution_notes: str | None = None, + ) -> PlatformAlert | None: + """Resolve a platform alert.""" + alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first() + + if alert and not alert.is_resolved: + alert.is_resolved = True + alert.resolved_at = datetime.utcnow() + alert.resolved_by_user_id = user_id + alert.resolution_notes = resolution_notes + db.flush() + + logger.info(f"Resolved platform alert {alert_id}") + + return alert + + def get_statistics(self, db: Session) -> dict[str, int]: + """Get alert statistics.""" + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + + total = db.query(PlatformAlert).count() + active = ( + db.query(PlatformAlert) + .filter(PlatformAlert.is_resolved == False) # noqa: E712 + .count() + ) + critical = ( + db.query(PlatformAlert) + .filter( + and_( + PlatformAlert.is_resolved == False, # noqa: E712 + PlatformAlert.severity == Severity.CRITICAL, + ) + ) + .count() + ) + resolved_today = ( + db.query(PlatformAlert) + .filter( + and_( + PlatformAlert.is_resolved == True, # noqa: E712 + PlatformAlert.resolved_at >= today_start, + ) + ) + .count() + ) + + return { + "total_alerts": total, + "active_alerts": active, + "critical_alerts": critical, + "resolved_today": resolved_today, + } + + def increment_occurrence( + self, + db: Session, + alert_id: int, + ) -> PlatformAlert | None: + """Increment occurrence count for repeated alert.""" + alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first() + + if alert: + alert.occurrence_count += 1 + alert.last_occurred_at = datetime.utcnow() + db.flush() + + return alert + + def find_similar_active_alert( + self, + db: Session, + alert_type: str, + title: str, + ) -> PlatformAlert | None: + """Find an active alert with same type and title.""" + return ( + db.query(PlatformAlert) + .filter( + and_( + PlatformAlert.alert_type == alert_type, + PlatformAlert.title == title, + PlatformAlert.is_resolved == False, # noqa: E712 + ) + ) + .first() + ) + + def create_or_increment_alert( + self, + db: Session, + alert_type: str, + severity: str, + title: str, + description: str | None = None, + affected_vendors: list[int] | None = None, + affected_systems: list[str] | None = None, + ) -> PlatformAlert: + """Create alert or increment occurrence if similar exists.""" + existing = self.find_similar_active_alert(db, alert_type, title) + + if existing: + self.increment_occurrence(db, existing.id) + return existing + + return self.create_alert( + db=db, + alert_type=alert_type, + severity=severity, + title=title, + description=description, + affected_vendors=affected_vendors, + affected_systems=affected_systems, + ) + + +# Singleton instances +admin_notification_service = AdminNotificationService() +platform_alert_service = PlatformAlertService() diff --git a/app/templates/admin/notifications.html b/app/templates/admin/notifications.html new file mode 100644 index 00000000..6e0c363c --- /dev/null +++ b/app/templates/admin/notifications.html @@ -0,0 +1,361 @@ +{# app/templates/admin/notifications.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Notifications{% endblock %} + +{% block alpine_data %}adminNotifications(){% endblock %} + +{% block content %} +{{ page_header('Notifications & Alerts') }} + +{{ loading_state('Loading notifications...') }} + +{{ error_state('Error loading notifications') }} + + +
+ +
+
+ +
+
+

+ Unread +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Active Alerts +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Critical +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Resolved Today +

+

+ 0 +

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + +
+ + +
+ + +
+ + + + +
    + +
+ + +
+ + Showing - of + +
+ + +
+
+
+
+ + +
+ +
+ + + +
+ + +
+ + + + +
    + +
+
+
+{% endblock %} + +{% block page_scripts %} + +{% endblock %} diff --git a/docs/implementation/admin-notification-system.md b/docs/implementation/admin-notification-system.md new file mode 100644 index 00000000..9ee7ac43 --- /dev/null +++ b/docs/implementation/admin-notification-system.md @@ -0,0 +1,187 @@ +# Admin Notification System + +## Overview + +The admin notification system provides real-time alerts and notifications to platform administrators for important events, errors, and system status updates. + +## Components + +### Backend + +#### Database Models + +Located in `models/database/admin.py`: + +- **AdminNotification**: Stores individual notifications + - `type`: Notification type (import_failure, order_sync_failure, etc.) + - `priority`: low, normal, high, critical + - `title`, `message`: Content + - `is_read`, `read_at`, `read_by_user_id`: Read tracking + - `action_required`, `action_url`: Optional action link + - `notification_metadata`: JSON for additional context + +- **PlatformAlert**: Stores platform-wide alerts + - `alert_type`: security, performance, capacity, integration, etc. + - `severity`: info, warning, error, critical + - `affected_vendors`, `affected_systems`: Scope tracking + - `occurrence_count`, `first_occurred_at`, `last_occurred_at`: Deduplication + - `is_resolved`, `resolved_at`, `resolution_notes`: Resolution tracking + +#### Service Layer + +Located in `app/services/admin_notification_service.py`: + +```python +from app.services.admin_notification_service import ( + admin_notification_service, + platform_alert_service, + NotificationType, + Priority, + AlertType, + Severity, +) +``` + +**AdminNotificationService** methods: + +| Method | Description | +|--------|-------------| +| `create_notification()` | Create a new notification | +| `get_notifications()` | List notifications with filters | +| `get_recent_notifications()` | Get recent unread for header dropdown | +| `get_unread_count()` | Count unread notifications | +| `mark_as_read()` | Mark single notification read | +| `mark_all_as_read()` | Mark all as read | +| `delete_notification()` | Delete a notification | + +**Convenience methods** for common scenarios: + +| Method | Use Case | +|--------|----------| +| `notify_import_failure()` | Product/order import failed | +| `notify_order_sync_failure()` | Letzshop sync failed | +| `notify_order_exception()` | Order has unmatched products | +| `notify_critical_error()` | System critical error | +| `notify_vendor_issue()` | Vendor-related problem | +| `notify_security_alert()` | Security event detected | + +**PlatformAlertService** methods: + +| Method | Description | +|--------|-------------| +| `create_alert()` | Create a new platform alert | +| `get_alerts()` | List alerts with filters | +| `resolve_alert()` | Mark alert as resolved | +| `get_statistics()` | Get alert counts and stats | +| `create_or_increment_alert()` | Deduplicate recurring alerts | + +#### API Endpoints + +Located in `app/api/v1/admin/notifications.py`: + +**Notifications:** +- `GET /api/v1/admin/notifications` - List with filters +- `POST /api/v1/admin/notifications` - Create (manual) +- `GET /api/v1/admin/notifications/recent` - For header dropdown +- `GET /api/v1/admin/notifications/unread-count` - Badge count +- `PUT /api/v1/admin/notifications/{id}/read` - Mark read +- `PUT /api/v1/admin/notifications/mark-all-read` - Mark all read +- `DELETE /api/v1/admin/notifications/{id}` - Delete + +**Platform Alerts:** +- `GET /api/v1/admin/notifications/alerts` - List with filters +- `POST /api/v1/admin/notifications/alerts` - Create (manual) +- `PUT /api/v1/admin/notifications/alerts/{id}/resolve` - Resolve +- `GET /api/v1/admin/notifications/alerts/stats` - Statistics + +### Frontend + +#### Header Dropdown + +Located in `app/templates/admin/partials/header.html`: + +- Real-time notification bell with unread count badge +- Polls for new notifications every 60 seconds +- Quick actions: mark as read, view all +- Priority-based color coding + +#### Notifications Page + +Located in `app/templates/admin/notifications.html` with `static/admin/js/notifications.js`: + +- Full notifications management interface +- Two tabs: Notifications and Platform Alerts +- Statistics cards (unread, active alerts, critical, resolved today) +- Filtering by priority, type, read status +- Bulk operations (mark all read) +- Alert resolution workflow + +## Automatic Triggers + +Notifications are automatically created in these scenarios: + +### Import Failures + +**Product Import** (`app/tasks/background_tasks.py`): +- When a product import job fails completely +- When import completes with 5+ errors + +**Historical Order Import** (`app/tasks/letzshop_tasks.py`): +- When Letzshop API returns an error +- When import fails with an unexpected exception + +### Example Usage + +```python +from app.services.admin_notification_service import admin_notification_service + +# In a background task or service +admin_notification_service.notify_import_failure( + db=db, + vendor_name="Acme Corp", + job_id=123, + error_message="CSV parsing failed: invalid column format", + vendor_id=5, +) +db.commit() +``` + +## Priority Levels + +| Priority | When to Use | Badge Color | +|----------|-------------|-------------| +| `critical` | System down, data loss risk | Red | +| `high` | Import/sync failures, action needed | Orange | +| `normal` | Informational alerts | Blue | +| `low` | Minor issues, suggestions | Gray | + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Background │────▢│ Notification β”‚ +β”‚ Tasks β”‚ β”‚ Service β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό +β”‚ API Endpoints │◀──────────────── +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Header │◀────│ Database β”‚ +β”‚ Dropdown β”‚ β”‚ (admin_notificationsβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ platform_alerts) β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Notifications β”‚ +β”‚ Page β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Future Enhancements + +- Email notifications for critical alerts +- Webhook integration for external systems +- Customizable notification preferences per admin +- Scheduled notification digests diff --git a/static/admin/js/notifications.js b/static/admin/js/notifications.js new file mode 100644 index 00000000..f95159cf --- /dev/null +++ b/static/admin/js/notifications.js @@ -0,0 +1,295 @@ +/** + * Admin Notifications Page + * + * Handles the notifications management interface including: + * - Notifications list with filtering and pagination + * - Platform alerts management + * - Mark as read, delete, and bulk operations + */ + +const notificationsLog = window.LogConfig?.createLogger('NOTIFICATIONS') || console; + +/** + * Admin Notifications Component + */ +function adminNotifications() { + return { + // Loading states + loading: true, + loadingNotifications: false, + loadingAlerts: false, + + // Tab state + activeTab: 'notifications', + + // Notifications state + notifications: [], + page: 1, + skip: 0, + limit: 10, + stats: { + total: 0, + unread_count: 0 + }, + + // Notifications filters + filters: { + priority: '', + is_read: '' + }, + + // Alerts state + alerts: [], + alertPage: 1, + alertSkip: 0, + alertLimit: 10, + alertStats: { + total: 0, + active_alerts: 0, + critical_alerts: 0, + resolved_today: 0, + by_type: {}, + by_severity: {} + }, + + // Alerts filters + alertFilters: { + severity: '', + is_resolved: '' + }, + + // Resolve modal state + showResolveModal: false, + resolvingAlert: null, + resolutionNotes: '', + + /** + * Initialize component + */ + async init() { + notificationsLog.debug('Initializing notifications page'); + await Promise.all([ + this.loadNotifications(), + this.loadAlertStats() + ]); + this.loading = false; + }, + + // ============================================================================ + // NOTIFICATIONS + // ============================================================================ + + /** + * Load notifications with current filters + */ + async loadNotifications() { + this.loadingNotifications = true; + try { + this.skip = (this.page - 1) * this.limit; + const params = new URLSearchParams(); + params.append('skip', this.skip); + params.append('limit', this.limit); + + if (this.filters.priority) params.append('priority', this.filters.priority); + if (this.filters.is_read !== '') params.append('is_read', this.filters.is_read); + + const response = await apiClient.get(`/admin/notifications?${params}`); + this.notifications = response.notifications || []; + this.stats.total = response.total || 0; + this.stats.unread_count = response.unread_count || 0; + + notificationsLog.debug(`Loaded ${this.notifications.length} notifications`); + } catch (error) { + notificationsLog.error('Failed to load notifications:', error); + window.showToast?.('Failed to load notifications', 'error'); + } finally { + this.loadingNotifications = false; + } + }, + + /** + * Mark notification as read + */ + async markAsRead(notification) { + try { + await apiClient.put(`/admin/notifications/${notification.id}/read`); + + // Update local state + notification.is_read = true; + this.stats.unread_count = Math.max(0, this.stats.unread_count - 1); + + window.showToast?.('Notification marked as read', 'success'); + } catch (error) { + notificationsLog.error('Failed to mark as read:', error); + window.showToast?.('Failed to mark notification as read', 'error'); + } + }, + + /** + * Mark all notifications as read + */ + async markAllAsRead() { + try { + await apiClient.put('/admin/notifications/mark-all-read'); + + // Update local state + this.notifications.forEach(n => n.is_read = true); + this.stats.unread_count = 0; + + window.showToast?.('All notifications marked as read', 'success'); + } catch (error) { + notificationsLog.error('Failed to mark all as read:', error); + window.showToast?.('Failed to mark all as read', 'error'); + } + }, + + /** + * Delete notification + */ + async deleteNotification(notificationId) { + if (!confirm('Are you sure you want to delete this notification?')) { + return; + } + + try { + await apiClient.delete(`/admin/notifications/${notificationId}`); + + // Remove from local state + const wasUnread = this.notifications.find(n => n.id === notificationId && !n.is_read); + this.notifications = this.notifications.filter(n => n.id !== notificationId); + this.stats.total = Math.max(0, this.stats.total - 1); + if (wasUnread) { + this.stats.unread_count = Math.max(0, this.stats.unread_count - 1); + } + + window.showToast?.('Notification deleted', 'success'); + } catch (error) { + notificationsLog.error('Failed to delete notification:', error); + window.showToast?.('Failed to delete notification', 'error'); + } + }, + + /** + * Get notification icon based on type + */ + getNotificationIcon(type) { + const icons = { + 'import_failure': window.$icon?.('x-circle', 'w-5 h-5') || '❌', + 'sync_issue': window.$icon?.('refresh', 'w-5 h-5') || 'πŸ”„', + 'vendor_alert': window.$icon?.('exclamation-triangle', 'w-5 h-5') || '⚠️', + 'system_health': window.$icon?.('heart', 'w-5 h-5') || 'πŸ’“', + 'security': window.$icon?.('shield-exclamation', 'w-5 h-5') || 'πŸ›‘οΈ', + 'performance': window.$icon?.('chart-bar', 'w-5 h-5') || 'πŸ“Š', + 'info': window.$icon?.('information-circle', 'w-5 h-5') || 'ℹ️' + }; + return icons[type] || window.$icon?.('bell', 'w-5 h-5') || 'πŸ””'; + }, + + // ============================================================================ + // PLATFORM ALERTS + // ============================================================================ + + /** + * Load platform alerts + */ + async loadAlerts() { + this.loadingAlerts = true; + try { + this.alertSkip = (this.alertPage - 1) * this.alertLimit; + const params = new URLSearchParams(); + params.append('skip', this.alertSkip); + params.append('limit', this.alertLimit); + + if (this.alertFilters.severity) params.append('severity', this.alertFilters.severity); + if (this.alertFilters.is_resolved !== '') params.append('is_resolved', this.alertFilters.is_resolved); + + const response = await apiClient.get(`/admin/notifications/alerts?${params}`); + this.alerts = response.alerts || []; + this.alertStats.total = response.total || 0; + this.alertStats.active_alerts = response.active_count || 0; + this.alertStats.critical_alerts = response.critical_count || 0; + + notificationsLog.debug(`Loaded ${this.alerts.length} alerts`); + } catch (error) { + notificationsLog.error('Failed to load alerts:', error); + window.showToast?.('Failed to load alerts', 'error'); + } finally { + this.loadingAlerts = false; + } + }, + + /** + * Load alert statistics + */ + async loadAlertStats() { + try { + const response = await apiClient.get('/admin/notifications/alerts/stats'); + this.alertStats = { + ...this.alertStats, + total: response.total || 0, + active_alerts: response.active || 0, + critical_alerts: response.critical || 0, + resolved_today: response.resolved_today || 0, + by_type: response.by_type || {}, + by_severity: response.by_severity || {} + }; + } catch (error) { + notificationsLog.error('Failed to load alert stats:', error); + } + }, + + /** + * Resolve alert + */ + async resolveAlert(alert) { + const notes = prompt('Resolution notes (optional):'); + if (notes === null) return; // User cancelled + + try { + await apiClient.put(`/admin/notifications/alerts/${alert.id}/resolve`, { + resolution_notes: notes + }); + + // Update local state + alert.is_resolved = true; + alert.resolution_notes = notes; + this.alertStats.active_alerts = Math.max(0, this.alertStats.active_alerts - 1); + if (alert.severity === 'critical') { + this.alertStats.critical_alerts = Math.max(0, this.alertStats.critical_alerts - 1); + } + this.alertStats.resolved_today++; + + window.showToast?.('Alert resolved successfully', 'success'); + } catch (error) { + notificationsLog.error('Failed to resolve alert:', error); + window.showToast?.('Failed to resolve alert', 'error'); + } + }, + + // ============================================================================ + // HELPERS + // ============================================================================ + + /** + * Format date for display + */ + formatDate(dateString) { + if (!dateString) return '-'; + const date = new Date(dateString); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + // Show relative time for recent dates + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 172800) return 'Yesterday'; + + // Show full date for older dates + return date.toLocaleString(); + } + }; +} + +// Make available globally +window.adminNotifications = adminNotifications;