From 51fe270b552f6a3a301d4bf174c2598243493966 Mon Sep 17 00:00:00 2001 From: Jeremy Childers Date: Thu, 19 Feb 2026 15:30:21 -0500 Subject: [PATCH 1/5] Wagtail Integration: Init and Taggable Mixin --- pages/__init__.py | 0 pages/admin.py | 1 + pages/apps.py | 5 +++++ pages/migrations/__init__.py | 0 pages/mixins.py | 38 ++++++++++++++++++++++++++++++++++++ pages/models.py | 0 pages/tests.py | 1 + pages/views.py | 1 + pages/wagtail_hooks.py | 0 9 files changed, 46 insertions(+) create mode 100644 pages/__init__.py create mode 100644 pages/admin.py create mode 100644 pages/apps.py create mode 100644 pages/migrations/__init__.py create mode 100644 pages/mixins.py create mode 100644 pages/models.py create mode 100644 pages/tests.py create mode 100644 pages/views.py create mode 100644 pages/wagtail_hooks.py diff --git a/pages/__init__.py b/pages/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pages/admin.py b/pages/admin.py new file mode 100644 index 000000000..846f6b406 --- /dev/null +++ b/pages/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/pages/apps.py b/pages/apps.py new file mode 100644 index 000000000..344e0f0cf --- /dev/null +++ b/pages/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PagesConfig(AppConfig): + name = "pages" diff --git a/pages/migrations/__init__.py b/pages/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pages/mixins.py b/pages/mixins.py new file mode 100644 index 000000000..42507934d --- /dev/null +++ b/pages/mixins.py @@ -0,0 +1,38 @@ +from django.db import models +from modelcluster.contrib.taggit import ClusterTaggableManager +from modelcluster.fields import ParentalKey +from taggit.models import ItemBase +from taggit.models import TagBase +from wagtail.snippets.models import register_snippet + + +@register_snippet +class ContentTag(TagBase): + # Disable Free tagging, to prevent adding extraneous tags + free_tagging = False + + class Meta: + verbose_name = "content tag" + verbose_name_plural = "content tags" + + +class TaggedContent(ItemBase): + tag = models.ForeignKey( + ContentTag, + related_name="tagged_content", + on_delete=models.CASCADE, + ) + content_object = ParentalKey( + to="wagtail.Page", on_delete=models.CASCADE, related_name="tagged_items" + ) + + +class TaggableMixin: + tags = ClusterTaggableManager( + through="pages.TaggedContent", + blank=True, + ) + + +class FlaggedMixin: + pass diff --git a/pages/models.py b/pages/models.py new file mode 100644 index 000000000..e69de29bb diff --git a/pages/tests.py b/pages/tests.py new file mode 100644 index 000000000..a39b155ac --- /dev/null +++ b/pages/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/pages/views.py b/pages/views.py new file mode 100644 index 000000000..60f00ef0e --- /dev/null +++ b/pages/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/pages/wagtail_hooks.py b/pages/wagtail_hooks.py new file mode 100644 index 000000000..e69de29bb From 42b596a100b2f6fd7db930b21e088d75adde35ff Mon Sep 17 00:00:00 2001 From: Jeremy Childers Date: Fri, 20 Feb 2026 17:25:32 -0500 Subject: [PATCH 2/5] Continue work, implement mixins, begin post pages --- config/settings.py | 2 + config/urls.py | 8 +- pages/blocks.py | 26 +++ pages/migrations/0001_initial.py | 108 ++++++++++ .../migrations/0002_routablehomepage_tags.py | 25 +++ .../migrations/0003_postindexpage_postpage.py | 107 ++++++++++ pages/mixins.py | 33 ++- pages/models.py | 99 +++++++++ templates/pages/post_index_page.html | 191 ++++++++++++++++++ templates/pages/post_page.html | 42 ++++ templates/pages/routable_home_page.html | 0 11 files changed, 633 insertions(+), 8 deletions(-) create mode 100644 pages/blocks.py create mode 100644 pages/migrations/0001_initial.py create mode 100644 pages/migrations/0002_routablehomepage_tags.py create mode 100644 pages/migrations/0003_postindexpage_postpage.py create mode 100644 templates/pages/post_index_page.html create mode 100644 templates/pages/post_page.html create mode 100644 templates/pages/routable_home_page.html diff --git a/config/settings.py b/config/settings.py index e7098e4c4..e21dd276a 100755 --- a/config/settings.py +++ b/config/settings.py @@ -103,6 +103,7 @@ "wagtail.images", "wagtail.search", "wagtail.admin", + "wagtail.contrib.routable_page", "wagtail", "wagtailmarkdown", "modelcluster", @@ -123,6 +124,7 @@ "slack", "testimonials", "patches", + "pages", "asciidoctor_sandbox", ] diff --git a/config/urls.py b/config/urls.py index 36a46efeb..4e7c513e0 100755 --- a/config/urls.py +++ b/config/urls.py @@ -461,6 +461,10 @@ ), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + [ + # Wagtail catch-all (must be last!) + path("pages/", include(wagtail_urls)), + ] + [ # Libraries docs, some HTML parts are re-written re_path( @@ -494,10 +498,6 @@ ), ] + djdt_urls - + [ - # Wagtail catch-all (must be last!) - path("", include(wagtail_urls)), - ] ) handler404 = "ak.views.custom_404_view" diff --git a/pages/blocks.py b/pages/blocks.py new file mode 100644 index 000000000..094fac936 --- /dev/null +++ b/pages/blocks.py @@ -0,0 +1,26 @@ +from wagtail.blocks import CharBlock +from wagtail.blocks import RichTextBlock +from wagtail.blocks import StructBlock +from wagtail.blocks import StreamBlock +from wagtail.blocks import URLBlock +from wagtail.embeds.blocks import EmbedBlock +from wagtail.images.blocks import ImageChooserBlock +from wagtailmarkdown.blocks import MarkdownBlock + + +class CustomVideoBlock(StructBlock): + video = EmbedBlock() + thumbnail = ImageChooserBlock() + + +class PollBlock(StreamBlock): + poll_choice = CharBlock(max_length=200) + + +POST_BLOCKS = [ + ("rich_text", RichTextBlock()), + ("markdown", MarkdownBlock()), + ("url", URLBlock()), + ("video", CustomVideoBlock(label="Video")), + ("poll", PollBlock()), +] diff --git a/pages/migrations/0001_initial.py b/pages/migrations/0001_initial.py new file mode 100644 index 000000000..2e1bbd473 --- /dev/null +++ b/pages/migrations/0001_initial.py @@ -0,0 +1,108 @@ +# Generated by Django 6.0.2 on 2026-02-19 22:22 + +import django.db.models.deletion +import modelcluster.fields +import pages.mixins +import wagtail.contrib.routable_page.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ContentTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=100, unique=True, verbose_name="name"), + ), + ( + "slug", + models.SlugField( + allow_unicode=True, + max_length=100, + unique=True, + verbose_name="slug", + ), + ), + ], + options={ + "verbose_name": "content tag", + "verbose_name_plural": "content tags", + }, + ), + migrations.CreateModel( + name="RoutableHomePage", + 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=( + wagtail.contrib.routable_page.models.RoutablePageMixin, + pages.mixins.FlaggedMixin, + pages.mixins.TaggableMixin, + "wagtailcore.page", + ), + ), + migrations.CreateModel( + name="TaggedContent", + fields=[ + ( + "id", + models.AutoField( + 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="wagtailcore.page", + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tagged_content", + to="pages.contenttag", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/pages/migrations/0002_routablehomepage_tags.py b/pages/migrations/0002_routablehomepage_tags.py new file mode 100644 index 000000000..e743109ab --- /dev/null +++ b/pages/migrations/0002_routablehomepage_tags.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.2 on 2026-02-20 14:56 + +import modelcluster.contrib.taggit +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="routablehomepage", + name="tags", + field=modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="pages.TaggedContent", + to="pages.ContentTag", + verbose_name="Tags", + ), + ), + ] diff --git a/pages/migrations/0003_postindexpage_postpage.py b/pages/migrations/0003_postindexpage_postpage.py new file mode 100644 index 000000000..260c4ed68 --- /dev/null +++ b/pages/migrations/0003_postindexpage_postpage.py @@ -0,0 +1,107 @@ +# Generated by Django 6.0.2 on 2026-02-20 19:08 + +import django.db.models.deletion +import modelcluster.contrib.taggit +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0002_routablehomepage_tags"), + ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PostIndexPage", + 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", + ), + ), + ( + "tags", + modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="pages.TaggedContent", + to="pages.ContentTag", + verbose_name="Tags", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + migrations.CreateModel( + name="PostPage", + 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", + ), + ), + ( + "content", + wagtail.fields.StreamField( + [ + ("rich_text", 0), + ("markdown", 1), + ("url", 2), + ("video", 5), + ("poll", 7), + ], + block_lookup={ + 0: ("wagtail.blocks.RichTextBlock", (), {}), + 1: ("wagtailmarkdown.blocks.MarkdownBlock", (), {}), + 2: ("wagtail.blocks.URLBlock", (), {}), + 3: ("wagtail.embeds.blocks.EmbedBlock", (), {}), + 4: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 5: ( + "wagtail.blocks.StructBlock", + [[("video", 3), ("thumbnail", 4)]], + {"label": "Video"}, + ), + 6: ("wagtail.blocks.CharBlock", (), {"max_length": 200}), + 7: ( + "wagtail.blocks.StreamBlock", + [[("poll_choice", 6)]], + {}, + ), + }, + ), + ), + ( + "tags", + modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="pages.TaggedContent", + to="pages.ContentTag", + verbose_name="Tags", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + ] diff --git a/pages/mixins.py b/pages/mixins.py index 42507934d..067a6f0fd 100644 --- a/pages/mixins.py +++ b/pages/mixins.py @@ -1,9 +1,12 @@ from django.db import models +from django.http import HttpResponseForbidden from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.fields import ParentalKey from taggit.models import ItemBase from taggit.models import TagBase +from wagtail.models import Page from wagtail.snippets.models import register_snippet +import waffle @register_snippet @@ -23,16 +26,38 @@ class TaggedContent(ItemBase): on_delete=models.CASCADE, ) content_object = ParentalKey( - to="wagtail.Page", on_delete=models.CASCADE, related_name="tagged_items" + to="wagtailcore.Page", + on_delete=models.CASCADE, + related_name="tagged_items", ) -class TaggableMixin: +class TaggableMixin(Page): tags = ClusterTaggableManager( through="pages.TaggedContent", blank=True, ) + content_panels = Page.content_panels + ["tags"] + + class Meta: + abstract = True + + +class FlaggedMixin(Page): + def serve(self, request, *args, **kwargs): + if not waffle.flag_is_active(request, "v3"): + return HttpResponseForbidden("You do not have access to this page.") + return super().serve(request, *args, **kwargs) + + class Meta: + abstract = True + + +class BasePage(FlaggedMixin, TaggableMixin, Page): + """ + Abstract Base Page for all our new Pages to inherit from + """ -class FlaggedMixin: - pass + class Meta(Page.Meta): + abstract = True diff --git a/pages/models.py b/pages/models.py index e69de29bb..08aa8fc19 100644 --- a/pages/models.py +++ b/pages/models.py @@ -0,0 +1,99 @@ +from wagtail.contrib.routable_page.models import RoutablePageMixin +from wagtail.fields import StreamField + +from django.db.models import QuerySet + +from pages.blocks import POST_BLOCKS +from pages.mixins import BasePage + + +class RoutableHomePage(BasePage, RoutablePageMixin): + """ + Empty home page that contains subroutes for handling special url patters. + + e.g. Making sure that outreach is found at /outreach and posts are found at /posts + """ + + # Defines this as a home page + parent_page_types = ["wagtailcore.Page"] + # + subpage_types = ["pages.PostIndexPage"] + max_count = 1 + + +class PostIndexPage(BasePage): + """ + Parent Index of News items, inheriting by base Page and displaying all content items when visited + """ + + parent_page_types = ["pages.RoutableHomePage"] + subpage_types = ["pages.PostPage"] + max_count = 1 + + def get_children_by_content_type(self, content_type: str) -> QuerySet["PostPage"]: + posts = PostPage.objects.child_of(self).live().order_by("-first_published_at") + print(posts.first().content[0].block) + return posts.filter(content__0__name=content_type) + + def get_context(self, request, *args, **kwargs): + ctx = super().get_context(request, *args, **kwargs) + + posts = ( + self.get_children().type(PostPage).live().order_by("-first_published_at") + ) + if content_type := request.GET.get("content-type", "").lower(): + match content_type: + case "blog": + posts = self.get_children_by_content_type("rich_text") + + ctx["posts"] = posts + return ctx + + +class PostPage(BasePage): + """ + News items, inheriting from base Page and having their content defined by a stream field named content + """ + + parent_page_types = ["pages.PostIndexPage"] + subpage_types = [] + content = StreamField(POST_BLOCKS, min_num=1, max_num=1) + + @property + def content_type(self): + if not len(self.content): + return "" + else: + return self.content[0].block.name + + @property + def post_content_type(self): + match self.content_type: + case "rich_text": + return "Blog" + case "markdown": + return "Blog" + case "url": + return "Link" + case "video": + return "Video" + case "poll": + return "Poll" + + @property + def icon_name(self): + match self.content_type: + case "rich_text": + return "comment" + case "markdown": + return "comment" + case "url": + return "link" + case "video": + return "video" + case "poll": + return "poll" + + content_panels = BasePage.content_panels + [ + "content", + ] diff --git a/templates/pages/post_index_page.html b/templates/pages/post_index_page.html new file mode 100644 index 000000000..7ff6656c6 --- /dev/null +++ b/templates/pages/post_index_page.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load news_tags %} +{% load avatar_tags %} +{% load text_helpers %} + +{% block title %} + {{self.title}} +{% endblock title %} + +{% block content %} +
+
+

