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

View File

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

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

View File

@ -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(
res = self.submit_basket(
self.customer,
[
BasketItem(self.snack.id, 2),
],
[BasketItem(self.snack.id, 2)],
counter=self.counter,
client=client,
).status_code
== 302 # Redirect to counter main
)
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(
res = self.submit_basket(
self.customer,
[
BasketItem(self.beer_tap.id, 5),
BasketItem(self.beer.id, 10),
],
).status_code
== 200
[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):

View File

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

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

View File

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