Enhance MemberListView with HTMX integration, refactor member filter template for partial rendering, and add HTMX utility mixins.

This commit is contained in:
2026-01-10 23:57:06 +01:00
parent f4c5377727
commit d2d50afdd7
4 changed files with 258 additions and 162 deletions

View File

@@ -10,12 +10,13 @@ from rules.contrib.views import PermissionRequiredMixin
from members.filters import MemberFilter from members.filters import MemberFilter
from members.models import Member from members.models import Member
from ..mixins import HTMXViewMixin
class MemberListView(PermissionRequiredMixin, FilterView): class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
filterset_class = MemberFilter filterset_class = MemberFilter
paginate_by = 50 paginate_by = 50
permission_denied_message = _("You do not have permission to view this page.") permission_denied_message = _("You do not have permission to view this page.")
permission_required = "members.view_member" permission_required = "members.view_member"
partial_name = "members/member_filter.html#content"
def handle_no_permission(self) -> HttpResponseRedirect: def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message()) messages.error(self.request, self.get_permission_denied_message())

93
backend/mixins.py Normal file
View File

@@ -0,0 +1,93 @@
from django.http import HttpResponse
from django.template.loader import render_to_string
from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh
class HTMXPartialMixin:
"""Mixin that automatically switches to a partial template when the request is made via HTMX."""
partial_template_name = None
def get_template_names(self):
# If HTMX request and a partial is defined, return it
if getattr(self.request, "htmx", False) and self.partial_template_name:
return [self.partial_template_name]
return super().get_template_names()
class HTMXViewMixin:
"""
A full featured HTMX integration mixin for Django CBVs.
Supports:
- partial rendering via django-partials
- HX-Redirect
- HX-Push-URL
- HX-Trigger events
- HX-Refresh
- Graceful fallback to normal DJango rendering
"""
# Name of the partial block: template.html#partial_name
partial_name = None
# Optional: automatically push URL on GET
htmx_push_url = None
# Optional: trigger events after rendering
htmx_trigger = None
htmx_trigger_after_settle = None
htmx_trigger_after_swap = None
# Optional: redirect target for HTMX
htmx_redirect_url = None
# Optional: refresh the page
htmx_refresh = False
def render_partial(self, context):
"""Render a django-partials block."""
request = self.request
return render_to_string(self.partial_name, context, request=request)
def apply_htmx_headers(self, response):
"""Attach HX-* headers to the response."""
request = self.request
if request.htmx:
is_get = request.method == "GET"
is_pagination = "page" in request.GET
if is_get and not is_pagination:
# Push the current path unless overridden
response.headers["HX-Push-Url"] = self.htmx_push_url or request.get_full_path()
print(response.headers)
if self.htmx_trigger:
response.headers["HX-Trigger"] = self.htmx_trigger
if self.htmx_trigger_after_settle:
response.headers["HX-Trigger-After-Settle"] = self.htmx_trigger_after_settle
if self.htmx_trigger_after_swap:
response.headers["HX-Trigger-After-Swap"] = self.htmx_trigger_after_swap
return response
def render_to_response(self, context, **response_kwargs):
"""Renders HTMX response, applying headers and handling directives"""
request = self.request
if not request.htmx:
response = super().render_to_response(context, **response_kwargs)
return self.apply_htmx_headers(response)
if self.htmx_redirect_url:
return HttpResponseClientRedirect(self.htmx_redirect_url)
if self.htmx_refresh:
return HttpResponseClientRefresh()
html = self.render_partial(context)
response = HttpResponse(html, **response_kwargs)
return self.apply_htmx_headers(response)

View File

@@ -101,7 +101,7 @@
<!-- MAIN CONTENT--> <!-- MAIN CONTENT-->
<div class="drawer-content flex w-full"> <div class="drawer-content flex w-full">
<main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full"> <main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full" id="content">
{% block content %} {% block content %}
<h1 class="text-3xl font-bold">Welcome!</h1> <h1 class="text-3xl font-bold">Welcome!</h1>
<p>This is your main content area.</p> <p>This is your main content area.</p>

View File

