diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 492f971b..33a9fa2f 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -50,7 +50,7 @@ from com.ics_calendar import IcsCalendar from com.models import News, NewsDate, Sith, Weekmail from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.utils import resize_image -from counter.models import Counter, Product, ProductType, StudentCard +from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard from election.models import Candidature, Election, ElectionList, Role from forum.models import Forum from pedagogy.models import UV @@ -470,7 +470,6 @@ Welcome to the wiki page! limit_age=18, ) cons = Product.objects.create( - id=settings.SITH_ECOCUP_CONS, name="Consigne Eco-cup", code="CONS", product_type=verre, @@ -480,7 +479,6 @@ Welcome to the wiki page! club=main_club, ) dcons = Product.objects.create( - id=settings.SITH_ECOCUP_DECO, name="Déconsigne Eco-cup", code="DECO", product_type=verre, @@ -529,6 +527,9 @@ Welcome to the wiki page! special_selling_price="0", club=refound, ) + ReturnableProduct.objects.create( + product=cons, returned_product=dcons, max_return=3 + ) # Accounting test values: BankAccount.objects.create(name="AE TG", club=main_club) diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss index c8e59098..941b32a5 100644 --- a/core/static/core/components/card.scss +++ b/core/static/core/components/card.scss @@ -55,6 +55,14 @@ width: 80%; } + .card-top-left { + position: absolute; + top: 10px; + right: 10px; + padding: 10px; + text-align: center; + } + .card-content { color: black; display: flex; diff --git a/core/templates/core/delete_confirm.jinja b/core/templates/core/delete_confirm.jinja index 3a393b67..6ae8a1b2 100644 --- a/core/templates/core/delete_confirm.jinja +++ b/core/templates/core/delete_confirm.jinja @@ -10,10 +10,17 @@ {% block nav %} {% endblock %} +{# if the template context has the `object_name` variable, + then this one will be used, + instead of the result of `str(object)` #} +{% if object and not object_name %} + {% set object_name=object %} +{% endif %} + {% block content %}

{% trans %}Delete confirmation{% endtrans %}

{% csrf_token %} -

{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}

+

{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}

diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja index 4c9b9462..e1e8e581 100644 --- a/core/templates/core/user_tools.jinja +++ b/core/templates/core/user_tools.jinja @@ -62,6 +62,11 @@ {% trans %}Product types management{% endtrans %} +
  • + + {% trans %}Returnable products management{% endtrans %} + +
  • {% trans %}Cash register summaries{% endtrans %} diff --git a/core/views/mixins.py b/core/views/mixins.py index 9687f5d9..e5b445d6 100644 --- a/core/views/mixins.py +++ b/core/views/mixins.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.views import View @@ -6,20 +8,24 @@ from django.views import View class TabedViewMixin(View): """Basic functions for displaying tabs in the template.""" + current_tab: ClassVar[str | None] = None + list_of_tabs: ClassVar[list | None] = None + tabs_title: ClassVar[str | None] = None + def get_tabs_title(self): - if hasattr(self, "tabs_title"): - return self.tabs_title - raise ImproperlyConfigured("tabs_title is required") + if not self.tabs_title: + raise ImproperlyConfigured("tabs_title is required") + return self.tabs_title def get_current_tab(self): - if hasattr(self, "current_tab"): - return self.current_tab - raise ImproperlyConfigured("current_tab is required") + if not self.current_tab: + raise ImproperlyConfigured("current_tab is required") + return self.current_tab def get_list_of_tabs(self): - if hasattr(self, "list_of_tabs"): - return self.list_of_tabs - raise ImproperlyConfigured("list_of_tabs is required") + if not self.list_of_tabs: + raise ImproperlyConfigured("list_of_tabs is required") + return self.list_of_tabs def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) diff --git a/counter/admin.py b/counter/admin.py index 5dc795f2..10f04c8d 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -26,6 +26,7 @@ from counter.models import ( Product, ProductType, Refilling, + ReturnableProduct, Selling, ) @@ -43,6 +44,18 @@ class ProductAdmin(SearchModelAdmin): search_fields = ("name", "code") +@admin.register(ReturnableProduct) +class ReturnableProductAdmin(admin.ModelAdmin): + list_display = ("product", "returned_product", "max_return") + search_fields = ( + "product__name", + "product__code", + "returned_product__name", + "returned_product__code", + ) + autocomplete_fields = ("product", "returned_product") + + @admin.register(Customer) class CustomerAdmin(SearchModelAdmin): list_display = ("user", "account_id", "amount") diff --git a/counter/forms.py b/counter/forms.py index b509b515..665e2ded 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -17,6 +17,7 @@ from counter.models import ( Eticket, Product, Refilling, + ReturnableProduct, StudentCard, ) from counter.widgets.ajax_select import ( @@ -213,6 +214,25 @@ class ProductEditForm(forms.ModelForm): return ret +class ReturnableProductForm(forms.ModelForm): + class Meta: + model = ReturnableProduct + fields = ["product", "returned_product", "max_return"] + widgets = { + "product": AutoCompleteSelectProduct(), + "returned_product": AutoCompleteSelectProduct(), + } + + def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT + instance: ReturnableProduct = super().save(commit=commit) + if commit: + # This is expensive, but we don't have a task queue to offload it. + # Hopefully, creations and updates of returnable products + # occur very rarely + instance.update_balances() + return instance + + class CashSummaryFormBase(forms.Form): begin_date = forms.DateTimeField( label=_("Begin date"), widget=SelectDateTime, required=False diff --git a/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py b/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py new file mode 100644 index 00000000..0ec3d95e --- /dev/null +++ b/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py @@ -0,0 +1,126 @@ +# Generated by Django 4.2.17 on 2025-03-05 14:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.db.migrations.state import StateApps + + +def migrate_cons_balances(apps: StateApps, schema_editor): + ReturnableProduct = apps.get_model("counter", "ReturnableProduct") + Product = apps.get_model("counter", "Product") + + cons = Product.objects.filter(pk=settings.SITH_ECOCUP_CONS).first() + dcons = Product.objects.filter(pk=settings.SITH_ECOCUP_DECO).first() + if not cons or not dcons: + return + returnable = ReturnableProduct.objects.create( + product=cons, returned_product=dcons, max_return=settings.SITH_ECOCUP_LIMIT + ) + returnable.update_balances() + + +class Migration(migrations.Migration): + dependencies = [("counter", "0029_alter_selling_label")] + + operations = [ + migrations.CreateModel( + name="ReturnableProduct", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "max_return", + models.PositiveSmallIntegerField( + default=0, + help_text=( + "The maximum number of items a customer can return " + "without having actually bought them." + ), + verbose_name="maximum returns", + ), + ), + ( + "product", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="cons", + to="counter.product", + verbose_name="returnable product", + ), + ), + ( + "returned_product", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="dcons", + to="counter.product", + verbose_name="returned product", + ), + ), + ], + options={ + "verbose_name": "returnable product", + "verbose_name_plural": "returnable products", + }, + ), + migrations.AddConstraint( + model_name="returnableproduct", + constraint=models.CheckConstraint( + check=models.Q( + ("product", models.F("returned_product")), _negated=True + ), + name="returnableproduct_product_different_from_returned", + violation_error_message="The returnable product cannot be the same as the returned one", + ), + ), + migrations.CreateModel( + name="ReturnableProductBalance", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("balance", models.SmallIntegerField(blank=True, default=0)), + ( + "customer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="return_balances", + to="counter.customer", + ), + ), + ( + "returnable", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="balances", + to="counter.returnableproduct", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="returnableproductbalance", + constraint=models.UniqueConstraint( + fields=("customer", "returnable"), + name="returnable_product_unique_type_per_customer", + ), + ), + migrations.RunPython( + migrate_cons_balances, reverse_code=migrations.RunPython.noop, elidable=True + ), + migrations.RemoveField(model_name="customer", name="recorded_products"), + ] diff --git a/counter/models.py b/counter/models.py index dc043509..072cee61 100644 --- a/counter/models.py +++ b/counter/models.py @@ -21,7 +21,7 @@ import string from datetime import date, datetime, timedelta from datetime import timezone as tz from decimal import Decimal -from typing import Self +from typing import Literal, Self from dict2xml import dict2xml from django.conf import settings @@ -94,7 +94,6 @@ class Customer(models.Model): user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) account_id = models.CharField(_("account id"), max_length=10, unique=True) amount = CurrencyField(_("amount"), default=0) - recorded_products = models.IntegerField(_("recorded product"), default=0) objects = CustomerQuerySet.as_manager() @@ -106,24 +105,50 @@ class Customer(models.Model): def __str__(self): return "%s - %s" % (self.user.username, self.account_id) - def save(self, *args, allow_negative=False, is_selling=False, **kwargs): + def save(self, *args, allow_negative=False, **kwargs): """is_selling : tell if the current action is a selling allow_negative : ignored if not a selling. Allow a selling to put the account in negative Those two parameters avoid blocking the save method of a customer if his account is negative. """ - if self.amount < 0 and (is_selling and not allow_negative): + if self.amount < 0 and not allow_negative: raise ValidationError(_("Not enough money")) super().save(*args, **kwargs) def get_absolute_url(self): return reverse("core:user_account", kwargs={"user_id": self.user.pk}) - @property - def can_record(self): - return self.recorded_products > -settings.SITH_ECOCUP_LIMIT + def update_returnable_balance(self): + """Update all returnable balances of this user to their real amount.""" - def can_record_more(self, number): - return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT + def purchases_qs(outer_ref: Literal["product_id", "returned_product_id"]): + return ( + Selling.objects.filter(customer=self, product=OuterRef(outer_ref)) + .values("product") + .annotate(quantity=Sum("quantity", default=0)) + .values("quantity") + ) + + balances = ( + ReturnableProduct.objects.annotate_balance_for(self) + .annotate( + nb_cons=Coalesce(Subquery(purchases_qs("product_id")), 0), + nb_dcons=Coalesce(Subquery(purchases_qs("returned_product_id")), 0), + ) + .annotate(new_balance=F("nb_cons") - F("nb_dcons")) + .values("id", "new_balance") + ) + updated_balances = [ + ReturnableProductBalance( + customer=self, returnable_id=b["id"], balance=b["new_balance"] + ) + for b in balances + ] + ReturnableProductBalance.objects.bulk_create( + updated_balances, + update_conflicts=True, + update_fields=["balance"], + unique_fields=["customer", "returnable"], + ) @property def can_buy(self) -> bool: @@ -379,14 +404,6 @@ class Product(models.Model): def get_absolute_url(self): return reverse("counter:product_list") - @property - def is_record_product(self): - return self.id == settings.SITH_ECOCUP_CONS - - @property - def is_unrecord_product(self): - return self.id == settings.SITH_ECOCUP_DECO - def is_owned_by(self, user): """Method to see if that object can be edited by the given user.""" if user.is_anonymous: @@ -860,7 +877,7 @@ class Selling(models.Model): self.full_clean() if not self.is_validated: self.customer.amount -= self.quantity * self.unit_price - self.customer.save(allow_negative=allow_negative, is_selling=True) + self.customer.save(allow_negative=allow_negative) self.is_validated = True user = self.customer.user if user.was_subscribed: @@ -945,6 +962,7 @@ class Selling(models.Model): self.customer.amount += self.quantity * self.unit_price self.customer.save() super().delete(*args, **kwargs) + self.customer.update_returnable_balance() def send_mail_customer(self): event = self.product.eticket.event_title or _("Unknown event") @@ -1211,3 +1229,134 @@ class StudentCard(models.Model): if isinstance(obj, User): return StudentCard.can_create(self.customer, obj) return False + + +class ReturnableProductQuerySet(models.QuerySet): + def annotate_balance_for(self, customer: Customer): + return self.annotate( + balance=Coalesce( + Subquery( + ReturnableProductBalance.objects.filter( + returnable=OuterRef("pk"), customer=customer + ).values("balance") + ), + 0, + ) + ) + + +class ReturnableProduct(models.Model): + """A returnable relation between two products (*consigne/déconsigne*).""" + + product = models.OneToOneField( + to=Product, + on_delete=models.CASCADE, + related_name="cons", + verbose_name=_("returnable product"), + ) + returned_product = models.OneToOneField( + to=Product, + on_delete=models.CASCADE, + related_name="dcons", + verbose_name=_("returned product"), + ) + max_return = models.PositiveSmallIntegerField( + _("maximum returns"), + default=0, + help_text=_( + "The maximum number of items a customer can return " + "without having actually bought them." + ), + ) + + objects = ReturnableProductQuerySet.as_manager() + + class Meta: + verbose_name = _("returnable product") + verbose_name_plural = _("returnable products") + constraints = [ + models.CheckConstraint( + check=~Q(product=F("returned_product")), + name="returnableproduct_product_different_from_returned", + violation_error_message=_( + "The returnable product cannot be the same as the returned one" + ), + ) + ] + + def __str__(self): + return f"returnable product ({self.product_id} -> {self.returned_product_id})" + + def update_balances(self): + """Update all returnable balances linked to this object. + + Call this when a ReturnableProduct is created or updated. + + Warning: + This function is expensive (around a few seconds), + so try not to run it outside a management command + or a task. + """ + + def product_balance_subquery(product_id: int): + return Subquery( + Selling.objects.filter(customer=OuterRef("pk"), product_id=product_id) + .values("customer") + .annotate(res=Sum("quantity")) + .values("res") + ) + + old_balance_subquery = Subquery( + ReturnableProductBalance.objects.filter( + customer=OuterRef("pk"), returnable=self + ).values("balance") + ) + new_balances = ( + Customer.objects.annotate( + nb_cons=Coalesce(product_balance_subquery(self.product_id), 0), + nb_dcons=Coalesce( + product_balance_subquery(self.returned_product_id), 0 + ), + ) + .annotate(new_balance=F("nb_cons") - F("nb_dcons")) + .exclude(new_balance=Coalesce(old_balance_subquery, 0)) + .values("pk", "new_balance") + ) + updates = [ + ReturnableProductBalance( + customer_id=c["pk"], returnable=self, balance=c["new_balance"] + ) + for c in new_balances + ] + ReturnableProductBalance.objects.bulk_create( + updates, + update_conflicts=True, + update_fields=["balance"], + unique_fields=["customer_id", "returnable"], + ) + + +class ReturnableProductBalance(models.Model): + """The returnable products balances of a customer""" + + customer = models.ForeignKey( + to=Customer, on_delete=models.CASCADE, related_name="return_balances" + ) + returnable = models.ForeignKey( + to=ReturnableProduct, on_delete=models.CASCADE, related_name="balances" + ) + balance = models.SmallIntegerField(blank=True, default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["customer", "returnable"], + name="returnable_product_unique_type_per_customer", + ) + ] + + def __str__(self): + return ( + f"return balance of {self.customer} " + f"for {self.returnable.product_id} : {self.balance}" + ) diff --git a/counter/templates/counter/returnable_list.jinja b/counter/templates/counter/returnable_list.jinja new file mode 100644 index 00000000..cc107a30 --- /dev/null +++ b/counter/templates/counter/returnable_list.jinja @@ -0,0 +1,67 @@ +{% extends "core/base.jinja" %} + +{% block title %} + {% trans %}Returnable products{% endtrans %} +{% endblock %} + +{% block additional_js %} +{% endblock %} + +{% block additional_css %} + + + + +{% endblock %} + +{% block content %} +

    {% trans %}Returnable products{% endtrans %}

    + {% if user.has_perm("counter.add_returnableproduct") %} +
    + {% trans %}New returnable product{% endtrans %} + + {% endif %} + +{% endblock content %} diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index d50bb6c4..84cea903 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -28,17 +28,19 @@ from django.utils import timezone from django.utils.timezone import localdate, now from freezegun import freeze_time from model_bakery import baker +from pytest_django.asserts import assertRedirects from club.models import Club, Membership from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.models import BanGroup, User -from counter.baker_recipes import product_recipe +from counter.baker_recipes import product_recipe, sale_recipe from counter.models import ( Counter, Customer, Permanency, Product, Refilling, + ReturnableProduct, Selling, ) @@ -97,7 +99,7 @@ class TestRefilling(TestFullClickBase): self, user: User | Customer, counter: Counter, - amount: int, + amount: int | float, client: Client | None = None, ) -> HttpResponse: used_client = client if client is not None else self.client @@ -241,31 +243,31 @@ class TestCounterClick(TestFullClickBase): special_selling_price="-1.5", ) cls.beer = product_recipe.make( - limit_age=18, selling_price="1.5", special_selling_price="1" + limit_age=18, selling_price=1.5, special_selling_price=1 ) cls.beer_tap = product_recipe.make( - limit_age=18, - tray=True, - selling_price="1.5", - special_selling_price="1", + limit_age=18, tray=True, selling_price=1.5, special_selling_price=1 ) - cls.snack = product_recipe.make( - limit_age=0, selling_price="1.5", special_selling_price="1" + limit_age=0, selling_price=1.5, special_selling_price=1 ) cls.stamps = product_recipe.make( - limit_age=0, selling_price="1.5", special_selling_price="1" + limit_age=0, selling_price=1.5, special_selling_price=1 + ) + ReturnableProduct.objects.all().delete() + cls.cons = baker.make(Product, selling_price=1) + cls.dcons = baker.make(Product, selling_price=-1) + baker.make( + ReturnableProduct, + product=cls.cons, + returned_product=cls.dcons, + max_return=3, ) - - cls.cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS) - cls.dcons = Product.objects.get(id=settings.SITH_ECOCUP_DECO) cls.counter.products.add( cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons ) - cls.other_counter.products.add(cls.snack) - cls.club_counter.products.add(cls.stamps) def login_in_bar(self, barmen: User | None = None): @@ -309,57 +311,36 @@ class TestCounterClick(TestFullClickBase): def test_click_eboutic_failure(self): eboutic = baker.make(Counter, type="EBOUTIC") self.client.force_login(self.club_admin) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.stamps.id, 5)], - counter=eboutic, - ).status_code - == 404 + res = self.submit_basket( + self.customer, [BasketItem(self.stamps.id, 5)], counter=eboutic ) + assert res.status_code == 404 def test_click_office_success(self): self.refill_user(self.customer, 10) self.client.force_login(self.club_admin) - - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.stamps.id, 5)], - counter=self.club_counter, - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter ) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("2.5") # Test no special price on office counter self.refill_user(self.club_admin, 10) - - assert ( - self.submit_basket( - self.club_admin, - [BasketItem(self.stamps.id, 1)], - counter=self.club_counter, - ).status_code - == 302 + res = self.submit_basket( + self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter ) + assert res.status_code == 302 assert self.updated_amount(self.club_admin) == Decimal("8.5") def test_click_bar_success(self): self.refill_user(self.customer, 10) self.login_in_bar(self.barmen) - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer.id, 2), - BasketItem(self.snack.id, 1), - ], - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)] ) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("5.5") @@ -378,29 +359,13 @@ class TestCounterClick(TestFullClickBase): self.login_in_bar(self.barmen) # Not applying tray price - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer_tap.id, 2), - ], - ).status_code - == 302 - ) - + res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("17") # Applying tray price - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer_tap.id, 7), - ], - ).status_code - == 302 - ) - + res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == Decimal("8") def test_click_alcool_unauthorized(self): @@ -410,28 +375,14 @@ class TestCounterClick(TestFullClickBase): self.refill_user(user, 10) # Buy product without age limit - assert ( - self.submit_basket( - user, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 302 - ) + res = self.submit_basket(user, [BasketItem(self.snack.id, 2)]) + assert res.status_code == 302 assert self.updated_amount(user) == Decimal("7") # Buy product without age limit - assert ( - self.submit_basket( - user, - [ - BasketItem(self.beer.id, 2), - ], - ).status_code - == 200 - ) + res = self.submit_basket(user, [BasketItem(self.beer.id, 2)]) + assert res.status_code == 200 assert self.updated_amount(user) == Decimal("7") @@ -443,12 +394,7 @@ class TestCounterClick(TestFullClickBase): self.customer_old_can_not_buy, ]: self.refill_user(user, 10) - resp = self.submit_basket( - user, - [ - BasketItem(self.snack.id, 2), - ], - ) + resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)]) assert resp.status_code == 302 assert resp.url == resolve_url(self.counter) @@ -456,44 +402,28 @@ class TestCounterClick(TestFullClickBase): def test_click_user_without_customer(self): self.login_in_bar() - assert ( - self.submit_basket( - self.customer_can_not_buy, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 404 + res = self.submit_basket( + self.customer_can_not_buy, [BasketItem(self.snack.id, 2)] ) + assert res.status_code == 404 def test_click_allowed_old_subscriber(self): self.login_in_bar() self.refill_user(self.customer_old_can_buy, 10) - assert ( - self.submit_basket( - self.customer_old_can_buy, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 302 + res = self.submit_basket( + self.customer_old_can_buy, [BasketItem(self.snack.id, 2)] ) + assert res.status_code == 302 assert self.updated_amount(self.customer_old_can_buy) == Decimal("7") def test_click_wrong_counter(self): self.login_in_bar() self.refill_user(self.customer, 10) - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - counter=self.other_counter, - ).status_code - == 302 # Redirect to counter main + res = self.submit_basket( + self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter ) + assertRedirects(res, self.other_counter.get_absolute_url()) # We want to test sending requests from another counter while # we are currently registered to another counter @@ -502,42 +432,25 @@ class TestCounterClick(TestFullClickBase): # that using a client not logged to a counter # where another client is logged still isn't authorized. client = Client() - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - counter=self.counter, - client=client, - ).status_code - == 302 # Redirect to counter main + res = self.submit_basket( + self.customer, + [BasketItem(self.snack.id, 2)], + counter=self.counter, + client=client, ) + assertRedirects(res, self.counter.get_absolute_url()) assert self.updated_amount(self.customer) == Decimal("10") def test_click_not_connected(self): self.refill_user(self.customer, 10) - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - ).status_code - == 302 # Redirect to counter main - ) + res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)]) + assertRedirects(res, self.counter.get_absolute_url()) - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.snack.id, 2), - ], - counter=self.club_counter, - ).status_code - == 403 + res = self.submit_basket( + self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter ) + assert res.status_code == 403 assert self.updated_amount(self.customer) == Decimal("10") @@ -545,15 +458,8 @@ class TestCounterClick(TestFullClickBase): self.refill_user(self.customer, 10) self.login_in_bar() - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.stamps.id, 2), - ], - ).status_code - == 200 - ) + res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)]) + assert res.status_code == 200 assert self.updated_amount(self.customer) == Decimal("10") def test_click_product_invalid(self): @@ -561,36 +467,24 @@ class TestCounterClick(TestFullClickBase): self.login_in_bar() for item in [ - BasketItem("-1", 2), + BasketItem(-1, 2), BasketItem(self.beer.id, -1), BasketItem(None, 1), BasketItem(self.beer.id, None), BasketItem(None, None), ]: - assert ( - self.submit_basket( - self.customer, - [item], - ).status_code - == 200 - ) + assert self.submit_basket(self.customer, [item]).status_code == 200 assert self.updated_amount(self.customer) == Decimal("10") def test_click_not_enough_money(self): self.refill_user(self.customer, 10) self.login_in_bar() - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer_tap.id, 5), - BasketItem(self.beer.id, 10), - ], - ).status_code - == 200 + res = self.submit_basket( + self.customer, + [BasketItem(self.beer_tap.id, 5), BasketItem(self.beer.id, 10)], ) + assert res.status_code == 200 assert self.updated_amount(self.customer) == Decimal("10") @@ -606,116 +500,73 @@ class TestCounterClick(TestFullClickBase): def test_selling_ordering(self): # Cheaper items should be processed with a higher priority self.login_in_bar(self.barmen) - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.beer.id, 1), - BasketItem(self.gift.id, 1), - ], - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)] ) + assert res.status_code == 302 assert self.updated_amount(self.customer) == 0 def test_recordings(self): self.refill_user(self.customer, self.cons.selling_price * 3) self.login_in_bar(self.barmen) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.cons.id, 3)], - ).status_code - == 302 - ) + res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == 0 + assert list( + self.customer.customer.return_balances.values("returnable", "balance") + ) == [{"returnable": self.cons.cons.id, "balance": 3}] - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, 3)], - ).status_code - == 302 - ) - + res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)]) + assert res.status_code == 302 assert self.updated_amount(self.customer) == self.dcons.selling_price * -3 - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)], - ).status_code - == 302 + res = self.submit_basket( + self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)] ) + # from now on, the user amount should not change + expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return) + assert res.status_code == 302 + assert self.updated_amount(self.customer) == expected_amount - assert self.updated_amount(self.customer) == self.dcons.selling_price * ( - -3 - settings.SITH_ECOCUP_LIMIT - ) + res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) + assert res.status_code == 200 + assert self.updated_amount(self.customer) == expected_amount - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, 1)], - ).status_code - == 200 - ) - - assert self.updated_amount(self.customer) == self.dcons.selling_price * ( - -3 - settings.SITH_ECOCUP_LIMIT - ) - - assert ( - self.submit_basket( - self.customer, - [ - BasketItem(self.cons.id, 1), - BasketItem(self.dcons.id, 1), - ], - ).status_code - == 302 - ) - - assert self.updated_amount(self.customer) == self.dcons.selling_price * ( - -3 - settings.SITH_ECOCUP_LIMIT + res = self.submit_basket( + self.customer, [BasketItem(self.cons.id, 1), BasketItem(self.dcons.id, 1)] ) + assert res.status_code == 302 + assert self.updated_amount(self.customer) == expected_amount def test_recordings_when_negative(self): - self.refill_user( - self.customer, - self.cons.selling_price * 3 + Decimal(self.beer.selling_price), + sale_recipe.make( + customer=self.customer.customer, + product=self.dcons, + unit_price=self.dcons.selling_price, + quantity=10, ) - self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10 - self.customer.customer.save() + self.customer.customer.update_returnable_balance() self.login_in_bar(self.barmen) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.dcons.id, 1)], - ).status_code - == 200 - ) - assert self.updated_amount( - self.customer - ) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price) - assert ( - self.submit_basket( - self.customer, - [BasketItem(self.cons.id, 3)], - ).status_code - == 302 - ) - assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price) + res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) + assert res.status_code == 200 + assert self.updated_amount(self.customer) == self.dcons.selling_price * -10 + res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)]) + assert res.status_code == 302 assert ( - self.submit_basket( - self.customer, - [BasketItem(self.beer.id, 1)], - ).status_code - == 302 + self.updated_amount(self.customer) + == self.dcons.selling_price * -10 - self.cons.selling_price * 3 + ) + + res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)]) + assert res.status_code == 302 + assert ( + self.updated_amount(self.customer) + == self.dcons.selling_price * -10 + - self.cons.selling_price * 3 + - self.beer.selling_price ) - assert self.updated_amount(self.customer) == 0 class TestCounterStats(TestCase): diff --git a/counter/tests/test_customer.py b/counter/tests/test_customer.py index bc3f4fb4..56daccb1 100644 --- a/counter/tests/test_customer.py +++ b/counter/tests/test_customer.py @@ -14,12 +14,13 @@ from model_bakery import baker from club.models import Membership from core.baker_recipes import board_user, subscriber_user from core.models import User -from counter.baker_recipes import refill_recipe, sale_recipe +from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.models import ( BillingInfo, Counter, Customer, Refilling, + ReturnableProduct, Selling, StudentCard, ) @@ -482,3 +483,31 @@ def test_update_balance(): for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False): customer.refresh_from_db() assert customer.amount == amount + + +@pytest.mark.django_db +def test_update_returnable_balance(): + ReturnableProduct.objects.all().delete() + customer = baker.make(Customer) + products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True) + returnables = [ + baker.make( + ReturnableProduct, product=products[0], returned_product=products[1] + ), + baker.make( + ReturnableProduct, product=products[2], returned_product=products[3] + ), + ] + balance_qs = ReturnableProduct.objects.annotate_balance_for(customer) + assert not customer.return_balances.exists() + assert list(balance_qs.values_list("balance", flat=True)) == [0, 0] + + sale_recipe.make(customer=customer, product=products[0], unit_price=0, quantity=5) + sale_recipe.make(customer=customer, product=products[2], unit_price=0, quantity=1) + sale_recipe.make(customer=customer, product=products[3], unit_price=0, quantity=3) + customer.update_returnable_balance() + assert list(customer.return_balances.values("returnable_id", "balance")) == [ + {"returnable_id": returnables[0].id, "balance": 5}, + {"returnable_id": returnables[1].id, "balance": -2}, + ] + assert set(balance_qs.values_list("balance", flat=True)) == {-2, 5} diff --git a/counter/tests/test_returnable_product.py b/counter/tests/test_returnable_product.py new file mode 100644 index 00000000..b25b45d6 --- /dev/null +++ b/counter/tests/test_returnable_product.py @@ -0,0 +1,37 @@ +import pytest +from model_bakery import baker + +from counter.baker_recipes import refill_recipe, sale_recipe +from counter.models import Customer, ReturnableProduct + + +@pytest.mark.django_db +def test_update_returnable_product_balance(): + Customer.objects.all().delete() + ReturnableProduct.objects.all().delete() + customers = baker.make(Customer, _quantity=2, _bulk_create=True) + refill_recipe.make(customer=iter(customers), _quantity=2, amount=100) + returnable = baker.make(ReturnableProduct) + sale_recipe.make( + unit_price=0, quantity=3, product=returnable.product, customer=customers[0] + ) + sale_recipe.make( + unit_price=0, quantity=1, product=returnable.product, customer=customers[0] + ) + sale_recipe.make( + unit_price=0, + quantity=2, + product=returnable.returned_product, + customer=customers[0], + ) + sale_recipe.make( + unit_price=0, quantity=4, product=returnable.product, customer=customers[1] + ) + + returnable.update_balances() + assert list( + returnable.balances.order_by("customer_id").values("customer_id", "balance") + ) == [ + {"customer_id": customers[0].pk, "balance": 2}, + {"customer_id": customers[1].pk, "balance": 4}, + ] diff --git a/counter/urls.py b/counter/urls.py index 885b4b14..c07205cb 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -30,6 +30,10 @@ from counter.views.admin import ( ProductTypeEditView, ProductTypeListView, RefillingDeleteView, + ReturnableProductCreateView, + ReturnableProductDeleteView, + ReturnableProductListView, + ReturnableProductUpdateView, SellingDeleteView, ) from counter.views.auth import counter_login, counter_logout @@ -51,10 +55,7 @@ from counter.views.home import ( CounterMain, ) from counter.views.invoice import InvoiceCallView -from counter.views.student_card import ( - StudentCardDeleteView, - StudentCardFormView, -) +from counter.views.student_card import StudentCardDeleteView, StudentCardFormView urlpatterns = [ path("/", CounterMain.as_view(), name="details"), @@ -129,6 +130,24 @@ urlpatterns = [ ProductTypeEditView.as_view(), name="product_type_edit", ), + path( + "admin/returnable/", ReturnableProductListView.as_view(), name="returnable_list" + ), + path( + "admin/returnable/create/", + ReturnableProductCreateView.as_view(), + name="create_returnable", + ), + path( + "admin/returnable//", + ReturnableProductUpdateView.as_view(), + name="edit_returnable", + ), + path( + "admin/returnable/delete//", + ReturnableProductDeleteView.as_view(), + name="delete_returnable", + ), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), path( diff --git a/counter/views/admin.py b/counter/views/admin.py index ffe81ea0..76e4e512 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -15,19 +15,28 @@ from datetime import timedelta from django.conf import settings +from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import PermissionDenied from django.forms import CheckboxSelectMultiple from django.forms.models import modelform_factory from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.translation import gettext as _ from django.views.generic import DetailView, ListView, TemplateView from django.views.generic.edit import CreateView, DeleteView, UpdateView from core.auth.mixins import CanEditMixin, CanViewMixin from core.utils import get_semester_code, get_start_of_semester -from counter.forms import CounterEditForm, ProductEditForm -from counter.models import Counter, Product, ProductType, Refilling, Selling +from counter.forms import CounterEditForm, ProductEditForm, ReturnableProductForm +from counter.models import ( + Counter, + Product, + ProductType, + Refilling, + ReturnableProduct, + Selling, +) from counter.utils import is_logged_in_counter from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin @@ -146,6 +155,69 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): current_tab = "products" +class ReturnableProductListView( + CounterAdminTabsMixin, PermissionRequiredMixin, ListView +): + model = ReturnableProduct + queryset = model.objects.select_related("product", "returned_product") + template_name = "counter/returnable_list.jinja" + current_tab = "returnable_products" + permission_required = "counter.view_returnableproduct" + + +class ReturnableProductCreateView( + CounterAdminTabsMixin, PermissionRequiredMixin, CreateView +): + form_class = ReturnableProductForm + template_name = "core/create.jinja" + current_tab = "returnable_products" + success_url = reverse_lazy("counter:returnable_list") + permission_required = "counter.add_returnableproduct" + + +class ReturnableProductUpdateView( + CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView +): + model = ReturnableProduct + pk_url_kwarg = "returnable_id" + queryset = model.objects.select_related("product", "returned_product") + form_class = ReturnableProductForm + template_name = "core/edit.jinja" + current_tab = "returnable_products" + success_url = reverse_lazy("counter:returnable_list") + permission_required = "counter.change_returnableproduct" + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object_name": _("returnable product : %(returnable)s -> %(returned)s") + % { + "returnable": self.object.product.name, + "returned": self.object.returned_product.name, + } + } + + +class ReturnableProductDeleteView( + CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView +): + model = ReturnableProduct + pk_url_kwarg = "returnable_id" + queryset = model.objects.select_related("product", "returned_product") + template_name = "core/delete_confirm.jinja" + current_tab = "returnable_products" + success_url = reverse_lazy("counter:returnable_list") + permission_required = "counter.delete_returnableproduct" + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object_name": _("returnable product : %(returnable)s -> %(returned)s") + % { + "returnable": self.object.product.name, + "returned": self.object.returned_product.name, + } + } + + class RefillingDeleteView(DeleteView): """Delete a refilling (for the admins).""" diff --git a/counter/views/click.py b/counter/views/click.py index 46bf8e62..a44e841f 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -16,6 +16,7 @@ import math from django.core.exceptions import PermissionDenied from django.db import transaction +from django.db.models import Q from django.forms import ( BaseFormSet, Form, @@ -35,7 +36,13 @@ from core.auth.mixins import CanViewMixin from core.models import User from core.utils import FormFragmentTemplateData from counter.forms import RefillForm -from counter.models import Counter, Customer, Product, Selling +from counter.models import ( + Counter, + Customer, + Product, + ReturnableProduct, + Selling, +) from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin from counter.views.student_card import StudentCardFormView @@ -99,17 +106,22 @@ class ProductForm(Form): class BaseBasketForm(BaseFormSet): def clean(self): - super().clean() - if len(self) == 0: + if len(self.forms) == 0: return self._check_forms_have_errors() + self._check_product_are_unique() self._check_recorded_products(self[0].customer) self._check_enough_money(self[0].counter, self[0].customer) def _check_forms_have_errors(self): if any(len(form.errors) > 0 for form in self): - raise ValidationError(_("Submmited basket is invalid")) + raise ValidationError(_("Submitted basket is invalid")) + + def _check_product_are_unique(self): + product_ids = {form.cleaned_data["id"] for form in self.forms} + if len(product_ids) != len(self.forms): + raise ValidationError(_("Duplicated product entries.")) def _check_enough_money(self, counter: Counter, customer: Customer): self.total_price = sum([data["total_price"] for data in self.cleaned_data]) @@ -118,21 +130,32 @@ class BaseBasketForm(BaseFormSet): def _check_recorded_products(self, customer: Customer): """Check for, among other things, ecocups and pitchers""" - self.total_recordings = 0 - for form in self: - # form.product is stored by the clean step of each formset form - if form.product.is_record_product: - self.total_recordings -= form.cleaned_data["quantity"] - if form.product.is_unrecord_product: - self.total_recordings += form.cleaned_data["quantity"] - - # We don't want to block an user that have negative recordings - # if he isn't recording anything or reducing it's recording count - if self.total_recordings <= 0: - return - - if not customer.can_record_more(self.total_recordings): - raise ValidationError(_("This user have reached his recording limit")) + items = { + form.cleaned_data["id"]: form.cleaned_data["quantity"] + for form in self.forms + } + ids = list(items.keys()) + returnables = list( + ReturnableProduct.objects.filter( + Q(product_id__in=ids) | Q(returned_product_id__in=ids) + ).annotate_balance_for(customer) + ) + limit_reached = [] + for returnable in returnables: + returnable.balance += items.get(returnable.product_id, 0) + for returnable in returnables: + dcons = items.get(returnable.returned_product_id, 0) + returnable.balance -= dcons + if dcons and returnable.balance < -returnable.max_return: + limit_reached.append(returnable.returned_product) + if limit_reached: + raise ValidationError( + _( + "This user have reached his recording limit " + "for the following products : %s" + ) + % ", ".join([str(p) for p in limit_reached]) + ) BasketForm = formset_factory( @@ -238,8 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): customer=self.customer, ).save() - self.customer.recorded_products -= formset.total_recordings - self.customer.save() + self.customer.update_returnable_balance() # Add some info for the main counter view to display self.request.session["last_customer"] = self.customer.user.get_display_name() @@ -248,6 +270,37 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): return ret + def _update_returnable_balance(self, formset): + ids = [form.cleaned_data["id"] for form in formset] + returnables = list( + ReturnableProduct.objects.filter( + Q(product_id__in=ids) | Q(returned_product_id__in=ids) + ).annotate_balance_for(self.customer) + ) + for returnable in returnables: + cons_quantity = next( + ( + form.cleaned_data["quantity"] + for form in formset + if form.cleaned_data["id"] == returnable.product_id + ), + 0, + ) + dcons_quantity = next( + ( + form.cleaned_data["quantity"] + for form in formset + if form.cleaned_data["id"] == returnable.returned_product_id + ), + 0, + ) + self.customer.return_balances.update_or_create( + returnable=returnable, + defaults={ + "balance": returnable.balance + cons_quantity - dcons_quantity + }, + ) + def get_success_url(self): return resolve_url(self.object) diff --git a/counter/views/mixins.py b/counter/views/mixins.py index 5c01392a..90bd1291 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -98,6 +98,11 @@ class CounterAdminTabsMixin(TabedViewMixin): "slug": "product_types", "name": _("Product types"), }, + { + "url": reverse_lazy("counter:returnable_list"), + "slug": "returnable_products", + "name": _("Returnable products"), + }, { "url": reverse_lazy("counter:cash_summary_list"), "slug": "cash_summary", diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 19b164df..891ecfda 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: 2025-02-25 16:38+0100\n" +"POT-Creation-Date: 2025-03-06 22:40+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -2837,6 +2837,7 @@ msgid "Users" msgstr "Utilisateurs" #: core/templates/core/search.jinja core/views/user.py +#: counter/templates/counter/product_list.jinja msgid "Clubs" msgstr "Clubs" @@ -3182,7 +3183,7 @@ msgid "Bans" msgstr "Bans" #: core/templates/core/user_tools.jinja counter/forms.py -#: counter/views/mixins.py +#: counter/templates/counter/product_list.jinja counter/views/mixins.py msgid "Counters" msgstr "Comptoirs" @@ -3198,6 +3199,10 @@ msgstr "Gestion des produits" msgid "Product types management" msgstr "Gestion des types de produit" +#: core/templates/core/user_tools.jinja +msgid "Returnable products management" +msgstr "Gestion des consignes" + #: core/templates/core/user_tools.jinja #: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py msgid "Cash register summaries" @@ -3460,10 +3465,6 @@ msgstr "Vidange de votre compte AE" msgid "account id" msgstr "numéro de compte" -#: counter/models.py -msgid "recorded product" -msgstr "produits consignés" - #: counter/models.py msgid "customer" msgstr "client" @@ -3709,6 +3710,36 @@ msgstr "carte étudiante" msgid "student cards" msgstr "cartes étudiantes" +#: counter/models.py +msgid "returnable product" +msgstr "produit consigné" + +#: counter/models.py +msgid "returned product" +msgstr "produit déconsigné" + +#: counter/models.py +msgid "maximum returns" +msgstr "nombre de déconsignes maximum" + +#: counter/models.py +msgid "" +"The maximum number of items a customer can return without having actually " +"bought them." +msgstr "" +"Le nombre maximum d'articles qu'un client peut déconsigner sans les avoir " +"achetés." + +#: counter/models.py +msgid "returnable products" +msgstr "produits consignés" + +#: counter/models.py +msgid "The returnable product cannot be the same as the returned one" +msgstr "" +"Le produit consigné ne peut pas être le même " +"que le produit de déconsigne" + #: counter/templates/counter/activity.jinja #, python-format msgid "%(counter_name)s activity" @@ -4076,6 +4107,14 @@ msgstr "Il n'y a pas de types de produit dans ce site web." msgid "Seller" msgstr "Vendeur" +#: counter/templates/counter/returnable_list.jinja counter/views/mixins.py +msgid "Returnable products" +msgstr "Produits consignés" + +#: counter/templates/counter/returnable_list.jinja +msgid "Returned product" +msgstr "Produit déconsigné" + #: counter/templates/counter/stats.jinja #, python-format msgid "%(counter_name)s stats" @@ -4108,6 +4147,11 @@ 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 +#, python-format +msgid "returnable product : %(returnable)s -> %(returned)s" +msgstr "produit consigné : %(returnable)s -> %(returned)s" + #: counter/views/cash.py msgid "10 cents" msgstr "10 centimes" @@ -4161,12 +4205,20 @@ msgid "The selected product isn't available for this user" msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur" #: counter/views/click.py -msgid "Submmited basket is invalid" +msgid "Submitted basket is invalid" msgstr "Le panier envoyé est invalide" #: counter/views/click.py -msgid "This user have reached his recording limit" -msgstr "Cet utilisateur a atteint sa limite de déconsigne" +msgid "Duplicated product entries." +msgstr "Saisie de produit dupliquée" + +#: counter/views/click.py +#, python-format +msgid "" +"This user have reached his recording limit for the following products : %s" +msgstr "" +"Cet utilisateur a atteint sa limite de déconsigne pour les produits " +"suivants : %s" #: counter/views/eticket.py msgid "people(s)"