7 Commits

Author SHA1 Message Date
imperosol
4027f5d1d6 add tests 2026-03-20 17:02:43 +01:00
imperosol
efea12cf1b add translations 2026-03-20 17:02:43 +01:00
imperosol
41eb7cde3e rework club list page 2026-03-20 17:02:43 +01:00
thomas girod
ffa0b94408 Merge pull request #1319 from ae-utbm/show-my-stats
show user stats to subscribers if show_my_stats is enabled
2026-03-20 13:49:48 +01:00
thomas girod
22a1f4ba07 Merge pull request #1317 from ae-utbm/remove-settings
remove unused settings
2026-03-20 13:47:22 +01:00
imperosol
1c0b89bfc7 show user stats to subscribers if show_my_stats is enabled 2026-03-14 16:23:56 +01:00
imperosol
f1a60e589a remove unused settings 2026-03-12 10:26:40 +01:00
17 changed files with 275 additions and 189 deletions

View File

@@ -6,9 +6,10 @@ 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 (
ClubProfileSchema,
ClubSchema,
ClubSearchFilterSchema,
SimpleClubSchema,
@@ -22,13 +23,21 @@ 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(
"/search-profile",
response=PaginatedResponseSchema[ClubProfileSchema],
url_name="search_club_profile",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club_profile(self, filters: Query[ClubSearchFilterSchema]):
"""Same as /api/club/search, but with more returned data"""
return filters.filter(Club.objects.order_by("name"))
@route.get(
"/{int:club_id}",

View File

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

View File

@@ -0,0 +1,63 @@
import { History, updateQueryString } from "#core:utils/history";
import {
type ClubProfileSchema,
type ClubSearchClubData,
clubSearchClubProfile,
type Options,
} from "#openapi";
type ClubStatus = "active" | "inactive" | "both";
const PAGE_SIZE = 50;
document.addEventListener("alpine:init", () => {
Alpine.data("clubList", () => ({
clubName: "",
clubStatus: "active" as ClubStatus,
currentPage: 1,
nbPages: 1,
clubs: [] as ClubProfileSchema[],
loading: false,
async init() {
const urlParams = new URLSearchParams(window.location.search);
this.clubName = urlParams.get("clubName") || "";
this.clubStatus = urlParams.get("clubStatus") || "active";
this.currentPage = urlParams.get("currentPage") || 1;
for (const param of ["clubName", "clubStatus", "currentPage"]) {
this.$watch(param, async (value: number | string) => {
updateQueryString(param, value.toString(), History.Replace);
await this.loadClubs();
});
}
await this.loadClubs();
},
async loadClubs() {
this.loading = true;
const searchParams: Options<ClubSearchClubData> = {
query: { page: this.currentPage },
};
if (this.clubName) {
searchParams.query.search = this.clubName;
}
if (this.clubStatus === "active") {
searchParams.query.is_active = true;
} else if (this.clubStatus === "inactive") {
searchParams.query.is_active = false;
}
const res = await clubSearchClubProfile(searchParams);
this.nbPages = Math.ceil(res.data.count / PAGE_SIZE);
this.clubs = res.data.results;
this.loading = false;
},
getParagraphs(s: string) {
if (!s) {
return [];
}
return s
.split("\n")
.map((s) => s.trim())
.filter((s) => s !== "");
},
}));
});

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,4 +1,5 @@
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import paginate_alpine %}
{% block title -%}
{% trans %}Club list{% endtrans %}
@@ -8,45 +9,76 @@
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%}
{% block additional_js %}
<script type="module" src="{{ static("bundled/club/club-list-index.ts") }}"></script>
{% endblock %}
{% 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 additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block content %}
{% if user.is_root %}
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
{% endif %}
{% if club_list %}
<main x-data="clubList">
<h3>{% trans %}Filters{% endtrans %}</h3>
<form id="club-list-filters">
<div class="row gap-4x">
<fieldset>
<label for="club-name-input">{% trans %}Name{% endtrans %}</label>
<input
id="club-name-input"
type="text"
name="club-name"
x-model.debounce.500ms="clubName"
/>
</fieldset>
<fieldset class="grow">
<legend>{% trans %}Club state{% endtrans %}</legend>
<div class="row">
<input type="radio" id="filter-active-clubs" x-model="clubStatus" value="active">
<label for="filter-active-clubs">{% trans %}Active{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-inactive-clubs" x-model="clubStatus" value="inactive">
<label for="filter-inactive-clubs">{% trans %}Inactive{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-all-clubs" x-model="clubStatus" value="both">
<label for="filter-all-clubs">{% trans %}All clubs{% endtrans %}</label>
</div>
</fieldset>
</div>
</form>
<h3>{% trans %}Club list{% endtrans %}</h3>
<ul>
{%- for club in club_list %}
{{ display_club(club) }}
{%- endfor %}
</ul>
{% else %}
{% trans %}There is no club in this website.{% endtrans %}
{% if user.has_perm("club.add_club") %}
<br>
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
</a>
{% endif %}
<section class="aria-busy-grow" :aria-busy="loading" id="club-list">
<template x-for="club of clubs" :key="club.id">
<div class="card">
<a :href="club.url">
<template x-if="club.logo">
<img class="club-image" :src="club.logo" :alt="`logo ${club.name}`">
</template>
<template x-if="!club.logo">
<i class="fa-regular fa-image fa-4x club-image"></i>
</template>
</a>
<div class="content">
<a :href="club.url">
<h4 x-text="`${club.name} ${club.is_active ? '' : '({% trans %}inactive{% endtrans %})'}`"></h4>
</a>
<template x-for="paragraph of getParagraphs(club.short_description)">
<p x-text="paragraph"></p>
</template>
</div>
</div>
</template>
</section>
{{ paginate_alpine("currentPage", "nbPages") }}
</main>
{% endblock %}

View File

@@ -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,10 @@ 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.django_db
def test_club_list(client: Client):
client.force_login(baker.make(User))
res = client.get(reverse("club:club_list"))
assert res.status_code == 200

View File

@@ -59,6 +59,14 @@ class TestClubSearch(TestCase):
ids = {d["id"] for d in response.json()["results"]}
assert ids == {c.id for c in [self.clubs[0], self.clubs[1], self.clubs[3]]}
def test_club_search_profile(self):
self.client.force_login(self.user)
url = reverse("api:search_club_profile")
response = self.client.get(url, {"search": "AE"})
assert response.status_code == 200
ids = {d["id"] for d in response.json()["results"]}
assert ids == {c.id for c in [self.clubs[0], self.clubs[1], self.clubs[3]]}
@pytest.mark.django_db
class TestFetchClub:

View File

@@ -42,7 +42,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
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 import DetailView, TemplateView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView
@@ -180,15 +180,10 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list
class ClubListView(ListView):
class ClubListView(TemplateView):
"""List the Clubs."""
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"
class ClubView(ClubTabsMixin, DetailView):

View File

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

View File

@@ -248,14 +248,15 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Groups"),
}
)
if (
can_view_account = (
hasattr(user, "customer")
and user.customer
and (
user == self.request.user
or self.request.user.has_perm("counter.view_customer")
)
):
)
if can_view_account or user.preferences.show_my_stats:
tab_list.append(
{
"url": reverse("core:user_stats", kwargs={"user_id": user.id}),
@@ -263,6 +264,7 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Stats"),
}
)
if can_view_account:
tab_list.append(
{
"url": reverse("core:user_account", kwargs={"user_id": user.id}),
@@ -349,7 +351,7 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
"""Display a user's stats."""
model = User
@@ -357,15 +359,20 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
context_object_name = "profile"
template_name = "core/user_stats.jinja"
current_tab = "stats"
queryset = User.objects.exclude(customer=None).select_related("customer")
queryset = User.objects.exclude(customer=None).select_related(
"customer", "_preferences"
)
def dispatch(self, request, *arg, **kwargs):
profile = self.get_object()
if not (
profile == request.user or request.user.has_perm("counter.view_customer")
):
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def test_func(self):
profile: User = self.get_object()
return (
profile == self.request.user
or self.request.user.has_perm("counter.view_customer")
or (
self.request.user.can_view(profile)
and profile.preferences.show_my_stats
)
)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)

View File

@@ -116,56 +116,6 @@
</span>
</div>
{% endif %}
<section>
<div class="category-header">
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
{% if user.is_subscribed %}
<div id="eurock-partner" style="
min-height: 600px;
background-color: lightgrey;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
">
<p style="text-align: center;">
{% trans trimmed %}
Our partner uses Weezevent to sell tickets.
Weezevent may collect user info according to
its own privacy policy.
By clicking the accept button you consent to
their terms of services.
{% endtrans %}
</p>
<a href="https://weezevent.com/fr/politique-de-confidentialite/">{% trans %}Privacy policy{% endtrans %}</a>
<button
hx-get="{{ url("eboutic:eurock") }}"
hx-target="#eurock-partner"
hx-swap="outerHTML"
hx-trigger="click, load[document.cookie.includes('weezevent_accept=true')]"
@htmx:after-request="document.cookie = 'weezevent_accept=true'"
>{% trans %}Accept{% endtrans %}
</button>
</div>
{% else %}
<p>
{%- trans trimmed %}
You must be subscribed to benefit from the partnership with the Eurockéennes.
{% endtrans -%}
</p>
<p>
{%- trans trimmed %}
This partnership offers a discount of up to 33%
on tickets for Friday, Saturday and Sunday,
as well as the 3-day package from Friday to Sunday.
{% endtrans -%}
</p>
{% endif %}
</div>
</section>
{% for priority_groups in products|groupby('order') %}
{% for category, items in priority_groups.list|groupby('category') %}
{% if items|count > 0 %}

View File

@@ -1,16 +0,0 @@
<a title="Logiciel billetterie en ligne"
href="https://www.weezevent.com?c=sys_widget"
class="weezevent-widget-integration"
target="_blank"
data-src="https://widget.weezevent.com/ticket/8aaba226-f7a3-4192-a64e-72ff8f5b35b7?id_evenement=1419869&locale=fr-FR&code=28747"
data-width="650"
data-height="600"
data-resize="1"
data-nopb="0"
data-type="neo"
data-width_auto="1"
data-noscroll="0"
data-id="1419869">
Billetterie Weezevent
</a>
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>

View File

@@ -0,0 +1,17 @@
<a
title="Logiciel billetterie en ligne"
href="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
class="weezevent-widget-integration"
target="_blank"
data-src="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
data-width="650"
data-height="600"
data-resize="1"
data-nopb="0"
data-type="neo"
data-width_auto="1"
data-noscroll="0"
data-id="1211855">
Billetterie Weezevent
</a>
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>

View File

@@ -31,7 +31,6 @@ from eboutic.views import (
EbouticMainView,
EbouticPayWithSith,
EtransactionAutoAnswer,
EurockPartnerFragment,
payment_result,
)
@@ -51,5 +50,4 @@ urlpatterns = [
EtransactionAutoAnswer.as_view(),
name="etransation_autoanswer",
),
path("eurock/", EurockPartnerFragment.as_view(), name="eurock"),
]

View File

@@ -42,11 +42,11 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
from django.views.generic import DetailView, FormView, UpdateView, View
from django.views.generic.edit import SingleObjectMixin
from django_countries.fields import Country
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm
from counter.models import (
@@ -350,7 +350,3 @@ class EtransactionAutoAnswer(View):
return HttpResponse(
"Payment failed with error: " + request.GET["Error"], status=202
)
class EurockPartnerFragment(IsSubscriberMixin, TemplateView):
template_name = "eboutic/eurock_fragment.jinja"

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-10 10:28+0100\n"
"POT-Creation-Date: 2026-03-20 16:24+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -310,16 +310,36 @@ msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja
msgid "inactive"
msgstr "inactif"
msgid "Filters"
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
msgid "New club"
msgstr "Nouveau club"
#: club/templates/club/club_list.jinja
msgid "There is no club in this website."
msgstr "Il n'y a pas de club dans ce site web."
msgid "inactive"
msgstr "inactif"
#: club/templates/club/club_members.jinja
msgid "Club members"
@@ -1860,10 +1880,6 @@ msgstr "L'AE"
msgid "AE's clubs"
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
msgid "Big event"
msgstr "Grandes Activités"
@@ -5838,39 +5854,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."

View File

@@ -355,7 +355,6 @@ SITH_TWITTER = "@ae_utbm"
# AE configuration
SITH_MAIN_CLUB_ID = env.int("SITH_MAIN_CLUB_ID", default=1)
SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2)
SITH_LAUNDERETTE_CLUB_ID = env.int("SITH_LAUNDERETTE_CLUB_ID", default=84)
# Main root for club pages
SITH_CLUB_ROOT_PAGE = "clubs"
@@ -483,13 +482,6 @@ SITH_LOG_OPERATION_TYPE = [
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide"
SITH_ECOCUP_CONS = env.int("SITH_ECOCUP_CONS", default=1151)
SITH_ECOCUP_DECO = env.int("SITH_ECOCUP_DECO", default=1152)
# The limit is the maximum difference between cons and deco possible for a customer
SITH_ECOCUP_LIMIT = 3
# Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
@@ -512,7 +504,6 @@ SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = env.int(
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = env.int(
"SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS", default=2
)
SITH_PRODUCTTYPE_SUBSCRIPTION = env.int("SITH_PRODUCTTYPE_SUBSCRIPTION", default=2)
# Number of weeks before the end of a subscription when the subscriber can resubscribe
SITH_SUBSCRIPTION_END = 10