Creating Page Plugins¶
Section plugins (see Tutorial: Creating Plugins) own resume facts – the data, forms, inline editing, and template fragments for one section. A page plugin owns a whole page: its route, which sections it shows, access control, and the themed template that frames them.
django-resume ships three built-in pages (the cover/detail page, the CV page,
and the owner-only permission-denied editor). An installed app can add its own
pages through the same mechanism, without changing django-resume and without a
database migration – page content keeps living in the existing section plugins’
Resume.plugin_data.
How discovery works¶
On startup django_resume calls autodiscover_pages(),
which imports a resume_pages module from every installed app (the same
side-effect-import pattern Django’s admin uses for admin modules). Each
resume_pages module registers its pages with the shared page_registry.
This happens before django_resume.urls is built, so a discovered page gets
a real route automatically. You do not need to edit django-resume’s URLconf or
its built-in page list.
Define a page¶
Create resume_pages.py in your app and register a
ResumePage subclass:
# myapp/resume_pages.py
from django_resume.pages import ResumePage, page_registry
class PortfolioPage(ResumePage):
url_name = "portfolio" # reverse("resume:portfolio", ...)
path = "portfolio/" # served under "<slug:slug>/portfolio/"
template_name = "portfolio.html" # pages/{theme}/portfolio.html
section_names = ["identity", "about", "skills", "projects"]
page_registry.register(PortfolioPage)
The attributes are:
url_nameThe URL name used for
reverse(). Under the example project’s include (include("django_resume.urls", namespace="resume")) the page above isreverse("resume:portfolio", kwargs={"slug": slug}).pathThe sub-segment appended to
<slug:slug>/. Use""for the default/root page. The bare<slug:slug>/catch-all is always emitted last, so more specific paths such asportfolio/never shadow it (and it never shadows them).template_nameResolved as
django_resume/pages/{resume.current_theme}/{template_name}.section_namesThe section plugins whose context the page builds. It is an inclusion filter, not an ordering – the template decides layout by including sections by name (
{% include about.templates.main %}). Three forms are accepted:an explicit list of plugin names, e.g.
["identity", "about"];the string
"__all__"to include every registered section plugin;a capability selector,
by_capability("portfolio"), to include every plugin tagged with that capability (see Selecting sections by capability below).
Selecting sections by capability¶
Listing plugins by name couples a page to a fixed set of sections. Instead, a
page can select sections by capability: each section plugin advertises a
capabilities tuple, and by_capability(...) includes every plugin that
matches. The built-in content plugins are tagged so that, for example,
identity, about, skills, and projects all carry "portfolio":
from django_resume.pages import ResumePage, by_capability, page_registry
class PortfolioPage(ResumePage):
url_name = "portfolio"
path = "portfolio/"
template_name = "portfolio.html"
section_names = by_capability("portfolio")
nav_title = "Portfolio"
by_capability("portfolio") matches a plugin that carries any of the given
tags; pass match="all" to require all of them, and several tags to widen
the match:
section_names = by_capability("cv", "experience") # any of these
section_names = by_capability("portfolio", "cv", match="all") # all of these
Tag your own section plugin by setting its capabilities so a capability-based
page can pick it up automatically:
class TestimonialsPlugin(SimplePlugin):
name = "testimonials"
capabilities = ("portfolio",)
Selection is deterministic (registry order) and the access/UI-control plugins
(token, theme) carry no capabilities, so they are never selected as
content. Explicit lists and "__all__" keep working unchanged.
Add a template¶
Place the template in your app’s template directory so it is found via
APP_DIRS:
myapp/templates/django_resume/pages/plain/portfolio.html
Extend the theme base and include the sections you listed in section_names.
A minimal plain template:
{% extends "./base.html" %}
{% load static %}
{% block body %}
<body class="center"{% if show_edit_button %} hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'{% endif %}>
{% if is_editable %}{% include "./edit_panel.html" %}{% endif %}
<main>
{% include identity.templates.main %}
{% include about.templates.main %}
{% include skills.templates.main %}
{% include projects.templates.main with projects=projects %}
</main>
{% if show_edit_button %}
<script src="{% static "django_resume/js/edit.js" %}"></script>
{% endif %}
</body>
{% endblock body %}
If your project supports more than one theme, add a template per theme (for
example pages/headwind/portfolio.html). When a resume’s active theme does
not ship a matching page template, django-resume falls back to the plain
theme rather than raising TemplateDoesNotExist – and it renders the section
fragments through plain too, so the page stays coherent instead of mixing
themes. Shipping at least a plain template is therefore enough to keep a page
working under every theme; add per-theme templates only where you want a themed
look. (The bundled example ships both plain and headwind portfolio
templates.)
Editing and access control¶
Because the page reuses the standard section-plugin context, inline editing
works for free: open the page with ?edit=true as the resume owner and each
section renders its existing edit controls. Saving goes through the section
plugin’s normal inline URLs and persists to Resume.plugin_data. Edit
controls only appear when the authenticated user owns the resume; anonymous and
non-owner visitors see the read-only page.
Pages are public by default. To gate a page, override check_access to return
a response (to take over) or None (to proceed), and use finalize_response
for response-level concerns such as headers:
from django.http import HttpResponse
class PortfolioPage(ResumePage):
url_name = "portfolio"
path = "portfolio/"
template_name = "portfolio.html"
section_names = ["identity", "about", "skills", "projects"]
def check_access(self, request, resume):
if not request.user.is_authenticated:
return HttpResponse(status=403)
return None
Shipping a page as its own package¶
A resume_pages module is discovered only for installed apps (entries in
INSTALLED_APPS). To ship a page as a standalone distribution that any
django-resume project can pip install – without that project adding your
package to INSTALLED_APPS – register it through an importlib.metadata
entry point in the django_resume.pages group:
# pyproject.toml of your distribution
[project.entry-points."django_resume.pages"]
contact = "mypkg.pages:ContactPage"
On startup django-resume loads each entry point’s target: a
ResumePage subclass is registered directly, and a
zero-argument callable is invoked so it can register several pages itself. This
runs before the URLconf is built, so the page gets a real route just like an
autodiscovered one, and the bare <slug:slug>/ catch-all still sorts last.
Because such a package is not in INSTALLED_APPS, APP_DIRS will not
find templates inside it. Either add your template directory to the project’s
TEMPLATES DIRS, or override serve to render without a theme template:
from django.http import HttpResponse
from django.template import Context, Template
from django_resume.pages import ResumePage
_TEMPLATE = Template("<h1>Contact {{ resume.name }}</h1>")
class ContactPage(ResumePage):
url_name = "contact"
path = "contact/"
section_names = []
def serve(self, request, resume, base_context):
return HttpResponse(_TEMPLATE.render(Context(base_context)))
The bundled example/resume_entrypoint_demo distribution is exactly this: it
is installed but not a Django app, and its ContactPage is reachable at
<slug>/contact/ purely through the entry point.