diff --git a/apps/blog/models.py b/apps/blog/models.py index 961b4ea..cbd4535 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -222,7 +222,24 @@ class ArticlePageAdminForm(WagtailAdminPageForm): self.initial["category"] = default_category.pk def clean(self): + cleaned_data = getattr(self, "cleaned_data", {}) + self._apply_defaults(cleaned_data) + self.cleaned_data = cleaned_data + cleaned_data = super().clean() + self._apply_defaults(cleaned_data) + + if not cleaned_data.get("slug"): + self.add_error("slug", "Slug is required.") + if not cleaned_data.get("author"): + self.add_error("author", "Author is required.") + if not cleaned_data.get("category"): + self.add_error("category", "Category is required.") + if not cleaned_data.get("summary"): + self.add_error("summary", "Summary is required.") + return cleaned_data + + def _apply_defaults(self, cleaned_data: dict[str, Any]) -> dict[str, Any]: title = (cleaned_data.get("title") or "").strip() if not cleaned_data.get("slug") and title: @@ -237,14 +254,6 @@ class ArticlePageAdminForm(WagtailAdminPageForm): max_chars=self.SUMMARY_MAX_CHARS, ) or title - if not cleaned_data.get("slug"): - self.add_error("slug", "Slug is required.") - if not cleaned_data.get("author"): - self.add_error("author", "Author is required.") - if not cleaned_data.get("category"): - self.add_error("category", "Category is required.") - if not cleaned_data.get("summary"): - self.add_error("summary", "Summary is required.") return cleaned_data def _get_default_author(self, *, create: bool) -> Author | None: @@ -374,9 +383,20 @@ class ArticlePage(SeoMixin, Page): self.summary = _generate_summary_from_stream(self.body) or self.title if not self.published_date and self.first_published_at: self.published_date = self.first_published_at - self.read_time_mins = self._compute_read_time() + if self._should_refresh_read_time(): + self.read_time_mins = self._compute_read_time() return super().save(*args, **kwargs) + def _should_refresh_read_time(self) -> bool: + if not self.pk: + return True + + previous = type(self).objects.only("body").filter(pk=self.pk).first() + if previous is None: + return True + + return previous.body_text != self.body_text + def _compute_read_time(self) -> int: words = [] for block in self.body: diff --git a/apps/blog/tests/test_admin_experience.py b/apps/blog/tests/test_admin_experience.py index 26b1add..6e69d46 100644 --- a/apps/blog/tests/test_admin_experience.py +++ b/apps/blog/tests/test_admin_experience.py @@ -2,6 +2,9 @@ from datetime import timedelta from types import SimpleNamespace import pytest +from django.contrib import messages +from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.sessions.middleware import SessionMiddleware from django.test import override_settings from django.utils import timezone @@ -295,7 +298,7 @@ def test_article_admin_form_relaxes_initial_required_fields(article_index, djang @pytest.mark.django_db def test_article_admin_form_clean_applies_defaults(article_index, django_user_model, monkeypatch): - """Form clean should auto-fill slug/author/category/summary when omitted.""" + """Form clean should populate defaults before parent validation runs.""" user = django_user_model.objects.create_user( username="writer", email="writer@example.com", @@ -310,16 +313,18 @@ def test_article_admin_form_clean_applies_defaults(article_index, django_user_mo SimpleNamespace(block_type="code", value=SimpleNamespace(raw_code="print('ignore')")), SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="
Hello world body text.
")), ] + form.cleaned_data = { + "title": "Auto Defaults Title", + "slug": "", + "author": None, + "category": None, + "summary": "", + "body": body, + } + observed = {} def fake_super_clean(_self): - _self.cleaned_data = { - "title": "Auto Defaults Title", - "slug": "", - "author": None, - "category": None, - "summary": "", - "body": body, - } + observed["slug_before_parent_clean"] = _self.cleaned_data.get("slug") return _self.cleaned_data mro = form.__class__.__mro__ @@ -327,6 +332,7 @@ def test_article_admin_form_clean_applies_defaults(article_index, django_user_mo monkeypatch.setattr(super_form_class, "clean", fake_super_clean) cleaned = form.clean() + assert observed["slug_before_parent_clean"] == "auto-defaults-title" assert cleaned["slug"] == "auto-defaults-title" assert cleaned["author"] is not None assert cleaned["author"].user_id == user.id @@ -378,3 +384,20 @@ def test_article_save_autogenerates_summary_when_missing(article_index): article.save() assert article.summary == "This should become the summary text." + + +@pytest.mark.django_db +def test_article_page_omits_admin_messages_on_frontend(article_page, rf): + """Frontend templates should not render admin session messages.""" + request = rf.get(article_page.url) + SessionMiddleware(lambda req: None).process_request(request) + request.session.save() + setattr(request, "_messages", FallbackStorage(request)) + messages.success(request, "Page 'Test' has been published.") + + response = article_page.serve(request) + response.render() + content = response.content.decode() + + assert "Page 'Test' has been published." not in content + assert 'aria-label="Messages"' not in content diff --git a/apps/blog/tests/test_models.py b/apps/blog/tests/test_models.py index c2a7808..63a2904 100644 --- a/apps/blog/tests/test_models.py +++ b/apps/blog/tests/test_models.py @@ -59,6 +59,32 @@ def test_article_default_category_is_assigned(home_page): assert article.category.slug == "general" +@pytest.mark.django_db +def test_article_read_time_is_not_recomputed_when_body_text_is_unchanged(home_page, monkeypatch): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Stable read time", + slug="stable-read-time", + author=author, + summary="s", + body=[("rich_text", "body words
")], + ) + index.add_child(instance=article) + article.save() + + def fail_compute(): + raise AssertionError("read time should not be recomputed when body text is unchanged") + + monkeypatch.setattr(article, "_compute_read_time", fail_compute) + article.title = "Retitled" + article.save() + article.refresh_from_db() + + assert article.read_time_mins == 1 + + @pytest.mark.django_db def test_category_ordering(): Category.objects.get_or_create(name="General", slug="general") diff --git a/apps/comments/tests/test_v2.py b/apps/comments/tests/test_v2.py index 8e33690..f48ba08 100644 --- a/apps/comments/tests/test_v2.py +++ b/apps/comments/tests/test_v2.py @@ -149,7 +149,7 @@ def test_non_htmx_post_still_redirects(client, _article): """Non-HTMX POST continues to redirect (progressive enhancement).""" resp = _post_comment(client, _article) assert resp.status_code == 302 - assert resp["Location"].endswith("?commented=1") + assert resp["Location"].endswith("?commented=pending") @pytest.mark.django_db diff --git a/apps/comments/tests/test_views.py b/apps/comments/tests/test_views.py index d55b8ba..ac57819 100644 --- a/apps/comments/tests/test_views.py +++ b/apps/comments/tests/test_views.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from django.core.cache import cache from django.test import override_settings @@ -28,10 +30,64 @@ def test_comment_post_flow(client, home_page): }, ) assert resp.status_code == 302 - assert resp["Location"].endswith("?commented=1") + assert resp["Location"].endswith("?commented=pending") assert Comment.objects.count() == 1 +@pytest.mark.django_db +def test_comment_post_redirect_banner_renders_on_article_page(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": "Hello", + "honeypot": "", + }, + follow=True, + ) + assert resp.status_code == 200 + assert b"Your comment has been posted and is awaiting moderation." in resp.content + + +@pytest.mark.django_db +@override_settings(TURNSTILE_SECRET_KEY="test-secret") +def test_comment_post_redirect_banner_renders_approved_state(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + with patch("apps.comments.views._verify_turnstile", return_value=True): + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": "Hello", + "honeypot": "", + "cf-turnstile-response": "tok", + }, + follow=True, + ) + + assert resp.status_code == 200 + assert b"Comment posted!" in resp.content + + @pytest.mark.django_db def test_comment_post_rejected_when_comments_disabled(client, home_page): cache.clear() diff --git a/apps/comments/views.py b/apps/comments/views.py index 9f45d05..aacee4c 100644 --- a/apps/comments/views.py +++ b/apps/comments/views.py @@ -4,7 +4,6 @@ import logging import requests as http_requests from django.conf import settings -from django.contrib import messages from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import IntegrityError @@ -41,6 +40,11 @@ def _add_vary_header(response): return response +def _comment_redirect(article: ArticlePage, *, approved: bool): + state = "approved" if approved else "pending" + return redirect(f"{article.url}?commented={state}") + + def _verify_turnstile(token: str, ip: str) -> bool: secret = getattr(settings, "TURNSTILE_SECRET_KEY", "") if not secret: @@ -201,7 +205,7 @@ class CommentCreateView(View): return _add_vary_header( render(request, "comments/_comment_success.html", {"message": "Comment posted!"}) ) - return redirect(f"{article.url}?commented=1") + return _comment_redirect(article, approved=True) # Turnstile verification turnstile_ok = False @@ -230,11 +234,7 @@ class CommentCreateView(View): if _is_htmx(request): return self._render_htmx_success(request, article, comment) - messages.success( - request, - "Comment posted!" if comment.is_approved else "Your comment is awaiting moderation", - ) - return redirect(f"{article.url}?commented=1") + return _comment_redirect(article, approved=comment.is_approved) if _is_htmx(request): return self._render_htmx_error(request, article, form) diff --git a/templates/base.html b/templates/base.html index e3da92d..008f7c9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -26,13 +26,6 @@ {% include 'components/nav.html' %} {% include 'components/cookie_banner.html' %} - {% if messages %} -{{ message }}
- {% endfor %} -