mirror of
https://github.com/ae-utbm/sith.git
synced 2025-03-10 07:17:11 +00:00
feat: generic returnable products
This commit is contained in:
parent
222ff762da
commit
9148cbf206
@ -50,7 +50,7 @@ from com.calendar import IcsCalendar
|
|||||||
from com.models import News, NewsDate, Sith, Weekmail
|
from 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)
|
||||||
|
@ -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")
|
||||||
|
@ -0,0 +1,126 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2025-03-05 14:03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_cons_balances(apps: StateApps, schema_editor):
|
||||||
|
ReturnableProduct = apps.get_model("counter", "ReturnableProduct")
|
||||||
|
Product = apps.get_model("counter", "Product")
|
||||||
|
|
||||||
|
cons = Product.objects.filter(pk=settings.SITH_ECOCUP_CONS).first()
|
||||||
|
dcons = Product.objects.filter(pk=settings.SITH_ECOCUP_DECO).first()
|
||||||
|
if not cons or not dcons:
|
||||||
|
return
|
||||||
|
returnable = ReturnableProduct.objects.create(
|
||||||
|
product=cons, returned_product=dcons, max_return=settings.SITH_ECOCUP_LIMIT
|
||||||
|
)
|
||||||
|
returnable.update_balances()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0029_alter_selling_label")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReturnableProduct",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"max_return",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text=(
|
||||||
|
"The maximum number of items a customer can return "
|
||||||
|
"without having actually bought them."
|
||||||
|
),
|
||||||
|
verbose_name="maximum returns",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"product",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="cons",
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="returnable product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"returned_product",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="dcons",
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="returned product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "returnable product",
|
||||||
|
"verbose_name_plural": "returnable products",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="returnableproduct",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
check=models.Q(
|
||||||
|
("product", models.F("returned_product")), _negated=True
|
||||||
|
),
|
||||||
|
name="returnableproduct_product_different_from_returned",
|
||||||
|
violation_error_message="The returnable product cannot be the same as the returned one",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReturnableProductBalance",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("balance", models.SmallIntegerField(blank=True, default=0)),
|
||||||
|
(
|
||||||
|
"customer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="return_balances",
|
||||||
|
to="counter.customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"returnable",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="balances",
|
||||||
|
to="counter.returnableproduct",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="returnableproductbalance",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("customer", "returnable"),
|
||||||
|
name="returnable_product_unique_type_per_customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_cons_balances, reverse_code=migrations.RunPython.noop, elidable=True
|
||||||
|
),
|
||||||
|
migrations.RemoveField(model_name="customer", name="recorded_products"),
|
||||||
|
]
|
@ -21,7 +21,7 @@ import string
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import 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}"
|
||||||
|
)
|
||||||
|
@ -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)],
|
||||||
[
|
counter=self.counter,
|
||||||
BasketItem(self.snack.id, 2),
|
client=client,
|
||||||
],
|
|
||||||
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")
|
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.customer,
|
||||||
self.submit_basket(
|
[BasketItem(self.beer_tap.id, 5), BasketItem(self.beer.id, 10)],
|
||||||
self.customer,
|
|
||||||
[
|
|
||||||
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):
|
||||||
|
@ -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}
|
||||||
|
37
counter/tests/test_returnable_product.py
Normal file
37
counter/tests/test_returnable_product.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import pytest
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from counter.baker_recipes import refill_recipe, sale_recipe
|
||||||
|
from counter.models import Customer, ReturnableProduct
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_update_returnable_product_balance():
|
||||||
|
Customer.objects.all().delete()
|
||||||
|
ReturnableProduct.objects.all().delete()
|
||||||
|
customers = baker.make(Customer, _quantity=2, _bulk_create=True)
|
||||||
|
refill_recipe.make(customer=iter(customers), _quantity=2, amount=100)
|
||||||
|
returnable = baker.make(ReturnableProduct)
|
||||||
|
sale_recipe.make(
|
||||||
|
unit_price=0, quantity=3, product=returnable.product, customer=customers[0]
|
||||||
|
)
|
||||||
|
sale_recipe.make(
|
||||||
|
unit_price=0, quantity=1, product=returnable.product, customer=customers[0]
|
||||||
|
)
|
||||||
|
sale_recipe.make(
|
||||||
|
unit_price=0,
|
||||||
|
quantity=2,
|
||||||
|
product=returnable.returned_product,
|
||||||
|
customer=customers[0],
|
||||||
|
)
|
||||||
|
sale_recipe.make(
|
||||||
|
unit_price=0, quantity=4, product=returnable.product, customer=customers[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
returnable.update_balances()
|
||||||
|
assert list(
|
||||||
|
returnable.balances.order_by("customer_id").values("customer_id", "balance")
|
||||||
|
) == [
|
||||||
|
{"customer_id": customers[0].pk, "balance": 2},
|
||||||
|
{"customer_id": customers[1].pk, "balance": 4},
|
||||||
|
]
|
@ -16,6 +16,7 @@ import math
|
|||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.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)
|
||||||
|
|
||||||
|
@ -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)"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user