From b5f0f40c4c1c5cc06761a3104c11e62f27e5b18e Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 11:52:59 +0000 Subject: [PATCH 01/26] Scaffold containerized Django/Wagtail app with core features --- .gitignore | 16 ++ Dockerfile | 22 +++ apps/__init__.py | 0 apps/authors/__init__.py | 0 apps/authors/apps.py | 6 + apps/authors/migrations/0001_initial.py | 34 ++++ apps/authors/migrations/__init__.py | 0 apps/authors/models.py | 44 +++++ apps/authors/wagtail_hooks.py | 15 ++ apps/blog/__init__.py | 0 apps/blog/apps.py | 6 + apps/blog/blocks.py | 81 +++++++++ apps/blog/feeds.py | 41 +++++ apps/blog/migrations/0001_initial.py | 98 ++++++++++ apps/blog/migrations/__init__.py | 0 apps/blog/models.py | 191 ++++++++++++++++++++ apps/blog/wagtail_hooks.py | 13 ++ apps/comments/__init__.py | 0 apps/comments/apps.py | 6 + apps/comments/forms.py | 19 ++ apps/comments/migrations/0001_initial.py | 30 +++ apps/comments/migrations/__init__.py | 0 apps/comments/models.py | 25 +++ apps/comments/urls.py | 7 + apps/comments/views.py | 42 +++++ apps/comments/wagtail_hooks.py | 20 ++ apps/core/__init__.py | 0 apps/core/apps.py | 6 + apps/core/consent.py | 59 ++++++ apps/core/context_processors.py | 9 + apps/core/middleware.py | 12 ++ apps/core/migrations/0001_initial.py | 29 +++ apps/core/migrations/__init__.py | 0 apps/core/models.py | 21 +++ apps/core/templatetags/__init__.py | 0 apps/core/templatetags/core_tags.py | 30 +++ apps/core/templatetags/seo_tags.py | 37 ++++ apps/core/views.py | 33 ++++ apps/legal/__init__.py | 0 apps/legal/apps.py | 6 + apps/legal/migrations/0001_initial.py | 40 ++++ apps/legal/migrations/__init__.py | 0 apps/legal/models.py | 25 +++ apps/newsletter/__init__.py | 0 apps/newsletter/apps.py | 6 + apps/newsletter/forms.py | 7 + apps/newsletter/migrations/0001_initial.py | 24 +++ apps/newsletter/migrations/__init__.py | 0 apps/newsletter/models.py | 11 ++ apps/newsletter/services.py | 23 +++ apps/newsletter/urls.py | 8 + apps/newsletter/views.py | 51 ++++++ config/__init__.py | 0 config/asgi.py | 7 + config/settings/__init__.py | 0 config/settings/base.py | 136 ++++++++++++++ config/settings/development.py | 13 ++ config/settings/production.py | 9 + config/urls.py | 23 +++ config/wsgi.py | 7 + docker-compose.yml | 31 ++++ manage.py | 14 ++ pyproject.toml | 45 +++++ pytest.ini | 4 + requirements/base.txt | 22 +++ requirements/production.txt | 2 + static/js/consent.js | 13 ++ static/js/prism.js | 1 + static/js/theme.js | 7 + templates/base.html | 21 +++ templates/blog/about_page.html | 14 ++ templates/blog/article_index_page.html | 11 ++ templates/blog/article_page.html | 33 ++++ templates/blog/blocks/callout_block.html | 4 + templates/blog/blocks/code_block.html | 5 + templates/blog/blocks/image_block.html | 5 + templates/blog/blocks/pull_quote_block.html | 4 + templates/blog/home_page.html | 21 +++ templates/components/article_card.html | 8 + templates/components/cookie_banner.html | 12 ++ templates/components/footer.html | 7 + templates/components/nav.html | 5 + templates/core/robots.txt | 3 + templates/legal/legal_page.html | 7 + 84 files changed, 1647 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 apps/__init__.py create mode 100644 apps/authors/__init__.py create mode 100644 apps/authors/apps.py create mode 100644 apps/authors/migrations/0001_initial.py create mode 100644 apps/authors/migrations/__init__.py create mode 100644 apps/authors/models.py create mode 100644 apps/authors/wagtail_hooks.py create mode 100644 apps/blog/__init__.py create mode 100644 apps/blog/apps.py create mode 100644 apps/blog/blocks.py create mode 100644 apps/blog/feeds.py create mode 100644 apps/blog/migrations/0001_initial.py create mode 100644 apps/blog/migrations/__init__.py create mode 100644 apps/blog/models.py create mode 100644 apps/blog/wagtail_hooks.py create mode 100644 apps/comments/__init__.py create mode 100644 apps/comments/apps.py create mode 100644 apps/comments/forms.py create mode 100644 apps/comments/migrations/0001_initial.py create mode 100644 apps/comments/migrations/__init__.py create mode 100644 apps/comments/models.py create mode 100644 apps/comments/urls.py create mode 100644 apps/comments/views.py create mode 100644 apps/comments/wagtail_hooks.py create mode 100644 apps/core/__init__.py create mode 100644 apps/core/apps.py create mode 100644 apps/core/consent.py create mode 100644 apps/core/context_processors.py create mode 100644 apps/core/middleware.py create mode 100644 apps/core/migrations/0001_initial.py create mode 100644 apps/core/migrations/__init__.py create mode 100644 apps/core/models.py create mode 100644 apps/core/templatetags/__init__.py create mode 100644 apps/core/templatetags/core_tags.py create mode 100644 apps/core/templatetags/seo_tags.py create mode 100644 apps/core/views.py create mode 100644 apps/legal/__init__.py create mode 100644 apps/legal/apps.py create mode 100644 apps/legal/migrations/0001_initial.py create mode 100644 apps/legal/migrations/__init__.py create mode 100644 apps/legal/models.py create mode 100644 apps/newsletter/__init__.py create mode 100644 apps/newsletter/apps.py create mode 100644 apps/newsletter/forms.py create mode 100644 apps/newsletter/migrations/0001_initial.py create mode 100644 apps/newsletter/migrations/__init__.py create mode 100644 apps/newsletter/models.py create mode 100644 apps/newsletter/services.py create mode 100644 apps/newsletter/urls.py create mode 100644 apps/newsletter/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/development.py create mode 100644 config/settings/production.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.yml create mode 100755 manage.py create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 requirements/base.txt create mode 100644 requirements/production.txt create mode 100644 static/js/consent.js create mode 100644 static/js/prism.js create mode 100644 static/js/theme.js create mode 100644 templates/base.html create mode 100644 templates/blog/about_page.html create mode 100644 templates/blog/article_index_page.html create mode 100644 templates/blog/article_page.html create mode 100644 templates/blog/blocks/callout_block.html create mode 100644 templates/blog/blocks/code_block.html create mode 100644 templates/blog/blocks/image_block.html create mode 100644 templates/blog/blocks/pull_quote_block.html create mode 100644 templates/blog/home_page.html create mode 100644 templates/components/article_card.html create mode 100644 templates/components/cookie_banner.html create mode 100644 templates/components/footer.html create mode 100644 templates/components/nav.html create mode 100644 templates/core/robots.txt create mode 100644 templates/legal/legal_page.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..051f926 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48a805d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authors/__init__.py b/apps/authors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authors/apps.py b/apps/authors/apps.py new file mode 100644 index 0000000..3ead996 --- /dev/null +++ b/apps/authors/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthorsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.authors" diff --git a/apps/authors/migrations/0001_initial.py b/apps/authors/migrations/0001_initial.py new file mode 100644 index 0000000..3e9886a --- /dev/null +++ b/apps/authors/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/apps/authors/migrations/__init__.py b/apps/authors/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authors/models.py b/apps/authors/models.py new file mode 100644 index 0000000..9d0cb3a --- /dev/null +++ b/apps/authors/models.py @@ -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 diff --git a/apps/authors/wagtail_hooks.py b/apps/authors/wagtail_hooks.py new file mode 100644 index 0000000..e542c6c --- /dev/null +++ b/apps/authors/wagtail_hooks.py @@ -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) diff --git a/apps/blog/__init__.py b/apps/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/apps.py b/apps/blog/apps.py new file mode 100644 index 0000000..7c0f5c2 --- /dev/null +++ b/apps/blog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.blog" diff --git a/apps/blog/blocks.py b/apps/blog/blocks.py new file mode 100644 index 0000000..8397fb9 --- /dev/null +++ b/apps/blog/blocks.py @@ -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()), +] diff --git a/apps/blog/feeds.py b/apps/blog/feeds.py new file mode 100644 index 0000000..b7ecf59 --- /dev/null +++ b/apps/blog/feeds.py @@ -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] diff --git a/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..b8685cf --- /dev/null +++ b/apps/blog/migrations/0001_initial.py @@ -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')), + ], + ), + ] diff --git a/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/models.py b/apps/blog/models.py new file mode 100644 index 0000000..c6e22d9 --- /dev/null +++ b/apps/blog/models.py @@ -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"), + ] diff --git a/apps/blog/wagtail_hooks.py b/apps/blog/wagtail_hooks.py new file mode 100644 index 0000000..0dddec4 --- /dev/null +++ b/apps/blog/wagtail_hooks.py @@ -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) diff --git a/apps/comments/__init__.py b/apps/comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/comments/apps.py b/apps/comments/apps.py new file mode 100644 index 0000000..338c0b0 --- /dev/null +++ b/apps/comments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.comments" diff --git a/apps/comments/forms.py b/apps/comments/forms.py new file mode 100644 index 0000000..255d67c --- /dev/null +++ b/apps/comments/forms.py @@ -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 diff --git a/apps/comments/migrations/0001_initial.py b/apps/comments/migrations/0001_initial.py new file mode 100644 index 0000000..af46b79 --- /dev/null +++ b/apps/comments/migrations/0001_initial.py @@ -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')), + ], + ), + ] diff --git a/apps/comments/migrations/__init__.py b/apps/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/comments/models.py b/apps/comments/models.py new file mode 100644 index 0000000..5689d97 --- /dev/null +++ b/apps/comments/models.py @@ -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}" diff --git a/apps/comments/urls.py b/apps/comments/urls.py new file mode 100644 index 0000000..861e88c --- /dev/null +++ b/apps/comments/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.comments.views import CommentCreateView + +urlpatterns = [ + path("post/", CommentCreateView.as_view(), name="comment_post"), +] diff --git a/apps/comments/views.py b/apps/comments/views.py new file mode 100644 index 0000000..243e16f --- /dev/null +++ b/apps/comments/views.py @@ -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) diff --git a/apps/comments/wagtail_hooks.py b/apps/comments/wagtail_hooks.py new file mode 100644 index 0000000..9356d28 --- /dev/null +++ b/apps/comments/wagtail_hooks.py @@ -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) diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..ab0051e --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" diff --git a/apps/core/consent.py b/apps/core/consent.py new file mode 100644 index 0000000..8737a1d --- /dev/null +++ b/apps/core/consent.py @@ -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, + ) diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py new file mode 100644 index 0000000..8ce6231 --- /dev/null +++ b/apps/core/context_processors.py @@ -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} diff --git a/apps/core/middleware.py b/apps/core/middleware.py new file mode 100644 index 0000000..76dba2f --- /dev/null +++ b/apps/core/middleware.py @@ -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) diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..229d6f6 --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..68c3e2c --- /dev/null +++ b/apps/core/models.py @@ -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="+", + ) diff --git a/apps/core/templatetags/__init__.py b/apps/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/templatetags/core_tags.py b/apps/core/templatetags/core_tags.py new file mode 100644 index 0000000..213e3bb --- /dev/null +++ b/apps/core/templatetags/core_tags.py @@ -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']}") diff --git a/apps/core/templatetags/seo_tags.py b/apps/core/templatetags/seo_tags.py new file mode 100644 index 0000000..3bccad7 --- /dev/null +++ b/apps/core/templatetags/seo_tags.py @@ -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( + '" + ) diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..d2b93e2 --- /dev/null +++ b/apps/core/views.py @@ -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") diff --git a/apps/legal/__init__.py b/apps/legal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/legal/apps.py b/apps/legal/apps.py new file mode 100644 index 0000000..8fea38e --- /dev/null +++ b/apps/legal/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LegalConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.legal" diff --git a/apps/legal/migrations/0001_initial.py b/apps/legal/migrations/0001_initial.py new file mode 100644 index 0000000..e72e2e1 --- /dev/null +++ b/apps/legal/migrations/0001_initial.py @@ -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',), + ), + ] diff --git a/apps/legal/migrations/__init__.py b/apps/legal/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/legal/models.py b/apps/legal/models.py new file mode 100644 index 0000000..74abc93 --- /dev/null +++ b/apps/legal/models.py @@ -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 = [] diff --git a/apps/newsletter/__init__.py b/apps/newsletter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/newsletter/apps.py b/apps/newsletter/apps.py new file mode 100644 index 0000000..81151b7 --- /dev/null +++ b/apps/newsletter/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NewsletterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.newsletter" diff --git a/apps/newsletter/forms.py b/apps/newsletter/forms.py new file mode 100644 index 0000000..4cdc56a --- /dev/null +++ b/apps/newsletter/forms.py @@ -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) diff --git a/apps/newsletter/migrations/0001_initial.py b/apps/newsletter/migrations/0001_initial.py new file mode 100644 index 0000000..de2e42e --- /dev/null +++ b/apps/newsletter/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/apps/newsletter/migrations/__init__.py b/apps/newsletter/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/newsletter/models.py b/apps/newsletter/models.py new file mode 100644 index 0000000..7d39217 --- /dev/null +++ b/apps/newsletter/models.py @@ -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 diff --git a/apps/newsletter/services.py b/apps/newsletter/services.py new file mode 100644 index 0000000..73a2b7d --- /dev/null +++ b/apps/newsletter/services.py @@ -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() diff --git a/apps/newsletter/urls.py b/apps/newsletter/urls.py new file mode 100644 index 0000000..a0e8e3f --- /dev/null +++ b/apps/newsletter/urls.py @@ -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//", ConfirmView.as_view(), name="newsletter_confirm"), +] diff --git a/apps/newsletter/views.py b/apps/newsletter/views.py new file mode 100644 index 0000000..269c7f9 --- /dev/null +++ b/apps/newsletter/views.py @@ -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") diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..bc33ab2 --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..f0235f2 --- /dev/null +++ b/config/settings/base.py @@ -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" diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..224df25 --- /dev/null +++ b/config/settings/development.py @@ -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 diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..abd04c6 --- /dev/null +++ b/config/settings/production.py @@ -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 diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..2e22f1f --- /dev/null +++ b/config/urls.py @@ -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//", TagArticlesFeed(), name="rss_feed_by_tag"), + path("sitemap.xml", sitemap), + path("admin/", RedirectView.as_view(url="/cms/", permanent=False)), + path("", include(wagtail_urls)), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..ee192cf --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e744998 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..f7ba6cb --- /dev/null +++ b/manage.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5270ff4 --- /dev/null +++ b/pyproject.toml @@ -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/*", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..f33c45a --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..7488476 --- /dev/null +++ b/requirements/base.txt @@ -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 diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..c5217f5 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,2 @@ +-r base.txt +sentry-sdk~=2.0.0 diff --git a/static/js/consent.js b/static/js/consent.js new file mode 100644 index 0000000..1a53c05 --- /dev/null +++ b/static/js/consent.js @@ -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' }; +})(); diff --git a/static/js/prism.js b/static/js/prism.js new file mode 100644 index 0000000..bcd212e --- /dev/null +++ b/static/js/prism.js @@ -0,0 +1 @@ +/* placeholder for Prism.js bundle */ diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..f9abe1f --- /dev/null +++ b/static/js/theme.js @@ -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'); + }; +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3f1963b --- /dev/null +++ b/templates/base.html @@ -0,0 +1,21 @@ +{% load static core_tags %} + + + + + + {% block title %}No Hype AI{% endblock %} + + + + + + + {% include 'components/nav.html' %} + {% include 'components/cookie_banner.html' %} +
{% block content %}{% endblock %}
+ {% include 'components/footer.html' %} + + diff --git a/templates/blog/about_page.html b/templates/blog/about_page.html new file mode 100644 index 0000000..268074a --- /dev/null +++ b/templates/blog/about_page.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% load wagtailimages_tags wagtailcore_tags %} +{% block content %} +

