5 Commits

Author SHA1 Message Date
imperosol
b5d8db0187 apply review comments 2026-03-29 10:41:00 +02:00
imperosol
26178f10c5 use HTMX for club list page search 2026-03-29 10:37:26 +02:00
imperosol
842ae5615d add tests 2026-03-29 10:31:53 +02:00
imperosol
5cd748e313 add translations 2026-03-29 10:31:53 +02:00
imperosol
2944804074 rework club list page 2026-03-29 10:31:53 +02:00
9 changed files with 222 additions and 69 deletions

View File

@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, HasPerm from api.permissions import CanView, HasPerm
from club.models import Club, Membership from club.models import Club, Membership
from club.schemas import ( from club.schemas import (
ClubSchema, ClubSchema,
@@ -22,13 +22,11 @@ class ClubController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimpleClubSchema], response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
url_name="search_club", url_name="search_club",
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]): def search_club(self, filters: Query[ClubSearchFilterSchema]):
return filters.filter(Club.objects.all()) return filters.filter(Club.objects.order_by("name")).values()
@route.get( @route.get(
"/{int:club_id}", "/{int:club_id}",

View File

@@ -315,3 +315,22 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid" _("You are already a member of this club"), code="invalid"
) )
return super().clean() return super().clean()
class ClubSearchForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name"]
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
club_status = forms.NullBooleanField(
label=_("Club status"),
widget=forms.RadioSelect(
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
),
initial=True,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["name"].required = False

View File

@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
class Meta: class Meta:
model = Club model = Club
fields = ["id", "name", "logo"] fields = ["id", "name", "logo", "is_active", "short_description"]
url: str url: str

View File

@@ -0,0 +1,47 @@
#club-list {
display: flex;
flex-direction: column;
gap: 2em;
padding: 2em;
.card {
display: block;
background-color: unset;
.club-image {
float: left;
margin-right: 2rem;
margin-bottom: .5rem;
width: 150px;
height: 150px;
border-radius: 10%;
background-color: rgba(173, 173, 173, 0.2);
@media screen and (max-width: 500px) {
width: 100px;
height: 100px;
}
}
i.club-image {
display: flex;
flex-direction: column;
justify-content: center;
color: black;
}
.content {
display: block;
text-align: justify;
h4 {
margin-top: 0;
margin-right: .5rem;
}
p {
font-size: 100%;
}
}
}
}

View File

@@ -1,52 +1,75 @@
{% extends "core/base.jinja" %} {% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% block title -%} {# Don't display tabs and errors #}
{% trans %}Club list{% endtrans %} {% block tabs %}
{%- endblock %} {% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% endif %}
{% block description -%} {% from "core/macros.jinja" import paginate_htmx %}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%}
{% if club.is_active or user.is_root %}
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
{% if not club.is_active %}
({% trans %}inactive{% endtrans %})
{% endif %}
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
{% endif %}
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{%- endif -%}
</li>
{%- endmacro %}
{% block content %} {% block content %}
{% if user.is_root %} <main>
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p> <h3>{% trans %}Filters{% endtrans %}</h3>
{% endif %} <form
{% if club_list %} id="club-list-filters"
hx-get="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="outerHtml"
>
<div class="row gap-4x">
{{ form }}
</div>
<button type="submit" class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button>
</form>
<h3>{% trans %}Club list{% endtrans %}</h3> <h3>{% trans %}Club list{% endtrans %}</h3>
<ul> {% if user.has_perm("club.add_club") %}
{%- for club in club_list %} <br>
{{ display_club(club) }} <a href="{{ url('club:club_new') }}" class="btn btn-blue">
{%- endfor %} <i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
</ul> </a>
{% else %} {% endif %}
{% trans %}There is no club in this website.{% endtrans %} <section class="aria-busy-grow" id="club-list">
{% endif %} {% for club in object_list %}
<div class="card">
{% set club_url = club.get_absolute_url() %}
<a href="{{ club_url }}">
{% if club.logo %}
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
{% else %}
<i class="fa-regular fa-image fa-4x club-image"></i>
{% endif %}
</a>
<div class="content">
<a href="{{ club_url }}">
<h4>
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
{{ club.short_description|markdown }}
</div>
</div>
{% endfor %}
</section>
{% if is_paginated %}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
</main>
{% endblock %} {% endblock %}

View File

