Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ jobs:
backend
core
logs
notifications
posts
reactions
reports
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AlienCommons

> The common land for every TechMC player — a full-stack community platform for publishing, discussion, and discovery.
> The common planet for all Minecraft players — a community platform for publishing, discussion, and discovery.

[![License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE)
[![Python](https://img.shields.io/badge/Python-3.14-3776AB?style=flat-square&logo=python)](https://python.org)
Expand Down
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 bookmarks comments posts reactions reports core users logs tasks backend manage.py
uv run ruff check articles bookmarks comments notifications posts reactions reports core users logs tasks backend 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 @@ -19,6 +19,7 @@ The Django API service for AlienCommons.
- `articles`: source articles, published articles, collections, and article workflows
- `bookmarks`: bookmark folders and article bookmarks
- `comments`: article comments and replies
- `notifications`: user notification events, fan-out deliveries, and inbox APIs
- `posts`: community posts and related content
- `reactions`: like/dislike targets and user reactions
- `reports`: content and user moderation reports
Expand All @@ -34,7 +35,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 posts reactions reports core users logs tasks backend manage.py
uv run ruff check articles bookmarks comments notifications posts reactions reports core users logs tasks backend manage.py
```

Or use the root Make targets:
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/articles/services/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from core.exceptions import ServiceError
from core.utils.alienmark import render_md_to_html
from logs.logging import get_logger
from notifications.services import notify_subscribed_author_posted
from .markdown import extract_title_from_markdown, validate_article_markdown

logger = get_logger(__name__)
Expand Down Expand Up @@ -159,6 +160,11 @@ def _create_or_update_published_article(self) -> PublishedArticle:
html=html,
publication_at=timezone.now(),
)
notify_subscribed_author_posted(
actor=self.source_article.author,
target=published_article.content_target,
content_kind="published_article",
)

return published_article

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 @@ -164,6 +164,7 @@
"reactions.apps.ReactionsConfig",
"reports.apps.ReportsConfig",
"posts.apps.PostsConfig",
"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 @@ -30,6 +30,7 @@
"reactions.apps.ReactionsConfig",
"reports.apps.ReportsConfig",
"posts.apps.PostsConfig",
"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 @@ -15,6 +15,7 @@
path("v1/", include("reactions.urls")),
path("v1/", include("reports.urls")),
path("v1/", include("posts.urls")),
path("v1/", include("notifications.urls")),
]

urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
Expand Down
18 changes: 17 additions & 1 deletion apps/backend/comments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
get_or_create_community_post_target,
get_or_create_published_article_target,
)
from notifications.services import notify_comment_reply, notify_mentions
from posts.models import CommunityPost

from .models import Comment
Expand Down Expand Up @@ -50,14 +51,29 @@ def create_comment(
body=body,
mentions=mentions,
)
get_or_create_comment_target(comment)
comment_target = get_or_create_comment_target(comment)
notify_mentions(
actor=author,
target=comment_target,
mention_user_ids=mentions,
dedupe_prefix=f"comment:{comment.id}:created",
)
if target.comment_id is not None:
notify_comment_reply(comment=comment)
return comment


def update_comment(*, comment: Comment, body: str, mentions: list):
previous_mentions = set(comment.mentions)
comment.body = body
comment.mentions = mentions
comment.save(update_fields=["body", "mentions", "updated_at"])
notify_mentions(
actor=comment.author,
target=comment.content_target,
mention_user_ids=set(mentions) - previous_mentions,
dedupe_prefix=f"comment:{comment.id}:updated",
)
return comment


Expand Down
1 change: 1 addition & 0 deletions apps/backend/notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

19 changes: 19 additions & 0 deletions apps/backend/notifications/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.contrib import admin

from .models import NotificationDelivery, NotificationEvent


@admin.register(NotificationEvent)
class NotificationEventAdmin(admin.ModelAdmin):
list_display = ("id", "reason", "channel", "actor", "target", "delivery_status", "created_at")
list_filter = ("reason", "channel", "delivery_status", "created_at")
search_fields = ("dedupe_key", "actor__username")
readonly_fields = ("id", "created_at", "updated_at", "delivered_at")


@admin.register(NotificationDelivery)
class NotificationDeliveryAdmin(admin.ModelAdmin):
list_display = ("id", "recipient", "event", "read_at", "created_at")
list_filter = ("read_at", "created_at")
search_fields = ("recipient__username", "event__dedupe_key")
readonly_fields = ("id", "created_at", "updated_at")
7 changes: 7 additions & 0 deletions apps/backend/notifications/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"

69 changes: 69 additions & 0 deletions apps/backend/notifications/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Generated by Django 6.0.5 on 2026-05-22 11: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 = [
('core', '0002_remove_contenttarget_content_target_requires_exactly_one_object_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='NotificationEvent',
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')),
('event_type', models.IntegerField(choices=[(1, 'mention'), (2, 'comment reply'), (3, 'new subscriber'), (4, 'subscribed author posted')], db_index=True)),
('data', models.JSONField(blank=True, default=dict)),
('dedupe_key', models.CharField(max_length=255, unique=True)),
('delivery_status', models.IntegerField(choices=[(1, 'pending'), (2, 'processing'), (3, 'delivered'), (4, 'failed')], db_index=True, default=1)),
('delivery_attempts', models.PositiveIntegerField(default=0)),
('delivered_at', models.DateTimeField(blank=True, null=True)),
('last_error', models.TextField(blank=True, default='')),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notification_events', to=settings.AUTH_USER_MODEL)),
('target', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notification_events', to='core.contenttarget')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='NotificationDelivery',
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')),
('read_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_deliveries', to=settings.AUTH_USER_MODEL)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='notifications.notificationevent')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='notificationevent',
index=models.Index(fields=['event_type', 'created_at'], name='notificatio_event_t_58f16e_idx'),
),
migrations.AddIndex(
model_name='notificationevent',
index=models.Index(fields=['delivery_status', 'created_at'], name='notificatio_deliver_f76ded_idx'),
),
migrations.AddIndex(
model_name='notificationdelivery',
index=models.Index(fields=['recipient', 'read_at', 'created_at'], name='notificatio_recipie_c4a9d7_idx'),
),
migrations.AddConstraint(
model_name='notificationdelivery',
constraint=models.UniqueConstraint(fields=('event', 'recipient'), name='unique_notification_event_delivery_recipient'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Generated by Codex on 2026-06-19

from django.db import migrations, models


def split_payload_and_recipients(apps, schema_editor):
NotificationEvent = apps.get_model("notifications", "NotificationEvent")
UserSubscription = apps.get_model("users", "UserSubscription")

for event in NotificationEvent.objects.all().iterator():
payload = dict(event.payload or {})
recipients = payload.pop("recipient_ids", [])
if event.reason == 4 and event.actor_id:
recipients = list(
UserSubscription.objects.filter(
subscribed_to_id=event.actor_id,
).values_list("subscriber_id", flat=True)
)

if event.reason == 1:
channel = 1
elif event.reason == 2:
channel = 2
elif event.reason == 4:
channel = 4
else:
channel = 3

event.payload = payload
event.recipients = [str(recipient_id) for recipient_id in recipients]
event.channel = channel
event.save(update_fields=["payload", "recipients", "channel"])


def merge_recipients_into_payload(apps, schema_editor):
NotificationEvent = apps.get_model("notifications", "NotificationEvent")

for event in NotificationEvent.objects.all().iterator():
payload = dict(event.payload or {})
if event.recipients:
payload["recipient_ids"] = event.recipients
event.payload = payload
event.save(update_fields=["payload"])


class Migration(migrations.Migration):
dependencies = [
("notifications", "0001_initial"),
("users", "0002_usersubscription"),
]

operations = [
migrations.RemoveIndex(
model_name="notificationevent",
name="notificatio_event_t_58f16e_idx",
),
migrations.RenameField(
model_name="notificationevent",
old_name="event_type",
new_name="reason",
),
migrations.RenameField(
model_name="notificationevent",
old_name="data",
new_name="payload",
),
migrations.AddField(
model_name="notificationevent",
name="channel",
field=models.IntegerField(
choices=[
(1, "mentions"),
(2, "comments"),
(3, "system"),
(4, "subscriptions"),
],
db_index=True,
default=3,
),
preserve_default=False,
),
migrations.AddField(
model_name="notificationevent",
name="recipients",
field=models.JSONField(blank=True, default=list),
),
migrations.RunPython(split_payload_and_recipients, merge_recipients_into_payload),
migrations.AlterField(
model_name="notificationevent",
name="reason",
field=models.IntegerField(
choices=[
(1, "mention"),
(2, "reply"),
(3, "system"),
(4, "subscription"),
],
db_index=True,
),
),
migrations.AddIndex(
model_name="notificationevent",
index=models.Index(fields=["channel", "created_at"], name="notificatio_channel_7086bb_idx"),
),
migrations.AddIndex(
model_name="notificationevent",
index=models.Index(fields=["reason", "created_at"], name="notificatio_reason_c4bfd9_idx"),
),
]
1 change: 1 addition & 0 deletions apps/backend/notifications/migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading