23 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
thomas girod
d374ea9651 Merge pull request #1318 from ae-utbm/vite
upgrade to vite 8
2026-03-13 09:48:42 +01:00
imperosol
10a4e71b7a upgrade to vite 8
FASTER FASTER FASTER FASTER FASTER FASTER
2026-03-13 09:46:12 +01:00
imperosol
f1a60e589a remove unused settings 2026-03-12 10:26:40 +01:00
thomas girod
00acda7ba3 Merge pull request #1316 from ae-utbm/update-deps
Update deps
2026-03-12 08:32:13 +01:00
imperosol
1686a9da87 update JS deps 2026-03-11 22:41:51 +01:00
imperosol
83255945c4 update python deps 2026-03-11 22:30:36 +01:00
thomas girod
b4a6b6961b Merge pull request #1307 from ae-utbm/counter-sellers
Counter sellers
2026-03-11 18:09:49 +01:00
thomas girod
0f0702825e Merge pull request #1281 from ae-utbm/test_election
add test_election_form
2026-03-10 19:42:02 +01:00
imperosol
b74b1ac691 refactor TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor
33d4a99a2c move form test into a class TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor
c154b311c3 add test with wrong data form 2026-03-10 19:39:40 +01:00
TitouanDor
fb8da93c68 add test_election_form 2026-03-10 19:39:40 +01:00
imperosol
4f84ec09d7 add tests 2026-03-10 19:26:05 +01:00
imperosol
7e649b40c5 add translation 2026-03-10 19:26:05 +01:00
imperosol
78c373f84e differentiate regular and temporary barmen on the counter edit view 2026-03-09 16:04:46 +01:00
imperosol
a7c8b318bd add fields to CounterSellers 2026-03-09 16:04:46 +01:00
imperosol
1701ab5f33 feat: custom through model for Counter.sellers 2026-03-09 16:04:46 +01:00
30 changed files with 1990 additions and 1891 deletions

3
.gitignore vendored
View File

@@ -24,6 +24,9 @@ node_modules/
# compiled documentation
site/
# rollup-bundle-visualizer report
.bundle-size-report.html
### Redis ###
# Ignore redis binary dump (dump.rdb) files

View File

