Pluggable Resume Pages¶
Goal¶
Provide a first-class page API for django-resume so built-in pages and third-party pages use the same extension mechanism.
The section plugin API is good at storing and rendering resume facts. The page
API described here lifts page structure out of hard-coded
django_resume.urls / django_resume.views into registered ResumePage
classes, so built-in and third-party pages use the same mechanism.
This document is the design record for that API. The research questions have
been answered (see Research Summary), the decisions are recorded (see
Decisions), and the API is implemented in django_resume.pages; only
a small set of genuinely open questions remain (see Open Questions). What
has shipped:
The page registry and the three built-in pages behind their existing URL names.
CvPagekeepssection_names = "__all__"deliberately, because its template renders every registered content section.Third-party discovery: installed apps register pages from a
resume_pagesmodule (see Third-Party Pages), and separately distributed packages register throughimportlib.metadataentry points (see Entry-Point Pages).Explicit navigation ordering and grouping (see Navigation Ordering And Grouping), capability-based section selection (see Section Selection), and a predictable theme template fallback to
plain(see Theme Template Fallback).
The bundled example project demonstrates the third-party paths: its core
app ships an editable PortfolioPage (resume_pages autodiscovery) and the
resume_entrypoint_demo distribution ships a ContactPage (entry point).
The user-facing how-to lives in Creating Page Plugins.
Problem¶
Custom plugins can add inline URLs today, but those URLs are mainly intended for editing plugin data. They are not a clean public page API.
The cover/detail page also loads a hard-coded subset of plugins
(["about", "identity", "cover", "theme"] in resume_detail), while the
CV page loads all registered plugins. That makes custom site structure
awkward: plugin authors can add new sections, but they cannot cleanly add a
new public resume page with navigation, permissions, and themed templates.
Two pieces of current behaviour are easy to break and need to become explicit infrastructure rather than incidental side effects:
Permission enforcement is a render side effect.
resume_cvloads every plugin;TokenPlugin.get_contexthappens to callTokenPlugin.check_permissionsand raisePermissionDenied. The view catches that and re-renderscv_403.html.The 403 page depends on plugin registration order. The caught-exception path renders
cv_403.htmlwith whatever partial context was built before the token plugin raised. That template needs thepermission_deniedcontext, which is only present becausePermissionDeniedPluginis registered beforeTokenPlugininapps.py. Reordering that list would silently blank the 403 page.
Research Summary¶
Comparable projects were surveyed for how they make page structure extensible. The consistent verdict: register a page class, resolve its template by convention from the type, and emit the broad catch-all route last.
Wagtail —
Pagemodel subclasses, auto-discovered viaContentType; a tree-walking router;get_context/serve/get_templateoverride hooks;servemay return anyHttpResponse. Worth copying: the override hooks. Worth avoiding: multi-table inheritance (a DB table and a migration per page type,.specificN+1 queries, a materialized-path tree) — the most-reported pain point and unnecessary here, since resume content already lives inResume.plugin_dataJSON. https://docs.wagtail.org/en/stable/topics/pages.htmldjango CMS — apphooks mount a whole URLconf under a page;
include('cms.urls')is placed last because it matches^. Worth copying: the “broad catch-all goes last” rule. https://docs.django-cms.org/en/stable/how_to/11-apphooks.htmlMezzanine — closest match in spirit: one view plus
@processor_for(Type)functions that return a context dict or anHttpResponse; most-specific-wins template fallback. https://mezzanine.readthedocs.io/en/latest/content-architecture.htmlDjango admin — the target pattern: an
AdminSite._registrydict plusautodiscover_modulesside-effect import. django-resume’s content plugins already follow this shape.Hugo / WordPress / Flask — declare a kind / post type / blueprint once, resolve the renderer via a most-specific-wins fallback with the type slug in the lookup. Confirms the existing
pages/{theme}/{name}.htmlconvention.
Net recommendation from the research: a class registry, built-ins converted to
registered classes, and a single trailing <slug:slug>/ catch-all — giving
Wagtail/Mezzanine-grade extensibility while keeping the public surface to a
handful of names and zero migrations per page type.
Decisions¶
These decisions shaped the page API. They were deliberately conservative: an internal registry that converts the existing pages without committing to a large public API up front. The follow-on capabilities (navigation ordering/grouping, capability-based selection, theme fallback, entry-point discovery) layered onto these without revisiting them.
A separate
page_registry. Mirror the existingplugin_registry(a module-level singleton withregister/get/get_urls) rather than folding pages into a broader combined registry yet.Plain Python
ResumePageclasses, no model-per-type. Adding a new page type requires no migration. Page content keeps living in section plugins andResume.plugin_data.Built-ins registered in core, explicitly, in
AppConfig.readybeforeregister_plugins. Third-party pages are then autodiscovered from each installed app’sresume_pagesmodule, and separately distributed packages register throughimportlib.metadataentry points (see Third-Party Pages) – both run before the URLconf is built.Each page owns a
pathsubsegment under<slug:slug>/. The default/root page usespath = "".page_registry.get_urlsguarantees the bare/most-general route is emitted last by construction (sorted on path specificity), so ordering is not a registration-order convention.section_namesis an inclusion filter, not an ordering. Theme templates{% include identity.templates.main %}by name, so layout order belongs to the template. The cover page uses an explicit list; the CV page uses"__all__". Capability-tag selection (by_capability(...)) has since shipped alongside those two modes (see Section Selection); admin-configurable per-resume selection remains deferred.Access control is a page method,
check_access, that returns an optional response. Token logic stays inTokenPlugin.check_permissions(already a@staticmethod); the CV page calls it directly instead of relying on a render side effect.A
finalize_responsehook lets token-gated pages setReferrer-Policy: no-referreron the success response, not only on the 403.The CV redirect stays a plain redirect.
cv/<slug:slug>/puts the slug in a different position than the<slug:slug>/{path}shape every real page uses and renders nothing, so it is not a page. It remains a hand-written redirect entry inurls.py.Templates resolve as
django_resume/pages/{theme}/{template_name}usingresume.current_theme— the existing convention, lifted to a page-level method, with a predictable fallback toplainwhen the active theme lacks the page template (see Theme Template Fallback).
Page Interface¶
class ResumePage:
url_name: str # kept stable for reverse() and backcompat
path: str # subsegment under "<slug:slug>/"; "" = default/root
template_name: str # resolved as pages/{theme}/{template_name}
section_names: list[str] | str # inclusion filter, or "__all__"
def check_access(self, request, resume) -> HttpResponse | None:
"""Return None to proceed, or a response to short-circuit
(e.g. a 403 or a future login redirect)."""
return None
def get_context(self, request, resume) -> dict:
...
def finalize_response(self, response, request, resume) -> HttpResponse:
"""Post-process the rendered response, e.g. set headers."""
return response
The check_access contract is the Wagtail/Mezzanine “return a response to
take over, or None to proceed” pattern. It is intentionally more capable
than a boolean has_permission: it lets the CV page build and return the
themed 403 itself.
finalize_response runs on every response the dispatcher returns,
including a short-circuit response from check_access. Response-level
concerns such as headers therefore apply on the denied path too, and pages
never need to duplicate them inside check_access.
Routing¶
URL ordering is simpler than first feared. <slug:slug>/ only matches a
single path segment, so <slug:slug>/cv/ and <slug:slug>/403/ are
unambiguous against it. The only ordering requirement is that the bare
<slug:slug>/ default route is registered after the more specific
subpaths.
page_registry.get_urlsgenerates<slug:slug>/{page.path}for each registered page and emits thepath == ""(default) page last. The invariant is structural, not a hand-maintained ordering.Use
<slug:slug>(single segment), never<path:...>, so a page route cannot swallowadmin/orstatic/.Non-page routes (
list,delete) and the legacycv-redirectstay hand-written inurls.py.Existing URL names
detail,cv,cv-redirect, and403are preserved soreverse()and external links keep working.
Permissions And The 403 Page¶
This is the part the spec most needs to make explicit, because today it is an order-dependent side effect (see Problem).
CvPage.check_accesscallsTokenPlugin.check_permissions(request, resume.plugin_data.get("token", {}))up front. OnPermissionDeniedit renders the themedcv_403.htmlwith an explicitly builtpermission_deniedcontext and returns it with status 403.Because
TokenPlugin.get_contextreturns{}(it only performs the permission side effect), the token plugin no longer needs to be in the CV’s render loop at all once access is checked explicitly. The CV keepssection_names = "__all__"because its template renders every content section; a page that wants a subset can use an explicit list orby_capability(...)instead.A shared helper builds the
permission_deniedcontext for both the CV denial path and the standalone owner-only 403 editor view, so the two paths cannot drift.CvPage.finalize_responsesetsReferrer-Policy: no-referrerwhenresume.token_is_required. Because the dispatcher appliesfinalize_responseto whatever response it returns — the rendered page or the 403 returned bycheck_access— the header is set on both the success and the 403 response, as today.
Section Selection¶
section_names answers “which section plugins should this page build
context for”. It is an inclusion filter; the theme template decides order and
layout by referencing sections by name.
Three selection modes are supported, all preserving the existing behavior and URL names:
Explicit list –
["identity", "about"]– includes those plugins by name (the cover page uses this).Wildcard –
"__all__"– includes every registered plugin (the CV page uses this).Capability tags –
by_capability("portfolio")– includes every plugin whosecapabilitiesmatch. Plugins carry capability tuples (identityis tagged("identity", "contact", "portfolio", "cv"); access/UI-control plugins such astoken/themecarry none).by_capability(*tags, match="any")includes a plugin sharing at least one tag;match="all"requires every tag. Selection stays deterministic (registry order). The examplePortfolioPageselects its sections withby_capability("portfolio"), so a new portfolio-suitable plugin joins the page just by carrying the tag – no edit to the page.Much later: admin-configurable per-resume section selection, which is product/UI work, not page-API work.
Built-In Page Conversions¶
CoverLetterPage—path = "", default/root, public, sections["about", "identity", "cover", "theme"].CvPage—path = "cv/", token-gatedcheck_access, referrer-policyfinalize_response,section_names = "__all__"(its template renders every registered content section).PermissionDeniedPage—path = "403/", owner-onlycheck_access, sections["permission_denied"]. The owner-only inline editor for the denied message.
The CV redirect is not converted; it stays a plain redirect.
Third-Party Pages¶
Installed apps register their own pages without touching django-resume and without a migration:
django_resume.pages.autodiscover_pages()imports aresume_pagesmodule from every installed app on startup, mirroring the admin’sautodiscover_modulesside-effect import. Each module registers its pages withpage_registryat import time. It then callsload_entry_point_pages()so packages that are not installed Django apps can contribute pages too (see Entry-Point Pages).AppConfig.readyrunsregister_pages()(built-ins), thenautodiscover_pages()(third-partyresume_pagesmodules and entry points), thenregister_plugins(). The page registry must be complete before that last step, because the first plugin registration importsdjango_resume.urls, which evaluatespage_registry.get_urls()exactly once. A page registered after that import would not get a route.A page subclasses
ResumePage, setsurl_name/path/template_name/section_names, and ships a template per supported theme undertemplates/django_resume/pages/{theme}/{template_name}. The bare<slug:slug>/catch-all is still emitted last by construction, so a third-partypathnever shadows (or is shadowed by) the default page.A page advertises itself in navigation by setting
nav_titleand, when it is only relevant in some states, overridingis_visible(resume)(the built-in 403 editor returnsresume.token_is_required). Thepage_navtemplate tag ({% page_nav_links resume as nav_links %}) renders the registry-driven link list, so the resume overview lists every registered page’s link for a resume without hand-editing the template, and a discovered third-party page appears there automatically. Link order is explicit vianav_order(a stable sort, so equal values keep registration order) and links bucket undernav_group– see Navigation Ordering And Grouping.
The example project’s core app demonstrates this with an editable
PortfolioPage (example/core/resume_pages.py) that also appears in the
resume overview’s navigation. The authoring how-to is
Creating Page Plugins.
Entry-Point Pages¶
A package that is not an installed Django app – so its resume_pages
module is never autodiscovered – can still contribute pages through
importlib.metadata entry points. This is the path for a page shipped as its
own PyPI distribution, independent of the project’s INSTALLED_APPS.
The package declares an entry point in the
django_resume.pagesgroup:[project.entry-points."django_resume.pages"] contact = "mypkg.pages:ContactPage"
load_entry_point_pages()loads each entry point’s target and registers it: aResumePagesubclass is registered directly; a zero-argument callable is invoked so it can register its own pages; anything else raisesTypeError.It runs inside
autodiscover_pages()(afterresume_pagesmodules and beforeregister_plugins()), so entry-point pages exist before the URLconf freezes and get routes under the same trailing-catch-all ordering guarantee. Registration order is built-ins, thenresume_pagesmodules, then entry points – which is also the stable-sort tiebreaker for navigation.Because such a package is not in
INSTALLED_APPS,APP_DIRSdoes not find its templates; an entry-point page that wants themed templates must ship them on a configured template path, or overrideserveto render directly.
The bundled example/resume_entrypoint_demo distribution demonstrates this:
it is installed (editable) but absent from INSTALLED_APPS, and its
ContactPage is reachable at <slug>/contact/ purely via the entry point.
The live-server test e2e_tests/entrypoint_page_test.py proves it renders.
Theme Template Fallback¶
A page resolves its template through resolve_page_theme(resume,
template_name): it uses resume.current_theme when that theme ships the
template, and otherwise falls back to plain. So a theme that does not
provide a page template renders predictably instead of raising
TemplateDoesNotExist.
The fallback is coherent, not just frame-deep. ResumePage.get_context
resolves the theme the same way and passes it to build_section_context, so
when a page frame falls back to plain its section fragments render through
plain too – the page never mixes a plain frame with another theme’s
sections. render_cv_403 resolves once and uses that theme for both the
section context and the cv_403.html template, matching serve.
Resolution is per template, so a theme that ships some page templates but not
others falls back only for the missing ones. The plain theme short-circuits
(it is the fallback target), so the common case costs no extra loader lookup.
Open Questions¶
Genuinely future, not blocking the current (shipped) page API:
Generic page dispatcher versus directly generated routes. The registry generates routes directly today; a dispatcher is only needed if pages are later resolved by a stored type via the catch-all.
Admin-configurable per-resume section selection (see Section Selection), which is product/UI work rather than page-API work.
Whether to revisit a hook framework such as pluggy after the explicit registry settles. Current decision: stay explicit.
Relationship To Content Plugins¶
Page plugins should compose content plugins, not replace them.
Content plugins should continue to own resume facts, forms, admin integration, inline editing, and template fragments. Page plugins should own route shape, page-level context, permissions, navigation metadata, and final page template selection.
Relationship To Application Workflows¶
Application workflows will need pages eventually, but they are not pages themselves. A job-application workflow may render a tailored CV page or cover letter page, but the workflow also needs state, approvals, delivery channels, and audit history.
Relationship To JSON Resume¶
JSON Resume import/export (see JSON Resume Support Plan) is intentionally decoupled
from pages: that plan keeps page layout, routing, permission, and token state
out of exported documents, and page config lives in Python page classes rather
than in Resume.plugin_data, so there is nothing for a JSON Resume adapter
to pick up. Three boundaries keep the two plans aligned:
The page registry owns
<slug:slug>/route ordering. JSON Resume’s URL-based export/import endpoints live under the resume slug but are not pages — they render no theme or sections, so they are ordinary non-page routes likedeleteandcv-redirect. They must be emitted ahead of the bare catch-all, under the same structural ordering guarantee described in Routing.Page access and export permission are different surfaces.
check_accessgates viewing a rendered page (public detail, token-gated CV). JSON Resume export/import are data operations gated by owner/edit permission. A CV view token must never authorize an export, so an export endpoint must not reuse the CV page’scheck_access.The page dispatch defers integration state. JSON Resume adds a core-owned
Resume.integration_datafield and expects render-only querysets to defer it. The page dispatch builds the render queryset, so it is the place to apply.defer("integration_data")once that field exists, keeping page rendering cheap regardless of integration payload size.
If the deferred admin-configurable section selection (see Section
Selection) ever turns page config into per-resume data, it should reuse the
same core-owned Resume.integration_data field under a separate pages
namespace and be treated as application-private — omitted from JSON Resume
exports — consistent with that plan’s layout and permission exclusion rule.
Implementation Sketch¶
Historical record of the first slice — the internal page registry with
identical URLs and no third-party discovery yet. It shipped as described below;
resume_pages autodiscovery, entry-point discovery, navigation
ordering/grouping, capability selection, and theme fallback were layered on
afterwards (see the sections above):
Add a
pagesmodule: theResumePagebase class, aPageRegistrywithregister/get/get_urls, and a module-levelpage_registrysingleton mirroringplugin_registry.Define
CoverLetterPage,CvPage, andPermissionDeniedPageas in Built-In Page Conversions.Register the built-in pages in
AppConfig.readynext toregister_plugins.page_registry.get_urlsemits<slug:slug>/{page.path}for each page, default (path == "") last. Keeplist,delete, andcv-redirecthand-written. Preserve URL namesdetail,cv,cv-redirect,403.Reduce the existing
resume_detail/resume_cv/cv_403views to a thin dispatch: look up the page and callcheck_access; if it returns a response use that, otherwise build context viaget_contextand renderpages/{theme}/{template_name}. Applyfinalize_responseto the resulting response on both paths — so a denied CV still gets itsReferrer-Policyheader — then return it.Extract a shared
permission_deniedcontext helper used by both the CV denial path and the standalone 403 editor.Tests: URL names still resolve; cover/detail is public; CV is gated (valid / expired / missing token → themed 403 carrying the
permission_deniedcontent andReferrer-Policy: no-referrer); the 403 editor is owner-only; themed template resolution picks the right theme.