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 @@ -93,6 +93,7 @@ jobs:
backend
core
logs
posts
reactions
reports
tasks
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:start -->
# GitNexus — Code Intelligence
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 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.
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
- `posts`: community posts and related content
- `reactions`: like/dislike targets and user reactions
- `reports`: content and user moderation reports
- `tasks`: scheduled/background task definitions
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 posts reactions reports core users logs tasks backend manage.py
```

Or use the root Make targets:
Expand Down
14 changes: 14 additions & 0 deletions apps/backend/articles/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
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",
"posts.apps.PostsConfig",
"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",
"posts.apps.PostsConfig",
"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("posts.urls")),
]

urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
Expand Down
31 changes: 31 additions & 0 deletions apps/backend/comments/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
),
)
30 changes: 25 additions & 5 deletions apps/backend/comments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
serialize_markdown_mentions,
validate_markdown_mentions,
)
from posts.models import CommunityPost

from .models import Comment

Expand All @@ -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:
Expand All @@ -27,6 +29,7 @@ class Meta:
"author_username",
"target",
"published_article",
"community_post",
"parent",
"body",
"render_body",
Expand All @@ -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)

Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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",
)

Expand Down
33 changes: 28 additions & 5 deletions apps/backend/comments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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
13 changes: 12 additions & 1 deletion apps/backend/comments/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())

54 changes: 54 additions & 0 deletions apps/backend/comments/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")

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