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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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
@@ -0,0 +1,3 @@
1
+ """HTMX views package for handling HTMX-specific view components."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Created on Thu Dec 26 14:46:18 2024
4
+
5
+ @author: phygbu
6
+ """
@@ -0,0 +1,4 @@
1
+ <option value="">---------</option>
2
+ {% for value, label in options %}
3
+ <option value="{{ value }}">{{ label }}</option>
4
+ {% endfor %}
@@ -0,0 +1,3 @@
1
+ <select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} id="id_{{ name }}">
2
+ <option>Select...</option>
3
+ </select>
@@ -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)