diff --git a/config/settings.py b/config/settings.py index c3ecde7bc..024a3663a 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 1ca01ec70..5d19f6767 100644 --- a/config/urls.py +++ b/config/urls.py @@ -407,6 +407,10 @@ ), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + [ + path("outreach/", include(wagtail_urls)), + path("pages/", include(wagtail_urls)), + ] + [ # Libraries docs, some HTML parts are re-written re_path( @@ -440,10 +444,6 @@ ), ] + djdt_urls - + [ - # Wagtail catch-all (must be last!) - path("", include(wagtail_urls)), - ] ) handler404 = "ak.views.custom_404_view" diff --git a/core/mixins.py b/core/mixins.py index ae0f9b246..9ce66126f 100644 --- a/core/mixins.py +++ b/core/mixins.py @@ -29,6 +29,11 @@ def dispatch(self, request, *args, **kwargs): raise Http404 return super().dispatch(request, *args, **kwargs) + def serve(self, request, *args, **kwargs): + if not flag_is_active(request, "v3"): + raise Http404 + return super().serve(request, *args, **kwargs) + def get_v3_context_data(self, **kwargs): """Override in subclasses to provide v3-specific context.""" return {} diff --git a/marketing/models.py b/marketing/models.py index 44bb3cf59..025c132d5 100644 --- a/marketing/models.py +++ b/marketing/models.py @@ -148,7 +148,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 @@ -158,13 +161,14 @@ def route(self, request, path_components): /outreach/program_page// => delegate to ProgramPageIndex -> ProgramPage /outreach/// => delegate to TopicPage -> DetailPage """ + print(path_components) if not path_components: return RouteResult(self) - _, second, *rest = path_components + first, *rest = path_components # Fixed segment for program pages - if second == "program_page": + if first == "program_page": try: program_page_index = ProgramPageIndex.objects.child_of(self).get() except ProgramPageIndex.DoesNotExist: @@ -174,7 +178,7 @@ def route(self, request, path_components): # Otherwise, first segment should be a TopicPage slug try: - topic = TopicPage.objects.child_of(self).get(slug=second) + topic = TopicPage.objects.child_of(self).get(slug=first) except TopicPage.DoesNotExist: raise Http404("Topic not found") 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/blocks.py b/pages/blocks.py new file mode 100644 index 000000000..cbf05c559 --- /dev/null +++ b/pages/blocks.py @@ -0,0 +1,29 @@ +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 Meta: + template = "blocks/custom_video_block.html" + + +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/management/commands/convert_news_entries.py b/pages/management/commands/convert_news_entries.py new file mode 100644 index 000000000..488e43f00 --- /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 {videos.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/0001_initial.py b/pages/migrations/0001_initial.py new file mode 100644 index 000000000..81aeac832 --- /dev/null +++ b/pages/migrations/0001_initial.py @@ -0,0 +1,222 @@ +# 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 wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), + ("wagtailimages", "0027_image_description"), + ] + + 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="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=[ + ( + "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)]], + {}, + ), + }, + ), + ), + ( + "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, + 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/__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..e218184a9 --- /dev/null +++ b/pages/mixins.py @@ -0,0 +1,53 @@ +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.models import Page +from wagtail.snippets.models import register_snippet + +from core.mixins import V3Mixin + + +@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="wagtailcore.Page", + on_delete=models.CASCADE, + related_name="tagged_items", + ) + + +class TaggableMixin(Page): + tags = ClusterTaggableManager( + through="pages.TaggedContent", + blank=True, + ) + + content_panels = Page.content_panels + ["tags"] + + class Meta: + abstract = True + + +class BasePage(V3Mixin, TaggableMixin, Page): + """ + Abstract Base Page for all our new Pages to inherit from + """ + + class Meta(Page.Meta): + abstract = True diff --git a/pages/models.py b/pages/models.py new file mode 100644 index 000000000..0524363f8 --- /dev/null +++ b/pages/models.py @@ -0,0 +1,230 @@ +from typing import NamedTuple +from structlog import get_logger +from wagtail.fields import StreamField + +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): + """ + 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", + "marketing.OutreachHomePage", + ] + max_count = 1 + + def route(self, request, path_components): + + path = request.path.rstrip("/").lstrip("/") + split_path = path.split("/") + base, *rest = split_path + + if match_child := self.get_children().filter(slug=base).first(): + matched_route = match_child.specific.route(request, rest) + return matched_route + return super().route(request, path_components) + + +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 + """ + + parent_page_types = ["pages.RoutableHomePage"] + subpage_types = ["pages.PostPage"] + max_count = 1 + + def get_children_by_content_type( + self, content_type: str | list[str] + ) -> 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) + 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) + + 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 + + +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) + 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 + + @cached_property + def stream_content_type(self): + if not len(self.content): + return "" + else: + return self.content[0].block.name + + @cached_property + def post_content_type(self): + return CONTENT_TYPES_BY_BLOCK.get( + self.stream_content_type, _PostContentType() + ).content_type + + @cached_property + def icon_name(self): + return CONTENT_TYPES_BY_BLOCK.get( + self.stream_content_type, _PostContentType() + ).icon_name + + @cached_property + def filter_name(self): + return CONTENT_TYPES_BY_BLOCK.get( + self.stream_content_type, _PostContentType() + ).filter_name + + content_panels = BasePage.content_panels + [ + "content", + "image", + "summary", + ] 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 diff --git a/templates/blocks/custom_video_block.html b/templates/blocks/custom_video_block.html new file mode 100644 index 000000000..2af2dce27 --- /dev/null +++ b/templates/blocks/custom_video_block.html @@ -0,0 +1,2 @@ +{% load wagtailcore_tags wagtailembeds_tags %} +{{ self.video|safe }} diff --git a/templates/pages/post_index_page.html b/templates/pages/post_index_page.html new file mode 100644 index 000000000..95f1f40c6 --- /dev/null +++ b/templates/pages/post_index_page.html @@ -0,0 +1,122 @@ +{% 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 %} +

+
+ + +
+
+ + {% for type in filters %} + +
{{type.content_type}}
+ +
+ {% endfor %} +
+
+
+ +
+
+ {% for entry in posts %} +
+ + + +
+
+ + +
+ {% if entry.specific.visible_content %} +
+
{{ entry.specific.visible_content }}
+ {% if entry.specific.use_summary %} + Read more… + {% endif %} +
+ {% endif %} + + +
+
+ {{ entry.owner.display_name }}
+ {{ entry.first_published_at }} +
+
+ + + +
+
+ {% 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..ecf2e6bec --- /dev/null +++ b/templates/pages/post_page.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} +{% load wagtailcore_tags wagtailimages_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 %} +
+ {% if self.image %} +
+ {% image self.image original as image %} + +
+ {% endif %} +
+
+ + {% if next or prev %} +
+
+ {% if next %} + Newer Post + {% endif %} +
+
+ {% if prev %} + Older Post + {% endif %} +
+
+ {% endif %} + + {% if next_in_category or prev_in_category %} +
+
+ {% if next_in_category %} + Newer {{ self.post_content_type|title }} Post + {% endif %} +
+
+ {% if prev_in_category %} + Older {{ self.post_content_type|title }} Post + {% endif %} +
+
+ {% endif %} + +
+{% endblock %} diff --git a/templates/pages/routable_home_page.html b/templates/pages/routable_home_page.html new file mode 100644 index 000000000..e69de29bb