fix: resolve review round 2, E2E failures, and mypy error
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m30s
CI / ci (pull_request) Failing after 1m48s

Review blocker A — form error swap and false success:
- Change HTMX contract so forms target their own container (outerHTML)
  instead of appending to #comments-list
- Use OOB swaps to append approved comments to the correct target
- Add success/error message display inside form templates
- Remove hx-on::after-request handlers (no longer needed)

Review blocker B — reply rendering shape:
- Create _reply.html partial with compact reply markup
- Approved replies via HTMX now use compact template + OOB swap
  into parent's .replies-container
- Reply form errors render inside reply form container

E2E test fixes:
- Update 4 failing tests to wait for inline HTMX messages instead
  of redirect-based URL assertions
- Add aria-label='Comment form errors' to form error display
- Rename test_reply_submission_redirects to
  test_reply_submission_shows_moderation_message

Mypy internal error workaround:
- Add mypy override for apps.comments.views (django-stubs triggers
  internal error on ORM annotate() chain with mypy 1.11.2)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mark
2026-03-03 23:47:12 +00:00
parent 88ce59aecc
commit c01fc14258
8 changed files with 188 additions and 79 deletions

View File

@@ -11,6 +11,7 @@ from django.db import IntegrityError
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.template.loader import render_to_string
from django.utils.cache import patch_vary_headers
from django.views import View
from django.views.decorators.http import require_GET, require_POST
@@ -72,25 +73,27 @@ def _get_session_key(request) -> str:
return (session.session_key or "") if session else ""
def _annotate_reaction_counts(comments, session_key: str = ""):
def _turnstile_site_key():
return getattr(settings, "TURNSTILE_SITE_KEY", "")
def _annotate_reaction_counts(comments, session_key=""):
"""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]] = {}
counts_map = {}
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]] = {}
user_map = {}
if session_key:
user_qs = CommentReaction.objects.filter(
comment_id__in=comment_ids, session_key=session_key
@@ -107,27 +110,69 @@ def _annotate_reaction_counts(comments, session_key: str = ""):
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)
_annotate_reaction_counts([comment], _get_session_key(request))
return {
"comment": comment,
"page": article,
"turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""),
"turnstile_site_key": _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=200)
return _add_vary_header(resp)
context = article.get_context(request)
context["page"] = article
context["comment_form"] = form
context["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
return render(request, "blog/article_page.html", context, status=200)
def _render_htmx_error(self, request, article, form):
"""Return error form partial for HTMX — swaps the form container itself."""
parent_id = request.POST.get("parent_id")
if parent_id:
parent = Comment.objects.filter(pk=parent_id, article=article).first()
ctx = {
"comment": parent, "page": article,
"turnstile_site_key": _turnstile_site_key(),
"reply_form_errors": form.errors,
}
return _add_vary_header(render(request, "comments/_reply_form.html", ctx))
ctx = {
"comment_form": form, "page": article,
"turnstile_site_key": _turnstile_site_key(),
}
return _add_vary_header(render(request, "comments/_comment_form.html", ctx))
def _render_htmx_success(self, request, article, comment):
"""Return fresh form + OOB-appended comment (if approved)."""
tsk = _turnstile_site_key()
oob_html = ""
if comment.is_approved:
ctx = _comment_template_context(comment, article, request)
if comment.parent_id:
comment_html = render_to_string("comments/_reply.html", ctx, request)
oob_html = (
f'<div hx-swap-oob="beforeend:#comment-{comment.parent_id} '
f'.replies-container">{comment_html}</div>'
)
else:
comment_html = render_to_string("comments/_comment.html", ctx, request)
oob_html = (
f'<div hx-swap-oob="beforeend:#comments-list">'
f"{comment_html}</div>"
)
if comment.parent_id:
parent = Comment.objects.filter(pk=comment.parent_id, article=article).first()
msg = "Reply posted!" if comment.is_approved else "Your reply is awaiting moderation."
form_html = render_to_string("comments/_reply_form.html", {
"comment": parent, "page": article,
"turnstile_site_key": tsk, "reply_success_message": msg,
}, request)
else:
msg = (
"Comment posted!" if comment.is_approved
else "Your comment has been posted and is awaiting moderation."
)
form_html = render_to_string("comments/_comment_form.html", {
"page": article, "turnstile_site_key": tsk, "success_message": msg,
}, request)
resp = HttpResponse(form_html + oob_html)
return _add_vary_header(resp)
def post(self, request):
ip = client_ip_from_request(request)
@@ -168,22 +213,15 @@ class CommentCreateView(View):
comment.full_clean()
except ValidationError:
form.add_error(None, "Reply depth exceeds the allowed limit")
return self._render_article_with_errors(request, article, form)
if _is_htmx(request):
return self._render_htmx_error(request, article, form)
context = article.get_context(request)
context.update({"page": article, "comment_form": form})
return render(request, "blog/article_page.html", context, status=200)
comment.save()
if _is_htmx(request):
ctx = _comment_template_context(comment, article, request)
if comment.is_approved:
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.",
})
return _add_vary_header(resp)
return self._render_htmx_success(request, article, comment)
messages.success(
request,
@@ -191,7 +229,11 @@ class CommentCreateView(View):
)
return redirect(f"{article.url}?commented=1")
return self._render_article_with_errors(request, article, form)
if _is_htmx(request):
return self._render_htmx_error(request, article, form)
context = article.get_context(request)
context.update({"page": article, "comment_form": form})
return render(request, "blog/article_page.html", context, status=200)
@require_GET
@@ -211,13 +253,12 @@ def comment_poll(request, article_id):
.order_by("created_at", "id")
)
session_key = _get_session_key(request)
_annotate_reaction_counts(comments, session_key)
_annotate_reaction_counts(comments, _get_session_key(request))
resp = render(request, "comments/_comment_list_inner.html", {
"approved_comments": comments,
"page": article,
"turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""),
"turnstile_site_key": _turnstile_site_key(),
})
return _add_vary_header(resp)