Corrective implementation of implementation.md (containerized Django/Wagtail) #3

Merged
mark merged 26 commits from codex_b/implementation-e2e into main 2026-02-28 17:55:14 +00:00
18 changed files with 279 additions and 0 deletions
Showing only changes of commit 683cba4280 - Show all commits

View File

@@ -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.

View File

@@ -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 <backup-host>:/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.

View File

@@ -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", "<p>body</p>")],
)
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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -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)."))

View File

@@ -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", "<p>body</p>")],
)
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

View File

@@ -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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -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."))

View File

@@ -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", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
with pytest.raises(CommandError, match="empty summary"):
call_command("check_content_integrity")

54
static/js/newsletter.js Normal file
View File

@@ -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();
})();

View File

@@ -12,10 +12,18 @@
<script src="{% static 'js/consent.js' %}" nonce="{{ request.csp_nonce|default:'' }}"></script>
<script src="{% static 'js/theme.js' %}" defer></script>
<script src="{% static 'js/prism.js' %}" defer></script>
<script src="{% static 'js/newsletter.js' %}" defer></script>
</head>
<body>
{% include 'components/nav.html' %}
{% include 'components/cookie_banner.html' %}
{% if messages %}
<section aria-label="Messages">
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</section>
{% endif %}
<main>{% block content %}{% endblock %}</main>
{% include 'components/footer.html' %}
</body>

View File

@@ -26,12 +26,22 @@
{{ page.body }}
{% article_json_ld page %}
</article>
<section aria-label="Share this article">
<h2>Share</h2>
<a href="https://x.com/intent/post?url={{ request.build_absolute_uri|urlencode }}&text={{ page.title|urlencode }}" target="_blank" rel="noopener noreferrer">Share on X</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer">Share on LinkedIn</a>
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}">Copy link</button>
</section>
<section>
<h2>Related</h2>
{% for article in related_articles %}
<a href="{{ article.url }}">{{ article.title }}</a>
{% endfor %}
</section>
<aside>
<h2>Newsletter</h2>
{% include 'components/newsletter_form.html' with source='article' label='Never miss a post' %}
</aside>
{% if page.comments_enabled %}
<section>
<form method="post" action="{% url 'comment_post' %}">

View File

@@ -1,6 +1,7 @@
{% load core_tags %}
<footer>
{% get_legal_pages as legal_pages %}
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
{% for page in legal_pages %}
<a href="{{ page.url }}">{{ page.title }}</a>
{% endfor %}

View File

@@ -2,4 +2,5 @@
<a href="/">Home</a>
<a href="/articles/">Articles</a>
<a href="/about/">About</a>
{% include 'components/newsletter_form.html' with source='nav' label='Get updates' %}
</nav>

View File

@@ -0,0 +1,11 @@
<form method="post" action="/newsletter/subscribe/" data-newsletter-form>
{% csrf_token %}
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
<label>
<span>{{ label|default:"Newsletter" }}</span>
<input type="email" name="email" required />
</label>
<input type="text" name="honeypot" style="display:none" />
<button type="submit">Subscribe</button>
<p data-newsletter-message aria-live="polite"></p>
</form>