Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e44666a
Story 2296: V3 Post Detail page
julioest Apr 29, 2026
0294b8e
feat: text_paragraphs filter for post bodies
julioest Apr 30, 2026
293a83f
style: V3 post detail visuals to match Figma
julioest Apr 30, 2026
118a493
feat: wire V3 post author role and badge
julioest Apr 30, 2026
9428b72
feat: pass description on V3 post-card data
julioest Apr 30, 2026
7db8b8a
fix: add aria-label to V3 post external URL link
julioest Apr 30, 2026
29b6a4e
style: V3 post detail mobile + tablet spacing
julioest Apr 30, 2026
35fd291
style: V3 post-header date format m/d/Y
julioest Apr 30, 2026
dbc1e53
fix: use --font-code token in V3 post body
julioest May 5, 2026
30df4f6
fix: pluralize Related Posts heading
julioest May 5, 2026
433b601
style: V3 post-detail card and profile gaps
julioest May 5, 2026
09d63aa
fix: tighten V3 post-detail mobile padding
julioest May 5, 2026
a36a832
fix: exclude deleted entries from V3 next/related
julioest May 6, 2026
92f8977
fix: stable next/related order and deleted notice
julioest May 7, 2026
4103cd6
refactor: extract user_profile_card helper
julioest May 7, 2026
2b01c95
fix: preserve list and signoff breaks in posts
julioest May 7, 2026
f294e5a
refactor: V3 detail reuses /news/entry route
julioest May 7, 2026
6714d65
fix: V3 post header date format and trim blank line
julioest May 10, 2026
71de469
fix: V3 post title uses letter-spacing-tight token
julioest May 10, 2026
5228655
fix: V3 post card uses category key with tag label
julioest May 10, 2026
b7f47e9
fix: V3 registry surfaces opted-out subclasses
julioest May 10, 2026
f7b8b60
fix: V3 post detail renders body as markdown
julioest May 10, 2026
e56ef83
chore: trim redundant CSS in post-detail body
julioest May 10, 2026
666f824
fix: post-card dates render in written form
julioest May 10, 2026
8f234ef
chore: rename TAG_LABELS to CATEGORY_LABELS
julioest May 12, 2026
ad08b2f
fix: post-card dates use 3-letter month abbreviation
julioest May 12, 2026
70a710f
Lazy-load the post detail hero image
julioest May 13, 2026
a9431e7
Note related-posts library-scoping intent
julioest May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions core/tests/test_v3_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ def _get_v3_view_classes() -> set[type]:
return {view_class for _, view_class in iter_v3_views()}


def _get_v3_view_classes_with_template() -> set[type]:
return {
view_class
for view_class in _get_v3_view_classes()
if getattr(view_class, "v3_template_name", None)
}


@pytest.fixture(scope="session")
def v3_view_classes():
return sorted(_get_v3_view_classes(), key=lambda c: c.__name__)
Expand All @@ -35,13 +43,17 @@ def test_v3_views_discovered(v3_view_classes):

@pytest.mark.parametrize(
"view_class",
_get_v3_view_classes(),
_get_v3_view_classes_with_template(),
ids=lambda c: c.__name__,
)
def test_v3_template_exists(view_class):
"""Every V3 view must point to a `v3_template_name` that Django can load."""
template = getattr(view_class, "v3_template_name", None)
assert template, f"{view_class.__name__}: no v3_template_name set"
"""Every V3 view that declares a template must point to a real one.

Views that opt out of v3 rendering (`v3_template_name = None`, e.g.
EntryModerationDetailView) are excluded from this check but still
surface in `iter_v3_views()` so the V3 Demo registry can list them.
"""
template = view_class.v3_template_name
try:
get_template(template)
except TemplateDoesNotExist:
Expand Down
74 changes: 68 additions & 6 deletions news/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden
from django.shortcuts import redirect, get_object_or_404
from django.template.defaultfilters import date as datefilter
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import localtime, now
Expand All @@ -29,6 +29,7 @@
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData

from core.mixins import V3Mixin
from users.profile_cards import user_profile_card
from .acl import can_approve
from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION
from .forms import BlogPostForm, EntryForm, LinkForm, NewsForm, PollForm, VideoForm
Expand Down Expand Up @@ -223,9 +224,19 @@ def test_func(self):
return can_approve(self.request.user)


class EntryDetailView(DetailView):
class EntryDetailView(V3Mixin, DetailView):
model = Entry
template_name = "news/detail.html"
v3_template_name = "news/v3/detail.html"

CATEGORY_LABELS = {"blogpost": "blog"}
AUTHOR_PREFETCH = ("author__badges", "author__maintainers")

def get_queryset(self):
qs = super().get_queryset()
if getattr(self, "_v3_active", False):
qs = qs.select_related("author").prefetch_related(*self.AUTHOR_PREFETCH)
return qs

