From 1701ab5f33b873d6775510b7e1aa0db19799bb9f Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 4 Mar 2026 16:40:41 +0100 Subject: [PATCH 1/5] feat: custom through model for `Counter.sellers` --- counter/migrations/0038_countersellers.py | 73 +++++++++++++++++++++++ counter/models.py | 23 ++++++- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 counter/migrations/0038_countersellers.py diff --git a/counter/migrations/0038_countersellers.py b/counter/migrations/0038_countersellers.py new file mode 100644 index 00000000..bdf50f51 --- /dev/null +++ b/counter/migrations/0038_countersellers.py @@ -0,0 +1,73 @@ +# 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", + ), + ), + ], + ) + ] diff --git a/counter/models.py b/counter/models.py index d53d129c..7b336957 100644 --- a/counter/models.py +++ b/counter/models.py @@ -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,23 @@ class Counter(models.Model): ] +class CounterSellers(models.Model): + counter = models.ForeignKey(Counter, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + verbose_name = _("counter seller") + 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. From a7c8b318bda433fc191a08066ff76c79a021ea2b Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 4 Mar 2026 16:54:49 +0100 Subject: [PATCH 2/5] add fields to CounterSellers --- counter/migrations/0038_countersellers.py | 17 ++++++++++++++++- counter/models.py | 5 ++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/counter/migrations/0038_countersellers.py b/counter/migrations/0038_countersellers.py index bdf50f51..48151573 100644 --- a/counter/migrations/0038_countersellers.py +++ b/counter/migrations/0038_countersellers.py @@ -69,5 +69,20 @@ class Migration(migrations.Migration): ), ), ], - ) + ), + 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"), + ), ] diff --git a/counter/models.py b/counter/models.py index 7b336957..a7e5cd56 100644 --- a/counter/models.py +++ b/counter/models.py @@ -748,11 +748,14 @@ 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: - verbose_name = _("counter seller") constraints = [ models.UniqueConstraint( fields=["counter", "user"], From 78c373f84e34e70ba19af247282dd536cce6ef40 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 5 Mar 2026 11:02:21 +0100 Subject: [PATCH 3/5] differentiate regular and temporary barmen on the counter edit view --- counter/forms.py | 88 ++++++++++++++++++++++++++++++++++++++++-- counter/views/admin.py | 6 ++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/counter/forms.py b/counter/forms.py index 1d9fa8a0..6172bd9b 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -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): diff --git a/counter/views/admin.py b/counter/views/admin.py index 25990425..9737bb16 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -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"): From 7e649b40c5e548608a4fd08febd227750a1d5460 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 5 Mar 2026 11:02:31 +0100 Subject: [PATCH 4/5] add translation --- counter/models.py | 2 +- locale/fr/LC_MESSAGES/django.po | 45 ++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/counter/models.py b/counter/models.py index a7e5cd56..29ecad2f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -752,7 +752,7 @@ class CounterSellers(models.Model): counter = models.ForeignKey(Counter, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) - is_regular = models.BooleanField("regular barman", default=False) + is_regular = models.BooleanField(_("regular barman"), default=False) created_at = models.DateTimeField(_("created at"), auto_now_add=True) class Meta: diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 8600040b..5e59772b 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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-10 10:28+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -2937,6 +2937,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 +2968,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 +3226,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" @@ -3897,6 +3934,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)" @@ -5245,8 +5286,6 @@ msgid "One day" msgstr "Un jour" #: sith/settings.py -#, fuzzy -#| msgid "GA staff member" msgid "GA staff member" msgstr "Membre staff GA" From 4f84ec09d779196255c34d4067e4e4795da09381 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 5 Mar 2026 11:02:36 +0100 Subject: [PATCH 5/5] add tests --- counter/tests/test_counter_admin.py | 123 +++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/counter/tests/test_counter_admin.py b/counter/tests/test_counter_admin.py index eaade1c5..91832786 100644 --- a/counter/tests/test_counter_admin.py +++ b/counter/tests/test_counter_admin.py @@ -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):