Enhance MemberListView with HTMX integration, refactor member filter template for partial rendering, and add HTMX utility mixins.
This commit is contained in:
@@ -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
93
backend/mixins.py
Normal 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)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">«<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"><</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">></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> »</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 %}">«<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"><</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">></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> »</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
Reference in New Issue
Block a user