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:
pacnpal
2026-01-05 09:50:00 -05:00
parent 1c6e219662
commit a801813dcf
27 changed files with 3829 additions and 131 deletions

View File

@@ -0,0 +1,10 @@
"""
Notifications app for ThrillWiki.
Provides notification management including:
- Subscriber management (Novu integration)
- Notification preferences
- Notification triggering and logging
"""
default_app_config = "apps.notifications.apps.NotificationsConfig"

View File

@@ -0,0 +1,38 @@
"""
Notifications admin configuration.
"""
from django.contrib import admin
from .models import NotificationLog, NotificationPreference, Subscriber, SystemAnnouncement
@admin.register(Subscriber)
class SubscriberAdmin(admin.ModelAdmin):
list_display = ["user", "novu_subscriber_id", "email", "created_at"]
search_fields = ["user__username", "novu_subscriber_id", "email"]
readonly_fields = ["created_at", "updated_at"]
@admin.register(NotificationPreference)
class NotificationPreferenceAdmin(admin.ModelAdmin):
list_display = ["user", "is_opted_out", "updated_at"]
list_filter = ["is_opted_out"]
search_fields = ["user__username"]
readonly_fields = ["created_at", "updated_at"]
@admin.register(NotificationLog)
class NotificationLogAdmin(admin.ModelAdmin):
list_display = ["workflow_id", "user", "channel", "status", "created_at"]
list_filter = ["status", "channel", "workflow_id"]
search_fields = ["user__username", "workflow_id", "novu_transaction_id"]
readonly_fields = ["created_at", "updated_at"]
@admin.register(SystemAnnouncement)
class SystemAnnouncementAdmin(admin.ModelAdmin):
list_display = ["title", "severity", "is_active", "created_by", "created_at"]
list_filter = ["severity", "is_active"]
search_fields = ["title", "message"]
readonly_fields = ["created_at"]

View File

@@ -0,0 +1,18 @@
"""
Notifications app configuration.
This app provides Django-native notification functionality for ThrillWiki,
including in-app notifications, email notifications, and user preferences.
"""
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
"""Configuration for the ThrillWiki notifications app."""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.notifications"
verbose_name = "Notifications"

View File

@@ -0,0 +1,159 @@
# Generated by Django 5.2.9 on 2026-01-05 13:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="NotificationPreference",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"channel_preferences",
models.JSONField(
blank=True, default=dict, help_text="Preferences per channel (email, push, in_app, sms)"
),
),
(
"workflow_preferences",
models.JSONField(blank=True, default=dict, help_text="Preferences per notification workflow"),
),
(
"frequency_settings",
models.JSONField(blank=True, default=dict, help_text="Digest and frequency settings"),
),
("is_opted_out", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="novu_notification_prefs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Notification Preference",
"verbose_name_plural": "Notification Preferences",
},
),
migrations.CreateModel(
name="Subscriber",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("novu_subscriber_id", models.CharField(db_index=True, max_length=255, unique=True)),
("first_name", models.CharField(blank=True, max_length=100)),
("last_name", models.CharField(blank=True, max_length=100)),
("email", models.EmailField(blank=True, max_length=254)),
("phone", models.CharField(blank=True, max_length=20)),
("avatar", models.URLField(blank=True)),
("locale", models.CharField(default="en", max_length=10)),
("data", models.JSONField(blank=True, default=dict, help_text="Custom subscriber data")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_subscriber",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Notification Subscriber",
"verbose_name_plural": "Notification Subscribers",
},
),
migrations.CreateModel(
name="SystemAnnouncement",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=255)),
("message", models.TextField()),
(
"severity",
models.CharField(
choices=[("info", "Information"), ("warning", "Warning"), ("critical", "Critical")],
default="info",
max_length=20,
),
),
("action_url", models.URLField(blank=True)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="announcements_created",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "System Announcement",
"verbose_name_plural": "System Announcements",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="NotificationLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("workflow_id", models.CharField(db_index=True, max_length=100)),
("notification_type", models.CharField(max_length=50)),
("channel", models.CharField(max_length=20)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("sent", "Sent"),
("delivered", "Delivered"),
("failed", "Failed"),
],
default="pending",
max_length=20,
),
),
("payload", models.JSONField(blank=True, default=dict)),
("error_message", models.TextField(blank=True)),
("novu_transaction_id", models.CharField(blank=True, db_index=True, max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="notification_logs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Notification Log",
"verbose_name_plural": "Notification Logs",
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["user", "-created_at"], name="notificatio_user_id_57d53d_idx"),
models.Index(fields=["workflow_id", "-created_at"], name="notificatio_workflo_e1a025_idx"),
],
},
),
]

