From 88ce59aecc91360bb2b2879346fafd6831e48423 Mon Sep 17 00:00:00 2001 From: Mark <162816078+markashton480@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:24:20 +0000 Subject: [PATCH] fix: resolve 5 PR review blockers for comments v2 1. Reply HTMX target: server sends HX-Retarget/HX-Reswap headers to insert replies inside parent comment's .replies-container div 2. Empty thread swap target: always render #comments-list container even when no approved comments exist 3. Reaction hydration: add _annotate_reaction_counts() helper that hydrates reaction_counts and user_reacted on comments in get_context(), comment_poll(), and single-comment responses 4. HTMX error swap: return 200 instead of 422 for form errors since HTMX 2 doesn't swap 4xx responses by default 5. Vary header: use patch_vary_headers() instead of direct assignment to avoid overwriting existing Vary directives Also fixes _get_session_key() to handle missing session attribute (e.g. from RequestFactory in performance tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/blog/models.py | 9 +++- apps/comments/tests/test_v2.py | 4 +- apps/comments/views.py | 71 ++++++++++++++++++++++++++++---- templates/blog/article_page.html | 1 + templates/comments/_comment.html | 2 + 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apps/blog/models.py b/apps/blog/models.py index 413b003..71fd03d 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -306,11 +306,16 @@ class ArticlePage(SeoMixin, Page): from django.conf import settings from apps.comments.models import Comment + from apps.comments.views import _annotate_reaction_counts, _get_session_key approved_replies = Comment.objects.filter(is_approved=True).select_related("parent") - ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related( - Prefetch("replies", queryset=approved_replies) + comments = list( + self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related( + Prefetch("replies", queryset=approved_replies) + ) ) + _annotate_reaction_counts(comments, _get_session_key(request)) + ctx["approved_comments"] = comments ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "") return ctx diff --git a/apps/comments/tests/test_v2.py b/apps/comments/tests/test_v2.py index f68346d..9ede878 100644 --- a/apps/comments/tests/test_v2.py +++ b/apps/comments/tests/test_v2.py @@ -90,14 +90,14 @@ def test_htmx_post_returns_comment_partial_when_turnstile_passes(client, _articl @pytest.mark.django_db def test_htmx_post_returns_form_with_errors_on_invalid(client, _article): - """HTMX POST with invalid data returns form partial with HTTP 422.""" + """HTMX POST with invalid data returns form partial with HTTP 200 (HTMX 2 requires 2xx for swap).""" cache.clear() resp = client.post( "/comments/post/", {"article_id": _article.id, "author_name": "T", "author_email": "t@t.com", "body": " ", "honeypot": ""}, HTTP_HX_REQUEST="true", ) - assert resp.status_code == 422 + assert resp.status_code == 200 assert "HX-Request" in resp["Vary"] assert Comment.objects.count() == 0 diff --git a/apps/comments/views.py b/apps/comments/views.py index ba790c9..672ceb2 100644 --- a/apps/comments/views.py +++ b/apps/comments/views.py @@ -8,9 +8,10 @@ from django.contrib import messages from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import IntegrityError -from django.db.models import Prefetch +from django.db.models import Count, Prefetch from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.utils.cache import patch_vary_headers from django.views import View from django.views.decorators.http import require_GET, require_POST @@ -35,7 +36,7 @@ def _is_htmx(request) -> bool: def _add_vary_header(response): - response["Vary"] = "HX-Request" + patch_vary_headers(response, ["HX-Request"]) return response @@ -66,12 +67,61 @@ def _turnstile_enabled() -> bool: return bool(getattr(settings, "TURNSTILE_SECRET_KEY", "")) +def _get_session_key(request) -> str: + session = getattr(request, "session", None) + return (session.session_key or "") if session else "" + + +def _annotate_reaction_counts(comments, session_key: str = ""): + """Hydrate each comment with reaction_counts dict and user_reacted set.""" + comment_ids = [c.id for c in comments] + if not comment_ids: + return comments + + # Aggregate counts per comment per type + counts_qs = ( + CommentReaction.objects.filter(comment_id__in=comment_ids) + .values("comment_id", "reaction_type") + .annotate(count=Count("id")) + ) + counts_map: dict[int, dict[str, int]] = {} + for row in counts_qs: + counts_map.setdefault(row["comment_id"], {"heart": 0, "plus_one": 0}) + counts_map[row["comment_id"]][row["reaction_type"]] = row["count"] + + # User's own reactions + user_map: dict[int, set[str]] = {} + if session_key: + user_qs = CommentReaction.objects.filter( + comment_id__in=comment_ids, session_key=session_key + ).values_list("comment_id", "reaction_type") + for cid, rtype in user_qs: + user_map.setdefault(cid, set()).add(rtype) + + for comment in comments: + comment.reaction_counts = counts_map.get(comment.id, {"heart": 0, "plus_one": 0}) + comment.user_reacted = user_map.get(comment.id, set()) + + return comments + + +def _comment_template_context(comment, article, request): + """Build template context for a single comment partial.""" + session_key = _get_session_key(request) + _annotate_reaction_counts([comment], session_key) + return { + "comment": comment, + "page": article, + "turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""), + } + + class CommentCreateView(View): def _render_article_with_errors(self, request, article, form): if _is_htmx(request): ctx = {"comment_form": form, "page": article} ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "") - resp = render(request, "comments/_comment_form.html", ctx, status=422) + resp = render(request, "comments/_comment_form.html", ctx, status=200) return _add_vary_header(resp) context = article.get_context(request) context["page"] = article @@ -122,11 +172,13 @@ class CommentCreateView(View): comment.save() if _is_htmx(request): + ctx = _comment_template_context(comment, article, request) if comment.is_approved: - resp = render(request, "comments/_comment.html", { - "comment": comment, "page": article, - "turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""), - }) + resp = render(request, "comments/_comment.html", ctx) + if comment.parent_id: + # Tell HTMX to retarget: insert reply inside parent comment + resp["HX-Retarget"] = f"#comment-{comment.parent_id} .replies-container" + resp["HX-Reswap"] = "beforeend" else: resp = render(request, "comments/_comment_success.html", { "message": "Your comment has been posted and is awaiting moderation.", @@ -153,12 +205,15 @@ def comment_poll(request, article_id): after_id = 0 approved_replies = Comment.objects.filter(is_approved=True).select_related("parent") - comments = ( + comments = list( article.comments.filter(is_approved=True, parent__isnull=True, id__gt=after_id) .prefetch_related(Prefetch("replies", queryset=approved_replies)) .order_by("created_at", "id") ) + session_key = _get_session_key(request) + _annotate_reaction_counts(comments, session_key) + resp = render(request, "comments/_comment_list_inner.html", { "approved_comments": comments, "page": article, diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html index 4b901ec..11db6aa 100644 --- a/templates/blog/article_page.html +++ b/templates/blog/article_page.html @@ -146,6 +146,7 @@ {% if approved_comments %} {% include "comments/_comment_list.html" %} {% else %} +

No comments yet. Be the first to comment.

diff --git a/templates/comments/_comment.html b/templates/comments/_comment.html index 02c3535..b81b322 100644 --- a/templates/comments/_comment.html +++ b/templates/comments/_comment.html @@ -8,6 +8,7 @@

{{ comment.body }}

{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %} +
{% for reply in comment.replies.all %}
@@ -20,5 +21,6 @@

{{ reply.body }}

{% endfor %} +
{% include "comments/_reply_form.html" with page=page comment=comment %}