@@ -6,189 +6,191 @@
{% load pagination %} {% load pagination %}
{% block content %} {% block content %}
<h1 class="page-title">Members</h1> {% partialdef content inline %}
<h1 class="page-title">Members</h1>
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border"> <div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border">
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-sm font-semibold"><i class="fa-solid fa-filter mr-2"></i>{% translate "Filter" %}{% if filter.is_bound %}<span class="ml-2 badge badge-sm badge-neutral">active</span>{% endif %}</div> <div class="collapse-title text-sm font-semibold"><i class="fa-solid fa-filter mr-2"></i>{% translate "Filter" %}{% if filter.is_bound %}<span class="ml-2 badge badge-sm badge-neutral">active</span>{% endif %}</div>
<div class="collapse-content"> <div class="collapse-content">
<form class="flex flex-col gap-2"> <form class="flex flex-col gap-2" hx-get="{% url "backend:members:list" %}" hx-target="#content">
{% for field in filter.form %} {% for field in filter.form %}
{% form_field field show_label=False size="small" %} {% form_field field show_label=False size="small" %}
{% endfor %} {% endfor %}
<div class="flex flex-row w-full gap-2"> <div class="flex flex-row w-full gap-2">
<button type="submit" class="btn btn-sm btn-outline btn-neutral grow"> <button type="submit" class="btn btn-sm btn-outline btn-neutral grow">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %} <i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button> </button>
{% if filter.is_bound %} {% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-sm grow" href="{% url "backend:members:list" %}"> <a class="btn btn-outline btn-error btn-sm grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %} <i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
</form> </form>
</div> </div>
</div>
<div class="action_bar">
<div class="filter hidden lg:flex">
<form>
{% for field in filter.form %}
{% form_field field show_label=False size="extra-small" %}
{% endfor %}
<div class="flex flex-row w-full gap-2 lg:w-fit">
<button type="submit">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button>
{% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-xs grow" href="{% url "backend:members:list" %}">
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a>
{% endif %}
</div>
</form>
</div> </div>
<div class="add"> <div class="action_bar">
<a class="btn btn-accent btn-sm grow hidden lg:flex" href=""> <div class="filter hidden lg:flex">
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %} <form hx-get="{% url "backend:members:list" %}" hx-target="#content">
</a> {% for field in filter.form %}
{% form_field field show_label=False size="extra-small" %}
{% endfor %}
<a class="btn btn-neutral btn-outline btn-sm grow" href=""> <div class="flex flex-row w-full gap-2 lg:w-fit">
<i class="fa-solid fa-plus"></i>{% translate "Add member" %} <button type="submit">
</a> <i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button>
{% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-xs grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a>
{% endif %}
</div>
</form>
</div>
<div class="add">
<a class="btn btn-accent btn-sm grow hidden lg:flex" href="">
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %}
</a>
<a class="btn btn-neutral btn-outline btn-sm grow" href="">
<i class="fa-solid fa-plus"></i>{% translate "Add member" %}
</a>
</div>
</div> </div>
</div>
<div class="hidden lg:flex mt-4"> <div class="hidden lg:flex mt-4">
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr>
<th>{% translate "Member" %}</th>
<th>{% translate "Birthday" %}</th>
<th>{% translate "License" %}</th>
<th>{% translate "Phone number(s)" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% if object_list|length == 0 %}
<tr> <tr>
<td colspan="5">{% translate "No members found" %}</td> <th>{% translate "Member" %}</th>
<th>{% translate "Birthday" %}</th>
<th>{% translate "License" %}</th>
<th>{% translate "Phone number(s)" %}</th>
<th></th>
</tr> </tr>
{% else %} </thead>
{% for member in object_list %} <tbody>
<tr class="hover:bg-base-300"> {% if object_list|length == 0 %}
<td> <tr>
<a href=""> <td colspan="5">{% translate "No members found" %}</td>
<div class="flex flex-row items-center gap-3"> </tr>
<div> {% else %}
{% avatar first_name=member.user.first_name last_name=member.user.last_name %} {% for member in object_list %}
</div> <tr class="hover:bg-base-300">
<td>
<a href="">
<div class="flex flex-row items-center gap-3">
<div>
{% avatar first_name=member.user.first_name last_name=member.user.last_name %}
</div>
<div class="flex flex-col grow"> <div class="flex flex-col grow">
<div class="font-bold">{{ member.user.get_full_name }}</div> <div class="font-bold">{{ member.user.get_full_name }}</div>
<div class="text-sm opacity-50">{{ member.user.email }}</div> <div class="text-sm opacity-50">{{ member.user.email }}</div>
</div> </div>
{% if member.user.is_superuser %} {% if member.user.is_superuser %}
<div class="badge badge-sm badge-accent"><i class="fa-solid fa-user-shield"></i>{% translate "Admin" %}</div> <div class="badge badge-sm badge-accent"><i class="fa-solid fa-user-shield"></i>{% translate "Admin" %}</div>
{% endif %}
{% if not member.user.is_active %}
<div class="badge badge-neutral badge-sm">{% translate "Inactive"%}</div>
{% endif %}
</div>
</a>
</td>
<td>{{ member.birthday|date:"d M Y"|default:"-" }}</td>
<td>{{ member.license|default:"-" }}</td>
<td>
<div class="flex flex-col gap-1">
{% if member.phone_number %}
<a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-info btn-xs">
<i class="fa-solid fa-phone"></i>{{ member.phone_number }}
</a>
{% endif %} {% endif %}
{% if not member.user.is_active %} {% if member.emergency_phone_number %}
<div class="badge badge-neutral badge-sm">{% translate "Inactive"%}</div> <a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-error btn-xs">
<i class="fa-solid fa-star-of-life"></i>{{ member.emergency_phone_number }}
</a>
{% endif %} {% endif %}
</div> </div>
</a> </td>
</td> <td>
<td>{{ member.birthday|date:"d M Y"|default:"-" }}</td> <div class="flex flex-row gap-2">
<td>{{ member.license|default:"-" }}</td> <a class="btn btn-outline btn-sm" href="">
<td> <i class="fa-solid fa-eye"></i>{% translate "Details" %}
<div class="flex flex-col gap-1">
{% if member.phone_number %}
<a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-info btn-xs">
<i class="fa-solid fa-phone"></i>{{ member.phone_number }}
</a> </a>
{% endif %}
{% if member.emergency_phone_number %} <a class="btn btn-outline btn-error btn-sm" href="">
<a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-error btn-xs"> <i class="fa-solid fa-trash"></i>{% translate "Delete" %}
<i class="fa-solid fa-star-of-life"></i>{{ member.emergency_phone_number }}
</a> </a>
{% endif %} </div>
</div> </td>
</td> </tr>
<td> {% endfor %}
<div class="flex flex-row gap-2"> {% endif %}
<a class="btn btn-outline btn-sm" href=""> </tbody>
<i class="fa-solid fa-eye"></i>{% translate "Details" %} </table>
</a> </div>
<a class="btn btn-outline btn-error btn-sm" href=""> <div class="lg:hidden">
<i class="fa-solid fa-trash"></i>{% translate "Delete" %} {% if object_list|length == 0 %}
</a> <div class="text-center w-full">{% translate "No members found" %}</div>
</div> {% else %}
</td> <div class="flex flex-col gap-1">
</tr> {% for member in object_list %}
<a class="border border-base-300 rounded-lg p-2 flex flex-row gap-2 items-center" href="">
<div>
{% avatar first_name=member.user.first_name last_name=member.user.last_name width="sm" %}
</div>
<div class="grow">
<div class="font-semibold text-sm">{{ member.user.get_full_name }} {% if member.license %}#{{ member.license }}{% endif %}</div>
<div class="opacity-50 text-xs">{{ member.birthday|date:"d M Y"|default:"" }}</div>
</div>
<div>
<i class="fa-solid fa-chevron-right"></i>
</div>
</a>
{% endfor %} {% endfor %}
{% endif %} </div>
</tbody> {% endif %}
</table> </div>
</div>
<div class="lg:hidden"> {% if is_paginated %}
{% if object_list|length == 0 %} <div class="flex justify-center mt-4">
<div class="text-center w-full">{% translate "No members found" %}</div> <div class="join">
{% else %} {% if page_obj.has_previous %}
<div class="flex flex-col gap-1"> <a class="join-item btn" href="?{% url_replace request "page" 1 %}" hx-get="?{% url_replace request "page" 1 %}" hx-target="#content">&laquo;<span
{% for member in object_list %} class="hidden lg:inline"> {% translate "first" %}</span></a>
<a class="border border-base-300 rounded-lg p-2 flex flex-row gap-2 items-center" href=""> <a class="join-item btn"
<div> href="?{% url_replace request "page" page_obj.previous_page_number %}" hx-get="?{% url_replace request "page" page_obj.previous_page_number %}" hx-target="#content"><span
{% avatar first_name=member.user.first_name last_name=member.user.last_name width="sm" %} class="hidden lg:inline">{% translate "previous" %}</span><span
</div> class="lg:hidden">&lt;</span></a>
{% endif %}
<div class="grow"> <button class="join-item btn btn-disabled">
<div class="font-semibold text-sm">{{ member.user.get_full_name }} {% if member.license %}#{{ member.license }}{% endif %}</div> {% blocktranslate with page=page_obj.number num_pages=page_obj.paginator.num_pages %}page {{ page }}
<div class="opacity-50 text-xs">{{ member.birthday|date:"d M Y"|default:"" }}</div> of {{ num_pages }}{% endblocktranslate %}</button>
</div>
<div> {% if page_obj.has_next %}
<i class="fa-solid fa-chevron-right"></i> <a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}" hx-get="?{% url_replace request "page" page_obj.next_page_number %}" hx-target="#content"><span
</div> class="hidden lg:inline">{% translate "next" %}</span><span
</a> class="lg:hidden">&gt;</span></a>
{% endfor %} <a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.num_pages %}" hx-target="#content"><span
class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
</div> {% endpartialdef content %}
{% if is_paginated %}
<div class="flex justify-center mt-4">
<div class="join">
{% if page_obj.has_previous %}
<a class="join-item btn" href="?{% url_replace request "page" 1 %}">&laquo;<span
class="hidden lg:inline"> {% translate "first" %}</span></a>
<a class="join-item btn"
href="?{% url_replace request "page" page_obj.previous_page_number %}"><span
class="hidden lg:inline">{% translate "previous" %}</span><span
class="lg:hidden">&lt;</span></a>
{% endif %}
<button class="join-item btn btn-disabled">
{% blocktranslate with page=page_obj.number num_pages=page_obj.paginator.num_pages %}page {{ page }}
of {{ num_pages }}{% endblocktranslate %}</button>
{% if page_obj.has_next %}
<a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}"><span
class="hidden lg:inline">{% translate "next" %}</span><span
class="lg:hidden">&gt;</span></a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}"><span
class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock content %} {% endblock content %}