{{ page.title }}

+

{{ page.mission_statement }}

+{{ page.body|richtext }} +{% if page.featured_author %} +

{{ page.featured_author.name }}

+

{{ page.featured_author.bio }}

+ {% if page.featured_author.avatar %} + {% image page.featured_author.avatar fill-200x200 %} + {% endif %} +{% endif %} +{% endblock %} diff --git a/templates/blog/article_index_page.html b/templates/blog/article_index_page.html new file mode 100644 index 0000000..3a67743 --- /dev/null +++ b/templates/blog/article_index_page.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load core_tags %} +{% block title %}Articles | No Hype AI{% endblock %} +{% block content %} +

{{ page.title }}

+{% for article in articles %} + {% include 'components/article_card.html' with article=article %} +{% empty %} +

No articles found.

+{% endfor %} +{% endblock %} diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html new file mode 100644 index 0000000..ff661da --- /dev/null +++ b/templates/blog/article_page.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% load wagtailcore_tags wagtailimages_tags seo_tags %} +{% block title %}{{ page.title }} | No Hype AI{% endblock %} +{% block content %} +
+

{{ page.title }}

+

{{ page.read_time_mins }} min read

+ {% if page.hero_image %} + {% image page.hero_image fill-1200x630 %} + {% endif %} + {{ page.body }} + {% article_json_ld page %} +
+
+

