diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 21fde2e5..141c6fce 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -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) 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/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 1467c9f4..4867a581 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/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/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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 19b164df..429d2090 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 16:50+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" @@ -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)"