Scaffold containerized Django/Wagtail app with core features
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
node_modules/
|
||||||
|
staticfiles/
|
||||||
|
media/
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
curl \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements /app/requirements
|
||||||
|
RUN pip install --upgrade pip && pip install -r requirements/base.txt
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
0
apps/__init__.py
Normal file
0
apps/__init__.py
Normal file
0
apps/authors/__init__.py
Normal file
0
apps/authors/__init__.py
Normal file
6
apps/authors/apps.py
Normal file
6
apps/authors/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.authors"
|
||||||
34
apps/authors/migrations/0001_initial.py
Normal file
34
apps/authors/migrations/0001_initial.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wagtailimages', '0027_image_description'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Author',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('bio', models.TextField(blank=True)),
|
||||||
|
('twitter_url', models.URLField(blank=True)),
|
||||||
|
('github_url', models.URLField(blank=True)),
|
||||||
|
('avatar', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||||
|
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Author',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/authors/migrations/__init__.py
Normal file
0
apps/authors/migrations/__init__.py
Normal file
44
apps/authors/models.py
Normal file
44
apps/authors/models.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import SET_NULL
|
||||||
|
from wagtail.admin.panels import FieldPanel
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Author(models.Model):
|
||||||
|
user = models.OneToOneField(User, null=True, blank=True, on_delete=SET_NULL)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
bio = models.TextField(blank=True)
|
||||||
|
avatar = models.ForeignKey(
|
||||||
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
twitter_url = models.URLField(blank=True)
|
||||||
|
github_url = models.URLField(blank=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("user"),
|
||||||
|
FieldPanel("name"),
|
||||||
|
FieldPanel("slug"),
|
||||||
|
FieldPanel("bio"),
|
||||||
|
FieldPanel("avatar"),
|
||||||
|
FieldPanel("twitter_url"),
|
||||||
|
FieldPanel("github_url"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Author"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_social_links(self) -> dict[str, str]:
|
||||||
|
links: dict[str, str] = {}
|
||||||
|
if self.twitter_url:
|
||||||
|
links["twitter"] = self.twitter_url
|
||||||
|
if self.github_url:
|
||||||
|
links["github"] = self.github_url
|
||||||
|
return links
|
||||||
15
apps/authors/wagtail_hooks.py
Normal file
15
apps/authors/wagtail_hooks.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from wagtail.snippets.models import register_snippet
|
||||||
|
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||||
|
|
||||||
|
from apps.authors.models import Author
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorViewSet(SnippetViewSet):
|
||||||
|
model = Author
|
||||||
|
icon = "user"
|
||||||
|
list_display = ["name", "slug"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
add_to_admin_menu = True
|
||||||
|
|
||||||
|
|
||||||
|
register_snippet(AuthorViewSet)
|
||||||
0
apps/blog/__init__.py
Normal file
0
apps/blog/__init__.py
Normal file
6
apps/blog/apps.py
Normal file
6
apps/blog/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BlogConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.blog"
|
||||||
81
apps/blog/blocks.py
Normal file
81
apps/blog/blocks.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from wagtail import blocks
|
||||||
|
from wagtail.embeds.blocks import EmbedBlock
|
||||||
|
from wagtail.images.blocks import ImageChooserBlock
|
||||||
|
|
||||||
|
|
||||||
|
class CodeBlock(blocks.StructBlock):
|
||||||
|
LANGUAGE_CHOICES = [
|
||||||
|
("python", "Python"),
|
||||||
|
("javascript", "JavaScript"),
|
||||||
|
("typescript", "TypeScript"),
|
||||||
|
("tsx", "TSX"),
|
||||||
|
("bash", "Bash"),
|
||||||
|
("json", "JSON"),
|
||||||
|
("css", "CSS"),
|
||||||
|
("html", "HTML"),
|
||||||
|
("plaintext", "Plain Text"),
|
||||||
|
]
|
||||||
|
|
||||||
|
language = blocks.ChoiceBlock(choices=LANGUAGE_CHOICES, default="python")
|
||||||
|
filename = blocks.CharBlock(required=False)
|
||||||
|
raw_code = blocks.TextBlock()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "code"
|
||||||
|
template = "blog/blocks/code_block.html"
|
||||||
|
|
||||||
|
def get_language_label(self, value):
|
||||||
|
choices = dict(self.LANGUAGE_CHOICES)
|
||||||
|
lang = str(value.get("language", "")) if isinstance(value, dict) else ""
|
||||||
|
return choices.get(lang, "Plain Text")
|
||||||
|
|
||||||
|
|
||||||
|
class CalloutBlock(blocks.StructBlock):
|
||||||
|
ICON_CHOICES = [
|
||||||
|
("info", "Info"),
|
||||||
|
("warning", "Warning"),
|
||||||
|
("trophy", "Trophy / Conclusion"),
|
||||||
|
("tip", "Tip"),
|
||||||
|
]
|
||||||
|
|
||||||
|
icon = blocks.ChoiceBlock(choices=ICON_CHOICES, default="info")
|
||||||
|
heading = blocks.CharBlock()
|
||||||
|
body = blocks.RichTextBlock(features=["bold", "italic", "link"])
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "pick"
|
||||||
|
template = "blog/blocks/callout_block.html"
|
||||||
|
|
||||||
|
|
||||||
|
class PullQuoteBlock(blocks.StructBlock):
|
||||||
|
quote = blocks.TextBlock()
|
||||||
|
attribution = blocks.CharBlock(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "openquote"
|
||||||
|
template = "blog/blocks/pull_quote_block.html"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageBlock(blocks.StructBlock):
|
||||||
|
image = ImageChooserBlock()
|
||||||
|
caption = blocks.CharBlock(required=False)
|
||||||
|
alt = blocks.CharBlock(required=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "image"
|
||||||
|
template = "blog/blocks/image_block.html"
|
||||||
|
|
||||||
|
|
||||||
|
ARTICLE_BODY_BLOCKS = [
|
||||||
|
(
|
||||||
|
"rich_text",
|
||||||
|
blocks.RichTextBlock(
|
||||||
|
features=["h2", "h3", "h4", "bold", "italic", "link", "ol", "ul", "hr", "blockquote", "code"]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("code", CodeBlock()),
|
||||||
|
("callout", CalloutBlock()),
|
||||||
|
("image", ImageBlock()),
|
||||||
|
("embed", EmbedBlock()),
|
||||||
|
("pull_quote", PullQuoteBlock()),
|
||||||
|
]
|
||||||
41
apps/blog/feeds.py
Normal file
41
apps/blog/feeds.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.syndication.views import Feed
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from taggit.models import Tag
|
||||||
|
|
||||||
|
from apps.blog.models import ArticlePage
|
||||||
|
|
||||||
|
|
||||||
|
class AllArticlesFeed(Feed):
|
||||||
|
title = "No Hype AI"
|
||||||
|
link = "/articles/"
|
||||||
|
description = "Honest AI coding tool reviews for developers."
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
return ArticlePage.objects.live().order_by("-first_published_at")[:20]
|
||||||
|
|
||||||
|
def item_title(self, item: ArticlePage):
|
||||||
|
return item.title
|
||||||
|
|
||||||
|
def item_description(self, item: ArticlePage):
|
||||||
|
return item.summary
|
||||||
|
|
||||||
|
def item_pubdate(self, item: ArticlePage):
|
||||||
|
return item.first_published_at
|
||||||
|
|
||||||
|
def item_author_name(self, item: ArticlePage):
|
||||||
|
return item.author.name
|
||||||
|
|
||||||
|
def item_link(self, item: ArticlePage):
|
||||||
|
return f"{settings.WAGTAILADMIN_BASE_URL}{item.url}"
|
||||||
|
|
||||||
|
|
||||||
|
class TagArticlesFeed(AllArticlesFeed):
|
||||||
|
def get_object(self, request, tag_slug: str):
|
||||||
|
return get_object_or_404(Tag, slug=tag_slug)
|
||||||
|
|
||||||
|
def title(self, obj):
|
||||||
|
return f"No Hype AI — {obj.name}"
|
||||||
|
|
||||||
|
def items(self, obj):
|
||||||
|
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20]
|
||||||
98
apps/blog/migrations/0001_initial.py
Normal file
98
apps/blog/migrations/0001_initial.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import modelcluster.contrib.taggit
|
||||||
|
import modelcluster.fields
|
||||||
|
import wagtail.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authors', '0001_initial'),
|
||||||
|
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
||||||
|
('wagtailcore', '0094_alter_page_locale'),
|
||||||
|
('wagtailimages', '0027_image_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ArticleIndexPage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AboutPage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
('mission_statement', models.TextField()),
|
||||||
|
('body', wagtail.fields.RichTextField(blank=True)),
|
||||||
|
('featured_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='authors.author')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ArticlePage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
('canonical_url', models.URLField(blank=True, help_text="Leave blank to use the page's URL.", max_length=255, verbose_name='Canonical URL')),
|
||||||
|
('summary', models.TextField()),
|
||||||
|
('body', wagtail.fields.StreamField([('rich_text', 0), ('code', 4), ('callout', 8), ('image', 11), ('embed', 12), ('pull_quote', 13)], block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {'features': ['h2', 'h3', 'h4', 'bold', 'italic', 'link', 'ol', 'ul', 'hr', 'blockquote', 'code']}), 1: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('python', 'Python'), ('javascript', 'JavaScript'), ('typescript', 'TypeScript'), ('tsx', 'TSX'), ('bash', 'Bash'), ('json', 'JSON'), ('css', 'CSS'), ('html', 'HTML'), ('plaintext', 'Plain Text')]}), 2: ('wagtail.blocks.CharBlock', (), {'required': False}), 3: ('wagtail.blocks.TextBlock', (), {}), 4: ('wagtail.blocks.StructBlock', [[('language', 1), ('filename', 2), ('raw_code', 3)]], {}), 5: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('info', 'Info'), ('warning', 'Warning'), ('trophy', 'Trophy / Conclusion'), ('tip', 'Tip')]}), 6: ('wagtail.blocks.CharBlock', (), {}), 7: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link']}), 8: ('wagtail.blocks.StructBlock', [[('icon', 5), ('heading', 6), ('body', 7)]], {}), 9: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 10: ('wagtail.blocks.CharBlock', (), {'required': True}), 11: ('wagtail.blocks.StructBlock', [[('image', 9), ('caption', 2), ('alt', 10)]], {}), 12: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 13: ('wagtail.blocks.StructBlock', [[('quote', 3), ('attribution', 2)]], {})})),
|
||||||
|
('read_time_mins', models.PositiveIntegerField(default=1, editable=False)),
|
||||||
|
('comments_enabled', models.BooleanField(default=True)),
|
||||||
|
('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authors.author')),
|
||||||
|
('hero_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||||
|
('og_image', models.ForeignKey(blank=True, help_text='Shown when linking to this page on social media. If blank, may show an image from the page, or the default from Settings > SEO.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Preview image')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page', models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ArticleTag',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='blog.articlepage')),
|
||||||
|
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='articlepage',
|
||||||
|
name='tags',
|
||||||
|
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='blog.ArticleTag', to='taggit.Tag', verbose_name='Tags'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HomePage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
('featured_article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='blog.articlepage')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TagMetadata',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('colour', models.CharField(choices=[('cyan', 'Cyan'), ('pink', 'Pink'), ('neutral', 'Neutral')], default='neutral', max_length=20)),
|
||||||
|
('tag', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata', to='taggit.tag')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/blog/migrations/__init__.py
Normal file
0
apps/blog/migrations/__init__.py
Normal file
191
apps/blog/models.py
Normal file
191
apps/blog/models.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from math import ceil
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import CASCADE, PROTECT, SET_NULL
|
||||||
|
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||||
|
from modelcluster.fields import ParentalKey
|
||||||
|
from taggit.models import TaggedItemBase
|
||||||
|
from wagtail.admin.panels import FieldPanel, PageChooserPanel
|
||||||
|
from wagtail.fields import RichTextField, StreamField
|
||||||
|
from wagtail.models import Page
|
||||||
|
from wagtailseo.models import SeoMixin
|
||||||
|
|
||||||
|
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
|
||||||
|
|
||||||
|
|
||||||
|
class HomePage(Page):
|
||||||
|
featured_article = models.ForeignKey(
|
||||||
|
"blog.ArticlePage", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
|
||||||
|
subpage_types = ["blog.ArticleIndexPage", "legal.LegalIndexPage", "blog.AboutPage"]
|
||||||
|
|
||||||
|
content_panels = Page.content_panels + [
|
||||||
|
PageChooserPanel("featured_article", "blog.ArticlePage"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
ctx = super().get_context(request, *args, **kwargs)
|
||||||
|
articles = (
|
||||||
|
ArticlePage.objects.live()
|
||||||
|
.public()
|
||||||
|
.select_related("author")
|
||||||
|
.prefetch_related("tags__metadata")
|
||||||
|
.order_by("-first_published_at")
|
||||||
|
)
|
||||||
|
ctx["featured_article"] = self.featured_article
|
||||||
|
ctx["latest_articles"] = articles[:5]
|
||||||
|
ctx["more_articles"] = articles[:3]
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleIndexPage(Page):
|
||||||
|
parent_page_types = ["blog.HomePage"]
|
||||||
|
subpage_types = ["blog.ArticlePage"]
|
||||||
|
ARTICLES_PER_PAGE = 12
|
||||||
|
|
||||||
|
def get_articles(self):
|
||||||
|
return (
|
||||||
|
ArticlePage.objects.child_of(self)
|
||||||
|
.live()
|
||||||
|
.select_related("author")
|
||||||
|
.prefetch_related("tags__metadata")
|
||||||
|
.order_by("-first_published_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
ctx = super().get_context(request, *args, **kwargs)
|
||||||
|
tag_slug = request.GET.get("tag")
|
||||||
|
articles = self.get_articles()
|
||||||
|
if tag_slug:
|
||||||
|
articles = articles.filter(tags__slug=tag_slug)
|
||||||
|
paginator = Paginator(articles, self.ARTICLES_PER_PAGE)
|
||||||
|
page_num = request.GET.get("page")
|
||||||
|
try:
|
||||||
|
page_obj = paginator.page(page_num)
|
||||||
|
except PageNotAnInteger:
|
||||||
|
page_obj = paginator.page(1)
|
||||||
|
except EmptyPage:
|
||||||
|
page_obj = paginator.page(paginator.num_pages)
|
||||||
|
ctx["articles"] = page_obj
|
||||||
|
ctx["paginator"] = paginator
|
||||||
|
ctx["active_tag"] = tag_slug
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleTag(TaggedItemBase):
|
||||||
|
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
class TagMetadata(models.Model):
|
||||||
|
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
||||||
|
|
||||||
|
tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata")
|
||||||
|
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_fallback_css(cls) -> dict[str, str]:
|
||||||
|
return {"bg": "bg-zinc-100", "text": "text-zinc-800"}
|
||||||
|
|
||||||
|
def get_css_classes(self) -> dict[str, str]:
|
||||||
|
mapping = {
|
||||||
|
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
|
||||||
|
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
|
||||||
|
"neutral": self.get_fallback_css(),
|
||||||
|
}
|
||||||
|
return mapping.get(self.colour, self.get_fallback_css())
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlePage(SeoMixin, Page):
|
||||||
|
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
||||||
|
hero_image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
summary = models.TextField()
|
||||||
|
body = StreamField(ARTICLE_BODY_BLOCKS, use_json_field=True)
|
||||||
|
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
|
||||||
|
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
|
||||||
|
comments_enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
parent_page_types = ["blog.ArticleIndexPage"]
|
||||||
|
subpage_types: list[str] = []
|
||||||
|
|
||||||
|
content_panels = Page.content_panels + [
|
||||||
|
FieldPanel("author"),
|
||||||
|
FieldPanel("hero_image"),
|
||||||
|
FieldPanel("summary"),
|
||||||
|
FieldPanel("body"),
|
||||||
|
FieldPanel("tags"),
|
||||||
|
FieldPanel("comments_enabled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
promote_panels = Page.promote_panels + SeoMixin.seo_panels
|
||||||
|
|
||||||
|
search_fields = Page.search_fields
|
||||||
|
|
||||||
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self.read_time_mins = self._compute_read_time()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def _compute_read_time(self) -> int:
|
||||||
|
words = []
|
||||||
|
for block in self.body:
|
||||||
|
if block.block_type == "code":
|
||||||
|
continue
|
||||||
|
value = block.value
|
||||||
|
text = value.source if hasattr(value, "source") else str(value)
|
||||||
|
words.extend(re.findall(r"\w+", text))
|
||||||
|
return max(1, ceil(len(words) / 200))
|
||||||
|
|
||||||
|
def get_tags_with_metadata(self):
|
||||||
|
tags = self.tags.all()
|
||||||
|
return [(tag, getattr(tag, "metadata", None)) for tag in tags]
|
||||||
|
|
||||||
|
def get_related_articles(self, count: int = 3):
|
||||||
|
tag_ids = self.tags.values_list("id", flat=True)
|
||||||
|
related = list(
|
||||||
|
ArticlePage.objects.live()
|
||||||
|
.filter(tags__in=tag_ids)
|
||||||
|
.exclude(pk=self.pk)
|
||||||
|
.distinct()
|
||||||
|
.order_by("-first_published_at")[:count]
|
||||||
|
)
|
||||||
|
if len(related) < count:
|
||||||
|
exclude_ids = [a.pk for a in related] + [self.pk]
|
||||||
|
fallback = list(
|
||||||
|
ArticlePage.objects.live()
|
||||||
|
.exclude(pk__in=exclude_ids)
|
||||||
|
.order_by("-first_published_at")[: count - len(related)]
|
||||||
|
)
|
||||||
|
return related + fallback
|
||||||
|
return related
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
ctx = super().get_context(request, *args, **kwargs)
|
||||||
|
ctx["related_articles"] = self.get_related_articles()
|
||||||
|
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).select_related(
|
||||||
|
"parent"
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class AboutPage(Page):
|
||||||
|
mission_statement = models.TextField()
|
||||||
|
body = RichTextField(blank=True)
|
||||||
|
featured_author = models.ForeignKey(
|
||||||
|
"authors.Author", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
|
||||||
|
parent_page_types = ["blog.HomePage"]
|
||||||
|
subpage_types: list[str] = []
|
||||||
|
|
||||||
|
content_panels = Page.content_panels + [
|
||||||
|
FieldPanel("mission_statement"),
|
||||||
|
FieldPanel("body"),
|
||||||
|
FieldPanel("featured_author"),
|
||||||
|
]
|
||||||
13
apps/blog/wagtail_hooks.py
Normal file
13
apps/blog/wagtail_hooks.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from wagtail.snippets.models import register_snippet
|
||||||
|
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||||
|
|
||||||
|
from apps.blog.models import TagMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class TagMetadataViewSet(SnippetViewSet):
|
||||||
|
model = TagMetadata
|
||||||
|
icon = "tag"
|
||||||
|
list_display = ["tag", "colour"]
|
||||||
|
|
||||||
|
|
||||||
|
register_snippet(TagMetadataViewSet)
|
||||||
0
apps/comments/__init__.py
Normal file
0
apps/comments/__init__.py
Normal file
6
apps/comments/apps.py
Normal file
6
apps/comments/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CommentsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.comments"
|
||||||
19
apps/comments/forms.py
Normal file
19
apps/comments/forms.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from apps.comments.models import Comment
|
||||||
|
|
||||||
|
|
||||||
|
class CommentForm(forms.ModelForm):
|
||||||
|
honeypot = forms.CharField(required=False)
|
||||||
|
article_id = forms.IntegerField(widget=forms.HiddenInput)
|
||||||
|
parent_id = forms.IntegerField(required=False, widget=forms.HiddenInput)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Comment
|
||||||
|
fields = ["author_name", "author_email", "body"]
|
||||||
|
|
||||||
|
def clean_body(self):
|
||||||
|
body = self.cleaned_data["body"]
|
||||||
|
if not body.strip():
|
||||||
|
raise forms.ValidationError("Comment body is required.")
|
||||||
|
return body
|
||||||
30
apps/comments/migrations/0001_initial.py
Normal file
30
apps/comments/migrations/0001_initial.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Comment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('author_name', models.CharField(max_length=100)),
|
||||||
|
('author_email', models.EmailField(max_length=254)),
|
||||||
|
('body', models.TextField(max_length=2000)),
|
||||||
|
('is_approved', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.articlepage')),
|
||||||
|
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='comments.comment')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/comments/migrations/__init__.py
Normal file
0
apps/comments/migrations/__init__.py
Normal file
25
apps/comments/models.py
Normal file
25
apps/comments/models.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(models.Model):
|
||||||
|
article = models.ForeignKey("blog.ArticlePage", on_delete=models.CASCADE, related_name="comments")
|
||||||
|
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies")
|
||||||
|
author_name = models.CharField(max_length=100)
|
||||||
|
author_email = models.EmailField()
|
||||||
|
body = models.TextField(max_length=2000)
|
||||||
|
is_approved = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
|
||||||
|
def clean(self) -> None:
|
||||||
|
if self.parent and self.parent.parent_id is not None:
|
||||||
|
raise ValidationError("Replies cannot be nested beyond one level.")
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return f"{self.article.url}#comment-{self.pk}"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Comment by {self.author_name}"
|
||||||
7
apps/comments/urls.py
Normal file
7
apps/comments/urls.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.comments.views import CommentCreateView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("post/", CommentCreateView.as_view(), name="comment_post"),
|
||||||
|
]
|
||||||
42
apps/comments/views.py
Normal file
42
apps/comments/views.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from apps.blog.models import ArticlePage
|
||||||
|
from apps.comments.forms import CommentForm
|
||||||
|
from apps.comments.models import Comment
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreateView(View):
|
||||||
|
def post(self, request):
|
||||||
|
ip = (request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR", "")).split(",")[0].strip()
|
||||||
|
key = f"comment-rate:{ip}"
|
||||||
|
count = cache.get(key, 0)
|
||||||
|
if count >= 3:
|
||||||
|
return HttpResponse(status=429)
|
||||||
|
cache.set(key, count + 1, timeout=60)
|
||||||
|
|
||||||
|
form = CommentForm(request.POST)
|
||||||
|
article = get_object_or_404(ArticlePage, pk=request.POST.get("article_id"))
|
||||||
|
if not article.comments_enabled:
|
||||||
|
return HttpResponse(status=404)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
if form.cleaned_data.get("honeypot"):
|
||||||
|
return redirect(f"{article.url}?commented=1")
|
||||||
|
comment = form.save(commit=False)
|
||||||
|
comment.article = article
|
||||||
|
parent_id = form.cleaned_data.get("parent_id")
|
||||||
|
if parent_id:
|
||||||
|
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
|
||||||
|
comment.ip_address = ip or None
|
||||||
|
comment.save()
|
||||||
|
messages.success(request, "Your comment is awaiting moderation")
|
||||||
|
return redirect(f"{article.url}?commented=1")
|
||||||
|
|
||||||
|
messages.error(request, "Please correct the form errors")
|
||||||
|
return redirect(article.url)
|
||||||
20
apps/comments/wagtail_hooks.py
Normal file
20
apps/comments/wagtail_hooks.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from wagtail.admin.ui.tables import BooleanColumn
|
||||||
|
from wagtail.snippets.models import register_snippet
|
||||||
|
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||||
|
|
||||||
|
from apps.comments.models import Comment
|
||||||
|
|
||||||
|
|
||||||
|
class CommentViewSet(SnippetViewSet):
|
||||||
|
model = Comment
|
||||||
|
icon = "comment"
|
||||||
|
list_display = ["author_name", "article", BooleanColumn("is_approved"), "created_at"]
|
||||||
|
list_filter = ["is_approved"]
|
||||||
|
search_fields = ["author_name", "body"]
|
||||||
|
add_to_admin_menu = True
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related("article", "parent")
|
||||||
|
|
||||||
|
|
||||||
|
register_snippet(CommentViewSet)
|
||||||
0
apps/core/__init__.py
Normal file
0
apps/core/__init__.py
Normal file
6
apps/core/apps.py
Normal file
6
apps/core/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.core"
|
||||||
59
apps/core/consent.py
Normal file
59
apps/core/consent.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import parse_qs, urlencode
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
CONSENT_COOKIE_NAME = "nhAiConsent"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConsentState:
|
||||||
|
analytics: bool = False
|
||||||
|
advertising: bool = False
|
||||||
|
policy_version: int = 0
|
||||||
|
timestamp: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_prompt(self) -> bool:
|
||||||
|
return self.policy_version != settings.CONSENT_POLICY_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentService:
|
||||||
|
@staticmethod
|
||||||
|
def get_consent(request) -> ConsentState:
|
||||||
|
raw = request.COOKIES.get(CONSENT_COOKIE_NAME, "")
|
||||||
|
if not raw:
|
||||||
|
return ConsentState()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = {k: v[0] for k, v in parse_qs(raw).items()}
|
||||||
|
return ConsentState(
|
||||||
|
analytics=data.get("a", "0") == "1",
|
||||||
|
advertising=data.get("d", "0") == "1",
|
||||||
|
policy_version=int(data.get("v", "0")),
|
||||||
|
timestamp=int(data.get("ts", "0")),
|
||||||
|
)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return ConsentState()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_consent(response, *, analytics: bool, advertising: bool) -> None:
|
||||||
|
payload = urlencode(
|
||||||
|
{
|
||||||
|
"a": int(analytics),
|
||||||
|
"d": int(advertising),
|
||||||
|
"v": settings.CONSENT_POLICY_VERSION,
|
||||||
|
"ts": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
CONSENT_COOKIE_NAME,
|
||||||
|
payload,
|
||||||
|
max_age=60 * 60 * 24 * 365,
|
||||||
|
httponly=False,
|
||||||
|
samesite="Lax",
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
)
|
||||||
9
apps/core/context_processors.py
Normal file
9
apps/core/context_processors.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from wagtail.models import Site
|
||||||
|
|
||||||
|
from apps.core.models import SiteSettings
|
||||||
|
|
||||||
|
|
||||||
|
def site_settings(request):
|
||||||
|
site = Site.find_for_request(request)
|
||||||
|
settings_obj = SiteSettings.for_site(site) if site else None
|
||||||
|
return {"site_settings": settings_obj}
|
||||||
12
apps/core/middleware.py
Normal file
12
apps/core/middleware.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .consent import ConsentService
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
request.consent = ConsentService.get_consent(request)
|
||||||
|
return self.get_response(request)
|
||||||
29
apps/core/migrations/0001_initial.py
Normal file
29
apps/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wagtailcore', '0094_alter_page_locale'),
|
||||||
|
('wagtailimages', '0027_image_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SiteSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('default_og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||||
|
('privacy_policy_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
|
||||||
|
('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.site')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/core/migrations/__init__.py
Normal file
0
apps/core/migrations/__init__.py
Normal file
21
apps/core/models.py
Normal file
21
apps/core/models.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.db.models import SET_NULL
|
||||||
|
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
|
||||||
|
|
||||||
|
|
||||||
|
@register_setting
|
||||||
|
class SiteSettings(BaseSiteSetting):
|
||||||
|
default_og_image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
privacy_policy_page = models.ForeignKey(
|
||||||
|
"wagtailcore.Page",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
0
apps/core/templatetags/__init__.py
Normal file
0
apps/core/templatetags/__init__.py
Normal file
30
apps/core/templatetags/core_tags.py
Normal file
30
apps/core/templatetags/core_tags.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from wagtail.models import Site
|
||||||
|
|
||||||
|
from apps.blog.models import TagMetadata
|
||||||
|
from apps.legal.models import LegalPage
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def get_legal_pages(context):
|
||||||
|
request = context.get("request")
|
||||||
|
site = Site.find_for_request(request) if request else None
|
||||||
|
pages = LegalPage.objects.live().filter(show_in_footer=True)
|
||||||
|
if site:
|
||||||
|
pages = pages.in_site(site)
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
@register.filter
|
||||||
|
def get_tag_css(tag):
|
||||||
|
meta = getattr(tag, "metadata", None)
|
||||||
|
if meta is None:
|
||||||
|
meta = TagMetadata.objects.filter(tag=tag).first()
|
||||||
|
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
|
||||||
|
return mark_safe(f"{classes['bg']} {classes['text']}")
|
||||||
37
apps/core/templatetags/seo_tags.py
Normal file
37
apps/core/templatetags/seo_tags.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from wagtail.images.models import Image
|
||||||
|
|
||||||
|
from apps.core.models import SiteSettings
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def article_json_ld(context, article):
|
||||||
|
request = context["request"]
|
||||||
|
site_settings = SiteSettings.for_request(request)
|
||||||
|
image = article.hero_image or site_settings.default_og_image
|
||||||
|
image_url = ""
|
||||||
|
if isinstance(image, Image):
|
||||||
|
rendition = image.get_rendition("fill-1200x630")
|
||||||
|
image_url = request.build_absolute_uri(rendition.url)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
"headline": article.title,
|
||||||
|
"author": {"@type": "Person", "name": article.author.name},
|
||||||
|
"datePublished": article.first_published_at.isoformat() if article.first_published_at else "",
|
||||||
|
"dateModified": article.last_published_at.isoformat() if article.last_published_at else "",
|
||||||
|
"description": article.search_description or article.summary,
|
||||||
|
"url": article.get_full_url(request),
|
||||||
|
"image": image_url,
|
||||||
|
}
|
||||||
|
return mark_safe(
|
||||||
|
'<script type="application/ld+json">' + json.dumps(data, ensure_ascii=True) + "</script>"
|
||||||
|
)
|
||||||
33
apps/core/views.py
Normal file
33
apps/core/views.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
|
from apps.core.consent import ConsentService
|
||||||
|
|
||||||
|
|
||||||
|
def consent_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
|
||||||
|
analytics = False
|
||||||
|
advertising = False
|
||||||
|
|
||||||
|
if request.POST.get("accept_all"):
|
||||||
|
analytics = True
|
||||||
|
advertising = True
|
||||||
|
elif request.POST.get("reject_all"):
|
||||||
|
analytics = False
|
||||||
|
advertising = False
|
||||||
|
else:
|
||||||
|
analytics = request.POST.get("analytics") in {"true", "1", "on"}
|
||||||
|
advertising = request.POST.get("advertising") in {"true", "1", "on"}
|
||||||
|
|
||||||
|
target = request.META.get("HTTP_REFERER", "/")
|
||||||
|
response = redirect(target)
|
||||||
|
ConsentService.set_consent(response, analytics=analytics, advertising=advertising)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def robots_txt(request: HttpRequest) -> HttpResponse:
|
||||||
|
return render(request, "core/robots.txt", content_type="text/plain")
|
||||||
0
apps/legal/__init__.py
Normal file
0
apps/legal/__init__.py
Normal file
6
apps/legal/apps.py
Normal file
6
apps/legal/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LegalConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.legal"
|
||||||
40
apps/legal/migrations/0001_initial.py
Normal file
40
apps/legal/migrations/0001_initial.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import wagtail.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wagtailcore', '0094_alter_page_locale'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LegalIndexPage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LegalPage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
('body', wagtail.fields.RichTextField()),
|
||||||
|
('last_updated', models.DateField()),
|
||||||
|
('show_in_footer', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/legal/migrations/__init__.py
Normal file
0
apps/legal/migrations/__init__.py
Normal file
25
apps/legal/models.py
Normal file
25
apps/legal/models.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.db import models
|
||||||
|
from wagtail.fields import RichTextField
|
||||||
|
from wagtail.models import Page
|
||||||
|
|
||||||
|
|
||||||
|
class LegalIndexPage(Page):
|
||||||
|
parent_page_types = ["blog.HomePage"]
|
||||||
|
subpage_types = ["legal.LegalPage"]
|
||||||
|
|
||||||
|
def serve(self, request):
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
def get_sitemap_urls(self, request=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class LegalPage(Page):
|
||||||
|
body = RichTextField()
|
||||||
|
last_updated = models.DateField()
|
||||||
|
show_in_footer = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
parent_page_types = ["legal.LegalIndexPage"]
|
||||||
|
subpage_types = []
|
||||||
0
apps/newsletter/__init__.py
Normal file
0
apps/newsletter/__init__.py
Normal file
6
apps/newsletter/apps.py
Normal file
6
apps/newsletter/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.newsletter"
|
||||||
7
apps/newsletter/forms.py
Normal file
7
apps/newsletter/forms.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionForm(forms.Form):
|
||||||
|
email = forms.EmailField()
|
||||||
|
source = forms.CharField(required=False)
|
||||||
|
honeypot = forms.CharField(required=False)
|
||||||
24
apps/newsletter/migrations/0001_initial.py
Normal file
24
apps/newsletter/migrations/0001_initial.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-28 11:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NewsletterSubscription',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email', models.EmailField(max_length=254, unique=True)),
|
||||||
|
('confirmed', models.BooleanField(default=False)),
|
||||||
|
('source', models.CharField(default='unknown', max_length=100)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/newsletter/migrations/__init__.py
Normal file
0
apps/newsletter/migrations/__init__.py
Normal file
11
apps/newsletter/models.py
Normal file
11
apps/newsletter/models.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterSubscription(models.Model):
|
||||||
|
email = models.EmailField(unique=True)
|
||||||
|
confirmed = models.BooleanField(default=False)
|
||||||
|
source = models.CharField(max_length=100, default="unknown")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.email
|
||||||
23
apps/newsletter/services.py
Normal file
23
apps/newsletter/services.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderSyncError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderSyncService:
|
||||||
|
def sync(self, subscription):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ButtondownSyncService(ProviderSyncService):
|
||||||
|
def sync(self, subscription):
|
||||||
|
logger.info("Synced subscription %s", subscription.email)
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_service() -> ProviderSyncService:
|
||||||
|
return ButtondownSyncService()
|
||||||
8
apps/newsletter/urls.py
Normal file
8
apps/newsletter/urls.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.newsletter.views import ConfirmView, SubscribeView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("subscribe/", SubscribeView.as_view(), name="newsletter_subscribe"),
|
||||||
|
path("confirm/<str:token>/", ConfirmView.as_view(), name="newsletter_confirm"),
|
||||||
|
]
|
||||||
51
apps/newsletter/views.py
Normal file
51
apps/newsletter/views.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core import signing
|
||||||
|
from django.http import Http404, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from apps.newsletter.forms import SubscriptionForm
|
||||||
|
from apps.newsletter.models import NewsletterSubscription
|
||||||
|
from apps.newsletter.services import ProviderSyncError, get_provider_service
|
||||||
|
|
||||||
|
CONFIRMATION_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 2
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeView(View):
|
||||||
|
def post(self, request):
|
||||||
|
form = SubscriptionForm(request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
return JsonResponse({"status": "error", "field": "email"}, status=400)
|
||||||
|
if form.cleaned_data.get("honeypot"):
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
email = form.cleaned_data["email"]
|
||||||
|
source = form.cleaned_data.get("source") or "unknown"
|
||||||
|
NewsletterSubscription.objects.get_or_create(email=email, defaults={"source": source})
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmView(View):
|
||||||
|
def get(self, request, token: str):
|
||||||
|
try:
|
||||||
|
email = signing.loads(
|
||||||
|
token,
|
||||||
|
salt="newsletter-confirm",
|
||||||
|
max_age=CONFIRMATION_TOKEN_MAX_AGE_SECONDS,
|
||||||
|
)
|
||||||
|
except signing.BadSignature as exc:
|
||||||
|
raise Http404 from exc
|
||||||
|
subscription = get_object_or_404(NewsletterSubscription, email=email)
|
||||||
|
subscription.confirmed = True
|
||||||
|
subscription.save(update_fields=["confirmed"])
|
||||||
|
service = get_provider_service()
|
||||||
|
try:
|
||||||
|
service.sync(subscription)
|
||||||
|
except ProviderSyncError:
|
||||||
|
pass
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
def confirmation_token(email: str) -> str:
|
||||||
|
return signing.dumps(email, salt="newsletter-confirm")
|
||||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
7
config/asgi.py
Normal file
7
config/asgi.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
0
config/settings/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
136
config/settings/base.py
Normal file
136
config/settings/base.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import dj_database_url
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "unsafe-dev-secret")
|
||||||
|
DEBUG = os.getenv("DEBUG", "0") == "1"
|
||||||
|
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h.strip()]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"django.contrib.sitemaps",
|
||||||
|
"taggit",
|
||||||
|
"modelcluster",
|
||||||
|
"wagtail.contrib.forms",
|
||||||
|
"wagtail.contrib.redirects",
|
||||||
|
"wagtail.contrib.sitemaps",
|
||||||
|
"wagtail.contrib.settings",
|
||||||
|
"wagtail.embeds",
|
||||||
|
"wagtail.sites",
|
||||||
|
"wagtail.users",
|
||||||
|
"wagtail.snippets",
|
||||||
|
"wagtail.documents",
|
||||||
|
"wagtail.images",
|
||||||
|
"wagtail.search",
|
||||||
|
"wagtail.admin",
|
||||||
|
"wagtail",
|
||||||
|
"wagtailseo",
|
||||||
|
"tailwind",
|
||||||
|
"apps.core",
|
||||||
|
"apps.blog",
|
||||||
|
"apps.authors",
|
||||||
|
"apps.comments",
|
||||||
|
"apps.newsletter",
|
||||||
|
"apps.legal",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
|
||||||
|
"apps.core.middleware.ConsentMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"apps.core.context_processors.site_settings",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": dj_database_url.parse(os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||||
|
MEDIA_URL = "/media/"
|
||||||
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
WAGTAIL_SITE_NAME = os.getenv("WAGTAIL_SITE_NAME", "No Hype AI")
|
||||||
|
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8035")
|
||||||
|
|
||||||
|
LOGIN_URL = "wagtailadmin_login"
|
||||||
|
|
||||||
|
CONSENT_POLICY_VERSION = int(os.getenv("CONSENT_POLICY_VERSION", "1"))
|
||||||
|
|
||||||
|
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
||||||
|
EMAIL_HOST = os.getenv("EMAIL_HOST", "")
|
||||||
|
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||||
|
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "1") == "1"
|
||||||
|
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||||
|
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||||
|
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "hello@nohypeai.com")
|
||||||
|
|
||||||
|
NEWSLETTER_PROVIDER = os.getenv("NEWSLETTER_PROVIDER", "buttondown")
|
||||||
|
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||||
|
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
|
||||||
|
|
||||||
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
13
config/settings/development.py
Normal file
13
config/settings/development.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .base import * # noqa
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
INTERNAL_IPS = ["127.0.0.1"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import debug_toolbar # noqa: F401
|
||||||
|
|
||||||
|
INSTALLED_APPS += ["debug_toolbar"]
|
||||||
|
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
9
config/settings/production.py
Normal file
9
config/settings/production.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from .base import * # noqa
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
SECURE_SSL_REDIRECT = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
23
config/urls.py
Normal file
23
config/urls.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
from wagtail import urls as wagtail_urls
|
||||||
|
from wagtail.contrib.sitemaps.views import sitemap
|
||||||
|
|
||||||
|
from apps.blog.feeds import AllArticlesFeed, TagArticlesFeed
|
||||||
|
from apps.core.views import consent_view, robots_txt
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("django-admin/", admin.site.urls),
|
||||||
|
path("cms/", include("wagtail.admin.urls")),
|
||||||
|
path("documents/", include("wagtail.documents.urls")),
|
||||||
|
path("comments/", include("apps.comments.urls")),
|
||||||
|
path("newsletter/", include("apps.newsletter.urls")),
|
||||||
|
path("consent/", consent_view, name="consent"),
|
||||||
|
path("robots.txt", robots_txt, name="robots_txt"),
|
||||||
|
path("feed/", AllArticlesFeed(), name="rss_feed"),
|
||||||
|
path("feed/tag/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
|
||||||
|
path("sitemap.xml", sitemap),
|
||||||
|
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||||
|
path("", include(wagtail_urls)),
|
||||||
|
]
|
||||||
7
config/wsgi.py
Normal file
7
config/wsgi.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: nohype-web
|
||||||
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
ports:
|
||||||
|
- "8035:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://nohype:nohype@db:5432/nohype
|
||||||
|
DJANGO_SETTINGS_MODULE: config.settings.development
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: nohype-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: nohype
|
||||||
|
POSTGRES_USER: nohype
|
||||||
|
POSTGRES_PASSWORD: nohype
|
||||||
|
ports:
|
||||||
|
- "5545:5432"
|
||||||
|
volumes:
|
||||||
|
- nohype_pg:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nohype_pg:
|
||||||
14
manage.py
Executable file
14
manage.py
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
45
pyproject.toml
Normal file
45
pyproject.toml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py312"
|
||||||
|
exclude = ["migrations"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"config/settings/development.py" = ["F403", "F405"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
plugins = ["mypy_django_plugin.main"]
|
||||||
|
warn_unused_configs = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
exclude = ["migrations"]
|
||||||
|
disable_error_code = ["var-annotated", "override", "import-untyped", "arg-type"]
|
||||||
|
allow_untyped_globals = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["wagtail.*", "taggit.*", "modelcluster.*", "wagtailseo.*", "debug_toolbar"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["apps.authors.models"]
|
||||||
|
ignore_errors = true
|
||||||
|
|
||||||
|
[tool.django-stubs]
|
||||||
|
django_settings_module = "config.settings.development"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["apps"]
|
||||||
|
omit = [
|
||||||
|
"*/migrations/*",
|
||||||
|
"*/tests/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
omit = [
|
||||||
|
"*/migrations/*",
|
||||||
|
"*/tests/*",
|
||||||
|
]
|
||||||
4
pytest.ini
Normal file
4
pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
DJANGO_SETTINGS_MODULE = config.settings.development
|
||||||
|
python_files = test_*.py
|
||||||
|
addopts = -q --cov=apps --cov-report=term-missing --cov-fail-under=90
|
||||||
22
requirements/base.txt
Normal file
22
requirements/base.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
Django~=5.2.0
|
||||||
|
wagtail~=7.0.0
|
||||||
|
wagtail-seo~=3.1.1
|
||||||
|
psycopg2-binary~=2.9.0
|
||||||
|
Pillow~=11.0.0
|
||||||
|
django-taggit~=6.0.0
|
||||||
|
whitenoise~=6.0.0
|
||||||
|
gunicorn~=23.0.0
|
||||||
|
python-dotenv~=1.0.0
|
||||||
|
dj-database-url~=2.2.0
|
||||||
|
django-tailwind~=3.8.0
|
||||||
|
django-csp~=3.8.0
|
||||||
|
pytest~=8.3.0
|
||||||
|
pytest-django~=4.9.0
|
||||||
|
pytest-cov~=5.0.0
|
||||||
|
pytest-benchmark~=4.0.0
|
||||||
|
factory-boy~=3.3.0
|
||||||
|
wagtail-factories~=4.2.0
|
||||||
|
feedparser~=6.0.0
|
||||||
|
ruff~=0.6.0
|
||||||
|
mypy~=1.11.0
|
||||||
|
django-stubs~=5.1.0
|
||||||
2
requirements/production.txt
Normal file
2
requirements/production.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r base.txt
|
||||||
|
sentry-sdk~=2.0.0
|
||||||
13
static/js/consent.js
Normal file
13
static/js/consent.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
(function () {
|
||||||
|
function parseCookieValue(name) {
|
||||||
|
const match = document.cookie.match(new RegExp('(?:^|;)\\s*' + name + '\\s*=\\s*([^;]+)'));
|
||||||
|
if (!match) return {};
|
||||||
|
try {
|
||||||
|
return Object.fromEntries(new URLSearchParams(match[1]));
|
||||||
|
} catch (_e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const c = parseCookieValue('nhAiConsent');
|
||||||
|
window.__nhConsent = { analytics: c.a === '1', advertising: c.d === '1' };
|
||||||
|
})();
|
||||||
1
static/js/prism.js
Normal file
1
static/js/prism.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* placeholder for Prism.js bundle */
|
||||||
7
static/js/theme.js
Normal file
7
static/js/theme.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
(function () {
|
||||||
|
window.toggleTheme = function toggleTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.toggle('dark');
|
||||||
|
localStorage.setItem('theme', root.classList.contains('dark') ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
})();
|
||||||
21
templates/base.html
Normal file
21
templates/base.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% load static core_tags %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{% block title %}No Hype AI{% endblock %}</title>
|
||||||
|
<script nonce="{{ request.csp_nonce|default:'' }}">
|
||||||
|
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
|
||||||
|
</script>
|
||||||
|
<script src="{% static 'js/consent.js' %}" nonce="{{ request.csp_nonce|default:'' }}"></script>
|
||||||
|
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/prism.js' %}" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/nav.html' %}
|
||||||
|
{% include 'components/cookie_banner.html' %}
|
||||||
|
<main>{% block content %}{% endblock %}</main>
|
||||||
|
{% include 'components/footer.html' %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
templates/blog/about_page.html
Normal file
14
templates/blog/about_page.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load wagtailimages_tags wagtailcore_tags %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.mission_statement }}</p>
|
||||||
|
{{ page.body|richtext }}
|
||||||
|
{% if page.featured_author %}
|
||||||
|
<h2>{{ page.featured_author.name }}</h2>
|
||||||
|
<p>{{ page.featured_author.bio }}</p>
|
||||||
|
{% if page.featured_author.avatar %}
|
||||||
|
{% image page.featured_author.avatar fill-200x200 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
11
templates/blog/article_index_page.html
Normal file
11
templates/blog/article_index_page.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load core_tags %}
|
||||||
|
{% block title %}Articles | No Hype AI{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
{% for article in articles %}
|
||||||
|
{% include 'components/article_card.html' with article=article %}
|
||||||
|
{% empty %}
|
||||||
|
<p>No articles found.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
33
templates/blog/article_page.html
Normal file
33
templates/blog/article_page.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load wagtailcore_tags wagtailimages_tags seo_tags %}
|
||||||
|
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.read_time_mins }} min read</p>
|
||||||
|
{% if page.hero_image %}
|
||||||
|
{% image page.hero_image fill-1200x630 %}
|
||||||
|
{% endif %}
|
||||||
|
{{ page.body }}
|
||||||
|
{% article_json_ld page %}
|
||||||
|
</article>
|
||||||
|
<section>
|
||||||
|
<h2>Related</h2>
|
||||||
|
{% for article in related_articles %}
|
||||||
|
<a href="{{ article.url }}">{{ article.title }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% if page.comments_enabled %}
|
||||||
|
<section>
|
||||||
|
<form method="post" action="{% url 'comment_post' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||||
|
<input type="text" name="author_name" required />
|
||||||
|
<input type="email" name="author_email" required />
|
||||||
|
<textarea name="body" required></textarea>
|
||||||
|
<input type="text" name="honeypot" style="display:none" />
|
||||||
|
<button type="submit">Post comment</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
4
templates/blog/blocks/callout_block.html
Normal file
4
templates/blog/blocks/callout_block.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="callout icon-{{ value.icon }}">
|
||||||
|
<h3>{{ value.heading }}</h3>
|
||||||
|
{{ value.body }}
|
||||||
|
</div>
|
||||||
5
templates/blog/blocks/code_block.html
Normal file
5
templates/blog/blocks/code_block.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<div class="code-block">
|
||||||
|
{% if value.filename %}<div>{{ value.filename }}</div>{% endif %}
|
||||||
|
<pre data-lang="{{ value.language }}"><code class="language-{{ value.language }}">{{ value.raw_code }}</code></pre>
|
||||||
|
</div>
|
||||||
5
templates/blog/blocks/image_block.html
Normal file
5
templates/blog/blocks/image_block.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% load wagtailimages_tags %}
|
||||||
|
<figure>
|
||||||
|
{% image value.image width-1024 alt=value.alt %}
|
||||||
|
{% if value.caption %}<figcaption>{{ value.caption }}</figcaption>{% endif %}
|
||||||
|
</figure>
|
||||||
4
templates/blog/blocks/pull_quote_block.html
Normal file
4
templates/blog/blocks/pull_quote_block.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<blockquote>
|
||||||
|
<p>{{ value.quote }}</p>
|
||||||
|
{% if value.attribution %}<cite>{{ value.attribution }}</cite>{% endif %}
|
||||||
|
</blockquote>
|
||||||
21
templates/blog/home_page.html
Normal file
21
templates/blog/home_page.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}No Hype AI{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section>
|
||||||
|
{% if featured_article %}
|
||||||
|
<h2>{{ featured_article.title }}</h2>
|
||||||
|
<p>{{ featured_article.author.name }}</p>
|
||||||
|
<p>{{ featured_article.read_time_mins }} min read</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
{% for article in latest_articles %}
|
||||||
|
{% include 'components/article_card.html' with article=article %}
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
{% for article in more_articles %}
|
||||||
|
{% include 'components/article_card.html' with article=article %}
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
8
templates/components/article_card.html
Normal file
8
templates/components/article_card.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% load core_tags %}
|
||||||
|
<article>
|
||||||
|
<a href="{{ article.url }}">{{ article.title }}</a>
|
||||||
|
<p>{{ article.summary|truncatewords:20 }}</p>
|
||||||
|
{% for tag in article.tags.all %}
|
||||||
|
<span class="{{ tag|get_tag_css }}">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</article>
|
||||||
12
templates/components/cookie_banner.html
Normal file
12
templates/components/cookie_banner.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% if request.consent.requires_prompt %}
|
||||||
|
<div id="cookie-banner">
|
||||||
|
<form method="post" action="{% url 'consent' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="accept_all" value="1">Accept all</button>
|
||||||
|
<button type="submit" name="reject_all" value="1">Reject all</button>
|
||||||
|
</form>
|
||||||
|
{% if site_settings and site_settings.privacy_policy_page %}
|
||||||
|
<a href="{{ site_settings.privacy_policy_page.url }}">Privacy Policy</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
7
templates/components/footer.html
Normal file
7
templates/components/footer.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% load core_tags %}
|
||||||
|
<footer>
|
||||||
|
{% get_legal_pages as legal_pages %}
|
||||||
|
{% for page in legal_pages %}
|
||||||
|
<a href="{{ page.url }}">{{ page.title }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</footer>
|
||||||
5
templates/components/nav.html
Normal file
5
templates/components/nav.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/articles/">Articles</a>
|
||||||
|
<a href="/about/">About</a>
|
||||||
|
</nav>
|
||||||
3
templates/core/robots.txt
Normal file
3
templates/core/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /cms/
|
||||||
|
Sitemap: {{ request.scheme }}://{{ request.get_host }}/sitemap.xml
|
||||||
7
templates/legal/legal_page.html
Normal file
7
templates/legal/legal_page.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load wagtailcore_tags %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>Last updated: {{ page.last_updated|date:'F Y' }}</p>
|
||||||
|
{{ page.body|richtext }}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user