mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 16:15:19 -05:00
572 lines
19 KiB
Python
572 lines
19 KiB
Python
"""
|
|
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()
|