django-htmx-views 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_htmx_views-0.1.0.dist-info/METADATA +235 -0
- django_htmx_views-0.1.0.dist-info/RECORD +16 -0
- django_htmx_views-0.1.0.dist-info/WHEEL +5 -0
- django_htmx_views-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_htmx_views-0.1.0.dist-info/top_level.txt +1 -0
- htmx_views/__init__.py +3 -0
- htmx_views/_optional.py +19 -0
- htmx_views/apps.py +20 -0
- htmx_views/linked_selects.py +50 -0
- htmx_views/models.py +6 -0
- htmx_views/templates/htmx_views/widgets/options.html +4 -0
- htmx_views/templates/htmx_views/widgets/select.html +3 -0
- htmx_views/templatetags/__init__.py +1 -0
- htmx_views/urls.py +13 -0
- htmx_views/views.py +335 -0
- htmx_views/widgets.py +68 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-htmx-views
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable Django view services for HTMX-enabled applications
|
|
5
|
+
Author: Gavin Burnell
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gb119/django-htmx-views
|
|
8
|
+
Project-URL: Repository, https://github.com/gb119/django-htmx-views
|
|
9
|
+
Project-URL: Issues, https://github.com/gb119/django-htmx-views/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.0
|
|
14
|
+
Classifier: Framework :: Django :: 5.1
|
|
15
|
+
Classifier: Framework :: Django :: 5.2
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django>=4.2
|
|
30
|
+
Requires-Dist: django-htmx>=1.17
|
|
31
|
+
Provides-Extra: docs
|
|
32
|
+
Requires-Dist: Sphinx<9,>=7; extra == "docs"
|
|
33
|
+
Requires-Dist: django-ajax-selects>=2.2; extra == "docs"
|
|
34
|
+
Requires-Dist: sphinx-better-theme>=0.1.5; extra == "docs"
|
|
35
|
+
Provides-Extra: linked-selects
|
|
36
|
+
Requires-Dist: django-ajax-selects>=2.2; extra == "linked-selects"
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: build>=1.2; extra == "test"
|
|
39
|
+
Requires-Dist: django-ajax-selects>=2.2; extra == "test"
|
|
40
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
41
|
+
Requires-Dist: pytest-django>=4.8; extra == "test"
|
|
42
|
+
Requires-Dist: tomli>=2; python_version < "3.11" and extra == "test"
|
|
43
|
+
Requires-Dist: twine>=5; extra == "test"
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# django-htmx-views
|
|
47
|
+
|
|
48
|
+
Reusable Django view mixins and widgets for building HTMX-enabled applications.
|
|
49
|
+
This package extracts the `htmx_views` app used by
|
|
50
|
+
[`gb119/labman`](https://github.com/gb119/labman) and
|
|
51
|
+
[`uolphysicsteaching/phas_vitals`](https://github.com/uolphysicsteaching/phas_vitals)
|
|
52
|
+
so that the implementation can be installed and maintained independently.
|
|
53
|
+
|
|
54
|
+
The initial package source is an exact copy of the newer `phas_vitals`
|
|
55
|
+
implementation at commit
|
|
56
|
+
[`1008bc4`](https://github.com/uolphysicsteaching/phas_vitals/commit/1008bc475a28a0ccca40df8769ad6902b68edebc).
|
|
57
|
+
The two upstream apps were compared before extraction and are not currently
|
|
58
|
+
identical; see [UPSTREAM_COMPARISON.md](UPSTREAM_COMPARISON.md) for the
|
|
59
|
+
verification details.
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
- HTMX-aware dispatch for Django class-based views.
|
|
64
|
+
- Routing by HTMX trigger name, trigger ID, or target ID.
|
|
65
|
+
- HTMX-specific templates, context data, and context object names.
|
|
66
|
+
- HTMX-aware form-valid and form-invalid handlers.
|
|
67
|
+
- Optional linked `<select>` widgets backed by `django-ajax-selects`.
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
Install the core package:
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
python -m pip install django-htmx-views
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Install linked-select support as well:
|
|
78
|
+
|
|
79
|
+
```console
|
|
80
|
+
python -m pip install "django-htmx-views[linked-selects]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Add the app and `django-htmx` middleware to the host project's settings:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
INSTALLED_APPS = [
|
|
87
|
+
# ...
|
|
88
|
+
"htmx_views",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
MIDDLEWARE = [
|
|
92
|
+
# ...
|
|
93
|
+
"django_htmx.middleware.HtmxMiddleware",
|
|
94
|
+
]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## HTMX-aware views
|
|
98
|
+
|
|
99
|
+
Importing `htmx_views.views` installs the existing HTMX-aware dispatch hook on
|
|
100
|
+
Django's base `View` class. Mix `HTMXProcessMixin` into a class-based view to
|
|
101
|
+
route HTMX requests to element-specific handlers:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from django.http import HttpResponse
|
|
105
|
+
from django.views.generic import TemplateView
|
|
106
|
+
|
|
107
|
+
from htmx_views.views import HTMXProcessMixin
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ResultsView(HTMXProcessMixin, TemplateView):
|
|
111
|
+
template_name = "results/page.html"
|
|
112
|
+
template_name_results = "results/_table.html"
|
|
113
|
+
|
|
114
|
+
def htmx_get_refresh(self, request, *args, **kwargs):
|
|
115
|
+
return HttpResponse("refreshed")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
For an HTMX `GET`, the mixin looks for handlers using the request's
|
|
119
|
+
`trigger_name`, `trigger`, and `target`, in that order. Element names are
|
|
120
|
+
lowercased and stripped of characters other than letters, numbers, and
|
|
121
|
+
underscores. The same convention is available for `DELETE`, `PATCH`, `POST`,
|
|
122
|
+
and `PUT`.
|
|
123
|
+
|
|
124
|
+
`HTMXFormMixin` adds equivalent `htmx_form_valid_<element>` and
|
|
125
|
+
`htmx_form_invalid_<element>` hooks for form views.
|
|
126
|
+
|
|
127
|
+
## Linked selects
|
|
128
|
+
|
|
129
|
+
Linked selects are optional because they require `django-ajax-selects`. After
|
|
130
|
+
installing the `linked-selects` extra, include the package URLs:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from django.urls import include, path
|
|
134
|
+
|
|
135
|
+
urlpatterns = [
|
|
136
|
+
path(
|
|
137
|
+
"htmx-views/",
|
|
138
|
+
include(("htmx_views.urls", "htmx_views"), namespace="htmx_views"),
|
|
139
|
+
),
|
|
140
|
+
]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Then use a registered `django-ajax-selects` lookup:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from django import forms
|
|
147
|
+
|
|
148
|
+
from htmx_views.widgets import HTMXSelectWidget
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class EquipmentForm(forms.Form):
|
|
152
|
+
module = forms.ModelChoiceField(queryset=...)
|
|
153
|
+
category = forms.ModelChoiceField(
|
|
154
|
+
queryset=...,
|
|
155
|
+
widget=HTMXSelectWidget("categories", parent="module"),
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The linked-select endpoint runs the lookup's authorisation check before
|
|
160
|
+
querying it. Importing linked-select modules without the optional dependency
|
|
161
|
+
raises an actionable `ImportError`; the core view mixins remain usable.
|
|
162
|
+
|
|
163
|
+
## Migrating an existing project
|
|
164
|
+
|
|
165
|
+
1. Install this package and add `"htmx_views"` to `INSTALLED_APPS`.
|
|
166
|
+
2. Replace imports beginning with `apps.htmx_views` with `htmx_views`.
|
|
167
|
+
3. If linked selects are used, install the extra and include
|
|
168
|
+
`htmx_views.urls` under the `htmx_views` namespace.
|
|
169
|
+
4. Remove the project's copied `apps/htmx_views` directory after its tests
|
|
170
|
+
pass against the package.
|
|
171
|
+
|
|
172
|
+
The former `htmx_views.views.LinkedSelectEndpointView` import remains available
|
|
173
|
+
for compatibility.
|
|
174
|
+
|
|
175
|
+
## Development and builds
|
|
176
|
+
|
|
177
|
+
Create a development environment and run the tests:
|
|
178
|
+
|
|
179
|
+
```console
|
|
180
|
+
python -m pip install -e ".[test,linked-selects]"
|
|
181
|
+
python -m pytest
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Build and validate the wheel and source distribution:
|
|
185
|
+
|
|
186
|
+
```console
|
|
187
|
+
python -m build
|
|
188
|
+
python -m twine check dist/*
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Build the noarch Conda package with `conda-build`:
|
|
192
|
+
|
|
193
|
+
```console
|
|
194
|
+
conda-build conda-recipe
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The GitHub Actions build workflow runs the tests and produces both Python and
|
|
198
|
+
Conda package artifacts.
|
|
199
|
+
|
|
200
|
+
## Documentation
|
|
201
|
+
|
|
202
|
+
The full documentation is published at
|
|
203
|
+
[gb119.github.io/django-htmx-views](https://gb119.github.io/django-htmx-views/).
|
|
204
|
+
|
|
205
|
+
Build it locally with:
|
|
206
|
+
|
|
207
|
+
```console
|
|
208
|
+
python -m pip install -e ".[docs]"
|
|
209
|
+
python -m sphinx -W --keep-going -b html docs/source docs/build/html
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The documentation workflow rebuilds and deploys GitHub Pages for published
|
|
213
|
+
releases and pre-releases, and can also be started manually.
|
|
214
|
+
|
|
215
|
+
## Publishing a release
|
|
216
|
+
|
|
217
|
+
Creating a GitHub release, including a pre-release, runs the release workflow.
|
|
218
|
+
It verifies that the release tag (for example, `vX.Y.Z`) matches
|
|
219
|
+
`htmx_views.__version__`, reruns the tests, builds both package formats, and
|
|
220
|
+
publishes:
|
|
221
|
+
|
|
222
|
+
- the wheel and source distribution to PyPI;
|
|
223
|
+
- the noarch Conda package to the `phygbu` Anaconda channel with the `main`
|
|
224
|
+
label.
|
|
225
|
+
|
|
226
|
+
Configure these GitHub Actions repository secrets before publishing:
|
|
227
|
+
|
|
228
|
+
- `PYPI_TOKEN`: a PyPI API token authorised for `django-htmx-views`;
|
|
229
|
+
- `ANACONDA`: an Anaconda.org API token authorised to upload to `phygbu`.
|
|
230
|
+
|
|
231
|
+
The upload steps deliberately refuse to overwrite an existing Conda package.
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
django_htmx_views-0.1.0.dist-info/licenses/LICENSE,sha256=-kGm_gyRmInhSKjaHveNSHKvwZ8ccUzUsUo_UueFPdk,1070
|
|
2
|
+
htmx_views/__init__.py,sha256=x75QnkCan48wE0DIdfiFDroLIw2hlcfGmUhG746bDBE,92
|
|
3
|
+
htmx_views/_optional.py,sha256=OYas-2IakdQ5GapcTQebhEenyYri1-787Yi3K5Qurmc,655
|
|
4
|
+
htmx_views/apps.py,sha256=Q9t6QQ1u9x_jmY4JRFx1yiNildRvMRZ9oXNA9Nou_5Q,436
|
|
5
|
+
htmx_views/linked_selects.py,sha256=K-ZzxJU8JUcZAEliRrK_chdPHvcebmAqnenKlVrRztc,1847
|
|
6
|
+
htmx_views/models.py,sha256=gGlplAaqxiVGsJvXnSdoGaOkvM9-T5EipGjKB11agPE,85
|
|
7
|
+
htmx_views/urls.py,sha256=TMLEVMj6g_nFttXWzJChQ_0e581b7X66p_a7utnfYRY,374
|
|
8
|
+
htmx_views/views.py,sha256=FRCSZrsYwJDfJD_hL2vyAj9YKauLVInZ16xCYk3gQLA,14234
|
|
9
|
+
htmx_views/widgets.py,sha256=OXa3H0AWVlgROVTVyS3SfihUGGJXNYWXxLIMfRW9fac,2359
|
|
10
|
+
htmx_views/templates/htmx_views/widgets/options.html,sha256=roJAarRWuoyzK4numxGJaTc28h8TEvi3dywAN_-fEPo,136
|
|
11
|
+
htmx_views/templates/htmx_views/widgets/select.html,sha256=ueVtSskiarnW3PfHc9O0q_PgNBqn41H5LHeOY2JCJfk,141
|
|
12
|
+
htmx_views/templatetags/__init__.py,sha256=B5LHgOXUzESA8__ksIlw5JWb5GjQPL_TpAPXAgDAYyE,44
|
|
13
|
+
django_htmx_views-0.1.0.dist-info/METADATA,sha256=BCStN2V1g45bMWV1oA4J1UHxXMg_T8sW092fl9Bas_Q,7415
|
|
14
|
+
django_htmx_views-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
15
|
+
django_htmx_views-0.1.0.dist-info/top_level.txt,sha256=9dOhcM3sntUymDFIPzE0_eSuTSkRmltIxZBxUL1fzGE,11
|
|
16
|
+
django_htmx_views-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gavin Burnell
|
|
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 @@
|
|
|
1
|
+
htmx_views
|
htmx_views/__init__.py
ADDED
htmx_views/_optional.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Helpers for optional HTMX views integrations."""
|
|
2
|
+
|
|
3
|
+
# Python imports
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_ajax_select_registry():
|
|
8
|
+
"""Return the ajax-select registry or explain how to enable linked selects."""
|
|
9
|
+
try:
|
|
10
|
+
ajax_select = import_module("ajax_select")
|
|
11
|
+
except ModuleNotFoundError as error:
|
|
12
|
+
if error.name != "ajax_select":
|
|
13
|
+
raise
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"Linked-select support requires the optional dependency "
|
|
16
|
+
"'django-ajax-selects'. Install it before importing "
|
|
17
|
+
"'htmx_views.widgets' or 'htmx_views.urls'."
|
|
18
|
+
) from error
|
|
19
|
+
return ajax_select.registry
|
htmx_views/apps.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import unicode_literals
|
|
2
|
+
|
|
3
|
+
# Python imports
|
|
4
|
+
from os.path import basename, dirname
|
|
5
|
+
|
|
6
|
+
# Django imports
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HTMXViewsConfig(AppConfig):
|
|
11
|
+
"""Django App config object for the htmx_views app."""
|
|
12
|
+
|
|
13
|
+
name = basename(dirname(__file__))
|
|
14
|
+
default = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EquipmentConfig(HTMXViewsConfig):
|
|
18
|
+
"""Compatibility name for code importing the former app config."""
|
|
19
|
+
|
|
20
|
+
default = False
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Linked-select endpoint backed by the optional django-ajax-selects package."""
|
|
2
|
+
|
|
3
|
+
# Django imports
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from django.http import Http404
|
|
6
|
+
from django.views.generic import TemplateView
|
|
7
|
+
|
|
8
|
+
# app imports
|
|
9
|
+
from ._optional import get_ajax_select_registry
|
|
10
|
+
|
|
11
|
+
registry = get_ajax_select_registry()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LinkedSelectEndpointView(TemplateView):
|
|
15
|
+
"""Return linked-select options from an authorised lookup."""
|
|
16
|
+
|
|
17
|
+
http_method_names = ["get", "head", "options"]
|
|
18
|
+
template_name = "htmx_views/widgets/options.html"
|
|
19
|
+
|
|
20
|
+
def dispatch(self, request, *args, **kwargs):
|
|
21
|
+
"""Resolve the lookup and enforce its authorisation policy."""
|
|
22
|
+
self.lookup_channel = kwargs.get("lookup_channel")
|
|
23
|
+
try:
|
|
24
|
+
self.lookup = registry.get(self.lookup_channel)
|
|
25
|
+
except ImproperlyConfigured as error:
|
|
26
|
+
raise Http404("Unknown linked-select lookup channel.") from error
|
|
27
|
+
|
|
28
|
+
self.lookup.check_auth(request)
|
|
29
|
+
self.parent = request.GET.get("_htmx_parent") or getattr(self.lookup, "parameter_name", None)
|
|
30
|
+
return super().dispatch(request, *args, **kwargs)
|
|
31
|
+
|
|
32
|
+
def get_context_data(self, **kwargs):
|
|
33
|
+
"""Add the option list to the context."""
|
|
34
|
+
if self.parent is None:
|
|
35
|
+
raise ImproperlyConfigured(
|
|
36
|
+
f"Creating an htmx_views widget for {self.lookup_channel} without knowing the trigger."
|
|
37
|
+
)
|
|
38
|
+
query = self.request.GET.get(self.parent)
|
|
39
|
+
try:
|
|
40
|
+
query = int(query)
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
context = super().get_context_data(**kwargs)
|
|
45
|
+
context["options"] = []
|
|
46
|
+
if query:
|
|
47
|
+
context["options"] = [
|
|
48
|
+
(item.pk, str(item)) for item in self.lookup.get_query(query, self.request).distinct()
|
|
49
|
+
]
|
|
50
|
+
return context
|
htmx_views/models.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Template tags package for HTMX views."""
|
htmx_views/urls.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""urls for the hhtmx_views app."""
|
|
3
|
+
# Python imports
|
|
4
|
+
from os.path import basename, dirname
|
|
5
|
+
|
|
6
|
+
# Django imports
|
|
7
|
+
from django.urls import path
|
|
8
|
+
|
|
9
|
+
# app imports
|
|
10
|
+
from .linked_selects import LinkedSelectEndpointView
|
|
11
|
+
|
|
12
|
+
app_name = basename(dirname(__file__))
|
|
13
|
+
urlpatterns = [path("select/<str:lookup_channel>/", LinkedSelectEndpointView.as_view(), name="select")]
|
htmx_views/views.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""View support classes and functions for htmx-views."""
|
|
3
|
+
|
|
4
|
+
# Python imports
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
# Django imports
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.views import View
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def temp_attr(obj, attr, value):
|
|
18
|
+
"""Temporarily set the value of an attribute and restore it afterwards."""
|
|
19
|
+
# Check if the attribute originally exists on the object
|
|
20
|
+
has_attr = hasattr(obj, attr)
|
|
21
|
+
original_value = getattr(obj, attr, None)
|
|
22
|
+
|
|
23
|
+
# Set the attribute to the new value
|
|
24
|
+
setattr(obj, attr, value)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
# Yield control back to the caller
|
|
28
|
+
yield
|
|
29
|
+
finally:
|
|
30
|
+
if has_attr:
|
|
31
|
+
# Restore the original value if the attribute existed
|
|
32
|
+
setattr(obj, attr, original_value)
|
|
33
|
+
else:
|
|
34
|
+
# Delete the attribute if it didn't exist originally
|
|
35
|
+
delattr(obj, attr)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dispatch(self, request, *args, **kwargs):
|
|
39
|
+
"""Dispatch method that becomes htmx aware.
|
|
40
|
+
|
|
41
|
+
If the =`htmx` request attribute is set (by django-htmx) and the method name is in either
|
|
42
|
+
`self.http_method_names` or `self.htmx_method_names` then try to locate the method
|
|
43
|
+
`htmx_<http_verb>` method and call that or else fall back to the regular `<http_verb>` method.
|
|
44
|
+
|
|
45
|
+
If an approrpiate method can't be located, call `self.http_method_bot_allowed` for error handline.
|
|
46
|
+
|
|
47
|
+
If the `htmx` request attrobute is not set or is False, then fall back to the original dispatch.
|
|
48
|
+
"""
|
|
49
|
+
if not getattr(request, "htmx", False): # Not an HTMX aware request
|
|
50
|
+
return self._non_htmx_dispatch(request, *args, **kwargs)
|
|
51
|
+
|
|
52
|
+
# Allow different allowed methods for htmx
|
|
53
|
+
allowed_names = getattr(self, "htmx_http_method_names", self.http_method_names)
|
|
54
|
+
|
|
55
|
+
if request.method.lower() in allowed_names:
|
|
56
|
+
handler = getattr(
|
|
57
|
+
self, f"htmx_{request.method.lower()}", getattr(self, request.method.lower(), self.http_method_not_allowed)
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
handler = self.http_method_not_allowed
|
|
61
|
+
if not callable(handler):
|
|
62
|
+
handler = self.http_method_not_allowed
|
|
63
|
+
return handler(request, *args, **kwargs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HTMXProcessMixin:
|
|
67
|
+
"""Provide versions of the htmx_`<http_verb>` methods that will delegate to trigger specific methods.
|
|
68
|
+
|
|
69
|
+
Each http verb DELETE,GET,PATCH,POST,PUT's htmx_<verb> method will look at the request.htmx attriobute
|
|
70
|
+
to see if htmx_<verb>_<trigger_name>, htmx_<verb>_<trigger>, htmx_<verb>_<target> is a method and then pass
|
|
71
|
+
on to the first matching method. If no match is foumd, the `http_method_not_allowed` method is used instead.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, *args, **kwargs):
|
|
75
|
+
"""Initialise HTMX process mixin with context flags.
|
|
76
|
+
|
|
77
|
+
Keyword Parameters:
|
|
78
|
+
*args: Variable length argument list.
|
|
79
|
+
**kwargs: Arbitrary keyword arguments.
|
|
80
|
+
"""
|
|
81
|
+
super().__init__(*args, **kwargs)
|
|
82
|
+
self._htmx_get_context_data = False
|
|
83
|
+
self._htmx_get_context_object_name = False
|
|
84
|
+
self._htmx_get_template_names = False
|
|
85
|
+
|
|
86
|
+
def htmx_elements(self):
|
|
87
|
+
"""Iterate over possible htmx element sources."""
|
|
88
|
+
for attr in ["trigger_name", "trigger", "target"]:
|
|
89
|
+
if elem := getattr(self.request.htmx, attr, None):
|
|
90
|
+
elem = re.sub(r"[^A-Za-z0-9_]", "", elem).lower()
|
|
91
|
+
if settings.DEBUG:
|
|
92
|
+
logger.debug(elem)
|
|
93
|
+
yield elem
|
|
94
|
+
|
|
95
|
+
def get_context_data_function(self, **kwargs):
|
|
96
|
+
"""Return the first callable element-specific context handler."""
|
|
97
|
+
del kwargs
|
|
98
|
+
for elem in self.htmx_elements():
|
|
99
|
+
handler = getattr(self, f"get_context_data_{elem}", None)
|
|
100
|
+
if callable(handler):
|
|
101
|
+
return handler
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def get_context_data(self, **kwargs):
|
|
105
|
+
"""Get context data being aware of htmx views."""
|
|
106
|
+
if not getattr(self.request, "htmx", False) or self._htmx_get_context_data: # Default behaviour
|
|
107
|
+
return super().get_context_data(**kwargs)
|
|
108
|
+
|
|
109
|
+
# Look for a request specific to the element involved.
|
|
110
|
+
handler = self.get_context_data_function(**kwargs)
|
|
111
|
+
if handler is not None:
|
|
112
|
+
with temp_attr(self, "_htmx_get_context_data", True):
|
|
113
|
+
return handler(**kwargs)
|
|
114
|
+
return super().get_context_data(**kwargs)
|
|
115
|
+
|
|
116
|
+
def get_context_object_name(self, object_list):
|
|
117
|
+
"""Get context object name being aware of htmx elements.
|
|
118
|
+
|
|
119
|
+
If the get_context_name_<element> method needs to call usper, it should set a keyword
|
|
120
|
+
argument, _default to be True to avoid a recursive loop.
|
|
121
|
+
"""
|
|
122
|
+
if not getattr(self.request, "htmx", False) or self._htmx_get_context_object_name: # Default behaviour
|
|
123
|
+
return super().get_context_object_name(object_list)
|
|
124
|
+
|
|
125
|
+
# Look for a request specific to the element involved.
|
|
126
|
+
for elem in self.htmx_elements():
|
|
127
|
+
for handler_name in (f"get_context_object_name_{elem}", f"get_context_object_name{elem}"):
|
|
128
|
+
if callable(handler := getattr(self, handler_name, None)):
|
|
129
|
+
with temp_attr(self, "_htmx_get_context_object_name", True):
|
|
130
|
+
return handler(object_list)
|
|
131
|
+
if sub_name := getattr(self, f"context_object_{elem}", False):
|
|
132
|
+
return sub_name
|
|
133
|
+
|
|
134
|
+
logger.debug("Super")
|
|
135
|
+
|
|
136
|
+
return super().get_context_object_name(object_list)
|
|
137
|
+
|
|
138
|
+
def get_template_names(self):
|
|
139
|
+
"""Look for htmx specific templates."""
|
|
140
|
+
if not getattr(self.request, "htmx", False) or self._htmx_get_template_names: # Default behaviour
|
|
141
|
+
return super().get_template_names()
|
|
142
|
+
|
|
143
|
+
# Look for a request specific to the element involved.
|
|
144
|
+
for elem in self.htmx_elements():
|
|
145
|
+
handler = getattr(self, f"get_template_names_{elem}", None)
|
|
146
|
+
if callable(handler):
|
|
147
|
+
if settings.DEBUG:
|
|
148
|
+
logger.debug(f"Template_handler: {handler.__name__}")
|
|
149
|
+
with temp_attr(self, "_htmx_get_template_names", True):
|
|
150
|
+
return handler()
|
|
151
|
+
sub_name = getattr(self, f"template_name_{elem}", False)
|
|
152
|
+
if sub_name:
|
|
153
|
+
if settings.DEBUG:
|
|
154
|
+
logger.debug(f"Template_name: {sub_name}")
|
|
155
|
+
return sub_name
|
|
156
|
+
return super().get_template_names()
|
|
157
|
+
|
|
158
|
+
def htmx_delete(self, request, *args, **kwargs):
|
|
159
|
+
"""Delegate HTMX DELETE requests.
|
|
160
|
+
|
|
161
|
+
Looks for the element that is related to the request by inspecting the `request.htmx` `trigger_name`, trigger
|
|
162
|
+
and target attributes in turn. If no matching `htmx_delete_<name>` methods are found, return the
|
|
163
|
+
`method_not_allowed` result instrad.
|
|
164
|
+
"""
|
|
165
|
+
for elem in self.htmx_elements():
|
|
166
|
+
handler = getattr(self, f"htmx_delete_{elem}", None)
|
|
167
|
+
if callable(handler):
|
|
168
|
+
break
|
|
169
|
+
else:
|
|
170
|
+
handler = getattr(self, "delete", self.http_method_not_allowed)
|
|
171
|
+
if not callable(handler):
|
|
172
|
+
handler = self.http_method_not_allowed
|
|
173
|
+
if settings.DEBUG:
|
|
174
|
+
logger.debug(f"HTMX Method handler: {handler.__name__}")
|
|
175
|
+
return handler(request, *args, **kwargs)
|
|
176
|
+
|
|
177
|
+
def htmx_get(self, request, *args, **kwargs):
|
|
178
|
+
"""Delegate HTMX GET requests.
|
|
179
|
+
|
|
180
|
+
Looks for the element that is related to the request by inspecting the `request.htmx` `trigger_name`, trigger
|
|
181
|
+
and target attributes in turn. If no matching `htmx_get_<name>` methods are found, return the
|
|
182
|
+
`method_not_allowed` result instrad.
|
|
183
|
+
"""
|
|
184
|
+
for elem in self.htmx_elements():
|
|
185
|
+
handler = getattr(self, f"htmx_get_{elem}", None)
|
|
186
|
+
if callable(handler):
|
|
187
|
+
break
|
|
188
|
+
else:
|
|
189
|
+
handler = getattr(self, "get", self.http_method_not_allowed)
|
|
190
|
+
if not callable(handler):
|
|
191
|
+
handler = self.http_method_not_allowed
|
|
192
|
+
if settings.DEBUG:
|
|
193
|
+
logger.debug(f"HTMX Method handler: {handler.__name__}")
|
|
194
|
+
return handler(request, *args, **kwargs)
|
|
195
|
+
|
|
196
|
+
def htmx_patch(self, request, *args, **kwargs):
|
|
197
|
+
"""Delegate HTMX PATCH requests.
|
|
198
|
+
|
|
199
|
+
Looks for the element that is related to the request by inspecting the `request.htmx` `trigger_name`, trigger
|
|
200
|
+
and target attributes in turn. If no matching `htmx_patch_<name>` methods are found, return the
|
|
201
|
+
`method_not_allowed` result instrad.
|
|
202
|
+
"""
|
|
203
|
+
for elem in self.htmx_elements():
|
|
204
|
+
handler = getattr(self, f"htmx_patch_{elem}", None)
|
|
205
|
+
if callable(handler):
|
|
206
|
+
break
|
|
207
|
+
else:
|
|
208
|
+
handler = getattr(self, "patch", self.http_method_not_allowed)
|
|
209
|
+
if not callable(handler):
|
|
210
|
+
handler = self.http_method_not_allowed
|
|
211
|
+
if settings.DEBUG:
|
|
212
|
+
logger.debug(f"HTMX Method handler: {handler.__name__}")
|
|
213
|
+
return handler(request, *args, **kwargs)
|
|
214
|
+
|
|
215
|
+
def htmx_post(self, request, *args, **kwargs):
|
|
216
|
+
"""Delegate HTMX POST requests.
|
|
217
|
+
|
|
218
|
+
Looks for the element that is related to the request by inspecting the `request.htmx` `trigger_name`, trigger
|
|
219
|
+
and target attributes in turn. If no matching `htmx_post_<name>` methods are found, return the
|
|
220
|
+
`method_not_allowed` result instrad.
|
|
221
|
+
"""
|
|
222
|
+
for elem in self.htmx_elements():
|
|
223
|
+
handler = getattr(self, f"htmx_post_{elem}", None)
|
|
224
|
+
if callable(handler):
|
|
225
|
+
break
|
|
226
|
+
else:
|
|
227
|
+
handler = getattr(self, "post", self.http_method_not_allowed)
|
|
228
|
+
if not callable(handler):
|
|
229
|
+
handler = self.http_method_not_allowed
|
|
230
|
+
if settings.DEBUG:
|
|
231
|
+
logger.debug(f"HTMX Method handler: {handler.__name__}")
|
|
232
|
+
return handler(request, *args, **kwargs)
|
|
233
|
+
|
|
234
|
+
def htmx_put(self, request, *args, **kwargs):
|
|
235
|
+
"""Delegate HTMX PUT requests.
|
|
236
|
+
|
|
237
|
+
Looks for the element that is related to the request by inspecting the `request.htmx` `trigger_name`, trigger
|
|
238
|
+
and target attributes in turn. If no matching `htmx_put_<name>` methods are found, return the
|
|
239
|
+
`method_not_allowed` result instrad.
|
|
240
|
+
"""
|
|
241
|
+
for elem in self.htmx_elements():
|
|
242
|
+
handler = getattr(self, f"htmx_put_{elem}", None)
|
|
243
|
+
if callable(handler):
|
|
244
|
+
break
|
|
245
|
+
else:
|
|
246
|
+
handler = getattr(self, "put", self.http_method_not_allowed)
|
|
247
|
+
if not callable(handler):
|
|
248
|
+
handler = self.http_method_not_allowed
|
|
249
|
+
if settings.DEBUG:
|
|
250
|
+
logger.debug(f"HTMX Method handler: {handler.__name__}")
|
|
251
|
+
return handler(request, *args, **kwargs)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class HTMXFormMixin(HTMXProcessMixin):
|
|
255
|
+
"""Provide additional methods to adapt FormView and friends for htmx requests as well."""
|
|
256
|
+
|
|
257
|
+
def __init__(self, *args, **kwargs):
|
|
258
|
+
"""Initialise HTMX form mixin with form validation flags.
|
|
259
|
+
|
|
260
|
+
Keyword Parameters:
|
|
261
|
+
*args: Variable length argument list.
|
|
262
|
+
**kwargs: Arbitrary keyword arguments.
|
|
263
|
+
"""
|
|
264
|
+
super().__init__(*args, **kwargs)
|
|
265
|
+
self._htmx_form_valid = False
|
|
266
|
+
self._htmx_form_invalid = False
|
|
267
|
+
|
|
268
|
+
def form_valid(self, form):
|
|
269
|
+
"""Look for HTMX form valid handlers.
|
|
270
|
+
|
|
271
|
+
If request.htmx is not set, then return the parent class form_valid, otherwise look for an element
|
|
272
|
+
specific htmx_form_valid_<name> method, or failing that just an htmx_form_valid meothd. Fnally,
|
|
273
|
+
give up and return the parent form_valid.
|
|
274
|
+
|
|
275
|
+
Notes:
|
|
276
|
+
htmx_form_valid* methods must not call super().form_valid - otherwise an infinite recursion happens!
|
|
277
|
+
"""
|
|
278
|
+
if not getattr(self.request, "htmx", False) or self._htmx_form_valid: # Non HTMX requests
|
|
279
|
+
return super().form_valid(form)
|
|
280
|
+
for elem in self.htmx_elements():
|
|
281
|
+
if settings.DEBUG:
|
|
282
|
+
logger.debug(f"Looking for htmx_form_valid_{elem}")
|
|
283
|
+
handler = getattr(self, f"htmx_form_valid_{elem}", False)
|
|
284
|
+
if handler and callable(handler):
|
|
285
|
+
with temp_attr(self, "_htmx_form_valid", True):
|
|
286
|
+
return handler(form)
|
|
287
|
+
if callable(handler := getattr(self, "htmx_form_valid", False)):
|
|
288
|
+
with temp_attr(self, "_htmx_form_valid", True):
|
|
289
|
+
return handler(form)
|
|
290
|
+
return super().form_valid(form)
|
|
291
|
+
|
|
292
|
+
def form_invalid(self, form):
|
|
293
|
+
"""Look for HTMX form valid handlers.
|
|
294
|
+
|
|
295
|
+
If request.htmx is not set, then return the parent class form_invalid, otherwise look for an element
|
|
296
|
+
specific htmx_form_invalid_<name> method, or failing that just an htmx_form_invalid meothd. Fnally,
|
|
297
|
+
give up and return the parent form_invalid.
|
|
298
|
+
|
|
299
|
+
Notes:
|
|
300
|
+
htmx_inform_valid* methods must not call super().form_valid - otherwise an infinite recursion happens!
|
|
301
|
+
"""
|
|
302
|
+
if not getattr(self.request, "htmx", False) or self._htmx_form_invalid: # Non HTMX requests
|
|
303
|
+
return super().form_invalid(form)
|
|
304
|
+
|
|
305
|
+
for elem in self.htmx_elements():
|
|
306
|
+
handler = getattr(self, f"htmx_form_invalid_{elem}", False)
|
|
307
|
+
if settings.DEBUG:
|
|
308
|
+
logger.debug(f"Looking for htmx_form_invalid_{elem}")
|
|
309
|
+
if handler and callable(handler):
|
|
310
|
+
with temp_attr(self, "_htmx_form_invalid", True):
|
|
311
|
+
return handler(form)
|
|
312
|
+
if callable(handler := getattr(self, "htmx_form_invalid", False)):
|
|
313
|
+
with temp_attr(self, "_htmx_form_invalid", True):
|
|
314
|
+
return handler(form)
|
|
315
|
+
return super().form_invalid(form)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def __getattr__(name):
|
|
319
|
+
"""Lazily preserve the former linked-select view import path."""
|
|
320
|
+
if name == "LinkedSelectEndpointView":
|
|
321
|
+
# app imports
|
|
322
|
+
from .linked_selects import LinkedSelectEndpointView
|
|
323
|
+
|
|
324
|
+
return LinkedSelectEndpointView
|
|
325
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _install_htmx_dispatch(view_class=View):
|
|
329
|
+
"""Install the HTMX-aware dispatch function idempotently."""
|
|
330
|
+
if not hasattr(view_class, "_non_htmx_dispatch"):
|
|
331
|
+
setattr(view_class, "_non_htmx_dispatch", view_class.dispatch)
|
|
332
|
+
setattr(view_class, "dispatch", dispatch)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
_install_htmx_dispatch()
|
htmx_views/widgets.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Create an htmx backed linked select widget."""
|
|
3
|
+
|
|
4
|
+
# Python imports
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
# Django imports
|
|
8
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
9
|
+
from django.forms.widgets import Select
|
|
10
|
+
from django.urls import reverse_lazy
|
|
11
|
+
from django.utils.text import format_lazy
|
|
12
|
+
|
|
13
|
+
# app imports
|
|
14
|
+
from ._optional import get_ajax_select_registry
|
|
15
|
+
|
|
16
|
+
registry = get_ajax_select_registry()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HTMXSelectWidget(Select):
|
|
20
|
+
"""Override SelectWidget to allow the options to be updated on a linked change.
|
|
21
|
+
|
|
22
|
+
This uses the machinery of django-ajax-select lookups to derive the list of options,
|
|
23
|
+
but uses htmx for the transfer mechanism.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# temmplate_name = "htmx_views/widgets/select.html"
|
|
27
|
+
|
|
28
|
+
def __init__(self, lookup_channel, parent=None, *args, **kwargs):
|
|
29
|
+
"""Extract linked Select.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
lookup_channel (str):
|
|
33
|
+
The name of an ajax_select lookup channel
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
self.lookup = registry.get(lookup_channel)
|
|
37
|
+
except ImproperlyConfigured as error:
|
|
38
|
+
raise ImproperlyConfigured(
|
|
39
|
+
f"Attempting to use a htmx_views widget with lookup channel {lookup_channel} that does not exist."
|
|
40
|
+
) from error
|
|
41
|
+
|
|
42
|
+
self.lookup_channel = lookup_channel
|
|
43
|
+
if parent is None:
|
|
44
|
+
parent = getattr(self.lookup, "parameter_name", None)
|
|
45
|
+
if parent is None:
|
|
46
|
+
raise ImproperlyConfigured(
|
|
47
|
+
f"Creating an htmx_views widget for {lookup_channel} without knowing the trigger."
|
|
48
|
+
)
|
|
49
|
+
self.parent_name = parent
|
|
50
|
+
endpoint = reverse_lazy("htmx_views:select", args=(self.lookup_channel,))
|
|
51
|
+
query_string = urlencode({"_htmx_parent": self.parent_name})
|
|
52
|
+
attrs = dict(kwargs.pop("attrs", {}))
|
|
53
|
+
attrs.update(
|
|
54
|
+
{
|
|
55
|
+
"hx-get": format_lazy("{}?{}", endpoint, query_string),
|
|
56
|
+
"hx-trigger": f"change from:#id_{self.parent_name}",
|
|
57
|
+
"hx-include": f"#id_{self.parent_name}",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
kwargs["attrs"] = attrs
|
|
61
|
+
|
|
62
|
+
super().__init__(*args, **kwargs)
|
|
63
|
+
|
|
64
|
+
def get_context(self, name, value, attrs):
|
|
65
|
+
"""Customise the context."""
|
|
66
|
+
attrs = dict(attrs or {})
|
|
67
|
+
attrs["hx-target"] = f"#id_{name}"
|
|
68
|
+
return super().get_context(name, value, attrs)
|