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.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)
|
||||
|
||||
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