diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2716d53..995d7ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,7 @@ jobs: backend core logs + posts reactions reports tasks diff --git a/AGENTS.md b/AGENTS.md index f46bcae..44fefd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ Run the smallest relevant checks for the change. If a check cannot be run, menti - Use focused commits and clear commit messages. - Feature work should normally branch from the active development branch. - Production releases should be represented by tags, not by editing this file. +- Do not include a `Verification` section in pull request descriptions unless the user explicitly asks for one. # GitNexus — Code Intelligence diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index d7bea29..868b6c4 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 bookmarks comments 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. diff --git a/apps/backend/README.md b/apps/backend/README.md index 0379bf9..5ac9d0e 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -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 +- `posts`: community posts and related content - `reactions`: like/dislike targets and user reactions - `reports`: content and user moderation reports - `tasks`: scheduled/background task definitions @@ -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 posts reactions reports core users logs tasks backend manage.py ``` Or use the root Make targets: diff --git a/apps/backend/articles/tests/test_models.py b/apps/backend/articles/tests/test_models.py index 8e79c56..d35541b 100644 --- a/apps/backend/articles/tests/test_models.py +++ b/apps/backend/articles/tests/test_models.py @@ -9,6 +9,7 @@ SourceArticle, ) from articles.services.articles import ArticleWorkflow +from core.models import ContentTarget from core.tests.factories import ( create_collection, create_collection_item, @@ -62,6 +63,19 @@ def test_published_article_string_representation_references_source_article(self) self.assertEqual(str(published), "Published version of article Ship log") + def test_published_article_created_directly_has_content_target(self): + article = create_source_article(author=self.author, title="Targetable") + + published = PublishedArticle.objects.create( + source_article=article, + title=article.title, + html=article.markdown, + publication_at=article.created_at, + ) + + self.assertEqual(published.content_target.target_type, ContentTarget.TargetType.PUBLISHED_ARTICLE) + self.assertEqual(published.content_target.published_article, published) + def test_article_snapshot_string_representation_uses_source_article_id(self): article = create_source_article(author=self.author) snapshot = ArticleSnapshot.objects.create( diff --git a/apps/backend/backend/settings/base.py b/apps/backend/backend/settings/base.py index 3c853ac..e760898 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", + "posts.apps.PostsConfig", "tasks.apps.TasksConfig", "corsheaders", "rest_framework", diff --git a/apps/backend/backend/settings/test.py b/apps/backend/backend/settings/test.py index f9c43fa..0021f53 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", + "posts.apps.PostsConfig", "tasks.apps.TasksConfig", "corsheaders", "rest_framework", diff --git a/apps/backend/backend/urls.py b/apps/backend/backend/urls.py index 78cf4b2..2779bf0 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("posts.urls")), ] urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/apps/backend/comments/querysets.py b/apps/backend/comments/querysets.py index 01b89b5..8ddcf55 100644 --- a/apps/backend/comments/querysets.py +++ b/apps/backend/comments/querysets.py @@ -33,3 +33,34 @@ def with_published_article_comment_count(queryset): + Coalesce(Subquery(reply_count, output_field=IntegerField()), Value(0)) ), ) + + +def with_community_post_comment_count(queryset): + top_level_count = ( + Comment.all_objects + .filter( + target__community_post_id=OuterRef("pk"), + is_deleted=False, + ) + .order_by() + .values("target__community_post") + .annotate(count=Count("id")) + .values("count")[:1] + ) + reply_count = ( + Comment.all_objects + .filter( + parent__target__community_post_id=OuterRef("pk"), + is_deleted=False, + ) + .order_by() + .values("parent__target__community_post") + .annotate(count=Count("id")) + .values("count")[:1] + ) + return queryset.annotate( + comment_count=( + Coalesce(Subquery(top_level_count, output_field=IntegerField()), Value(0)) + + Coalesce(Subquery(reply_count, output_field=IntegerField()), Value(0)) + ), + ) diff --git a/apps/backend/comments/serializers.py b/apps/backend/comments/serializers.py index c52c20e..9172ee0 100644 --- a/apps/backend/comments/serializers.py +++ b/apps/backend/comments/serializers.py @@ -8,6 +8,7 @@ serialize_markdown_mentions, validate_markdown_mentions, ) +from posts.models import CommunityPost from .models import Comment @@ -17,6 +18,7 @@ class CommentReadSerializer(serializers.ModelSerializer): render_body = serializers.SerializerMethodField() mention_users = serializers.SerializerMethodField() published_article = serializers.SerializerMethodField() + community_post = serializers.SerializerMethodField() reply_count = serializers.SerializerMethodField() class Meta: @@ -27,6 +29,7 @@ class Meta: "author_username", "target", "published_article", + "community_post", "parent", "body", "render_body", @@ -43,6 +46,11 @@ def get_published_article(self, obj): return obj.parent.target.published_article_id return obj.target.published_article_id + def get_community_post(self, obj): + if obj.parent_id is not None: + return obj.parent.target.community_post_id + return obj.target.community_post_id + def get_render_body(self, obj): return render_markdown_mentions(obj.body, obj.mentions) @@ -58,6 +66,7 @@ def get_reply_count(self, obj): class CommentWriteSerializer(serializers.Serializer): published_article = serializers.UUIDField(required=False) + community_post = serializers.UUIDField(required=False) target = serializers.UUIDField(required=False) body = serializers.CharField(allow_blank=False, trim_whitespace=True) mentions = serializers.ListField( @@ -75,6 +84,15 @@ def validate_published_article(self, value): code="published_article_not_found", ) from exc + def validate_community_post(self, value): + try: + return CommunityPost.objects.get(pk=value) + except CommunityPost.DoesNotExist as exc: + raise serializers.ValidationError( + detail="Community post does not exist", + code="community_post_not_found", + ) from exc + def validate_target(self, value): try: return ContentTarget.objects.select_related("comment").get(pk=value) @@ -95,6 +113,7 @@ def validate_body(self, value): def validate(self, attrs): target = attrs.get("target") published_article = attrs.get("published_article") + community_post = attrs.get("community_post") body = attrs.get("body", getattr(self.instance, "body", "")) mentions = attrs.get("mentions", getattr(self.instance, "mentions", [])) try: @@ -103,22 +122,23 @@ def validate(self, attrs): raise serializers.ValidationError(detail=exc.detail, code=exc.code) from exc if self.instance is not None: - if target is not None or published_article is not None: + if target is not None or published_article is not None or community_post is not None: raise serializers.ValidationError( detail="Comment target cannot be changed", code="comment_target_immutable", ) return attrs - if target is not None and published_article is not None: + direct_targets = [target, published_article, community_post] + if sum(item is not None for item in direct_targets) > 1: raise serializers.ValidationError( - detail="Provide either target or published_article, not both", + detail="Provide only one comment target", code="ambiguous_comment_target", ) - if target is None and published_article is None: + if target is None and published_article is None and community_post is None: raise serializers.ValidationError( - detail="A published article or content target is required", + detail="A published article, community post, or content target is required", code="comment_target_required", ) diff --git a/apps/backend/comments/services.py b/apps/backend/comments/services.py index 476f68a..d4c55ca 100644 --- a/apps/backend/comments/services.py +++ b/apps/backend/comments/services.py @@ -5,14 +5,24 @@ from core.models import ContentTarget from core.services.content_targets import ( get_or_create_comment_target, + get_or_create_community_post_target, get_or_create_published_article_target, ) +from posts.models import CommunityPost from .models import Comment @transaction.atomic -def create_comment(*, author, body: str, mentions: list, published_article: PublishedArticle = None, target=None): +def create_comment( + *, + author, + body: str, + mentions: list, + published_article: PublishedArticle = None, + community_post: CommunityPost = None, + target=None, +): if target is not None: if target.comment_id is None: raise ServiceError( @@ -22,13 +32,16 @@ def create_comment(*, author, body: str, mentions: list, published_article: Publ target_comment = target.comment parent = target_comment if target_comment.parent_id is None else target_comment.parent else: - if published_article is None: + if published_article is None and community_post is None: raise ServiceError( - detail="A published article is required", - code="published_article_required", + detail="A published article or community post is required", + code="content_target_required", ) parent = None - target = get_or_create_published_article_target(published_article) + if published_article is not None: + target = get_or_create_published_article_target(published_article) + else: + target = get_or_create_community_post_target(community_post) comment = Comment.objects.create( author=author, @@ -62,3 +75,13 @@ def get_published_article_target(published_article_id): ) except ContentTarget.DoesNotExist: return None + + +def get_community_post_target(community_post_id): + try: + return ContentTarget.objects.get( + target_type=ContentTarget.TargetType.COMMUNITY_POST, + community_post_id=community_post_id, + ) + except ContentTarget.DoesNotExist: + return None diff --git a/apps/backend/comments/tests/test_models.py b/apps/backend/comments/tests/test_models.py index 7b1b4ce..6db0b9b 100644 --- a/apps/backend/comments/tests/test_models.py +++ b/apps/backend/comments/tests/test_models.py @@ -32,6 +32,18 @@ def test_comment_has_own_content_target(self): self.assertTrue(ContentTarget.objects.filter(comment=comment).exists()) + def test_comment_created_directly_has_content_target(self): + target = get_or_create_published_article_target(self.published) + + comment = Comment.objects.create( + author=self.author, + target=target, + body="Direct", + ) + + self.assertEqual(comment.content_target.target_type, ContentTarget.TargetType.COMMENT) + self.assertEqual(comment.content_target.comment, comment) + def test_published_article_delete_cascades_comments_and_comment_targets(self): comment = create_comment(self.author, self.published) comment_target = comment.content_target @@ -41,4 +53,3 @@ def test_published_article_delete_cascades_comments_and_comment_targets(self): self.assertFalse(PublishedArticle.objects.filter(id=self.published.id).exists()) self.assertFalse(Comment.all_objects.filter(id=comment.id).exists()) self.assertFalse(ContentTarget.objects.filter(id=comment_target.id).exists()) - diff --git a/apps/backend/comments/tests/test_views.py b/apps/backend/comments/tests/test_views.py index dfcd68b..864a0e2 100644 --- a/apps/backend/comments/tests/test_views.py +++ b/apps/backend/comments/tests/test_views.py @@ -5,6 +5,7 @@ from comments.models import Comment from core.tests.factories import ( create_comment, + create_community_post, create_published_article, create_source_article, create_user, @@ -40,6 +41,28 @@ def test_user_can_comment_on_published_article(self): self.assertEqual(response.data["data"]["body"], "This is useful") self.assertEqual(Comment.objects.count(), 1) + def test_user_can_comment_on_community_post(self): + post = create_community_post(author=self.author, body="Post") + + self.authenticate(self.other_user) + response = self.post_json( + reverse("comment-list"), + { + "community_post": str(post.id), + "body": "A post comment", + }, + ) + + self.assert_success_response( + response, + status_code=status.HTTP_201_CREATED, + code="created", + ) + self.assert_uuid_equal(response.data["data"]["target"], post.content_target.id) + self.assert_uuid_equal(response.data["data"]["community_post"], post.id) + self.assertIsNone(response.data["data"]["published_article"]) + self.assertEqual(Comment.objects.count(), 1) + def test_user_can_reply_to_top_level_comment(self): parent = create_comment(self.author, self.published, body="Top level") @@ -215,6 +238,37 @@ def test_list_filters_comments_by_published_article(self): comment_ids = {item["id"] for item in response.data["data"]["results"]} self.assertEqual(comment_ids, {str(top_level.id), str(reply.id)}) + def test_list_filters_comments_by_community_post(self): + post = create_community_post(author=self.author, body="Post") + other_post = create_community_post(author=self.author, body="Other") + top_level = Comment.objects.create( + author=self.author, + target=post.content_target, + body="Top level", + ) + reply = create_comment(self.other_user, None, reply_to=top_level, body="Reply") + Comment.objects.create( + author=self.author, + target=other_post.content_target, + body="Other", + ) + + self.authenticate(self.author) + response = self.get_json( + reverse("comment-list"), + { + "community_post": str(post.id), + }, + ) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="listed", + ) + comment_ids = {item["id"] for item in response.data["data"]["results"]} + self.assertEqual(comment_ids, {str(top_level.id), str(reply.id)}) + def test_published_article_response_includes_comment_count(self): top_level = create_comment(self.author, self.published, body="Top level") create_comment(self.other_user, self.published, reply_to=top_level, body="Reply") diff --git a/apps/backend/comments/views.py b/apps/backend/comments/views.py index d8d7482..964fcbe 100644 --- a/apps/backend/comments/views.py +++ b/apps/backend/comments/views.py @@ -7,6 +7,7 @@ from .serializers import CommentReadSerializer, CommentWriteSerializer from .services import ( create_comment, + get_community_post_target, get_published_article_target, soft_delete_comment, update_comment, @@ -19,6 +20,7 @@ class CommentViewSet(MyModelViewSet): "target", "target__published_article", "target__comment", + "target__community_post", "parent", "parent__target", ) @@ -46,6 +48,7 @@ def get_queryset(self): ) ) published_article_id = self.request.query_params.get("published_article") + community_post_id = self.request.query_params.get("community_post") parent_id = self.request.query_params.get("parent") if published_article_id: @@ -57,6 +60,15 @@ def get_queryset(self): | Q(parent__target=target) ) + if community_post_id: + target = get_community_post_target(community_post_id) + if target is None: + return queryset.none() + queryset = queryset.filter( + Q(target=target) + | Q(parent__target=target) + ) + if parent_id: queryset = queryset.filter(parent_id=parent_id) @@ -73,6 +85,7 @@ def create(self, request, *args, **kwargs): body=input_serializer.validated_data["body"], mentions=input_serializer.validated_data["mentions"], published_article=input_serializer.validated_data.get("published_article"), + community_post=input_serializer.validated_data.get("community_post"), target=input_serializer.validated_data.get("target"), ) output_serializer = CommentReadSerializer( diff --git a/apps/backend/core/apps.py b/apps/backend/core/apps.py index c0ce093..7dc3779 100644 --- a/apps/backend/core/apps.py +++ b/apps/backend/core/apps.py @@ -4,3 +4,6 @@ class CoreConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "core" + + def ready(self): + from . import signals # noqa: F401 diff --git a/apps/backend/core/migrations/0002_remove_contenttarget_content_target_requires_exactly_one_object_and_more.py b/apps/backend/core/migrations/0002_remove_contenttarget_content_target_requires_exactly_one_object_and_more.py new file mode 100644 index 0000000..d941990 --- /dev/null +++ b/apps/backend/core/migrations/0002_remove_contenttarget_content_target_requires_exactly_one_object_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.5 on 2026-05-21 11:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('articles', '0004_alter_sourcearticle_markdown'), + ('comments', '0003_comment_mentions'), + ('core', '0001_initial'), + ('posts', '0002_communitypost_mentions'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='contenttarget', + name='content_target_requires_exactly_one_object', + ), + migrations.AddField( + model_name='contenttarget', + name='community_post', + field=models.OneToOneField(blank=True, help_text='The community post this content target points to', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content_target', to='posts.communitypost', verbose_name='community post'), + ), + migrations.AlterField( + model_name='contenttarget', + name='target_type', + field=models.IntegerField(choices=[(1, 'Published Article'), (2, 'Comment'), (3, 'Community Post')], db_index=True, help_text='The type of object this content target points to', verbose_name='target type'), + ), + migrations.AddConstraint( + model_name='contenttarget', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('comment__isnull', True), ('community_post__isnull', True), ('published_article__isnull', False), ('target_type', 1)), models.Q(('comment__isnull', False), ('community_post__isnull', True), ('published_article__isnull', True), ('target_type', 2)), models.Q(('comment__isnull', True), ('community_post__isnull', False), ('published_article__isnull', True), ('target_type', 3)), _connector='OR'), name='content_target_requires_exactly_one_object'), + ), + ] diff --git a/apps/backend/core/models.py b/apps/backend/core/models.py index 0deda31..9705d2b 100644 --- a/apps/backend/core/models.py +++ b/apps/backend/core/models.py @@ -13,6 +13,7 @@ class ContentTarget(UUIDPrimaryKeyMixin, class TargetType(models.IntegerChoices): PUBLISHED_ARTICLE = 1, "Published Article" COMMENT = 2, "Comment" + COMMUNITY_POST = 3, "Community Post" target_type = models.IntegerField( choices=TargetType.choices, @@ -38,6 +39,15 @@ class TargetType(models.IntegerChoices): verbose_name=_("comment"), help_text=_("The comment this content target points to"), ) + community_post = models.OneToOneField( + "posts.CommunityPost", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="content_target", + verbose_name=_("community post"), + help_text=_("The community post this content target points to"), + ) class Meta: verbose_name = _("content target") @@ -51,11 +61,19 @@ class Meta: target_type=1, published_article__isnull=False, comment__isnull=True, + community_post__isnull=True, ) | models.Q( target_type=2, published_article__isnull=True, comment__isnull=False, + community_post__isnull=True, + ) + | models.Q( + target_type=3, + published_article__isnull=True, + comment__isnull=True, + community_post__isnull=False, ) ), name="content_target_requires_exactly_one_object", diff --git a/apps/backend/core/services/content_targets.py b/apps/backend/core/services/content_targets.py index 621a5c8..9d74b70 100644 --- a/apps/backend/core/services/content_targets.py +++ b/apps/backend/core/services/content_targets.py @@ -6,7 +6,7 @@ def get_or_create_published_article_target(published_article: PublishedArticle): return ContentTarget.objects.get_or_create( target_type=ContentTarget.TargetType.PUBLISHED_ARTICLE, published_article=published_article, - defaults={"comment": None}, + defaults={"comment": None, "community_post": None}, )[0] @@ -14,6 +14,13 @@ def get_or_create_comment_target(comment): return ContentTarget.objects.get_or_create( target_type=ContentTarget.TargetType.COMMENT, comment=comment, - defaults={"published_article": None}, + defaults={"published_article": None, "community_post": None}, )[0] + +def get_or_create_community_post_target(community_post): + return ContentTarget.objects.get_or_create( + target_type=ContentTarget.TargetType.COMMUNITY_POST, + community_post=community_post, + defaults={"published_article": None, "comment": None}, + )[0] diff --git a/apps/backend/core/signals.py b/apps/backend/core/signals.py new file mode 100644 index 0000000..55fc606 --- /dev/null +++ b/apps/backend/core/signals.py @@ -0,0 +1,26 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.services.content_targets import ( + get_or_create_comment_target, + get_or_create_community_post_target, + get_or_create_published_article_target, +) + + +@receiver(post_save, sender="articles.PublishedArticle") +def create_published_article_content_target(sender, instance, created, **kwargs): + if created: + get_or_create_published_article_target(instance) + + +@receiver(post_save, sender="comments.Comment") +def create_comment_content_target(sender, instance, created, **kwargs): + if created: + get_or_create_comment_target(instance) + + +@receiver(post_save, sender="posts.CommunityPost") +def create_community_post_content_target(sender, instance, created, **kwargs): + if created: + get_or_create_community_post_target(instance) diff --git a/apps/backend/core/tests/factories.py b/apps/backend/core/tests/factories.py index 7be753a..16bf83d 100644 --- a/apps/backend/core/tests/factories.py +++ b/apps/backend/core/tests/factories.py @@ -13,6 +13,7 @@ ) from reactions.models import Reaction from reports.models import ContentReport, UserReport +from posts.services import create_community_post as create_community_post_service from .helpers import unique_suffix @@ -164,6 +165,18 @@ def create_comment(author, published_article, **kwargs): return comment +def create_community_post(author=None, body="Test post", **kwargs): + if author is None: + author = create_user() + + defaults = { + "author": author, + "body": body, + } + defaults.update(kwargs) + return create_community_post_service(**defaults) + + def create_content_report(reporter, target, **kwargs): from reports.services import build_content_report_snapshot diff --git a/apps/backend/posts/__init__.py b/apps/backend/posts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/posts/admin.py b/apps/backend/posts/admin.py new file mode 100644 index 0000000..96468df --- /dev/null +++ b/apps/backend/posts/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import CommunityPost + + +@admin.register(CommunityPost) +class CommunityPostAdmin(admin.ModelAdmin): + list_display = ( + "id", + "author", + "created_at", + "updated_at", + "is_deleted", + ) + list_filter = ("is_deleted", "created_at") + search_fields = ("body", "author__username") + ordering = ("-created_at",) diff --git a/apps/backend/posts/apps.py b/apps/backend/posts/apps.py new file mode 100644 index 0000000..81782a2 --- /dev/null +++ b/apps/backend/posts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PostsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "posts" diff --git a/apps/backend/posts/migrations/0001_initial.py b/apps/backend/posts/migrations/0001_initial.py new file mode 100644 index 0000000..22cb61f --- /dev/null +++ b/apps/backend/posts/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.5 on 2026-05-11 03:25 + +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CommunityPost', + 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')), + ('is_deleted', models.BooleanField(db_index=True, default=False, help_text='Whether the object is deleted', verbose_name='deleted')), + ('body', models.TextField(help_text='The post body', verbose_name='body')), + ('author', models.ForeignKey(help_text='The author of the post', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='community_posts', to=settings.AUTH_USER_MODEL, verbose_name='author')), + ], + options={ + 'verbose_name': 'community post', + 'verbose_name_plural': 'community posts', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['author', 'created_at'], name='posts_commu_author__2f7e8e_idx'), models.Index(fields=['is_deleted', 'created_at'], name='posts_commu_is_dele_0ee537_idx')], + }, + ), + ] diff --git a/apps/backend/posts/migrations/0002_communitypost_mentions.py b/apps/backend/posts/migrations/0002_communitypost_mentions.py new file mode 100644 index 0000000..50c195c --- /dev/null +++ b/apps/backend/posts/migrations/0002_communitypost_mentions.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-05-21 11:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='communitypost', + name='mentions', + field=models.JSONField(blank=True, default=list, help_text='Ordered user IDs referenced by mention tokens in the post body', verbose_name='mentions'), + ), + ] diff --git a/apps/backend/posts/migrations/__init__.py b/apps/backend/posts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/posts/models.py b/apps/backend/posts/models.py new file mode 100644 index 0000000..adb3a24 --- /dev/null +++ b/apps/backend/posts/models.py @@ -0,0 +1,42 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.model_mixins import SoftDeleteMixin, TimeStampedMixin, UUIDPrimaryKeyMixin + + +class CommunityPost(UUIDPrimaryKeyMixin, + TimeStampedMixin, + SoftDeleteMixin, + models.Model): + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="community_posts", + verbose_name=_("author"), + help_text=_("The author of the post"), + ) + body = models.TextField( + verbose_name=_("body"), + help_text=_("The post body"), + ) + mentions = models.JSONField( + default=list, + blank=True, + verbose_name=_("mentions"), + help_text=_("Ordered user IDs referenced by mention tokens in the post body"), + ) + + class Meta: + verbose_name = _("community post") + verbose_name_plural = _("community posts") + + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["author", "created_at"]), + models.Index(fields=["is_deleted", "created_at"]), + ] + + def __str__(self): + return f"Post {self.id} by {self.author_id}" diff --git a/apps/backend/posts/permissions.py b/apps/backend/posts/permissions.py new file mode 100644 index 0000000..af4d654 --- /dev/null +++ b/apps/backend/posts/permissions.py @@ -0,0 +1,16 @@ +from rest_framework import permissions + + +class CommunityPostPermission(permissions.BasePermission): + """ + Authenticated users can read and create community posts. + Authors can edit and soft-delete their own community posts. + """ + + def has_permission(self, request, view): + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + return obj.author == request.user diff --git a/apps/backend/posts/serializers.py b/apps/backend/posts/serializers.py new file mode 100644 index 0000000..affeaf1 --- /dev/null +++ b/apps/backend/posts/serializers.py @@ -0,0 +1,153 @@ +from rest_framework import serializers +from django.db import models + +from core.exceptions import ServiceError +from core.utils.markdown import ( + render_markdown_mentions, + serialize_markdown_mentions, + validate_markdown_mentions, +) +from users.serializers import UserListSerializer + +from .models import CommunityPost + + +class CommunityPostReadSerializer(serializers.ModelSerializer): + author = UserListSerializer(read_only=True) + author_username = serializers.SerializerMethodField() + content_target = serializers.SerializerMethodField() + render_body = serializers.SerializerMethodField() + mention_users = serializers.SerializerMethodField() + like_count = serializers.SerializerMethodField() + dislike_count = serializers.SerializerMethodField() + my_reaction = serializers.SerializerMethodField() + comment_count = serializers.SerializerMethodField() + + def get_author_username(self, obj): + if obj.author is None: + return None + return obj.author.username + + def get_render_body(self, obj): + return render_markdown_mentions(obj.body, obj.mentions) + + def get_content_target(self, obj): + try: + return str(obj.content_target.id) + except CommunityPost.content_target.RelatedObjectDoesNotExist: + return None + + def get_mention_users(self, obj): + return serialize_markdown_mentions(obj.mentions) + + def get_like_count(self, obj): + annotated_value = getattr(obj, "like_count", None) + if annotated_value is not None: + return annotated_value + + content_target = getattr(obj, "content_target", None) + if content_target is None: + return 0 + + from reactions.models import Reaction + + return content_target.reactions.filter( + reaction_type=Reaction.ReactionType.LIKE, + ).count() + + def get_dislike_count(self, obj): + annotated_value = getattr(obj, "dislike_count", None) + if annotated_value is not None: + return annotated_value + + content_target = getattr(obj, "content_target", None) + if content_target is None: + return 0 + + from reactions.models import Reaction + + return content_target.reactions.filter( + reaction_type=Reaction.ReactionType.DISLIKE, + ).count() + + def get_my_reaction(self, obj): + annotated_value = getattr(obj, "my_reaction", None) + if annotated_value is not None: + return annotated_value + + request = self.context.get("request") + if request is None or request.user.is_anonymous: + return None + + content_target = getattr(obj, "content_target", None) + if content_target is None: + return None + + return ( + content_target.reactions + .filter(user=request.user) + .values_list("reaction_type", flat=True) + .first() + ) + + def get_comment_count(self, obj): + annotated_value = getattr(obj, "comment_count", None) + if annotated_value is not None: + return annotated_value + + content_target = getattr(obj, "content_target", None) + if content_target is None: + return 0 + + from comments.models import Comment + + return Comment.objects.filter( + models.Q(target=content_target) + | models.Q(parent__target=content_target) + ).count() + + class Meta: + model = CommunityPost + fields = ( + "id", + "author", + "author_username", + "content_target", + "body", + "render_body", + "mentions", + "mention_users", + "like_count", + "dislike_count", + "my_reaction", + "comment_count", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class CommunityPostWriteSerializer(serializers.Serializer): + body = serializers.CharField(max_length=5000, trim_whitespace=True) + mentions = serializers.ListField( + child=serializers.UUIDField(), + required=False, + allow_empty=True, + ) + + def validate_body(self, value): + if not value: + raise serializers.ValidationError( + detail="Community post body cannot be blank", + code="blank_post_body", + ) + return value + + def validate(self, attrs): + body = attrs.get("body", getattr(self.instance, "body", "")) + mentions = attrs.get("mentions", getattr(self.instance, "mentions", [])) + try: + attrs["mentions"] = validate_markdown_mentions(body=body, mentions=mentions) + except ServiceError as exc: + raise serializers.ValidationError(detail=exc.detail, code=exc.code) from exc + return attrs diff --git a/apps/backend/posts/services.py b/apps/backend/posts/services.py new file mode 100644 index 0000000..916430b --- /dev/null +++ b/apps/backend/posts/services.py @@ -0,0 +1,23 @@ +from core.services.content_targets import get_or_create_community_post_target + +from .models import CommunityPost + + +def create_community_post(*, author, body: str, mentions: list = None): + post = CommunityPost.objects.create(author=author, body=body, mentions=mentions or []) + get_or_create_community_post_target(post) + return post + + +def update_community_post(*, post: CommunityPost, body: str, mentions: list = None): + post.body = body + if mentions is not None: + post.mentions = mentions + post.save(update_fields=["body", "mentions", "updated_at"]) + return post + + +def soft_delete_community_post(post: CommunityPost): + post.is_deleted = True + post.save(update_fields=["is_deleted", "updated_at"]) + return post diff --git a/apps/backend/posts/tests/__init__.py b/apps/backend/posts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/posts/tests/test_models.py b/apps/backend/posts/tests/test_models.py new file mode 100644 index 0000000..2a461aa --- /dev/null +++ b/apps/backend/posts/tests/test_models.py @@ -0,0 +1,58 @@ +from core.models import ContentTarget +from core.tests.factories import create_community_post, create_user +from core.tests.testcases import BaseTestCase +from posts.models import CommunityPost + + +class CommunityPostModelTests(BaseTestCase): + def test_post_creation_with_author(self): + author = create_user(username="post-author") + + post = create_community_post(author=author, body="Hello community") + + self.assertEqual(post.author, author) + self.assertEqual(post.body, "Hello community") + self.assertFalse(post.is_deleted) + self.assertTrue(CommunityPost.objects.filter(id=post.id).exists()) + self.assertEqual(post.content_target.target_type, ContentTarget.TargetType.COMMUNITY_POST) + self.assertEqual(post.content_target.community_post, post) + + def test_post_created_directly_has_content_target(self): + author = create_user(username="direct-post-author") + + post = CommunityPost.objects.create(author=author, body="Direct") + + self.assertEqual(post.content_target.target_type, ContentTarget.TargetType.COMMUNITY_POST) + self.assertEqual(post.content_target.community_post, post) + + def test_posts_are_ordered_newest_first(self): + author = create_user(username="post-author") + older = create_community_post(author=author, body="Older") + newer = create_community_post(author=author, body="Newer") + + posts = list(CommunityPost.objects.all()) + + self.assertEqual(posts, [newer, older]) + + def test_soft_deleted_posts_are_excluded_from_default_manager(self): + post = create_community_post(body="Deleted") + + post.is_deleted = True + post.save(update_fields=["is_deleted"]) + + self.assertFalse(CommunityPost.objects.filter(id=post.id).exists()) + self.assertTrue(CommunityPost.all_objects.filter(id=post.id, is_deleted=True).exists()) + + def test_string_representation_includes_post_and_author_ids(self): + author = create_user(username="post-author") + post = create_community_post(author=author, body="Hello community") + + self.assertEqual(str(post), f"Post {post.id} by {author.id}") + + def test_post_delete_cascades_content_target(self): + post = create_community_post() + target_id = post.content_target.id + + post.delete() + + self.assertFalse(ContentTarget.objects.filter(id=target_id).exists()) diff --git a/apps/backend/posts/tests/test_permissions.py b/apps/backend/posts/tests/test_permissions.py new file mode 100644 index 0000000..bfb8931 --- /dev/null +++ b/apps/backend/posts/tests/test_permissions.py @@ -0,0 +1,47 @@ +from django.contrib.auth.models import AnonymousUser + +from rest_framework.test import APIRequestFactory + +from core.tests.factories import create_user +from core.tests.testcases import BaseTestCase +from posts.models import CommunityPost +from posts.permissions import CommunityPostPermission + + +class CommunityPostPermissionTests(BaseTestCase): + def setUp(self): + self.permission = CommunityPostPermission() + self.factory = APIRequestFactory() + self.author = create_user(username="post-author") + self.other_user = create_user(username="other-user") + self.post = CommunityPost.objects.create(author=self.author, body="Hello community") + + def request(self, method, user): + request = getattr(self.factory, method.lower())("/posts/") + request.user = user + return request + + def test_anonymous_users_do_not_have_general_permission(self): + request = self.request("get", AnonymousUser()) + + self.assertFalse(self.permission.has_permission(request, None)) + + def test_authenticated_users_have_general_permission(self): + request = self.request("get", self.author) + + self.assertTrue(self.permission.has_permission(request, None)) + + def test_safe_object_methods_are_allowed_for_authenticated_users(self): + request = self.request("get", self.other_user) + + self.assertTrue(self.permission.has_object_permission(request, None, self.post)) + + def test_author_can_modify_own_post(self): + request = self.request("patch", self.author) + + self.assertTrue(self.permission.has_object_permission(request, None, self.post)) + + def test_other_user_cannot_modify_post(self): + request = self.request("patch", self.other_user) + + self.assertFalse(self.permission.has_object_permission(request, None, self.post)) diff --git a/apps/backend/posts/tests/test_serializers.py b/apps/backend/posts/tests/test_serializers.py new file mode 100644 index 0000000..965fa15 --- /dev/null +++ b/apps/backend/posts/tests/test_serializers.py @@ -0,0 +1,71 @@ +from rest_framework.exceptions import ErrorDetail + +from core.tests.factories import create_community_post, create_user +from core.tests.testcases import BaseTestCase +from posts.serializers import CommunityPostReadSerializer, CommunityPostWriteSerializer + + +class CommunityPostSerializerTests(BaseTestCase): + def test_write_serializer_rejects_blank_body(self): + serializer = CommunityPostWriteSerializer(data={"body": ""}) + + self.assertFalse(serializer.is_valid()) + self.assertEqual( + serializer.errors["body"], + [ErrorDetail(string="This field may not be blank.", code="blank")], + ) + + def test_write_serializer_rejects_whitespace_only_body(self): + serializer = CommunityPostWriteSerializer(data={"body": " \n\t "}) + + self.assertFalse(serializer.is_valid()) + self.assertEqual( + serializer.errors["body"], + [ErrorDetail(string="This field may not be blank.", code="blank")], + ) + + def test_write_serializer_rejects_body_over_5000_characters(self): + serializer = CommunityPostWriteSerializer(data={"body": "x" * 5001}) + + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors["body"][0].code, "max_length") + + def test_write_serializer_accepts_and_trims_valid_body(self): + serializer = CommunityPostWriteSerializer(data={"body": " Hello community "}) + + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["body"], "Hello community") + + def test_read_serializer_exposes_author_username(self): + author = create_user(username="post-author") + post = create_community_post(author=author, body="Hello community") + + data = CommunityPostReadSerializer(post).data + + self.assertEqual(data["author_username"], "post-author") + self.assertEqual(data["author"]["username"], "post-author") + self.assertEqual(data["content_target"], str(post.content_target.id)) + + def test_write_serializer_validates_mentions(self): + mentioned = create_user(username="mentioned") + serializer = CommunityPostWriteSerializer( + data={ + "body": "Hello {{mention:0}}", + "mentions": [str(mentioned.id)], + } + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["mentions"], [str(mentioned.id)]) + + def test_write_serializer_rejects_unused_mentions(self): + mentioned = create_user(username="mentioned") + serializer = CommunityPostWriteSerializer( + data={ + "body": "Hello community", + "mentions": [str(mentioned.id)], + } + ) + + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors["non_field_errors"][0].code, "unused_mention") diff --git a/apps/backend/posts/tests/test_services.py b/apps/backend/posts/tests/test_services.py new file mode 100644 index 0000000..7298f75 --- /dev/null +++ b/apps/backend/posts/tests/test_services.py @@ -0,0 +1,54 @@ +from core.tests.factories import create_user +from core.tests.testcases import BaseTestCase +from posts.models import CommunityPost +from posts.services import ( + create_community_post, + soft_delete_community_post, + update_community_post, +) + + +class CommunityPostServiceTests(BaseTestCase): + def test_create_community_post_returns_created_post(self): + author = create_user(username="post-author") + + post = create_community_post(author=author, body="Hello community") + + self.assertEqual(post.author, author) + self.assertEqual(post.body, "Hello community") + self.assertTrue(CommunityPost.objects.filter(id=post.id).exists()) + self.assertEqual(post.content_target.community_post, post) + + def test_update_community_post_updates_body_and_returns_post(self): + author = create_user(username="post-author") + post = CommunityPost.objects.create(author=author, body="Before") + + updated = update_community_post(post=post, body="After") + + post.refresh_from_db() + self.assertEqual(updated, post) + self.assertEqual(post.body, "After") + + def test_update_community_post_updates_mentions(self): + author = create_user(username="post-author") + mentioned = create_user(username="mentioned") + post = CommunityPost.objects.create(author=author, body="Before") + + update_community_post( + post=post, + body="{{mention:0}}", + mentions=[str(mentioned.id)], + ) + + post.refresh_from_db() + self.assertEqual(post.mentions, [str(mentioned.id)]) + + def test_soft_delete_community_post_marks_post_deleted_and_returns_post(self): + author = create_user(username="post-author") + post = CommunityPost.objects.create(author=author, body="Hello community") + + deleted = soft_delete_community_post(post) + + post.refresh_from_db() + self.assertEqual(deleted, post) + self.assertTrue(post.is_deleted) diff --git a/apps/backend/posts/tests/test_views.py b/apps/backend/posts/tests/test_views.py new file mode 100644 index 0000000..2878b16 --- /dev/null +++ b/apps/backend/posts/tests/test_views.py @@ -0,0 +1,281 @@ +from django.urls import reverse + +from rest_framework import status + +from core.tests.factories import create_community_post, create_user +from core.tests.testcases import BaseAPITestCase +from comments.models import Comment +from posts.models import CommunityPost +from reactions.models import Reaction + + +class CommunityPostViewTests(BaseAPITestCase): + def setUp(self): + self.author = create_user(username="post-author") + self.other_user = create_user(username="other-user") + + def test_list_returns_authenticated_visible_posts_newest_first(self): + older = create_community_post(author=self.author, body="Older") + deleted = create_community_post(author=self.author, body="Deleted") + deleted.is_deleted = True + deleted.save(update_fields=["is_deleted"]) + newer = create_community_post(author=self.other_user, body="Newer") + + self.authenticate(self.author) + response = self.get_json(reverse("community_post-list")) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="listed", + ) + post_ids = [item["id"] for item in response.data["data"]["results"]] + self.assertEqual(post_ids, [str(newer.id), str(older.id)]) + + def test_authenticated_user_can_retrieve_post(self): + post = create_community_post(author=self.author, body="Hello community") + + self.authenticate(self.other_user) + response = self.get_json(reverse("community_post-detail", args=[post.id])) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="retrieved", + ) + self.assert_uuid_equal(response.data["data"]["id"], post.id) + self.assertEqual(response.data["data"]["body"], "Hello community") + self.assertEqual(response.data["data"]["author_username"], "post-author") + + def test_post_response_includes_comment_and_reaction_summary(self): + post = create_community_post(author=self.author, body="Summary") + Comment.objects.create( + author=self.other_user, + target=post.content_target, + body="Top level", + ) + Reaction.objects.create( + user=self.author, + target=post.content_target, + reaction_type=Reaction.ReactionType.LIKE, + ) + Reaction.objects.create( + user=self.other_user, + target=post.content_target, + reaction_type=Reaction.ReactionType.DISLIKE, + ) + + self.authenticate(self.author) + response = self.get_json(reverse("community_post-detail", args=[post.id])) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="retrieved", + ) + self.assertEqual(response.data["data"]["comment_count"], 1) + self.assertEqual(response.data["data"]["like_count"], 1) + self.assertEqual(response.data["data"]["dislike_count"], 1) + self.assertEqual(response.data["data"]["my_reaction"], Reaction.ReactionType.LIKE) + + def test_authenticated_user_can_create_post_as_request_user(self): + self.authenticate(self.author) + response = self.post_json( + reverse("community_post-list"), + { + "body": "Hello community", + }, + ) + + self.assert_success_response( + response, + status_code=status.HTTP_201_CREATED, + code="created", + ) + post = CommunityPost.objects.get() + self.assertEqual(post.author, self.author) + self.assertEqual(post.body, "Hello community") + self.assert_uuid_equal(response.data["data"]["author"]["id"], self.author.id) + self.assert_uuid_equal(response.data["data"]["content_target"], post.content_target.id) + self.assertEqual(response.data["data"]["mentions"], []) + + def test_post_mentions_render_with_current_usernames(self): + self.authenticate(self.author) + response = self.post_json( + reverse("community_post-list"), + { + "body": "{{mention:0}} and {{mention:1}}", + "mentions": [str(self.other_user.id), str(self.author.id)], + }, + ) + + self.assert_success_response( + response, + status_code=status.HTTP_201_CREATED, + code="created", + ) + self.assertEqual( + response.data["data"]["render_body"], + "[@other-user](http://testserver/users/other-user) " + "and [@post-author](http://testserver/users/post-author)", + ) + self.assertEqual( + response.data["data"]["mention_users"], + [ + { + "user_id": str(self.other_user.id), + "username": "other-user", + }, + { + "user_id": str(self.author.id), + "username": "post-author", + }, + ], + ) + + def test_create_rejects_unused_mentions(self): + self.authenticate(self.author) + response = self.post_json( + reverse("community_post-list"), + { + "body": "{{mention:0}}", + "mentions": [str(self.other_user.id), str(self.author.id)], + }, + ) + + self.assert_error_response( + response, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def test_author_can_update_own_post(self): + post = create_community_post(author=self.author, body="Before") + + self.authenticate(self.author) + response = self.patch_json( + reverse("community_post-detail", args=[post.id]), + { + "body": "After", + }, + ) + + post.refresh_from_db() + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="updated", + ) + self.assertEqual(post.body, "After") + self.assertEqual(response.data["data"]["body"], "After") + + def test_author_can_patch_post_without_body(self): + post = create_community_post(author=self.author, body="Before") + + self.authenticate(self.author) + response = self.patch_json( + reverse("community_post-detail", args=[post.id]), + {}, + ) + + post.refresh_from_db() + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="updated", + ) + self.assertEqual(post.body, "Before") + self.assertEqual(response.data["data"]["body"], "Before") + + def test_other_user_cannot_update_post(self): + post = create_community_post(author=self.author, body="Before") + + self.authenticate(self.other_user) + response = self.patch_json( + reverse("community_post-detail", args=[post.id]), + { + "body": "After", + }, + ) + + post.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(post.body, "Before") + + def test_author_can_destroy_own_post_with_soft_delete(self): + post = create_community_post(author=self.author, body="Hello community") + + self.authenticate(self.author) + response = self.delete_json(reverse("community_post-detail", args=[post.id])) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="deleted", + ) + self.assertEqual(response.content, response.rendered_content) + self.assertIn(b'"success":true', response.content) + self.assertFalse(CommunityPost.objects.filter(id=post.id).exists()) + self.assertTrue(CommunityPost.all_objects.filter(id=post.id, is_deleted=True).exists()) + + def test_authenticated_user_can_retrieve_post_without_author(self): + post = CommunityPost.objects.create(author=None, body="Orphaned post") + + self.authenticate(self.other_user) + response = self.get_json(reverse("community_post-detail", args=[post.id])) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="retrieved", + ) + self.assertIsNone(response.data["data"]["author"]) + self.assertIsNone(response.data["data"]["author_username"]) + + def test_other_user_cannot_destroy_post(self): + post = create_community_post(author=self.author, body="Hello community") + + self.authenticate(self.other_user) + response = self.delete_json(reverse("community_post-detail", args=[post.id])) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(CommunityPost.objects.filter(id=post.id).exists()) + + def test_anonymous_users_cannot_access_posts(self): + post = create_community_post(author=self.author, body="Hello community") + + responses = [ + self.get_json(reverse("community_post-list")), + self.get_json(reverse("community_post-detail", args=[post.id])), + self.post_json(reverse("community_post-list"), {"body": "Hello"}), + self.patch_json(reverse("community_post-detail", args=[post.id]), {"body": "After"}), + self.delete_json(reverse("community_post-detail", args=[post.id])), + ] + + for response in responses: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_rejects_invalid_body_values(self): + self.authenticate(self.author) + + for body in ("", " \n\t ", "x" * 5001): + response = self.post_json(reverse("community_post-list"), {"body": body}) + + self.assert_error_response( + response, + status_code=status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual(CommunityPost.objects.count(), 0) + + def test_update_rejects_invalid_body_values(self): + post = create_community_post(author=self.author, body="Before") + self.authenticate(self.author) + + for body in ("", " \n\t ", "x" * 5001): + response = self.patch_json(reverse("community_post-detail", args=[post.id]), {"body": body}) + + self.assert_error_response( + response, + status_code=status.HTTP_400_BAD_REQUEST, + ) + post.refresh_from_db() + self.assertEqual(post.body, "Before") diff --git a/apps/backend/posts/urls.py b/apps/backend/posts/urls.py new file mode 100644 index 0000000..d2fd8f9 --- /dev/null +++ b/apps/backend/posts/urls.py @@ -0,0 +1,9 @@ +from rest_framework import routers + +from .views import CommunityPostViewSet + + +router = routers.SimpleRouter() +router.register(r"community_posts", CommunityPostViewSet, basename="community_post") + +urlpatterns = router.urls diff --git a/apps/backend/posts/views.py b/apps/backend/posts/views.py new file mode 100644 index 0000000..3ad323a --- /dev/null +++ b/apps/backend/posts/views.py @@ -0,0 +1,95 @@ +from rest_framework import status + +from core.views.viewsets import MyModelViewSet + +from .models import CommunityPost +from .permissions import CommunityPostPermission +from .serializers import CommunityPostReadSerializer, CommunityPostWriteSerializer +from .services import ( + create_community_post, + soft_delete_community_post, + update_community_post, +) + + +class CommunityPostViewSet(MyModelViewSet): + queryset = CommunityPost.objects.filter(is_deleted=False).select_related("author").order_by("-created_at") + permission_classes = [CommunityPostPermission] + default_serializer_class = CommunityPostReadSerializer + serializer_class_mapping = { + "create": CommunityPostWriteSerializer, + "update": CommunityPostWriteSerializer, + "partial_update": CommunityPostWriteSerializer, + } + + def get_serializer_class(self): + return self.serializer_class_mapping.get(self.action, self.default_serializer_class) + + def get_queryset(self): + from comments.querysets import with_community_post_comment_count + from reactions.querysets import with_community_post_reaction_summary + + queryset = with_community_post_reaction_summary( + super().get_queryset(), + user=self.request.user, + ) + return with_community_post_comment_count(queryset) + + def create(self, request, *args, **kwargs): + serializer = CommunityPostWriteSerializer( + data=request.data, + context=self.get_serializer_context(), + ) + serializer.is_valid(raise_exception=True) + post = create_community_post( + author=request.user, + **serializer.validated_data, + ) + output_serializer = CommunityPostReadSerializer( + instance=post, + context=self.get_serializer_context(), + ) + + return self.format_success_response( + message="created", + code="created", + data=output_serializer.data, + status_code=status.HTTP_201_CREATED, + ) + + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + post = self.get_object() + serializer = CommunityPostWriteSerializer( + post, + data=request.data, + partial=partial, + context=self.get_serializer_context(), + ) + serializer.is_valid(raise_exception=True) + post = update_community_post( + post=post, + body=serializer.validated_data.get("body", post.body), + mentions=serializer.validated_data.get("mentions", post.mentions), + ) + output_serializer = CommunityPostReadSerializer( + instance=post, + context=self.get_serializer_context(), + ) + + return self.format_success_response( + message="updated", + code="updated", + data=output_serializer.data, + status_code=status.HTTP_200_OK, + ) + + def destroy(self, request, *args, **kwargs): + post = self.get_object() + soft_delete_community_post(post=post) + + return self.format_success_response( + message="deleted", + code="deleted", + status_code=status.HTTP_200_OK, + ) diff --git a/apps/backend/reactions/querysets.py b/apps/backend/reactions/querysets.py index 2788e84..5be2986 100644 --- a/apps/backend/reactions/querysets.py +++ b/apps/backend/reactions/querysets.py @@ -31,3 +31,33 @@ def with_published_article_reaction_summary(queryset, *, user=None): queryset = queryset.annotate(my_reaction=Subquery(my_reaction)) return queryset + + +def with_community_post_reaction_summary(queryset, *, user=None): + queryset = queryset.annotate( + like_count=Count( + "content_target__reactions", + filter=Q( + content_target__reactions__reaction_type=Reaction.ReactionType.LIKE, + ), + ), + dislike_count=Count( + "content_target__reactions", + filter=Q( + content_target__reactions__reaction_type=Reaction.ReactionType.DISLIKE, + ), + ), + ) + + if user and user.is_authenticated: + my_reaction = ( + Reaction.objects + .filter( + user=user, + target__community_post_id=OuterRef("pk"), + ) + .values("reaction_type")[:1] + ) + queryset = queryset.annotate(my_reaction=Subquery(my_reaction)) + + return queryset diff --git a/apps/backend/reactions/serializers.py b/apps/backend/reactions/serializers.py index 252451f..eb0ea71 100644 --- a/apps/backend/reactions/serializers.py +++ b/apps/backend/reactions/serializers.py @@ -7,6 +7,7 @@ class ReactionReadSerializer(serializers.ModelSerializer): target_type = serializers.IntegerField(source="target.target_type", read_only=True) target_type_display = serializers.CharField(source="target.get_target_type_display", read_only=True) published_article = serializers.UUIDField(source="target.published_article_id", read_only=True) + community_post = serializers.UUIDField(source="target.community_post_id", read_only=True) reaction_type_display = serializers.CharField(source="get_reaction_type_display", read_only=True) class Meta: @@ -18,6 +19,7 @@ class Meta: "target_type", "target_type_display", "published_article", + "community_post", "reaction_type", "reaction_type_display", "created_at", @@ -27,6 +29,7 @@ class Meta: class ReactionWriteSerializer(serializers.Serializer): published_article = serializers.UUIDField(write_only=True, required=False) + community_post = serializers.UUIDField(write_only=True, required=False) reaction_type = serializers.ChoiceField(choices=Reaction.ReactionType.choices) def validate_published_article(self, value): @@ -40,18 +43,42 @@ def validate_published_article(self, value): code="published_article_not_found", ) from exc + def validate_community_post(self, value): + from posts.models import CommunityPost + + try: + return CommunityPost.objects.get(pk=value) + except CommunityPost.DoesNotExist as exc: + raise serializers.ValidationError( + detail="Community post does not exist", + code="community_post_not_found", + ) from exc + def validate(self, attrs): published_article = attrs.get("published_article") + community_post = attrs.get("community_post") - if self.instance is None and published_article is None: + if self.instance is None and published_article is None and community_post is None: raise serializers.ValidationError( - detail={"published_article": "A published article is required"}, - code="published_article_required", + detail="A published article or community post is required", + code="reaction_target_required", ) - if self.instance is not None and published_article is not None: - current_article_id = self.instance.target.published_article_id - if published_article.id != current_article_id: + if published_article is not None and community_post is not None: + raise serializers.ValidationError( + detail="Provide only one reaction target", + code="ambiguous_reaction_target", + ) + + if self.instance is not None: + target_changed = ( + published_article is not None + and published_article.id != self.instance.target.published_article_id + ) or ( + community_post is not None + and community_post.id != self.instance.target.community_post_id + ) + if target_changed: raise serializers.ValidationError( detail="Reaction target cannot be changed", code="content_target_immutable", diff --git a/apps/backend/reactions/services.py b/apps/backend/reactions/services.py index 4645dd5..15bedb4 100644 --- a/apps/backend/reactions/services.py +++ b/apps/backend/reactions/services.py @@ -2,7 +2,11 @@ from articles.models import PublishedArticle from core.models import ContentTarget -from core.services.content_targets import get_or_create_published_article_target +from core.services.content_targets import ( + get_or_create_community_post_target, + get_or_create_published_article_target, +) +from posts.models import CommunityPost from .models import Reaction @@ -18,6 +22,17 @@ def set_published_article_reaction(*, user, published_article: PublishedArticle, return reaction, created +@transaction.atomic +def set_community_post_reaction(*, user, community_post: CommunityPost, reaction_type: int): + target = get_or_create_community_post_target(community_post) + reaction, created = Reaction.objects.update_or_create( + user=user, + target=target, + defaults={"reaction_type": reaction_type}, + ) + return reaction, created + + def update_reaction_type(*, reaction: Reaction, reaction_type: int): reaction.reaction_type = reaction_type reaction.save(update_fields=["reaction_type"]) @@ -31,3 +46,12 @@ def clear_published_article_reaction(*, user, published_article: PublishedArticl target__published_article=published_article, ).delete() return deleted_count > 0 + + +def clear_community_post_reaction(*, user, community_post: CommunityPost): + deleted_count, _ = Reaction.objects.filter( + user=user, + target__target_type=ContentTarget.TargetType.COMMUNITY_POST, + target__community_post=community_post, + ).delete() + return deleted_count > 0 diff --git a/apps/backend/reactions/tests/test_views.py b/apps/backend/reactions/tests/test_views.py index 1236d03..4e64692 100644 --- a/apps/backend/reactions/tests/test_views.py +++ b/apps/backend/reactions/tests/test_views.py @@ -3,6 +3,7 @@ from rest_framework import status from core.tests.factories import ( + create_community_post, create_published_article, create_reaction, create_source_article, @@ -41,6 +42,28 @@ def test_user_can_like_published_article(self): self.assertEqual(Reaction.objects.count(), 1) self.assertEqual(ContentTarget.objects.count(), 1) + def test_user_can_like_community_post(self): + post = create_community_post(author=self.other_user, body="Post") + + self.authenticate(self.user) + response = self.post_json( + reverse("reaction-list"), + { + "community_post": str(post.id), + "reaction_type": Reaction.ReactionType.LIKE, + }, + ) + + self.assert_success_response( + response, + status_code=status.HTTP_201_CREATED, + code="created", + ) + self.assert_uuid_equal(response.data["data"]["user"], self.user.id) + self.assert_uuid_equal(response.data["data"]["community_post"], post.id) + self.assertIsNone(response.data["data"]["published_article"]) + self.assertEqual(Reaction.objects.count(), 1) + def test_posting_same_target_switches_existing_reaction(self): reaction = create_reaction( self.user, @@ -132,6 +155,30 @@ def test_clear_published_article_reaction_is_idempotent(self): ) self.assertFalse(response.data["data"]["deleted"]) + def test_user_can_clear_own_reaction_by_community_post(self): + post = create_community_post(author=self.other_user, body="Post") + reaction = Reaction.objects.create( + user=self.user, + target=post.content_target, + reaction_type=Reaction.ReactionType.LIKE, + ) + + self.authenticate(self.user) + response = self.delete_json( + reverse( + "reaction-clear-community-post", + args=[post.id], + ), + ) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="deleted", + ) + self.assertTrue(response.data["data"]["deleted"]) + self.assertFalse(Reaction.objects.filter(id=reaction.id).exists()) + def test_user_cannot_delete_another_users_reaction(self): reaction = create_reaction(self.other_user, self.published) diff --git a/apps/backend/reactions/views.py b/apps/backend/reactions/views.py index 57605c8..823ddc9 100644 --- a/apps/backend/reactions/views.py +++ b/apps/backend/reactions/views.py @@ -5,10 +5,13 @@ from articles.models import PublishedArticle from core.views.viewsets import MyModelViewSet +from posts.models import CommunityPost from .models import Reaction from .serializers import ReactionReadSerializer, ReactionWriteSerializer from .services import ( + clear_community_post_reaction, clear_published_article_reaction, + set_community_post_reaction, set_published_article_reaction, update_reaction_type, ) @@ -19,6 +22,7 @@ class ReactionViewSet(MyModelViewSet): "user", "target", "target__published_article", + "target__community_post", ) permission_classes = [IsAuthenticated] default_serializer_class = ReactionReadSerializer @@ -41,11 +45,18 @@ def create(self, request, *args, **kwargs): context=self.get_serializer_context(), ) input_serializer.is_valid(raise_exception=True) - reaction, created = set_published_article_reaction( - user=request.user, - published_article=input_serializer.validated_data["published_article"], - reaction_type=input_serializer.validated_data["reaction_type"], - ) + if "published_article" in input_serializer.validated_data: + reaction, created = set_published_article_reaction( + user=request.user, + published_article=input_serializer.validated_data["published_article"], + reaction_type=input_serializer.validated_data["reaction_type"], + ) + else: + reaction, created = set_community_post_reaction( + user=request.user, + community_post=input_serializer.validated_data["community_post"], + reaction_type=input_serializer.validated_data["reaction_type"], + ) output_serializer = ReactionReadSerializer( instance=reaction, context=self.get_serializer_context(), @@ -109,3 +120,22 @@ def clear_published_article(self, request, published_article_id=None): data={"deleted": deleted}, status_code=status.HTTP_200_OK, ) + + @action( + detail=False, + methods=["delete"], + url_path=r"community_posts/(?P[^/.]+)", + ) + def clear_community_post(self, request, community_post_id=None): + community_post = get_object_or_404(CommunityPost, pk=community_post_id) + deleted = clear_community_post_reaction( + user=request.user, + community_post=community_post, + ) + + return self.format_success_response( + message="deleted" if deleted else "not reacted", + code="deleted" if deleted else "not_reacted", + data={"deleted": deleted}, + status_code=status.HTTP_200_OK, + ) diff --git a/apps/backend/reports/services.py b/apps/backend/reports/services.py index f3dc8bc..bb07884 100644 --- a/apps/backend/reports/services.py +++ b/apps/backend/reports/services.py @@ -48,6 +48,20 @@ def build_content_report_snapshot(target: ContentTarget): ) return snapshot + if target.target_type == ContentTarget.TargetType.COMMUNITY_POST: + post = target.community_post + snapshot.update( + { + "target_object_id": str(post.id), + "author_id": str(post.author_id) if post.author_id else None, + "body": post.body, + "render_body": render_markdown_mentions(post.body, post.mentions), + "mentions": post.mentions, + "is_deleted": post.is_deleted, + } + ) + return snapshot + return snapshot @@ -103,4 +117,3 @@ def moderate_report(*, report, moderator, status: int, resolution_note: str = "" report.save(update_fields=["status", "resolution_note", "resolved_by", "resolved_at", "updated_at"]) return report - diff --git a/apps/backend/reports/tests/test_models.py b/apps/backend/reports/tests/test_models.py index 641452c..0fe2bd1 100644 --- a/apps/backend/reports/tests/test_models.py +++ b/apps/backend/reports/tests/test_models.py @@ -1,5 +1,6 @@ from core.services.content_targets import get_or_create_published_article_target from core.tests.factories import ( + create_community_post, create_content_report, create_published_article, create_source_article, @@ -39,6 +40,22 @@ def test_content_report_keeps_snapshot_when_target_is_deleted(self): self.assertEqual(report.snapshot["title"], "Guide") self.assertTrue(ContentReport.objects.filter(id=report.id).exists()) + def test_content_report_snapshots_community_post_mentions(self): + post = create_community_post( + author=self.reported_user, + body="Hello {{mention:0}}", + mentions=[str(self.reporter.id)], + ) + + report = create_content_report(self.reporter, post.content_target) + + self.assertEqual(report.snapshot["target_object_id"], str(post.id)) + self.assertEqual(report.snapshot["mentions"], [str(self.reporter.id)]) + self.assertEqual( + report.snapshot["render_body"], + "Hello [@reporter](http://testserver/users/reporter)", + ) + def test_user_report_keeps_snapshot_when_reported_user_is_deleted(self): report = create_user_report(self.reporter, self.reported_user) @@ -48,4 +65,3 @@ def test_user_report_keeps_snapshot_when_reported_user_is_deleted(self): self.assertIsNone(report.reported_user) self.assertEqual(report.snapshot["username"], "reported") self.assertTrue(UserReport.objects.filter(id=report.id).exists()) -