mirror of
https://github.com/ae-utbm/sith.git
synced 2025-03-09 23:07:11 +00:00
feat: generic returnable products
This commit is contained in:
parent
222ff762da
commit
9148cbf206
@ -50,7 +50,7 @@ from com.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)
|
||||
|
@ -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")
|
||||
|
@ -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}"
|
||||
)
|
||||
|
@ -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}
|
||||
|
37
counter/tests/test_returnable_product.py
Normal file
37
counter/tests/test_returnable_product.py
Normal file
@ -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},
|
||||
]
|
@ -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)
|
||||
|
||||
|
@ -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 16:50+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"
|
||||
|
||||
@ -3460,10 +3461,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 +3706,30 @@ 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 "produits déconsignés"
|
||||
|
||||
#: 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é."
|
||||
|
||||
#: counter/models.py
|
||||
msgid "returnable products"
|
||||
msgstr "produits consignés"
|
||||
|
||||
#: counter/templates/counter/activity.jinja
|
||||
#, python-format
|
||||
msgid "%(counter_name)s activity"
|
||||
@ -4161,12 +4182,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)"
|
||||
|
Loading…
x
Reference in New Issue
Block a user