feat: generic returnable products

This commit is contained in:
imperosol 2025-03-06 16:52:34 +01:00
parent 222ff762da
commit 9148cbf206
9 changed files with 600 additions and 312 deletions

View File

@ -50,7 +50,7 @@ from com.calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image 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 election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum from forum.models import Forum
from pedagogy.models import UV from pedagogy.models import UV
@ -470,7 +470,6 @@ Welcome to the wiki page!
limit_age=18, limit_age=18,
) )
cons = Product.objects.create( cons = Product.objects.create(
id=settings.SITH_ECOCUP_CONS,
name="Consigne Eco-cup", name="Consigne Eco-cup",
code="CONS", code="CONS",
product_type=verre, product_type=verre,
@ -480,7 +479,6 @@ Welcome to the wiki page!
club=main_club, club=main_club,
) )
dcons = Product.objects.create( dcons = Product.objects.create(
id=settings.SITH_ECOCUP_DECO,
name="Déconsigne Eco-cup", name="Déconsigne Eco-cup",
code="DECO", code="DECO",
product_type=verre, product_type=verre,
@ -529,6 +527,9 @@ Welcome to the wiki page!
special_selling_price="0", special_selling_price="0",
club=refound, club=refound,
) )
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
# Accounting test values: # Accounting test values:
BankAccount.objects.create(name="AE TG", club=main_club) BankAccount.objects.create(name="AE TG", club=main_club)

View File

@ -26,6 +26,7 @@ from counter.models import (
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
ReturnableProduct,
Selling, Selling,
) )
@ -43,6 +44,18 @@ class ProductAdmin(SearchModelAdmin):
search_fields = ("name", "code") 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) @admin.register(Customer)
class CustomerAdmin(SearchModelAdmin): class CustomerAdmin(SearchModelAdmin):
list_display = ("user", "account_id", "amount") list_display = ("user", "account_id", "amount")

View File

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

View File

