mirror of
https://github.com/ae-utbm/sith.git
synced 2026-03-13 15:15:03 +00:00
@@ -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):
|
||||
|
||||
88
counter/migrations/0038_countersellers.py
Normal file
88
counter/migrations/0038_countersellers.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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 <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\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"
|
||||
@@ -3905,6 +3942,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 +5294,6 @@ msgid "One day"
|
||||
msgstr "Un jour"
|
||||
|
||||
#: sith/settings.py
|
||||
#, fuzzy
|
||||
#| msgid "GA staff member"
|
||||
msgid "GA staff member"
|
||||
msgstr "Membre staff GA"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user