Corrective implementation of implementation.md (containerized Django/Wagtail) #3
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
.benchmarks/
|
||||
media/
|
||||
staticfiles/
|
||||
121
.gitea/workflows/ci.yml
Normal file
121
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,121 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_IMAGE: nohype-ci:${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build
|
||||
run: docker build -t "$CI_IMAGE" .
|
||||
|
||||
- name: Start PostgreSQL
|
||||
run: |
|
||||
docker run -d --name ci-postgres \
|
||||
-e POSTGRES_DB=nohype \
|
||||
-e POSTGRES_USER=nohype \
|
||||
-e POSTGRES_PASSWORD=nohype \
|
||||
postgres:16-alpine
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec ci-postgres pg_isready -U nohype -d nohype >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs ci-postgres || true
|
||||
exit 1
|
||||
|
||||
- name: Ruff
|
||||
run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" ruff check .
|
||||
|
||||
- name: Mypy
|
||||
run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" mypy apps config
|
||||
|
||||
- name: Pytest
|
||||
run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" pytest
|
||||
|
||||
- name: Tailwind build (assert generated diff is clean)
|
||||
run: |
|
||||
docker run --name ci-tailwind \
|
||||
--network container:ci-postgres \
|
||||
-e SECRET_KEY=ci-secret-key \
|
||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||
"$CI_IMAGE" \
|
||||
sh -lc "python manage.py tailwind install --no-input && python manage.py tailwind build"
|
||||
docker cp ci-tailwind:/app/theme/static/css/styles.css /tmp/ci-styles.css
|
||||
docker rm -f ci-tailwind
|
||||
cmp -s theme/static/css/styles.css /tmp/ci-styles.css
|
||||
|
||||
- name: Remove PostgreSQL
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f ci-postgres || true
|
||||
|
||||
- name: Remove CI image
|
||||
if: always()
|
||||
run: docker image rm -f "$CI_IMAGE" || true
|
||||
|
||||
nightly-e2e:
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: docker build -t "$CI_IMAGE" .
|
||||
- name: Start PostgreSQL
|
||||
run: |
|
||||
docker run -d --name nightly-postgres \
|
||||
-e POSTGRES_DB=nohype \
|
||||
-e POSTGRES_USER=nohype \
|
||||
-e POSTGRES_PASSWORD=nohype \
|
||||
postgres:16-alpine
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec nightly-postgres pg_isready -U nohype -d nohype >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs nightly-postgres || true
|
||||
exit 1
|
||||
- name: Start dev server with seeded content
|
||||
run: |
|
||||
docker run -d --name nightly-e2e --network container:nightly-postgres \
|
||||
-e SECRET_KEY=ci-secret-key \
|
||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||
"$CI_IMAGE" \
|
||||
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
||||
for i in $(seq 1 40); do
|
||||
if docker exec nightly-e2e curl -fsS http://127.0.0.1:8000/ >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs nightly-e2e || true
|
||||
exit 1
|
||||
- name: Run nightly Playwright journey
|
||||
run: |
|
||||
docker exec nightly-e2e python -m playwright install chromium
|
||||
docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 nightly-e2e \
|
||||
pytest -o addopts='' apps/core/tests/test_nightly_e2e_playwright.py -q
|
||||
- name: Remove nightly container
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f nightly-e2e || true
|
||||
docker rm -f nightly-postgres || true
|
||||
- name: Remove CI image
|
||||
if: always()
|
||||
run: docker image rm -f "$CI_IMAGE" || true
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
*.log
|
||||
.env
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
node_modules/
|
||||
staticfiles/
|
||||
media/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-28
|
||||
|
||||
- Scaffolded Dockerized Django/Wagtail project structure with split settings.
|
||||
- Implemented core apps: blog, authors, legal, comments, newsletter, core consent/settings.
|
||||
- Added Wagtail models, snippets, StreamField blocks, RSS feeds, sitemap/robots routes.
|
||||
- Added consent middleware/service and cookie banner integration.
|
||||
- Added comment submission flow with moderation-ready model and rate limiting.
|
||||
- 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.
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
curl \
|
||||
nodejs \
|
||||
npm \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcups2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements /app/requirements
|
||||
RUN pip install --upgrade pip && pip install -r requirements/base.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# No Hype AI
|
||||
|
||||
Django 5.2 + Wagtail 7 blog engine for No Hype AI.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required:
|
||||
- `SECRET_KEY`
|
||||
- `DATABASE_URL`
|
||||
- `ALLOWED_HOSTS`
|
||||
- `DEBUG`
|
||||
- `WAGTAIL_SITE_NAME`
|
||||
|
||||
Also used:
|
||||
- `WAGTAILADMIN_BASE_URL`
|
||||
- `CONSENT_POLICY_VERSION`
|
||||
- `EMAIL_BACKEND`
|
||||
- `EMAIL_HOST`
|
||||
- `EMAIL_PORT`
|
||||
- `EMAIL_USE_TLS`
|
||||
- `EMAIL_HOST_USER`
|
||||
- `EMAIL_HOST_PASSWORD`
|
||||
- `DEFAULT_FROM_EMAIL`
|
||||
- `NEWSLETTER_PROVIDER`
|
||||
|
||||
## Containerized Development
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose run --rm web python manage.py migrate
|
||||
docker compose up
|
||||
```
|
||||
|
||||
App is exposed on `http://localhost:8035`.
|
||||
|
||||
## Test/Lint/Typecheck (Docker)
|
||||
|
||||
```bash
|
||||
docker compose run --rm web pytest
|
||||
docker compose run --rm web ruff check .
|
||||
docker compose run --rm web mypy apps config
|
||||
```
|
||||
|
||||
## Deploy Runbook
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Backups
|
||||
|
||||
- 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.
|
||||
0
apps/__init__.py
Normal file
0
apps/__init__.py
Normal file
0
apps/authors/__init__.py
Normal file
0
apps/authors/__init__.py
Normal file
6
apps/authors/apps.py
Normal file
6
apps/authors/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthorsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.authors"
|
||||
34
apps/authors/migrations/0001_initial.py
Normal file
34
apps/authors/migrations/0001_initial.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('wagtailimages', '0027_image_description'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Author',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('bio', models.TextField(blank=True)),
|
||||
('twitter_url', models.URLField(blank=True)),
|
||||
('github_url', models.URLField(blank=True)),
|
||||
('avatar', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Author',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/authors/migrations/__init__.py
Normal file
0
apps/authors/migrations/__init__.py
Normal file
44
apps/authors/models.py
Normal file
44
apps/authors/models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.db.models import SET_NULL
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Author(models.Model):
|
||||
user = models.OneToOneField(User, null=True, blank=True, on_delete=SET_NULL)
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
bio = models.TextField(blank=True)
|
||||
avatar = models.ForeignKey(
|
||||
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||
)
|
||||
twitter_url = models.URLField(blank=True)
|
||||
github_url = models.URLField(blank=True)
|
||||
|
||||
panels = [
|
||||
FieldPanel("user"),
|
||||
FieldPanel("name"),
|
||||
FieldPanel("slug"),
|
||||
FieldPanel("bio"),
|
||||
FieldPanel("avatar"),
|
||||
FieldPanel("twitter_url"),
|
||||
FieldPanel("github_url"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Author"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def get_social_links(self) -> dict[str, str]:
|
||||
links: dict[str, str] = {}
|
||||
if self.twitter_url:
|
||||
links["twitter"] = self.twitter_url
|
||||
if self.github_url:
|
||||
links["github"] = self.github_url
|
||||
return links
|
||||
0
apps/authors/tests/__init__.py
Normal file
0
apps/authors/tests/__init__.py
Normal file
16
apps/authors/tests/test_models.py
Normal file
16
apps/authors/tests/test_models.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
|
||||
from apps.authors.models import Author
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_author_create_and_social_links():
|
||||
author = Author.objects.create(name="Mark", slug="mark", twitter_url="https://x.com/mark")
|
||||
assert str(author) == "Mark"
|
||||
assert author.get_social_links() == {"twitter": "https://x.com/mark"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_author_user_nullable():
|
||||
author = Author.objects.create(name="No User", slug="no-user")
|
||||
assert author.user is None
|
||||
15
apps/authors/wagtail_hooks.py
Normal file
15
apps/authors/wagtail_hooks.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||
|
||||
from apps.authors.models import Author
|
||||
|
||||
|
||||
class AuthorViewSet(SnippetViewSet):
|
||||
model = Author
|
||||
icon = "user"
|
||||
list_display = ["name", "slug"]
|
||||
search_fields = ["name"]
|
||||
add_to_admin_menu = True
|
||||
|
||||
|
||||
register_snippet(AuthorViewSet)
|
||||
0
apps/blog/__init__.py
Normal file
0
apps/blog/__init__.py
Normal file
6
apps/blog/apps.py
Normal file
6
apps/blog/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.blog"
|
||||
81
apps/blog/blocks.py
Normal file
81
apps/blog/blocks.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from wagtail import blocks
|
||||
from wagtail.embeds.blocks import EmbedBlock
|
||||
from wagtail.images.blocks import ImageChooserBlock
|
||||
|
||||
|
||||
class CodeBlock(blocks.StructBlock):
|
||||
LANGUAGE_CHOICES = [
|
||||
("python", "Python"),
|
||||
("javascript", "JavaScript"),
|
||||
("typescript", "TypeScript"),
|
||||
("tsx", "TSX"),
|
||||
("bash", "Bash"),
|
||||
("json", "JSON"),
|
||||
("css", "CSS"),
|
||||
("html", "HTML"),
|
||||
("plaintext", "Plain Text"),
|
||||
]
|
||||
|
||||
language = blocks.ChoiceBlock(choices=LANGUAGE_CHOICES, default="python")
|
||||
filename = blocks.CharBlock(required=False)
|
||||
raw_code = blocks.TextBlock()
|
||||
|
||||
class Meta:
|
||||
icon = "code"
|
||||
template = "blog/blocks/code_block.html"
|
||||
|
||||
def get_language_label(self, value):
|
||||
choices = dict(self.LANGUAGE_CHOICES)
|
||||
lang = str(value.get("language", "")) if isinstance(value, dict) else ""
|
||||
return choices.get(lang, "Plain Text")
|
||||
|
||||
|
||||
class CalloutBlock(blocks.StructBlock):
|
||||
ICON_CHOICES = [
|
||||
("info", "Info"),
|
||||
("warning", "Warning"),
|
||||
("trophy", "Trophy / Conclusion"),
|
||||
("tip", "Tip"),
|
||||
]
|
||||
|
||||
icon = blocks.ChoiceBlock(choices=ICON_CHOICES, default="info")
|
||||
heading = blocks.CharBlock()
|
||||
body = blocks.RichTextBlock(features=["bold", "italic", "link"])
|
||||
|
||||
class Meta:
|
||||
icon = "pick"
|
||||
template = "blog/blocks/callout_block.html"
|
||||
|
||||
|
||||
class PullQuoteBlock(blocks.StructBlock):
|
||||
quote = blocks.TextBlock()
|
||||
attribution = blocks.CharBlock(required=False)
|
||||
|
||||
class Meta:
|
||||
icon = "openquote"
|
||||
template = "blog/blocks/pull_quote_block.html"
|
||||
|
||||
|
||||
class ImageBlock(blocks.StructBlock):
|
||||
image = ImageChooserBlock()
|
||||
caption = blocks.CharBlock(required=False)
|
||||
alt = blocks.CharBlock(required=True)
|
||||
|
||||
class Meta:
|
||||
icon = "image"
|
||||
template = "blog/blocks/image_block.html"
|
||||
|
||||
|
||||
ARTICLE_BODY_BLOCKS = [
|
||||
(
|
||||
"rich_text",
|
||||
blocks.RichTextBlock(
|
||||
features=["h2", "h3", "h4", "bold", "italic", "link", "ol", "ul", "hr", "blockquote", "code"]
|
||||
),
|
||||
),
|
||||
("code", CodeBlock()),
|
||||
("callout", CalloutBlock()),
|
||||
("image", ImageBlock()),
|
||||
("embed", EmbedBlock()),
|
||||
("pull_quote", PullQuoteBlock()),
|
||||
]
|
||||
50
apps/blog/feeds.py
Normal file
50
apps/blog/feeds.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.shortcuts import get_object_or_404
|
||||
from taggit.models import Tag
|
||||
|
||||
from apps.blog.models import ArticlePage
|
||||
|
||||
|
||||
class AllArticlesFeed(Feed):
|
||||
title = "No Hype AI"
|
||||
link = "/articles/"
|
||||
description = "Honest AI coding tool reviews for developers."
|
||||
|
||||
def get_object(self, request):
|
||||
self.request = request
|
||||
return None
|
||||
|
||||
def items(self):
|
||||
return ArticlePage.objects.live().order_by("-first_published_at")[:20]
|
||||
|
||||
def item_title(self, item: ArticlePage):
|
||||
return item.title
|
||||
|
||||
def item_description(self, item: ArticlePage):
|
||||
return item.summary
|
||||
|
||||
def item_pubdate(self, item: ArticlePage):
|
||||
return item.first_published_at
|
||||
|
||||
def item_author_name(self, item: ArticlePage):
|
||||
return item.author.name
|
||||
|
||||
def item_link(self, item: ArticlePage):
|
||||
if hasattr(self, "request") and self.request is not None:
|
||||
full_url = item.get_full_url(self.request)
|
||||
if full_url:
|
||||
return full_url
|
||||
return f"{settings.WAGTAILADMIN_BASE_URL.rstrip('/')}{item.url}"
|
||||
|
||||
|
||||
class TagArticlesFeed(AllArticlesFeed):
|
||||
def get_object(self, request, tag_slug: str):
|
||||
self.request = request
|
||||
return get_object_or_404(Tag, slug=tag_slug)
|
||||
|
||||
def title(self, obj):
|
||||
return f"No Hype AI — {obj.name}"
|
||||
|
||||
def items(self, obj):
|
||||
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20]
|
||||
98
apps/blog/migrations/0001_initial.py
Normal file
98
apps/blog/migrations/0001_initial.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||
|
||||
import django.db.models.deletion
|
||||
import modelcluster.contrib.taggit
|
||||
import modelcluster.fields
|
||||
import wagtail.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('authors', '0001_initial'),
|
||||
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
||||
('wagtailcore', '0094_alter_page_locale'),
|
||||
('wagtailimages', '0027_image_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ArticleIndexPage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AboutPage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
('mission_statement', models.TextField()),
|
||||
('body', wagtail.fields.RichTextField(blank=True)),
|
||||
('featured_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='authors.author')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ArticlePage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
('canonical_url', models.URLField(blank=True, help_text="Leave blank to use the page's URL.", max_length=255, verbose_name='Canonical URL')),
|
||||
('summary', models.TextField()),
|
||||
('body', wagtail.fields.StreamField([('rich_text', 0), ('code', 4), ('callout', 8), ('image', 11), ('embed', 12), ('pull_quote', 13)], block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {'features': ['h2', 'h3', 'h4', 'bold', 'italic', 'link', 'ol', 'ul', 'hr', 'blockquote', 'code']}), 1: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('python', 'Python'), ('javascript', 'JavaScript'), ('typescript', 'TypeScript'), ('tsx', 'TSX'), ('bash', 'Bash'), ('json', 'JSON'), ('css', 'CSS'), ('html', 'HTML'), ('plaintext', 'Plain Text')]}), 2: ('wagtail.blocks.CharBlock', (), {'required': False}), 3: ('wagtail.blocks.TextBlock', (), {}), 4: ('wagtail.blocks.StructBlock', [[('language', 1), ('filename', 2), ('raw_code', 3)]], {}), 5: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('info', 'Info'), ('warning', 'Warning'), ('trophy', 'Trophy / Conclusion'), ('tip', 'Tip')]}), 6: ('wagtail.blocks.CharBlock', (), {}), 7: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link']}), 8: ('wagtail.blocks.StructBlock', [[('icon', 5), ('heading', 6), ('body', 7)]], {}), 9: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 10: ('wagtail.blocks.CharBlock', (), {'required': True}), 11: ('wagtail.blocks.StructBlock', [[('image', 9), ('caption', 2), ('alt', 10)]], {}), 12: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 13: ('wagtail.blocks.StructBlock', [[('quote', 3), ('attribution', 2)]], {})})),
|
||||
('read_time_mins', models.PositiveIntegerField(default=1, editable=False)),
|
||||
('comments_enabled', models.BooleanField(default=True)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authors.author')),
|
||||
('hero_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||
('og_image', models.ForeignKey(blank=True, help_text='Shown when linking to this page on social media. If blank, may show an image from the page, or the default from Settings > SEO.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Preview image')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page', models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ArticleTag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='blog.articlepage')),
|
||||
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='articlepage',
|
||||
name='tags',
|
||||
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='blog.ArticleTag', to='taggit.Tag', verbose_name='Tags'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HomePage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
('featured_article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='blog.articlepage')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TagMetadata',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('colour', models.CharField(choices=[('cyan', 'Cyan'), ('pink', 'Pink'), ('neutral', 'Neutral')], default='neutral', max_length=20)),
|
||||
('tag', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata', to='taggit.tag')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
apps/blog/migrations/__init__.py
Normal file
0
apps/blog/migrations/__init__.py
Normal file
199
apps/blog/models.py
Normal file
199
apps/blog/models.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
|
||||
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||
from modelcluster.fields import ParentalKey
|
||||
from taggit.models import Tag, TaggedItemBase
|
||||
from wagtail.admin.panels import FieldPanel, PageChooserPanel
|
||||
from wagtail.fields import RichTextField, StreamField
|
||||
from wagtail.models import Page
|
||||
from wagtailseo.models import SeoMixin
|
||||
|
||||
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
|
||||
|
||||
|
||||
class HomePage(Page):
|
||||
featured_article = models.ForeignKey(
|
||||
"blog.ArticlePage", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||
)
|
||||
|
||||
subpage_types = ["blog.ArticleIndexPage", "legal.LegalIndexPage", "blog.AboutPage"]
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
PageChooserPanel("featured_article", "blog.ArticlePage"),
|
||||
]
|
||||
|
||||
def get_context(self, request, *args, **kwargs):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
articles_qs = (
|
||||
ArticlePage.objects.live()
|
||||
.public()
|
||||
.select_related("author")
|
||||
.prefetch_related("tags__metadata")
|
||||
.order_by("-first_published_at")
|
||||
)
|
||||
articles = list(articles_qs[:5])
|
||||
ctx["featured_article"] = self.featured_article
|
||||
ctx["latest_articles"] = articles
|
||||
ctx["more_articles"] = articles[:3]
|
||||
return ctx
|
||||
|
||||
|
||||
class ArticleIndexPage(Page):
|
||||
parent_page_types = ["blog.HomePage"]
|
||||
subpage_types = ["blog.ArticlePage"]
|
||||
ARTICLES_PER_PAGE = 12
|
||||
|
||||
def get_articles(self):
|
||||
return (
|
||||
ArticlePage.objects.child_of(self)
|
||||
.live()
|
||||
.select_related("author")
|
||||
.prefetch_related("tags__metadata")
|
||||
.order_by("-first_published_at")
|
||||
)
|
||||
|
||||
def get_context(self, request, *args, **kwargs):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
tag_slug = request.GET.get("tag")
|
||||
articles = self.get_articles()
|
||||
available_tags = (
|
||||
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
|
||||
)
|
||||
if tag_slug:
|
||||
articles = articles.filter(tags__slug=tag_slug)
|
||||
paginator = Paginator(articles, self.ARTICLES_PER_PAGE)
|
||||
page_num = request.GET.get("page")
|
||||
try:
|
||||
page_obj = paginator.page(page_num)
|
||||
except PageNotAnInteger:
|
||||
page_obj = paginator.page(1)
|
||||
except EmptyPage:
|
||||
page_obj = paginator.page(paginator.num_pages)
|
||||
ctx["articles"] = page_obj
|
||||
ctx["paginator"] = paginator
|
||||
ctx["active_tag"] = tag_slug
|
||||
ctx["available_tags"] = available_tags
|
||||
return ctx
|
||||
|
||||
|
||||
class ArticleTag(TaggedItemBase):
|
||||
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
|
||||
|
||||
|
||||
class TagMetadata(models.Model):
|
||||
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
||||
|
||||
tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata")
|
||||
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
||||
|
||||
@classmethod
|
||||
def get_fallback_css(cls) -> dict[str, str]:
|
||||
return {"bg": "bg-zinc-100", "text": "text-zinc-800"}
|
||||
|
||||
def get_css_classes(self) -> dict[str, str]:
|
||||
mapping = {
|
||||
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
|
||||
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
|
||||
"neutral": self.get_fallback_css(),
|
||||
}
|
||||
return mapping.get(self.colour, self.get_fallback_css())
|
||||
|
||||
|
||||
class ArticlePage(SeoMixin, Page):
|
||||
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
||||
hero_image = models.ForeignKey(
|
||||
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||
)
|
||||
summary = models.TextField()
|
||||
body = StreamField(ARTICLE_BODY_BLOCKS, use_json_field=True)
|
||||
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
|
||||
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
|
||||
comments_enabled = models.BooleanField(default=True)
|
||||
|
||||
parent_page_types = ["blog.ArticleIndexPage"]
|
||||
subpage_types: list[str] = []
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
FieldPanel("author"),
|
||||
FieldPanel("hero_image"),
|
||||
FieldPanel("summary"),
|
||||
FieldPanel("body"),
|
||||
FieldPanel("tags"),
|
||||
FieldPanel("comments_enabled"),
|
||||
]
|
||||
|
||||
promote_panels = Page.promote_panels + SeoMixin.seo_panels
|
||||
|
||||
search_fields = Page.search_fields
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.read_time_mins = self._compute_read_time()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def _compute_read_time(self) -> int:
|
||||
words = []
|
||||
for block in self.body:
|
||||
if block.block_type == "code":
|
||||
continue
|
||||
value = block.value
|
||||
text = value.source if hasattr(value, "source") else str(value)
|
||||
words.extend(re.findall(r"\w+", text))
|
||||
return max(1, ceil(len(words) / 200))
|
||||
|
||||
def get_tags_with_metadata(self):
|
||||
tags = self.tags.all()
|
||||
return [(tag, getattr(tag, "metadata", None)) for tag in tags]
|
||||
|
||||
def get_related_articles(self, count: int = 3):
|
||||
tag_ids = self.tags.values_list("id", flat=True)
|
||||
related = list(
|
||||
ArticlePage.objects.live()
|
||||
.filter(tags__in=tag_ids)
|
||||
.exclude(pk=self.pk)
|
||||
.distinct()
|
||||
.order_by("-first_published_at")[:count]
|
||||
)
|
||||
if len(related) < count:
|
||||
exclude_ids = [a.pk for a in related] + [self.pk]
|
||||
fallback = list(
|
||||
ArticlePage.objects.live()
|
||||
.exclude(pk__in=exclude_ids)
|
||||
.order_by("-first_published_at")[: count - len(related)]
|
||||
)
|
||||
return related + fallback
|
||||
return related
|
||||
|
||||
def get_context(self, request, *args, **kwargs):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
ctx["related_articles"] = self.get_related_articles()
|
||||
from apps.comments.models import Comment
|
||||
|
||||
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
||||
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
|
||||
Prefetch("replies", queryset=approved_replies)
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
class AboutPage(Page):
|
||||
mission_statement = models.TextField()
|
||||
body = RichTextField(blank=True)
|
||||
featured_author = models.ForeignKey(
|
||||
"authors.Author", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||
)
|
||||
|
||||
parent_page_types = ["blog.HomePage"]
|
||||
subpage_types: list[str] = []
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
FieldPanel("mission_statement"),
|
||||
FieldPanel("body"),
|
||||
FieldPanel("featured_author"),
|
||||
]
|
||||
0
apps/blog/tests/__init__.py
Normal file
0
apps/blog/tests/__init__.py
Normal file
64
apps/blog/tests/factories.py
Normal file
64
apps/blog/tests/factories.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import factory
|
||||
import wagtail_factories
|
||||
from django.utils import timezone
|
||||
from taggit.models import Tag
|
||||
from wagtail.models import Page
|
||||
|
||||
from apps.authors.models import Author
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
||||
from apps.legal.models import LegalIndexPage, LegalPage
|
||||
|
||||
|
||||
class AuthorFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Author
|
||||
|
||||
name = factory.Sequence(lambda n: f"Author {n}")
|
||||
slug = factory.Sequence(lambda n: f"author-{n}")
|
||||
|
||||
|
||||
class HomePageFactory(wagtail_factories.PageFactory):
|
||||
class Meta:
|
||||
model = HomePage
|
||||
|
||||
|
||||
class ArticleIndexPageFactory(wagtail_factories.PageFactory):
|
||||
class Meta:
|
||||
model = ArticleIndexPage
|
||||
|
||||
|
||||
class ArticlePageFactory(wagtail_factories.PageFactory):
|
||||
class Meta:
|
||||
model = ArticlePage
|
||||
|
||||
title = factory.Sequence(lambda n: f"Article {n}")
|
||||
slug = factory.Sequence(lambda n: f"article-{n}")
|
||||
author = factory.SubFactory(AuthorFactory)
|
||||
summary = "Summary"
|
||||
body = [("rich_text", "<p>Hello world</p>")]
|
||||
first_published_at = factory.LazyFunction(timezone.now)
|
||||
|
||||
|
||||
class LegalIndexPageFactory(wagtail_factories.PageFactory):
|
||||
class Meta:
|
||||
model = LegalIndexPage
|
||||
|
||||
|
||||
class LegalPageFactory(wagtail_factories.PageFactory):
|
||||
class Meta:
|
||||
model = LegalPage
|
||||
|
||||
title = factory.Sequence(lambda n: f"Legal {n}")
|
||||
slug = factory.Sequence(lambda n: f"legal-{n}")
|
||||
body = "<p>Body</p>"
|
||||
last_updated = factory.Faker("date_object")
|
||||
|
||||
|
||||
def root_page():
|
||||
return Page.get_first_root_node()
|
||||
|
||||
|
||||
def create_tag_with_meta(name: str, colour: str = "neutral"):
|
||||
tag, _ = Tag.objects.get_or_create(name=name, slug=name)
|
||||
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": colour})
|
||||
return tag
|
||||
34
apps/blog/tests/test_feeds.py
Normal file
34
apps/blog/tests/test_feeds.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_feed_endpoint(client):
|
||||
resp = client.get("/feed/")
|
||||
assert resp.status_code == 200
|
||||
assert resp["Content-Type"].startswith("application/rss+xml")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(WAGTAILADMIN_BASE_URL="http://wrong-host.example")
|
||||
def test_feed_uses_request_host_for_item_links(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(
|
||||
title="Feed Article",
|
||||
slug="feed-article",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>Body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/feed/")
|
||||
body = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert "http://localhost/articles/feed-article/" in body
|
||||
18
apps/blog/tests/test_feeds_more.py
Normal file
18
apps/blog/tests/test_feeds_more.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
|
||||
from apps.blog.feeds import AllArticlesFeed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_all_feed_methods(article_page):
|
||||
feed = AllArticlesFeed()
|
||||
assert feed.item_title(article_page) == article_page.title
|
||||
assert article_page.summary in feed.item_description(article_page)
|
||||
assert article_page.author.name == feed.item_author_name(article_page)
|
||||
assert feed.item_link(article_page).startswith("http")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_tag_feed_not_found(client):
|
||||
resp = client.get("/feed/tag/does-not-exist/")
|
||||
assert resp.status_code == 404
|
||||
42
apps/blog/tests/test_models.py
Normal file
42
apps/blog/tests/test_models.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
from django.db import IntegrityError
|
||||
from taggit.models import Tag
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_home_page_creation(home_page):
|
||||
assert HomePage.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_index_parent_restriction():
|
||||
assert ArticleIndexPage.parent_page_types == ["blog.HomePage"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_compute_read_time_excludes_code(home_page):
|
||||
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", "<p>one two three</p>"), ("code", {"language": "python", "raw_code": "x y z"})],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save()
|
||||
assert article.read_time_mins == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_tag_metadata_css_and_uniqueness():
|
||||
tag = Tag.objects.create(name="llms", slug="llms")
|
||||
meta = TagMetadata.objects.create(tag=tag, colour="cyan")
|
||||
assert meta.get_css_classes()["bg"].startswith("bg-cyan")
|
||||
with pytest.raises(IntegrityError):
|
||||
TagMetadata.objects.create(tag=tag, colour="pink")
|
||||
27
apps/blog/tests/test_more_models.py
Normal file
27
apps/blog/tests/test_more_models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import pytest
|
||||
|
||||
from apps.blog.models import TagMetadata
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_home_context_lists_articles(home_page, article_page):
|
||||
ctx = home_page.get_context(type("Req", (), {"GET": {}})())
|
||||
assert "latest_articles" in ctx
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_index_context_handles_page_values(article_index, article_page, rf):
|
||||
request = rf.get("/", {"page": "notanumber"})
|
||||
ctx = article_index.get_context(request)
|
||||
assert ctx["articles"].number == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_related_articles_fallback(article_page, article_index):
|
||||
related = article_page.get_related_articles()
|
||||
assert isinstance(related, list)
|
||||
|
||||
|
||||
def test_tag_metadata_fallback_classes():
|
||||
css = TagMetadata.get_fallback_css()
|
||||
assert css["bg"].startswith("bg-")
|
||||
35
apps/blog/tests/test_seo.py
Normal file
35
apps/blog/tests/test_seo.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_page_renders_core_seo_meta(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(
|
||||
title="SEO Article",
|
||||
slug="seo-article",
|
||||
author=author,
|
||||
summary="Summary content",
|
||||
body=[("rich_text", "<p>Body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/articles/seo-article/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert '<link rel="canonical" href="http' in html
|
||||
assert 'property="og:type" content="article"' in html
|
||||
assert 'name="twitter:card" content="summary_large_image"' in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_homepage_renders_website_og_type(client, home_page):
|
||||
resp = client.get("/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert 'property="og:type" content="website"' in html
|
||||
159
apps/blog/tests/test_views.py
Normal file
159
apps/blog/tests/test_views.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import pytest
|
||||
from taggit.models import Tag
|
||||
|
||||
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_homepage_render(client, home_page):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_index_pagination_and_tag_filter(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
for n in range(14):
|
||||
article = ArticlePage(
|
||||
title=f"A{n}",
|
||||
slug=f"a{n}",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/articles/?page=2")
|
||||
assert resp.status_code == 200
|
||||
assert resp.context["articles"].number == 2
|
||||
assert "Pagination" in resp.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_page_related_context(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
main = ArticlePage(
|
||||
title="Main",
|
||||
slug="main",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>body</p>")],
|
||||
)
|
||||
index.add_child(instance=main)
|
||||
main.save_revision().publish()
|
||||
|
||||
related = ArticlePage(
|
||||
title="Related",
|
||||
slug="related",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>body</p>")],
|
||||
)
|
||||
index.add_child(instance=related)
|
||||
related.save_revision().publish()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_page_renders_approved_comments_and_reply_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()
|
||||
comment = Comment.objects.create(
|
||||
article=article,
|
||||
author_name="A",
|
||||
author_email="a@example.com",
|
||||
body="Top level",
|
||||
is_approved=True,
|
||||
)
|
||||
Comment.objects.create(
|
||||
article=article,
|
||||
parent=comment,
|
||||
author_name="B",
|
||||
author_email="b@example.com",
|
||||
body="Reply",
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
resp = client.get("/articles/main/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert "Top level" in html
|
||||
assert "Reply" in html
|
||||
assert f'name="parent_id" value="{comment.id}"' in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_index_renders_tag_filter_controls(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()
|
||||
tag = Tag.objects.create(name="TagOne", slug="tag-one")
|
||||
article.tags.add(tag)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/articles/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert "/articles/?tag=tag-one" in html
|
||||
13
apps/blog/wagtail_hooks.py
Normal file
13
apps/blog/wagtail_hooks.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||
|
||||
from apps.blog.models import TagMetadata
|
||||
|
||||
|
||||
class TagMetadataViewSet(SnippetViewSet):
|
||||
model = TagMetadata
|
||||
icon = "tag"
|
||||
list_display = ["tag", "colour"]
|
||||
|
||||
|
||||
register_snippet(TagMetadataViewSet)
|
||||
0
apps/comments/__init__.py
Normal file
0
apps/comments/__init__.py
Normal file
6
apps/comments/apps.py
Normal file
6
apps/comments/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.comments"
|
||||
19
apps/comments/forms.py
Normal file
19
apps/comments/forms.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django import forms
|
||||
|
||||
from apps.comments.models import Comment
|
||||
|
||||
|
||||
class CommentForm(forms.ModelForm):
|
||||
honeypot = forms.CharField(required=False)
|
||||
article_id = forms.IntegerField(widget=forms.HiddenInput)
|
||||
parent_id = forms.IntegerField(required=False, widget=forms.HiddenInput)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ["author_name", "author_email", "body"]
|
||||
|
||||
def clean_body(self):
|
||||
body = self.cleaned_data["body"]
|
||||
if not body.strip():
|
||||
raise forms.ValidationError("Comment body is required.")
|
||||
return body
|
||||
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)."))
|
||||
30
apps/comments/migrations/0001_initial.py
Normal file
30
apps/comments/migrations/0001_initial.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('blog', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('author_name', models.CharField(max_length=100)),
|
||||
('author_email', models.EmailField(max_length=254)),
|
||||
('body', models.TextField(max_length=2000)),
|
||||
('is_approved', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.articlepage')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='comments.comment')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
apps/comments/migrations/__init__.py
Normal file
0
apps/comments/migrations/__init__.py
Normal file
25
apps/comments/models.py
Normal file
25
apps/comments/models.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
article = models.ForeignKey("blog.ArticlePage", on_delete=models.CASCADE, related_name="comments")
|
||||
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies")
|
||||
author_name = models.CharField(max_length=100)
|
||||
author_email = models.EmailField()
|
||||
body = models.TextField(max_length=2000)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
def clean(self) -> None:
|
||||
if self.parent and self.parent.parent_id is not None:
|
||||
raise ValidationError("Replies cannot be nested beyond one level.")
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"{self.article.url}#comment-{self.pk}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Comment by {self.author_name}"
|
||||
0
apps/comments/tests/__init__.py
Normal file
0
apps/comments/tests/__init__.py
Normal file
81
apps/comments/tests/test_admin.py
Normal file
81
apps/comments/tests/test_admin.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import pytest
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
from apps.comments.models import Comment
|
||||
from apps.comments.wagtail_hooks import ApproveCommentBulkAction, CommentViewSet
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_viewset_annotates_pending_in_article(rf, home_page):
|
||||
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", "<p>body</p>")])
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
pending = Comment.objects.create(
|
||||
article=article,
|
||||
author_name="Pending",
|
||||
author_email="pending@example.com",
|
||||
body="Awaiting moderation",
|
||||
is_approved=False,
|
||||
)
|
||||
Comment.objects.create(
|
||||
article=article,
|
||||
author_name="Pending2",
|
||||
author_email="pending2@example.com",
|
||||
body="Awaiting moderation too",
|
||||
is_approved=False,
|
||||
)
|
||||
Comment.objects.create(
|
||||
article=article,
|
||||
author_name="Approved",
|
||||
author_email="approved@example.com",
|
||||
body="Already approved",
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
viewset = CommentViewSet()
|
||||
qs = viewset.get_queryset(rf.get("/cms/snippets/comments/comment/"))
|
||||
annotated = qs.get(pk=pending.pk)
|
||||
|
||||
assert annotated.pending_in_article == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_approve_action_marks_selected_pending_comments_as_approved(home_page):
|
||||
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", "<p>body</p>")])
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
pending = Comment.objects.create(
|
||||
article=article,
|
||||
author_name="Pending",
|
||||
author_email="pending@example.com",
|
||||
body="Awaiting moderation",
|
||||
is_approved=False,
|
||||
)
|
||||
approved = Comment.objects.create(
|
||||
article=article,
|
||||
author_name="Approved",
|
||||
author_email="approved@example.com",
|
||||
body="Already approved",
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
class _Context:
|
||||
model = Comment
|
||||
|
||||
updated, child_updates = ApproveCommentBulkAction.execute_action([pending, approved], self=_Context())
|
||||
pending.refresh_from_db()
|
||||
approved.refresh_from_db()
|
||||
|
||||
assert updated == 1
|
||||
assert child_updates == 0
|
||||
assert pending.is_approved is True
|
||||
assert approved.is_approved is True
|
||||
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
|
||||
40
apps/comments/tests/test_models.py
Normal file
40
apps/comments/tests/test_models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
from apps.comments.models import Comment
|
||||
|
||||
|
||||
def create_article(home):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>body</p>")])
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
return article
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_defaults_and_absolute_url(home_page):
|
||||
article = create_article(home_page)
|
||||
comment = Comment.objects.create(article=article, author_name="N", author_email="n@example.com", body="hello")
|
||||
assert comment.is_approved is False
|
||||
assert comment.get_absolute_url().endswith(f"#comment-{comment.id}")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reply_depth_validation(home_page):
|
||||
article = create_article(home_page)
|
||||
parent = Comment.objects.create(article=article, author_name="P", author_email="p@example.com", body="p")
|
||||
child = Comment.objects.create(
|
||||
article=article,
|
||||
author_name="C",
|
||||
author_email="c@example.com",
|
||||
body="c",
|
||||
parent=parent,
|
||||
)
|
||||
nested = Comment(article=article, author_name="X", author_email="x@example.com", body="x", parent=child)
|
||||
with pytest.raises(ValidationError):
|
||||
nested.clean()
|
||||
26
apps/comments/tests/test_more.py
Normal file
26
apps/comments/tests/test_more.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.comments.forms import CommentForm
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_form_rejects_blank_body():
|
||||
form = CommentForm(data={"author_name": "A", "author_email": "a@a.com", "body": " ", "article_id": 1})
|
||||
assert not form.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_rate_limit(client, article_page):
|
||||
cache.clear()
|
||||
payload = {
|
||||
"article_id": article_page.id,
|
||||
"author_name": "T",
|
||||
"author_email": "t@example.com",
|
||||
"body": "Hi",
|
||||
"honeypot": "",
|
||||
}
|
||||
for _ in range(3):
|
||||
client.post("/comments/post/", payload)
|
||||
resp = client.post("/comments/post/", payload)
|
||||
assert resp.status_code == 429
|
||||
160
apps/comments/tests/test_views.py
Normal file
160
apps/comments/tests/test_views.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
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_comment_post_flow(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", "<p>body</p>")])
|
||||
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": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"].endswith("?commented=1")
|
||||
assert Comment.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_post_rejected_when_comments_disabled(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", "<p>body</p>")],
|
||||
comments_enabled=False,
|
||||
)
|
||||
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": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert Comment.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_comment_post_rerenders_form_with_errors(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", "<p>body</p>")])
|
||||
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": " ",
|
||||
"honeypot": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert b'aria-label="Comment form errors"' in resp.content
|
||||
assert b'value="Test"' in resp.content
|
||||
assert b"test@example.com" in resp.content
|
||||
assert Comment.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_comment_reply_depth_is_enforced(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", "<p>body</p>")])
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
parent = Comment.objects.create(
|
||||
article=article,
|
||||
author_name="Parent",
|
||||
author_email="p@example.com",
|
||||
body="Parent",
|
||||
is_approved=True,
|
||||
)
|
||||
child = Comment.objects.create(
|
||||
article=article,
|
||||
parent=parent,
|
||||
author_name="Child",
|
||||
author_email="c@example.com",
|
||||
body="Child",
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/comments/post/",
|
||||
{
|
||||
"article_id": article.id,
|
||||
"parent_id": child.id,
|
||||
"author_name": "TooDeep",
|
||||
"author_email": "deep@example.com",
|
||||
"body": "Nope",
|
||||
"honeypot": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"Reply depth exceeds the allowed limit" in resp.content
|
||||
assert Comment.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(TRUSTED_PROXY_IPS=[])
|
||||
def test_comment_uses_remote_addr_when_proxy_untrusted(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", "<p>body</p>")])
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
client.post(
|
||||
"/comments/post/",
|
||||
{
|
||||
"article_id": article.id,
|
||||
"author_name": "Test",
|
||||
"author_email": "test@example.com",
|
||||
"body": "Hello",
|
||||
"honeypot": "",
|
||||
},
|
||||
REMOTE_ADDR="10.0.0.1",
|
||||
HTTP_X_FORWARDED_FOR="203.0.113.7",
|
||||
)
|
||||
comment = Comment.objects.get()
|
||||
assert comment.ip_address == "10.0.0.1"
|
||||
7
apps/comments/urls.py
Normal file
7
apps/comments/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from apps.comments.views import CommentCreateView
|
||||
|
||||
urlpatterns = [
|
||||
path("post/", CommentCreateView.as_view(), name="comment_post"),
|
||||
]
|
||||
63
apps/comments/views.py
Normal file
63
apps/comments/views.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views import View
|
||||
|
||||
from apps.blog.models import ArticlePage
|
||||
from apps.comments.forms import CommentForm
|
||||
from apps.comments.models import Comment
|
||||
|
||||
|
||||
def client_ip_from_request(request) -> str:
|
||||
remote_addr = request.META.get("REMOTE_ADDR", "").strip()
|
||||
trusted_proxies = getattr(settings, "TRUSTED_PROXY_IPS", [])
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if remote_addr in trusted_proxies and x_forwarded_for:
|
||||
return x_forwarded_for.split(",")[0].strip()
|
||||
return remote_addr
|
||||
|
||||
|
||||
class CommentCreateView(View):
|
||||
def _render_article_with_errors(self, request, article, form):
|
||||
context = article.get_context(request)
|
||||
context["page"] = article
|
||||
context["comment_form"] = form
|
||||
return render(request, "blog/article_page.html", context, status=200)
|
||||
|
||||
def post(self, request):
|
||||
ip = client_ip_from_request(request)
|
||||
key = f"comment-rate:{ip}"
|
||||
count = cache.get(key, 0)
|
||||
if count >= 3:
|
||||
return HttpResponse(status=429)
|
||||
cache.set(key, count + 1, timeout=60)
|
||||
|
||||
form = CommentForm(request.POST)
|
||||
article = get_object_or_404(ArticlePage, pk=request.POST.get("article_id"))
|
||||
if not article.comments_enabled:
|
||||
return HttpResponse(status=404)
|
||||
|
||||
if form.is_valid():
|
||||
if form.cleaned_data.get("honeypot"):
|
||||
return redirect(f"{article.url}?commented=1")
|
||||
comment = form.save(commit=False)
|
||||
comment.article = article
|
||||
parent_id = form.cleaned_data.get("parent_id")
|
||||
if parent_id:
|
||||
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
|
||||
comment.ip_address = ip or None
|
||||
try:
|
||||
comment.full_clean()
|
||||
except ValidationError:
|
||||
form.add_error(None, "Reply depth exceeds the allowed limit")
|
||||
return self._render_article_with_errors(request, article, form)
|
||||
comment.save()
|
||||
messages.success(request, "Your comment is awaiting moderation")
|
||||
return redirect(f"{article.url}?commented=1")
|
||||
|
||||
return self._render_article_with_errors(request, article, form)
|
||||
72
apps/comments/wagtail_hooks.py
Normal file
72
apps/comments/wagtail_hooks.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from typing import Any, cast
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import ngettext
|
||||
from wagtail import hooks
|
||||
from wagtail.admin.ui.tables import BooleanColumn
|
||||
from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
|
||||
from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.permissions import get_permission_name
|
||||
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||
|
||||
from apps.comments.models import Comment
|
||||
|
||||
|
||||
class ApproveCommentBulkAction(SnippetBulkAction):
|
||||
display_name = _("Approve")
|
||||
action_type = "approve"
|
||||
aria_label = _("Approve selected comments")
|
||||
template_name = "comments/confirm_bulk_approve.html"
|
||||
action_priority = 20
|
||||
models = [Comment]
|
||||
|
||||
def check_perm(self, snippet):
|
||||
if getattr(self, "can_change_items", None) is None:
|
||||
self.can_change_items = self.request.user.has_perm(get_permission_name("change", self.model))
|
||||
return self.can_change_items
|
||||
|
||||
@classmethod
|
||||
def execute_action(cls, objects, **kwargs):
|
||||
updated = kwargs["self"].model.objects.filter(pk__in=[obj.pk for obj in objects], is_approved=False).update(
|
||||
is_approved=True
|
||||
)
|
||||
return updated, 0
|
||||
|
||||
def get_success_message(self, num_parent_objects, num_child_objects):
|
||||
return ngettext(
|
||||
"%(count)d comment approved.",
|
||||
"%(count)d comments approved.",
|
||||
num_parent_objects,
|
||||
) % {"count": num_parent_objects}
|
||||
|
||||
|
||||
class CommentViewSet(SnippetViewSet):
|
||||
model = Comment
|
||||
queryset = Comment.objects.all()
|
||||
icon = "comment"
|
||||
list_display = ["author_name", "article", BooleanColumn("is_approved"), "pending_in_article", "created_at"]
|
||||
list_filter = ["is_approved"]
|
||||
search_fields = ["author_name", "body"]
|
||||
add_to_admin_menu = True
|
||||
|
||||
def get_queryset(self, request):
|
||||
base_qs = self.model.objects.all().select_related("article", "parent")
|
||||
# mypy-django-plugin currently crashes on QuerySet.annotate() in this file.
|
||||
typed_qs = cast(Any, base_qs)
|
||||
return typed_qs.annotate(
|
||||
pending_in_article=Count(
|
||||
"article__comments",
|
||||
filter=Q(article__comments__is_approved=False),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
|
||||
def pending_in_article(self, obj):
|
||||
return obj.pending_in_article
|
||||
|
||||
pending_in_article.short_description = "Pending (article)" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
register_snippet(CommentViewSet)
|
||||
hooks.register("register_bulk_action", ApproveCommentBulkAction)
|
||||
0
apps/core/__init__.py
Normal file
0
apps/core/__init__.py
Normal file
6
apps/core/apps.py
Normal file
6
apps/core/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
59
apps/core/consent.py
Normal file
59
apps/core/consent.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
CONSENT_COOKIE_NAME = "nhAiConsent"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsentState:
|
||||
analytics: bool = False
|
||||
advertising: bool = False
|
||||
policy_version: int = 0
|
||||
timestamp: int = 0
|
||||
|
||||
@property
|
||||
def requires_prompt(self) -> bool:
|
||||
return self.policy_version != settings.CONSENT_POLICY_VERSION
|
||||
|
||||
|
||||
class ConsentService:
|
||||
@staticmethod
|
||||
def get_consent(request) -> ConsentState:
|
||||
raw = request.COOKIES.get(CONSENT_COOKIE_NAME, "")
|
||||
if not raw:
|
||||
return ConsentState()
|
||||
|
||||
try:
|
||||
data = {k: v[0] for k, v in parse_qs(raw).items()}
|
||||
return ConsentState(
|
||||
analytics=data.get("a", "0") == "1",
|
||||
advertising=data.get("d", "0") == "1",
|
||||
policy_version=int(data.get("v", "0")),
|
||||
timestamp=int(data.get("ts", "0")),
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
return ConsentState()
|
||||
|
||||
@staticmethod
|
||||
def set_consent(response, *, analytics: bool, advertising: bool) -> None:
|
||||
payload = urlencode(
|
||||
{
|
||||
"a": int(analytics),
|
||||
"d": int(advertising),
|
||||
"v": settings.CONSENT_POLICY_VERSION,
|
||||
"ts": int(time.time()),
|
||||
}
|
||||
)
|
||||
response.set_cookie(
|
||||
CONSENT_COOKIE_NAME,
|
||||
payload,
|
||||
max_age=60 * 60 * 24 * 365,
|
||||
httponly=False,
|
||||
samesite="Lax",
|
||||
secure=not settings.DEBUG,
|
||||
)
|
||||
9
apps/core/context_processors.py
Normal file
9
apps/core/context_processors.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.core.models import SiteSettings
|
||||
|
||||
|
||||
def site_settings(request):
|
||||
site = Site.find_for_request(request)
|
||||
settings_obj = SiteSettings.for_site(site) if site else None
|
||||
return {"site_settings": settings_obj}
|
||||
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."))
|
||||
63
apps/core/management/commands/seed_e2e_content.py
Normal file
63
apps/core/management/commands/seed_e2e_content.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from wagtail.models import Page, Site
|
||||
|
||||
from apps.authors.models import Author
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed deterministic content for nightly Playwright E2E checks."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
root = Page.get_first_root_node()
|
||||
|
||||
home = HomePage.objects.child_of(root).first()
|
||||
if home is None:
|
||||
home = HomePage(title="Nightly Home", slug="nightly-home")
|
||||
root.add_child(instance=home)
|
||||
home.save_revision().publish()
|
||||
|
||||
article_index = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first()
|
||||
if article_index is None:
|
||||
article_index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home.add_child(instance=article_index)
|
||||
article_index.save_revision().publish()
|
||||
|
||||
author, _ = Author.objects.get_or_create(
|
||||
slug="e2e-author",
|
||||
defaults={
|
||||
"name": "E2E Author",
|
||||
"bio": "Seeded nightly test author.",
|
||||
},
|
||||
)
|
||||
|
||||
article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
|
||||
if article is None:
|
||||
article = ArticlePage(
|
||||
title="Nightly Playwright Journey",
|
||||
slug="nightly-playwright-journey",
|
||||
author=author,
|
||||
summary="Seeded article for nightly browser journey.",
|
||||
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
|
||||
comments_enabled=True,
|
||||
)
|
||||
article_index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
site, _ = Site.objects.get_or_create(
|
||||
hostname="127.0.0.1",
|
||||
port=8000,
|
||||
defaults={
|
||||
"root_page": home,
|
||||
"is_default_site": True,
|
||||
"site_name": "No Hype AI",
|
||||
},
|
||||
)
|
||||
site.root_page = home
|
||||
site.is_default_site = True
|
||||
site.site_name = "No Hype AI"
|
||||
site.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded nightly E2E content."))
|
||||
37
apps/core/middleware.py
Normal file
37
apps/core/middleware.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from .consent import ConsentService
|
||||
|
||||
|
||||
class ConsentMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
request.consent = ConsentService.get_consent(request)
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
request.csp_nonce = nonce
|
||||
response = self.get_response(request)
|
||||
response["Content-Security-Policy"] = (
|
||||
f"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{nonce}'; "
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
return response
|
||||
29
apps/core/migrations/0001_initial.py
Normal file
29
apps/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('wagtailcore', '0094_alter_page_locale'),
|
||||
('wagtailimages', '0027_image_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('default_og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||
('privacy_policy_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
|
||||
('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.site')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/core/migrations/__init__.py
Normal file
0
apps/core/migrations/__init__.py
Normal file
21
apps/core/models.py
Normal file
21
apps/core/models.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.db import models
|
||||
from django.db.models import SET_NULL
|
||||
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
|
||||
|
||||
|
||||
@register_setting
|
||||
class SiteSettings(BaseSiteSetting):
|
||||
default_og_image = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=SET_NULL,
|
||||
related_name="+",
|
||||
)
|
||||
privacy_policy_page = models.ForeignKey(
|
||||
"wagtailcore.Page",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=SET_NULL,
|
||||
related_name="+",
|
||||
)
|
||||
0
apps/core/templatetags/__init__.py
Normal file
0
apps/core/templatetags/__init__.py
Normal file
30
apps/core/templatetags/core_tags.py
Normal file
30
apps/core/templatetags/core_tags.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.blog.models import TagMetadata
|
||||
from apps.legal.models import LegalPage
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def get_legal_pages(context):
|
||||
request = context.get("request")
|
||||
site = Site.find_for_request(request) if request else None
|
||||
pages = LegalPage.objects.live().filter(show_in_footer=True)
|
||||
if site:
|
||||
pages = pages.in_site(site)
|
||||
return pages
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
@register.filter
|
||||
def get_tag_css(tag):
|
||||
meta = getattr(tag, "metadata", None)
|
||||
if meta is None:
|
||||
meta = TagMetadata.objects.filter(tag=tag).first()
|
||||
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
|
||||
return mark_safe(f"{classes['bg']} {classes['text']}")
|
||||
58
apps/core/templatetags/seo_tags.py
Normal file
58
apps/core/templatetags/seo_tags.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from wagtail.images.models import Image
|
||||
|
||||
from apps.core.models import SiteSettings
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def _article_image_url(request, article) -> str:
|
||||
site_settings = SiteSettings.for_request(request)
|
||||
image = article.hero_image or site_settings.default_og_image
|
||||
if isinstance(image, Image):
|
||||
rendition = image.get_rendition("fill-1200x630")
|
||||
return request.build_absolute_uri(rendition.url)
|
||||
return ""
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def canonical_url(context, page=None) -> str:
|
||||
request = context["request"]
|
||||
target = page or context.get("page")
|
||||
if target and hasattr(target, "get_full_url"):
|
||||
return target.get_full_url(request)
|
||||
return request.build_absolute_uri()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def article_og_image_url(context, article) -> str:
|
||||
return _article_image_url(context["request"], article)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def article_json_ld(context, article):
|
||||
request = context["request"]
|
||||
nonce = getattr(request, "csp_nonce", "")
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": article.title,
|
||||
"author": {"@type": "Person", "name": article.author.name},
|
||||
"datePublished": article.first_published_at.isoformat() if article.first_published_at else "",
|
||||
"dateModified": article.last_published_at.isoformat() if article.last_published_at else "",
|
||||
"description": article.search_description or article.summary,
|
||||
"url": article.get_full_url(request),
|
||||
"image": _article_image_url(request, article),
|
||||
}
|
||||
return mark_safe(
|
||||
'<script type="application/ld+json" nonce="'
|
||||
+ nonce
|
||||
+ '">'
|
||||
+ json.dumps(data, ensure_ascii=True)
|
||||
+ "</script>"
|
||||
)
|
||||
0
apps/core/tests/__init__.py
Normal file
0
apps/core/tests/__init__.py
Normal file
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")
|
||||
67
apps/core/tests/test_consent.py
Normal file
67
apps/core/tests/test_consent.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import pytest
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from apps.core.consent import CONSENT_COOKIE_NAME, ConsentService
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_consent_round_trip(rf):
|
||||
request = HttpRequest()
|
||||
response = HttpResponse()
|
||||
ConsentService.set_consent(response, analytics=True, advertising=False)
|
||||
cookie = response.cookies[CONSENT_COOKIE_NAME].value
|
||||
request.COOKIES[CONSENT_COOKIE_NAME] = cookie
|
||||
state = ConsentService.get_consent(request)
|
||||
assert state.analytics is True
|
||||
assert state.advertising is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_consent_post_view(client):
|
||||
resp = client.post("/consent/", {"accept_all": "1"}, follow=False)
|
||||
assert resp.status_code == 302
|
||||
assert CONSENT_COOKIE_NAME in resp.cookies
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_consent_get_without_cookie_defaults_false():
|
||||
request = HttpRequest()
|
||||
state = ConsentService.get_consent(request)
|
||||
assert state.analytics is False
|
||||
assert state.advertising is False
|
||||
assert state.requires_prompt is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_consent_malformed_cookie_returns_safe_default():
|
||||
request = HttpRequest()
|
||||
request.COOKIES[CONSENT_COOKIE_NAME] = "not=a=valid%%%cookie"
|
||||
state = ConsentService.get_consent(request)
|
||||
assert state.analytics is False
|
||||
assert state.advertising is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_consent_post_preferences(client):
|
||||
resp = client.post("/consent/", {"analytics": "1", "advertising": ""})
|
||||
assert resp.status_code == 302
|
||||
value = resp.cookies[CONSENT_COOKIE_NAME].value
|
||||
assert "a=1" in value
|
||||
assert "d=0" in value
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_consent_get_method_not_allowed(client):
|
||||
resp = client.get("/consent/")
|
||||
assert resp.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cookie_banner_hides_after_consent(client, home_page):
|
||||
first = client.get("/")
|
||||
assert "id=\"cookie-banner\"" in first.content.decode()
|
||||
consented = client.post("/consent/", {"accept_all": "1"})
|
||||
cookie_value = consented.cookies[CONSENT_COOKIE_NAME].value
|
||||
client.cookies[CONSENT_COOKIE_NAME] = cookie_value
|
||||
second = client.get("/")
|
||||
assert "id=\"cookie-banner\"" not in second.content.decode()
|
||||
50
apps/core/tests/test_more.py
Normal file
50
apps/core/tests/test_more.py
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import pytest
|
||||
from django.template import Context
|
||||
from django.test import RequestFactory
|
||||
from taggit.models import Tag
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.core.context_processors import site_settings
|
||||
from apps.core.templatetags import core_tags
|
||||
from apps.core.templatetags.seo_tags import article_json_ld
|
||||
from apps.legal.models import LegalIndexPage, LegalPage
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_context_processor_returns_sitesettings(home_page):
|
||||
rf = RequestFactory()
|
||||
request = rf.get("/")
|
||||
request.site = Site.find_for_request(request)
|
||||
data = site_settings(request)
|
||||
assert "site_settings" in data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_tag_css_fallback():
|
||||
tag = Tag.objects.create(name="x", slug="x")
|
||||
value = core_tags.get_tag_css(tag)
|
||||
assert "bg-zinc" in value
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_legal_pages_tag_callable(home_page):
|
||||
legal_index = LegalIndexPage(title="Legal", slug="legal")
|
||||
home_page.add_child(instance=legal_index)
|
||||
legal = LegalPage(title="Privacy", slug="privacy-policy", body="<p>x</p>", last_updated="2026-01-01")
|
||||
legal_index.add_child(instance=legal)
|
||||
legal.save_revision().publish()
|
||||
|
||||
rf = RequestFactory()
|
||||
request = rf.get("/")
|
||||
pages = core_tags.get_legal_pages({"request": request})
|
||||
assert pages.count() >= 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_json_ld_contains_headline(article_page, rf):
|
||||
request = rf.get("/")
|
||||
request.site = Site.objects.filter(is_default_site=True).first()
|
||||
result = article_json_ld(Context({"request": request}), article_page)
|
||||
assert "application/ld+json" in result
|
||||
assert article_page.title in result
|
||||
47
apps/core/tests/test_nightly_e2e_playwright.py
Normal file
47
apps/core/tests/test_nightly_e2e_playwright.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_nightly_playwright_journey() -> None:
|
||||
base_url = os.getenv("E2E_BASE_URL")
|
||||
if not base_url:
|
||||
pytest.skip("E2E_BASE_URL is not set")
|
||||
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch()
|
||||
page = browser.new_page()
|
||||
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
expect(page.locator("#cookie-banner")).to_be_visible()
|
||||
page.get_by_role("button", name="Toggle theme").click()
|
||||
page.get_by_role("button", name="Accept all").first.click()
|
||||
expect(page.locator("#cookie-banner")).to_have_count(0)
|
||||
|
||||
page.goto(f"{base_url}/articles/", wait_until="networkidle")
|
||||
first_article_link = page.locator("main article a").first
|
||||
expect(first_article_link).to_be_visible()
|
||||
article_href = first_article_link.get_attribute("href")
|
||||
assert article_href
|
||||
|
||||
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
|
||||
page.goto(article_url, wait_until="networkidle")
|
||||
expect(page.get_by_role("heading", name="Comments")).to_be_visible()
|
||||
expect(page.get_by_role("button", name="Post comment")).to_be_visible()
|
||||
|
||||
page.goto(f"{base_url}/feed/", wait_until="networkidle")
|
||||
feed_content = page.content()
|
||||
assert (
|
||||
"<rss" in feed_content
|
||||
or "<feed" in feed_content
|
||||
or "<rss" in feed_content
|
||||
or "<feed" in feed_content
|
||||
)
|
||||
|
||||
browser.close()
|
||||
75
apps/core/tests/test_performance.py
Normal file
75
apps/core/tests/test_performance.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
from taggit.models import Tag
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
def _build_article_tree(home_page: HomePage, count: int = 12):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
tag = Tag.objects.create(name="Bench", slug="bench")
|
||||
TagMetadata.objects.create(tag=tag, colour="cyan")
|
||||
|
||||
for n in range(count):
|
||||
article = ArticlePage(
|
||||
title=f"Article {n}",
|
||||
slug=f"article-{n}",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>body words</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.tags.add(tag)
|
||||
article.save_revision().publish()
|
||||
return index
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_homepage_query_budget(rf, home_page, django_assert_num_queries):
|
||||
_build_article_tree(home_page, count=8)
|
||||
request = rf.get("/")
|
||||
request.site = Site.objects.get(is_default_site=True)
|
||||
with django_assert_num_queries(10, exact=False):
|
||||
context = home_page.get_context(request)
|
||||
list(context["latest_articles"])
|
||||
list(context["more_articles"])
|
||||
assert len(context["latest_articles"]) <= 5
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_index_query_budget(rf, home_page, django_assert_num_queries):
|
||||
index = _build_article_tree(home_page, count=12)
|
||||
request = rf.get("/articles/")
|
||||
request.site = Site.objects.get(is_default_site=True)
|
||||
with django_assert_num_queries(12, exact=False):
|
||||
context = index.get_context(request)
|
||||
list(context["articles"])
|
||||
list(context["available_tags"])
|
||||
assert context["paginator"].count == 12
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_read_query_budget(rf, home_page, django_assert_num_queries):
|
||||
index = _build_article_tree(home_page, count=4)
|
||||
article = ArticlePage.objects.child_of(index).live().first()
|
||||
assert article is not None
|
||||
request = rf.get(article.url)
|
||||
request.site = Site.objects.get(is_default_site=True)
|
||||
with django_assert_num_queries(8, exact=False):
|
||||
context = article.get_context(request)
|
||||
list(context["related_articles"])
|
||||
list(context["approved_comments"])
|
||||
assert context["related_articles"] is not None
|
||||
|
||||
|
||||
def test_read_time_benchmark(benchmark):
|
||||
author = AuthorFactory.build()
|
||||
body = [("rich_text", "<p>" + "word " * 1000 + "</p>")]
|
||||
article = ArticlePage(title="Bench", slug="bench", author=author, summary="summary", body=body)
|
||||
|
||||
result = benchmark(article._compute_read_time)
|
||||
assert result >= 1
|
||||
assert benchmark.stats.stats.mean < 0.05
|
||||
101
apps/core/tests/test_security.py
Normal file
101
apps/core/tests/test_security.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_security_headers_present(client, home_page):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "Content-Security-Policy" in resp
|
||||
assert "Permissions-Policy" in resp
|
||||
assert "unsafe-inline" not in resp["Content-Security-Policy"]
|
||||
assert "script-src" in resp["Content-Security-Policy"]
|
||||
assert resp["X-Frame-Options"] == "SAMEORIGIN"
|
||||
assert "strict-origin-when-cross-origin" in resp["Referrer-Policy"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_csp_nonce_applied_to_inline_script(client, home_page):
|
||||
resp = client.get("/")
|
||||
csp = resp["Content-Security-Policy"]
|
||||
match = re.search(r"nonce-([^' ;]+)", csp)
|
||||
assert match
|
||||
nonce = match.group(1)
|
||||
html = resp.content.decode()
|
||||
assert f'nonce="{nonce}"' in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_robots_disallows_cms_and_contains_sitemap(client):
|
||||
resp = client.get("/robots.txt")
|
||||
body = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert "Disallow: /cms/" in body
|
||||
assert "Sitemap:" in body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_admin_obscured_path_redirects_to_cms(client):
|
||||
resp = client.get("/admin/")
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"] == "/cms/"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_comment_form_contains_csrf_token(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(
|
||||
title="CSRF Article",
|
||||
slug="csrf-article",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>Body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/articles/csrf-article/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert "csrfmiddlewaretoken" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_consent_rejects_open_redirect(client, home_page):
|
||||
resp = client.post(
|
||||
"/consent/",
|
||||
{"reject_all": "1"},
|
||||
HTTP_REFERER="https://evil.example.com/phish",
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"] == "/"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_json_ld_script_has_csp_nonce(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(
|
||||
title="Nonce Article",
|
||||
slug="nonce-article",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>Body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/articles/nonce-article/")
|
||||
csp = resp["Content-Security-Policy"]
|
||||
match = re.search(r"nonce-([^' ;]+)", csp)
|
||||
assert match
|
||||
nonce = match.group(1)
|
||||
html = resp.content.decode()
|
||||
assert f'type="application/ld+json" nonce="{nonce}"' in html
|
||||
2
apps/core/tests/test_smoke.py
Normal file
2
apps/core/tests/test_smoke.py
Normal file
@@ -0,0 +1,2 @@
|
||||
def test_smoke():
|
||||
assert 1 == 1
|
||||
15
apps/core/tests/test_tags.py
Normal file
15
apps/core/tests/test_tags.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import pytest
|
||||
|
||||
from apps.legal.models import LegalIndexPage, LegalPage
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_legal_pages_tag(client, home_page):
|
||||
legal_index = LegalIndexPage(title="Legal", slug="legal")
|
||||
home_page.add_child(instance=legal_index)
|
||||
legal = LegalPage(title="Privacy", slug="privacy-policy", last_updated="2026-01-01", body="<p>x</p>")
|
||||
legal_index.add_child(instance=legal)
|
||||
legal.save_revision().publish()
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
40
apps/core/views.py
Normal file
40
apps/core/views.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
from apps.core.consent import ConsentService
|
||||
|
||||
|
||||
def consent_view(request: HttpRequest) -> HttpResponse:
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
|
||||
analytics = False
|
||||
advertising = False
|
||||
|
||||
if request.POST.get("accept_all"):
|
||||
analytics = True
|
||||
advertising = True
|
||||
elif request.POST.get("reject_all"):
|
||||
analytics = False
|
||||
advertising = False
|
||||
else:
|
||||
analytics = request.POST.get("analytics") in {"true", "1", "on"}
|
||||
advertising = request.POST.get("advertising") in {"true", "1", "on"}
|
||||
|
||||
target = request.META.get("HTTP_REFERER", "/")
|
||||
if not url_has_allowed_host_and_scheme(
|
||||
url=target,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
target = "/"
|
||||
response = redirect(target)
|
||||
ConsentService.set_consent(response, analytics=analytics, advertising=advertising)
|
||||
return response
|
||||
|
||||
|
||||
def robots_txt(request: HttpRequest) -> HttpResponse:
|
||||
return render(request, "core/robots.txt", content_type="text/plain")
|
||||
0
apps/legal/__init__.py
Normal file
0
apps/legal/__init__.py
Normal file
6
apps/legal/apps.py
Normal file
6
apps/legal/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LegalConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.legal"
|
||||
40
apps/legal/migrations/0001_initial.py
Normal file
40
apps/legal/migrations/0001_initial.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||
|
||||
import django.db.models.deletion
|
||||
import wagtail.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('wagtailcore', '0094_alter_page_locale'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LegalIndexPage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LegalPage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
('body', wagtail.fields.RichTextField()),
|
||||
('last_updated', models.DateField()),
|
||||
('show_in_footer', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page',),
|
||||
),
|
||||
]
|
||||
0
apps/legal/migrations/__init__.py
Normal file
0
apps/legal/migrations/__init__.py
Normal file
25
apps/legal/models.py
Normal file
25
apps/legal/models.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import models
|
||||
from wagtail.fields import RichTextField
|
||||
from wagtail.models import Page
|
||||
|
||||
|
||||
class LegalIndexPage(Page):
|
||||
parent_page_types = ["blog.HomePage"]
|
||||
subpage_types = ["legal.LegalPage"]
|
||||
|
||||
def serve(self, request):
|
||||
from django.shortcuts import redirect
|
||||
|
||||
return redirect("/")
|
||||
|
||||
def get_sitemap_urls(self, request=None):
|
||||
return []
|
||||
|
||||
|
||||
class LegalPage(Page):
|
||||
body = RichTextField()
|
||||
last_updated = models.DateField()
|
||||
show_in_footer = models.BooleanField(default=True)
|
||||
|
||||
parent_page_types = ["legal.LegalIndexPage"]
|
||||
subpage_types = []
|
||||
0
apps/legal/tests/__init__.py
Normal file
0
apps/legal/tests/__init__.py
Normal file
23
apps/legal/tests/test_models.py
Normal file
23
apps/legal/tests/test_models.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from apps.legal.models import LegalIndexPage, LegalPage
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_legal_index_redirects(client, home_page):
|
||||
legal_index = LegalIndexPage(title="Legal", slug="legal")
|
||||
home_page.add_child(instance=legal_index)
|
||||
legal_index.save_revision().publish()
|
||||
resp = client.get("/legal/")
|
||||
assert resp.status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_legal_page_render(client, home_page):
|
||||
legal_index = LegalIndexPage(title="Legal", slug="legal")
|
||||
home_page.add_child(instance=legal_index)
|
||||
legal = LegalPage(title="Privacy", slug="privacy-policy", last_updated="2026-01-01", body="<p>x</p>")
|
||||
legal_index.add_child(instance=legal)
|
||||
legal.save_revision().publish()
|
||||
resp = client.get("/legal/privacy-policy/")
|
||||
assert resp.status_code == 200
|
||||
8
apps/legal/tests/test_more.py
Normal file
8
apps/legal/tests/test_more.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from apps.legal.models import LegalIndexPage
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_legal_index_sitemap_urls_empty():
|
||||
assert LegalIndexPage().get_sitemap_urls() == []
|
||||
0
apps/newsletter/__init__.py
Normal file
0
apps/newsletter/__init__.py
Normal file
6
apps/newsletter/apps.py
Normal file
6
apps/newsletter/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NewsletterConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.newsletter"
|
||||
7
apps/newsletter/forms.py
Normal file
7
apps/newsletter/forms.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class SubscriptionForm(forms.Form):
|
||||
email = forms.EmailField()
|
||||
source = forms.CharField(required=False)
|
||||
honeypot = forms.CharField(required=False)
|
||||
24
apps/newsletter/migrations/0001_initial.py
Normal file
24
apps/newsletter/migrations/0001_initial.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NewsletterSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('confirmed', models.BooleanField(default=False)),
|
||||
('source', models.CharField(default='unknown', max_length=100)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
apps/newsletter/migrations/__init__.py
Normal file
0
apps/newsletter/migrations/__init__.py
Normal file
11
apps/newsletter/models.py
Normal file
11
apps/newsletter/models.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class NewsletterSubscription(models.Model):
|
||||
email = models.EmailField(unique=True)
|
||||
confirmed = models.BooleanField(default=False)
|
||||
source = models.CharField(max_length=100, default="unknown")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.email
|
||||
43
apps/newsletter/services.py
Normal file
43
apps/newsletter/services.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProviderSyncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProviderSyncService:
|
||||
def sync(self, subscription):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ButtondownSyncService(ProviderSyncService):
|
||||
endpoint = "https://api.buttondown.email/v1/subscribers"
|
||||
|
||||
def sync(self, subscription):
|
||||
api_key = os.getenv("BUTTONDOWN_API_KEY", "")
|
||||
if not api_key:
|
||||
raise ProviderSyncError("BUTTONDOWN_API_KEY is not configured")
|
||||
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"},
|
||||
json={"email": subscription.email},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise ProviderSyncError(f"Buttondown sync failed: {response.status_code}")
|
||||
logger.info("Synced subscription %s to Buttondown", subscription.email)
|
||||
|
||||
|
||||
def get_provider_service() -> ProviderSyncService:
|
||||
provider = os.getenv("NEWSLETTER_PROVIDER", "buttondown").lower().strip()
|
||||
if provider != "buttondown":
|
||||
raise ProviderSyncError(f"Unsupported newsletter provider: {provider}")
|
||||
return ButtondownSyncService()
|
||||
0
apps/newsletter/tests/__init__.py
Normal file
0
apps/newsletter/tests/__init__.py
Normal file
14
apps/newsletter/tests/test_more.py
Normal file
14
apps/newsletter/tests/test_more.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import pytest
|
||||
|
||||
from apps.newsletter.services import ProviderSyncService
|
||||
from apps.newsletter.views import confirmation_token
|
||||
|
||||
|
||||
def test_confirmation_token_roundtrip():
|
||||
token = confirmation_token("x@example.com")
|
||||
assert token
|
||||
|
||||
|
||||
def test_provider_sync_not_implemented():
|
||||
with pytest.raises(NotImplementedError):
|
||||
ProviderSyncService().sync(None)
|
||||
55
apps/newsletter/tests/test_views.py
Normal file
55
apps/newsletter/tests/test_views.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from django.core import signing
|
||||
|
||||
from apps.newsletter.models import NewsletterSubscription
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_subscribe_ok(client):
|
||||
resp = client.post("/newsletter/subscribe/", {"email": "a@example.com", "source": "nav"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
assert NewsletterSubscription.objects.filter(email="a@example.com").exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_subscribe_sends_confirmation_email(client, mailoutbox):
|
||||
resp = client.post("/newsletter/subscribe/", {"email": "new@example.com", "source": "nav"})
|
||||
assert resp.status_code == 200
|
||||
assert len(mailoutbox) == 1
|
||||
assert "Confirm your No Hype AI newsletter subscription" in mailoutbox[0].subject
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_duplicate_subscribe_returns_ok_without_extra_email(client, mailoutbox):
|
||||
client.post("/newsletter/subscribe/", {"email": "dupe@example.com", "source": "nav"})
|
||||
assert len(mailoutbox) == 1
|
||||
resp = client.post("/newsletter/subscribe/", {"email": "dupe@example.com", "source": "footer"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
assert len(mailoutbox) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_subscribe_invalid(client):
|
||||
resp = client.post("/newsletter/subscribe/", {"email": "bad"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_confirm_endpoint(client):
|
||||
sub = NewsletterSubscription.objects.create(email="b@example.com")
|
||||
token = signing.dumps(sub.email, salt="newsletter-confirm")
|
||||
resp = client.get(f"/newsletter/confirm/{token}/")
|
||||
assert resp.status_code == 302
|
||||
sub.refresh_from_db()
|
||||
assert sub.confirmed is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_confirm_endpoint_with_expired_token(client, monkeypatch):
|
||||
sub = NewsletterSubscription.objects.create(email="c@example.com")
|
||||
token = signing.dumps(sub.email, salt="newsletter-confirm")
|
||||
monkeypatch.setattr("apps.newsletter.views.CONFIRMATION_TOKEN_MAX_AGE_SECONDS", -1)
|
||||
resp = client.get(f"/newsletter/confirm/{token}/")
|
||||
assert resp.status_code == 404
|
||||
8
apps/newsletter/urls.py
Normal file
8
apps/newsletter/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from apps.newsletter.views import ConfirmView, SubscribeView
|
||||
|
||||
urlpatterns = [
|
||||
path("subscribe/", SubscribeView.as_view(), name="newsletter_subscribe"),
|
||||
path("confirm/<str:token>/", ConfirmView.as_view(), name="newsletter_confirm"),
|
||||
]
|
||||
78
apps/newsletter/views.py
Normal file
78
apps/newsletter/views.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.core import signing
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
|
||||
from apps.newsletter.forms import SubscriptionForm
|
||||
from apps.newsletter.models import NewsletterSubscription
|
||||
from apps.newsletter.services import ProviderSyncError, get_provider_service
|
||||
|
||||
CONFIRMATION_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 2
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def confirmation_token(email: str) -> str:
|
||||
return signing.dumps(email, salt="newsletter-confirm")
|
||||
|
||||
|
||||
def send_confirmation_email(request, subscription: NewsletterSubscription) -> None:
|
||||
token = confirmation_token(subscription.email)
|
||||
confirm_url = request.build_absolute_uri(reverse("newsletter_confirm", args=[token]))
|
||||
context = {"confirmation_url": confirm_url, "subscription": subscription}
|
||||
subject = render_to_string("newsletter/email/confirmation_subject.txt", context).strip()
|
||||
text_body = render_to_string("newsletter/email/confirmation_body.txt", context)
|
||||
html_body = render_to_string("newsletter/email/confirmation_body.html", context)
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
to=[subscription.email],
|
||||
)
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
message.send()
|
||||
|
||||
|
||||
class SubscribeView(View):
|
||||
def post(self, request):
|
||||
form = SubscriptionForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return JsonResponse({"status": "error", "field": "email"}, status=400)
|
||||
if form.cleaned_data.get("honeypot"):
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
email = form.cleaned_data["email"].lower().strip()
|
||||
source = form.cleaned_data.get("source") or "unknown"
|
||||
subscription, created = NewsletterSubscription.objects.get_or_create(
|
||||
email=email,
|
||||
defaults={"source": source},
|
||||
)
|
||||
if created and not subscription.confirmed:
|
||||
send_confirmation_email(request, subscription)
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
class ConfirmView(View):
|
||||
def get(self, request, token: str):
|
||||
try:
|
||||
email = signing.loads(
|
||||
token,
|
||||
salt="newsletter-confirm",
|
||||
max_age=CONFIRMATION_TOKEN_MAX_AGE_SECONDS,
|
||||
)
|
||||
except signing.BadSignature as exc:
|
||||
raise Http404 from exc
|
||||
subscription = get_object_or_404(NewsletterSubscription, email=email)
|
||||
subscription.confirmed = True
|
||||
subscription.save(update_fields=["confirmed"])
|
||||
service = get_provider_service()
|
||||
try:
|
||||
service.sync(subscription)
|
||||
except ProviderSyncError as exc:
|
||||
logger.exception("Newsletter provider sync failed: %s", exc)
|
||||
return redirect("/")
|
||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
7
config/asgi.py
Normal file
7
config/asgi.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
|
||||
application = get_asgi_application()
|
||||
0
config/settings/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
147
config/settings/base.py
Normal file
147
config/settings/base.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import dj_database_url
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
raise ImproperlyConfigured("SECRET_KEY environment variable is required.")
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
if not DATABASE_URL:
|
||||
raise ImproperlyConfigured("DATABASE_URL environment variable is required.")
|
||||
DEBUG = os.getenv("DEBUG", "0") == "1"
|
||||
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h.strip()]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sitemaps",
|
||||
"taggit",
|
||||
"modelcluster",
|
||||
"wagtail.contrib.forms",
|
||||
"wagtail.contrib.redirects",
|
||||
"wagtail.contrib.sitemaps",
|
||||
"wagtail.contrib.settings",
|
||||
"wagtail.embeds",
|
||||
"wagtail.sites",
|
||||
"wagtail.users",
|
||||
"wagtail.snippets",
|
||||
"wagtail.documents",
|
||||
"wagtail.images",
|
||||
"wagtail.search",
|
||||
"wagtail.admin",
|
||||
"wagtail",
|
||||
"wagtailseo",
|
||||
"tailwind",
|
||||
"theme",
|
||||
"apps.core",
|
||||
"apps.blog",
|
||||
"apps.authors",
|
||||
"apps.comments",
|
||||
"apps.newsletter",
|
||||
"apps.legal",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"apps.core.middleware.SecurityHeadersMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
|
||||
"apps.core.middleware.ConsentMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"apps.core.context_processors.site_settings",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
DATABASES = {"default": dj_database_url.parse(DATABASE_URL)}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
WAGTAIL_SITE_NAME = os.getenv("WAGTAIL_SITE_NAME", "No Hype AI")
|
||||
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8035")
|
||||
|
||||
LOGIN_URL = "wagtailadmin_login"
|
||||
|
||||
CONSENT_POLICY_VERSION = int(os.getenv("CONSENT_POLICY_VERSION", "1"))
|
||||
|
||||
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "1") == "1"
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "hello@nohypeai.com")
|
||||
|
||||
NEWSLETTER_PROVIDER = os.getenv("NEWSLETTER_PROVIDER", "buttondown")
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_CONTENT_TYPE_OPTIONS = "nosniff"
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
|
||||
TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()]
|
||||
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
TAILWIND_APP_NAME = "theme"
|
||||
13
config/settings/development.py
Normal file
13
config/settings/development.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from .base import * # noqa
|
||||
|
||||
DEBUG = True
|
||||
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
try:
|
||||
import debug_toolbar # noqa: F401
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar"]
|
||||
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
||||
except Exception:
|
||||
pass
|
||||
9
config/settings/production.py
Normal file
9
config/settings/production.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .base import * # noqa
|
||||
|
||||
DEBUG = False
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user