View File

@@ -0,0 +1,93 @@
# Generated by Django 5.2.9 on 2026-01-05 14:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("notifications", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="subscriber",
name="novu_subscriber_id",
field=models.CharField(
db_index=True, help_text="Legacy Novu subscriber ID (deprecated)", max_length=255, unique=True
),
),
migrations.CreateModel(
name="Notification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("verb", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"level",
models.CharField(
choices=[("info", "Info"), ("success", "Success"), ("warning", "Warning"), ("error", "Error")],
default="info",
max_length=20,
),
),
("action_object_id", models.PositiveIntegerField(blank=True, null=True)),
("target_id", models.PositiveIntegerField(blank=True, null=True)),
("data", models.JSONField(blank=True, default=dict)),
("unread", models.BooleanField(db_index=True, default=True)),
("timestamp", models.DateTimeField(auto_now_add=True)),
("read_at", models.DateTimeField(blank=True, null=True)),
(
"action_object_content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_action_objects",
to="contenttypes.contenttype",
),
),
(
"actor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="notifications_sent",
to=settings.AUTH_USER_MODEL,
),
),
(
"recipient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="in_app_notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"target_content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_targets",
to="contenttypes.contenttype",
),
),
],
options={
"verbose_name": "Notification",
"verbose_name_plural": "Notifications",
"ordering": ["-timestamp"],
"indexes": [
models.Index(fields=["recipient", "-timestamp"], name="notificatio_recipie_b8fa2a_idx"),
models.Index(fields=["recipient", "unread"], name="notificatio_recipie_8bedf2_idx"),
],
},
),
]

View File

