From b5f0f40c4c1c5cc06761a3104c11e62f27e5b18e Mon Sep 17 00:00:00 2001 From: Codex_B Date: Sat, 28 Feb 2026 11:52:59 +0000 Subject: [PATCH] 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 %}