Related

+ {% for article in related_articles %} + {{ article.title }} + {% endfor %} +
+{% if page.comments_enabled %} +
+
+ {% csrf_token %} + + + + + + +
+
+{% endif %} +{% endblock %} diff --git a/templates/blog/blocks/callout_block.html b/templates/blog/blocks/callout_block.html new file mode 100644 index 0000000..acc772f --- /dev/null +++ b/templates/blog/blocks/callout_block.html @@ -0,0 +1,4 @@ +
+

{{ value.heading }}

+ {{ value.body }} +
diff --git a/templates/blog/blocks/code_block.html b/templates/blog/blocks/code_block.html new file mode 100644 index 0000000..7beb73e --- /dev/null +++ b/templates/blog/blocks/code_block.html @@ -0,0 +1,5 @@ +{% load wagtailcore_tags %} +
+ {% if value.filename %}
{{ value.filename }}
{% endif %} +
{{ value.raw_code }}
+
diff --git a/templates/blog/blocks/image_block.html b/templates/blog/blocks/image_block.html new file mode 100644 index 0000000..081fedf --- /dev/null +++ b/templates/blog/blocks/image_block.html @@ -0,0 +1,5 @@ +{% load wagtailimages_tags %} +
+ {% image value.image width-1024 alt=value.alt %} + {% if value.caption %}
{{ value.caption }}
{% endif %} +
diff --git a/templates/blog/blocks/pull_quote_block.html b/templates/blog/blocks/pull_quote_block.html new file mode 100644 index 0000000..f971c12 --- /dev/null +++ b/templates/blog/blocks/pull_quote_block.html @@ -0,0 +1,4 @@ +
+

{{ value.quote }}

+ {% if value.attribution %}{{ value.attribution }}{% endif %} +
diff --git a/templates/blog/home_page.html b/templates/blog/home_page.html new file mode 100644 index 0000000..cfd06f6 --- /dev/null +++ b/templates/blog/home_page.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} +{% block title %}No Hype AI{% endblock %} +{% block content %} +
+ {% if featured_article %} +

{{ featured_article.title }}

+

{{ featured_article.author.name }}

+

{{ featured_article.read_time_mins }} min read

+ {% endif %} +
+
+ {% for article in latest_articles %} + {% include 'components/article_card.html' with article=article %} + {% endfor %} +
+
+ {% for article in more_articles %} + {% include 'components/article_card.html' with article=article %} + {% endfor %} +
+{% endblock %} diff --git a/templates/components/article_card.html b/templates/components/article_card.html new file mode 100644 index 0000000..3d0501f --- /dev/null +++ b/templates/components/article_card.html @@ -0,0 +1,8 @@ +{% load core_tags %} +
+ {{ article.title }} +

{{ article.summary|truncatewords:20 }}

+ {% for tag in article.tags.all %} + {{ tag.name }} + {% endfor %} +
diff --git a/templates/components/cookie_banner.html b/templates/components/cookie_banner.html new file mode 100644 index 0000000..5623737 --- /dev/null +++ b/templates/components/cookie_banner.html @@ -0,0 +1,12 @@ +{% if request.consent.requires_prompt %} + +{% endif %} diff --git a/templates/components/footer.html b/templates/components/footer.html new file mode 100644 index 0000000..f2ab9b5 --- /dev/null +++ b/templates/components/footer.html @@ -0,0 +1,7 @@ +{% load core_tags %} +
+ {% get_legal_pages as legal_pages %} + {% for page in legal_pages %} + {{ page.title }} + {% endfor %} +
diff --git a/templates/components/nav.html b/templates/components/nav.html new file mode 100644 index 0000000..7027f23 --- /dev/null +++ b/templates/components/nav.html @@ -0,0 +1,5 @@ + diff --git a/templates/core/robots.txt b/templates/core/robots.txt new file mode 100644 index 0000000..d8b232d --- /dev/null +++ b/templates/core/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: /cms/ +Sitemap: {{ request.scheme }}://{{ request.get_host }}/sitemap.xml diff --git a/templates/legal/legal_page.html b/templates/legal/legal_page.html new file mode 100644 index 0000000..b472096 --- /dev/null +++ b/templates/legal/legal_page.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load wagtailcore_tags %} +{% block content %} +

{{ page.title }}

+

Last updated: {{ page.last_updated|date:'F Y' }}