@@ -0,0 +1,298 @@
"""
Notifications models.
Provides models for:
- Subscriber: User notification profile (legacy, kept for compatibility)
- NotificationPreference: User notification preferences
- NotificationLog: Audit trail of sent notifications
- SystemAnnouncement: System-wide announcements
Note: Now using django-notifications-hq for the core notification system.
Subscriber model is kept for backward compatibility but is optional.
"""
from django.conf import settings
from django.db import models
class Subscriber(models.Model):
"""
User notification profile.
Note: This model is kept for backward compatibility. The new
django-notifications-hq system uses User directly for notifications.
This can be used for storing additional notification-related user data.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_subscriber",
)
# Legacy field - kept for migration compatibility
novu_subscriber_id = models.CharField(
max_length=255,
unique=True,
db_index=True,
help_text="Legacy Novu subscriber ID (deprecated)"
)
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
email = models.EmailField(blank=True)
phone = models.CharField(max_length=20, blank=True)
avatar = models.URLField(blank=True)
locale = models.CharField(max_length=10, default="en")
data = models.JSONField(default=dict, blank=True, help_text="Custom subscriber data")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Notification Subscriber"
verbose_name_plural = "Notification Subscribers"
def __str__(self):
return f"Subscriber({self.user.username})"
class NotificationPreference(models.Model):
"""
User notification preferences across channels and workflows.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="novu_notification_prefs", # Renamed to avoid conflict with User.notification_preferences JSONField
)
# Channel preferences
channel_preferences = models.JSONField(
default=dict,
blank=True,
help_text="Preferences per channel (email, push, in_app, sms)",
)
# Workflow-specific preferences
workflow_preferences = models.JSONField(
default=dict,
blank=True,
help_text="Preferences per notification workflow",
)
# Frequency settings
frequency_settings = models.JSONField(
default=dict,
blank=True,
help_text="Digest and frequency settings",
)
# Global opt-out
is_opted_out = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
def __str__(self):
return f"Preferences({self.user.username})"
class NotificationLog(models.Model):
"""
Audit log of sent notifications.
"""
class Status(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
DELIVERED = "delivered", "Delivered"
FAILED = "failed", "Failed"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="notification_logs",
)
workflow_id = models.CharField(max_length=100, db_index=True)
notification_type = models.CharField(max_length=50)
channel = models.CharField(max_length=20) # email, push, in_app, sms
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
)
payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True)
novu_transaction_id = models.CharField(max_length=255, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Notification Log"
verbose_name_plural = "Notification Logs"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "-created_at"]),
models.Index(fields=["workflow_id", "-created_at"]),
]
def __str__(self):
return f"Log({self.workflow_id}, {self.status})"
class SystemAnnouncement(models.Model):
"""
System-wide announcements.
"""
class Severity(models.TextChoices):
INFO = "info", "Information"
WARNING = "warning", "Warning"
CRITICAL = "critical", "Critical"
title = models.CharField(max_length=255)
message = models.TextField()
severity = models.CharField(
max_length=20,
choices=Severity.choices,
default=Severity.INFO,
)
action_url = models.URLField(blank=True)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="announcements_created",
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "System Announcement"
verbose_name_plural = "System Announcements"
ordering = ["-created_at"]
def __str__(self):
return f"{self.title} ({self.severity})"
class Notification(models.Model):
"""
In-app notification model.
This is a Django-native implementation for storing user notifications,
supporting both in-app and email notification channels.
"""
class Level(models.TextChoices):
INFO = "info", "Info"
SUCCESS = "success", "Success"
WARNING = "warning", "Warning"
ERROR = "error", "Error"
# Who receives the notification
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="in_app_notifications", # Renamed to avoid clash with accounts.UserNotification
)
# Who triggered the notification (can be null for system notifications)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="notifications_sent",
)
# What happened
verb = models.CharField(max_length=255)
description = models.TextField(blank=True)
level = models.CharField(
max_length=20,
choices=Level.choices,
default=Level.INFO,
)
# The object that was acted upon (generic foreign key)
action_object_content_type = models.ForeignKey(
"contenttypes.ContentType",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="notification_action_objects",
)
action_object_id = models.PositiveIntegerField(blank=True, null=True)
# The target of the action (generic foreign key)
target_content_type = models.ForeignKey(
"contenttypes.ContentType",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="notification_targets",
)
target_id = models.PositiveIntegerField(blank=True, null=True)
# Additional data
data = models.JSONField(default=dict, blank=True)
# Status
unread = models.BooleanField(default=True, db_index=True)
# Timestamps
timestamp = models.DateTimeField(auto_now_add=True)
read_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "Notification"
verbose_name_plural = "Notifications"
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["recipient", "-timestamp"]),
models.Index(fields=["recipient", "unread"]),
]
def __str__(self):
return f"{self.verb} -> {self.recipient}"
def mark_as_read(self):
"""Mark this notification as read."""
if self.unread:
from django.utils import timezone
self.unread = False
self.read_at = timezone.now()
self.save(update_fields=["unread", "read_at"])
@property
def action_object(self):
"""Get the action object instance."""
if self.action_object_content_type and self.action_object_id:
return self.action_object_content_type.get_object_for_this_type(
pk=self.action_object_id
)
return None
@property
def target(self):
"""Get the target instance."""
if self.target_content_type and self.target_id:
return self.target_content_type.get_object_for_this_type(pk=self.target_id)
return None
class NotificationManager(models.Manager):
"""Custom manager for Notification model."""
def unread(self):
"""Return only unread notifications."""
return self.filter(unread=True)
def read(self):
"""Return only read notifications."""
return self.filter(unread=False)
def mark_all_as_read(self):
"""Mark all notifications as read."""
from django.utils import timezone
return self.filter(unread=True).update(unread=False, read_at=timezone.now())
# Add custom manager to Notification model
Notification.objects = NotificationManager()
Notification.objects.model = Notification

View File

