Corrective implementation of implementation.md (containerized Django/Wagtail) #3
@@ -10,3 +10,6 @@
|
|||||||
- Added newsletter subscription + confirmation flow with provider sync abstraction.
|
- Added newsletter subscription + confirmation flow with provider sync abstraction.
|
||||||
- Added templates/static assets baseline for homepage, article index/read, legal, about.
|
- 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 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
|
pip install -r requirements/production.txt
|
||||||
python manage.py migrate --run-syncdb
|
python manage.py migrate --run-syncdb
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
python manage.py tailwind build
|
||||||
|
python manage.py check_content_integrity
|
||||||
sudo systemctl reload gunicorn
|
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`
|
- PostgreSQL dump daily: `pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz`
|
||||||
- `MEDIA_ROOT` rsynced offsite daily
|
- `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/")
|
resp = client.get("/articles/main/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "related_articles" in resp.context
|
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.status_code == 302
|
||||||
|
assert resp["Location"].endswith("?commented=1")
|
||||||
assert Comment.objects.count() == 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/consent.js' %}" nonce="{{ request.csp_nonce|default:'' }}"></script>
|
||||||
<script src="{% static 'js/theme.js' %}" defer></script>
|
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||||
<script src="{% static 'js/prism.js' %}" defer></script>
|
<script src="{% static 'js/prism.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include 'components/nav.html' %}
|
{% include 'components/nav.html' %}
|
||||||
{% include 'components/cookie_banner.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>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
{% include 'components/footer.html' %}
|
{% include 'components/footer.html' %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -26,12 +26,22 @@
|
|||||||
{{ page.body }}
|
{{ page.body }}
|
||||||
{% article_json_ld page %}
|
{% article_json_ld page %}
|
||||||
</article>
|
</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>
|
<section>
|
||||||
<h2>Related</h2>
|
<h2>Related</h2>
|
||||||
{% for article in related_articles %}
|
{% for article in related_articles %}
|
||||||
<a href="{{ article.url }}">{{ article.title }}</a>
|
<a href="{{ article.url }}">{{ article.title }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
<aside>
|
||||||
|
<h2>Newsletter</h2>
|
||||||
|
{% include 'components/newsletter_form.html' with source='article' label='Never miss a post' %}
|
||||||
|
</aside>
|
||||||
{% if page.comments_enabled %}
|
{% if page.comments_enabled %}
|
||||||
<section>
|
<section>
|
||||||
<form method="post" action="{% url 'comment_post' %}">
|
<form method="post" action="{% url 'comment_post' %}">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% load core_tags %}
|
{% load core_tags %}
|
||||||
<footer>
|
<footer>
|
||||||
{% get_legal_pages as legal_pages %}
|
{% get_legal_pages as legal_pages %}
|
||||||
|
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
|
||||||
{% for page in legal_pages %}
|
{% for page in legal_pages %}
|
||||||
<a href="{{ page.url }}">{{ page.title }}</a>
|
<a href="{{ page.url }}">{{ page.title }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/articles/">Articles</a>
|
<a href="/articles/">Articles</a>
|
||||||
<a href="/about/">About</a>
|
<a href="/about/">About</a>
|
||||||
|
{% include 'components/newsletter_form.html' with source='nav' label='Get updates' %}
|
||||||
</nav>
|
</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