Skip to content

Commit 22662c9

Browse files
authored
Story 2370: V3 Migration View Registry (boostorg#2308)
1 parent d907ac8 commit 22662c9

8 files changed

Lines changed: 259 additions & 46 deletions

File tree

config/urls.py

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22

33
from django.conf import settings
4-
from django.contrib.admin.views.decorators import staff_member_required
54
from django.conf.urls.static import static
65
from django.contrib import admin
76
from django.urls import include, path, re_path, register_converter, reverse_lazy
@@ -20,6 +19,7 @@
2019
OKView,
2120
)
2221
from config.settings import DEBUG_TOOLBAR
22+
from config.v3_urls import v3_urlpatterns
2323
from core.views import (
2424
BSLView,
2525
BoostDevelopmentView,
@@ -30,8 +30,6 @@
3030
MarkdownTemplateView,
3131
TermsOfUseView,
3232
PrivacyPolicyView,
33-
V3ComponentDemoView,
34-
LearnPageView,
3533
ModernizedDocsView,
3634
RedirectToDocsView,
3735
RedirectToHTMLDocsView,
@@ -72,7 +70,6 @@
7270
NewsListView,
7371
PollCreateView,
7472
PollListView,
75-
V3AllTypesCreateView,
7673
VideoCreateView,
7774
VideoListView,
7875
)
@@ -83,8 +80,6 @@
8380
CustomLoginView,
8481
CustomSignupView,
8582
CustomSocialSignupViewView,
86-
V3LoginView,
87-
V3SignupView,
8883
UserViewSet,
8984
UserAvatar,
9085
DeleteUserView,
@@ -251,26 +246,6 @@
251246
TemplateView.as_view(template_name="style_guide.html"),
252247
name="style-guide",
253248
),
254-
path(
255-
"v3/demo/components/",
256-
staff_member_required(V3ComponentDemoView.as_view()),
257-
name="v3-demo-components",
258-
),
259-
path(
260-
"v3/demo/learn-page/",
261-
staff_member_required(LearnPageView.as_view()),
262-
name="v3-learn-page",
263-
),
264-
path(
265-
"v3/accounts/signup/",
266-
V3SignupView.as_view(),
267-
name="v3-signup",
268-
),
269-
path(
270-
"v3/accounts/login/",
271-
V3LoginView.as_view(),
272-
name="v3-login",
273-
),
274249
path("libraries/", LibraryListDispatcher.as_view(), name="libraries"),
275250
path(
276251
"libraries/<boostversionslug:version_slug>/<str:library_view_str>/",
@@ -329,7 +304,6 @@
329304
path("news/add/link/", LinkCreateView.as_view(), name="news-link-create"),
330305
path("news/add/poll/", PollCreateView.as_view(), name="news-poll-create"),
331306
path("news/add/video/", VideoCreateView.as_view(), name="news-video-create"),
332-
path("v3/news/add/", V3AllTypesCreateView.as_view(), name="v3-news-create"),
333307
path("news/moderate/", EntryModerationListView.as_view(), name="news-moderate"),
334308
path(
335309
"news/moderate/<slug:slug>/",
@@ -448,6 +422,7 @@
448422
# Custom Django views (must come before Wagtail catch-all)
449423
path("testimonials/", include("testimonials.urls")),
450424
]
425+
+ v3_urlpatterns
451426
+ [
452427
re_path(
453428
r"^lib/(?P<library_slug>[^/]+)/?$",

config/v3_urls.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
V3 URL Registry
3+
===============
4+
5+
All URLs with the explicit `/v3/` prefix live here.
6+
7+
All V3 views inherit from `V3Mixin` (see `core/mixins.py`). The test
8+
`core/tests/test_v3_registry.py` auto-discovers all `V3Mixin`
9+
subclasses via the URL resolver and verifies their templates exist — no
10+
manual registration needed.
11+
12+
`V3Mixin` handles two cases based on class attributes:
13+
14+
* **Both templates** (`template_name` + `v3_template_name`) — legacy
15+
template by default, V3 template when the `v3` waffle flag is active.
16+
* **V3 template only** (`v3_template_name`, no `template_name`) —
17+
returns 404 when the flag is off.
18+
19+
Full-migration procedure
20+
------------------------
21+
The `v3` waffle flag and `V3Mixin` are migration scaffolding.
22+
23+
A. Convert each view that has both templates
24+
1. Set `template_name` to the V3 template path.
25+
2. Fold `get_v3_context_data` into `get_context_data`.
26+
3. Remove `V3Mixin` from the class bases.
27+
4. Remove `v3_template_name` and `get_v3_context_data`.
28+
5. Delete the legacy template.
29+
30+
B. Promote each `/v3/` route
31+
For each route in this file, move it into `config/urls.py` to
32+
replace the legacy route (or create a permanent route if none
33+
exists). Delete or rename the V3 view class as appropriate.
34+
35+
C. Remove the flag from templates and JS
36+
Unwrap every `{% flag "v3" %}` block and drop matching JS gates.
37+
38+
D. Delete the scaffolding
39+
1. `V3Mixin` from `core/mixins.py`.
40+
2. `core/tests/test_v3_registry.py`.
41+
3. The `v3` waffle flag record from the database.
42+
4. This file and its import in `config/urls.py`.
43+
44+
See `docs/django-waffle-v3-flag.md` for additional flag context.
45+
"""
46+
47+
from django.contrib.admin.views.decorators import staff_member_required
48+
from django.urls import path
49+
50+
from core.views import LearnPageView, V3ComponentDemoView
51+
from news.views import V3AllTypesCreateView
52+
from users.views import V3LoginView, V3SignupView
53+
54+
v3_urlpatterns = [
55+
path(
56+
"v3/demo/components/",
57+
staff_member_required(V3ComponentDemoView.as_view()),
58+
name="v3-demo-components",
59+
),
60+
path(
61+
"v3/demo/learn-page/",
62+
staff_member_required(LearnPageView.as_view()),
63+
name="v3-learn-page",
64+
),
65+
path(
66+
"v3/news/add/",
67+
V3AllTypesCreateView.as_view(),
68+
name="v3-news-create",
69+
),
70+
path(
71+
"v3/accounts/signup/",
72+
V3SignupView.as_view(),
73+
name="v3-signup",
74+
),
75+
path(
76+
"v3/accounts/login/",
77+
V3LoginView.as_view(),
78+
name="v3-login",
79+
),
80+
]

core/mixins.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from django.http import Http404
2+
from django.urls import URLPattern, URLResolver, get_resolver
13
from waffle import flag_is_active
24

35

@@ -11,6 +13,9 @@ class V3Mixin:
1113
v3_template_name: str — template to render when v3 is active
1214
1315
And override get_v3_context_data() to supply view-specific context.
16+
17+
When the flag is off and no legacy template_name exists (i.e. a
18+
V3-only view), dispatch returns 404.
1419
"""
1520

1621
v3_template_name = None
@@ -20,6 +25,8 @@ def dispatch(self, request, *args, **kwargs):
2025
self._v3_active = True
2126
return self.render_v3_response()
2227
self._v3_active = False
28+
if not getattr(self, "template_name", None):
29+
raise Http404
2330
return super().dispatch(request, *args, **kwargs)
2431

2532
def get_v3_context_data(self, **kwargs):
@@ -35,3 +42,24 @@ def get_template_names(self):
3542
if getattr(self, "_v3_active", False):
3643
return [self.v3_template_name]
3744
return super().get_template_names()
45+
46+
47+
def iter_v3_views():
48+
"""Yield (URLPattern, view_class) for every V3Mixin view in the URL conf."""
49+
50+
def walk(patterns):
51+
for entry in patterns:
52+
if isinstance(entry, URLResolver):
53+
yield from walk(entry.url_patterns)
54+
elif isinstance(entry, URLPattern):
55+
callback = entry.callback
56+
view_class = None
57+
while callback is not None:
58+
view_class = getattr(callback, "view_class", None)
59+
if view_class is not None:
60+
break
61+
callback = getattr(callback, "__wrapped__", None)
62+
if view_class and issubclass(view_class, V3Mixin):
63+
yield entry, view_class
64+
65+
yield from walk(get_resolver().url_patterns)

core/tests/test_v3_registry.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Drift checks for V3 views.
2+
3+
V3 views are discovered automatically by walking Django's URL resolver
4+
and finding every `V3Mixin` subclass — no manual list to maintain.
5+
6+
Two tests run against the discovered set: one verifies at least one V3 view
7+
exists, and a parametrized one verifies template paths are valid.
8+
9+
This whole module gets deleted when the migration ends — see
10+
`config/v3_urls.py` for teardown steps.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import pytest
16+
from django.template import TemplateDoesNotExist
17+
from django.template.loader import get_template
18+
19+
from core.mixins import iter_v3_views
20+
21+
22+
def _get_v3_view_classes() -> set[type]:
23+
return {view_class for _, view_class in iter_v3_views()}
24+
25+
26+
@pytest.fixture(scope="session")
27+
def v3_view_classes():
28+
return sorted(_get_v3_view_classes(), key=lambda c: c.__name__)
29+
30+
31+
def test_v3_views_discovered(v3_view_classes):
32+
"""The resolver must find at least one V3 view."""
33+
assert v3_view_classes, "No V3Mixin subclasses found in URL conf"
34+
35+
36+
@pytest.mark.parametrize(
37+
"view_class",
38+
_get_v3_view_classes(),
39+
ids=lambda c: c.__name__,
40+
)
41+
def test_v3_template_exists(view_class):
42+
"""Every V3 view must point to a `v3_template_name` that Django can load."""
43+
template = getattr(view_class, "v3_template_name", None)
44+
assert template, f"{view_class.__name__}: no v3_template_name set"
45+
try:
46+
get_template(template)
47+
except TemplateDoesNotExist:
48+
pytest.fail(f"{view_class.__name__}: template {template!r} not found")

core/views.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from textwrap import dedent
88
from urllib.parse import urljoin
9-
from waffle import flag_is_active
109

1110
import structlog
1211
from bs4 import BeautifulSoup
@@ -43,7 +42,7 @@
4342
)
4443
from versions.models import Version, docs_path_to_boost_name
4544

46-
from .mixins import V3Mixin
45+
from .mixins import V3Mixin, iter_v3_views
4746
from .asciidoc import convert_adoc_to_html
4847
from .boostrenderer import (
4948
convert_img_paths,
@@ -280,11 +279,6 @@ def get_v3_context_data(self, **kwargs):
280279
class LearnPageView(V3Mixin, TemplateView):
281280
v3_template_name = "v3/learn_page.html"
282281

283-
def dispatch(self, request, *args, **kwargs):
284-
if not flag_is_active(request, "v3"):
285-
return HttpResponseNotFound()
286-
return super().dispatch(request, *args, **kwargs)
287-
288282
def get_v3_context_data(self, **kwargs):
289283
ctx = self.get_context_data(**kwargs)
290284
ctx["learn_card_data"] = [
@@ -1247,10 +1241,10 @@ def get(self, request: HttpRequest, campaign_identifier: str, main_path: str = "
12471241
return HttpResponseRedirect(redirect_path)
12481242

12491243

1250-
class V3ComponentDemoView(TemplateView):
1244+
class V3ComponentDemoView(V3Mixin, TemplateView):
12511245
"""Demo page for V3 design system components."""
12521246

1253-
template_name = "base.html"
1247+
v3_template_name = "base.html"
12541248

12551249
def get_context_data(self, **kwargs):
12561250
from django.urls import reverse
@@ -1913,4 +1907,18 @@ def get_context_data(self, **kwargs):
19131907
)
19141908
context["demo_library_items"] = demo_library_items
19151909

1910+
# V3 paths registry
1911+
v3_paths = [
1912+
{
1913+
"class_name": view_class.__name__,
1914+
"url": f"/{entry.pattern}",
1915+
"url_name": entry.name or "",
1916+
"v3_template": getattr(view_class, "v3_template_name", ""),
1917+
"has_legacy_template": bool(getattr(view_class, "template_name", None)),
1918+
}
1919+
for entry, view_class in iter_v3_views()
1920+
]
1921+
v3_paths.sort(key=lambda p: p["class_name"])
1922+
context["v3_paths"] = v3_paths
1923+
19161924
return context

news/views.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from django.views.generic.detail import SingleObjectMixin
2828
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData
2929

30+
from core.mixins import V3Mixin
3031
from .acl import can_approve
3132
from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION
3233
from .forms import BlogPostForm, EntryForm, LinkForm, NewsForm, PollForm, VideoForm
@@ -341,8 +342,8 @@ def get_context_data(self, **kwargs):
341342
return context
342343

343344

344-
class V3AllTypesCreateView(AllTypesCreateView):
345-
template_name = "news/v3/create.html"
345+
class V3AllTypesCreateView(V3Mixin, AllTypesCreateView):
346+
v3_template_name = "news/v3/create.html"
346347
http_method_names = ["get", "post"]
347348

348349
_POST_TYPE_MAP = {
@@ -352,6 +353,13 @@ class V3AllTypesCreateView(AllTypesCreateView):
352353
"video": (Video, VideoForm),
353354
}
354355

356+
def dispatch(self, request, *args, **kwargs):
357+
# Run AllTypesCreateView's profile-completeness guard before V3Mixin takes over.
358+
response = AllTypesCreateView.dispatch(self, request, *args, **kwargs)
359+
if response.status_code != 200:
360+
return response
361+
return super().dispatch(request, *args, **kwargs)
362+
355363
def get_context_data(self, **kwargs):
356364
context = super().get_context_data(**kwargs)
357365
context.update(_v3_create_context())

0 commit comments

Comments
 (0)