feat: replace email flows with in-app notifications

Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
This commit is contained in:
2026-09-26 12:23:10 +03:30
parent b706a2d10a
commit 3ec28638d1
54 changed files with 787 additions and 4843 deletions

View File

@@ -9,10 +9,10 @@ class NotificationSchema(Schema):
type: str
title: str
message: str
has_body: bool = False
level: str
created_at: datetime | str
is_seen: bool
delete_on_seen: bool
action_url: str | None = None
entity_type: str | None = None
entity_id: int | str | None = None
@@ -25,6 +25,10 @@ class NotificationListSchema(Schema):
notifications: list[NotificationSchema]
class NotificationDetailSchema(NotificationSchema):
body_markdown: str | None = None
class NotificationSeenIn(Schema):
id: str

View File

@@ -9,6 +9,7 @@ from django.views import View
from ninja import Router
from apps.notifications.api.schemas import (
NotificationDetailSchema,
NotificationDeleteResponseSchema,
NotificationListSchema,
NotificationMarkAllReadResponseSchema,
@@ -54,7 +55,9 @@ def _validate_stream_token(token: str | None) -> str:
@notifications_router.get("/", response=NotificationListSchema, auth=jwt_auth)
def list_notifications(request, limit: int | None = None, offset: int = 0, type: str | None = None):
def list_notifications(request, limit: int | None = None, offset: int = 0, type: str | None = None, status: str = "all"):
if status not in {"all", "unread", "read"}:
return JsonResponse({"error": "Invalid status filter."}, status=400)
user_id = str(request.auth.id)
safe_limit = min(_safe_int(limit, settings.NOTIFICATION_DEFAULT_PAGE_SIZE), settings.NOTIFICATION_MAX_PAGE_SIZE)
notifications, total_count = RedisNotificationStore.list(
@@ -62,11 +65,12 @@ def list_notifications(request, limit: int | None = None, offset: int = 0, type:
limit=safe_limit,
offset=_safe_int(offset, 0),
type_filter=type,
read_filter=status,
)
return {
"count": total_count,
"unread_count": RedisNotificationStore.unread_count(user_id),
"notifications": notifications,
"notifications": [RedisNotificationStore.summary(item) for item in notifications],
}
@@ -78,18 +82,6 @@ def mark_notification_seen(request, data: NotificationSeenIn):
return 200, {"marked_read": True, **payload}
@notifications_router.delete("/{notif_id}", response={200: NotificationDeleteResponseSchema, 404: ErrorSchema}, auth=jwt_auth)
def delete_notification(request, notif_id: str):
deleted = RedisNotificationStore.delete(str(request.auth.id), notif_id)
if not deleted:
return 404, {"error": "Notification not found."}
return 200, {
"deleted": True,
"notification_id": notif_id,
"unread_count": RedisNotificationStore.unread_count(str(request.auth.id)),
}
@notifications_router.post("/mark-all-read", response=NotificationMarkAllReadResponseSchema, auth=jwt_auth)
def mark_all_notifications_read(request, type: str | None = None):
updated = RedisNotificationStore.mark_all_seen(str(request.auth.id), type_filter=type)
@@ -106,6 +98,26 @@ def get_notification_stream_token(request):
}
@notifications_router.get("/{notif_id}", response={200: NotificationDetailSchema, 404: ErrorSchema}, auth=jwt_auth)
def get_notification(request, notif_id: str):
notification = RedisNotificationStore.get(str(request.auth.id), notif_id)
if notification is None:
return 404, {"error": "Notification not found."}
return 200, {**notification, "has_body": bool(notification.get("body_markdown"))}
@notifications_router.delete("/{notif_id}", response={200: NotificationDeleteResponseSchema, 404: ErrorSchema}, auth=jwt_auth)
def delete_notification(request, notif_id: str):
deleted = RedisNotificationStore.delete(str(request.auth.id), notif_id)
if not deleted:
return 404, {"error": "Notification not found."}
return 200, {
"deleted": True,
"notification_id": notif_id,
"unread_count": RedisNotificationStore.unread_count(str(request.auth.id)),
}
class NotificationStreamView(View):
def _build_stream(self, user_id: str) -> Iterator[str]:
pubsub = RedisNotificationStore.get_pubsub()
@@ -118,7 +130,7 @@ class NotificationStreamView(View):
)
unread_count = RedisNotificationStore.unread_count(user_id)
yield f"retry: {settings.NOTIFICATION_SSE_RETRY_MS}\n\n"
yield _format_sse_event("connected", {"notifications": initial_notifications, "unread_count": unread_count})
yield _format_sse_event("connected", {"notifications": [RedisNotificationStore.summary(item) for item in initial_notifications], "unread_count": unread_count})
yield _format_sse_event("unread_count", {"unread_count": unread_count})
pubsub.subscribe(channel)