django-website-widgets 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_website_widgets-0.1.0.dist-info/METADATA +514 -0
- django_website_widgets-0.1.0.dist-info/RECORD +17 -0
- django_website_widgets-0.1.0.dist-info/WHEEL +4 -0
- django_website_widgets-0.1.0.dist-info/licenses/LICENSE +21 -0
- website_widgets/__init__.py +16 -0
- website_widgets/apps.py +13 -0
- website_widgets/config.py +36 -0
- website_widgets/management/__init__.py +0 -0
- website_widgets/management/commands/__init__.py +0 -0
- website_widgets/management/commands/show_page_resolve_order.py +170 -0
- website_widgets/management/commands/test_website_widgets.py +53 -0
- website_widgets/page.py +114 -0
- website_widgets/page_routes.py +240 -0
- website_widgets/tests.py +634 -0
- website_widgets/url_templates.py +66 -0
- website_widgets/widget.py +290 -0
- website_widgets/widget_context.py +19 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-website-widgets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-rendered pages built from composable widgets, for Django
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: django,widgets,components,pages
|
|
8
|
+
Author: Nate Brooks
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
|
+
Classifier: Framework :: Django :: 6.0
|
|
15
|
+
Classifier: Framework :: Django :: 6.1
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
25
|
+
Requires-Dist: django (>=5.2)
|
|
26
|
+
Project-URL: Homepage, https://github.com/nateonguitar/django-website-widgets
|
|
27
|
+
Project-URL: Issues, https://github.com/nateonguitar/django-website-widgets/issues
|
|
28
|
+
Project-URL: Repository, https://github.com/nateonguitar/django-website-widgets
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Django Website Widgets
|
|
32
|
+
|
|
33
|
+
A reusable Django app for building component-based web interfaces with widgets and pages.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Requires Python 3.12+ and Django 5.2+.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install django-website-widgets
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or with Poetry:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
poetry add django-website-widgets
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
1. Add to `INSTALLED_APPS` in `settings.py`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
INSTALLED_APPS = [
|
|
55
|
+
...
|
|
56
|
+
'website_widgets',
|
|
57
|
+
]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
2. Create a document template for all pages to use: the whole HTML document, `<head>` included.
|
|
61
|
+
Customize it as needed with stylesheets, an appbar, a footer, etc.
|
|
62
|
+
|
|
63
|
+
`website/document_template.html`
|
|
64
|
+
```html
|
|
65
|
+
{% load static %}
|
|
66
|
+
<!DOCTYPE html>
|
|
67
|
+
<html lang="en">
|
|
68
|
+
<head>
|
|
69
|
+
<meta charset="UTF-8">
|
|
70
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
71
|
+
<title>{{ page.title|default:"Foo" }}</title>
|
|
72
|
+
<link rel="stylesheet" href="{% static 'styles.css' %}">
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
{{ children.appbar }}
|
|
76
|
+
|
|
77
|
+
<main id="main-content">
|
|
78
|
+
{{ page_content_html }}
|
|
79
|
+
</main>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
3. Create a `Page` subclass for every other page to extend, pointing at that template:
|
|
86
|
+
|
|
87
|
+
`website/app_page.py`
|
|
88
|
+
```python
|
|
89
|
+
import os
|
|
90
|
+
|
|
91
|
+
from website_widgets import Page
|
|
92
|
+
from website.widgets.appbar import Appbar
|
|
93
|
+
|
|
94
|
+
class AppPage(Page):
|
|
95
|
+
# Absolute: a relative path resolves beside each concrete page, not beside this file.
|
|
96
|
+
document_template_path = os.path.join(os.path.dirname(__file__), 'document_template.html')
|
|
97
|
+
|
|
98
|
+
async def set_context(self):
|
|
99
|
+
# Construct widget - system will render it automatically
|
|
100
|
+
self.context.children['appbar'] = Appbar()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A page renders two templates against the same context: its own, then the document template, which
|
|
104
|
+
receives the first as `{{ page_content_html }}`. So `children.appbar` can render in either one.
|
|
105
|
+
Here it sits outside the page's content but still reads `context.page`, which every page fills
|
|
106
|
+
before either template renders:
|
|
107
|
+
|
|
108
|
+
`website/widgets/appbar/appbar.html`
|
|
109
|
+
```html
|
|
110
|
+
<header class="appbar">
|
|
111
|
+
<a href="/">Home</a>
|
|
112
|
+
<span>{{ page.title }}</span>
|
|
113
|
+
</header>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Where the appbar goes is the project's choice: every page can set its own
|
|
117
|
+
`children['appbar']`, a page can leave it out, or the document template can hold the markup
|
|
118
|
+
directly.
|
|
119
|
+
|
|
120
|
+
4. Create a widget and a template next to it, named after the widget's Python file:
|
|
121
|
+
|
|
122
|
+
`website/widgets/project_details_card/project_details_card.py`
|
|
123
|
+
```python
|
|
124
|
+
from website_widgets import Widget
|
|
125
|
+
from project.models import Project
|
|
126
|
+
|
|
127
|
+
class ProjectDetailsCard(Widget):
|
|
128
|
+
"""Widget that displays project details."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, project: Project):
|
|
131
|
+
super().__init__()
|
|
132
|
+
self.context.data['project'] = project
|
|
133
|
+
|
|
134
|
+
def set_context(self):
|
|
135
|
+
"""Called before rendering. Can be sync or async."""
|
|
136
|
+
super().set_context()
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`website/widgets/project_details_card/project_details_card.html`
|
|
140
|
+
```html
|
|
141
|
+
<div class="project-details-card">
|
|
142
|
+
<p>Name: {{ data.project.name }}</p>
|
|
143
|
+
</div>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
5. Create a page and its template the same way:
|
|
147
|
+
|
|
148
|
+
`website/pages/project_page/project_page.py`
|
|
149
|
+
```python
|
|
150
|
+
from project.models import Project
|
|
151
|
+
from website.app_page import AppPage
|
|
152
|
+
from website.widgets.project_details_card import ProjectDetailsCard
|
|
153
|
+
|
|
154
|
+
class ProjectPage(AppPage):
|
|
155
|
+
"""Page that displays a single project."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, pk: int):
|
|
158
|
+
super().__init__()
|
|
159
|
+
self.pk = pk
|
|
160
|
+
|
|
161
|
+
async def set_context(self):
|
|
162
|
+
await super().set_context()
|
|
163
|
+
|
|
164
|
+
project = await Project.objects.aget(pk=self.pk)
|
|
165
|
+
self.context.page['title'] = f'Project - {project.name}'
|
|
166
|
+
self.context.data['project'] = project
|
|
167
|
+
|
|
168
|
+
# Construct widgets - system will render them automatically
|
|
169
|
+
self.context.children['project_details_card'] = ProjectDetailsCard(
|
|
170
|
+
project=project
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
`website/pages/project_page/project_page.html`
|
|
176
|
+
```html
|
|
177
|
+
<div id="project-details-page">
|
|
178
|
+
{{ children.project_details_card }}
|
|
179
|
+
|
|
180
|
+
<p>Other content here</p>
|
|
181
|
+
</div>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
6. Register in `urls.py`:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from django.urls import path
|
|
188
|
+
from .pages.project_page import ProjectPage
|
|
189
|
+
|
|
190
|
+
urlpatterns = [
|
|
191
|
+
path('project/<int:pk>', ProjectPage.as_view(), name='project_page'),
|
|
192
|
+
]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Core Concepts
|
|
196
|
+
|
|
197
|
+
### Widget
|
|
198
|
+
|
|
199
|
+
Base class for reusable UI components. Uses a declarative, constructor-based pattern similar to Flutter or React.
|
|
200
|
+
|
|
201
|
+
**Key Pattern:**
|
|
202
|
+
- Accept data via `__init__()` parameters
|
|
203
|
+
- Store data on instance variables
|
|
204
|
+
- Implement `set_context()` to populate `self.context.data` and create child widgets
|
|
205
|
+
- System automatically renders widgets after initialization
|
|
206
|
+
|
|
207
|
+
**Example:**
|
|
208
|
+
```python
|
|
209
|
+
class InfoCard(Widget):
|
|
210
|
+
def __init__(self, title: str, content: str):
|
|
211
|
+
super().__init__()
|
|
212
|
+
self.title = title
|
|
213
|
+
self.content = content
|
|
214
|
+
|
|
215
|
+
def set_context(self):
|
|
216
|
+
self.context.data['title'] = self.title
|
|
217
|
+
self.context.data['content'] = self.content
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Widgets:
|
|
221
|
+
- Accept data parameters in `__init__()` and store them
|
|
222
|
+
- Use `set_context()` to create child widgets (Widget objects or lists)
|
|
223
|
+
- Store children in `self.context.children` - system renders them
|
|
224
|
+
- Access `self.request` and `self.request.user` from `set_context()` onwards (not in `__init__()`)
|
|
225
|
+
- Can be sync or async - use `async def set_context(self):` only when awaiting. Rendering always
|
|
226
|
+
runs in an async view, so database access in a sync `set_context()` raises
|
|
227
|
+
`SynchronousOnlyOperation`; use the async ORM
|
|
228
|
+
|
|
229
|
+
### Page
|
|
230
|
+
|
|
231
|
+
Extends `Widget` and represents a full page.
|
|
232
|
+
|
|
233
|
+
**Key Pattern:**
|
|
234
|
+
- URL parameters passed to `__init__()` - store them on self
|
|
235
|
+
- `set_context()` loads data and creates child widgets
|
|
236
|
+
- Assign Widget objects (or lists) to `self.context.children`
|
|
237
|
+
- System calls `set_context()` recursively, then renders all widgets
|
|
238
|
+
|
|
239
|
+
**Example with URL parameters:**
|
|
240
|
+
```python
|
|
241
|
+
class ProfilePage(AppPage):
|
|
242
|
+
def __init__(self, username: str):
|
|
243
|
+
super().__init__()
|
|
244
|
+
self.username = username
|
|
245
|
+
|
|
246
|
+
async def set_context(self):
|
|
247
|
+
await super().set_context()
|
|
248
|
+
user = await User.objects.aget(username=self.username)
|
|
249
|
+
self.context.page['title'] = f'Profile: {user.username}'
|
|
250
|
+
|
|
251
|
+
# Construct widgets; the page renders them
|
|
252
|
+
self.context.children['profile_card'] = ProfileCard(user=user)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Pages:
|
|
256
|
+
- Set `document_template_path` for the HTML document wrapping their own template
|
|
257
|
+
- Receive URL parameters in `__init__()`
|
|
258
|
+
- Use `set_context()` to load data and construct child widgets
|
|
259
|
+
- Route with `@register_url` (see below), or by hand in `urls.py` with `.as_view()`
|
|
260
|
+
- Override `handle_request()` for authentication, redirects or conditional responses, and
|
|
261
|
+
`build_response()` for headers
|
|
262
|
+
|
|
263
|
+
### WidgetContext
|
|
264
|
+
|
|
265
|
+
Each widget has `self.context`, a `WidgetContext`, with:
|
|
266
|
+
- `data`: Template variables (dict)
|
|
267
|
+
- `children`: Child widgets - values can be Widget, list[Widget], or None
|
|
268
|
+
- `page`: Shared page-level data (title, meta, etc.)
|
|
269
|
+
|
|
270
|
+
System renders widgets to HTML automatically. Lists iterate in templates via `{% for item in children.items %}`.
|
|
271
|
+
|
|
272
|
+
### Rendering a widget on its own
|
|
273
|
+
|
|
274
|
+
Outside a page, for an endpoint returning HTML fragments (an infinite scroller, a "load more"
|
|
275
|
+
button), construct the widget, adjust it, and render it once:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
widget = ProjectListTile(project=project)
|
|
279
|
+
widget.context.page['nonce'] = nonce
|
|
280
|
+
html = await widget.render__standalone(request)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Tests render a widget directly against a mock request, optionally as a given user:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
html = await ProjectListTile(project=project).render__test(user=user) # async def test
|
|
287
|
+
html = ProjectListTile(project=project).render__test_sync(user=user) # def test
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Each runs the widget's `set_context()` and every descendant's, then the templates. No document
|
|
291
|
+
template wraps the result.
|
|
292
|
+
|
|
293
|
+
### Routing pages with `@register_url`
|
|
294
|
+
|
|
295
|
+
A page can declare its own routes instead of being listed in `urls.py`. The decorator takes
|
|
296
|
+
`path()`'s arguments, and stacks one per route:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
from website_widgets import register_url
|
|
300
|
+
|
|
301
|
+
@register_url('')
|
|
302
|
+
class HomePage(AppPage): ...
|
|
303
|
+
|
|
304
|
+
@register_url('forum/<int:post_id>', name='forum_post')
|
|
305
|
+
@register_url('tab-requests/<int:post_id>', name='tab_request')
|
|
306
|
+
class ForumPostPage(AppPage): ...
|
|
307
|
+
|
|
308
|
+
@register_url('forum/create-post', name='forum_create_post', kwargs={'post_type': 'forum_post'})
|
|
309
|
+
class ForumCreatePostPage(AppPage): ...
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Then include every decorated page with one line, the way the admin is included:
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
from website_widgets import PageRoutes
|
|
316
|
+
|
|
317
|
+
urlpatterns = [
|
|
318
|
+
path('', PageRoutes.urls()),
|
|
319
|
+
...
|
|
320
|
+
]
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
- **Pages are found by importing every module under the website app's `pages/` folder**, the app
|
|
324
|
+
being `WebsiteWidgetsConfig.website_app_name` (default `'website'`). A page elsewhere isn't found.
|
|
325
|
+
- **Routes live in a namespace named after that app.** `name` defaults to the class name, so
|
|
326
|
+
`HomePage` reverses as `reverse('website:HomePage')`, and `forum_post` as `'website:forum_post'`.
|
|
327
|
+
- **`kwargs` reach the page's constructor** alongside the URL's parameters.
|
|
328
|
+
- **Routes are tried most specific first, never in file order.** They are compared segment by
|
|
329
|
+
segment: a fixed segment comes before `int`, then `uuid`, `slug`, `str` and `path`, and equally
|
|
330
|
+
specific routes fall back to alphabetical order. So `profile/settings` is always tried before
|
|
331
|
+
`profile/<str:username>`, wherever either is declared.
|
|
332
|
+
- **Two equally specific routes that can match the same URL fail a system check**
|
|
333
|
+
(`website_widgets.E001`), such as `a/<str:x>` and `a/<str:y>`.
|
|
334
|
+
- **A decorated route is an ordinary `path()`.** `reverse()`, `{% url %}`, `resolve()` and URL
|
|
335
|
+
tools such as django-extensions' `show_urls` all see it. Routing a page by hand with
|
|
336
|
+
`path('...', MyPage.as_view(), name='...')` still works, and the two mix freely; a hand-routed
|
|
337
|
+
page's name sits outside the namespace.
|
|
338
|
+
|
|
339
|
+
To see the order routes are tried in, and check that nothing clobbers them, run
|
|
340
|
+
`show_page_resolve_order`:
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
$ python manage.py show_page_resolve_order
|
|
344
|
+
# URL Segments Page
|
|
345
|
+
1 /moderation/reports/<int:report_id> f/f/i ViewReportPage
|
|
346
|
+
...
|
|
347
|
+
15 /profile/<str:username> f/s UserProfilePage
|
|
348
|
+
...
|
|
349
|
+
17 / f HomePage
|
|
350
|
+
|
|
351
|
+
Segments: f fixed, i int, s str, g slug, u uuid, p path, and a project converter by its name.
|
|
352
|
+
All 37 page routes reach their page.
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
For each route it reverses a sample URL (`/profile/a`) and resolves it through the whole project,
|
|
356
|
+
so it also catches a route that never reaches its page because something earlier in `urls.py`
|
|
357
|
+
takes its URLs, such as a hand-written route or a catch-all, or because `PageRoutes.urls()` isn't
|
|
358
|
+
included. A route that fails prints in red with the reason, and the command exits non-zero, so it
|
|
359
|
+
can run in CI.
|
|
360
|
+
|
|
361
|
+
- `--columns` picks the columns after `#`, in order, from `url,segments,name,page,reaches_page`
|
|
362
|
+
(default `url,segments,page`). The reachability check runs either way.
|
|
363
|
+
- `--path-param-type` shows a path parameter as declared (`full`, the default:
|
|
364
|
+
`<str:game_slug>`), as its converter (`type`: `<str>`) or as the sample value the check uses
|
|
365
|
+
(`example`: `a`).
|
|
366
|
+
|
|
367
|
+
### URL templates for the frontend
|
|
368
|
+
|
|
369
|
+
`reverse_template()` stands in for `reverse()` when the frontend fills in the parameters. It
|
|
370
|
+
reverses a URL name into a template with a `{name}` placeholder per route parameter:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
from website_widgets import reverse_template
|
|
374
|
+
|
|
375
|
+
# path('projects/<int:project_id>/tasks/<slug:task_slug>', ..., name='project_task')
|
|
376
|
+
reverse_template('project_task') # '/projects/{project_id}/tasks/{task_slug}'
|
|
377
|
+
reverse_template('project_list') # '/projects', as reverse() returns it
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Placeholders take the route's own parameter names, and namespaces, `include()` prefixes and the
|
|
381
|
+
script prefix apply as they do in `reverse()`. Converters don't run, so a placeholder is accepted
|
|
382
|
+
wherever a parameter goes. Pass `paramnames` to rename the placeholders in route order, which a
|
|
383
|
+
`re_path()` with unnamed groups needs: `reverse_template('legacy', ['legacy_id'])`. A name whose
|
|
384
|
+
routes take different parameters raises `ValueError` unless `paramnames` picks one by count.
|
|
385
|
+
|
|
386
|
+
## Templates
|
|
387
|
+
|
|
388
|
+
A widget's template sits next to its Python file and shares its name: `my_widget.py` renders
|
|
389
|
+
`my_widget.html`. Set `template_filename` to use a different file, either on the class or in
|
|
390
|
+
`set_context()` to pick between files at runtime.
|
|
391
|
+
|
|
392
|
+
**`template_filename` is a bare file name, never a path.** A template lives beside the code that
|
|
393
|
+
chooses it, so a `/`, a `\` or `..` raises `ValueError`. The file is looked up beside the class's
|
|
394
|
+
own `.py` file first, then beside each base class's file in inheritance order, and the first match
|
|
395
|
+
wins. That lets a base class keep a variant beside its own module and switch any subclass to it.
|
|
396
|
+
A name found nowhere raises `FileNotFoundError` listing every folder searched.
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
class ProjectPage(AppPage):
|
|
400
|
+
async def set_context(self):
|
|
401
|
+
await super().set_context()
|
|
402
|
+
project = await Project.objects.filter(pk=self.pk).afirst()
|
|
403
|
+
if project is None:
|
|
404
|
+
self.status_code = 404
|
|
405
|
+
self.template_filename = 'project_page__not_found.html'
|
|
406
|
+
return
|
|
407
|
+
...
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Suggested convention: name a variant `<module>__<variant>.html`**, with a double underscore,
|
|
411
|
+
beside the main template:
|
|
412
|
+
|
|
413
|
+
```
|
|
414
|
+
project_page/
|
|
415
|
+
project_page.py
|
|
416
|
+
project_page.html
|
|
417
|
+
project_page__not_found.html
|
|
418
|
+
project_page__archived.html
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
The double underscore marks where the module name ends, the way it separates the parts of a Django
|
|
422
|
+
lookup (`author__name__startswith`): module and variant names both contain single underscores, so
|
|
423
|
+
one alone can't. The prefix keeps every variant sorted next to the template it replaces, and an
|
|
424
|
+
editor tab or a search result names its component without the folder. Give the variant the same
|
|
425
|
+
root element as the main template, so the component's styles still apply to it.
|
|
426
|
+
|
|
427
|
+
A page's document template is the one template set by path: `document_template_path` names a file
|
|
428
|
+
shared by every page, so it lives beside the shared `Page` subclass rather than beside any one page.
|
|
429
|
+
|
|
430
|
+
Override `get_template_path()` when the choice needs more than an attribute.
|
|
431
|
+
|
|
432
|
+
```html
|
|
433
|
+
<div class="my-widget">
|
|
434
|
+
<h1>{{ data.title }}</h1>
|
|
435
|
+
{{ children.my_widget }}
|
|
436
|
+
</div>
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Templates are opened directly rather than through Django's template loader, so context processors
|
|
440
|
+
do not run. The context holds only `request`, `user`, `page`, `data` and `children`. See
|
|
441
|
+
`docs/rendering_pipeline.md` for the full render order.
|
|
442
|
+
|
|
443
|
+
## Configuration
|
|
444
|
+
|
|
445
|
+
Configure django-website-widgets in your `settings.py`:
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
from website_widgets.config import WebsiteWidgetsConfig
|
|
449
|
+
|
|
450
|
+
WEBSITE_WIDGETS = WebsiteWidgetsConfig(
|
|
451
|
+
show_debug_tree=True, # Show widget tree visualization on pages (default: False)
|
|
452
|
+
website_app_name='website', # Label of the app holding your pages/ (default: 'website')
|
|
453
|
+
)
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
If `WEBSITE_WIDGETS` is not defined, a default configuration is used automatically. The setting is
|
|
457
|
+
read once per process, so changing it at runtime has no effect.
|
|
458
|
+
|
|
459
|
+
### Debug Tree Visualization
|
|
460
|
+
|
|
461
|
+
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.
|
|
462
|
+
|
|
463
|
+
**Example output:**
|
|
464
|
+
```
|
|
465
|
+
CompositionPage
|
|
466
|
+
stylesheet:
|
|
467
|
+
AdvancedStylesheet
|
|
468
|
+
constructor_card:
|
|
469
|
+
InfoCard
|
|
470
|
+
list_cards: [
|
|
471
|
+
InfoCard
|
|
472
|
+
InfoCard
|
|
473
|
+
InfoCard
|
|
474
|
+
]
|
|
475
|
+
skipped_card: None
|
|
476
|
+
page_title_note:
|
|
477
|
+
PageTitleNote
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
#### Adding Debug Labels
|
|
481
|
+
|
|
482
|
+
For better debugging, add a `_debug_label` to your widgets:
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
class InfoCard(Widget):
|
|
486
|
+
def __init__(self, title: str, content: str):
|
|
487
|
+
super().__init__()
|
|
488
|
+
self._debug_label = title # Shows as "InfoCard(My Title)" in debug tree
|
|
489
|
+
self.title = title
|
|
490
|
+
self.content = content
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
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.
|
|
494
|
+
|
|
495
|
+
**Recommendation:** Only enable `show_debug_tree` in development environments, not in production.
|
|
496
|
+
|
|
497
|
+
## Management Commands
|
|
498
|
+
|
|
499
|
+
Check installation:
|
|
500
|
+
```bash
|
|
501
|
+
python manage.py test_website_widgets --verbose
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
List the `@register_url` routes in the order they are tried, and check each reaches its page
|
|
505
|
+
(see "Routing pages with `@register_url`"):
|
|
506
|
+
```bash
|
|
507
|
+
python manage.py show_page_resolve_order
|
|
508
|
+
python manage.py show_page_resolve_order --columns=url,name,page,reaches_page --path-param-type=type
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
## License
|
|
512
|
+
|
|
513
|
+
MIT
|
|
514
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
website_widgets/__init__.py,sha256=FyDUWgMvJPWofFLmlNOj0kR1cvp6rr6u2a0vRJDmt30,336
|
|
2
|
+
website_widgets/apps.py,sha256=azmjoN6qrZWtfjxpTnjW4e1sMf3vCHCEBGZdRiBUl8Q,352
|
|
3
|
+
website_widgets/config.py,sha256=shtZBEr51LpFJ96yHPybSMMBMJHUkCFUs2tyUak9SSI,1020
|
|
4
|
+
website_widgets/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
website_widgets/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
website_widgets/management/commands/show_page_resolve_order.py,sha256=WObtZ6b40sj_uc4lMdJwJih_xG_TK6ZhFVgpWQDelMw,7375
|
|
7
|
+
website_widgets/management/commands/test_website_widgets.py,sha256=82j7MOlC-xzQ8hLw8RF8dsN5MtPgliLsf9pwbQ5vXlo,2084
|
|
8
|
+
website_widgets/page.py,sha256=1w4NLSB34BklAXNUqqIfcajvCAl2BsZ7219z5xXz1sk,5023
|
|
9
|
+
website_widgets/page_routes.py,sha256=BxTHHjhuaRlKRVWE2900ZhSFoYqeAdlqNCcShY9l08w,10283
|
|
10
|
+
website_widgets/tests.py,sha256=AgdkSgbVu0HqoxBnO03g7ahQudCitb8OIZfYxaOFhwU,25854
|
|
11
|
+
website_widgets/url_templates.py,sha256=ftCiKvMBOKoJxk_gbtzAeOw6gbbDo9INrcCLOT6HcwI,3058
|
|
12
|
+
website_widgets/widget.py,sha256=URz8IHx-suWMt0Shc3d47UVqKhezbMxqJ1UpijB-VzE,12346
|
|
13
|
+
website_widgets/widget_context.py,sha256=-3O05yaM3u4YWFk9XarRUEwRQD5Ijf00J_NOleIl3D0,682
|
|
14
|
+
django_website_widgets-0.1.0.dist-info/METADATA,sha256=a_1JFSeMmSAqFXY8uak2gS6I77q4Wtwuu-sMgwjH3TM,17883
|
|
15
|
+
django_website_widgets-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
16
|
+
django_website_widgets-0.1.0.dist-info/licenses/LICENSE,sha256=qB736cZgYSvxLrQG-Vv7zP-YixV9NdobEx26j--srFo,1068
|
|
17
|
+
django_website_widgets-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Nate Brooks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .widget import Widget
|
|
2
|
+
from .page import Page
|
|
3
|
+
from .widget_context import WidgetContext
|
|
4
|
+
from .url_templates import reverse_template
|
|
5
|
+
from .page_routes import PageRoutes
|
|
6
|
+
|
|
7
|
+
register_url = PageRoutes.register_url
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'Widget',
|
|
11
|
+
'Page',
|
|
12
|
+
'WidgetContext',
|
|
13
|
+
'reverse_template',
|
|
14
|
+
'PageRoutes',
|
|
15
|
+
'register_url',
|
|
16
|
+
]
|
website_widgets/apps.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WebsiteWidgetsConfig(AppConfig):
|
|
5
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
6
|
+
name = 'website_widgets'
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from django.core import checks
|
|
10
|
+
|
|
11
|
+
from website_widgets.page_routes import PageRoutes
|
|
12
|
+
|
|
13
|
+
checks.register(PageRoutes.check_for_ties, checks.Tags.urls)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class WebsiteWidgetsConfig:
|
|
2
|
+
def __init__(
|
|
3
|
+
self,
|
|
4
|
+
show_debug_tree: bool = False,
|
|
5
|
+
website_app_name: str = 'website',
|
|
6
|
+
):
|
|
7
|
+
self.show_debug_tree = show_debug_tree
|
|
8
|
+
# An app label, resolved through the app registry, so nothing depends on the working
|
|
9
|
+
# directory.
|
|
10
|
+
self.website_app_name = website_app_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_config_cache: WebsiteWidgetsConfig | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config() -> WebsiteWidgetsConfig:
|
|
17
|
+
"""
|
|
18
|
+
Get the website widgets configuration from Django settings.
|
|
19
|
+
|
|
20
|
+
Caches the configuration on first access. If settings.WEBSITE_WIDGETS is not defined,
|
|
21
|
+
uses a default configuration.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
WebsiteWidgetsConfig: The configuration object
|
|
25
|
+
"""
|
|
26
|
+
global _config_cache
|
|
27
|
+
|
|
28
|
+
if _config_cache is None:
|
|
29
|
+
from django.conf import settings
|
|
30
|
+
|
|
31
|
+
if hasattr(settings, 'WEBSITE_WIDGETS'):
|
|
32
|
+
_config_cache = settings.WEBSITE_WIDGETS
|
|
33
|
+
else:
|
|
34
|
+
_config_cache = WebsiteWidgetsConfig()
|
|
35
|
+
|
|
36
|
+
return _config_cache
|
|
File without changes
|
|
File without changes
|