Corrective implementation of implementation.md (containerized Django/Wagtail) #3
@@ -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.
|
||||
|
||||
10
README.md
10
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 <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.
|
||||
|
||||
@@ -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
|
||||
|
||||
1
apps/comments/management/__init__.py
Normal file
1
apps/comments/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/comments/management/commands/__init__.py
Normal file
1
apps/comments/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
31
apps/comments/management/commands/purge_old_comment_data.py
Normal file
31
apps/comments/management/commands/purge_old_comment_data.py
Normal 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)."))
|
||||
40
apps/comments/tests/test_commands.py
Normal file
40
apps/comments/tests/test_commands.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
1
apps/core/management/__init__.py
Normal file
1
apps/core/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/core/management/commands/__init__.py
Normal file
1
apps/core/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
42
apps/core/management/commands/check_content_integrity.py
Normal file
42
apps/core/management/commands/check_content_integrity.py
Normal 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."))
|
||||
30
apps/core/tests/test_commands.py
Normal file
30
apps/core/tests/test_commands.py
Normal 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
54
static/js/newsletter.js
Normal 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();
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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' %}">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
templates/components/newsletter_form.html
Normal file
11
templates/components/newsletter_form.html
Normal 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>
|
||||
Reference in New Issue
Block a user