HTMX features tend to scatter across urls.py, views.py, and a handful of template
snippets. djxi solves this by bundling the URL patterns, view logic, and HTML sections
for a feature into a single class — the DXEndpointBattery.
from djxi import DXEndpointBattery, dx_get, dx_post, dx_delete
class TodoBattery(DXEndpointBattery):
inline_template = """
<dx-section name="list">
<ul>{% for item in items %}<li>{{ item }}</li>{% endfor %}</ul>
</dx-section>
<dx-section name="form">
<form hx-post="{% url 'todo:create' %}">...</form>
</dx-section>
"""
@dx_get("list", name="list")
def list(self, request):
return self.render_section(request, "list", {"items": Item.objects.all()})
@dx_post("create", name="create")
def create(self, request):
Item.objects.create(title=request.POST["title"])
return self.render_section(request, "list", {"items": Item.objects.all()})
@dx_delete("item/<int:pk>/delete", name="delete")
def delete(self, request, pk):
Item.objects.filter(pk=pk).delete()
return self.render_empty(request)# urls.py
urlpatterns = [
path("todo/", include((TodoBattery.url_patterns(), "todo"), namespace="todo")),
]| Feature | Description |
|---|---|
| DXEndpointBattery | Bundles URLs, view logic, and HTML in one class |
<dx-section> / <dx-include> |
Split templates into named, reusable sections |
| Routing decorators | @dx_get, @dx_post, @dx_put, @dx_patch, @dx_delete, @dx_action |
battery_prefix |
Per-battery default URL prefix (overrides global DX_ROUTER_PREFIX) |
| Async handlers | async def methods get ASGI-compatible views; arender_section() for async rendering |
| Permission hooks | requires_auth, login_url, and check_permissions() override |
| HTMX headers | request.htmx / response.htmx with fluent chainable setters |
| Method override | X-HTTP-Method-Override header and <input name="_method"> POST field |
| Django messages | Out-of-band message injection via hx-swap-oob |
djxi_routes |
Management command listing all registered battery routes |
djxi.testing |
DXRequestFactory, DXBatteryTestCase, assertion helpers |
| Typed | Full type annotations; ships py.typed for PEP 561 |
| HTMX 2 & 4 | Both HTMX versions supported; switch with DX_HTMX_VERSION |
pip install djxisettings.py:
INSTALLED_APPS = [
# ...
"djxi",
]
MIDDLEWARE = [
# ... Django middleware ...
"djxi.middleware.DjxiHeadersMiddleware", # optional but recommended
]Base template:
{% load djxi %}
<!doctype html>
<html>
<head>
{% htmx_script_inclusion %}
</head>
<body {% htmx_headers %}>
{% flash_messages_inclusion %}
{% block content %}{% endblock %}
</body>
</html>Templates are split using custom HTML tags (no Django template syntax required — they sit alongside it):
<dx-section name="item-form">
<form hx-post="{% url 'todo:create' %}">
<input name="title">
<button>Add</button>
</form>
</dx-section>
<dx-section name="item-row">
<li id="item-{{ item.pk }}">
{{ item.title }}
<dx-include name="item-actions"/> {# reuse another section inline #}
</li>
</dx-section>
<dx-section name="item-actions">
<button hx-delete="{% url 'todo:delete' item.pk %}">Delete</button>
</dx-section>Decorators mark methods as endpoints. url_patterns() converts them to Django paths:
class MyBattery(DXEndpointBattery):
battery_prefix = "htmx" # overrides DX_ROUTER_PREFIX for this battery
@dx_action("item/<int:pk>", methods=["GET", "POST"], name="item")
def item(self, request, pk):
...
@dx_delete("item/<int:pk>/delete", name="item-delete")
def delete(self, request, pk):
...urlpatterns = [
path("api/", include(MyBattery.url_patterns())),
# → GET/POST api/htmx/item/<pk>
# → DELETE api/htmx/item/<pk>/delete
]@dx_put("item/<int:pk>/flag", name="flag")
def flag(self, request, pk):
item = Item.objects.get(pk=pk)
response = self.render_section(request, "item-row", {"item": item})
# Fluent chaining — all setters return self
response.htmx.set_trigger("itemFlagged").set_retarget(f"#item-{pk}")
return response@dx_get("items/", name="items")
async def items(self, request):
items = [item async for item in Item.objects.all()]
return await self.arender_section(request, "item-list", {"items": items})class ProtectedBattery(DXEndpointBattery):
requires_auth = True # 403 for anonymous users
login_url = "/login/" # redirect instead of 403
# Or fine-grained:
def check_permissions(self, request):
if not request.user.has_perm("myapp.can_edit"):
return HttpResponseForbidden()
return None # allow throughfrom djxi.testing import DXBatteryTestCase
class TodoBatteryTests(DXBatteryTestCase):
def test_list_renders_items(self):
request = self.dx.get("/") # middleware already applied
response = TodoBattery().render_section(request, "list", {"items": []})
self.assert_section_rendered(response, "No todos found")All settings are optional. Override in your settings.py:
| Setting | Default | Description |
|---|---|---|
DX_HTMX_VERSION |
"4" |
HTMX version for CDN script tag ("2" or "4") |
DX_HTMX_COMPRESSION |
".js" |
Script variant (".js" or ".min.js") |
DX_ROUTER_PREFIX |
"dx" |
Global URL prefix prepended to all battery routes |
DX_MESSAGE_CONTAINER_ID |
"message-container" |
DOM ID for OOB message swap target |
DX_MESSAGE_SWAP_METHOD |
"beforeend" |
hx-swap-oob insertion method |
DX_MESSAGE_TEMPLATE |
"djxi/messages/message_list.html" |
Template for the message list |
DJXI_ROUTE_MODULES |
["views","endpoints","batteries"] |
Modules scanned by djxi_routes |
Constants (cannot be overridden via settings.py):
| Constant | Value |
|---|---|
DX_SECTION_TAG |
"dx-section" |
DX_INCLUDE_TAG |
"dx-include" |
Pre-Alpha — experimental. API may change between minor versions.
- v0.2.0: Public Alpha — improve existing facilities / coverage
- v0.3.0: Public Beta
- v1.0.0: Stable release