@@ -0,0 +1,156 @@
"""
Notification serializers.
"""
from rest_framework import serializers
from .models import NotificationLog, NotificationPreference, Subscriber, SystemAnnouncement
class SubscriberSerializer(serializers.ModelSerializer):
"""Serializer for Subscriber model."""
subscriber_id = serializers.CharField(source="novu_subscriber_id", read_only=True)
class Meta:
model = Subscriber
fields = [
"subscriber_id",
"first_name",
"last_name",
"email",
"phone",
"avatar",
"locale",
"data",
"created_at",
"updated_at",
]
read_only_fields = ["subscriber_id", "created_at", "updated_at"]
class CreateSubscriberSerializer(serializers.Serializer):
"""Serializer for creating a new subscriber."""
subscriber_id = serializers.CharField(required=True)
first_name = serializers.CharField(required=False, allow_blank=True, default="")
last_name = serializers.CharField(required=False, allow_blank=True, default="")
email = serializers.EmailField(required=False, allow_blank=True)
phone = serializers.CharField(required=False, allow_blank=True, default="")
avatar = serializers.URLField(required=False, allow_blank=True)
locale = serializers.CharField(required=False, default="en")
data = serializers.JSONField(required=False, default=dict)
class UpdateSubscriberSerializer(serializers.Serializer):
"""Serializer for updating a subscriber."""
subscriber_id = serializers.CharField(required=True)
first_name = serializers.CharField(required=False, allow_blank=True)
last_name = serializers.CharField(required=False, allow_blank=True)
email = serializers.EmailField(required=False, allow_blank=True)
phone = serializers.CharField(required=False, allow_blank=True)
avatar = serializers.URLField(required=False, allow_blank=True)
locale = serializers.CharField(required=False)
data = serializers.JSONField(required=False)
class NotificationPreferenceSerializer(serializers.ModelSerializer):
"""Serializer for NotificationPreference model."""
class Meta:
model = NotificationPreference
fields = [
"channel_preferences",
"workflow_preferences",
"frequency_settings",
"is_opted_out",
"updated_at",
]
read_only_fields = ["updated_at"]
class UpdatePreferencesSerializer(serializers.Serializer):
"""Serializer for updating notification preferences."""
user_id = serializers.CharField(required=True)
preferences = serializers.JSONField(required=True)
class TriggerNotificationSerializer(serializers.Serializer):
"""Serializer for triggering a notification."""
workflow_id = serializers.CharField(required=True)
subscriber_id = serializers.CharField(required=True)
payload = serializers.JSONField(required=False, default=dict)
overrides = serializers.JSONField(required=False, default=dict)
class ModeratorSubmissionNotificationSerializer(serializers.Serializer):
"""Serializer for moderator submission notifications."""
submission_id = serializers.CharField(required=True)
submission_type = serializers.CharField(required=True)
submitter_name = serializers.CharField(required=True)
action = serializers.CharField(required=True)
class ModeratorReportNotificationSerializer(serializers.Serializer):
"""Serializer for moderator report notifications."""
report_id = serializers.CharField(required=True)
report_type = serializers.CharField(required=True)
reported_entity_type = serializers.CharField(required=True)
reported_entity_id = serializers.CharField(required=True)
reporter_name = serializers.CharField(required=True)
reason = serializers.CharField(required=True)
entity_preview = serializers.CharField(required=False, allow_blank=True)
reported_at = serializers.DateTimeField(required=False)
class SystemAnnouncementSerializer(serializers.ModelSerializer):
"""Serializer for system announcements."""
class Meta:
model = SystemAnnouncement
fields = [
"id",
"title",
"message",
"severity",
"action_url",
"is_active",
"created_at",
"expires_at",
]
read_only_fields = ["id", "created_at"]
class CreateAnnouncementSerializer(serializers.Serializer):
"""Serializer for creating system announcements."""
title = serializers.CharField(required=True, max_length=255)
message = serializers.CharField(required=True)
severity = serializers.ChoiceField(
choices=["info", "warning", "critical"],
default="info",
)
action_url = serializers.URLField(required=False, allow_blank=True)
class NotificationLogSerializer(serializers.ModelSerializer):
"""Serializer for notification logs."""
class Meta:
model = NotificationLog
fields = [
"id",
"workflow_id",
"notification_type",
"channel",
"status",
"payload",
"error_message",
"created_at",
]
read_only_fields = ["id", "created_at"]

View 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()

View File