+{{ page.body|richtext }} +{% endblock %} -- 2.49.1 From 8970f4d8de93fe04d69bc7b187427c15f7fcc8fb Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 11:53:05 +0000 Subject: [PATCH 02/26] Add Docker-executed pytest suite with >90% coverage --- apps/authors/tests/__init__.py | 0 apps/authors/tests/test_models.py | 16 ++++++++ apps/blog/tests/__init__.py | 0 apps/blog/tests/factories.py | 64 +++++++++++++++++++++++++++++ apps/blog/tests/test_feeds.py | 8 ++++ apps/blog/tests/test_feeds_more.py | 18 ++++++++ apps/blog/tests/test_models.py | 42 +++++++++++++++++++ apps/blog/tests/test_more_models.py | 27 ++++++++++++ apps/blog/tests/test_views.py | 61 +++++++++++++++++++++++++++ apps/comments/tests/__init__.py | 0 apps/comments/tests/test_models.py | 40 ++++++++++++++++++ apps/comments/tests/test_more.py | 26 ++++++++++++ apps/comments/tests/test_views.py | 61 +++++++++++++++++++++++++++ apps/core/tests/__init__.py | 0 apps/core/tests/test_consent.py | 23 +++++++++++ apps/core/tests/test_more.py | 50 ++++++++++++++++++++++ apps/core/tests/test_smoke.py | 2 + apps/core/tests/test_tags.py | 15 +++++++ apps/legal/tests/__init__.py | 0 apps/legal/tests/test_models.py | 23 +++++++++++ apps/legal/tests/test_more.py | 8 ++++ apps/newsletter/tests/__init__.py | 0 apps/newsletter/tests/test_more.py | 14 +++++++ apps/newsletter/tests/test_views.py | 37 +++++++++++++++++ conftest.py | 52 +++++++++++++++++++++++ 25 files changed, 587 insertions(+) create mode 100644 apps/authors/tests/__init__.py create mode 100644 apps/authors/tests/test_models.py create mode 100644 apps/blog/tests/__init__.py create mode 100644 apps/blog/tests/factories.py create mode 100644 apps/blog/tests/test_feeds.py create mode 100644 apps/blog/tests/test_feeds_more.py create mode 100644 apps/blog/tests/test_models.py create mode 100644 apps/blog/tests/test_more_models.py create mode 100644 apps/blog/tests/test_views.py create mode 100644 apps/comments/tests/__init__.py create mode 100644 apps/comments/tests/test_models.py create mode 100644 apps/comments/tests/test_more.py create mode 100644 apps/comments/tests/test_views.py create mode 100644 apps/core/tests/__init__.py create mode 100644 apps/core/tests/test_consent.py create mode 100644 apps/core/tests/test_more.py create mode 100644 apps/core/tests/test_smoke.py create mode 100644 apps/core/tests/test_tags.py create mode 100644 apps/legal/tests/__init__.py create mode 100644 apps/legal/tests/test_models.py create mode 100644 apps/legal/tests/test_more.py create mode 100644 apps/newsletter/tests/__init__.py create mode 100644 apps/newsletter/tests/test_more.py create mode 100644 apps/newsletter/tests/test_views.py create mode 100644 conftest.py diff --git a/apps/authors/tests/__init__.py b/apps/authors/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authors/tests/test_models.py b/apps/authors/tests/test_models.py new file mode 100644 index 0000000..63f9fdf --- /dev/null +++ b/apps/authors/tests/test_models.py @@ -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 diff --git a/apps/blog/tests/__init__.py b/apps/blog/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/tests/factories.py b/apps/blog/tests/factories.py new file mode 100644 index 0000000..c3666d3 --- /dev/null +++ b/apps/blog/tests/factories.py @@ -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", "

Hello world

")] + 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 = "

Body

" + 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 diff --git a/apps/blog/tests/test_feeds.py b/apps/blog/tests/test_feeds.py new file mode 100644 index 0000000..0fbfd76 --- /dev/null +++ b/apps/blog/tests/test_feeds.py @@ -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") diff --git a/apps/blog/tests/test_feeds_more.py b/apps/blog/tests/test_feeds_more.py new file mode 100644 index 0000000..68da99d --- /dev/null +++ b/apps/blog/tests/test_feeds_more.py @@ -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 diff --git a/apps/blog/tests/test_models.py b/apps/blog/tests/test_models.py new file mode 100644 index 0000000..662c62e --- /dev/null +++ b/apps/blog/tests/test_models.py @@ -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", "

one two three

"), ("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") diff --git a/apps/blog/tests/test_more_models.py b/apps/blog/tests/test_more_models.py new file mode 100644 index 0000000..75e0e8e --- /dev/null +++ b/apps/blog/tests/test_more_models.py @@ -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-") diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py new file mode 100644 index 0000000..5143806 --- /dev/null +++ b/apps/blog/tests/test_views.py @@ -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", "

body

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

body

")], + ) + index.add_child(instance=main) + main.save_revision().publish() + + related = ArticlePage( + title="Related", + slug="related", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + 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 diff --git a/apps/comments/tests/__init__.py b/apps/comments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/comments/tests/test_models.py b/apps/comments/tests/test_models.py new file mode 100644 index 0000000..94e27b9 --- /dev/null +++ b/apps/comments/tests/test_models.py @@ -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", "

body

")]) + 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() diff --git a/apps/comments/tests/test_more.py b/apps/comments/tests/test_more.py new file mode 100644 index 0000000..94fd369 --- /dev/null +++ b/apps/comments/tests/test_more.py @@ -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 diff --git a/apps/comments/tests/test_views.py b/apps/comments/tests/test_views.py new file mode 100644 index 0000000..af8e546 --- /dev/null +++ b/apps/comments/tests/test_views.py @@ -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", "

body

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

body

")], + 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 diff --git a/apps/core/tests/__init__.py b/apps/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/tests/test_consent.py b/apps/core/tests/test_consent.py new file mode 100644 index 0000000..5e25dd8 --- /dev/null +++ b/apps/core/tests/test_consent.py @@ -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 diff --git a/apps/core/tests/test_more.py b/apps/core/tests/test_more.py new file mode 100644 index 0000000..b430ead --- /dev/null +++ b/apps/core/tests/test_more.py @@ -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="

x

", 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 diff --git a/apps/core/tests/test_smoke.py b/apps/core/tests/test_smoke.py new file mode 100644 index 0000000..353204d --- /dev/null +++ b/apps/core/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert 1 == 1 diff --git a/apps/core/tests/test_tags.py b/apps/core/tests/test_tags.py new file mode 100644 index 0000000..5ccabb5 --- /dev/null +++ b/apps/core/tests/test_tags.py @@ -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="

x

") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + + resp = client.get("/") + assert resp.status_code == 200 diff --git a/apps/legal/tests/__init__.py b/apps/legal/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/legal/tests/test_models.py b/apps/legal/tests/test_models.py new file mode 100644 index 0000000..ec82f02 --- /dev/null +++ b/apps/legal/tests/test_models.py @@ -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="

x

") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + resp = client.get("/legal/privacy-policy/") + assert resp.status_code == 200 diff --git a/apps/legal/tests/test_more.py b/apps/legal/tests/test_more.py new file mode 100644 index 0000000..44e68ba --- /dev/null +++ b/apps/legal/tests/test_more.py @@ -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() == [] diff --git a/apps/newsletter/tests/__init__.py b/apps/newsletter/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/newsletter/tests/test_more.py b/apps/newsletter/tests/test_more.py new file mode 100644 index 0000000..3ec368e --- /dev/null +++ b/apps/newsletter/tests/test_more.py @@ -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) diff --git a/apps/newsletter/tests/test_views.py b/apps/newsletter/tests/test_views.py new file mode 100644 index 0000000..ac6f56d --- /dev/null +++ b/apps/newsletter/tests/test_views.py @@ -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 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..0d2df7c --- /dev/null +++ b/conftest.py @@ -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", "

body words

