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 %} - - {%- 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 %}

    +
    +
    + {{ form }} +
    + +

    {% trans %}Club list{% endtrans %}

    - - {% 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 @@