diff --git a/club/api.py b/club/api.py
index 3ed425bf..cde007c2 100644
--- a/club/api.py
+++ b/club/api.py
@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
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.schemas import (
ClubSchema,
@@ -22,13 +22,11 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
- auth=[ApiKeyAuth(), SessionAuth()],
- permissions=[CanAccessLookup],
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]):
- return filters.filter(Club.objects.all())
+ return filters.filter(Club.objects.order_by("name")).values()
@route.get(
"/{int:club_id}",
diff --git a/club/forms.py b/club/forms.py
index dcd270e7..3c10dfce 100644
--- a/club/forms.py
+++ b/club/forms.py
@@ -315,3 +315,27 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid"
)
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, data: dict | None = None, **kwargs):
+ super().__init__(*args, data=data, **kwargs)
+ if data is not None and "club_status" not in data:
+ # if the key is missing, it is considered as None,
+ # even though we want the default True value to be applied in such a case
+ # so we enforce it.
+ self.fields["club_status"].value = True
+ self.fields["name"].required = False
diff --git a/club/schemas.py b/club/schemas.py
index 08488c31..02622110 100644
--- a/club/schemas.py
+++ b/club/schemas.py
@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
class Meta:
model = Club
- fields = ["id", "name", "logo"]
+ fields = ["id", "name", "logo", "is_active", "short_description"]
url: str
diff --git a/club/static/club/list.scss b/club/static/club/list.scss
new file mode 100644
index 00000000..9fbf952f
--- /dev/null
+++ b/club/static/club/list.scss
@@ -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%;
+ }
+ }
+ }
+}
diff --git a/club/templates/club/club_list.jinja b/club/templates/club/club_list.jinja
index da0a54be..8ae08bf3 100644
--- a/club/templates/club/club_list.jinja
+++ b/club/templates/club/club_list.jinja
@@ -1,52 +1,76 @@
-{% extends "core/base.jinja" %}
+{% if is_fragment %}
+ {% extends "core/base_fragment.jinja" %}
-{% block title -%}
- {% trans %}Club list{% endtrans %}
-{%- endblock %}
+ {# Don't display tabs and errors #}
+ {% block tabs %}
+ {% endblock %}
+ {% block errors %}
+ {% endblock %}
+{% else %}
+ {% extends "core/base.jinja" %}
+ {% block additional_css %}
+
+ {% endblock %}
+ {% block description -%}
+ {% trans %}The list of all clubs existing at UTBM.{% endtrans %}
+ {%- endblock %}
+ {% block title -%}
+ {% trans %}Club list{% endtrans %}
+ {%- endblock %}
+{% endif %}
-{% block description -%}
- {% trans %}The list of all clubs existing at UTBM.{% endtrans %}
-{%- endblock %}
-
-{% macro display_club(club) -%}
-
- {% if club.is_active or user.is_root %}
-
-
{{ club.name }}
-
- {% if not club.is_active %}
- ({% trans %}inactive{% endtrans %})
- {% endif %}
-
- {% if club.president %} - {{ club.president.user }} {% endif %}
- {% if club.short_description %}{{ club.short_description|markdown }}
{% endif %}
-
- {% endif %}
-
- {%- if club.children.all()|length != 0 %}
-
- {%- for c in club.children.order_by('name').prefetch_related("children") %}
- {{ display_club(c) }}
- {%- endfor %}
-
- {%- endif -%}
-
-{%- endmacro %}
+{% from "core/macros.jinja" import paginate_htmx %}
{% block content %}
- {% if user.is_root %}
- {% trans %}New club{% endtrans %}
- {% endif %}
- {% if club_list %}
+
+ {% trans %}Filters{% endtrans %}
+
{% trans %}Club list{% endtrans %}
-
- {%- for club in club_list %}
- {{ display_club(club) }}
- {%- endfor %}
-
- {% else %}
- {% trans %}There is no club in this website.{% endtrans %}
- {% endif %}
+ {% if user.has_perm("club.add_club") %}
+
+
+ {% trans %}New club{% endtrans %}
+
+ {% endif %}
+
+ {% for club in object_list %}
+
+ {% endfor %}
+
+ {% if is_paginated %}
+ {{ paginate_htmx(request, page_obj, paginator) }}
+ {% endif %}
+
{% endblock %}
diff --git a/club/tests/test_club.py b/club/tests/test_club.py
index 2a232b19..4c69b2c4 100644
--- a/club/tests/test_club.py
+++ b/club/tests/test_club.py
@@ -1,12 +1,15 @@
from datetime import timedelta
import pytest
+from django.test import Client
+from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
+from core.models import User
@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)
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
diff --git a/club/views.py b/club/views.py
index 9077d0d7..6a9963a9 100644
--- a/club/views.py
+++ b/club/views.py
@@ -44,13 +44,19 @@ from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
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 (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
+ ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
@@ -66,7 +72,12 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
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
if TYPE_CHECKING:
@@ -180,15 +191,41 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list
-class ClubListView(ListView):
- """List the Clubs."""
+class ClubListView(AllowFragment, FormMixin, ListView):
+ """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"
- queryset = (
- Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
- )
- context_object_name = "club_list"
+ form_class = ClubSearchForm
+ queryset = Club.objects.order_by("name")
+ paginate_by = 20
+
+ def get_form_kwargs(self):
+ res = super().get_form_kwargs()
+ if self.request.method == "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):
diff --git a/core/templates/core/base/navbar.jinja b/core/templates/core/base/navbar.jinja
index fd1e6ddc..64ca9721 100644
--- a/core/templates/core/base/navbar.jinja
+++ b/core/templates/core/base/navbar.jinja
@@ -5,9 +5,8 @@