diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2716d53..b4de47e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,7 @@ jobs: backend core logs + notifications reactions reports tasks diff --git a/.gitignore b/.gitignore index 5b0cbe2..0848613 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,16 @@ env/.env.stg # Observability runtime data o11y/grafana/data/ o11y/loki/data/ + +# GitNexus .gitnexus + +# Worktrees +.worktree + +# Agents +.pi +.opencode +.sisyphus +.codex +.claude diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index d7bea29..a19e27d 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -34,7 +34,7 @@ This directory contains the Django backend for AlienCommons. Keep backend change - For lint-sensitive changes, prefer: ```bash - uv run ruff check articles backend core logs tasks users manage.py + uv run ruff check articles backend core logs notifications tasks users manage.py ``` - If local dependencies or services make a check impossible, say exactly what was not run and why. diff --git a/apps/backend/README.md b/apps/backend/README.md index 0379bf9..11025c1 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -22,6 +22,7 @@ The Django API service for AlienCommons. - `reactions`: like/dislike targets and user reactions - `reports`: content and user moderation reports - `tasks`: scheduled/background task definitions +- `notifications`: user notification preferences and delivery - `core`: shared utilities, responses, permissions, and test helpers ## Local Commands @@ -33,7 +34,7 @@ uv run python manage.py check uv run python manage.py makemigrations uv run python manage.py migrate uv run python manage.py test --settings=backend.settings.test -uv run ruff check articles bookmarks comments reactions reports core users logs tasks backend manage.py +uv run ruff check articles bookmarks comments notifications reactions reports core users logs tasks backend manage.py ``` Or use the root Make targets: diff --git a/apps/backend/backend/settings/base.py b/apps/backend/backend/settings/base.py index 3c853ac..9c7a978 100644 --- a/apps/backend/backend/settings/base.py +++ b/apps/backend/backend/settings/base.py @@ -163,6 +163,7 @@ "comments.apps.CommentsConfig", "reactions.apps.ReactionsConfig", "reports.apps.ReportsConfig", + "notifications.apps.NotificationsConfig", "tasks.apps.TasksConfig", "corsheaders", "rest_framework", diff --git a/apps/backend/backend/settings/test.py b/apps/backend/backend/settings/test.py index f9c43fa..2401fb9 100644 --- a/apps/backend/backend/settings/test.py +++ b/apps/backend/backend/settings/test.py @@ -29,6 +29,7 @@ "comments.apps.CommentsConfig", "reactions.apps.ReactionsConfig", "reports.apps.ReportsConfig", + "notifications.apps.NotificationsConfig", "tasks.apps.TasksConfig", "corsheaders", "rest_framework", diff --git a/apps/backend/backend/urls.py b/apps/backend/backend/urls.py index 78cf4b2..6547d85 100644 --- a/apps/backend/backend/urls.py +++ b/apps/backend/backend/urls.py @@ -14,6 +14,7 @@ path("v1/", include("comments.urls")), path("v1/", include("reactions.urls")), path("v1/", include("reports.urls")), + path("v1/", include("notifications.urls")), ] urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/apps/backend/comments/services.py b/apps/backend/comments/services.py index 476f68a..b8b326c 100644 --- a/apps/backend/comments/services.py +++ b/apps/backend/comments/services.py @@ -7,6 +7,7 @@ get_or_create_comment_target, get_or_create_published_article_target, ) +from notifications.services import create_mention_notifications_for_comment from .models import Comment @@ -38,13 +39,17 @@ def create_comment(*, author, body: str, mentions: list, published_article: Publ mentions=mentions, ) get_or_create_comment_target(comment) + create_mention_notifications_for_comment(comment) return comment +@transaction.atomic def update_comment(*, comment: Comment, body: str, mentions: list): + previous_mentions = set(comment.mentions or []) comment.body = body comment.mentions = mentions comment.save(update_fields=["body", "mentions", "updated_at"]) + create_mention_notifications_for_comment(comment, previous_mentions=previous_mentions) return comment diff --git a/apps/backend/notifications/__init__.py b/apps/backend/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/notifications/admin.py b/apps/backend/notifications/admin.py new file mode 100644 index 0000000..4c1d118 --- /dev/null +++ b/apps/backend/notifications/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + model = Notification + list_display = ( + "recipient", + "notification_type", + "verb", + "is_read", + "created_at", + ) + list_filter = ("notification_type", "is_read", "created_at") + search_fields = ( + "recipient__username", + "verb", + "dedupe_key", + ) + ordering = ("-created_at",) + readonly_fields = ("id", "created_at", "updated_at") diff --git a/apps/backend/notifications/apps.py b/apps/backend/notifications/apps.py new file mode 100644 index 0000000..3a08476 --- /dev/null +++ b/apps/backend/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" diff --git a/apps/backend/notifications/migrations/0001_initial.py b/apps/backend/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..9040458 --- /dev/null +++ b/apps/backend/notifications/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.5 on 2026-05-11 05:30 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the object', verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, help_text='The updated DateTime of the object', verbose_name='updated at')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID')), + ('actor_object_id', models.UUIDField(help_text='The UUID of the actor object', verbose_name='actor object ID')), + ('target_object_id', models.UUIDField(blank=True, help_text='The UUID of the target object', null=True, verbose_name='target object ID')), + ('action_object_object_id', models.UUIDField(blank=True, help_text='The UUID of the action object', null=True, verbose_name='action object ID')), + ('notification_type', models.CharField(choices=[('mention', 'Mention')], help_text='The type of notification', max_length=50, verbose_name='notification type')), + ('verb', models.CharField(help_text='The action verb describing what happened', max_length=255, verbose_name='verb')), + ('is_read', models.BooleanField(db_index=True, default=False, help_text='Whether the notification has been read', verbose_name='is read')), + ('data', models.JSONField(blank=True, default=dict, help_text='Additional data for the notification', verbose_name='data')), + ('dedupe_key', models.CharField(db_index=True, help_text='Unique key to prevent duplicate notifications', max_length=255, unique=True, verbose_name='dedupe key')), + ('action_object_content_type', models.ForeignKey(blank=True, help_text='The content type of the action object', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_action_object', to='contenttypes.contenttype', verbose_name='action object content type')), + ('actor_content_type', models.ForeignKey(help_text='The content type of the actor', on_delete=django.db.models.deletion.CASCADE, related_name='notification_actor', to='contenttypes.contenttype', verbose_name='actor content type')), + ('recipient', models.ForeignKey(help_text='The user who receives the notification', on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='recipient')), + ('target_content_type', models.ForeignKey(blank=True, help_text='The content type of the target', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_target', to='contenttypes.contenttype', verbose_name='target content type')), + ], + options={ + 'verbose_name': 'notification', + 'verbose_name_plural': 'notifications', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['recipient', 'is_read'], name='notificatio_recipie_4e3567_idx'), models.Index(fields=['recipient', 'notification_type'], name='notificatio_recipie_028906_idx'), models.Index(fields=['recipient', 'created_at'], name='notificatio_recipie_f39341_idx')], + }, + ), + ] diff --git a/apps/backend/notifications/migrations/__init__.py b/apps/backend/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/notifications/models.py b/apps/backend/notifications/models.py new file mode 100644 index 0000000..d0b0ba7 --- /dev/null +++ b/apps/backend/notifications/models.py @@ -0,0 +1,123 @@ +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.model_mixins import TimeStampedMixin, UUIDPrimaryKeyMixin + + +class Notification(UUIDPrimaryKeyMixin, + TimeStampedMixin, + models.Model): + class NotificationType(models.TextChoices): + MENTION = "mention", "Mention" + + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notifications", + verbose_name=_("recipient"), + help_text=_("The user who receives the notification"), + ) + + actor_content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name="notification_actor", + verbose_name=_("actor content type"), + help_text=_("The content type of the actor"), + ) + actor_object_id = models.UUIDField( + verbose_name=_("actor object ID"), + help_text=_("The UUID of the actor object"), + ) + actor = GenericForeignKey( + "actor_content_type", + "actor_object_id", + ) + + target_content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="notification_target", + verbose_name=_("target content type"), + help_text=_("The content type of the target"), + ) + target_object_id = models.UUIDField( + null=True, + blank=True, + verbose_name=_("target object ID"), + help_text=_("The UUID of the target object"), + ) + target = GenericForeignKey( + "target_content_type", + "target_object_id", + ) + + action_object_content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="notification_action_object", + verbose_name=_("action object content type"), + help_text=_("The content type of the action object"), + ) + action_object_object_id = models.UUIDField( + null=True, + blank=True, + verbose_name=_("action object ID"), + help_text=_("The UUID of the action object"), + ) + action_object = GenericForeignKey( + "action_object_content_type", + "action_object_object_id", + ) + + notification_type = models.CharField( + max_length=50, + choices=NotificationType.choices, + verbose_name=_("notification type"), + help_text=_("The type of notification"), + ) + verb = models.CharField( + max_length=255, + verbose_name=_("verb"), + help_text=_("The action verb describing what happened"), + ) + is_read = models.BooleanField( + default=False, + db_index=True, + verbose_name=_("is read"), + help_text=_("Whether the notification has been read"), + ) + data = models.JSONField( + default=dict, + blank=True, + verbose_name=_("data"), + help_text=_("Additional data for the notification"), + ) + dedupe_key = models.CharField( + max_length=255, + unique=True, + db_index=True, + verbose_name=_("dedupe key"), + help_text=_("Unique key to prevent duplicate notifications"), + ) + + class Meta: + verbose_name = _("notification") + verbose_name_plural = _("notifications") + + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["recipient", "is_read"]), + models.Index(fields=["recipient", "notification_type"]), + models.Index(fields=["recipient", "created_at"]), + ] + + def __str__(self): + return f"{self.notification_type} notification for {self.recipient}" diff --git a/apps/backend/notifications/serializers.py b/apps/backend/notifications/serializers.py new file mode 100644 index 0000000..c8929a8 --- /dev/null +++ b/apps/backend/notifications/serializers.py @@ -0,0 +1,40 @@ +from rest_framework import serializers + +from .models import Notification + + +class NotificationReadSerializer(serializers.ModelSerializer): + """Read-only serializer for user notifications. + + Permission enforcement is handled at the ViewSet level via get_queryset(), + which filters by recipient=request.user. This serializer only shapes the + response data and does not perform authorization checks. + """ + + actor_id = serializers.SerializerMethodField() + actor_type = serializers.SerializerMethodField() + + class Meta: + model = Notification + fields = ( + "id", + "notification_type", + "verb", + "is_read", + "data", + "actor_id", + "actor_type", + "created_at", + "updated_at", + ) + read_only_fields = fields + + def get_actor_id(self, obj): + if obj.actor is None: + return None + return str(obj.actor.pk) + + def get_actor_type(self, obj): + if obj.actor_content_type_id is None: + return None + return obj.actor_content_type.model diff --git a/apps/backend/notifications/services.py b/apps/backend/notifications/services.py new file mode 100644 index 0000000..848b008 --- /dev/null +++ b/apps/backend/notifications/services.py @@ -0,0 +1,106 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import transaction + + +from .models import Notification + + +@transaction.atomic +def create_notification( + *, + recipient, + actor, + notification_type, + verb, + dedupe_key, + data=None, + target=None, + action_object=None, +): + actor_content_type = ContentType.objects.get_for_model(actor) + + defaults = { + "recipient": recipient, + "actor_content_type": actor_content_type, + "actor_object_id": actor.id, + "notification_type": notification_type, + "verb": verb, + "data": data or {}, + } + + if target is not None: + target_content_type = ContentType.objects.get_for_model(target) + defaults["target_content_type"] = target_content_type + defaults["target_object_id"] = target.id + + if action_object is not None: + action_object_content_type = ContentType.objects.get_for_model(action_object) + defaults["action_object_content_type"] = action_object_content_type + defaults["action_object_object_id"] = action_object.id + + return Notification.objects.get_or_create( + dedupe_key=dedupe_key, + defaults=defaults, + ) + + +def create_mention_notifications_for_comment(comment, previous_mentions=None): + mention_ids = set(comment.mentions or []) + + if previous_mentions is not None: + previous_ids = set(previous_mentions) + mention_ids = mention_ids - previous_ids + + mention_ids.discard(str(comment.author_id)) + + notifications = [] + for user_id in mention_ids: + from users.models import User + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + continue + + dedupe_key = f"comment:{comment.id}:mention:{user_id}" + data = { + "comment_id": str(comment.id), + "author_id": str(comment.author_id), + "author_username": comment.author.username, + } + + notification, created = create_notification( + recipient=user, + actor=comment.author, + action_object=comment, + target=comment.target if hasattr(comment, 'target') else None, + notification_type="mention", + verb="mentioned you in a comment", + dedupe_key=dedupe_key, + data=data, + ) + + if created: + notifications.append(notification) + + return notifications + + +def mark_notification_read(notification): + if not notification.is_read: + notification.is_read = True + notification.save(update_fields=["is_read"]) + return notification + + +def mark_all_notifications_read(user): + return Notification.objects.filter( + recipient=user, + is_read=False, + ).update(is_read=True) + + +def get_unread_count(user): + return Notification.objects.filter( + recipient=user, + is_read=False, + ).count() diff --git a/apps/backend/notifications/urls.py b/apps/backend/notifications/urls.py new file mode 100644 index 0000000..fd817bf --- /dev/null +++ b/apps/backend/notifications/urls.py @@ -0,0 +1,10 @@ +from rest_framework import routers + +from .views import NotificationViewSet + + +router = routers.SimpleRouter() +router.register(r"notifications", NotificationViewSet, basename="notification") + +urlpatterns = [] +urlpatterns += router.urls diff --git a/apps/backend/notifications/views.py b/apps/backend/notifications/views.py new file mode 100644 index 0000000..a13797f --- /dev/null +++ b/apps/backend/notifications/views.py @@ -0,0 +1,54 @@ +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from django_filters import rest_framework as filters + +from core.views.viewsets import MyReadOnlyModelViewSet +from .models import Notification +from .serializers import NotificationReadSerializer +from .services import ( + get_unread_count, + mark_all_notifications_read, + mark_notification_read, +) + + +class NotificationViewSet(MyReadOnlyModelViewSet): + serializer_class = NotificationReadSerializer + permission_classes = [IsAuthenticated] + filter_backends = [filters.DjangoFilterBackend] + filterset_fields = ["is_read", "notification_type"] + + def get_queryset(self): + return Notification.objects.filter(recipient=self.request.user) + + @action(methods=["post"], detail=True) + def mark_read(self, request, pk=None): + notification = self.get_object() + mark_notification_read(notification) + return self.format_success_response( + message="Notification marked as read", + code="marked_read", + data={"id": str(notification.id), "is_read": notification.is_read}, + status_code=status.HTTP_200_OK, + ) + + @action(methods=["post"], detail=False) + def mark_all_read(self, request): + count = mark_all_notifications_read(request.user) + return self.format_success_response( + message="All notifications marked as read", + code="marked_all_read", + data={"count": count}, + status_code=status.HTTP_200_OK, + ) + + @action(methods=["get"], detail=False) + def unread_count(self, request): + count = get_unread_count(request.user) + return self.format_success_response( + message="Unread count retrieved", + code="unread_count", + data={"count": count}, + status_code=status.HTTP_200_OK, + )