3 Commits

Author SHA1 Message Date
Codex_B
938ff5b0d2 Add CI workflow and project runbook documentation
Some checks failed
CI / test (push) Failing after 2m52s
CI / test (pull_request) Failing after 2m32s
2026-02-28 11:53:09 +00:00
Codex_B
8970f4d8de Add Docker-executed pytest suite with >90% coverage 2026-02-28 11:53:05 +00:00
Codex_B
b5f0f40c4c Scaffold containerized Django/Wagtail app with core features 2026-02-28 11:52:59 +00:00
112 changed files with 2323 additions and 0 deletions

20
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
run: docker compose build
- name: Pytest
run: docker compose run --rm web pytest
- name: Ruff
run: docker compose run --rm web ruff check .
- name: Mypy
run: docker compose run --rm web mypy apps config

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/

12
CHANGELOG.md Normal file
View File

@@ -0,0 +1,12 @@
# 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.

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
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 \
&& 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"]

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# 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
sudo systemctl reload gunicorn
```
## Backups
- PostgreSQL dump daily: `pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz`
- `MEDIA_ROOT` rsynced offsite daily

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

41
apps/blog/feeds.py Normal file
View File

@@ -0,0 +1,41 @@
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 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):
return f"{settings.WAGTAILADMIN_BASE_URL}{item.url}"
class TagArticlesFeed(AllArticlesFeed):
def get_object(self, request, tag_slug: str):
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

191
apps/blog/models.py Normal file
View File

@@ -0,0 +1,191 @@
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
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import 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 = (
ArticlePage.objects.live()
.public()
.select_related("author")
.prefetch_related("tags__metadata")
.order_by("-first_published_at")
)
ctx["featured_article"] = self.featured_article
ctx["latest_articles"] = articles[:5]
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()
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
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()
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).select_related(
"parent"
)
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,8 @@
import pytest
@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")

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,61 @@
import pytest
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@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
@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

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,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,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,61 @@
import pytest
from django.core.cache import cache
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 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

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

42
apps/comments/views.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from django.contrib import messages
from django.core.cache import cache
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from apps.blog.models import ArticlePage
from apps.comments.forms import CommentForm
from apps.comments.models import Comment
class CommentCreateView(View):
def post(self, request):
ip = (request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR", "")).split(",")[0].strip()
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
comment.save()
messages.success(request, "Your comment is awaiting moderation")
return redirect(f"{article.url}?commented=1")
messages.error(request, "Please correct the form errors")
return redirect(article.url)

View File

@@ -0,0 +1,20 @@
from wagtail.admin.ui.tables import BooleanColumn
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet
from apps.comments.models import Comment
class CommentViewSet(SnippetViewSet):
model = Comment
icon = "comment"
list_display = ["author_name", "article", BooleanColumn("is_approved"), "created_at"]
list_filter = ["is_approved"]
search_fields = ["author_name", "body"]
add_to_admin_menu = True
def get_queryset(self, request):
return super().get_queryset(request).select_related("article", "parent")
register_snippet(CommentViewSet)

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}

12
apps/core/middleware.py Normal file
View File

@@ -0,0 +1,12 @@
from __future__ import annotations
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)

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,37 @@
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()
@register.simple_tag(takes_context=True)
def article_json_ld(context, article):
request = context["request"]
site_settings = SiteSettings.for_request(request)
image = article.hero_image or site_settings.default_og_image
image_url = ""
if isinstance(image, Image):
rendition = image.get_rendition("fill-1200x630")
image_url = request.build_absolute_uri(rendition.url)
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": image_url,
}
return mark_safe(
'<script type="application/ld+json">' + json.dumps(data, ensure_ascii=True) + "</script>"
)

View File

View File

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

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

33
apps/core/views.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
from django.shortcuts import redirect, render
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", "/")
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,23 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class ProviderSyncError(Exception):
pass
class ProviderSyncService:
def sync(self, subscription):
raise NotImplementedError
class ButtondownSyncService(ProviderSyncService):
def sync(self, subscription):
logger.info("Synced subscription %s", subscription.email)
def get_provider_service() -> ProviderSyncService:
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,37 @@
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_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"),
]

51
apps/newsletter/views.py Normal file
View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from django.core import signing
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
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
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"]
source = form.cleaned_data.get("source") or "unknown"
NewsletterSubscription.objects.get_or_create(email=email, defaults={"source": source})
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:
pass
return redirect("/")
def confirmation_token(email: str) -> str:
return signing.dumps(email, salt="newsletter-confirm")

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

136
config/settings/base.py Normal file
View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import os
from pathlib import Path
import dj_database_url
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parents[2]
SECRET_KEY = os.getenv("SECRET_KEY", "unsafe-dev-secret")
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",
"apps.core",
"apps.blog",
"apps.authors",
"apps.comments",
"apps.newsletter",
"apps.legal",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"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(os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"))
}
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
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"

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

23
config/urls.py Normal file
View File

@@ -0,0 +1,23 @@
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
from wagtail import urls as wagtail_urls
from wagtail.contrib.sitemaps.views import sitemap
from apps.blog.feeds import AllArticlesFeed, TagArticlesFeed
from apps.core.views import consent_view, robots_txt
urlpatterns = [
path("django-admin/", admin.site.urls),
path("cms/", include("wagtail.admin.urls")),
path("documents/", include("wagtail.documents.urls")),
path("comments/", include("apps.comments.urls")),
path("newsletter/", include("apps.newsletter.urls")),
path("consent/", consent_view, name="consent"),
path("robots.txt", robots_txt, name="robots_txt"),
path("feed/", AllArticlesFeed(), name="rss_feed"),
path("feed/tag/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
path("sitemap.xml", sitemap),
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
path("", include(wagtail_urls)),
]

7
config/wsgi.py Normal file
View File

@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_wsgi_application()

52
conftest.py Normal file
View File

@@ -0,0 +1,52 @@
import pytest
from wagtail.models import Page, Site
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage
from apps.blog.tests.factories import AuthorFactory
@pytest.fixture
def home_page(db):
root = Page.get_first_root_node()
home = HomePage(title="Home", slug=f"home-{HomePage.objects.count() + 1}")
root.add_child(instance=home)
home.save_revision().publish()
site = Site.objects.filter(is_default_site=True).first()
if site:
site.root_page = home
site.hostname = "localhost"
site.port = 80
site.site_name = "No Hype AI"
site.save()
else:
Site.objects.create(
hostname="localhost",
root_page=home,
is_default_site=True,
site_name="No Hype AI",
port=80,
)
return home
@pytest.fixture
def article_index(home_page):
index = ArticleIndexPage(title="Articles", slug=f"articles-{ArticleIndexPage.objects.count() + 1}")
home_page.add_child(instance=index)
index.save_revision().publish()
return index
@pytest.fixture
def article_page(article_index):
author = AuthorFactory()
article = ArticlePage(
title=f"Article {ArticlePage.objects.count() + 1}",
slug=f"article-{ArticlePage.objects.count() + 1}",
author=author,
summary="summary",
body=[("rich_text", "<p>body words</p>")],
)
article_index.add_child(instance=article)
article.save_revision().publish()
return article

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
web:
build: .
container_name: nohype-web
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8035:8000"
env_file:
- .env
environment:
DATABASE_URL: postgres://nohype:nohype@db:5432/nohype
DJANGO_SETTINGS_MODULE: config.settings.development
depends_on:
- db
db:
image: postgres:16-alpine
container_name: nohype-db
environment:
POSTGRES_DB: nohype
POSTGRES_USER: nohype
POSTGRES_PASSWORD: nohype
ports:
- "5545:5432"
volumes:
- nohype_pg:/var/lib/postgresql/data
volumes:
nohype_pg:

14
manage.py Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

45
pyproject.toml Normal file
View File

@@ -0,0 +1,45 @@
[tool.ruff]
line-length = 120
target-version = "py312"
exclude = ["migrations"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
[tool.ruff.lint.per-file-ignores]
"config/settings/development.py" = ["F403", "F405"]
[tool.mypy]
python_version = "3.12"
plugins = ["mypy_django_plugin.main"]
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
check_untyped_defs = true
exclude = ["migrations"]
disable_error_code = ["var-annotated", "override", "import-untyped", "arg-type"]
allow_untyped_globals = true
[[tool.mypy.overrides]]
module = ["wagtail.*", "taggit.*", "modelcluster.*", "wagtailseo.*", "debug_toolbar"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["apps.authors.models"]
ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings.development"
[tool.coverage.run]
source = ["apps"]
omit = [
"*/migrations/*",
"*/tests/*",
]
[tool.coverage.report]
omit = [
"*/migrations/*",
"*/tests/*",
]

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.development
python_files = test_*.py
addopts = -q --cov=apps --cov-report=term-missing --cov-fail-under=90

22
requirements/base.txt Normal file
View File

@@ -0,0 +1,22 @@
Django~=5.2.0
wagtail~=7.0.0
wagtail-seo~=3.1.1
psycopg2-binary~=2.9.0
Pillow~=11.0.0
django-taggit~=6.0.0
whitenoise~=6.0.0
gunicorn~=23.0.0
python-dotenv~=1.0.0
dj-database-url~=2.2.0
django-tailwind~=3.8.0
django-csp~=3.8.0
pytest~=8.3.0
pytest-django~=4.9.0
pytest-cov~=5.0.0
pytest-benchmark~=4.0.0
factory-boy~=3.3.0
wagtail-factories~=4.2.0
feedparser~=6.0.0
ruff~=0.6.0
mypy~=1.11.0
django-stubs~=5.1.0

View File

@@ -0,0 +1,2 @@
-r base.txt
sentry-sdk~=2.0.0

13
static/js/consent.js Normal file
View File

@@ -0,0 +1,13 @@
(function () {
function parseCookieValue(name) {
const match = document.cookie.match(new RegExp('(?:^|;)\\s*' + name + '\\s*=\\s*([^;]+)'));
if (!match) return {};
try {
return Object.fromEntries(new URLSearchParams(match[1]));
} catch (_e) {
return {};
}
}
const c = parseCookieValue('nhAiConsent');
window.__nhConsent = { analytics: c.a === '1', advertising: c.d === '1' };
})();

1
static/js/prism.js Normal file
View File

@@ -0,0 +1 @@
/* placeholder for Prism.js bundle */

7
static/js/theme.js Normal file
View File

@@ -0,0 +1,7 @@
(function () {
window.toggleTheme = function toggleTheme() {
const root = document.documentElement;
root.classList.toggle('dark');
localStorage.setItem('theme', root.classList.contains('dark') ? 'dark' : 'light');
};
})();

21
templates/base.html Normal file
View File

@@ -0,0 +1,21 @@
{% load static core_tags %}
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}No Hype AI{% endblock %}</title>
<script nonce="{{ request.csp_nonce|default:'' }}">
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
</script>
<script src="{% static 'js/consent.js' %}" nonce="{{ request.csp_nonce|default:'' }}"></script>
<script src="{% static 'js/theme.js' %}" defer></script>
<script src="{% static 'js/prism.js' %}" defer></script>
</head>
<body>
{% include 'components/nav.html' %}
{% include 'components/cookie_banner.html' %}
<main>{% block content %}{% endblock %}</main>
{% include 'components/footer.html' %}
</body>
</html>

View File

@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% load wagtailimages_tags wagtailcore_tags %}
{% block content %}
<h1>{{ page.title }}</h1>
<p>{{ page.mission_statement }}</p>
{{ page.body|richtext }}
{% if page.featured_author %}
<h2>{{ page.featured_author.name }}</h2>
<p>{{ page.featured_author.bio }}</p>
{% if page.featured_author.avatar %}
{% image page.featured_author.avatar fill-200x200 %}
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load core_tags %}
{% block title %}Articles | No Hype AI{% endblock %}
{% block content %}
<h1>{{ page.title }}</h1>
{% for article in articles %}
{% include 'components/article_card.html' with article=article %}
{% empty %}
<p>No articles found.</p>
{% endfor %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More