feat: add admin notification system
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,19 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
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.database.user import User
|
||||||
from models.schema.admin import (
|
from models.schema.admin import (
|
||||||
|
AdminNotificationCreate,
|
||||||
AdminNotificationListResponse,
|
AdminNotificationListResponse,
|
||||||
|
AdminNotificationResponse,
|
||||||
PlatformAlertCreate,
|
PlatformAlertCreate,
|
||||||
PlatformAlertListResponse,
|
PlatformAlertListResponse,
|
||||||
PlatformAlertResolve,
|
PlatformAlertResolve,
|
||||||
|
PlatformAlertResponse,
|
||||||
)
|
)
|
||||||
from models.schema.notification import (
|
from models.schema.notification import (
|
||||||
AlertStatisticsResponse,
|
AlertStatisticsResponse,
|
||||||
@@ -40,27 +47,115 @@ logger = logging.getLogger(__name__)
|
|||||||
@router.get("", response_model=AdminNotificationListResponse)
|
@router.get("", response_model=AdminNotificationListResponse)
|
||||||
def get_notifications(
|
def get_notifications(
|
||||||
priority: str | None = Query(None, description="Filter by priority"),
|
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"),
|
is_read: bool | None = Query(None, description="Filter by read status"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> AdminNotificationListResponse:
|
||||||
"""Get admin notifications with filtering."""
|
"""Get admin notifications with filtering."""
|
||||||
# TODO: Implement notification service
|
notifications, total, unread_count = admin_notification_service.get_notifications(
|
||||||
return AdminNotificationListResponse(
|
db=db,
|
||||||
notifications=[], total=0, unread_count=0, skip=skip, limit=limit
|
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)
|
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||||
def get_unread_count(
|
def get_unread_count(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> UnreadCountResponse:
|
||||||
"""Get count of unread notifications."""
|
"""Get count of unread notifications."""
|
||||||
# TODO: Implement
|
count = admin_notification_service.get_unread_count(db)
|
||||||
return UnreadCountResponse(unread_count=0)
|
return UnreadCountResponse(unread_count=count)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{notification_id}/read", response_model=MessageResponse)
|
@router.put("/{notification_id}/read", response_model=MessageResponse)
|
||||||
@@ -68,20 +163,48 @@ def mark_as_read(
|
|||||||
notification_id: int,
|
notification_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> MessageResponse:
|
||||||
"""Mark notification as read."""
|
"""Mark notification as read."""
|
||||||
# TODO: Implement
|
notification = admin_notification_service.mark_as_read(
|
||||||
return MessageResponse(message="Notification marked 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)
|
@router.put("/mark-all-read", response_model=MessageResponse)
|
||||||
def mark_all_as_read(
|
def mark_all_as_read(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> MessageResponse:
|
||||||
"""Mark all notifications as read."""
|
"""Mark all notifications as read."""
|
||||||
# TODO: Implement
|
count = admin_notification_service.mark_all_as_read(
|
||||||
return MessageResponse(message="All notifications marked 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)
|
@router.get("/alerts", response_model=PlatformAlertListResponse)
|
||||||
def get_platform_alerts(
|
def get_platform_alerts(
|
||||||
severity: str | None = Query(None, description="Filter by severity"),
|
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"),
|
is_resolved: bool | None = Query(None, description="Filter by resolution status"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> PlatformAlertListResponse:
|
||||||
"""Get platform alerts with filtering."""
|
"""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(
|
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(
|
def create_platform_alert(
|
||||||
alert_data: PlatformAlertCreate,
|
alert_data: PlatformAlertCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> PlatformAlertResponse:
|
||||||
"""Create new platform alert (manual)."""
|
"""Create new platform alert (manual)."""
|
||||||
# TODO: Implement - return PlatformAlertResponse when service is ready
|
alert = platform_alert_service.create_from_schema(db=db, data=alert_data)
|
||||||
logger.info(f"Admin {current_admin.username} created alert: {alert_data.title}")
|
db.commit()
|
||||||
return MessageResponse(message="Platform alert creation coming soon")
|
|
||||||
|
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)
|
@router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse)
|
||||||
@@ -123,23 +300,28 @@ def resolve_platform_alert(
|
|||||||
resolve_data: PlatformAlertResolve,
|
resolve_data: PlatformAlertResolve,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> MessageResponse:
|
||||||
"""Resolve platform alert."""
|
"""Resolve platform alert."""
|
||||||
# TODO: Implement
|
alert = platform_alert_service.resolve_alert(
|
||||||
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
|
db=db,
|
||||||
return MessageResponse(message="Alert resolved successfully")
|
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)
|
@router.get("/alerts/stats", response_model=AlertStatisticsResponse)
|
||||||
def get_alert_statistics(
|
def get_alert_statistics(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
) -> AlertStatisticsResponse:
|
||||||
"""Get alert statistics for dashboard."""
|
"""Get alert statistics for dashboard."""
|
||||||
# TODO: Implement
|
stats = platform_alert_service.get_statistics(db)
|
||||||
return AlertStatisticsResponse(
|
return AlertStatisticsResponse(**stats)
|
||||||
total_alerts=0,
|
|
||||||
active_alerts=0,
|
|
||||||
critical_alerts=0,
|
|
||||||
resolved_today=0,
|
|
||||||
)
|
|
||||||
|
|||||||
701
app/services/admin_notification_service.py
Normal file
701
app/services/admin_notification_service.py
Normal file
@@ -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()
|
||||||
361
app/templates/admin/notifications.html
Normal file
361
app/templates/admin/notifications.html
Normal file
@@ -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') }}
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<!-- Card: Unread Notifications -->
|
||||||
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
|
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||||
|
<span x-html="$icon('bell', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
Unread
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.unread_count || 0">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Active Alerts -->
|
||||||
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||||
|
<span x-html="$icon('exclamation-triangle', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
Active Alerts
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.active_alerts || 0">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Critical -->
|
||||||
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
|
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||||
|
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
Critical
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.critical_alerts || 0">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Resolved Today -->
|
||||||
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
|
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||||
|
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
Resolved Today
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.resolved_today || 0">
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div x-show="!loading" class="mb-6">
|
||||||
|
<div class="flex border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'notifications'"
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === 'notifications'
|
||||||
|
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
>
|
||||||
|
Notifications
|
||||||
|
<span x-show="stats.unread_count > 0"
|
||||||
|
class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full dark:bg-red-600 dark:text-white"
|
||||||
|
x-text="stats.unread_count"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'alerts'; loadAlerts()"
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === 'alerts'
|
||||||
|
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
>
|
||||||
|
Platform Alerts
|
||||||
|
<span x-show="alertStats.active_alerts > 0"
|
||||||
|
class="ml-2 px-2 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full dark:bg-orange-600 dark:text-white"
|
||||||
|
x-text="alertStats.active_alerts"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications Tab -->
|
||||||
|
<div x-show="!loading && activeTab === 'notifications'" class="space-y-4">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
x-model="filters.priority"
|
||||||
|
@change="page = 1; loadNotifications()"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
x-model="filters.is_read"
|
||||||
|
@change="page = 1; loadNotifications()"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="false">Unread</option>
|
||||||
|
<option value="true">Read</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
x-show="stats.unread_count > 0"
|
||||||
|
@click="markAllAsRead()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications List -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
|
||||||
|
<template x-if="loadingNotifications && notifications.length === 0">
|
||||||
|
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||||
|
<p>Loading notifications...</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!loadingNotifications && notifications.length === 0">
|
||||||
|
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-html="$icon('bell', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
|
||||||
|
<p class="font-medium">No notifications</p>
|
||||||
|
<p class="text-sm mt-1">You're all caught up!</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<template x-for="notif in notifications" :key="notif.id">
|
||||||
|
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
:class="notif.is_read ? 'opacity-60' : ''">
|
||||||
|
<div class="flex items-start px-4 py-4">
|
||||||
|
<!-- Priority indicator -->
|
||||||
|
<div class="flex-shrink-0 mr-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': notif.priority === 'critical',
|
||||||
|
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': notif.priority === 'high',
|
||||||
|
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': notif.priority === 'normal',
|
||||||
|
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300': notif.priority === 'low'
|
||||||
|
}">
|
||||||
|
<span x-html="getNotificationIcon(notif.type)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="notif.title"></p>
|
||||||
|
<span class="text-xs text-gray-400" x-text="formatDate(notif.created_at)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="notif.message"></p>
|
||||||
|
<div class="flex items-center gap-4 mt-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded"
|
||||||
|
:class="{
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': notif.priority === 'critical',
|
||||||
|
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': notif.priority === 'high',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': notif.priority === 'normal',
|
||||||
|
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200': notif.priority === 'low'
|
||||||
|
}"
|
||||||
|
x-text="notif.priority"></span>
|
||||||
|
<span class="text-xs text-gray-500" x-text="notif.type.replace('_', ' ')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 ml-4">
|
||||||
|
<template x-if="notif.action_url">
|
||||||
|
<a :href="notif.action_url"
|
||||||
|
class="px-3 py-1 text-xs font-medium text-purple-600 bg-purple-100 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template x-if="!notif.is_read">
|
||||||
|
<button @click="markAsRead(notif)"
|
||||||
|
class="px-3 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
Mark read
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button @click="deleteNotification(notif.id)"
|
||||||
|
class="p-1 text-gray-400 hover:text-red-500 transition-colors">
|
||||||
|
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div x-show="stats.total > limit" class="flex items-center justify-between px-4 py-3 border-t dark:border-gray-700">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Showing <span x-text="skip + 1"></span>-<span x-text="Math.min(skip + limit, stats.total)"></span> of <span x-text="stats.total"></span>
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="page--; loadNotifications()"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="page++; loadNotifications()"
|
||||||
|
:disabled="page * limit >= stats.total"
|
||||||
|
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts Tab -->
|
||||||
|
<div x-show="!loading && activeTab === 'alerts'" class="space-y-4">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
|
<select
|
||||||
|
x-model="alertFilters.severity"
|
||||||
|
@change="alertPage = 1; loadAlerts()"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="">All Severities</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
x-model="alertFilters.is_resolved"
|
||||||
|
@change="alertPage = 1; loadAlerts()"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="false">Active</option>
|
||||||
|
<option value="true">Resolved</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts List -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
|
||||||
|
<template x-if="loadingAlerts && alerts.length === 0">
|
||||||
|
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||||
|
<p>Loading alerts...</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!loadingAlerts && alerts.length === 0">
|
||||||
|
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-html="$icon('shield-check', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
|
||||||
|
<p class="font-medium">No alerts</p>
|
||||||
|
<p class="text-sm mt-1">All systems are running smoothly</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<template x-for="alert in alerts" :key="alert.id">
|
||||||
|
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
:class="alert.is_resolved ? 'opacity-60' : ''">
|
||||||
|
<div class="flex items-start px-4 py-4">
|
||||||
|
<!-- Severity indicator -->
|
||||||
|
<div class="flex-shrink-0 mr-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': alert.severity === 'critical',
|
||||||
|
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': alert.severity === 'error',
|
||||||
|
'bg-yellow-100 text-yellow-600 dark:bg-yellow-900 dark:text-yellow-300': alert.severity === 'warning',
|
||||||
|
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': alert.severity === 'info'
|
||||||
|
}">
|
||||||
|
<span x-html="$icon('exclamation-triangle', 'w-5 h-5')"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="alert.title"></p>
|
||||||
|
<template x-if="alert.occurrence_count > 1">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
x-text="alert.occurrence_count + 'x'"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="alert.description"></p>
|
||||||
|
<div class="flex items-center gap-4 mt-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded uppercase"
|
||||||
|
:class="{
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': alert.severity === 'critical',
|
||||||
|
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': alert.severity === 'error',
|
||||||
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': alert.severity === 'warning',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': alert.severity === 'info'
|
||||||
|
}"
|
||||||
|
x-text="alert.severity"></span>
|
||||||
|
<span class="text-xs text-gray-500" x-text="alert.alert_type"></span>
|
||||||
|
<span class="text-xs text-gray-400" x-text="'Last: ' + formatDate(alert.last_occurred_at)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 ml-4">
|
||||||
|
<template x-if="!alert.is_resolved">
|
||||||
|
<button @click="resolveAlert(alert)"
|
||||||
|
class="px-3 py-1 text-xs font-medium text-green-600 bg-green-100 rounded hover:bg-green-200 dark:bg-green-900 dark:text-green-300">
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template x-if="alert.is_resolved">
|
||||||
|
<span class="px-3 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded dark:bg-gray-700">
|
||||||
|
Resolved
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
|
<script src="{{ url_for('static', path='admin/js/notifications.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
187
docs/implementation/admin-notification-system.md
Normal file
187
docs/implementation/admin-notification-system.md
Normal file
@@ -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
|
||||||
295
static/admin/js/notifications.js
Normal file
295
static/admin/js/notifications.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user