def get_object(self, *args, **kwargs):
# Published news are available to anyone,
Expand All @@ -235,11 +246,64 @@ def get_object(self, *args, **kwargs):
raise Http404()
return result

def get_v3_context_data(self, **kwargs):
self.object = self.get_object()
entry = self.object
next_entry = (
Entry.objects.published()
.select_related("author")
.prefetch_related(*self.AUTHOR_PREFETCH)
.filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True)
.exclude(pk=entry.pk)
.order_by("publish_at", "pk")
.first()
)
Comment on lines +252 to +260
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think the next post should actually be the one relative to the current one, otherwise it'll always show the most recent post for all posts

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I dug into this a bit so I can better understand. I ran the same queryset in the dev shell with full timestamps. The query does return the chronologically next post, not the most recent overall.

2023-10-10 10:12:22 "The Boost.Asio property system" -> "CppCon YouTube Channel 100k Subscriber Milestone!"
2023-10-12 14:54:38 "CppCon YouTube Channel 100k Subscriber Milestone!" -> "CppCon 2023 Trip Report"
2023-10-19 17:11:06 "CppCon 2023 Trip Report" -> "Sam's Q3 Projects"
2023-10-25 17:13:24 "Sam's Q3 Projects" -> "Joaquín's Boost.Unordered Update"
2023-10-27 16:50:50 "Joaquín's Boost.Unordered Update" -> "Alan's Work on MrDocs and Handlebars"
2023-10-27 17:16:38 "Alan's Work on MrDocs and Handlebars" -> "Christian's Unordered Update"
2023-10-27 17:17:18 "Christian's Unordered Update" -> "Fernando's Adventures in Boost"
2023-10-27 17:29:32 "Fernando's Adventures in Boost" -> "Klemens Boost.Async"
2023-10-27 17:30:32 "Klemens Boost.Async" -> "Peter Turcan Documentation Status"
2023-10-28 17:20:46 "Peter Turcan Documentation Status" -> "Matt's Charconv and Decimal Update"

The 2023-10-27 cluster makes it clear: five posts within ~40 minutes, each pointing at the next neighbor by timestamp.

This direction matches v2's get_next_by_publish_at, so this feels more like a design call than a bug. Happy to flip it either way though.

Let us know whatcha think @henryajisegiri

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @julioest we can leave this for the integration ticket then. Thanks a ton for the digging. Henry is also aware we're leaving this for later.

# TODO: once Entry has a relation to libraries, scope related
# posts to those linked to the libraries referenced by this
# entry. Falls back to "any other published post" until that
# relation exists.
related_qs = (
Entry.objects.published()
.select_related("author")
.prefetch_related(*self.AUTHOR_PREFETCH)
.filter(deleted_at__isnull=True)
.exclude(pk=entry.pk)
)
Comment thread
julioest marked this conversation as resolved.
if next_entry is not None:
related_qs = related_qs.exclude(pk=next_entry.pk)
return {
"post_author": user_profile_card(entry.author),
"post_tag": self.CATEGORY_LABELS.get(entry.tag, entry.tag),
"next_post_items": (
[self._post_card_item(next_entry)] if next_entry else []
),
"related_posts": [
self._post_card_item(e)
for e in related_qs.order_by("-publish_at", "-pk")[:3]
],
}

@classmethod
def _post_card_item(cls, entry):
return {
"title": entry.title,
"description": entry.summary or "",
"url": reverse("news-detail", args=[entry.slug]),
"date": entry.publish_at,
"category": cls.CATEGORY_LABELS.get(entry.tag, entry.tag).capitalize(),
"author": user_profile_card(entry.author),
}

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
next_url = self.request.GET.get("next")
if url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
context["next_url"] = next_url
context["user_can_approve"] = self.object.can_approve(self.request.user)
context["user_can_edit"] = self.object.can_edit(self.request.user)
context["user_can_delete"] = self.object.can_delete(self.request.user)
if getattr(self, "_v3_active", False):
return context
context["next"] = get_published_or_none(self.object.get_next_by_publish_at)
context["prev"] = get_published_or_none(self.object.get_previous_by_publish_at)
if self.object.tag:
Expand All @@ -252,13 +316,11 @@ def get_context_data(self, **kwargs):
context["prev_in_category"] = get_published_or_none(
partial(self.object.get_previous_by_publish_at, **category_kwarg)
)
context["user_can_approve"] = self.object.can_approve(self.request.user)
context["user_can_edit"] = self.object.can_edit(self.request.user)
context["user_can_delete"] = self.object.can_delete(self.request.user)
return context


class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): ...
class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView):
v3_template_name = None


class EntryModerationMagicApproveView(View):
Expand Down
Loading
Loading