mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 12:15:26 -05:00
feat: Implement a new notifications application, add admin API views for dashboard metrics, introduce scheduled tasks, and update API routing and project configurations.
This commit is contained in:
571
backend/apps/notifications/services.py
Normal file
571
backend/apps/notifications/services.py
Normal file
@@ -0,0 +1,571 @@
|
||||
"""
|
||||
Django-native notification service.
|
||||
|
||||
This service provides a fully Django-native notification system. Supports:
|
||||
- In-app notifications
|
||||
- Email notifications (via Django email backend)
|
||||
- Real-time notifications (ready for Django Channels integration)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.mail import send_mail
|
||||
from django.db.models import QuerySet
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
from .models import Notification, NotificationLog, NotificationPreference, SystemAnnouncement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""
|
||||
Django-native notification service using django-notifications-hq.
|
||||
|
||||
This replaces the Novu-based service with a fully Django-native approach.
|
||||
"""
|
||||
|
||||
# Notification workflow types
|
||||
WORKFLOW_SUBMISSION_STATUS = "submission_status"
|
||||
WORKFLOW_MODERATION_ALERT = "moderation_alert"
|
||||
WORKFLOW_SYSTEM_ANNOUNCEMENT = "system_announcement"
|
||||
WORKFLOW_ADMIN_ALERT = "admin_alert"
|
||||
WORKFLOW_WELCOME = "welcome"
|
||||
WORKFLOW_COMMENT_REPLY = "comment_reply"
|
||||
WORKFLOW_MENTION = "mention"
|
||||
WORKFLOW_FOLLOW = "follow"
|
||||
|
||||
def __init__(self):
|
||||
self.from_email = getattr(
|
||||
settings, "DEFAULT_FROM_EMAIL", "noreply@thrillwiki.com"
|
||||
)
|
||||
self.site_name = getattr(settings, "SITE_NAME", "ThrillWiki")
|
||||
self.site_url = getattr(settings, "SITE_URL", "https://thrillwiki.com")
|
||||
|
||||
def send_notification(
|
||||
self,
|
||||
recipient: User,
|
||||
actor: User | None,
|
||||
verb: str,
|
||||
action_object: Any = None,
|
||||
target: Any = None,
|
||||
description: str = "",
|
||||
level: str = "info",
|
||||
data: dict | None = None,
|
||||
send_email: bool = True,
|
||||
email_template: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send a notification to a user.
|
||||
|
||||
Args:
|
||||
recipient: The user to notify
|
||||
actor: The user who performed the action (can be None for system notifications)
|
||||
verb: Description of the action (e.g., "approved your submission")
|
||||
action_object: The object that was acted upon
|
||||
target: The target of the action
|
||||
description: Additional description text
|
||||
level: Notification level (info, success, warning, error)
|
||||
data: Additional data to store with the notification
|
||||
send_email: Whether to also send an email notification
|
||||
email_template: Template path for email (optional)
|
||||
|
||||
Returns:
|
||||
True if notification was sent successfully
|
||||
"""
|
||||
try:
|
||||
# Check user preferences
|
||||
if self._is_user_opted_out(recipient):
|
||||
logger.debug(f"User {recipient.id} opted out of notifications")
|
||||
return False
|
||||
|
||||
# Create in-app notification using our native model
|
||||
notification_data = {
|
||||
"recipient": recipient,
|
||||
"actor": actor,
|
||||
"verb": verb,
|
||||
"description": description,
|
||||
"level": level,
|
||||
"data": data or {},
|
||||
}
|
||||
|
||||
# Add generic foreign key for action_object if provided
|
||||
if action_object:
|
||||
notification_data["action_object_content_type"] = ContentType.objects.get_for_model(action_object)
|
||||
notification_data["action_object_id"] = action_object.pk
|
||||
|
||||
# Add generic foreign key for target if provided
|
||||
if target:
|
||||
notification_data["target_content_type"] = ContentType.objects.get_for_model(target)
|
||||
notification_data["target_id"] = target.pk
|
||||
|
||||
Notification.objects.create(**notification_data)
|
||||
|
||||
# Log the notification
|
||||
self._log_notification(
|
||||
user=recipient,
|
||||
workflow_id=data.get("workflow_id", "general") if data else "general",
|
||||
notification_type=level,
|
||||
channel="in_app",
|
||||
status=NotificationLog.Status.SENT,
|
||||
payload=data or {},
|
||||
)
|
||||
|
||||
# Optionally send email
|
||||
if send_email and self._should_send_email(recipient, data):
|
||||
self._send_email_notification(
|
||||
recipient=recipient,
|
||||
verb=verb,
|
||||
actor=actor,
|
||||
action_object=action_object,
|
||||
target=target,
|
||||
description=description,
|
||||
template=email_template,
|
||||
data=data,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send notification to {recipient.id}: {e}")
|
||||
self._log_notification(
|
||||
user=recipient,
|
||||
workflow_id=data.get("workflow_id", "general") if data else "general",
|
||||
notification_type=level,
|
||||
channel="in_app",
|
||||
status=NotificationLog.Status.FAILED,
|
||||
payload=data or {},
|
||||
error_message=str(e),
|
||||
)
|
||||
return False
|
||||
|
||||
def send_to_group(
|
||||
self,
|
||||
recipients: QuerySet | list,
|
||||
actor: User | None,
|
||||
verb: str,
|
||||
action_object: Any = None,
|
||||
target: Any = None,
|
||||
description: str = "",
|
||||
level: str = "info",
|
||||
data: dict | None = None,
|
||||
send_email: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Send a notification to multiple users.
|
||||
|
||||
Returns:
|
||||
Dict with success/failure counts
|
||||
"""
|
||||
results = {"success": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
for recipient in recipients:
|
||||
if self._is_user_opted_out(recipient):
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
|
||||
success = self.send_notification(
|
||||
recipient=recipient,
|
||||
actor=actor,
|
||||
verb=verb,
|
||||
action_object=action_object,
|
||||
target=target,
|
||||
description=description,
|
||||
level=level,
|
||||
data=data,
|
||||
send_email=send_email,
|
||||
)
|
||||
|
||||
if success:
|
||||
results["success"] += 1
|
||||
else:
|
||||
results["failed"] += 1
|
||||
|
||||
return results
|
||||
|
||||
def notify_moderators(
|
||||
self,
|
||||
verb: str,
|
||||
action_object: Any = None,
|
||||
description: str = "",
|
||||
data: dict | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Send a notification to all moderators.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
|
||||
# Get users with moderator permissions
|
||||
moderators = User.objects.filter(
|
||||
is_active=True,
|
||||
is_staff=True, # Or use a specific permission check
|
||||
).exclude(
|
||||
novu_notification_prefs__is_opted_out=True
|
||||
)
|
||||
|
||||
return self.send_to_group(
|
||||
recipients=moderators,
|
||||
actor=None,
|
||||
verb=verb,
|
||||
action_object=action_object,
|
||||
description=description,
|
||||
level="info",
|
||||
data={**(data or {}), "workflow_id": self.WORKFLOW_MODERATION_ALERT},
|
||||
send_email=True,
|
||||
)
|
||||
|
||||
def notify_admins(
|
||||
self,
|
||||
verb: str,
|
||||
description: str = "",
|
||||
level: str = "warning",
|
||||
data: dict | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Send a notification to all admins.
|
||||
"""
|
||||
admins = User.objects.filter(is_superuser=True, is_active=True)
|
||||
|
||||
return self.send_to_group(
|
||||
recipients=admins,
|
||||
actor=None,
|
||||
verb=verb,
|
||||
description=description,
|
||||
level=level,
|
||||
data={**(data or {}), "workflow_id": self.WORKFLOW_ADMIN_ALERT},
|
||||
send_email=True,
|
||||
)
|
||||
|
||||
def send_system_announcement(
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
severity: str = "info",
|
||||
action_url: str = "",
|
||||
target_users: QuerySet | None = None,
|
||||
created_by: User | None = None,
|
||||
) -> SystemAnnouncement:
|
||||
"""
|
||||
Create and broadcast a system announcement.
|
||||
"""
|
||||
# Create the announcement
|
||||
announcement = SystemAnnouncement.objects.create(
|
||||
title=title,
|
||||
message=message,
|
||||
severity=severity,
|
||||
action_url=action_url,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Notify users
|
||||
recipients = target_users or User.objects.filter(is_active=True)
|
||||
|
||||
self.send_to_group(
|
||||
recipients=recipients,
|
||||
actor=created_by,
|
||||
verb=f"System announcement: {title}",
|
||||
action_object=announcement,
|
||||
description=message,
|
||||
level=severity,
|
||||
data={
|
||||
"workflow_id": self.WORKFLOW_SYSTEM_ANNOUNCEMENT,
|
||||
"announcement_id": str(announcement.id),
|
||||
"action_url": action_url,
|
||||
},
|
||||
send_email=severity in ["warning", "critical"],
|
||||
)
|
||||
|
||||
return announcement
|
||||
|
||||
def get_user_notifications(
|
||||
self,
|
||||
user: User,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""
|
||||
Get notifications for a user.
|
||||
"""
|
||||
qs = Notification.objects.filter(recipient=user)
|
||||
|
||||
if unread_only:
|
||||
qs = qs.unread()
|
||||
|
||||
return qs[:limit]
|
||||
|
||||
def mark_as_read(self, user: User, notification_id: int | None = None):
|
||||
"""
|
||||
Mark notification(s) as read.
|
||||
"""
|
||||
if notification_id:
|
||||
try:
|
||||
notification = Notification.objects.get(recipient=user, id=notification_id)
|
||||
notification.mark_as_read()
|
||||
except Notification.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
# Mark all as read
|
||||
Notification.objects.filter(recipient=user).mark_all_as_read()
|
||||
|
||||
def get_unread_count(self, user: User) -> int:
|
||||
"""
|
||||
Get count of unread notifications.
|
||||
"""
|
||||
return Notification.objects.filter(recipient=user, unread=True).count()
|
||||
|
||||
def _is_user_opted_out(self, user: User) -> bool:
|
||||
"""Check if user has opted out of notifications."""
|
||||
try:
|
||||
prefs = NotificationPreference.objects.get(user=user)
|
||||
return prefs.is_opted_out
|
||||
except NotificationPreference.DoesNotExist:
|
||||
return False
|
||||
|
||||
def _should_send_email(self, user: User, data: dict | None) -> bool:
|
||||
"""Check if email should be sent based on user preferences."""
|
||||
try:
|
||||
prefs = NotificationPreference.objects.get(user=user)
|
||||
|
||||
# Check channel preferences
|
||||
channel_prefs = prefs.channel_preferences or {}
|
||||
email_enabled = channel_prefs.get("email", True)
|
||||
|
||||
if not email_enabled:
|
||||
return False
|
||||
|
||||
# Check workflow-specific preferences
|
||||
if data and "workflow_id" in data:
|
||||
workflow_prefs = prefs.workflow_preferences or {}
|
||||
workflow_email = workflow_prefs.get(data["workflow_id"], {}).get("email", True)
|
||||
return workflow_email
|
||||
|
||||
return True
|
||||
|
||||
except NotificationPreference.DoesNotExist:
|
||||
# Default to sending email if no preferences set
|
||||
return True
|
||||
|
||||
def _send_email_notification(
|
||||
self,
|
||||
recipient: User,
|
||||
verb: str,
|
||||
actor: User | None,
|
||||
action_object: Any,
|
||||
target: Any,
|
||||
description: str,
|
||||
template: str | None,
|
||||
data: dict | None,
|
||||
):
|
||||
"""Send an email notification."""
|
||||
try:
|
||||
# Build context
|
||||
context = {
|
||||
"recipient": recipient,
|
||||
"actor": actor,
|
||||
"verb": verb,
|
||||
"action_object": action_object,
|
||||
"target": target,
|
||||
"description": description,
|
||||
"site_name": self.site_name,
|
||||
"site_url": self.site_url,
|
||||
"data": data or {},
|
||||
}
|
||||
|
||||
# Render email
|
||||
if template:
|
||||
html_content = render_to_string(template, context)
|
||||
text_content = strip_tags(html_content)
|
||||
else:
|
||||
# Default simple email
|
||||
actor_name = actor.username if actor else self.site_name
|
||||
subject = f"{actor_name} {verb}"
|
||||
text_content = description or f"{actor_name} {verb}"
|
||||
html_content = f"<p>{text_content}</p>"
|
||||
|
||||
if data and data.get("action_url"):
|
||||
html_content += f'<p><a href="{data["action_url"]}">View details</a></p>'
|
||||
|
||||
subject = f"[{self.site_name}] {verb[:50]}"
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=text_content,
|
||||
from_email=self.from_email,
|
||||
recipient_list=[recipient.email],
|
||||
html_message=html_content,
|
||||
fail_silently=True,
|
||||
)
|
||||
|
||||
# Log email notification
|
||||
self._log_notification(
|
||||
user=recipient,
|
||||
workflow_id=data.get("workflow_id", "general") if data else "general",
|
||||
notification_type="email",
|
||||
channel="email",
|
||||
status=NotificationLog.Status.SENT,
|
||||
payload=data or {},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send email to {recipient.email}: {e}")
|
||||
self._log_notification(
|
||||
user=recipient,
|
||||
workflow_id=data.get("workflow_id", "general") if data else "general",
|
||||
notification_type="email",
|
||||
channel="email",
|
||||
status=NotificationLog.Status.FAILED,
|
||||
payload=data or {},
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
def _log_notification(
|
||||
self,
|
||||
user: User,
|
||||
workflow_id: str,
|
||||
notification_type: str,
|
||||
channel: str,
|
||||
status: str,
|
||||
payload: dict,
|
||||
error_message: str = "",
|
||||
):
|
||||
"""Log a notification to the audit trail."""
|
||||
NotificationLog.objects.create(
|
||||
user=user,
|
||||
workflow_id=workflow_id,
|
||||
notification_type=notification_type,
|
||||
channel=channel,
|
||||
status=status,
|
||||
payload=payload,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
notification_service = NotificationService()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Backward compatibility - keep old NovuService interface but delegate to native
|
||||
# ============================================================================
|
||||
|
||||
class NovuServiceSync:
|
||||
"""
|
||||
Backward-compatible wrapper that delegates to the new notification service.
|
||||
|
||||
This maintains the old API signature for existing code while using
|
||||
the new Django-native implementation.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._service = notification_service
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Always configured since we're using Django-native system."""
|
||||
return True
|
||||
|
||||
def create_subscriber(self, subscriber_id: str, **kwargs) -> dict[str, Any]:
|
||||
"""Create subscriber - now a no-op as django-notifications-hq uses User directly."""
|
||||
logger.info(f"Subscriber creation not needed for django-notifications-hq: {subscriber_id}")
|
||||
return {"subscriberId": subscriber_id, "status": "native"}
|
||||
|
||||
def update_subscriber(self, subscriber_id: str, **kwargs) -> dict[str, Any]:
|
||||
"""Update subscriber - now a no-op."""
|
||||
logger.info(f"Subscriber update not needed for django-notifications-hq: {subscriber_id}")
|
||||
return {"subscriberId": subscriber_id, "status": "native"}
|
||||
|
||||
def trigger_notification(
|
||||
self,
|
||||
workflow_id: str,
|
||||
subscriber_id: str,
|
||||
payload: dict | None = None,
|
||||
overrides: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Trigger a notification using the new native service."""
|
||||
try:
|
||||
user = User.objects.get(pk=subscriber_id)
|
||||
|
||||
verb = payload.get("message", f"Notification: {workflow_id}") if payload else f"Notification: {workflow_id}"
|
||||
description = payload.get("description", "") if payload else ""
|
||||
|
||||
success = self._service.send_notification(
|
||||
recipient=user,
|
||||
actor=None,
|
||||
verb=verb,
|
||||
description=description,
|
||||
data={**(payload or {}), "workflow_id": workflow_id},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "sent" if success else "failed",
|
||||
"workflow_id": workflow_id,
|
||||
}
|
||||
except User.DoesNotExist:
|
||||
logger.error(f"User not found for notification: {subscriber_id}")
|
||||
return {"status": "failed", "error": "User not found"}
|
||||
|
||||
def trigger_topic_notification(
|
||||
self,
|
||||
workflow_id: str,
|
||||
topic_key: str,
|
||||
payload: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Trigger topic notification - maps to group notification."""
|
||||
logger.info(f"Topic notification: {workflow_id} -> {topic_key}")
|
||||
|
||||
# Map topic keys to user groups
|
||||
if topic_key == "moderators":
|
||||
result = self._service.notify_moderators(
|
||||
verb=payload.get("message", "New moderation task") if payload else "New moderation task",
|
||||
data={**(payload or {}), "workflow_id": workflow_id},
|
||||
)
|
||||
elif topic_key == "admins":
|
||||
result = self._service.notify_admins(
|
||||
verb=payload.get("message", "Admin notification") if payload else "Admin notification",
|
||||
data={**(payload or {}), "workflow_id": workflow_id},
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unknown topic key: {topic_key}")
|
||||
result = {"success": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
return {
|
||||
"status": "sent",
|
||||
"workflow_id": workflow_id,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
def update_preferences(
|
||||
self,
|
||||
subscriber_id: str,
|
||||
preferences: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Update notification preferences."""
|
||||
try:
|
||||
user = User.objects.get(pk=subscriber_id)
|
||||
prefs, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
|
||||
if "channel_preferences" in preferences:
|
||||
prefs.channel_preferences = preferences["channel_preferences"]
|
||||
if "workflow_preferences" in preferences:
|
||||
prefs.workflow_preferences = preferences["workflow_preferences"]
|
||||
if "is_opted_out" in preferences:
|
||||
prefs.is_opted_out = preferences["is_opted_out"]
|
||||
|
||||
prefs.save()
|
||||
|
||||
return {"status": "updated"}
|
||||
except User.DoesNotExist:
|
||||
return {"status": "failed", "error": "User not found"}
|
||||
|
||||
|
||||
# Keep old name for backward compatibility
|
||||
novu_service = NovuServiceSync()
|
||||
Reference in New Issue
Block a user