+ Latest Stories + +

+ +
+
+

+ Stay up to date with Boost and the C++ ecosystem with the latest news, videos, resources, polls, and user-created content. + {% if user.is_authenticated %} + Or, Create a Post to include in the feed (posts are reviewed before publication). + {% else %} + Signed-in users may submit items to include in the feed (posts are reviewed before publication). + {% endif %} +

+
+ + +
+
+ + {% url 'news' as target_url %} + +
All
+ +
+ + {% url 'news-news-list' as target_url %} + +
News
+ +
+ + {% url 'news-blogpost-list' as target_url %} + +
Blogs
+ +
+ + {% url 'news-link-list' as target_url %} + +
Links
+ +
+ + {% comment %} + + {% url 'news-poll-list' as target_url %} + +
Polls
+ +
+ {% endcomment %} + + {% url 'news-video-list' as target_url %} + +
Videos
+ +
+ + {% if is_moderator %} + +
Moderate
+ +
+ {% endif %} +
+
+
+ +
+
+ {% for entry in posts %} +
+ + + +
+
+ + +
+ {% if entry.visible_content %} +
+
{{ entry.visible_content|urlize|url_target_blank:'text-sky-600 dark:text-sky-300 hover:text-orange dark:hover:text-orange'|linebreaksbr|multi_truncate_middle:30 }}
+ {% if entry.use_summary %} + {% if entry.determined_news_type == "link" %} + Read more… + {% else %} + Read more… + {% endif %} + {% endif %} +
+ {% endif %} + + +
+
+ {{ entry.owner.display_name }}
+ {{ entry.display_publish_at }} +
+
+ {% if entry.tag == "link" %} + {% url 'news-link-list' as target_url %} + + + + {% elif entry.tag == "news" %} + {% url 'news-news-list' as target_url %} + + + + {% elif entry.tag == "blogpost" %} + {% url 'news-blogpost-list' as target_url %} + + + + {% elif entry.tag == "poll" %} + {% url 'news-poll-list' as target_url %} + + + + {% elif entry.tag == "video" %} + {% url 'news-video-list' as target_url %} + + + + {% else %} + + {% endif %} +
+
+ {% if entry.owner.image %} + + {{ entry.owner.display_name }} + + {% else %} + + + + {% endif %} +
+
+ +
+
+ {% empty %} + {% if user.is_authenticated %} +

