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 +
+Loading notifications...
+No notifications
+You're all caught up!
+Loading alerts...
+No alerts
+All systems are running smoothly
+