Skip to content
Closed
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ jobs:
backend
core
logs
notifications
reactions
reports
tasks
Expand Down
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions apps/backend/backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
"comments.apps.CommentsConfig",
"reactions.apps.ReactionsConfig",
"reports.apps.ReportsConfig",
"notifications.apps.NotificationsConfig",
"tasks.apps.TasksConfig",
"corsheaders",
"rest_framework",
Expand Down
1 change: 1 addition & 0 deletions apps/backend/backend/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"comments.apps.CommentsConfig",
"reactions.apps.ReactionsConfig",
"reports.apps.ReportsConfig",
"notifications.apps.NotificationsConfig",
"tasks.apps.TasksConfig",
"corsheaders",
"rest_framework",
Expand Down
1 change: 1 addition & 0 deletions apps/backend/backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/comments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down
Empty file.
23 changes: 23 additions & 0 deletions apps/backend/notifications/admin.py
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 6 additions & 0 deletions apps/backend/notifications/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"
45 changes: 45 additions & 0 deletions apps/backend/notifications/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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')],
},
),
]
Empty file.
123 changes: 123 additions & 0 deletions apps/backend/notifications/models.py
Original file line number Diff line number Diff line change
@@ -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}"
40 changes: 40 additions & 0 deletions apps/backend/notifications/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Loading