@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.0
rev: v0.15.5
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@2.3.14"]
additional_dependencies: ["@biomejs/biome@2.4.6"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.10
hooks:

View File

@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**/static/**"]
"includes": ["**/static/**", "vite.config.mts"]
},
"formatter": {
"enabled": true,

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

@@ -26,7 +26,6 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card"));
});
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();

View File

@@ -115,7 +115,6 @@ blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {

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

@@ -5,6 +5,7 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
@@ -15,7 +16,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User
from core.models import User, UserQuerySet
from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
@@ -32,6 +33,7 @@ from core.views.widgets.ajax_select import (
from counter.models import (
BillingInfo,
Counter,
CounterSellers,
Customer,
Eticket,
InvoiceCall,
@@ -170,14 +172,39 @@ class RefillForm(forms.ModelForm):
class CounterEditForm(forms.ModelForm):
class Meta:
model = Counter
fields = ["sellers", "products"]
widgets = {"sellers": AutoCompleteSelectMultipleUser}
fields = ["products"]
sellers_regular = forms.ModelMultipleChoiceField(
label=_("Regular barmen"),
help_text=_(
"Barmen having regular permanences "
"or frequently giving a hand throughout the semester."
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
sellers_temporary = forms.ModelMultipleChoiceField(
label=_("Temporary barmen"),
help_text=_(
"Barmen who will be there only for a limited period (e.g. for one evening)"
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
field_order = ["sellers_regular", "sellers_temporary", "products"]
def __init__(self, *args, user: User, instance: Counter, **kwargs):
super().__init__(*args, instance=instance, **kwargs)
# if the user is an admin, he will have access to all products,
# else only to active products owned by the counter's club
# or already on the counter
if user.has_perm("counter.change_counter"):
self.fields["products"].widget = AutoCompleteSelectMultipleProduct()
else:
# updating the queryset of the field also updates the choices of
# the widget, so it's important to set the queryset after the widget
self.fields["products"].widget = AutoCompleteSelectMultiple()
self.fields["products"].queryset = Product.objects.filter(
Q(club_id=instance.club_id) | Q(counters=instance), archived=False
@@ -186,6 +213,61 @@ class CounterEditForm(forms.ModelForm):
"If you want to add a product that is not owned by "
"your club to this counter, you should ask an admin."
)
self.fields["sellers_regular"].initial = self.instance.sellers.filter(
countersellers__is_regular=True
).all()
self.fields["sellers_temporary"].initial = self.instance.sellers.filter(
countersellers__is_regular=False
).all()
def clean(self):
regular: UserQuerySet = self.cleaned_data["sellers_regular"]
temporary: UserQuerySet = self.cleaned_data["sellers_temporary"]
duplicates = list(regular.intersection(temporary))
if duplicates:
raise ValidationError(
_(
"A user cannot be a regular and a temporary barman "
"at the same time, "
"but the following users have been defined as both : %(users)s"
)
% {"users": ", ".join([u.get_display_name() for u in duplicates])}
)
return self.cleaned_data
def save_sellers(self):
sellers = []
for users, is_regular in (
(self.cleaned_data["sellers_regular"], True),
(self.cleaned_data["sellers_temporary"], False),
):
sellers.extend(
[
CounterSellers(counter=self.instance, user=u, is_regular=is_regular)
for u in users
]
)
# start by deleting removed CounterSellers objects
user_ids = [seller.user.id for seller in sellers]
CounterSellers.objects.filter(
~Q(user_id__in=user_ids), counter=self.instance
).delete()
# then create or update the new barmen
CounterSellers.objects.bulk_create(
sellers,
update_conflicts=True,
update_fields=["is_regular"],
unique_fields=["user", "counter"],
)
def save(self, commit=True): # noqa: FBT002
self.instance = super().save(commit=commit)
if commit and any(
key in self.changed_data for key in ("sellers_regular", "sellers_temporary")
):
self.save_sellers()
return self.instance
class ScheduledProductActionForm(forms.ModelForm):

View File

@@ -0,0 +1,88 @@
# Generated by Django 5.2.11 on 2026-03-04 15:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0037_productformula"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# cf. https://docs.djangoproject.com/fr/stable/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
sql="ALTER TABLE counter_counter_sellers RENAME TO counter_countersellers",
reverse_sql="ALTER TABLE counter_countersellers RENAME TO counter_counter_sellers",
),
],
state_operations=[
migrations.CreateModel(
name="CounterSellers",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"counter",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="counter.counter",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("counter", "user"),
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
],
},
),
migrations.AlterField(
model_name="counter",
name="sellers",
field=models.ManyToManyField(
blank=True,
related_name="counters",
through="counter.CounterSellers",
to=settings.AUTH_USER_MODEL,
verbose_name="sellers",
),
),
],
),
migrations.AddField(
model_name="countersellers",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="created at",
),
preserve_default=False,
),
migrations.AddField(
model_name="countersellers",
name="is_regular",
field=models.BooleanField(default=False, verbose_name="regular barman"),
),
]

View File

@@ -551,7 +551,11 @@ class Counter(models.Model):
choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))],
)
sellers = models.ManyToManyField(
User, verbose_name=_("sellers"), related_name="counters", blank=True
User,
verbose_name=_("sellers"),
related_name="counters",
blank=True,
through="CounterSellers",
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_counters", blank=True
@@ -743,6 +747,26 @@ class Counter(models.Model):
]
class CounterSellers(models.Model):
"""Custom through model for the counter-sellers M2M relationship."""
counter = models.ForeignKey(Counter, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
is_regular = models.BooleanField(_("regular barman"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["counter", "user"],
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
]
def __str__(self):
return f"counter {self.counter_id} - user {self.user_id}"
class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self:
"""Annotate the Queryset with the total amount.

View File

@@ -64,7 +64,7 @@ document.addEventListener("alpine:init", () => {
checkFormulas() {
const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i)),
Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)),
);
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p));

View File

@@ -1,13 +1,132 @@
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from club.models import Membership
from core.baker_recipes import subscriber_user
from core.models import User
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.forms import CounterEditForm
from counter.models import Counter
from counter.models import Counter, CounterSellers
class TestEditCounterSellers(TestCase):
@classmethod
def setUpTestData(cls):
cls.counter = baker.make(Counter, type="BAR")
cls.products = product_recipe.make(_quantity=2, _bulk_create=True)
cls.counter.products.add(*cls.products)
users = subscriber_user.make(_quantity=6, _bulk_create=True)
cls.regular_barmen = users[:2]
cls.tmp_barmen = users[2:4]
cls.not_barmen = users[4:]
CounterSellers.objects.bulk_create(
[
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.regular_barmen),
is_regular=True,
_quantity=len(cls.regular_barmen),
),
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.tmp_barmen),
is_regular=False,
_quantity=len(cls.tmp_barmen),
),
]
)
cls.operator = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
def test_view_ok(self):
url = reverse("counter:admin", kwargs={"counter_id": self.counter.id})
self.client.force_login(self.operator)
res = self.client.get(url)
assert res.status_code == 200
res = self.client.post(
url,
data={
"sellers_regular": [u.id for u in self.regular_barmen],
"sellers_temporary": [u.id for u in self.tmp_barmen],
"products": [p.id for p in self.products],
},
)
self.assertRedirects(res, url)
def test_add_barmen(self):
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[1]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.not_barmen[0],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=False)) == {
*self.tmp_barmen,
self.not_barmen[1],
}
def test_barman_change_status(self):
"""Test when a barman goes from temporary to regular"""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.tmp_barmen[0]],
"sellers_temporary": [*self.tmp_barmen[1:]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.tmp_barmen[0],
}
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen[1:])
def test_barman_duplicate(self):
"""Test that a barman cannot be regular and temporary at the same time."""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[0]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert not form.is_valid()
assert form.errors == {
"__all__": [
"Un utilisateur ne peut pas être un barman "
"régulier et temporaire en même temps, "
"mais les utilisateurs suivants ont été définis "
f"comme les deux : {self.not_barmen[0].get_display_name()}"
],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == set(
self.regular_barmen
)
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen)
class TestEditCounterProducts(TestCase):

View File

@@ -16,6 +16,7 @@ from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.forms import CheckboxSelectMultiple
@@ -58,7 +59,9 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
current_tab = "counters"
class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
class CounterEditView(
CounterAdminTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
"""Edit a counter's main informations (for the counter's manager)."""
model = Counter
@@ -66,6 +69,7 @@ class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja"
current_tab = "counters"
success_message = _("Counter update done")
def test_func(self):
if self.request.user.has_perm("counter.change_counter"):

View File

@@ -6,6 +6,8 @@ from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Group, User
@@ -52,6 +54,102 @@ class TestElectionUpdateView(TestElection):
assert response.status_code == 403
class TestElectionForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.election = baker.make(Election, end_date=now() + timedelta(days=1))
cls.group = baker.make(Group)
cls.election.vote_groups.add(cls.group)
cls.election.edit_groups.add(cls.group)
lists = baker.make(
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
)
cls.roles = baker.make(
Role, election=cls.election, _quantity=2, _bulk_create=True
)
users = baker.make(User, _quantity=4, _bulk_create=True)
recipe = Recipe(Candidature)
cls.cand = [
recipe.prepare(role=cls.roles[0], user=users[0], election_list=lists[0]),
recipe.prepare(role=cls.roles[0], user=users[1], election_list=lists[1]),
recipe.prepare(role=cls.roles[1], user=users[2], election_list=lists[0]),
recipe.prepare(role=cls.roles[1], user=users[3], election_list=lists[1]),
]
Candidature.objects.bulk_create(cls.cand)
cls.vote_url = reverse("election:vote", kwargs={"election_id": cls.election.id})
cls.detail_url = reverse(
"election:detail", kwargs={"election_id": cls.election.id}
)
def test_election_good_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[2].id)},
{postes[0]: "", postes[1]: ""},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[2].id)},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[3].id)},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
assert self.election.can_vote(voter)
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert set(self.election.voters.all()) == set(voters)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 50.0, "vote": 2},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 50.0, "vote": 2},
"total vote": 4,
},
postes[1]: {
self.cand[2].user.username: {"percent": 50.0, "vote": 2},
self.cand[3].user.username: {"percent": 25.0, "vote": 1},
"blank vote": {"percent": 25.0, "vote": 1},
"total vote": 4,
},
}
def test_election_bad_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[0].id)}, # wrong candidate
{postes[0]: ""},
{
postes[0]: "0123456789", # unknow users
postes[1]: str(subscriber_user.make().id), # not a candidate
},
{},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 0.0, "vote": 0},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
postes[1]: {
self.cand[2].user.username: {"percent": 0.0, "vote": 0},
self.cand[3].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
}
@pytest.mark.django_db
def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1))

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-07 15:47+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"
@@ -2937,6 +2953,29 @@ msgstr "Cet UID est invalide"
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Regular barmen"
msgstr "Barmen réguliers"
#: counter/forms.py
msgid ""
"Barmen having regular permanences or frequently giving a hand throughout the "
"semester."
msgstr ""
"Les barmen assurant des permanences régulières ou donnant régulièrement un "
"coup de main au cours du semestre."
#: counter/forms.py
msgid "Temporary barmen"
msgstr "Barmen temporaires"
#: counter/forms.py
msgid ""
"Barmen who will be there only for a limited period (e.g. for one evening)"
msgstr ""
"Les barmen qui seront là uniquement pour une durée limitée (par exemple, le "
"temps d'une soirée)"
#: counter/forms.py
msgid ""
"If you want to add a product that is not owned by your club to this counter, "
@@ -2945,6 +2984,16 @@ msgstr ""
"Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à "
"votre club, vous devriez demander à un admin."
#: counter/forms.py
#, python-format
msgid ""
"A user cannot be a regular and a temporary barman at the same time, but the "
"following users have been defined as both : %(users)s"
msgstr ""
"Un utilisateur ne peut pas être un barman régulier et temporaire en même "
"temps, mais les utilisateurs suivants ont été définis comme les deux : "
"%(users)s"
#: counter/forms.py
msgid "Date and time of action"
msgstr "Date et heure de l'action"
@@ -3193,6 +3242,10 @@ msgstr "vendeurs"
msgid "token"
msgstr "jeton"
#: counter/models.py
msgid "regular barman"
msgstr "barman régulier"
#: counter/models.py sith/settings.py
msgid "Credit card"
msgstr "Carte bancaire"
@@ -3905,6 +3958,10 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
msgid "Counter update done"
msgstr "Mise à jour du comptoir effectuée"
#: counter/views/admin.py
#, python-format
msgid "%(formula)s (formula)"
@@ -5253,8 +5310,6 @@ msgid "One day"
msgstr "Un jour"
#: sith/settings.py
#, fuzzy
#| msgid "GA staff member"
msgid "GA staff member"
msgstr "Membre staff GA"

2367
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,6 @@
"compile-dev": "vite build --mode development",
"serve": "vite build --mode development --watch --minify false",
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "tsc && biome check --write"
},
"keywords": [],
@@ -28,29 +26,28 @@
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@biomejs/biome": "^2.3.14",
"@hey-api/openapi-ts": "^0.92.4",
"@biomejs/biome": "^2.4.6",
"@hey-api/openapi-ts": "^0.94.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.2.0"
"vite": "^8.0.0"
},
"dependencies": {
"@alpinejs/sort": "^3.15.8",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.5",
"@floating-ui/dom": "^1.7.6",
"@fortawesome/fontawesome-free": "^7.2.0",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/icalendar": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@sentry/browser": "^10.38.0",
"@zip.js/zip.js": "^2.8.20",
"@sentry/browser": "^10.43.0",
"@zip.js/zip.js": "^2.8.23",
"3d-force-graph": "^1.79.1",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
@@ -60,14 +57,14 @@
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
"easymde": "^2.20.0",
"glob": "^13.0.2",
"glob": "^13.0.6",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.8",
"js-cookie": "^3.0.5",
"lit-html": "^3.3.2",
"native-file-system-adapter": "^3.0.1",
"three": "^0.182.0",
"three": "^0.183.2",
"three-spritetext": "^1.10.0",
"tom-select": "^2.5.1"
"tom-select": "^2.5.2"
}
}

View File

@@ -19,7 +19,7 @@ authors = [
license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12"
dependencies = [
"django>=5.2.11,<6.0.0",
"django>=5.2.12,<6.0.0",
"django-ninja>=1.5.3,<6.0.0",
"django-ninja-extra>=0.31.0",
"Pillow>=12.1.1,<13.0.0",
@@ -27,15 +27,15 @@ dependencies = [
"django-jinja<3.0.0,>=2.11.0",
"cryptography>=46.0.5,<47.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0",
"phonenumbers>=9.0.23,<10.0.0",
"reportlab>=4.4.9,<5.0.0",
"phonenumbers>=9.0.25,<10.0.0",
"reportlab>=4.4.10,<5.0.0",
"django-haystack<4.0.0,>=3.3.0",
"xapian-haystack<4.0.0,>=3.1.0",
"libsass<1.0.0,>=0.23.0",
"django-ordered-model<4.0.0,>=3.7.4",
"django-simple-captcha<1.0.0,>=0.6.3",
"python-dateutil<3.0.0.0,>=2.9.0.post0",
"sentry-sdk>=2.52.0,<3.0.0",
"sentry-sdk>=2.54.0,<3.0.0",
"jinja2<4.0.0,>=3.1.6",
"django-countries>=8.2.0,<9.0.0",
"dict2xml>=1.7.8,<2.0.0",
@@ -51,7 +51,7 @@ dependencies = [
"psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7",
"django-celery-results>=2.5.1",
"django-celery-beat>=2.7.0",
"django-celery-beat>=2.9.0",
]
[project.urls]
@@ -60,31 +60,31 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups]
prod = [
"psycopg[c]>=3.3.2,<4.0.0",
"psycopg[c]>=3.3.3,<4.0.0",
]
dev = [
"django-debug-toolbar>=6.2.0,<7",
"ipython>=9.10.0,<10.0.0",
"ipython>=9.11.0,<10.0.0",
"pre-commit>=4.5.1,<5.0.0",
"ruff>=0.15.0,<1.0.0",
"ruff>=0.15.5,<1.0.0",
"djhtml>=3.0.10,<4.0.0",
"faker>=40.4.0,<41.0.0",
"faker>=40.8.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0",
]
tests = [
"freezegun>=1.5.5,<2.0.0",
"pytest>=9.0.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0",
"pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.23.2",
"pytest-django<5.0.0,>=4.12.0",
"model-bakery<2.0.0,>=1.23.3",
"beautifulsoup4>=4.14.3,<5",
"lxml>=6.0.2,<7",
]
docs = [
"mkdocs<2.0.0,>=1.6.1",
"mkdocs-material>=9.7.1,<10.0.0",
"mkdocs-material>=9.7.5,<10.0.0",
"mkdocstrings>=1.0.3,<2.0.0",
"mkdocstrings-python>=2.0.2,<3.0.0",
"mkdocstrings-python>=2.0.3,<3.0.0",
"mkdocs-include-markdown-plugin>=7.2.1,<8.0.0",
]

View File

@@ -1,7 +1,6 @@
import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
import { paginated } from "#core:utils/api.ts";
import { exportToHtml } from "#core:utils/globals.ts";
import { History } from "#core:utils/history.ts";
import {
type IdentifiedUserSchema,

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

629
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,17 @@
// biome-ignore lint/correctness/noNodejsModules: this is backend side
import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject";
import { glob } from "glob";
import type { Rollup } from "vite";
import { type AliasOptions, defineConfig, type UserConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";
import {
type AliasOptions,
defineConfig,
type PluginOption,
type Rollup,
type UserConfig,
} from "vite";
import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const vendored = resolve(outDir, "vendored");
const nodeModules = resolve(__dirname, "node_modules");
const collectedFiles = glob.sync(
"./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)",
);
@@ -42,7 +45,6 @@ function getRelativeAssetPath(path: string): string {
return relativePath.join("/");
}
// biome-ignore lint/style/noDefaultExport: this is recommended by documentation
export default defineConfig((config: UserConfig) => {
return {
base: "/static/bundled/",
@@ -86,6 +88,7 @@ export default defineConfig((config: UserConfig) => {
Alpine: "alpinejs",
htmx: "htmx.org",
}),
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
],
} satisfies UserConfig;
});