No news yet; consider submitting something!

+ {% endif %} + {% endfor %} +
+
+ + +
+ +{% endblock content %} diff --git a/templates/pages/post_page.html b/templates/pages/post_page.html new file mode 100644 index 000000000..3fa7a5c89 --- /dev/null +++ b/templates/pages/post_page.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} +{% load wagtailcore_tags %} + +{% block title %} + {{ self.title }} +{% endblock %} + +{% block content %} + +
+
+

+ + {{ self.title }} +

+
+ {% with author=self.owner %} + {% if author.image %} + + {{ author.display_name }} + + {% else %} + + + + {% endif %} + {% if author.display_name %} +
+ {{ author.display_name }}
+ {{ self.first_published_at|date:'M jS, Y' }} +
+ {% endif %} + {% endwith %} +
+
+
+ {% include_block self.content %} +
+
+
+
+{% endblock %} diff --git a/templates/pages/routable_home_page.html b/templates/pages/routable_home_page.html new file mode 100644 index 000000000..e69de29bb From 0988ce0f78a19c1c2ad8dac6a6787d10b5124162 Mon Sep 17 00:00:00 2001 From: Jeremy Childers Date: Mon, 23 Feb 2026 23:18:50 -0500 Subject: [PATCH 3/5] Continue work on post pages --- pages/blocks.py | 3 + pages/models.py | 120 ++++++++++++++++------- templates/blocks/custom_video_block.html | 2 + templates/pages/post_index_page.html | 60 ++---------- 4 files changed, 102 insertions(+), 83 deletions(-) create mode 100644 templates/blocks/custom_video_block.html diff --git a/pages/blocks.py b/pages/blocks.py index 094fac936..cbf05c559 100644 --- a/pages/blocks.py +++ b/pages/blocks.py @@ -12,6 +12,9 @@ class CustomVideoBlock(StructBlock): video = EmbedBlock() thumbnail = ImageChooserBlock() + class Meta: + template = "blocks/custom_video_block.html" + class PollBlock(StreamBlock): poll_choice = CharBlock(max_length=200) diff --git a/pages/models.py b/pages/models.py index 08aa8fc19..73aacedcc 100644 --- a/pages/models.py +++ b/pages/models.py @@ -1,3 +1,5 @@ +from typing import NamedTuple + from wagtail.contrib.routable_page.models import RoutablePageMixin from wagtail.fields import StreamField @@ -21,6 +23,58 @@ class RoutableHomePage(BasePage, RoutablePageMixin): max_count = 1 +class _PostContentType(NamedTuple): + """ + Associates content block names with label, icon, and filter name + """ + + block_name: list = [] + icon_name: str = "" + content_type: str = "" + filter_name: str = "" + + +POST_CONTENT_TYPES = ( + _PostContentType( + block_name=[], + icon_name="globe", + content_type="All", + filter_name="", + ), + _PostContentType( + block_name=["rich_text", "markdown"], + icon_name="comment", + content_type="Blog", + filter_name="blog", + ), + _PostContentType( + block_name=[], + icon_name="newspaper", + content_type="News", + filter_name="news", + ), + _PostContentType( + block_name=["video"], + icon_name="video", + content_type="Video", + filter_name="video", + ), + _PostContentType( + block_name=["url"], + icon_name="link", + content_type="Link", + filter_name="link", + ), +) +CONTENT_TYPES_BY_FILTER: dict[str, _PostContentType] = { + x.filter_name: x for x in POST_CONTENT_TYPES if x.filter_name +} +CONTENT_TYPES_BY_BLOCK: dict[str, _PostContentType] = {} +for i in POST_CONTENT_TYPES: + for bn in i.block_name: + CONTENT_TYPES_BY_BLOCK[bn] = i + + class PostIndexPage(BasePage): """ Parent Index of News items, inheriting by base Page and displaying all content items when visited @@ -30,23 +84,33 @@ class PostIndexPage(BasePage): subpage_types = ["pages.PostPage"] max_count = 1 - def get_children_by_content_type(self, content_type: str) -> QuerySet["PostPage"]: + def get_children_by_content_type( + self, content_type: str | list[str] + ) -> QuerySet["PostPage"]: posts = PostPage.objects.child_of(self).live().order_by("-first_published_at") - print(posts.first().content[0].block) - return posts.filter(content__0__name=content_type) + if isinstance(content_type, str): + return posts.filter(content__0__type=content_type) + elif isinstance(content_type, list): + return posts.filter(content__0__type__in=content_type) + else: + return posts.none() def get_context(self, request, *args, **kwargs): ctx = super().get_context(request, *args, **kwargs) - posts = ( - self.get_children().type(PostPage).live().order_by("-first_published_at") - ) - if content_type := request.GET.get("content-type", "").lower(): - match content_type: - case "blog": - posts = self.get_children_by_content_type("rich_text") + content_type = request.GET.get("type", "").lower() + if content_value := CONTENT_TYPES_BY_FILTER.get(content_type, None): + posts = self.get_children_by_content_type(content_value.block_name) + else: + posts = ( + self.get_children() + .type(PostPage) + .live() + .order_by("-first_published_at") + ) ctx["posts"] = posts + ctx["filters"] = POST_CONTENT_TYPES return ctx @@ -60,7 +124,7 @@ class PostPage(BasePage): content = StreamField(POST_BLOCKS, min_num=1, max_num=1) @property - def content_type(self): + def stream_content_type(self): if not len(self.content): return "" else: @@ -68,31 +132,21 @@ def content_type(self): @property def post_content_type(self): - match self.content_type: - case "rich_text": - return "Blog" - case "markdown": - return "Blog" - case "url": - return "Link" - case "video": - return "Video" - case "poll": - return "Poll" + return CONTENT_TYPES_BY_BLOCK.get( + self.stream_content_type, _PostContentType() + ).content_type @property def icon_name(self): - match self.content_type: - case "rich_text": - return "comment" - case "markdown": - return "comment" - case "url": - return "link" - case "video": - return "video" - case "poll": - return "poll" + return CONTENT_TYPES_BY_BLOCK.get( + self.stream_content_type, _PostContentType() + ).icon_name + + @property + def filter_name(self): + return CONTENT_TYPES_BY_BLOCK.get( + self.stream_content_type, _PostContentType() + ).filter_name content_panels = BasePage.content_panels + [ "content", diff --git a/templates/blocks/custom_video_block.html b/templates/blocks/custom_video_block.html new file mode 100644 index 000000000..d683956da --- /dev/null +++ b/templates/blocks/custom_video_block.html @@ -0,0 +1,2 @@ +{% load wagtailcore_tags wagtailembeds_tags %} +{% embed self.video %} diff --git a/templates/pages/post_index_page.html b/templates/pages/post_index_page.html index 7ff6656c6..200ad8f9b 100644 --- a/templates/pages/post_index_page.html +++ b/templates/pages/post_index_page.html @@ -33,51 +33,12 @@

- {% url 'news' as target_url %} - -
All
- + {% for type in filters %} +
+
{{type.content_type}}
+
- - {% url 'news-news-list' as target_url %} - -
News
- -
- - {% url 'news-blogpost-list' as target_url %} - -
Blogs
- -
- - {% url 'news-link-list' as target_url %} - -
Links
- -
- - {% comment %} - - {% url 'news-poll-list' as target_url %} - -
Polls
- -
- {% endcomment %} - - {% url 'news-video-list' as target_url %} - -
Videos
- -
- - {% if is_moderator %} - -
Moderate
- -
- {% endif %} + {% endfor %}

@@ -87,11 +48,10 @@
Moderate
{% for entry in posts %}
@@ -103,21 +63,21 @@
Moderate
{% if entry.visible_content %}
{{ entry.visible_content|urlize|url_target_blank:'text-sky-600 dark:text-sky-300 hover:text-orange dark:hover:text-orange'|linebreaksbr|multi_truncate_middle:30 }}
{% if entry.use_summary %} - {% if entry.determined_news_type == "link" %} + {% if entry.specific.content_type == "Link" %} Read more… {% else %} Read more… From 8897110019a5c007135ba448fd07390e291995c9 Mon Sep 17 00:00:00 2001 From: Jeremy Childers Date: Tue, 24 Feb 2026 20:38:58 -0500 Subject: [PATCH 4/5] Implement routing and import script, clean up some additional feature parity --- config/urls.py | 2 +- marketing/models.py | 5 +- .../commands/convert_news_entries.py | 110 ++++++++++++++++++ pages/migrations/0004_postpage_image.py | 26 +++++ pages/migrations/0005_postpage_summary.py | 22 ++++ pages/models.py | 97 +++++++++++++-- templates/blocks/custom_video_block.html | 2 +- templates/pages/post_index_page.html | 49 ++------ templates/pages/post_page.html | 39 ++++++- 9 files changed, 299 insertions(+), 53 deletions(-) create mode 100644 pages/management/commands/convert_news_entries.py create mode 100644 pages/migrations/0004_postpage_image.py create mode 100644 pages/migrations/0005_postpage_summary.py diff --git a/config/urls.py b/config/urls.py index 4e7c513e0..230200950 100755 --- a/config/urls.py +++ b/config/urls.py @@ -462,7 +462,7 @@ ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + [ - # Wagtail catch-all (must be last!) + path("outreach/", include(wagtail_urls)), path("pages/", include(wagtail_urls)), ] + [ diff --git a/marketing/models.py b/marketing/models.py index 8f3d6b82a..00e285b55 100644 --- a/marketing/models.py +++ b/marketing/models.py @@ -139,7 +139,10 @@ class DetailPage(EmailCapturePage): class OutreachHomePage(Page): """A dummy homepage to just return a 404 at the `/outreach/` url""" - parent_page_types = ["wagtailcore.Page"] + parent_page_types = [ + "wagtailcore.Page", + "pages.RoutableHomePage", + ] subpage_types = ["marketing.ProgramPageIndex", "marketing.TopicPage"] max_count = 1 # one container diff --git a/pages/management/commands/convert_news_entries.py b/pages/management/commands/convert_news_entries.py new file mode 100644 index 000000000..dae24f56f --- /dev/null +++ b/pages/management/commands/convert_news_entries.py @@ -0,0 +1,110 @@ +import djclick as click +from wagtail.models import Page +from wagtail.images.models import Image + +from pages.models import PostPage +from pages.models import PostIndexPage + +from news.models import Video +from news.models import News +from news.models import BlogPost +from news.models import Link +from news.models import Entry + +from django.template.defaultfilters import urlize +from django.template.defaultfilters import linebreaks_filter + + +def get_or_create_page(entry: Entry, index_page: PostIndexPage) -> PostPage: + try: + page = index_page.get_children().get(title=entry.title).specific + except Page.DoesNotExist: + page = PostPage( + title=entry.title, + first_published_at=entry.publish_at, + owner=entry.author, + ) + index_page.add_child(instance=page) + return page + + +def convert_text_content(content: str): + r_content = content + r_content = urlize(r_content) + r_content = linebreaks_filter(r_content) + return r_content + + +def convert_image(entry: Entry, post_page: PostPage): + image = entry.image + wagtail_image, _ = Image.objects.get_or_create( + title=image.name, + defaults={"width": image.width, "height": image.height, "file": image}, + ) + post_page.image = wagtail_image + post_page.save() + + +def basic_conversion(entry: Entry, index_page: PostIndexPage): + print(f"Creating or updating PostPage {entry.title}") + page = get_or_create_page(entry, index_page) + if entry.image: + convert_image(entry, page) + if entry.summary: + page.summary = entry.summary + return page + + +@click.command() +def command(): + post_index_page = PostIndexPage.objects.first() + if not post_index_page: + raise Exception( + "No Post Index Page found. Create one before running this command." + ) + + blogs_posts = BlogPost.objects.all() + print(f"Creating or updating {blogs_posts.count()} Blog Posts") + for bp in blogs_posts: + page = basic_conversion(bp, post_index_page) + page.content = [ + { + "type": "markdown", + "value": convert_text_content(bp.content), + } + ] + page.save() + news_posts = News.objects.all() + print(f"Creating or updating {news_posts.count()} News Posts") + for np in news_posts: + page = basic_conversion(np, post_index_page) + page.content = [ + { + "type": "markdown", + "value": convert_text_content(np.content), + } + ] + page.save() + videos = Video.objects.all() + print(f"Creating or updating {news_posts.count()} Videos") + for video in videos: + page = basic_conversion(video, post_index_page) + page.content = [ + { + "type": "video", + "value": {"video": video.external_url}, + } + ] + page.save() + + links = Link.objects.all() + print(f"Creating or updating {links.count()} Links") + for link in links: + page = basic_conversion(link, post_index_page) + page.content = [ + { + "type": "url", + "value": link.external_url, + } + ] + page.save() diff --git a/pages/migrations/0004_postpage_image.py b/pages/migrations/0004_postpage_image.py new file mode 100644 index 000000000..fbe2685c0 --- /dev/null +++ b/pages/migrations/0004_postpage_image.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.2 on 2026-02-24 19:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0003_postindexpage_postpage"), + ("wagtailimages", "0027_image_description"), + ] + + operations = [ + migrations.AddField( + model_name="postpage", + name="image", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + ), + ), + ] diff --git a/pages/migrations/0005_postpage_summary.py b/pages/migrations/0005_postpage_summary.py new file mode 100644 index 000000000..fcdb37de7 --- /dev/null +++ b/pages/migrations/0005_postpage_summary.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.2 on 2026-02-24 21:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0004_postpage_image"), + ] + + operations = [ + migrations.AddField( + model_name="postpage", + name="summary", + field=models.TextField( + blank=True, + default="", + help_text="AI generated summary. Delete to regenerate.", + ), + ), + ] diff --git a/pages/models.py b/pages/models.py index 73aacedcc..68cef23a3 100644 --- a/pages/models.py +++ b/pages/models.py @@ -1,15 +1,23 @@ from typing import NamedTuple - -from wagtail.contrib.routable_page.models import RoutablePageMixin +from structlog import get_logger from wagtail.fields import StreamField -from django.db.models import QuerySet +from django.db import models +from django.utils.functional import cached_property +from django.utils.text import slugify + from pages.blocks import POST_BLOCKS from pages.mixins import BasePage +from news.constants import CONTENT_SUMMARIZATION_THRESHOLD +from news.tasks import summary_dispatcher + + +logger = get_logger(__name__) + -class RoutableHomePage(BasePage, RoutablePageMixin): +class RoutableHomePage(BasePage): """ Empty home page that contains subroutes for handling special url patters. @@ -19,9 +27,22 @@ class RoutableHomePage(BasePage, RoutablePageMixin): # Defines this as a home page parent_page_types = ["wagtailcore.Page"] # - subpage_types = ["pages.PostIndexPage"] + subpage_types = [ + "pages.PostIndexPage", + "marketing.OutreachHomePage", + ] max_count = 1 + def route(self, request, path_components): + from marketing.models import OutreachHomePage + + path = request.path + base = path.split("/")[1] + if base == "outreach": + outreach_home_page = self.get_children().type(OutreachHomePage).first() + return outreach_home_page.route(request, path_components) + return super().route(request, path_components) + class _PostContentType(NamedTuple): """ @@ -86,7 +107,7 @@ class PostIndexPage(BasePage): def get_children_by_content_type( self, content_type: str | list[str] - ) -> QuerySet["PostPage"]: + ) -> models.QuerySet["PostPage"]: posts = PostPage.objects.child_of(self).live().order_by("-first_published_at") if isinstance(content_type, str): return posts.filter(content__0__type=content_type) @@ -122,27 +143,81 @@ class PostPage(BasePage): parent_page_types = ["pages.PostIndexPage"] subpage_types = [] content = StreamField(POST_BLOCKS, min_num=1, max_num=1) + image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + related_name="+", + on_delete=models.SET_NULL, + ) + summary = models.TextField( + blank=True, default="", help_text="AI generated summary. Delete to regenerate." + ) + + def get_context(self, request, *args, **kwargs): + ctx = super().get_context(request, *args, **kwargs) + pages = self.__class__.objects.live().order_by("-first_published_at") + prev_objects = pages.filter(first_published_at__lt=self.first_published_at) + next_objects = pages.filter(first_published_at__gt=self.first_published_at) + ctx["prev"] = prev_objects.first() + ctx["prev_in_category"] = prev_objects.filter( + content__0__type=self.stream_content_type + ).first() + ctx["next"] = next_objects.last() + ctx["next_in_category"] = next_objects.filter( + content__0__type=self.stream_content_type + ).last() + return ctx + + def get_listing_url(self, request=None, current_site=None): + if self.stream_content_type == "url": + return self.content[0] + return super().get_url(request, current_site) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + result = super().save(*args, **kwargs) + + if not self.summary: + logger.info(f"Passing {self.pk=} to dispatcher") + summary_dispatcher.delay(self.pk) + + return result + + @cached_property + def use_summary(self): + return bool(len(self.summary)) and ( + not self.content + or len(str(self.content[0])) > CONTENT_SUMMARIZATION_THRESHOLD + ) + + @cached_property + def visible_content(self): + if self.use_summary: + return self.summary + return self.content - @property + @cached_property def stream_content_type(self): if not len(self.content): return "" else: return self.content[0].block.name - @property + @cached_property def post_content_type(self): return CONTENT_TYPES_BY_BLOCK.get( self.stream_content_type, _PostContentType() ).content_type - @property + @cached_property def icon_name(self): return CONTENT_TYPES_BY_BLOCK.get( self.stream_content_type, _PostContentType() ).icon_name - @property + @cached_property def filter_name(self): return CONTENT_TYPES_BY_BLOCK.get( self.stream_content_type, _PostContentType() @@ -150,4 +225,6 @@ def filter_name(self): content_panels = BasePage.content_panels + [ "content", + "image", + "summary", ] diff --git a/templates/blocks/custom_video_block.html b/templates/blocks/custom_video_block.html index d683956da..2af2dce27 100644 --- a/templates/blocks/custom_video_block.html +++ b/templates/blocks/custom_video_block.html @@ -1,2 +1,2 @@ {% load wagtailcore_tags wagtailembeds_tags %} -{% embed self.video %} +{{ self.video|safe }} diff --git a/templates/pages/post_index_page.html b/templates/pages/post_index_page.html index 200ad8f9b..95f1f40c6 100644 --- a/templates/pages/post_index_page.html +++ b/templates/pages/post_index_page.html @@ -48,7 +48,7 @@
{{type.content_type}}
{% for entry in posts %}
{% endblock %} From a984bd75f4972e6803ede03013f27646ce59e310 Mon Sep 17 00:00:00 2001 From: Jeremy Childers Date: Fri, 27 Feb 2026 16:27:22 -0500 Subject: [PATCH 5/5] Regeneate migrations --- pages/migrations/0001_initial.py | 156 +++++++++++++++--- .../migrations/0002_routablehomepage_tags.py | 25 --- .../migrations/0003_postindexpage_postpage.py | 107 ------------ pages/migrations/0004_postpage_image.py | 26 --- pages/migrations/0005_postpage_summary.py | 22 --- 5 files changed, 135 insertions(+), 201 deletions(-) delete mode 100644 pages/migrations/0002_routablehomepage_tags.py delete mode 100644 pages/migrations/0003_postindexpage_postpage.py delete mode 100644 pages/migrations/0004_postpage_image.py delete mode 100644 pages/migrations/0005_postpage_summary.py diff --git a/pages/migrations/0001_initial.py b/pages/migrations/0001_initial.py index 2e1bbd473..81aeac832 100644 --- a/pages/migrations/0001_initial.py +++ b/pages/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# Generated by Django 6.0.2 on 2026-02-19 22:22 +# Generated by Django 6.0.2 on 2026-02-27 21:21 import django.db.models.deletion +import modelcluster.contrib.taggit import modelcluster.fields -import pages.mixins -import wagtail.contrib.routable_page.models +import wagtail.fields from django.db import migrations, models @@ -13,6 +13,7 @@ class Migration(migrations.Migration): dependencies = [ ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), + ("wagtailimages", "0027_image_description"), ] operations = [ @@ -47,6 +48,39 @@ class Migration(migrations.Migration): "verbose_name_plural": "content tags", }, ), + migrations.CreateModel( + name="TaggedContent", + fields=[ + ( + "id", + models.AutoField( + 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="wagtailcore.page", + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tagged_content", + to="pages.contenttag", + ), + ), + ], + options={ + "abstract": False, + }, + ), migrations.CreateModel( name="RoutableHomePage", fields=[ @@ -61,48 +95,128 @@ class Migration(migrations.Migration): to="wagtailcore.page", ), ), + ( + "tags", + modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="pages.TaggedContent", + to="pages.ContentTag", + verbose_name="Tags", + ), + ), ], options={ "abstract": False, }, - bases=( - wagtail.contrib.routable_page.models.RoutablePageMixin, - pages.mixins.FlaggedMixin, - pages.mixins.TaggableMixin, - "wagtailcore.page", - ), + bases=("wagtailcore.page",), ), migrations.CreateModel( - name="TaggedContent", + name="PostPage", fields=[ ( - "id", - models.AutoField( + "page_ptr", + models.OneToOneField( auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, - verbose_name="ID", + to="wagtailcore.page", ), ), ( - "content_object", - modelcluster.fields.ParentalKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="tagged_items", - to="wagtailcore.page", + "content", + wagtail.fields.StreamField( + [ + ("rich_text", 0), + ("markdown", 1), + ("url", 2), + ("video", 5), + ("poll", 7), + ], + block_lookup={ + 0: ("wagtail.blocks.RichTextBlock", (), {}), + 1: ("wagtailmarkdown.blocks.MarkdownBlock", (), {}), + 2: ("wagtail.blocks.URLBlock", (), {}), + 3: ("wagtail.embeds.blocks.EmbedBlock", (), {}), + 4: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 5: ( + "wagtail.blocks.StructBlock", + [[("video", 3), ("thumbnail", 4)]], + {"label": "Video"}, + ), + 6: ("wagtail.blocks.CharBlock", (), {"max_length": 200}), + 7: ( + "wagtail.blocks.StreamBlock", + [[("poll_choice", 6)]], + {}, + ), + }, ), ), ( - "tag", + "summary", + models.TextField( + blank=True, + default="", + help_text="AI generated summary. Delete to regenerate.", + ), + ), + ( + "image", models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + ), + ), + ( + "tags", + modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="pages.TaggedContent", + to="pages.ContentTag", + verbose_name="Tags", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + migrations.CreateModel( + name="PostIndexPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, on_delete=django.db.models.deletion.CASCADE, - related_name="tagged_content", - to="pages.contenttag", + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "tags", + modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="pages.TaggedContent", + to="pages.ContentTag", + verbose_name="Tags", ), ), ], options={ "abstract": False, }, + bases=("wagtailcore.page",), ), ] diff --git a/pages/migrations/0002_routablehomepage_tags.py b/pages/migrations/0002_routablehomepage_tags.py deleted file mode 100644 index e743109ab..000000000 --- a/pages/migrations/0002_routablehomepage_tags.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-20 14:56 - -import modelcluster.contrib.taggit -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("pages", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="routablehomepage", - name="tags", - field=modelcluster.contrib.taggit.ClusterTaggableManager( - blank=True, - help_text="A comma-separated list of tags.", - through="pages.TaggedContent", - to="pages.ContentTag", - verbose_name="Tags", - ), - ), - ] diff --git a/pages/migrations/0003_postindexpage_postpage.py b/pages/migrations/0003_postindexpage_postpage.py deleted file mode 100644 index 260c4ed68..000000000 --- a/pages/migrations/0003_postindexpage_postpage.py +++ /dev/null @@ -1,107 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-20 19:08 - -import django.db.models.deletion -import modelcluster.contrib.taggit -import wagtail.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("pages", "0002_routablehomepage_tags"), - ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="PostIndexPage", - 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", - ), - ), - ( - "tags", - modelcluster.contrib.taggit.ClusterTaggableManager( - blank=True, - help_text="A comma-separated list of tags.", - through="pages.TaggedContent", - to="pages.ContentTag", - verbose_name="Tags", - ), - ), - ], - options={ - "abstract": False, - }, - bases=("wagtailcore.page",), - ), - migrations.CreateModel( - name="PostPage", - 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", - ), - ), - ( - "content", - wagtail.fields.StreamField( - [ - ("rich_text", 0), - ("markdown", 1), - ("url", 2), - ("video", 5), - ("poll", 7), - ], - block_lookup={ - 0: ("wagtail.blocks.RichTextBlock", (), {}), - 1: ("wagtailmarkdown.blocks.MarkdownBlock", (), {}), - 2: ("wagtail.blocks.URLBlock", (), {}), - 3: ("wagtail.embeds.blocks.EmbedBlock", (), {}), - 4: ("wagtail.images.blocks.ImageChooserBlock", (), {}), - 5: ( - "wagtail.blocks.StructBlock", - [[("video", 3), ("thumbnail", 4)]], - {"label": "Video"}, - ), - 6: ("wagtail.blocks.CharBlock", (), {"max_length": 200}), - 7: ( - "wagtail.blocks.StreamBlock", - [[("poll_choice", 6)]], - {}, - ), - }, - ), - ), - ( - "tags", - modelcluster.contrib.taggit.ClusterTaggableManager( - blank=True, - help_text="A comma-separated list of tags.", - through="pages.TaggedContent", - to="pages.ContentTag", - verbose_name="Tags", - ), - ), - ], - options={ - "abstract": False, - }, - bases=("wagtailcore.page",), - ), - ] diff --git a/pages/migrations/0004_postpage_image.py b/pages/migrations/0004_postpage_image.py deleted file mode 100644 index fbe2685c0..000000000 --- a/pages/migrations/0004_postpage_image.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-24 19:58 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("pages", "0003_postindexpage_postpage"), - ("wagtailimages", "0027_image_description"), - ] - - operations = [ - migrations.AddField( - model_name="postpage", - name="image", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="wagtailimages.image", - ), - ), - ] diff --git a/pages/migrations/0005_postpage_summary.py b/pages/migrations/0005_postpage_summary.py deleted file mode 100644 index fcdb37de7..000000000 --- a/pages/migrations/0005_postpage_summary.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-24 21:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("pages", "0004_postpage_image"), - ] - - operations = [ - migrations.AddField( - model_name="postpage", - name="summary", - field=models.TextField( - blank=True, - default="", - help_text="AI generated summary. Delete to regenerate.", - ), - ), - ]