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

Merged
mark merged 26 commits from codex_b/implementation-e2e into main 2026-02-28 17:55:14 +00:00
142 changed files with 4773 additions and 7 deletions

15
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

0
apps/authors/__init__.py Normal file
View File

6
apps/authors/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthorsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.authors"

View 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',
},
),
]

View File

44
apps/authors/models.py Normal file
View 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

View File

View 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

View 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
View File

6
apps/blog/apps.py Normal file
View 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
View 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
View 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]

View 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')),
],
),
]

View File

199
apps/blog/models.py Normal file
View 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"),
]

View File

View 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

View 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

View 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

View 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")

View 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-")

View 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

View 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

View 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)

View File

6
apps/comments/apps.py Normal file
View 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
View 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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.comments.models import Comment
class Command(BaseCommand):
help = "Nullify comment personal data for comments older than the retention window."
def add_arguments(self, parser):
parser.add_argument(
"--months",
type=int,
default=24,
help="Retention window in months before personal data is purged (default: 24).",
)
def handle(self, *args, **options):
months = options["months"]
cutoff = timezone.now() - timedelta(days=30 * months)
purged = (
Comment.objects.filter(created_at__lt=cutoff)
.exclude(author_email="")
.update(author_email="", ip_address=None)
)
self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s)."))

View File

@@ -0,0 +1,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')),
],
),
]

View File

25
apps/comments/models.py Normal file
View 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}"

View File

View 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

View File

@@ -0,0 +1,40 @@
from datetime import timedelta
import pytest
from django.core.management import call_command
from django.utils import timezone
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
from apps.comments.models import Comment
@pytest.mark.django_db
def test_purge_old_comment_data_clears_personal_fields(home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Article",
slug="article",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
old_comment = Comment.objects.create(
article=article,
author_name="Old",
author_email="old@example.com",
body="legacy",
ip_address="127.0.0.1",
)
Comment.objects.filter(pk=old_comment.pk).update(created_at=timezone.now() - timedelta(days=800))
call_command("purge_old_comment_data")
old_comment.refresh_from_db()
assert old_comment.author_email == ""
assert old_comment.ip_address is None

View File

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

View 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

View 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
View 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
View 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)

View 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
View File

6
apps/core/apps.py Normal file
View 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
View 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,
)

View 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}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.db.models.functions import Trim
from wagtail.models import Site
from apps.blog.models import ArticlePage
from apps.core.models import SiteSettings
class Command(BaseCommand):
help = "Validate content-integrity constraints for live article pages."
def handle(self, *args, **options):
errors: list[str] = []
missing_summary = ArticlePage.objects.live().annotate(summary_trimmed=Trim("summary")).filter(
summary_trimmed=""
)
if missing_summary.exists():
errors.append(f"{missing_summary.count()} live article(s) have an empty summary.")
missing_author = ArticlePage.objects.live().filter(author__isnull=True)
if missing_author.exists():
errors.append(f"{missing_author.count()} live article(s) have no author.")
default_site = Site.objects.filter(is_default_site=True).first()
default_og_image = None
if default_site:
default_og_image = SiteSettings.for_site(default_site).default_og_image
if default_og_image is None:
missing_hero = ArticlePage.objects.live().filter(hero_image__isnull=True)
if missing_hero.exists():
errors.append(
f"{missing_hero.count()} live article(s) have no hero image and no site default OG image is set."
)
if errors:
raise CommandError("Content integrity check failed: " + " ".join(errors))
self.stdout.write(self.style.SUCCESS("Content integrity check passed."))

View File

@@ -0,0 +1,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
View 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

View 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,
},
),
]

View File

21
apps/core/models.py Normal file
View 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="+",
)

View File

View 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']}")

View 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>"
)

View File

View File

@@ -0,0 +1,30 @@
import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@pytest.mark.django_db
def test_check_content_integrity_passes_when_requirements_met(home_page):
call_command("check_content_integrity")
@pytest.mark.django_db
def test_check_content_integrity_fails_for_blank_summary(home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Article",
slug="article",
author=author,
summary=" ",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
with pytest.raises(CommandError, match="empty summary"):
call_command("check_content_integrity")

View 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()

View 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

View 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 "&lt;rss" in feed_content
or "&lt;feed" in feed_content
)
browser.close()

View 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

View 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

View File

@@ -0,0 +1,2 @@
def test_smoke():
assert 1 == 1

View 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
View 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
View File

6
apps/legal/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LegalConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.legal"

View 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',),
),
]

View File

25
apps/legal/models.py Normal file
View 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 = []

View File

View 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

View 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() == []

View File

6
apps/newsletter/apps.py Normal file
View 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
View 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)

View 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)),
],
),
]

View File

11
apps/newsletter/models.py Normal file
View 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

View 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()

View File

View 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)

View 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
View 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
View 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
View File

7
config/asgi.py Normal file
View 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()

View File

147
config/settings/base.py Normal file
View 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"

View 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

View 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