- Implement notification service with email, webhook, and in-app support - Add security headers middleware (CORS, CSP, HSTS, XSS protection) - Create comprehensive audit logging service for security events - Add data validation utilities with Pydantic validators - Implement cache service with in-memory and Redis backend support All 714 tests passing
627 lines
20 KiB
Python
627 lines
20 KiB
Python
"""
|
|
Notification Service for AniWorld.
|
|
|
|
This module provides notification functionality including email, webhooks,
|
|
and in-app notifications for download events and system alerts.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
import aiohttp
|
|
from pydantic import BaseModel, EmailStr, Field, HttpUrl
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NotificationType(str, Enum):
|
|
"""Types of notifications."""
|
|
|
|
DOWNLOAD_COMPLETE = "download_complete"
|
|
DOWNLOAD_FAILED = "download_failed"
|
|
QUEUE_COMPLETE = "queue_complete"
|
|
SYSTEM_ERROR = "system_error"
|
|
SYSTEM_WARNING = "system_warning"
|
|
SYSTEM_INFO = "system_info"
|
|
|
|
|
|
class NotificationPriority(str, Enum):
|
|
"""Notification priority levels."""
|
|
|
|
LOW = "low"
|
|
NORMAL = "normal"
|
|
HIGH = "high"
|
|
CRITICAL = "critical"
|
|
|
|
|
|
class NotificationChannel(str, Enum):
|
|
"""Available notification channels."""
|
|
|
|
EMAIL = "email"
|
|
WEBHOOK = "webhook"
|
|
IN_APP = "in_app"
|
|
|
|
|
|
class NotificationPreferences(BaseModel):
|
|
"""User notification preferences."""
|
|
|
|
enabled_channels: Set[NotificationChannel] = Field(
|
|
default_factory=lambda: {NotificationChannel.IN_APP}
|
|
)
|
|
enabled_types: Set[NotificationType] = Field(
|
|
default_factory=lambda: set(NotificationType)
|
|
)
|
|
email_address: Optional[EmailStr] = None
|
|
webhook_urls: List[HttpUrl] = Field(default_factory=list)
|
|
quiet_hours_start: Optional[int] = Field(None, ge=0, le=23)
|
|
quiet_hours_end: Optional[int] = Field(None, ge=0, le=23)
|
|
min_priority: NotificationPriority = NotificationPriority.NORMAL
|
|
|
|
|
|
class Notification(BaseModel):
|
|
"""Notification model."""
|
|
|
|
id: str
|
|
type: NotificationType
|
|
priority: NotificationPriority
|
|
title: str
|
|
message: str
|
|
data: Optional[Dict[str, Any]] = None
|
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
read: bool = False
|
|
channels: Set[NotificationChannel] = Field(
|
|
default_factory=lambda: {NotificationChannel.IN_APP}
|
|
)
|
|
|
|
|
|
class EmailNotificationService:
|
|
"""Service for sending email notifications."""
|
|
|
|
def __init__(
|
|
self,
|
|
smtp_host: Optional[str] = None,
|
|
smtp_port: int = 587,
|
|
smtp_username: Optional[str] = None,
|
|
smtp_password: Optional[str] = None,
|
|
from_address: Optional[str] = None,
|
|
):
|
|
"""
|
|
Initialize email notification service.
|
|
|
|
Args:
|
|
smtp_host: SMTP server hostname
|
|
smtp_port: SMTP server port
|
|
smtp_username: SMTP authentication username
|
|
smtp_password: SMTP authentication password
|
|
from_address: Email sender address
|
|
"""
|
|
self.smtp_host = smtp_host
|
|
self.smtp_port = smtp_port
|
|
self.smtp_username = smtp_username
|
|
self.smtp_password = smtp_password
|
|
self.from_address = from_address
|
|
self._enabled = all(
|
|
[smtp_host, smtp_username, smtp_password, from_address]
|
|
)
|
|
|
|
async def send_email(
|
|
self, to_address: str, subject: str, body: str, html: bool = False
|
|
) -> bool:
|
|
"""
|
|
Send an email notification.
|
|
|
|
Args:
|
|
to_address: Recipient email address
|
|
subject: Email subject
|
|
body: Email body content
|
|
html: Whether body is HTML format
|
|
|
|
Returns:
|
|
True if email sent successfully
|
|
"""
|
|
if not self._enabled:
|
|
logger.warning("Email notifications not configured")
|
|
return False
|
|
|
|
try:
|
|
# Import here to make aiosmtplib optional
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
|
|
import aiosmtplib
|
|
|
|
message = MIMEMultipart("alternative")
|
|
message["Subject"] = subject
|
|
message["From"] = self.from_address
|
|
message["To"] = to_address
|
|
|
|
mime_type = "html" if html else "plain"
|
|
message.attach(MIMEText(body, mime_type))
|
|
|
|
await aiosmtplib.send(
|
|
message,
|
|
hostname=self.smtp_host,
|
|
port=self.smtp_port,
|
|
username=self.smtp_username,
|
|
password=self.smtp_password,
|
|
start_tls=True,
|
|
)
|
|
|
|
logger.info(f"Email notification sent to {to_address}")
|
|
return True
|
|
|
|
except ImportError:
|
|
logger.error(
|
|
"aiosmtplib not installed. Install with: pip install aiosmtplib"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Failed to send email notification: {e}")
|
|
return False
|
|
|
|
|
|
class WebhookNotificationService:
|
|
"""Service for sending webhook notifications."""
|
|
|
|
def __init__(self, timeout: int = 10, max_retries: int = 3):
|
|
"""
|
|
Initialize webhook notification service.
|
|
|
|
Args:
|
|
timeout: Request timeout in seconds
|
|
max_retries: Maximum number of retry attempts
|
|
"""
|
|
self.timeout = timeout
|
|
self.max_retries = max_retries
|
|
|
|
async def send_webhook(
|
|
self, url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None
|
|
) -> bool:
|
|
"""
|
|
Send a webhook notification.
|
|
|
|
Args:
|
|
url: Webhook URL
|
|
payload: JSON payload to send
|
|
headers: Optional custom headers
|
|
|
|
Returns:
|
|
True if webhook sent successfully
|
|
"""
|
|
if headers is None:
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
for attempt in range(self.max_retries):
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
url,
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
) as response:
|
|
if response.status < 400:
|
|
logger.info(f"Webhook notification sent to {url}")
|
|
return True
|
|
else:
|
|
logger.warning(
|
|
f"Webhook returned status {response.status}: {url}"
|
|
)
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"Webhook timeout (attempt {attempt + 1}/{self.max_retries}): {url}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send webhook (attempt {attempt + 1}/{self.max_retries}): {e}")
|
|
|
|
if attempt < self.max_retries - 1:
|
|
await asyncio.sleep(2 ** attempt) # Exponential backoff
|
|
|
|
return False
|
|
|
|
|
|
class InAppNotificationService:
|
|
"""Service for managing in-app notifications."""
|
|
|
|
def __init__(self, max_notifications: int = 100):
|
|
"""
|
|
Initialize in-app notification service.
|
|
|
|
Args:
|
|
max_notifications: Maximum number of notifications to keep
|
|
"""
|
|
self.notifications: List[Notification] = []
|
|
self.max_notifications = max_notifications
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def add_notification(self, notification: Notification) -> None:
|
|
"""
|
|
Add a notification to the in-app list.
|
|
|
|
Args:
|
|
notification: Notification to add
|
|
"""
|
|
async with self._lock:
|
|
self.notifications.insert(0, notification)
|
|
if len(self.notifications) > self.max_notifications:
|
|
self.notifications = self.notifications[: self.max_notifications]
|
|
|
|
async def get_notifications(
|
|
self, unread_only: bool = False, limit: Optional[int] = None
|
|
) -> List[Notification]:
|
|
"""
|
|
Get in-app notifications.
|
|
|
|
Args:
|
|
unread_only: Only return unread notifications
|
|
limit: Maximum number of notifications to return
|
|
|
|
Returns:
|
|
List of notifications
|
|
"""
|
|
async with self._lock:
|
|
notifications = self.notifications
|
|
if unread_only:
|
|
notifications = [n for n in notifications if not n.read]
|
|
if limit:
|
|
notifications = notifications[:limit]
|
|
return notifications.copy()
|
|
|
|
async def mark_as_read(self, notification_id: str) -> bool:
|
|
"""
|
|
Mark a notification as read.
|
|
|
|
Args:
|
|
notification_id: ID of notification to mark
|
|
|
|
Returns:
|
|
True if notification was found and marked
|
|
"""
|
|
async with self._lock:
|
|
for notification in self.notifications:
|
|
if notification.id == notification_id:
|
|
notification.read = True
|
|
return True
|
|
return False
|
|
|
|
async def mark_all_as_read(self) -> int:
|
|
"""
|
|
Mark all notifications as read.
|
|
|
|
Returns:
|
|
Number of notifications marked as read
|
|
"""
|
|
async with self._lock:
|
|
count = 0
|
|
for notification in self.notifications:
|
|
if not notification.read:
|
|
notification.read = True
|
|
count += 1
|
|
return count
|
|
|
|
async def clear_notifications(self, read_only: bool = True) -> int:
|
|
"""
|
|
Clear notifications.
|
|
|
|
Args:
|
|
read_only: Only clear read notifications
|
|
|
|
Returns:
|
|
Number of notifications cleared
|
|
"""
|
|
async with self._lock:
|
|
if read_only:
|
|
initial_count = len(self.notifications)
|
|
self.notifications = [n for n in self.notifications if not n.read]
|
|
return initial_count - len(self.notifications)
|
|
else:
|
|
count = len(self.notifications)
|
|
self.notifications.clear()
|
|
return count
|
|
|
|
|
|
class NotificationService:
|
|
"""Main notification service coordinating all notification channels."""
|
|
|
|
def __init__(
|
|
self,
|
|
email_service: Optional[EmailNotificationService] = None,
|
|
webhook_service: Optional[WebhookNotificationService] = None,
|
|
in_app_service: Optional[InAppNotificationService] = None,
|
|
):
|
|
"""
|
|
Initialize notification service.
|
|
|
|
Args:
|
|
email_service: Email notification service instance
|
|
webhook_service: Webhook notification service instance
|
|
in_app_service: In-app notification service instance
|
|
"""
|
|
self.email_service = email_service or EmailNotificationService()
|
|
self.webhook_service = webhook_service or WebhookNotificationService()
|
|
self.in_app_service = in_app_service or InAppNotificationService()
|
|
self.preferences = NotificationPreferences()
|
|
|
|
def set_preferences(self, preferences: NotificationPreferences) -> None:
|
|
"""
|
|
Update notification preferences.
|
|
|
|
Args:
|
|
preferences: New notification preferences
|
|
"""
|
|
self.preferences = preferences
|
|
|
|
def _is_in_quiet_hours(self) -> bool:
|
|
"""
|
|
Check if current time is within quiet hours.
|
|
|
|
Returns:
|
|
True if in quiet hours
|
|
"""
|
|
if (
|
|
self.preferences.quiet_hours_start is None
|
|
or self.preferences.quiet_hours_end is None
|
|
):
|
|
return False
|
|
|
|
current_hour = datetime.now().hour
|
|
start = self.preferences.quiet_hours_start
|
|
end = self.preferences.quiet_hours_end
|
|
|
|
if start <= end:
|
|
return start <= current_hour < end
|
|
else: # Quiet hours span midnight
|
|
return current_hour >= start or current_hour < end
|
|
|
|
def _should_send_notification(
|
|
self, notification_type: NotificationType, priority: NotificationPriority
|
|
) -> bool:
|
|
"""
|
|
Determine if a notification should be sent based on preferences.
|
|
|
|
Args:
|
|
notification_type: Type of notification
|
|
priority: Priority level
|
|
|
|
Returns:
|
|
True if notification should be sent
|
|
"""
|
|
# Check if type is enabled
|
|
if notification_type not in self.preferences.enabled_types:
|
|
return False
|
|
|
|
# Check priority level
|
|
priority_order = [
|
|
NotificationPriority.LOW,
|
|
NotificationPriority.NORMAL,
|
|
NotificationPriority.HIGH,
|
|
NotificationPriority.CRITICAL,
|
|
]
|
|
if (
|
|
priority_order.index(priority)
|
|
< priority_order.index(self.preferences.min_priority)
|
|
):
|
|
return False
|
|
|
|
# Check quiet hours (critical notifications bypass quiet hours)
|
|
if priority != NotificationPriority.CRITICAL and self._is_in_quiet_hours():
|
|
return False
|
|
|
|
return True
|
|
|
|
async def send_notification(self, notification: Notification) -> Dict[str, bool]:
|
|
"""
|
|
Send a notification through enabled channels.
|
|
|
|
Args:
|
|
notification: Notification to send
|
|
|
|
Returns:
|
|
Dictionary mapping channel names to success status
|
|
"""
|
|
if not self._should_send_notification(notification.type, notification.priority):
|
|
logger.debug(
|
|
f"Notification not sent due to preferences: {notification.type}"
|
|
)
|
|
return {}
|
|
|
|
results = {}
|
|
|
|
# Send in-app notification
|
|
if NotificationChannel.IN_APP in self.preferences.enabled_channels:
|
|
try:
|
|
await self.in_app_service.add_notification(notification)
|
|
results["in_app"] = True
|
|
except Exception as e:
|
|
logger.error(f"Failed to send in-app notification: {e}")
|
|
results["in_app"] = False
|
|
|
|
# Send email notification
|
|
if (
|
|
NotificationChannel.EMAIL in self.preferences.enabled_channels
|
|
and self.preferences.email_address
|
|
):
|
|
try:
|
|
success = await self.email_service.send_email(
|
|
to_address=self.preferences.email_address,
|
|
subject=f"[{notification.priority.upper()}] {notification.title}",
|
|
body=notification.message,
|
|
)
|
|
results["email"] = success
|
|
except Exception as e:
|
|
logger.error(f"Failed to send email notification: {e}")
|
|
results["email"] = False
|
|
|
|
# Send webhook notifications
|
|
if (
|
|
NotificationChannel.WEBHOOK in self.preferences.enabled_channels
|
|
and self.preferences.webhook_urls
|
|
):
|
|
payload = {
|
|
"id": notification.id,
|
|
"type": notification.type,
|
|
"priority": notification.priority,
|
|
"title": notification.title,
|
|
"message": notification.message,
|
|
"data": notification.data,
|
|
"created_at": notification.created_at.isoformat(),
|
|
}
|
|
|
|
webhook_results = []
|
|
for url in self.preferences.webhook_urls:
|
|
try:
|
|
success = await self.webhook_service.send_webhook(str(url), payload)
|
|
webhook_results.append(success)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send webhook notification to {url}: {e}")
|
|
webhook_results.append(False)
|
|
|
|
results["webhook"] = all(webhook_results) if webhook_results else False
|
|
|
|
return results
|
|
|
|
async def notify_download_complete(
|
|
self, series_name: str, episode: str, file_path: str
|
|
) -> Dict[str, bool]:
|
|
"""
|
|
Send notification for completed download.
|
|
|
|
Args:
|
|
series_name: Name of the series
|
|
episode: Episode identifier
|
|
file_path: Path to downloaded file
|
|
|
|
Returns:
|
|
Dictionary of send results by channel
|
|
"""
|
|
notification = Notification(
|
|
id=f"download_complete_{datetime.utcnow().timestamp()}",
|
|
type=NotificationType.DOWNLOAD_COMPLETE,
|
|
priority=NotificationPriority.NORMAL,
|
|
title=f"Download Complete: {series_name}",
|
|
message=f"Episode {episode} has been downloaded successfully.",
|
|
data={
|
|
"series_name": series_name,
|
|
"episode": episode,
|
|
"file_path": file_path,
|
|
},
|
|
)
|
|
return await self.send_notification(notification)
|
|
|
|
async def notify_download_failed(
|
|
self, series_name: str, episode: str, error: str
|
|
) -> Dict[str, bool]:
|
|
"""
|
|
Send notification for failed download.
|
|
|
|
Args:
|
|
series_name: Name of the series
|
|
episode: Episode identifier
|
|
error: Error message
|
|
|
|
Returns:
|
|
Dictionary of send results by channel
|
|
"""
|
|
notification = Notification(
|
|
id=f"download_failed_{datetime.utcnow().timestamp()}",
|
|
type=NotificationType.DOWNLOAD_FAILED,
|
|
priority=NotificationPriority.HIGH,
|
|
title=f"Download Failed: {series_name}",
|
|
message=f"Episode {episode} failed to download: {error}",
|
|
data={"series_name": series_name, "episode": episode, "error": error},
|
|
)
|
|
return await self.send_notification(notification)
|
|
|
|
async def notify_queue_complete(self, total_downloads: int) -> Dict[str, bool]:
|
|
"""
|
|
Send notification for completed download queue.
|
|
|
|
Args:
|
|
total_downloads: Number of downloads completed
|
|
|
|
Returns:
|
|
Dictionary of send results by channel
|
|
"""
|
|
notification = Notification(
|
|
id=f"queue_complete_{datetime.utcnow().timestamp()}",
|
|
type=NotificationType.QUEUE_COMPLETE,
|
|
priority=NotificationPriority.NORMAL,
|
|
title="Download Queue Complete",
|
|
message=f"All {total_downloads} downloads have been completed.",
|
|
data={"total_downloads": total_downloads},
|
|
)
|
|
return await self.send_notification(notification)
|
|
|
|
async def notify_system_error(self, error: str, details: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
|
|
"""
|
|
Send notification for system error.
|
|
|
|
Args:
|
|
error: Error message
|
|
details: Optional error details
|
|
|
|
Returns:
|
|
Dictionary of send results by channel
|
|
"""
|
|
notification = Notification(
|
|
id=f"system_error_{datetime.utcnow().timestamp()}",
|
|
type=NotificationType.SYSTEM_ERROR,
|
|
priority=NotificationPriority.CRITICAL,
|
|
title="System Error",
|
|
message=error,
|
|
data=details,
|
|
)
|
|
return await self.send_notification(notification)
|
|
|
|
|
|
# Global notification service instance
|
|
_notification_service: Optional[NotificationService] = None
|
|
|
|
|
|
def get_notification_service() -> NotificationService:
|
|
"""
|
|
Get the global notification service instance.
|
|
|
|
Returns:
|
|
NotificationService instance
|
|
"""
|
|
global _notification_service
|
|
if _notification_service is None:
|
|
_notification_service = NotificationService()
|
|
return _notification_service
|
|
|
|
|
|
def configure_notification_service(
|
|
smtp_host: Optional[str] = None,
|
|
smtp_port: int = 587,
|
|
smtp_username: Optional[str] = None,
|
|
smtp_password: Optional[str] = None,
|
|
from_address: Optional[str] = None,
|
|
) -> NotificationService:
|
|
"""
|
|
Configure the global notification service.
|
|
|
|
Args:
|
|
smtp_host: SMTP server hostname
|
|
smtp_port: SMTP server port
|
|
smtp_username: SMTP authentication username
|
|
smtp_password: SMTP authentication password
|
|
from_address: Email sender address
|
|
|
|
Returns:
|
|
Configured NotificationService instance
|
|
"""
|
|
global _notification_service
|
|
email_service = EmailNotificationService(
|
|
smtp_host=smtp_host,
|
|
smtp_port=smtp_port,
|
|
smtp_username=smtp_username,
|
|
smtp_password=smtp_password,
|
|
from_address=from_address,
|
|
)
|
|
_notification_service = NotificationService(email_service=email_service)
|
|
return _notification_service
|