diff --git a/CHANGELOG.md b/CHANGELOG.md index 484c7ff..ad6ff29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,6 @@ - Added newsletter subscription + confirmation flow with provider sync abstraction. - Added templates/static assets baseline for homepage, article index/read, legal, about. - Added pytest suite with >90% coverage enforcement and passing Docker CI checks. +- Added PR-only containerized CI path (`docker build` + `docker run`) to avoid compose-network exhaustion on shared runners. +- Added newsletter signup forms in nav/footer/article, client-side progressive submit UX, and article social share controls. +- Added content integrity management command and comment data-retention purge command with automated tests. diff --git a/README.md b/README.md index 5222a2b..171176b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ git pull origin main pip install -r requirements/production.txt python manage.py migrate --run-syncdb python manage.py collectstatic --noinput +python manage.py tailwind build +python manage.py check_content_integrity sudo systemctl reload gunicorn ``` @@ -55,3 +57,11 @@ sudo systemctl reload gunicorn - PostgreSQL dump daily: `pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz` - `MEDIA_ROOT` rsynced offsite daily +- Restore DB: `gunzip -c backup-YYYYMMDD.sql.gz | psql "$DATABASE_URL"` +- Restore media: `rsync -avz :/path/to/media/ /srv/nohypeai/media/` + +## Runtime Notes + +- Keep Caddy serving `/static/` and `/media/` directly in production. +- Keep Gunicorn behind Caddy and run from a systemd service/socket pair. +- Use `python manage.py purge_old_comment_data --months 24` in cron for comment-data retention. diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py index 5143806..138531f 100644 --- a/apps/blog/tests/test_views.py +++ b/apps/blog/tests/test_views.py @@ -59,3 +59,36 @@ def test_article_page_related_context(client, home_page): resp = client.get("/articles/main/") assert resp.status_code == 200 assert "related_articles" in resp.context + + +@pytest.mark.django_db +def test_newsletter_forms_render_in_nav_and_footer(client, home_page): + resp = client.get("/") + html = resp.content.decode() + assert resp.status_code == 200 + assert 'name="source" value="nav"' in html + assert 'name="source" value="footer"' in html + + +@pytest.mark.django_db +def test_article_page_renders_share_links_and_newsletter_form(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/articles/main/") + html = resp.content.decode() + assert resp.status_code == 200 + assert "Share on X" in html + assert "Share on LinkedIn" in html + assert 'data-copy-link' in html + assert 'name="source" value="article"' in html diff --git a/apps/comments/management/__init__.py b/apps/comments/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/comments/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/comments/management/commands/__init__.py b/apps/comments/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/comments/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/comments/management/commands/purge_old_comment_data.py b/apps/comments/management/commands/purge_old_comment_data.py new file mode 100644 index 0000000..73b17bf --- /dev/null +++ b/apps/comments/management/commands/purge_old_comment_data.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.comments.models import Comment + + +class Command(BaseCommand): + help = "Nullify comment personal data for comments older than the retention window." + + def add_arguments(self, parser): + parser.add_argument( + "--months", + type=int, + default=24, + help="Retention window in months before personal data is purged (default: 24).", + ) + + def handle(self, *args, **options): + months = options["months"] + cutoff = timezone.now() - timedelta(days=30 * months) + + purged = ( + Comment.objects.filter(created_at__lt=cutoff) + .exclude(author_email="") + .update(author_email="", ip_address=None) + ) + self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s).")) diff --git a/apps/comments/tests/test_commands.py b/apps/comments/tests/test_commands.py new file mode 100644 index 0000000..7e87e44 --- /dev/null +++ b/apps/comments/tests/test_commands.py @@ -0,0 +1,40 @@ +from datetime import timedelta + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.comments.models import Comment + + +@pytest.mark.django_db +def test_purge_old_comment_data_clears_personal_fields(home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Article", + slug="article", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + old_comment = Comment.objects.create( + article=article, + author_name="Old", + author_email="old@example.com", + body="legacy", + ip_address="127.0.0.1", + ) + Comment.objects.filter(pk=old_comment.pk).update(created_at=timezone.now() - timedelta(days=800)) + + call_command("purge_old_comment_data") + + old_comment.refresh_from_db() + assert old_comment.author_email == "" + assert old_comment.ip_address is None diff --git a/apps/comments/tests/test_views.py b/apps/comments/tests/test_views.py index af8e546..74a20d8 100644 --- a/apps/comments/tests/test_views.py +++ b/apps/comments/tests/test_views.py @@ -27,6 +27,7 @@ def test_comment_post_flow(client, home_page): }, ) assert resp.status_code == 302 + assert resp["Location"].endswith("?commented=1") assert Comment.objects.count() == 1 diff --git a/apps/core/management/__init__.py b/apps/core/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/management/commands/__init__.py b/apps/core/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/management/commands/check_content_integrity.py b/apps/core/management/commands/check_content_integrity.py new file mode 100644 index 0000000..e4088d6 --- /dev/null +++ b/apps/core/management/commands/check_content_integrity.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError +from django.db.models.functions import Trim +from wagtail.models import Site + +from apps.blog.models import ArticlePage +from apps.core.models import SiteSettings + + +class Command(BaseCommand): + help = "Validate content-integrity constraints for live article pages." + + def handle(self, *args, **options): + errors: list[str] = [] + + missing_summary = ArticlePage.objects.live().annotate(summary_trimmed=Trim("summary")).filter( + summary_trimmed="" + ) + if missing_summary.exists(): + errors.append(f"{missing_summary.count()} live article(s) have an empty summary.") + + missing_author = ArticlePage.objects.live().filter(author__isnull=True) + if missing_author.exists(): + errors.append(f"{missing_author.count()} live article(s) have no author.") + + default_site = Site.objects.filter(is_default_site=True).first() + default_og_image = None + if default_site: + default_og_image = SiteSettings.for_site(default_site).default_og_image + + if default_og_image is None: + missing_hero = ArticlePage.objects.live().filter(hero_image__isnull=True) + if missing_hero.exists(): + errors.append( + f"{missing_hero.count()} live article(s) have no hero image and no site default OG image is set." + ) + + if errors: + raise CommandError("Content integrity check failed: " + " ".join(errors)) + + self.stdout.write(self.style.SUCCESS("Content integrity check passed.")) diff --git a/apps/core/tests/test_commands.py b/apps/core/tests/test_commands.py new file mode 100644 index 0000000..b7fedb8 --- /dev/null +++ b/apps/core/tests/test_commands.py @@ -0,0 +1,30 @@ +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory + + +@pytest.mark.django_db +def test_check_content_integrity_passes_when_requirements_met(home_page): + call_command("check_content_integrity") + + +@pytest.mark.django_db +def test_check_content_integrity_fails_for_blank_summary(home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Article", + slug="article", + author=author, + summary=" ", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + with pytest.raises(CommandError, match="empty summary"): + call_command("check_content_integrity") diff --git a/static/js/newsletter.js b/static/js/newsletter.js new file mode 100644 index 0000000..6562ae2 --- /dev/null +++ b/static/js/newsletter.js @@ -0,0 +1,54 @@ +(() => { + const setMessage = (form, text) => { + const target = form.querySelector("[data-newsletter-message]"); + if (target) { + target.textContent = text; + } + }; + + const bindNewsletterForms = () => { + const forms = document.querySelectorAll("form[data-newsletter-form]"); + forms.forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(form); + try { + const response = await fetch(form.action, { + method: "POST", + body: formData, + }); + if (!response.ok) { + setMessage(form, "Please enter a valid email."); + return; + } + setMessage(form, "Check your email to confirm your subscription."); + form.reset(); + } catch (error) { + setMessage(form, "Subscription failed. Please try again."); + } + }); + }); + }; + + const bindCopyLink = () => { + const button = document.querySelector("[data-copy-link]"); + if (!button) { + return; + } + button.addEventListener("click", async () => { + const url = button.getAttribute("data-copy-url"); + if (!url) { + return; + } + try { + await navigator.clipboard.writeText(url); + button.textContent = "Copied"; + } catch (error) { + button.textContent = "Copy failed"; + } + }); + }; + + bindNewsletterForms(); + bindCopyLink(); +})(); diff --git a/templates/base.html b/templates/base.html index 1afe279..aef3585 100644 --- a/templates/base.html +++ b/templates/base.html @@ -12,10 +12,18 @@ + {% include 'components/nav.html' %} {% include 'components/cookie_banner.html' %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %}
{% block content %}{% endblock %}
{% include 'components/footer.html' %} diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html index c43f36b..97a105c 100644 --- a/templates/blog/article_page.html +++ b/templates/blog/article_page.html @@ -26,12 +26,22 @@ {{ page.body }} {% article_json_ld page %} +
+

Share

+ Share on X + Share on LinkedIn + +

Related

{% for article in related_articles %} {{ article.title }} {% endfor %}
+ {% if page.comments_enabled %}
diff --git a/templates/components/footer.html b/templates/components/footer.html index f2ab9b5..4d77528 100644 --- a/templates/components/footer.html +++ b/templates/components/footer.html @@ -1,6 +1,7 @@ {% load core_tags %}