@@ -0,0 +1,76 @@
"""
Notification URL configuration.
Note: Now using django-notifications-hq for native Django notifications.
Legacy Novu endpoints are kept for backward compatibility.
"""
from django.urls import path
from .views import (
AdminAlertView,
AdminCriticalErrorView,
CreateSubscriberView,
NotificationListView,
NotificationMarkReadView,
NotificationUnreadCountView,
NotifyModeratorsReportView,
NotifyModeratorsSubmissionView,
NotifyUserSubmissionStatusView,
SystemAnnouncementView,
TriggerNotificationView,
UpdatePreferencesView,
UpdateSubscriberView,
)
app_name = "notifications"
urlpatterns = [
# ========== Native Notification Endpoints ==========
# List notifications for current user
path("", NotificationListView.as_view(), name="list"),
# Mark notification(s) as read
path("mark-read/", NotificationMarkReadView.as_view(), name="mark_read"),
# Get unread count
path("unread-count/", NotificationUnreadCountView.as_view(), name="unread_count"),
# ========== Legacy/Compatibility Endpoints ==========
# Subscriber management (legacy - kept for backward compatibility)
path("subscribers/", CreateSubscriberView.as_view(), name="create_subscriber"),
path("subscribers/update/", UpdateSubscriberView.as_view(), name="update_subscriber"),
# Preferences
path("preferences/", UpdatePreferencesView.as_view(), name="preferences"),
# Trigger notifications
path("trigger/", TriggerNotificationView.as_view(), name="trigger"),
# Moderator notifications
path(
"moderators/submission/",
NotifyModeratorsSubmissionView.as_view(),
name="moderators_submission",
),
path(
"moderators/report/",
NotifyModeratorsReportView.as_view(),
name="moderators_report",
),
# User notifications
path(
"user/submission-status/",
NotifyUserSubmissionStatusView.as_view(),
name="user_submission_status",
),
# System notifications
path(
"system/announcement/",
SystemAnnouncementView.as_view(),
name="system_announcement",
),
# Admin notifications
path("admin/alert/", AdminAlertView.as_view(), name="admin_alert"),
path(
"admin/critical-error/",
AdminCriticalErrorView.as_view(),
name="admin_critical_error",
),
]

View File