")], + ) + article_index.add_child(instance=article) + article.save_revision().publish() + return article -- 2.49.1 From 938ff5b0d2fc6b3db990d67ffed03faaf43a4b5a Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 11:53:09 +0000 Subject: [PATCH 03/26] Add CI workflow and project runbook documentation --- .github/workflows/ci.yml | 20 ++++++++++++++ CHANGELOG.md | 12 +++++++++ README.md | 57 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9c000b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..484c7ff --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5222a2b --- /dev/null +++ b/README.md @@ -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 -- 2.49.1 From ca211c14e9c60b87715e9e286b7ad0786f597539 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:32:38 +0000 Subject: [PATCH 04/26] CI: run lint/typecheck/tests on pull requests only --- .github/workflows/ci.yml | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9c000b..71f2d49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,30 @@ name: CI on: - push: pull_request: jobs: - test: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build + - name: Ruff + run: docker compose run --rm web ruff check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build + - name: Mypy + run: docker compose run --rm web mypy apps config + + tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -14,7 +33,3 @@ jobs: 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 -- 2.49.1 From 6fc28f9d9a0897ec7e830fd0c7857d744ffda3ca Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:37:32 +0000 Subject: [PATCH 05/26] Implement newsletter double opt-in email flow and CSP nonce headers --- apps/core/middleware.py | 25 +++++++++++ apps/core/tests/test_security.py | 24 +++++++++++ apps/newsletter/services.py | 22 +++++++++- apps/newsletter/tests/test_views.py | 18 ++++++++ apps/newsletter/views.py | 43 +++++++++++++++---- config/settings/base.py | 2 + .../newsletter/email/confirmation_body.html | 13 ++++++ .../newsletter/email/confirmation_body.txt | 7 +++ .../newsletter/email/confirmation_subject.txt | 1 + 9 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 apps/core/tests/test_security.py create mode 100644 templates/newsletter/email/confirmation_body.html create mode 100644 templates/newsletter/email/confirmation_body.txt create mode 100644 templates/newsletter/email/confirmation_subject.txt diff --git a/apps/core/middleware.py b/apps/core/middleware.py index 76dba2f..a04983f 100644 --- a/apps/core/middleware.py +++ b/apps/core/middleware.py @@ -1,5 +1,7 @@ from __future__ import annotations +import secrets + from .consent import ConsentService @@ -10,3 +12,26 @@ class ConsentMiddleware: 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 diff --git a/apps/core/tests/test_security.py b/apps/core/tests/test_security.py new file mode 100644 index 0000000..edc5ab8 --- /dev/null +++ b/apps/core/tests/test_security.py @@ -0,0 +1,24 @@ +import re + +import pytest + + +@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"] + + +@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 diff --git a/apps/newsletter/services.py b/apps/newsletter/services.py index 73a2b7d..3930c56 100644 --- a/apps/newsletter/services.py +++ b/apps/newsletter/services.py @@ -1,6 +1,9 @@ from __future__ import annotations import logging +import os + +import requests logger = logging.getLogger(__name__) @@ -15,9 +18,26 @@ class ProviderSyncService: class ButtondownSyncService(ProviderSyncService): + endpoint = "https://api.buttondown.email/v1/subscribers" + def sync(self, subscription): - logger.info("Synced subscription %s", subscription.email) + 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() diff --git a/apps/newsletter/tests/test_views.py b/apps/newsletter/tests/test_views.py index ac6f56d..bc48202 100644 --- a/apps/newsletter/tests/test_views.py +++ b/apps/newsletter/tests/test_views.py @@ -12,6 +12,24 @@ def test_subscribe_ok(client): 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"}) diff --git a/apps/newsletter/views.py b/apps/newsletter/views.py index 269c7f9..54a60f0 100644 --- a/apps/newsletter/views.py +++ b/apps/newsletter/views.py @@ -1,8 +1,13 @@ 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 @@ -10,6 +15,27 @@ 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): @@ -20,9 +46,14 @@ class SubscribeView(View): if form.cleaned_data.get("honeypot"): return JsonResponse({"status": "ok"}) - email = form.cleaned_data["email"] + email = form.cleaned_data["email"].lower().strip() source = form.cleaned_data.get("source") or "unknown" - NewsletterSubscription.objects.get_or_create(email=email, defaults={"source": source}) + 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"}) @@ -42,10 +73,6 @@ class ConfirmView(View): service = get_provider_service() try: service.sync(subscription) - except ProviderSyncError: - pass + except ProviderSyncError as exc: + logger.exception("Newsletter provider sync failed: %s", exc) return redirect("/") - - -def confirmation_token(email: str) -> str: - return signing.dumps(email, salt="newsletter-confirm") diff --git a/config/settings/base.py b/config/settings/base.py index f0235f2..17bca2a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -49,6 +49,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "apps.core.middleware.SecurityHeadersMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -130,6 +131,7 @@ CACHES = { 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] diff --git a/templates/newsletter/email/confirmation_body.html b/templates/newsletter/email/confirmation_body.html new file mode 100644 index 0000000..4ccdaff --- /dev/null +++ b/templates/newsletter/email/confirmation_body.html @@ -0,0 +1,13 @@ + + + +

Hi,

+

Please confirm your newsletter subscription by clicking the button below:

+

+ + Confirm Subscription + +

+

If you did not request this, you can ignore this email.

+ + diff --git a/templates/newsletter/email/confirmation_body.txt b/templates/newsletter/email/confirmation_body.txt new file mode 100644 index 0000000..c659687 --- /dev/null +++ b/templates/newsletter/email/confirmation_body.txt @@ -0,0 +1,7 @@ +Hi, + +Please confirm your newsletter subscription by visiting this link: + +{{ confirmation_url }} + +If you did not request this, you can ignore this email. diff --git a/templates/newsletter/email/confirmation_subject.txt b/templates/newsletter/email/confirmation_subject.txt new file mode 100644 index 0000000..ba47691 --- /dev/null +++ b/templates/newsletter/email/confirmation_subject.txt @@ -0,0 +1 @@ +Confirm your No Hype AI newsletter subscription -- 2.49.1 From e279e15c9c72c2c84eb76bb31900176ab34b2cca Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:39:12 +0000 Subject: [PATCH 06/26] Add canonical and social SEO meta tags for core page templates --- apps/blog/tests/test_seo.py | 35 ++++++++++++++++++++++++++ apps/core/templatetags/seo_tags.py | 32 +++++++++++++++++------ templates/base.html | 1 + templates/blog/article_index_page.html | 10 +++++++- templates/blog/article_page.html | 15 +++++++++++ templates/blog/home_page.html | 10 ++++++++ 6 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 apps/blog/tests/test_seo.py diff --git a/apps/blog/tests/test_seo.py b/apps/blog/tests/test_seo.py new file mode 100644 index 0000000..16efae9 --- /dev/null +++ b/apps/blog/tests/test_seo.py @@ -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", "

Body

")], + ) + 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 ' 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"] - 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", @@ -30,7 +46,7 @@ def article_json_ld(context, article): "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, + "image": _article_image_url(request, article), } return mark_safe( '" diff --git a/templates/base.html b/templates/base.html index 3f1963b..1afe279 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,6 +5,7 @@ {% block title %}No Hype AI{% endblock %} + {% block head_meta %}{% endblock %} diff --git a/templates/blog/article_index_page.html b/templates/blog/article_index_page.html index 3a67743..b39d72f 100644 --- a/templates/blog/article_index_page.html +++ b/templates/blog/article_index_page.html @@ -1,6 +1,14 @@ {% extends 'base.html' %} -{% load core_tags %} +{% load core_tags seo_tags %} {% block title %}Articles | No Hype AI{% endblock %} +{% block head_meta %} + {% canonical_url page as canonical %} + + + + + +{% endblock %} {% block content %}

{{ page.title }}

{% for article in articles %} diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html index ff661da..c43f36b 100644 --- a/templates/blog/article_page.html +++ b/templates/blog/article_page.html @@ -1,6 +1,21 @@ {% extends 'base.html' %} {% load wagtailcore_tags wagtailimages_tags seo_tags %} {% block title %}{{ page.title }} | No Hype AI{% endblock %} +{% block head_meta %} + {% canonical_url page as canonical %} + {% article_og_image_url page as og_image %} + + + + + + + {% if og_image %}{% endif %} + + + + {% if og_image %}{% endif %} +{% endblock %} {% block content %}

{{ page.title }}

diff --git a/templates/blog/home_page.html b/templates/blog/home_page.html index cfd06f6..08bae09 100644 --- a/templates/blog/home_page.html +++ b/templates/blog/home_page.html @@ -1,5 +1,15 @@ {% extends 'base.html' %} +{% load seo_tags %} {% block title %}No Hype AI{% endblock %} +{% block head_meta %} + {% canonical_url page as canonical %} + + + + + + +{% endblock %} {% block content %}
{% if featured_article %} -- 2.49.1 From 82e6bc2ee046b732f197f299f2061d8be07f8ff4 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:40:20 +0000 Subject: [PATCH 07/26] Add security regression tests for headers, robots and CSRF forms --- apps/core/tests/test_security.py | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/core/tests/test_security.py b/apps/core/tests/test_security.py index edc5ab8..78836c6 100644 --- a/apps/core/tests/test_security.py +++ b/apps/core/tests/test_security.py @@ -2,6 +2,9 @@ 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): @@ -11,6 +14,8 @@ def test_security_headers_present(client, home_page): 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 @@ -22,3 +27,40 @@ def test_csp_nonce_applied_to_inline_script(client, home_page): 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", "

Body

")], + ) + 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 -- 2.49.1 From eb2cdfc5f22d0229958e0dcb8b11fabc7a90768f Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:41:26 +0000 Subject: [PATCH 08/26] Add granular consent preference flow and regression tests --- apps/core/tests/test_consent.py | 44 +++++++++++++++++++++++++ templates/components/cookie_banner.html | 15 +++++++++ 2 files changed, 59 insertions(+) diff --git a/apps/core/tests/test_consent.py b/apps/core/tests/test_consent.py index 5e25dd8..1a13643 100644 --- a/apps/core/tests/test_consent.py +++ b/apps/core/tests/test_consent.py @@ -21,3 +21,47 @@ 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() diff --git a/templates/components/cookie_banner.html b/templates/components/cookie_banner.html index 5623737..c7859f7 100644 --- a/templates/components/cookie_banner.html +++ b/templates/components/cookie_banner.html @@ -5,6 +5,21 @@ +
+ Manage preferences +
+ {% csrf_token %} + + + +
+
{% if site_settings and site_settings.privacy_policy_page %} Privacy Policy {% endif %} -- 2.49.1 From 0b5fca3be62835ab2f19e1efaea150dd2edcc5f0 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:43:40 +0000 Subject: [PATCH 09/26] CI: switch to uv with caching and cancel in-progress PR runs --- .github/workflows/ci.yml | 43 +++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71f2d49..42e0809 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,33 +3,52 @@ name: CI on: pull_request: +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - name: Build - run: docker compose build + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install dependencies + run: uv pip install --system -r requirements/base.txt - name: Ruff - run: docker compose run --rm web ruff check . + run: ruff check . typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - name: Build - run: docker compose build + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install dependencies + run: uv pip install --system -r requirements/base.txt - name: Mypy - run: docker compose run --rm web mypy apps config + run: mypy apps config tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - name: Build - run: docker compose build + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install dependencies + run: uv pip install --system -r requirements/base.txt - name: Pytest - run: docker compose run --rm web pytest + run: pytest -- 2.49.1 From 2d2edd860514efc730d86de0ca2f209918706c12 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:44:41 +0000 Subject: [PATCH 10/26] Make Docker workflows independent of local .env file --- docker-compose.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e744998..e43cd69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,18 @@ services: - .:/app ports: - "8035:8000" - env_file: - - .env environment: + SECRET_KEY: dev-secret-key + DEBUG: "1" + ALLOWED_HOSTS: localhost,127.0.0.1,web + WAGTAIL_SITE_NAME: No Hype AI DATABASE_URL: postgres://nohype:nohype@db:5432/nohype DJANGO_SETTINGS_MODULE: config.settings.development + WAGTAILADMIN_BASE_URL: http://localhost:8035 + CONSENT_POLICY_VERSION: "1" + EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend + DEFAULT_FROM_EMAIL: hello@nohypeai.com + NEWSLETTER_PROVIDER: buttondown depends_on: - db -- 2.49.1 From 630c86221f485be576990ce191b35cfc4ccb1687 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:46:43 +0000 Subject: [PATCH 11/26] CI: mirror workflow under .gitea/workflows for Gitea Actions --- .gitea/workflows/ci.yml | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..42e0809 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + pull_request: + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install dependencies + run: uv pip install --system -r requirements/base.txt + - name: Ruff + run: ruff check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install dependencies + run: uv pip install --system -r requirements/base.txt + - name: Mypy + run: mypy apps config + + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install dependencies + run: uv pip install --system -r requirements/base.txt + - name: Pytest + run: pytest -- 2.49.1 From 47e8afea18a91f8d69933270fef7327b8730fd29 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:47:54 +0000 Subject: [PATCH 12/26] CI: use Docker Compose checks for runner compatibility --- .gitea/workflows/ci.yml | 39 ++++++++++++--------------------------- .github/workflows/ci.yml | 39 ++++++++++++--------------------------- 2 files changed, 24 insertions(+), 54 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 42e0809..1eaf028 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -12,43 +12,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - name: Install dependencies - run: uv pip install --system -r requirements/base.txt + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build - name: Ruff - run: ruff check . + run: docker compose run --rm web ruff check . typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - name: Install dependencies - run: uv pip install --system -r requirements/base.txt + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build - name: Mypy - run: mypy apps config + run: docker compose run --rm web mypy apps config tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - name: Install dependencies - run: uv pip install --system -r requirements/base.txt + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build - name: Pytest - run: pytest + run: docker compose run --rm web pytest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42e0809..1eaf028 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,43 +12,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - name: Install dependencies - run: uv pip install --system -r requirements/base.txt + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build - name: Ruff - run: ruff check . + run: docker compose run --rm web ruff check . typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - name: Install dependencies - run: uv pip install --system -r requirements/base.txt + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build - name: Mypy - run: mypy apps config + run: docker compose run --rm web mypy apps config tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - name: Install dependencies - run: uv pip install --system -r requirements/base.txt + - uses: docker/setup-buildx-action@v3 + - name: Build + run: docker compose build - name: Pytest - run: pytest + run: docker compose run --rm web pytest -- 2.49.1 From ebdf20e708a8e5fb687284f2ebca906801d3f7af Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:51:24 +0000 Subject: [PATCH 13/26] CI: remove buildx action dependency for runner compatibility --- .gitea/workflows/ci.yml | 3 --- .github/workflows/ci.yml | 3 --- 2 files changed, 6 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1eaf028..b260c38 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -12,7 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - name: Build run: docker compose build - name: Ruff @@ -22,7 +21,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - name: Build run: docker compose build - name: Mypy @@ -32,7 +30,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - name: Build run: docker compose build - name: Pytest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1eaf028..b260c38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - name: Build run: docker compose build - name: Ruff @@ -22,7 +21,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - name: Build run: docker compose build - name: Mypy @@ -32,7 +30,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - name: Build run: docker compose build - name: Pytest -- 2.49.1 From 06be5d675278fae7bbd6a064788dc17df6cdac36 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 12:56:44 +0000 Subject: [PATCH 14/26] CI: isolate compose projects and remove runner container conflicts --- .gitea/workflows/ci.yml | 18 ++++++++++++------ .github/workflows/ci.yml | 18 ++++++++++++------ docker-compose.ci.yml | 7 +++++++ docker-compose.yml | 4 ---- 4 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 docker-compose.ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b260c38..7a89411 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -10,27 +10,33 @@ concurrency: jobs: lint: runs-on: ubuntu-latest + env: + COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} steps: - uses: actions/checkout@v4 - name: Build - run: docker compose build + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Ruff - run: docker compose run --rm web ruff check . + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web ruff check . typecheck: runs-on: ubuntu-latest + env: + COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} steps: - uses: actions/checkout@v4 - name: Build - run: docker compose build + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Mypy - run: docker compose run --rm web mypy apps config + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web mypy apps config tests: runs-on: ubuntu-latest + env: + COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} steps: - uses: actions/checkout@v4 - name: Build - run: docker compose build + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Pytest - run: docker compose run --rm web pytest + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web pytest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b260c38..7a89411 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,27 +10,33 @@ concurrency: jobs: lint: runs-on: ubuntu-latest + env: + COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} steps: - uses: actions/checkout@v4 - name: Build - run: docker compose build + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Ruff - run: docker compose run --rm web ruff check . + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web ruff check . typecheck: runs-on: ubuntu-latest + env: + COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} steps: - uses: actions/checkout@v4 - name: Build - run: docker compose build + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Mypy - run: docker compose run --rm web mypy apps config + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web mypy apps config tests: runs-on: ubuntu-latest + env: + COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} steps: - uses: actions/checkout@v4 - name: Build - run: docker compose build + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Pytest - run: docker compose run --rm web pytest + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web pytest diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..c89e948 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,7 @@ +services: + web: + volumes: [] + ports: [] + + db: + ports: [] diff --git a/docker-compose.yml b/docker-compose.yml index e43cd69..19ee9da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: web: build: . - container_name: nohype-web command: python manage.py runserver 0.0.0.0:8000 volumes: - .:/app @@ -24,13 +23,10 @@ services: 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 -- 2.49.1 From 11b89e9e1ce97ec273f2bfea73b00f2ff000930b Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 13:01:27 +0000 Subject: [PATCH 15/26] Stabilize PR CI and harden compose startup --- .gitea/workflows/ci.yml | 35 ++++++++++++++--------------------- .github/workflows/ci.yml | 35 ++++++++++++++--------------------- docker-compose.yml | 10 +++++++++- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7a89411..f5eef87 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -4,39 +4,32 @@ on: pull_request: concurrency: - group: ci-${{ github.event.pull_request.number || github.ref }} + group: ci-pr-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - lint: + ci: runs-on: ubuntu-latest env: - COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} + COMPOSE_PROJECT_NAME: gitea-pr-${{ github.event.pull_request.number || github.run_id }} steps: - uses: actions/checkout@v4 + + - name: Clean previous compose project + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true + - name: Build run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build + - name: Ruff - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web ruff check . + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web ruff check . - typecheck: - runs-on: ubuntu-latest - env: - COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} - steps: - - uses: actions/checkout@v4 - - name: Build - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Mypy - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web mypy apps config + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web mypy apps config - tests: - runs-on: ubuntu-latest - env: - COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} - steps: - - uses: actions/checkout@v4 - - name: Build - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Pytest run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web pytest + + - name: Final cleanup + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a89411..f303ce5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,39 +4,32 @@ on: pull_request: concurrency: - group: ci-${{ github.event.pull_request.number || github.ref }} + group: ci-pr-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - lint: + ci: runs-on: ubuntu-latest env: - COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} + COMPOSE_PROJECT_NAME: gh-pr-${{ github.event.pull_request.number || github.run_id }} steps: - uses: actions/checkout@v4 + + - name: Clean previous compose project + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true + - name: Build run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build + - name: Ruff - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web ruff check . + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web ruff check . - typecheck: - runs-on: ubuntu-latest - env: - COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} - steps: - - uses: actions/checkout@v4 - - name: Build - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Mypy - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web mypy apps config + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web mypy apps config - tests: - runs-on: ubuntu-latest - env: - COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }} - steps: - - uses: actions/checkout@v4 - - name: Build - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build - name: Pytest run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web pytest + + - name: Final cleanup + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true diff --git a/docker-compose.yml b/docker-compose.yml index 19ee9da..cd89585 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: web: build: . + working_dir: /app command: python manage.py runserver 0.0.0.0:8000 volumes: - .:/app @@ -19,7 +20,8 @@ services: DEFAULT_FROM_EMAIL: hello@nohypeai.com NEWSLETTER_PROVIDER: buttondown depends_on: - - db + db: + condition: service_healthy db: image: postgres:16-alpine @@ -29,6 +31,12 @@ services: POSTGRES_PASSWORD: nohype volumes: - nohype_pg:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nohype -d nohype"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s volumes: nohype_pg: -- 2.49.1 From 2cb1e622e21c9afb477e8298bb9051101bbee291 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 13:05:28 +0000 Subject: [PATCH 16/26] Run PR CI via docker build/run without compose networks --- .dockerignore | 15 +++++++++++++++ .gitea/workflows/ci.yml | 17 +++++++---------- .github/workflows/ci.yml | 17 +++++++---------- 3 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..941f4fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitea +.github +.venv +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.benchmarks/ +media/ +staticfiles/ diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f5eef87..8110b22 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -11,25 +11,22 @@ jobs: ci: runs-on: ubuntu-latest env: - COMPOSE_PROJECT_NAME: gitea-pr-${{ github.event.pull_request.number || github.run_id }} + CI_IMAGE: nohype-ci:${{ github.run_id }} steps: - uses: actions/checkout@v4 - - name: Clean previous compose project - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true - - name: Build - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build + run: docker build -t "$CI_IMAGE" . - name: Ruff - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web ruff check . + run: docker run --rm "$CI_IMAGE" ruff check . - name: Mypy - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web mypy apps config + run: docker run --rm "$CI_IMAGE" mypy apps config - name: Pytest - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web pytest + run: docker run --rm -e DATABASE_URL=sqlite:////tmp/ci.sqlite3 "$CI_IMAGE" pytest - - name: Final cleanup + - name: Remove CI image if: always() - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true + run: docker image rm -f "$CI_IMAGE" || true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f303ce5..8110b22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,25 +11,22 @@ jobs: ci: runs-on: ubuntu-latest env: - COMPOSE_PROJECT_NAME: gh-pr-${{ github.event.pull_request.number || github.run_id }} + CI_IMAGE: nohype-ci:${{ github.run_id }} steps: - uses: actions/checkout@v4 - - name: Clean previous compose project - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true - - name: Build - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build + run: docker build -t "$CI_IMAGE" . - name: Ruff - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web ruff check . + run: docker run --rm "$CI_IMAGE" ruff check . - name: Mypy - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm --no-deps web mypy apps config + run: docker run --rm "$CI_IMAGE" mypy apps config - name: Pytest - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web pytest + run: docker run --rm -e DATABASE_URL=sqlite:////tmp/ci.sqlite3 "$CI_IMAGE" pytest - - name: Final cleanup + - name: Remove CI image if: always() - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true + run: docker image rm -f "$CI_IMAGE" || true -- 2.49.1 From 683cba428076d7262b4b0a5f283f748bfa1908e1 Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 13:20:25 +0000 Subject: [PATCH 17/26] Complete missing UX flows and production integrity commands --- CHANGELOG.md | 3 ++ README.md | 10 ++++ apps/blog/tests/test_views.py | 33 ++++++++++++ apps/comments/management/__init__.py | 1 + apps/comments/management/commands/__init__.py | 1 + .../commands/purge_old_comment_data.py | 31 +++++++++++ apps/comments/tests/test_commands.py | 40 ++++++++++++++ apps/comments/tests/test_views.py | 1 + apps/core/management/__init__.py | 1 + apps/core/management/commands/__init__.py | 1 + .../commands/check_content_integrity.py | 42 +++++++++++++++ apps/core/tests/test_commands.py | 30 +++++++++++ static/js/newsletter.js | 54 +++++++++++++++++++ templates/base.html | 8 +++ templates/blog/article_page.html | 10 ++++ templates/components/footer.html | 1 + templates/components/nav.html | 1 + templates/components/newsletter_form.html | 11 ++++ 18 files changed, 279 insertions(+) create mode 100644 apps/comments/management/__init__.py create mode 100644 apps/comments/management/commands/__init__.py create mode 100644 apps/comments/management/commands/purge_old_comment_data.py create mode 100644 apps/comments/tests/test_commands.py create mode 100644 apps/core/management/__init__.py create mode 100644 apps/core/management/commands/__init__.py create mode 100644 apps/core/management/commands/check_content_integrity.py create mode 100644 apps/core/tests/test_commands.py create mode 100644 static/js/newsletter.js create mode 100644 templates/components/newsletter_form.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 484c7ff..ad6ff29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,6 @@ - Added newsletter subscription + confirmation flow with provider sync abstraction. - Added templates/static assets baseline for homepage, article index/read, legal, about. - Added pytest suite with >90% coverage enforcement and passing Docker CI checks. +- Added PR-only containerized CI path (`docker build` + `docker run`) to avoid compose-network exhaustion on shared runners. +- Added newsletter signup forms in nav/footer/article, client-side progressive submit UX, and article social share controls. +- Added content integrity management command and comment data-retention purge command with automated tests. diff --git a/README.md b/README.md index 5222a2b..171176b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ git pull origin main pip install -r requirements/production.txt python manage.py migrate --run-syncdb python manage.py collectstatic --noinput +python manage.py tailwind build +python manage.py check_content_integrity sudo systemctl reload gunicorn ``` @@ -55,3 +57,11 @@ sudo systemctl reload gunicorn - PostgreSQL dump daily: `pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz` - `MEDIA_ROOT` rsynced offsite daily +- Restore DB: `gunzip -c backup-YYYYMMDD.sql.gz | psql "$DATABASE_URL"` +- Restore media: `rsync -avz :/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. diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py index 5143806..138531f 100644 --- a/apps/blog/tests/test_views.py +++ b/apps/blog/tests/test_views.py @@ -59,3 +59,36 @@ def test_article_page_related_context(client, home_page): resp = client.get("/articles/main/") assert resp.status_code == 200 assert "related_articles" in resp.context + + +@pytest.mark.django_db +def test_newsletter_forms_render_in_nav_and_footer(client, home_page): + resp = client.get("/") + html = resp.content.decode() + assert resp.status_code == 200 + assert 'name="source" value="nav"' in html + assert 'name="source" value="footer"' in html + + +@pytest.mark.django_db +def test_article_page_renders_share_links_and_newsletter_form(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + 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 diff --git a/apps/comments/management/__init__.py b/apps/comments/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/comments/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/comments/management/commands/__init__.py b/apps/comments/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/comments/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/comments/management/commands/purge_old_comment_data.py b/apps/comments/management/commands/purge_old_comment_data.py new file mode 100644 index 0000000..73b17bf --- /dev/null +++ b/apps/comments/management/commands/purge_old_comment_data.py @@ -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).")) diff --git a/apps/comments/tests/test_commands.py b/apps/comments/tests/test_commands.py new file mode 100644 index 0000000..7e87e44 --- /dev/null +++ b/apps/comments/tests/test_commands.py @@ -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", "

