Integrate Wagtail CMS and set up landing page structure (#2014)

This commit is contained in:
Greg Kaleka
2025-11-18 15:53:27 -05:00
parent 1e56c838b6
commit e0fe6d61e1
36 changed files with 1103 additions and 252 deletions

View File

@@ -6,4 +6,4 @@ from marketing.models import CapturedEmail
@admin.register(CapturedEmail)
class CapturedEmailAdmin(admin.ModelAdmin):
model = CapturedEmail
list_display = ("email", "referrer", "page_slug")
list_display = ("email", "referrer", "page")

View File

@@ -1,17 +0,0 @@
from django import forms
from .models import CapturedEmail
class CapturedEmailForm(forms.ModelForm):
class Meta:
model = CapturedEmail
fields = ["email"]
widgets = {
"email": forms.EmailInput(
attrs={
"placeholder": "your@email.com",
"autocomplete": "email",
}
)
}

View File

@@ -0,0 +1,231 @@
# Generated by Django 5.2.8 on 2025-11-17 21:27
import django.db.models.deletion
import django.utils.timezone
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("marketing", "0001_initial"),
("wagtailcore", "0095_groupsitepermission"),
]
operations = [
migrations.CreateModel(
name="DetailPage",
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",
),
),
(
"email_capture_intro",
models.TextField(
default="Drop your email below to get engineering updates."
),
),
(
"privacy_blurb",
models.TextField(
default="Privacy: no spam, one step unsubscribe. We'll only send high-signal dev content re this and other Boost libraries."
),
),
(
"body",
wagtail.fields.StreamField(
[("rich", 0), ("md", 1)],
blank=True,
block_lookup={
0: (
"wagtail.blocks.RichTextBlock",
(),
{
"features": [
"h1",
"h2",
"h3",
"bold",
"italic",
"link",
"ol",
"ul",
"code",
"blockquote",
],
"label": "Rich text",
},
),
1: (
"wagtailmarkdown.blocks.MarkdownBlock",
(),
{"label": "Markdown"},
),
},
),
),
],
options={
"abstract": False,
},
bases=("wagtailcore.page",),
),
migrations.CreateModel(
name="OutreachHomePage",
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="ProgramPage",
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",
),
),
(
"email_capture_intro",
models.TextField(
default="Drop your email below to get engineering updates."
),
),
(
"privacy_blurb",
models.TextField(
default="Privacy: no spam, one step unsubscribe. We'll only send high-signal dev content re this and other Boost libraries."
),
),
(
"body",
wagtail.fields.StreamField(
[("rich", 0), ("md", 1)],
blank=True,
block_lookup={
0: (
"wagtail.blocks.RichTextBlock",
(),
{
"features": [
"h1",
"h2",
"h3",
"bold",
"italic",
"link",
"ol",
"ul",
"code",
"blockquote",
],
"label": "Rich text",
},
),
1: (
"wagtailmarkdown.blocks.MarkdownBlock",
(),
{"label": "Markdown"},
),
},
),
),
],
options={
"abstract": False,
},
bases=("wagtailcore.page",),
),
migrations.CreateModel(
name="ProgramPageIndex",
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="TopicPage",
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={
"verbose_name": "Topic",
},
bases=("wagtailcore.page",),
),
migrations.RemoveField(
model_name="capturedemail",
name="page_slug",
),
migrations.AddField(
model_name="capturedemail",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="capturedemail",
name="page",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="captured_emails",
to="wagtailcore.page",
),
),
]

View File