@@ -0,0 +1,617 @@
"""
Notification views.
Provides REST API endpoints for:
- Subscriber management (legacy compatibility)
- Preference updates
- Notification triggering
- Moderator notifications
- System announcements
- User notification list and management
Note: Now using django-notifications-hq for native Django notifications.
The novu_service import provides backward compatibility.
"""
import logging
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.core.utils import capture_and_log
from .models import NotificationLog, NotificationPreference, Subscriber, SystemAnnouncement
from .serializers import (
CreateAnnouncementSerializer,
CreateSubscriberSerializer,
ModeratorReportNotificationSerializer,
ModeratorSubmissionNotificationSerializer,
NotificationPreferenceSerializer,
SystemAnnouncementSerializer,
TriggerNotificationSerializer,
UpdatePreferencesSerializer,
UpdateSubscriberSerializer,
)
from .services import novu_service, notification_service
logger = logging.getLogger(__name__)
User = get_user_model()
class CreateSubscriberView(APIView):
"""
POST /notifications/subscribers/
Create or update a Novu subscriber.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = CreateSubscriberSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
subscriber_id = data["subscriber_id"]
try:
# Update or create local subscriber record
subscriber, created = Subscriber.objects.update_or_create(
user=request.user,
defaults={
"novu_subscriber_id": subscriber_id,
"first_name": data.get("first_name", ""),
"last_name": data.get("last_name", ""),
"email": data.get("email") or request.user.email,
"phone": data.get("phone", ""),
"avatar": data.get("avatar", ""),
"locale": data.get("locale", "en"),
"data": data.get("data", {}),
},
)
# Sync to Novu if configured
if novu_service.is_configured:
novu_service.create_subscriber(
subscriber_id=subscriber_id,
email=subscriber.email,
first_name=subscriber.first_name,
last_name=subscriber.last_name,
phone=subscriber.phone,
avatar=subscriber.avatar,
locale=subscriber.locale,
data=subscriber.data,
)
return Response(
{"subscriberId": subscriber_id, "created": created},
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
except Exception as e:
capture_and_log(e, "Create notification subscriber", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class UpdateSubscriberView(APIView):
"""
POST /notifications/subscribers/update/
Update a Novu subscriber.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = UpdateSubscriberSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
subscriber_id = data["subscriber_id"]
try:
# Update local record
subscriber = Subscriber.objects.filter(user=request.user).first()
if not subscriber:
return Response(
{"detail": "Subscriber not found"},
status=status.HTTP_404_NOT_FOUND,
)
# Update fields
for field in ["first_name", "last_name", "email", "phone", "avatar", "locale", "data"]:
if field in data:
setattr(subscriber, field, data[field])
subscriber.save()
# Sync to Novu
if novu_service.is_configured:
update_fields = {k: v for k, v in data.items() if k != "subscriber_id"}
novu_service.update_subscriber(subscriber_id, **update_fields)
return Response({"success": True})
except Exception as e:
capture_and_log(e, "Update notification subscriber", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class UpdatePreferencesView(APIView):
"""
POST /notifications/preferences/
Update notification preferences.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = UpdatePreferencesSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
preferences = data["preferences"]
try:
# Update local preferences
pref, created = NotificationPreference.objects.update_or_create(
user=request.user,
defaults={
"channel_preferences": preferences.get("channelPreferences", {}),
"workflow_preferences": preferences.get("workflowPreferences", {}),
"frequency_settings": preferences.get("frequencySettings", {}),
},
)
# Sync to Novu
if novu_service.is_configured:
subscriber = Subscriber.objects.filter(user=request.user).first()
if subscriber:
novu_service.update_preferences(subscriber.novu_subscriber_id, preferences)
return Response({"success": True})
except Exception as e:
capture_and_log(e, "Update notification preferences", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def get(self, request):
"""Get current user's notification preferences."""
try:
pref = NotificationPreference.objects.filter(user=request.user).first()
if not pref:
return Response(
{
"channelPreferences": {},
"workflowPreferences": {},
"frequencySettings": {},
}
)
return Response(NotificationPreferenceSerializer(pref).data)
except Exception as e:
capture_and_log(e, "Get notification preferences", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class TriggerNotificationView(APIView):
"""
POST /notifications/trigger/
Trigger a notification workflow.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = TriggerNotificationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
# Log the notification
log = NotificationLog.objects.create(
user=request.user,
workflow_id=data["workflow_id"],
notification_type="trigger",
channel="all",
payload=data.get("payload", {}),
)
# Trigger via Novu
if novu_service.is_configured:
result = novu_service.trigger_notification(
workflow_id=data["workflow_id"],
subscriber_id=data["subscriber_id"],
payload=data.get("payload"),
overrides=data.get("overrides"),
)
log.novu_transaction_id = result.get("transactionId", "")
log.status = NotificationLog.Status.SENT
else:
log.status = NotificationLog.Status.SENT # Mock success
log.save()
return Response({"success": True, "transactionId": log.novu_transaction_id})
except Exception as e:
capture_and_log(e, "Trigger notification", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class NotifyModeratorsSubmissionView(APIView):
"""
POST /notifications/moderators/submission/
Notify moderators about a new submission.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = ModeratorSubmissionNotificationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
# Log the notification
NotificationLog.objects.create(
user=request.user,
workflow_id="moderator-submission-notification",
notification_type="moderator_submission",
channel="in_app",
payload=data,
status=NotificationLog.Status.SENT,
)
# Trigger to moderator topic
if novu_service.is_configured:
novu_service.trigger_topic_notification(
workflow_id="moderator-submission-notification",
topic_key="moderators",
payload=data,
)
return Response({"success": True})
except Exception as e:
capture_and_log(e, "Notify moderators (submission)", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class NotifyModeratorsReportView(APIView):
"""
POST /notifications/moderators/report/
Notify moderators about a new report.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = ModeratorReportNotificationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
# Log the notification
NotificationLog.objects.create(
user=request.user,
workflow_id="moderator-report-notification",
notification_type="moderator_report",
channel="in_app",
payload=data,
status=NotificationLog.Status.SENT,
)
# Trigger to moderator topic
if novu_service.is_configured:
novu_service.trigger_topic_notification(
workflow_id="moderator-report-notification",
topic_key="moderators",
payload=data,
)
return Response({"success": True})
except Exception as e:
capture_and_log(e, "Notify moderators (report)", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class NotifyUserSubmissionStatusView(APIView):
"""
POST /notifications/user/submission-status/
Notify a user about their submission status change.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
data = request.data
try:
subscriber_id = data.get("subscriber_id") or str(request.user.id)
# Log the notification
NotificationLog.objects.create(
user=request.user,
workflow_id="submission-status-update",
notification_type="submission_status",
channel="email",
payload=data,
status=NotificationLog.Status.SENT,
)
# Trigger notification
if novu_service.is_configured:
novu_service.trigger_notification(
workflow_id="submission-status-update",
subscriber_id=subscriber_id,
payload=data,
)
return Response({"success": True})
except Exception as e:
capture_and_log(e, "Notify user submission status", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class SystemAnnouncementView(APIView):
"""
POST /notifications/system/announcement/
Send a system-wide announcement (admin only).
"""
permission_classes = [IsAdminUser]
def post(self, request):
serializer = CreateAnnouncementSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
# Create announcement record
announcement = SystemAnnouncement.objects.create(
title=data["title"],
message=data["message"],
severity=data.get("severity", "info"),
action_url=data.get("action_url", ""),
created_by=request.user,
)
# Trigger to all users topic
if novu_service.is_configured:
novu_service.trigger_topic_notification(
workflow_id="system-announcement",
topic_key="users",
payload={
"title": announcement.title,
"message": announcement.message,
"severity": announcement.severity,
"actionUrl": announcement.action_url,
},
)
return Response(
{
"success": True,
"announcementId": str(announcement.id),
},
status=status.HTTP_201_CREATED,
)
except Exception as e:
capture_and_log(e, "System announcement", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class AdminAlertView(APIView):
"""
POST /notifications/admin/alert/
Send alert to admins.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
data = request.data
try:
# Log the alert
NotificationLog.objects.create(
user=request.user,
workflow_id="admin-alert",
notification_type="admin_alert",
channel="email",
payload=data,
status=NotificationLog.Status.SENT,
)
# Trigger to admin topic
if novu_service.is_configured:
novu_service.trigger_topic_notification(
workflow_id="admin-alert",
topic_key="admins",
payload=data,
)
return Response({"success": True})
except Exception as e:
capture_and_log(e, "Admin alert", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class AdminCriticalErrorView(APIView):
"""
POST /notifications/admin/critical-error/
Send critical error alert to admins.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
data = request.data
try:
# Log the alert
NotificationLog.objects.create(
user=request.user,
workflow_id="admin-critical-error",
notification_type="critical_error",
channel="email",
payload=data,
status=NotificationLog.Status.SENT,
)
# Trigger to admin topic with urgent priority
if novu_service.is_configured:
novu_service.trigger_topic_notification(
workflow_id="admin-critical-error",
topic_key="admins",
payload=data,
)
return Response({"success": True})
except Exception as e:
capture_and_log(e, "Admin critical error", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# ============================================================================
# Native Notification Views (django-notifications-hq)
# ============================================================================
class NotificationListView(APIView):
"""
GET /notifications/
Get list of notifications for the current user.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
try:
unread_only = request.query_params.get("unread_only", "false").lower() == "true"
limit = min(int(request.query_params.get("limit", 50)), 100)
notifications = notification_service.get_user_notifications(
user=request.user,
unread_only=unread_only,
limit=limit,
)
# Serialize notifications
notification_list = []
for notif in notifications:
notification_list.append({
"id": notif.id,
"actor": str(notif.actor) if notif.actor else None,
"verb": notif.verb,
"description": notif.description or "",
"target": str(notif.target) if notif.target else None,
"actionObject": str(notif.action_object) if notif.action_object else None,
"level": notif.level,
"unread": notif.unread,
"data": notif.data or {},
"timestamp": notif.timestamp.isoformat(),
})
return Response({
"notifications": notification_list,
"unreadCount": notification_service.get_unread_count(request.user),
})
except Exception as e:
capture_and_log(e, "Get notifications", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class NotificationMarkReadView(APIView):
"""
POST /notifications/mark-read/
Mark notification(s) as read.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
notification_id = request.data.get("notification_id")
notification_service.mark_as_read(
user=request.user,
notification_id=notification_id,
)
return Response({
"success": True,
"unreadCount": notification_service.get_unread_count(request.user),
})
except Exception as e:
capture_and_log(e, "Mark notification read", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class NotificationUnreadCountView(APIView):
"""
GET /notifications/unread-count/
Get count of unread notifications.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
try:
count = notification_service.get_unread_count(request.user)
return Response({"unreadCount": count})
except Exception as e:
capture_and_log(e, "Get unread count", source="api")
return Response(
{"detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)