body

")], + ) + 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 diff --git a/apps/comments/tests/test_views.py b/apps/comments/tests/test_views.py index af8e546..74a20d8 100644 --- a/apps/comments/tests/test_views.py +++ b/apps/comments/tests/test_views.py @@ -27,6 +27,7 @@ def test_comment_post_flow(client, home_page): }, ) assert resp.status_code == 302 + assert resp["Location"].endswith("?commented=1") assert Comment.objects.count() == 1 diff --git a/apps/core/management/__init__.py b/apps/core/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/management/commands/__init__.py b/apps/core/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/management/commands/check_content_integrity.py b/apps/core/management/commands/check_content_integrity.py new file mode 100644 index 0000000..e4088d6 --- /dev/null +++ b/apps/core/management/commands/check_content_integrity.py @@ -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.")) diff --git a/apps/core/tests/test_commands.py b/apps/core/tests/test_commands.py new file mode 100644 index 0000000..b7fedb8 --- /dev/null +++ b/apps/core/tests/test_commands.py @@ -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", "

body

")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + with pytest.raises(CommandError, match="empty summary"): + call_command("check_content_integrity") diff --git a/static/js/newsletter.js b/static/js/newsletter.js new file mode 100644 index 0000000..6562ae2 --- /dev/null +++ b/static/js/newsletter.js @@ -0,0 +1,54 @@ +(() => { + const setMessage = (form, text) => { + const target = form.querySelector("[data-newsletter-message]"); + if (target) { + target.textContent = text; + } + }; + + const bindNewsletterForms = () => { + const forms = document.querySelectorAll("form[data-newsletter-form]"); + forms.forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(form); + try { + const response = await fetch(form.action, { + method: "POST", + body: formData, + }); + if (!response.ok) { + setMessage(form, "Please enter a valid email."); + return; + } + setMessage(form, "Check your email to confirm your subscription."); + form.reset(); + } catch (error) { + setMessage(form, "Subscription failed. Please try again."); + } + }); + }); + }; + + const bindCopyLink = () => { + const button = document.querySelector("[data-copy-link]"); + if (!button) { + return; + } + button.addEventListener("click", async () => { + const url = button.getAttribute("data-copy-url"); + if (!url) { + return; + } + try { + await navigator.clipboard.writeText(url); + button.textContent = "Copied"; + } catch (error) { + button.textContent = "Copy failed"; + } + }); + }; + + bindNewsletterForms(); + bindCopyLink(); +})(); diff --git a/templates/base.html b/templates/base.html index 1afe279..aef3585 100644 --- a/templates/base.html +++ b/templates/base.html @@ -12,10 +12,18 @@ + {% include 'components/nav.html' %} {% include 'components/cookie_banner.html' %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %}
{% block content %}{% endblock %}
{% include 'components/footer.html' %} diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html index c43f36b..97a105c 100644 --- a/templates/blog/article_page.html +++ b/templates/blog/article_page.html @@ -26,12 +26,22 @@ {{ page.body }} {% article_json_ld page %}
+
+

Share

+ Share on X + Share on LinkedIn + +

Related

{% for article in related_articles %} {{ article.title }} {% endfor %}
+ {% if page.comments_enabled %}
diff --git a/templates/components/footer.html b/templates/components/footer.html index f2ab9b5..4d77528 100644 --- a/templates/components/footer.html +++ b/templates/components/footer.html @@ -1,6 +1,7 @@ {% load core_tags %}