@@ -1,13 +1,210 @@
from django import forms
from django.contrib import messages
from django.db import models
from django.http import Http404
from django.shortcuts import render, redirect
from wagtail.admin.panels import FieldPanel
from wagtail.blocks import RichTextBlock
from wagtail.fields import StreamField
from wagtail.models import Page
from wagtail.url_routing import RouteResult
from wagtailmarkdown.blocks import MarkdownBlock
RICH_TEXT_FEATURES = [
"h1",
"h2",
"h3",
"bold",
"italic",
"link",
"ol",
"ul",
"code",
"blockquote",
]
class CapturedEmail(models.Model):
email = models.EmailField()
referrer = models.CharField(blank=True, default="")
page_slug = models.CharField(blank=True, default="")
page = models.ForeignKey(
Page,
related_name="captured_emails",
on_delete=models.SET_NULL,
null=True,
blank=True,
default=None,
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.email
def __repr__(self):
return f"<{self.__class__.__name__} ({self.pk}): {self}>"
class CapturedEmailForm(forms.ModelForm):
class Meta:
model = CapturedEmail
fields = ["email"]
widgets = {
"email": forms.EmailInput(
attrs={"placeholder": "your@email.com", "autocomplete": "email"}
)
}
class EmailCapturePage(Page):
"""Abstract page with reusable logic for pages that capture an email."""
email_capture_intro = models.TextField(
default="Drop your email below to get engineering updates."
)
privacy_blurb = models.TextField(
default="Privacy: no spam, one step unsubscribe. We'll only send high-signal dev content re this and other Boost libraries."
)
body = StreamField(
[
("rich", RichTextBlock(features=RICH_TEXT_FEATURES, label="Rich text")),
("md", MarkdownBlock(label="Markdown")),
],
use_json_field=True,
blank=True,
)
content_panels = Page.content_panels + [
FieldPanel("email_capture_intro"),
FieldPanel("privacy_blurb"),
FieldPanel("body"),
]
class Meta:
abstract = True
def get_referrer(self, request):
original = request.session.get("original_referrer", "")
return original or request.headers.get("referer", "")
def build_form(self, request) -> CapturedEmailForm:
"""Create a form instance appropriate to the request method."""
if request.method == "POST":
return CapturedEmailForm(data=request.POST)
return CapturedEmailForm()
def get_success_url(self, request):
"""Redirect back to the same page after a successful POST."""
return self.url
def handle_email_form(self, request, form):
captured = form.save(commit=False)
captured.referrer = self.get_referrer(request)
captured.page = self
captured.save()
messages.success(request, "Thanks! We'll be in touch.")
return redirect(self.get_success_url(request))
def serve(self, request, *args, **kwargs):
"""
Unified GET/POST handling:
- On GET: render template with empty form.
- On POST: validate, save CapturedEmail, redirect, or redisplay with errors.
"""
form = self.build_form(request)
if request.method == "POST" and form.is_valid():
return self.handle_email_form(request, form)
# Fall through: GET, or invalid POST
context = super().get_context(request, *args, **kwargs)
context["form"] = form
return render(request, self.get_template(request), context)
class ProgramPage(EmailCapturePage):
parent_page_types = ["marketing.ProgramPageIndex"]
subpage_types = []
class DetailPage(EmailCapturePage):
parent_page_types = ["marketing.TopicPage"]
subpage_types = []
# ===================
### Dummy pages ###
# ===================
class OutreachHomePage(Page):
"""A dummy homepage to just return a 404 at the `/outreach/` url"""
parent_page_types = ["wagtailcore.Page"]
subpage_types = ["marketing.ProgramPageIndex", "marketing.TopicPage"]
max_count = 1 # one container
def route(self, request, path_components):
"""
Custom router so public URLs don't include container slugs.
/outreach/program_page/<slug>/ => delegate to ProgramPageIndex -> ProgramPage
/outreach/<topic>/<detail>/ => delegate to TopicPage -> DetailPage
"""
if not path_components:
return RouteResult(self)
first, *rest = path_components
# Fixed segment for program pages
if first == "program_page":
try:
program_page_index = ProgramPageIndex.objects.child_of(self).get()
except ProgramPageIndex.DoesNotExist:
raise Http404("Program index not found")
# Delegate the remaining segments
return program_page_index.route(request, rest)
# Otherwise, first segment should be a TopicPage slug
try:
topic = TopicPage.objects.child_of(self).get(slug=first)
except TopicPage.DoesNotExist:
raise Http404("Topic not found")
return topic.route(request, rest)
# Hide this page publicly: /outreach/ -> 404
def serve(self, request, *args, **kwargs):
raise Http404
def get_sitemap_urls(self, request=None):
return []
class ProgramPageIndex(Page):
"""A dummy index page to facilitate our url scheme"""
parent_page_types = ["marketing.OutreachHomePage"]
subpage_types = ["marketing.ProgramPage"]
max_count = 1 # one container
# Hide index page: /outreach/program_page/ -> 404
def serve(self, request, *args, **kwargs):
raise Http404
def get_sitemap_urls(self, request=None):
return []
class TopicPage(Page):
"""A dummy topic page that represents a given topic (e.g. a library)"""
parent_page_types = ["marketing.OutreachHomePage"]
subpage_types = ["marketing.DetailPage"]
class Meta:
verbose_name = "Topic"
# Hide this page publicly: /outreach/ -> 404
def serve(self, request, *args, **kwargs):
raise Http404
def get_sitemap_urls(self, request=None):
return []

View File

@@ -8,8 +8,7 @@ from django.views.decorators.cache import never_cache
from django.views.generic import CreateView
from core.views import logger
from marketing.forms import CapturedEmailForm
from marketing.models import CapturedEmail
from marketing.models import CapturedEmail, CapturedEmailForm
@method_decorator(never_cache, name="dispatch")