Skip to content

Commit 709e9d6

Browse files
Story 2096: Implement Wagtail Integration (#2100)
Co-authored-by: Teodoro B. Mendes <teodoro.mendes@metalab.com>
1 parent c7720d2 commit 709e9d6

34 files changed

Lines changed: 1245 additions & 73 deletions

config/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"wagtail.images",
105105
"wagtail.search",
106106
"wagtail.admin",
107+
"wagtail.contrib.routable_page",
107108
"wagtail",
108109
"wagtailmarkdown",
109110
"modelcluster",
@@ -124,6 +125,7 @@
124125
"slack",
125126
"testimonials",
126127
"patches",
128+
"pages",
127129
"asciidoctor_sandbox",
128130
]
129131

config/urls.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
StaticContentTemplateView,
4444
UserGuideTemplateView,
4545
)
46-
from marketing.views import PlausibleRedirectView, WhitePaperView
46+
from marketing.views import PlausibleRedirectView
4747
from libraries.api import LibrarySearchView
4848
from libraries.views import (
4949
LibraryDetail,
@@ -134,11 +134,6 @@
134134
PlausibleRedirectView.as_view(),
135135
name="bsm",
136136
),
137-
path(
138-
"outreach/<slug:category>/<slug:slug>",
139-
WhitePaperView.as_view(),
140-
name="whitepaper",
141-
),
142137
path(
143138
"accounts/social/signup/",
144139
CustomSocialSignupViewView.as_view(),
@@ -424,6 +419,10 @@
424419
),
425420
]
426421
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
422+
+ [
423+
path("outreach/", include(wagtail_urls)),
424+
path("pages/", include(wagtail_urls)),
425+
]
427426
+ [
428427
# Libraries docs, some HTML parts are re-written
429428
re_path(
@@ -457,10 +456,6 @@
457456
),
458457
]
459458
+ djdt_urls
460-
+ [
461-
# Wagtail catch-all (must be last!)
462-
path("", include(wagtail_urls)),
463-
]
464459
)
465460

466461
handler404 = "ak.views.custom_404_view"

core/mixins.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ def get_context_data(self, **kwargs):
4242
context = super().get_context_data(**kwargs)
4343
return context
4444

45+
def serve(self, request, *args, **kwargs):
46+
if not flag_is_active(request, "v3"):
47+
raise Http404
48+
return super().serve(request, *args, **kwargs)
49+
4550
def get_v3_context_data(self, **kwargs):
4651
"""Override in subclasses to provide v3-specific context."""
4752
return {**kwargs}

marketing/models.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,15 @@ class DetailPage(EmailCapturePage):
148148
class OutreachHomePage(Page):
149149
"""A dummy homepage to just return a 404 at the `/outreach/` url"""
150150

151-
parent_page_types = ["wagtailcore.Page"]
152-
subpage_types = ["marketing.ProgramPageIndex", "marketing.TopicPage"]
151+
parent_page_types = [
152+
"wagtailcore.Page",
153+
"pages.RoutableHomePage",
154+
]
155+
subpage_types = [
156+
"marketing.ProgramPageIndex",
157+
"marketing.TopicPage",
158+
"testimonials.TestimonialsIndexPage",
159+
]
153160
max_count = 1 # one container
154161