@ -21,7 +21,7 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import Self from typing import Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@ -94,7 +94,6 @@ class Customer(models.Model):
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
account_id = models.CharField(_("account id"), max_length=10, unique=True) account_id = models.CharField(_("account id"), max_length=10, unique=True)
amount = CurrencyField(_("amount"), default=0) amount = CurrencyField(_("amount"), default=0)
recorded_products = models.IntegerField(_("recorded product"), default=0)
objects = CustomerQuerySet.as_manager() objects = CustomerQuerySet.as_manager()
@ -106,24 +105,50 @@ class Customer(models.Model):
def __str__(self): def __str__(self):
return "%s - %s" % (self.user.username, self.account_id) 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 """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 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. 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")) raise ValidationError(_("Not enough money"))
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("core:user_account", kwargs={"user_id": self.user.pk}) return reverse("core:user_account", kwargs={"user_id": self.user.pk})
@property def update_returnable_balance(self):
def can_record(self): """Update all returnable balances of this user to their real amount."""
return self.recorded_products > -settings.SITH_ECOCUP_LIMIT
def can_record_more(self, number): def purchases_qs(outer_ref: Literal["product_id", "returned_product_id"]):
return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT 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 @property
def can_buy(self) -> bool: def can_buy(self) -> bool:
@ -379,14 +404,6 @@ class Product(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("counter:product_list") 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): def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
@ -860,7 +877,7 @@ class Selling(models.Model):
self.full_clean() self.full_clean()
if not self.is_validated: if not self.is_validated:
self.customer.amount -= self.quantity * self.unit_price 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 self.is_validated = True
user = self.customer.user user = self.customer.user
if user.was_subscribed: if user.was_subscribed:
@ -945,6 +962,7 @@ class Selling(models.Model):
self.customer.amount += self.quantity * self.unit_price self.customer.amount += self.quantity * self.unit_price
self.customer.save() self.customer.save()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
self.customer.update_returnable_balance()
def send_mail_customer(self): def send_mail_customer(self):
event = self.product.eticket.event_title or _("Unknown event") event = self.product.eticket.event_title or _("Unknown event")
@ -1211,3 +1229,134 @@ class StudentCard(models.Model):
if isinstance(obj, User): if isinstance(obj, User):
return StudentCard.can_create(self.customer, obj) return StudentCard.can_create(self.customer, obj)
return False 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}"
)

View File

@ -28,17 +28,19 @@ from django.utils import timezone
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from freezegun import freeze_time from freezegun import freeze_time
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import BanGroup, 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 ( from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
Product, Product,
Refilling, Refilling,
ReturnableProduct,
Selling, Selling,
) )
@ -97,7 +99,7 @@ class TestRefilling(TestFullClickBase):
self, self,
user: User | Customer, user: User | Customer,
counter: Counter, counter: Counter,
amount: int, amount: int | float,
client: Client | None = None, client: Client | None = None,
) -> HttpResponse: ) -> HttpResponse:
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
@ -241,31 +243,31 @@ class TestCounterClick(TestFullClickBase):
special_selling_price="-1.5", special_selling_price="-1.5",
) )
cls.beer = product_recipe.make( 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( cls.beer_tap = product_recipe.make(
limit_age=18, limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
tray=True,
selling_price="1.5",
special_selling_price="1",
) )
cls.snack = product_recipe.make( 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( 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.counter.products.add(
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
) )
cls.other_counter.products.add(cls.snack) cls.other_counter.products.add(cls.snack)
cls.club_counter.products.add(cls.stamps) cls.club_counter.products.add(cls.stamps)
def login_in_bar(self, barmen: User | None = None): def login_in_bar(self, barmen: User | None = None):
@ -309,57 +311,36 @@ class TestCounterClick(TestFullClickBase):
def test_click_eboutic_failure(self): def test_click_eboutic_failure(self):
eboutic = baker.make(Counter, type="EBOUTIC") eboutic = baker.make(Counter, type="EBOUTIC")
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.stamps.id, 5)], counter=eboutic
self.customer,
[BasketItem(self.stamps.id, 5)],
counter=eboutic,
).status_code
== 404
) )
assert res.status_code == 404
def test_click_office_success(self): def test_click_office_success(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)
res = self.submit_basket(
assert ( self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
self.submit_basket(
self.customer,
[BasketItem(self.stamps.id, 5)],
counter=self.club_counter,
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == Decimal("2.5") assert self.updated_amount(self.customer) == Decimal("2.5")
# Test no special price on office counter # Test no special price on office counter
self.refill_user(self.club_admin, 10) self.refill_user(self.club_admin, 10)
res = self.submit_basket(
assert ( self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
self.submit_basket(
self.club_admin,
[BasketItem(self.stamps.id, 1)],
counter=self.club_counter,
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.club_admin) == Decimal("8.5") assert self.updated_amount(self.club_admin) == Decimal("8.5")
def test_click_bar_success(self): def test_click_bar_success(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
res = self.submit_basket(
assert ( self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
self.submit_basket(
self.customer,
[
BasketItem(self.beer.id, 2),
BasketItem(self.snack.id, 1),
],
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == Decimal("5.5") assert self.updated_amount(self.customer) == Decimal("5.5")
@ -378,29 +359,13 @@ class TestCounterClick(TestFullClickBase):
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
# Not applying tray price # Not applying tray price
assert ( res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
self.submit_basket( assert res.status_code == 302
self.customer,
[
BasketItem(self.beer_tap.id, 2),
],
).status_code
== 302
)
assert self.updated_amount(self.customer) == Decimal("17") assert self.updated_amount(self.customer) == Decimal("17")
# Applying tray price # Applying tray price
assert ( res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
self.submit_basket( assert res.status_code == 302
self.customer,
[
BasketItem(self.beer_tap.id, 7),
],
).status_code
== 302
)
assert self.updated_amount(self.customer) == Decimal("8") assert self.updated_amount(self.customer) == Decimal("8")
def test_click_alcool_unauthorized(self): def test_click_alcool_unauthorized(self):
@ -410,28 +375,14 @@ class TestCounterClick(TestFullClickBase):
self.refill_user(user, 10) self.refill_user(user, 10)
# Buy product without age limit # Buy product without age limit
assert ( res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
self.submit_basket( assert res.status_code == 302
user,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302
)
assert self.updated_amount(user) == Decimal("7") assert self.updated_amount(user) == Decimal("7")
# Buy product without age limit # Buy product without age limit
assert ( res = self.submit_basket(user, [BasketItem(self.beer.id, 2)])
self.submit_basket( assert res.status_code == 200
user,
[
BasketItem(self.beer.id, 2),
],
).status_code
== 200
)
assert self.updated_amount(user) == Decimal("7") assert self.updated_amount(user) == Decimal("7")
@ -443,12 +394,7 @@ class TestCounterClick(TestFullClickBase):
self.customer_old_can_not_buy, self.customer_old_can_not_buy,
]: ]:
self.refill_user(user, 10) self.refill_user(user, 10)
resp = self.submit_basket( resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
user,
[
BasketItem(self.snack.id, 2),
],
)
assert resp.status_code == 302 assert resp.status_code == 302
assert resp.url == resolve_url(self.counter) assert resp.url == resolve_url(self.counter)
@ -456,44 +402,28 @@ class TestCounterClick(TestFullClickBase):
def test_click_user_without_customer(self): def test_click_user_without_customer(self):
self.login_in_bar() self.login_in_bar()
assert ( res = self.submit_basket(
self.submit_basket( self.customer_can_not_buy, [BasketItem(self.snack.id, 2)]
self.customer_can_not_buy,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 404
) )
assert res.status_code == 404
def test_click_allowed_old_subscriber(self): def test_click_allowed_old_subscriber(self):
self.login_in_bar() self.login_in_bar()
self.refill_user(self.customer_old_can_buy, 10) self.refill_user(self.customer_old_can_buy, 10)
assert ( res = self.submit_basket(
self.submit_basket( self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
self.customer_old_can_buy,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer_old_can_buy) == Decimal("7") assert self.updated_amount(self.customer_old_can_buy) == Decimal("7")
def test_click_wrong_counter(self): def test_click_wrong_counter(self):
self.login_in_bar() self.login_in_bar()
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
self.customer,
[
BasketItem(self.snack.id, 2),
],
counter=self.other_counter,
).status_code
== 302 # Redirect to counter main
) )
assertRedirects(res, self.other_counter.get_absolute_url())
# We want to test sending requests from another counter while # We want to test sending requests from another counter while
# we are currently registered to another counter # we are currently registered to another counter
@ -502,42 +432,25 @@ class TestCounterClick(TestFullClickBase):
# that using a client not logged to a counter # that using a client not logged to a counter
# where another client is logged still isn't authorized. # where another client is logged still isn't authorized.
client = Client() client = Client()
assert ( res = self.submit_basket(
self.submit_basket(
self.customer, self.customer,
[ [BasketItem(self.snack.id, 2)],
BasketItem(self.snack.id, 2),
],
counter=self.counter, counter=self.counter,
client=client, client=client,
).status_code
== 302 # Redirect to counter main
) )
assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_connected(self): def test_click_not_connected(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
assert ( res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
self.submit_basket( assertRedirects(res, self.counter.get_absolute_url())
self.customer,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302 # Redirect to counter main
)
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
self.customer,
[
BasketItem(self.snack.id, 2),
],
counter=self.club_counter,
).status_code
== 403
) )
assert res.status_code == 403
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
@ -545,15 +458,8 @@ class TestCounterClick(TestFullClickBase):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.login_in_bar() self.login_in_bar()
assert ( res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
self.submit_basket( assert res.status_code == 200
self.customer,
[
BasketItem(self.stamps.id, 2),
],
).status_code
== 200
)
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_invalid(self): def test_click_product_invalid(self):
@ -561,36 +467,24 @@ class TestCounterClick(TestFullClickBase):
self.login_in_bar() self.login_in_bar()
for item in [ for item in [
BasketItem("-1", 2), BasketItem(-1, 2),
BasketItem(self.beer.id, -1), BasketItem(self.beer.id, -1),
BasketItem(None, 1), BasketItem(None, 1),
BasketItem(self.beer.id, None), BasketItem(self.beer.id, None),
BasketItem(None, None), BasketItem(None, None),
]: ]:
assert ( assert self.submit_basket(self.customer, [item]).status_code == 200
self.submit_basket(
self.customer,
[item],
).status_code
== 200
)
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_enough_money(self): def test_click_not_enough_money(self):
self.refill_user(self.customer, 10) self.refill_user(self.customer, 10)
self.login_in_bar() self.login_in_bar()
res = self.submit_basket(
assert (
self.submit_basket(
self.customer, self.customer,
[ [BasketItem(self.beer_tap.id, 5), BasketItem(self.beer.id, 10)],
BasketItem(self.beer_tap.id, 5),
BasketItem(self.beer.id, 10),
],
).status_code
== 200
) )
assert res.status_code == 200
assert self.updated_amount(self.customer) == Decimal("10") assert self.updated_amount(self.customer) == Decimal("10")
@ -606,116 +500,73 @@ class TestCounterClick(TestFullClickBase):
def test_selling_ordering(self): def test_selling_ordering(self):
# Cheaper items should be processed with a higher priority # Cheaper items should be processed with a higher priority
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
res = self.submit_basket(
assert ( self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
self.submit_basket(
self.customer,
[
BasketItem(self.beer.id, 1),
BasketItem(self.gift.id, 1),
],
).status_code
== 302
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
def test_recordings(self): def test_recordings(self):
self.refill_user(self.customer, self.cons.selling_price * 3) self.refill_user(self.customer, self.cons.selling_price * 3)
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
assert ( res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
self.submit_basket( assert res.status_code == 302
self.customer,
[BasketItem(self.cons.id, 3)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == 0 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 ( res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
self.submit_basket( assert res.status_code == 302
self.customer,
[BasketItem(self.dcons.id, 3)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3 assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
self.customer,
[BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)],
).status_code
== 302
) )
# 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 * ( res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
-3 - settings.SITH_ECOCUP_LIMIT assert res.status_code == 200
) assert self.updated_amount(self.customer) == expected_amount
assert ( res = self.submit_basket(
self.submit_basket( self.customer, [BasketItem(self.cons.id, 1), BasketItem(self.dcons.id, 1)]
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
) )
assert res.status_code == 302
assert self.updated_amount(self.customer) == expected_amount
def test_recordings_when_negative(self): def test_recordings_when_negative(self):
self.refill_user( sale_recipe.make(
self.customer, customer=self.customer.customer,
self.cons.selling_price * 3 + Decimal(self.beer.selling_price), product=self.dcons,
unit_price=self.dcons.selling_price,
quantity=10,
) )
self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10 self.customer.customer.update_returnable_balance()
self.customer.customer.save()
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
assert ( res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
self.submit_basket( assert res.status_code == 200
self.customer, assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
[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.cons.id, 3)])
assert res.status_code == 302
assert ( assert (
self.submit_basket( self.updated_amount(self.customer)
self.customer, == self.dcons.selling_price * -10 - self.cons.selling_price * 3
[BasketItem(self.beer.id, 1)], )
).status_code
== 302 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): class TestCounterStats(TestCase):

View File

@ -14,12 +14,13 @@ from model_bakery import baker
from club.models import Membership from club.models import Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import 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 ( from counter.models import (
BillingInfo, BillingInfo,
Counter, Counter,
Customer, Customer,
Refilling, Refilling,
ReturnableProduct,
Selling, Selling,
StudentCard, StudentCard,
) )
@ -482,3 +483,31 @@ def test_update_balance():
for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False): for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False):
customer.refresh_from_db() customer.refresh_from_db()
assert customer.amount == amount 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}

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

View File

@ -16,6 +16,7 @@ import math
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.forms import ( from django.forms import (
BaseFormSet, BaseFormSet,
Form, Form,
@ -35,7 +36,13 @@ from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from counter.forms import RefillForm 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.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormView from counter.views.student_card import StudentCardFormView
@ -99,17 +106,22 @@ class ProductForm(Form):
class BaseBasketForm(BaseFormSet): class BaseBasketForm(BaseFormSet):
def clean(self): def clean(self):
super().clean() if len(self.forms) == 0:
if len(self) == 0:
return return
self._check_forms_have_errors() self._check_forms_have_errors()
self._check_product_are_unique()
self._check_recorded_products(self[0].customer) self._check_recorded_products(self[0].customer)
self._check_enough_money(self[0].counter, self[0].customer) self._check_enough_money(self[0].counter, self[0].customer)
def _check_forms_have_errors(self): def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in 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): def _check_enough_money(self, counter: Counter, customer: Customer):
self.total_price = sum([data["total_price"] for data in self.cleaned_data]) 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): def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers""" """Check for, among other things, ecocups and pitchers"""
self.total_recordings = 0 items = {
for form in self: form.cleaned_data["id"]: form.cleaned_data["quantity"]
# form.product is stored by the clean step of each formset form for form in self.forms
if form.product.is_record_product: }
self.total_recordings -= form.cleaned_data["quantity"] ids = list(items.keys())
if form.product.is_unrecord_product: returnables = list(
self.total_recordings += form.cleaned_data["quantity"] ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
# We don't want to block an user that have negative recordings ).annotate_balance_for(customer)
# if he isn't recording anything or reducing it's recording count )
if self.total_recordings <= 0: limit_reached = []
return for returnable in returnables:
returnable.balance += items.get(returnable.product_id, 0)
if not customer.can_record_more(self.total_recordings): for returnable in returnables:
raise ValidationError(_("This user have reached his recording limit")) 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( BasketForm = formset_factory(
@ -238,8 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
customer=self.customer, customer=self.customer,
).save() ).save()
self.customer.recorded_products -= formset.total_recordings self.customer.update_returnable_balance()
self.customer.save()
# Add some info for the main counter view to display # Add some info for the main counter view to display
self.request.session["last_customer"] = self.customer.user.get_display_name() self.request.session["last_customer"] = self.customer.user.get_display_name()
@ -248,6 +270,37 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
return ret 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): def get_success_url(self):
return resolve_url(self.object) return resolve_url(self.object)

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -2837,6 +2837,7 @@ msgid "Users"
msgstr "Utilisateurs" msgstr "Utilisateurs"
#: core/templates/core/search.jinja core/views/user.py #: core/templates/core/search.jinja core/views/user.py
#: counter/templates/counter/product_list.jinja
msgid "Clubs" msgid "Clubs"
msgstr "Clubs" msgstr "Clubs"
@ -3182,7 +3183,7 @@ msgid "Bans"
msgstr "Bans" msgstr "Bans"
#: core/templates/core/user_tools.jinja counter/forms.py #: 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" msgid "Counters"
msgstr "Comptoirs" msgstr "Comptoirs"
@ -3460,10 +3461,6 @@ msgstr "Vidange de votre compte AE"
msgid "account id" msgid "account id"
msgstr "numéro de compte" msgstr "numéro de compte"
#: counter/models.py
msgid "recorded product"
msgstr "produits consignés"
#: counter/models.py #: counter/models.py
msgid "customer" msgid "customer"
msgstr "client" msgstr "client"
@ -3709,6 +3706,30 @@ msgstr "carte étudiante"
msgid "student cards" msgid "student cards"
msgstr "cartes étudiantes" 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 #: counter/templates/counter/activity.jinja
#, python-format #, python-format
msgid "%(counter_name)s activity" 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" msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
#: counter/views/click.py #: counter/views/click.py
msgid "Submmited basket is invalid" msgid "Submitted basket is invalid"
msgstr "Le panier envoyé est invalide" msgstr "Le panier envoyé est invalide"
#: counter/views/click.py #: counter/views/click.py
msgid "This user have reached his recording limit" msgid "Duplicated product entries."
msgstr "Cet utilisateur a atteint sa limite de déconsigne" 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 #: counter/views/eticket.py
msgid "people(s)" msgid "people(s)"