fix: resolve review round 2, E2E failures, and mypy error
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:
@@ -67,30 +67,37 @@ def _post_comment(client, article, extra=None, htmx=False):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_htmx_post_returns_partial_on_success(client, _article):
|
||||
"""HTMX POST with Turnstile disabled returns moderation notice partial."""
|
||||
def test_htmx_post_returns_form_with_moderation_on_success(client, _article):
|
||||
"""HTMX POST with Turnstile disabled returns fresh form + moderation message."""
|
||||
resp = _post_comment(client, _article, htmx=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"awaiting moderation" in resp.content
|
||||
# Response swaps the form container (contains form + success message)
|
||||
assert b"comment-form-container" in resp.content
|
||||
assert "HX-Request" in resp["Vary"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||
def test_htmx_post_returns_comment_partial_when_turnstile_passes(client, _article):
|
||||
"""HTMX POST with successful Turnstile returns comment partial for append."""
|
||||
def test_htmx_post_returns_form_plus_oob_comment_when_approved(client, _article):
|
||||
"""HTMX POST with successful Turnstile returns fresh form + OOB comment."""
|
||||
with patch("apps.comments.views._verify_turnstile", return_value=True):
|
||||
resp = _post_comment(client, _article, extra={"cf-turnstile-response": "tok"}, htmx=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Hello world" in resp.content
|
||||
assert b"comment-" in resp.content
|
||||
content = resp.content.decode()
|
||||
# Fresh form container is the primary response
|
||||
assert "comment-form-container" in content
|
||||
assert "Comment posted!" in content
|
||||
# OOB swap appends the comment to #comments-list
|
||||
assert "hx-swap-oob" in content
|
||||
assert "Hello world" in content
|
||||
comment = Comment.objects.get()
|
||||
assert comment.is_approved is True
|
||||
|
||||
|
||||
@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 200 (HTMX 2 requires 2xx for swap)."""
|
||||
"""HTMX POST with invalid data returns form with errors (HTTP 200)."""
|
||||
cache.clear()
|
||||
resp = client.post(
|
||||
"/comments/post/",
|
||||
@@ -98,10 +105,43 @@ def test_htmx_post_returns_form_with_errors_on_invalid(client, _article):
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"comment-form-container" in resp.content
|
||||
assert b"Comment form errors" in resp.content
|
||||
assert "HX-Request" in resp["Vary"]
|
||||
assert Comment.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||
def test_htmx_reply_returns_oob_reply_when_approved(client, _article, approved_comment):
|
||||
"""Approved reply via HTMX returns compact reply partial via OOB swap."""
|
||||
cache.clear()
|
||||
with patch("apps.comments.views._verify_turnstile", return_value=True):
|
||||
resp = client.post(
|
||||
"/comments/post/",
|
||||
{
|
||||
"article_id": _article.id,
|
||||
"parent_id": approved_comment.id,
|
||||
"author_name": "Replier",
|
||||
"author_email": "r@r.com",
|
||||
"body": "Nice reply",
|
||||
"honeypot": "",
|
||||
"cf-turnstile-response": "tok",
|
||||
},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
content = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
# OOB targets the parent's replies-container
|
||||
assert f"#comment-{approved_comment.id}" in content
|
||||
assert "hx-swap-oob" in content
|
||||
# Reply uses compact markup (no nested reply form)
|
||||
assert "Reply posted!" in content
|
||||
reply = Comment.objects.exclude(pk=approved_comment.pk).get()
|
||||
assert reply.parent_id == approved_comment.id
|
||||
assert reply.is_approved is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_htmx_post_still_redirects(client, _article):
|
||||
"""Non-HTMX POST continues to redirect (progressive enhancement)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user