155162
def route(self, request, path_components):
@@ -158,13 +165,14 @@ def route(self, request, path_components):
158165
/outreach/program_page/<slug>/ => delegate to ProgramPageIndex -> ProgramPage
159166
/outreach/<topic>/<detail>/ => delegate to TopicPage -> DetailPage
160167
"""
168+
print(path_components)
161169
if not path_components:
162170
return RouteResult(self)
163171

164-
_, second, *rest = path_components
172+
first, *rest = path_components
165173

166174
# Fixed segment for program pages
167-
if second == "program_page":
175+
if first == "program_page":
168176
try:
169177
program_page_index = ProgramPageIndex.objects.child_of(self).get()
170178
except ProgramPageIndex.DoesNotExist:
@@ -174,7 +182,7 @@ def route(self, request, path_components):
174182

175183
# Otherwise, first segment should be a TopicPage slug
176184
try:
177-
topic = TopicPage.objects.child_of(self).get(slug=second)
185+
topic = TopicPage.objects.child_of(self).get(slug=first)
178186
except TopicPage.DoesNotExist:
179187
raise Http404("Topic not found")
180188

marketing/tests.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@
44
import pytest
55

66

7-
def test_whitepaper_view(tp):
8-
tp.assertGoodView("whitepaper", slug="_example")
9-
10-
117
@pytest.mark.parametrize("url_stem", ["qrc", "bsm"])
128
def test_plausible_redirect_and_plausible_payload(tp, url_stem):
139
"""XFF present; querystring preserved; payload/headers correct."""

news/management/commands/backpopulate_thumbnails.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from celery import group
33

44
from news.models import Video
5-
from news.tasks import set_thumbnail_for_video_entry
5+
from news.tasks import set_thumbnail_for_video_entry, set_thumbnail_for_video_page
6+
7+
from pages.models import PostPage
68

79

810
@click.command()
@@ -14,22 +16,27 @@
1416
def command(dry_run):
1517
"""Backpopulate video entry thumbnails that have not been set"""
1618

17-
qs = Video.objects.filter(thumbnail__isnull=True)
18-
count = qs.count()
19+
v_qs = Video.objects.filter(thumbnail__isnull=True)
20+
p_qs = PostPage.objects.filter(
21+
video_thumbnail__isnull=True, content__0__type="video"
22+
)
23+
count = v_qs.count() + p_qs.count()
1924

2025
if count == 0:
2126
click.echo("No entries found without thumbnails.")
2227
return
2328

2429
if dry_run:
2530
click.echo(f"Would process {count} entries:")
26-
for entry in qs[:10]:
31+
for entry in (list(v_qs) + list(p_qs))[:10]:
2732
click.echo(f" - {entry.pk}: {entry.title}")
2833
if count > 10:
2934
click.echo(f" ... and {count - 10} more")
3035
return
3136

3237
click.echo("Dispatching thumbnail tasks...")
33-
g = group(set_thumbnail_for_video_entry.s(i.pk) for i in qs.iterator())
38+
g = group(set_thumbnail_for_video_entry.s(i.pk) for i in v_qs.iterator())
3439
g.apply_async()
40+
g_2 = group(set_thumbnail_for_video_page.s(i.pk) for i in p_qs.iterator())
41+
g_2.apply_async()
3542
click.echo(f"Thumbnail tasks for {count} tasks queued.")

news/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,13 @@ def video_thumbnail(self):
162162
result = None
163163
return result
164164

165+
@property
166+
def image_url(self):
167+
if not self.image:
168+
return ""
169+
else:
170+
return self.image.url
171+
165172
@cached_property
166173
def tag(self):
167174
return getattr(self, "_tag", self.news_type)
@@ -230,6 +237,9 @@ def approve(self, user, commit=True):
230237
if commit:
231238
self.save(update_fields=["moderator", "approved_at", "modified_at"])
232239

240+
def get_content(self):
241+
return self.content
242+
233243
@cached_property
234244
def use_summary(self):
235245
return self.summary and (

news/tasks.py

Lines changed: 110 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from config.celery import app
77
from config.settings import OPENROUTER_API_KEY, OPENROUTER_URL, SUMMARIZATION_MODEL
88
from news.constants import CONTENT_SUMMARIZATION_THRESHOLD
9-
from news.helpers import UnsafeURLError, extract_article, safe_get
9+
from news.helpers import UnsafeURLError, extract_article, safe_get, extract_content
1010
from news.utils import set_video_thumbnail
1111

1212
logger = structlog.get_logger(__name__)
@@ -138,18 +138,52 @@ def save_entry_summary_value(summary: str | None, pk: int):
138138

139139

140140
@app.task
141-
def summary_dispatcher(pk: int):
141+
def save_page_summary_value(summary: str, pk: int):
142+
from pages.models import PostPage
143+
144+
# generate_summary returns None/"" on malformed or empty model output; saving
145+
# that would clobber an existing Entry.summary, so treat it as "do not save".
146+
if not summary:
147+
logger.warning(f"Skipping summary save for {pk=}: empty/malformed model output")
148+
return
149+
150+
page = PostPage.objects.get(pk=pk)
151+
page.summary = summary
152+
page.save()
153+
154+
155+
@app.task
156+
def summary_dispatcher(pk: int, model_name: str = "Entry"):
142157
from news.models import Entry
158+
from pages.models import PostPage
159+
160+
if model_name == "PostPage":
161+
model = PostPage
162+
elif model_name == "Entry":
163+
model = Entry
164+
else:
165+
logger.warning("Invalid Model Name passed to summary dispatcher")
166+
return
143167

144-
entry = Entry.objects.get(pk=pk)
145-
logger.info(f"Dispatching {pk=} with {entry.news_type=}")
146-
handler = {
147-
"news": set_summary_for_event_entry,
148-
"blogpost": set_summary_for_event_entry,
149-
"link": set_summary_for_link_entry,
150-
"video": set_summary_for_video_entry,
151-
"poll": set_summary_for_poll_entry,
152-
}[entry.determined_news_type]
168+
entry = model.objects.get(pk=pk)
169+
if model == Entry:
170+
logger.info(f"Dispatching {pk=} with {entry.news_type=}")
171+
handler = {
172+
"news": set_summary_for_event_entry,
173+
"blogpost": set_summary_for_event_entry,
174+
"link": set_summary_for_link_entry,
175+
"video": set_summary_for_video_entry,
176+
"poll": set_summary_for_poll_entry,
177+
}[entry.determined_news_type.lower()]
178+
elif model == PostPage:
179+
logger.info(f"Dispatching {pk=} with {entry.post_content_type=}")
180+
handler = {
181+
"news": set_summary_for_event_page,
182+
"blogpost": set_summary_for_event_page,
183+
"link": set_summary_for_link_page,
184+
"video": set_summary_for_video_page,
185+
"poll": set_summary_for_poll_page,
186+
}[entry.determined_news_type.lower()]
153187
logger.info(f"Dispatching summary task for {pk=} to {handler.__name__=}")
154188
handler.delay(pk)
155189

@@ -240,3 +274,68 @@ def sync_post_views_from_plausible():
240274

241275
updated = update_page_views(slug_views)
242276
logger.info("sync_post_views.done", updated=updated)
277+
278+
279+
# These tasks emulate the functionality of setting the summmary, but for PostPages instead of Entry.
280+
# Once the Entry model has been depricated and removed, the above functions can be deleted and the selector logic
281+
# removed from the dispatcher.
282+
283+
284+
@app.task
285+
def set_summary_for_event_page(pk: int):
286+
from pages.models import PostPage
287+
288+
page = PostPage.objects.get(pk=pk)
289+
content = page.content[0].value
290+
logger.info(f"dispatching summarize task for {pk=} with {content[:40]=}...")
291+
if content and len(content) < CONTENT_SUMMARIZATION_THRESHOLD:
292+
logger.warning(f"Content too short to summarize for {pk=}, skipping.")
293+
return
294+
logger.info(f"handing off {pk=} to summarize_content task")
295+
summarize_content.apply_async(
296+
(content, page.title, SUMMARIZATION_MODEL),
297+
link=save_page_summary_value.s(pk),
298+
)
299+
300+
301+
@app.task
302+
def set_summary_for_link_page(pk: int):
303+
logger.info(f"Setting summary for link page {pk=}")
304+
from pages.models import PostPage
305+
306+
page = PostPage.objects.get(pk=pk)
307+
external_url = page.external_url
308+
try:
309+
logger.info(f"Fetching content from {external_url=} for entry.{pk=}")
310+
response = requests.get(external_url, timeout=10)
311+
response.raise_for_status()
312+
markup = response.text
313+
logger.debug(f"Fetched {len(markup)=} for entry.{pk=}...")
314+
content = extract_content(markup)
315+
logger.info(f"extracted content from {external_url=}, {markup[:100]=}")
316+
except requests.RequestException as e:
317+
logger.error(f"Error fetching content from {external_url=}: {e=}")
318+
return
319+
320+
logger.info(f"dispatching summarize task for {pk=} with {content[:40]=}...")
321+
summarize_content.apply_async(
322+
(content, page.title, SUMMARIZATION_MODEL), link=save_page_summary_value.s(pk)
323+
)
324+
325+
326+
@app.task
327+
def set_summary_for_video_page(pk: int):
328+
logger.info("Summarization not implemented")
329+
330+
331+
@app.task
332+
def set_summary_for_poll_page(pk: int):
333+
logger.info("Summarization not implemented")
334+
335+
336+
@app.task
337+
def set_thumbnail_for_video_page(pk: int):
338+
from pages.models import PostPage
339+
340+
video = PostPage.objects.get(pk=pk)
341+
set_video_thumbnail(video)

news/utils.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,22 @@
1212

1313
if typing.TYPE_CHECKING:
1414
from news.models import Video
15+
from pages.models import PostPage
1516

1617

17-
def set_video_thumbnail(video: "Video"):
18+
def set_video_thumbnail(video: typing.Union["Video", "PostPage"]):
1819
"""
1920
Given a video model, use oembed to fetch the thumbnail and save it to the model
2021
"""
22+
from news.models import Video
23+
from pages.models import PostPage
24+
2125
YOUTUBE_OEMBED_ENDPOINT = "https://www.youtube.com/oembed"
2226

23-
if not video.is_video:
27+
if isinstance(video, Video) and not video.is_video:
2428
raise Exception(f"{video} is not a video, cannot set thumbnail.")
29+
elif isinstance(video, PostPage) and not video.post_content_type == "Video":
30+
raise Exception(f"{video}'s content is not a video, cannot set thumbnail.")
2531

2632
url = YOUTUBE_OEMBED_ENDPOINT + f"?url={video.external_url}"
2733
response = requests.get(url)
@@ -40,7 +46,11 @@ def set_video_thumbnail(video: "Video"):
4046
width=json.get("thumbnail_width"),
4147
height=json.get("thumbnail_height"),
4248
)
43-
video.thumbnail = image
49+
50+
if isinstance(video, Video):
51+
video.thumbnail = image
52+
elif isinstance(video, PostPage):
53+
video.video_thumbnail = image
4454
video.save()
4555

4656

0 commit comments

Comments
 (0)