1
0
mirror of https://github.com/ae-utbm/sith.git synced 2025-03-27 07:37:09 +00:00

Merge e2f1610c2ac8fe64c7263a5da7cc939b0b2646ab into bb3dfb7e8a87e4c4ca61d2ee095bb6c3f7ffc115

This commit is contained in:
thomas girod 2025-03-14 13:28:24 +01:00 committed by GitHub
commit f5a62e5ad5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 848 additions and 328 deletions

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

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

@ -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 %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form>
<form method="GET" action="javascript:history.back();">

@ -62,6 +62,11 @@
{% trans %}Product types management{% endtrans %}
</a>
</li>
<li>
<a href="{{ url("counter:returnable_list") }}">
{% trans %}Returnable products management{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('counter:cash_summary_list') }}">
{% trans %}Cash register summaries{% endtrans %}

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

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

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

@ -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"),
]

@ -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}"
)

@ -0,0 +1,67 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Returnable products{% endtrans %}
{% endblock %}
{% block additional_js %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{% endblock %}
{% block content %}
<h3 class="margin-bottom">{% trans %}Returnable products{% endtrans %}</h3>
{% if user.has_perm("counter.add_returnableproduct") %}
<a href="{{ url('counter:create_returnable') }}" class="btn btn-blue margin-bottom">
{% trans %}New returnable product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% endif %}
<div class="product-group">
{% for returnable in object_list %}
{% if user.has_perm("counter.change_returnableproduct") %}
<a
class="card card-row shadow clickable"
href="{{ url("counter:edit_returnable", returnable_id=returnable.id) }}"
>
{% else %}
<div class="card card-row shadow">
{% endif %}
{% if returnable.product.icon %}
<img
class="card-image"
src="{{ returnable.product.icon.url }}"
alt="{{ returnable.product.name }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %}
<div class="card-content">
<strong class="card-title">{{ returnable.product }}</strong>
<p>{% trans %}Returned product{% endtrans %} : {{ returnable.returned_product }}</p>
</div>
{% if user.has_perm("counter.delete_returnableproduct") %}
<button
x-data
class="btn btn-red btn-no-text card-top-left"
@click.prevent="document.location.href = '{{ url("counter:delete_returnable", returnable_id=returnable.id) }}'"
>
{# The delete link is a button with a JS event listener
instead of a proper <a> element,
because the enclosing card is already a <a>,
and HTML forbids nested <a> #}
<i class="fa fa-trash"></i>
</button>
{% endif %}
{% if user.has_perm("counter.change_returnableproduct") %}
</a>
{% else %}
</div>
{% endif %}
{% endfor %}
</div>
{% endblock content %}

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

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

@ -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},
]

@ -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("<int:counter_id>/", 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/<int:returnable_id>/",
ReturnableProductUpdateView.as_view(),
name="edit_returnable",
),
path(
"admin/returnable/delete/<int:returnable_id>/",
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(

@ -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)."""

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

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

@ -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 <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\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)"