Django Website Widgets
A reusable Django app for building component-based web interfaces with widgets and pages.
Installation
Requires Python 3.12+ and Django 5.2+.
pip install django-website-widgets
Or with Poetry:
poetry add django-website-widgets
Quick Start
- Add to
INSTALLED_APPSinsettings.py:
INSTALLED_APPS = [
...
'website_widgets',
]
- Create a document template for all pages to use: the whole HTML document,
<head>included. Customize it as needed with stylesheets, an appbar, a footer, etc.
website/document_template.html
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.title|default:"Foo" }}</title>
<link rel="stylesheet" href="{% static 'styles.css' %}">
</head>
<body>
{{ children.appbar }}
<main id="main-content">
{{ page_content_html }}
</main>
</body>
</html>
- Create a
Pagesubclass for every other page to extend, pointing at that template:
website/app_page.py
import os
from website_widgets import Page
from website.widgets.appbar import Appbar
class AppPage(Page):
# Absolute: a relative path resolves beside each concrete page, not beside this file.
document_template_path = os.path.join(os.path.dirname(__file__), 'document_template.html')
async def set_context(self):
# Construct widget - system will render it automatically
self.context.children['appbar'] = Appbar()
A page renders two templates against the same context: its own, then the document template, which
receives the first as {{ page_content_html }}. So children.appbar can render in either one.
Here it sits outside the page's content but still reads context.page, which every page fills
before either template renders:
website/widgets/appbar/appbar.html
<header class="appbar">
<a href="/">Home</a>
<span>{{ page.title }}</span>
</header>
Where the appbar goes is the project's choice: every page can set its own
children['appbar'], a page can leave it out, or the document template can hold the markup
directly.
- Create a widget and a template next to it, named after the widget's Python file:
website/widgets/project_details_card/project_details_card.py
from website_widgets import Widget
from project.models import Project
class ProjectDetailsCard(Widget):
"""Widget that displays project details."""
def __init__(self, project: Project):
super().__init__()
self.context.data['project'] = project
def set_context(self):
"""Called before rendering. Can be sync or async."""
super().set_context()
website/widgets/project_details_card/project_details_card.html
<div class="project-details-card">
<p>Name: {{ data.project.name }}</p>
</div>
- Create a page and its template the same way:
website/pages/project_page/project_page.py
from project.models import Project
from website.app_page import AppPage
from website.widgets.project_details_card import ProjectDetailsCard
class ProjectPage(AppPage):
"""Page that displays a single project."""
def __init__(self, pk: int):
super().__init__()
self.pk = pk
async def set_context(self):
await super().set_context()
project = await Project.objects.aget(pk=self.pk)
self.context.page['title'] = f'Project - {project.name}'
self.context.data['project'] = project
# Construct widgets - system will render them automatically
self.context.children['project_details_card'] = ProjectDetailsCard(
project=project
)
website/pages/project_page/project_page.html
<div id="project-details-page">
{{ children.project_details_card }}
<p>Other content here</p>
</div>
- Register in
urls.py:
from django.urls import path
from .pages.project_page import ProjectPage
urlpatterns = [
path('project/<int:pk>', ProjectPage.as_view(), name='project_page'),
]
Core Concepts
Widget
Base class for reusable UI components. Uses a declarative, constructor-based pattern similar to Flutter or React.
Key Pattern:
- Accept data via
__init__()parameters - Store data on instance variables
- Implement
set_context()to populateself.context.dataand create child widgets - System automatically renders widgets after initialization
Example:
class InfoCard(Widget):
def __init__(self, title: str, content: str):
super().__init__()
self.title = title
self.content = content
def set_context(self):
self.context.data['title'] = self.title
self.context.data['content'] = self.content
Widgets:
- Accept data parameters in
__init__()and store them - Use
set_context()to create child widgets (Widget objects or lists) - Store children in
self.context.children- system renders them - Access
self.requestandself.request.userfromset_context()onwards (not in__init__()) - Can be sync or async - use
async def set_context(self):only when awaiting. Rendering always runs in an async view, so database access in a syncset_context()raisesSynchronousOnlyOperation; use the async ORM
Page
Extends Widget and represents a full page.
Key Pattern:
- URL parameters passed to
__init__()- store them on self set_context()loads data and creates child widgets- Assign Widget objects (or lists) to
self.context.children - System calls
set_context()recursively, then renders all widgets
Example with URL parameters:
class ProfilePage(AppPage):
def __init__(self, username: str):
super().__init__()
self.username = username
async def set_context(self):
await super().set_context()
user = await User.objects.aget(username=self.username)
self.context.page['title'] = f'Profile: {user.username}'
# Construct widgets; the page renders them
self.context.children['profile_card'] = ProfileCard(user=user)
Pages:
- Set
document_template_pathfor the HTML document wrapping their own template - Receive URL parameters in
__init__() - Use
set_context()to load data and construct child widgets - Route with
@register_url(see below), or by hand inurls.pywith.as_view() - Override
handle_request()for authentication, redirects or conditional responses, andbuild_response()for headers
WidgetContext
Each widget has self.context, a WidgetContext, with:
data: Template variables (dict)children: Child widgets - values can be Widget, list[Widget], or Nonepage: Shared page-level data (title, meta, etc.)
System renders widgets to HTML automatically. Lists iterate in templates via {% for item in children.items %}.
Rendering a widget on its own
Outside a page, for an endpoint returning HTML fragments (an infinite scroller, a "load more" button), construct the widget, adjust it, and render it once:
widget = ProjectListTile(project=project)
widget.context.page['nonce'] = nonce
html = await widget.render__standalone(request)
Tests render a widget directly against a mock request, optionally as a given user:
html = await ProjectListTile(project=project).render__test(user=user) # async def test
html = ProjectListTile(project=project).render__test_sync(user=user) # def test
Each runs the widget's set_context() and every descendant's, then the templates. No document
template wraps the result.
Routing pages with @register_url
A page can declare its own routes instead of being listed in urls.py. The decorator takes
path()'s arguments, and stacks one per route:
from website_widgets import register_url
@register_url('')
class HomePage(AppPage): ...
@register_url('forum/<int:post_id>', name='forum_post')
@register_url('tab-requests/<int:post_id>', name='tab_request')
class ForumPostPage(AppPage): ...
@register_url('forum/create-post', name='forum_create_post', kwargs={'post_type': 'forum_post'})
class ForumCreatePostPage(AppPage): ...
Then include every decorated page with one line, the way the admin is included:
from website_widgets import PageRoutes
urlpatterns = [
path('', PageRoutes.urls()),
...
]
- Pages are found by importing every module under the website app's
pages/folder, the app beingWebsiteWidgetsConfig.website_app_name(default'website'). A page elsewhere isn't found. - Routes live in a namespace named after that app.
namedefaults to the class name, soHomePagereverses asreverse('website:HomePage'), andforum_postas'website:forum_post'. kwargsreach the page's constructor alongside the URL's parameters.- Routes are tried most specific first, never in file order. They are compared segment by
segment: a fixed segment comes before
int, thenuuid,slug,strandpath, and equally specific routes fall back to alphabetical order. Soprofile/settingsis always tried beforeprofile/<str:username>, wherever either is declared. - Two equally specific routes that can match the same URL fail a system check
(
website_widgets.E001), such asa/<str:x>anda/<str:y>. - A decorated route is an ordinary
path().reverse(),{% url %},resolve()and URL tools such as django-extensions'show_urlsall see it. Routing a page by hand withpath('...', MyPage.as_view(), name='...')still works, and the two mix freely; a hand-routed page's name sits outside the namespace.
To see the order routes are tried in, and check that nothing clobbers them, run
show_page_resolve_order:
$ python manage.py show_page_resolve_order
# URL Segments Page
1 /moderation/reports/<int:report_id> f/f/i ViewReportPage
...
15 /profile/<str:username> f/s UserProfilePage
...
17 / f HomePage
Segments: f fixed, i int, s str, g slug, u uuid, p path, and a project converter by its name.
All 37 page routes reach their page.
For each route it reverses a sample URL (/profile/a) and resolves it through the whole project,
so it also catches a route that never reaches its page because something earlier in urls.py
takes its URLs, such as a hand-written route or a catch-all, or because PageRoutes.urls() isn't
included. A route that fails prints in red with the reason, and the command exits non-zero, so it
can run in CI.
--columnspicks the columns after#, in order, fromurl,segments,name,page,reaches_page(defaulturl,segments,page). The reachability check runs either way.--path-param-typeshows a path parameter as declared (full, the default:<str:game_slug>), as its converter (type:<str>) or as the sample value the check uses (example:a).
URL templates for the frontend
reverse_template() stands in for reverse() when the frontend fills in the parameters. It
reverses a URL name into a template with a {name} placeholder per route parameter:
from website_widgets import reverse_template
# path('projects/<int:project_id>/tasks/<slug:task_slug>', ..., name='project_task')
reverse_template('project_task') # '/projects/{project_id}/tasks/{task_slug}'
reverse_template('project_list') # '/projects', as reverse() returns it
Placeholders take the route's own parameter names, and namespaces, include() prefixes and the
script prefix apply as they do in reverse(). Converters don't run, so a placeholder is accepted
wherever a parameter goes. Pass paramnames to rename the placeholders in route order, which a
re_path() with unnamed groups needs: reverse_template('legacy', ['legacy_id']). A name whose
routes take different parameters raises ValueError unless paramnames picks one by count.
Templates
A widget's template sits next to its Python file and shares its name: my_widget.py renders
my_widget.html. Set template_filename to use a different file, either on the class or in
set_context() to pick between files at runtime.
template_filename is a bare file name, never a path. A template lives beside the code that
chooses it, so a /, a \ or .. raises ValueError. The file is looked up beside the class's
own .py file first, then beside each base class's file in inheritance order, and the first match
wins. That lets a base class keep a variant beside its own module and switch any subclass to it.
A name found nowhere raises FileNotFoundError listing every folder searched.
class ProjectPage(AppPage):
async def set_context(self):
await super().set_context()
project = await Project.objects.filter(pk=self.pk).afirst()
if project is None:
self.status_code = 404
self.template_filename = 'project_page__not_found.html'
return
...
Suggested convention: name a variant <module>__<variant>.html, with a double underscore,
beside the main template:
project_page/
project_page.py
project_page.html
project_page__not_found.html
project_page__archived.html
The double underscore marks where the module name ends, the way it separates the parts of a Django
lookup (author__name__startswith): module and variant names both contain single underscores, so
one alone can't. The prefix keeps every variant sorted next to the template it replaces, and an
editor tab or a search result names its component without the folder. Give the variant the same
root element as the main template, so the component's styles still apply to it.
A page's document template is the one template set by path: document_template_path names a file
shared by every page, so it lives beside the shared Page subclass rather than beside any one page.
Override get_template_path() when the choice needs more than an attribute.
<div class="my-widget">
<h1>{{ data.title }}</h1>
{{ children.my_widget }}
</div>
Templates are opened directly rather than through Django's template loader, so context processors
do not run. The context holds only request, user, page, data and children. See
docs/rendering_pipeline.md for the full render order.
Configuration
Configure django-website-widgets in your settings.py:
from website_widgets.config import WebsiteWidgetsConfig
WEBSITE_WIDGETS = WebsiteWidgetsConfig(
show_debug_tree=True, # Show widget tree visualization on pages (default: False)
website_app_name='website', # Label of the app holding your pages/ (default: 'website')
)
If WEBSITE_WIDGETS is not defined, a default configuration is used automatically. The setting is
read once per process, so changing it at runtime has no effect.
Debug Tree Visualization
Enable show_debug_tree=True to display a visual representation of your widget hierarchy after each page's main content. This is useful during development to understand the structure of your widget tree.
Example output:
CompositionPage
stylesheet:
AdvancedStylesheet
constructor_card:
InfoCard
list_cards: [
InfoCard
InfoCard
InfoCard
]
skipped_card: None
page_title_note:
PageTitleNote
Adding Debug Labels
For better debugging, add a _debug_label to your widgets:
class InfoCard(Widget):
def __init__(self, title: str, content: str):
super().__init__()
self._debug_label = title # Shows as "InfoCard(My Title)" in debug tree
self.title = title
self.content = content
Without _debug_label, widgets display only their class name (e.g., InfoCard). With the label, they display as InfoCard(My Title), making it easier to identify specific widget instances in complex trees.
Recommendation: Only enable show_debug_tree in development environments, not in production.
Management Commands
Check installation:
python manage.py test_website_widgets --verbose
List the @register_url routes in the order they are tried, and check each reaches its page
(see "Routing pages with @register_url"):
python manage.py show_page_resolve_order
python manage.py show_page_resolve_order --columns=url,name,page,reaches_page --path-param-type=type
License
MIT
Metadata
Release files for django-website-widgets 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_website_widgets-0.1.0.tar.gz | 30.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_website_widgets-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 59.5 kB
Release files / django_website_widgets-0.1.0.tar.gz
| Download URL | django_website_widgets-0.1.0.tar.gz |
|---|---|
| Size | 30.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
56516b7d58d5f3b3be1fb81968c7aefd020e4e7aceaf84faf3e0222ca1ea6577
|
|
BLAKE2b-256 checksum How to use checksums |
450086f52d52b8008744beb27a7d53f7d1f4f6e2397a9b5f88da8ca9df0da853
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.8 Linux/7.2.8-200.fc44.x86_64
|
Release files / django_website_widgets-0.1.0-py3-none-any.whl
| Download URL | django_website_widgets-0.1.0-py3-none-any.whl |
|---|---|
| Size | 29.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ecc9f007f653a5641d5ea5f06255debd682f666e4d54d2b6bf318c987d6cc6c5
|
|
BLAKE2b-256 checksum How to use checksums |
d8788dcb1595e02bd214cdc518212d83f966ff3e6ded77c09cea9b6b2eef3dff
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.8 Linux/7.2.8-200.fc44.x86_64
|