@@ -1,12 +1,15 @@
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User
@pytest.mark.django_db @pytest.mark.django_db
@@ -25,3 +28,14 @@ def test_club_queryset_having_board_member():
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True) club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id} assert set(club_ids) == {clubs[1].id, clubs[2].id}
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
@pytest.mark.parametrize("is_fragment", [True, False])
@pytest.mark.django_db
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
client.force_login(baker.make(User))
baker.make(Club, _quantity=nb_additional_clubs)
headers = {"HX-Request": True} if is_fragment else {}
res = client.get(reverse("club:club_list"), headers=headers)
assert res.status_code == 200

View File

@@ -44,13 +44,19 @@ from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import (
CreateView,
DeleteView,
FormMixin,
UpdateView,
)
from club.forms import ( from club.forms import (
ClubAddMemberForm, ClubAddMemberForm,
ClubAdminEditForm, ClubAdminEditForm,
ClubEditForm, ClubEditForm,
ClubOldMemberForm, ClubOldMemberForm,
ClubSearchForm,
JoinClubForm, JoinClubForm,
MailingForm, MailingForm,
SellingsForm, SellingsForm,
@@ -66,7 +72,12 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin from core.views.mixins import (
AllowFragment,
FragmentMixin,
FragmentRenderer,
TabedViewMixin,
)
from counter.models import Selling from counter.models import Selling
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -180,15 +191,41 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list return tab_list
class ClubListView(ListView): class ClubListView(AllowFragment, FormMixin, ListView):
"""List the Clubs.""" """List the clubs of the AE, with a form to perform basic search.
Notes:
This view is fully public, because we want to advertise as much as possible
the cultural life of the AE.
In accordance with that matter, searching and listing the clubs is done
entirely server-side (no AlpineJS involved) ;
this is done this way in order to be sure the page is the most accessible
and SEO-friendly possible, even if it makes the UX slightly less smooth.
"""
model = Club
template_name = "club/club_list.jinja" template_name = "club/club_list.jinja"
queryset = ( form_class = ClubSearchForm
Club.objects.filter(parent=None).order_by("name").prefetch_related("children") queryset = Club.objects.order_by("name")
) paginate_by = 1
context_object_name = "club_list"
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.GET:
res |= {"data": self.request.GET, "initial": self.request.GET}
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_bound:
return qs.filter(is_active=True)
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
qs = qs.filter(name__icontains=name)
if (is_active := form.cleaned_data.get("club_status")) is not None:
qs = qs.filter(is_active=is_active)
return qs
class ClubView(ClubTabsMixin, DetailView): class ClubView(ClubTabsMixin, DetailView):

View File

@@ -5,9 +5,8 @@
<details name="navbar" class="menu"> <details name="navbar" class="menu">
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary> <summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
<ul class="content"> <ul class="content">
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li> <li><a href="{{ url("core:page", page_name="ae") }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li> <li><a href="{{ url("club:club_list") }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
</ul> </ul>
</details> </details>
<details name="navbar" class="menu"> <details name="navbar" class="menu">

View File

@@ -310,16 +310,36 @@ msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM" msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "inactive" msgid "Filters"
msgstr "inactif" msgstr "Filtres"
#: club/templates/club/club_list.jinja
msgid "Name"
msgstr "Nom"
#: club/templates/club/club_list.jinja
msgid "Club state"
msgstr "Etat du club"
#: club/templates/club/club_list.jinja
msgid "Active"
msgstr "Actif"
#: club/templates/club/club_list.jinja
msgid "Inactive"
msgstr "Inactif"
#: club/templates/club/club_list.jinja
msgid "All clubs"
msgstr "Tous les clubs"
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja #: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
msgid "New club" msgid "New club"
msgstr "Nouveau club" msgstr "Nouveau club"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "There is no club in this website." msgid "inactive"
msgstr "Il n'y a pas de club dans ce site web." msgstr "inactif"
#: club/templates/club/club_members.jinja #: club/templates/club/club_members.jinja
msgid "Club members" msgid "Club members"
@@ -1881,10 +1901,6 @@ msgstr "L'AE"
msgid "AE's clubs" msgid "AE's clubs"
msgstr "Les clubs de L'AE" msgstr "Les clubs de L'AE"
#: core/templates/core/base/navbar.jinja
msgid "Others UTBM's Associations"
msgstr "Les autres associations de l'UTBM"
#: core/templates/core/base/navbar.jinja #: core/templates/core/base/navbar.jinja
msgid "Big event" msgid "Big event"
msgstr "Grandes Activités" msgstr "Grandes Activités"