Merge e2f1610c2ac8fe64c7263a5da7cc939b0b2646ab into bb3dfb7e8a87e4c4ca61d2ee095bb6c3f7ffc115

This commit is contained in:
thomas girod 2025-03-14 13:28:24 +01:00 committed by GitHub
commit f5a62e5ad5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 848 additions and 328 deletions

View File

@ -50,7 +50,7 @@ from com.ics_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

@ -55,6 +55,14 @@
width: 80%; width: 80%;
} }
.card-top-left {
position: absolute;
top: 10px;
right: 10px;
padding: 10px;
text-align: center;
}
.card-content { .card-content {
color: black; color: black;
display: flex; display: flex;

View File

@ -10,10 +10,17 @@
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}
{# if the template context has the `object_name` variable,
then this one will be used,
instead of the result of `str(object)` #}
{% if object and not object_name %}
{% set object_name=object %}
{% endif %}
{% block content %} {% block content %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2> <h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p> <p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form> </form>
<form method="GET" action="javascript:history.back();"> <form method="GET" action="javascript:history.back();">

View File

@ -62,6 +62,11 @@
{% trans %}Product types management{% endtrans %} {% trans %}Product types management{% endtrans %}
</a> </a>
</li> </li>
<li>
<a href="{{ url("counter:returnable_list") }}">
{% trans %}Returnable products management{% endtrans %}
</a>
</li>
<li> <li>
<a href="{{ url('counter:cash_summary_list') }}"> <a href="{{ url('counter:cash_summary_list') }}">
{% trans %}Cash register summaries{% endtrans %} {% trans %}Cash register summaries{% endtrans %}

View File

@ -1,3 +1,5 @@
from typing import ClassVar
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.views import View from django.views import View
@ -6,20 +8,24 @@ from django.views import View
class TabedViewMixin(View): class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template.""" """Basic functions for displaying tabs in the template."""
current_tab: ClassVar[str | None] = None
list_of_tabs: ClassVar[list | None] = None
tabs_title: ClassVar[str | None] = None
def get_tabs_title(self): def get_tabs_title(self):
if hasattr(self, "tabs_title"): if not self.tabs_title:
return self.tabs_title raise ImproperlyConfigured("tabs_title is required")
raise ImproperlyConfigured("tabs_title is required") return self.tabs_title
def get_current_tab(self): def get_current_tab(self):
if hasattr(self, "current_tab"): if not self.current_tab:
return self.current_tab raise ImproperlyConfigured("current_tab is required")
raise ImproperlyConfigured("current_tab is required") return self.current_tab
def get_list_of_tabs(self): def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"): if not self.list_of_tabs:
return self.list_of_tabs raise ImproperlyConfigured("list_of_tabs is required")
raise ImproperlyConfigured("list_of_tabs is required") return self.list_of_tabs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

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

@ -17,6 +17,7 @@ from counter.models import (
Eticket, Eticket,
Product, Product,
Refilling, Refilling,
ReturnableProduct,
StudentCard, StudentCard,
) )
from counter.widgets.ajax_select import ( from counter.widgets.ajax_select import (
@ -213,6 +214,25 @@ class ProductEditForm(forms.ModelForm):
return ret return ret
class ReturnableProductForm(forms.ModelForm):
class Meta:
model = ReturnableProduct
fields = ["product", "returned_product", "max_return"]
widgets = {
"product": AutoCompleteSelectProduct(),
"returned_product": AutoCompleteSelectProduct(),
}
def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT
instance: ReturnableProduct = super().save(commit=commit)
if commit:
# This is expensive, but we don't have a task queue to offload it.
# Hopefully, creations and updates of returnable products
# occur very rarely
instance.update_balances()
return instance
class CashSummaryFormBase(forms.Form): class CashSummaryFormBase(forms.Form):
begin_date = forms.DateTimeField( begin_date = forms.DateTimeField(
label=_("Begin date"), widget=SelectDateTime, required=False label=_("Begin date"), widget=SelectDateTime, required=False

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

@ -0,0 +1,67 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Returnable products{% endtrans %}
{% endblock %}
{% block additional_js %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{% endblock %}
{% block content %}
<h3 class="margin-bottom">{% trans %}Returnable products{% endtrans %}</h3>
{% if user.has_perm("counter.add_returnableproduct") %}
<a href="{{ url('counter:create_returnable') }}" class="btn btn-blue margin-bottom">
{% trans %}New returnable product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% endif %}
<div class="product-group">
{% for returnable in object_list %}
{% if user.has_perm("counter.change_returnableproduct") %}
<a
class="card card-row shadow clickable"
href="{{ url("counter:edit_returnable", returnable_id=returnable.id) }}"
>
{% else %}
<div class="card card-row shadow">
{% endif %}
{% if returnable.product.icon %}
<img
class="card-image"
src="{{ returnable.product.icon.url }}"
alt="{{ returnable.product.name }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %}
<div class="card-content">
<strong class="card-title">{{ returnable.product }}</strong>
<p>{% trans %}Returned product{% endtrans %} : {{ returnable.returned_product }}</p>
</div>
{% if user.has_perm("counter.delete_returnableproduct") %}
<button
x-data
class="btn btn-red btn-no-text card-top-left"
@click.prevent="document.location.href = '{{ url("counter:delete_returnable", returnable_id=returnable.id) }}'"
>
{# The delete link is a button with a JS event listener
instead of a proper <a> element,
because the enclosing card is already a <a>,
and HTML forbids nested <a> #}
<i class="fa fa-trash"></i>
</button>
{% endif %}
{% if user.has_perm("counter.change_returnableproduct") %}
</a>
{% else %}
</div>
{% endif %}
{% endfor %}
</div>
{% endblock content %}

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)],
[ 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):

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

@ -30,6 +30,10 @@ from counter.views.admin import (
ProductTypeEditView, ProductTypeEditView,
ProductTypeListView, ProductTypeListView,
RefillingDeleteView, RefillingDeleteView,
ReturnableProductCreateView,
ReturnableProductDeleteView,
ReturnableProductListView,
ReturnableProductUpdateView,
SellingDeleteView, SellingDeleteView,
) )
from counter.views.auth import counter_login, counter_logout from counter.views.auth import counter_login, counter_logout
@ -51,10 +55,7 @@ from counter.views.home import (
CounterMain, CounterMain,
) )
from counter.views.invoice import InvoiceCallView from counter.views.invoice import InvoiceCallView
from counter.views.student_card import ( from counter.views.student_card import StudentCardDeleteView, StudentCardFormView
StudentCardDeleteView,
StudentCardFormView,
)
urlpatterns = [ urlpatterns = [
path("<int:counter_id>/", CounterMain.as_view(), name="details"), path("<int:counter_id>/", CounterMain.as_view(), name="details"),
@ -129,6 +130,24 @@ urlpatterns = [
ProductTypeEditView.as_view(), ProductTypeEditView.as_view(),
name="product_type_edit", name="product_type_edit",
), ),
path(
"admin/returnable/", ReturnableProductListView.as_view(), name="returnable_list"
),
path(
"admin/returnable/create/",
ReturnableProductCreateView.as_view(),
name="create_returnable",
),
path(
"admin/returnable/<int:returnable_id>/",
ReturnableProductUpdateView.as_view(),
name="edit_returnable",
),
path(
"admin/returnable/delete/<int:returnable_id>/",
ReturnableProductDeleteView.as_view(),
name="delete_returnable",
),
path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),
path( path(

View File

@ -15,19 +15,28 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from counter.forms import CounterEditForm, ProductEditForm from counter.forms import CounterEditForm, ProductEditForm, ReturnableProductForm
from counter.models import Counter, Product, ProductType, Refilling, Selling from counter.models import (
Counter,
Product,
ProductType,
Refilling,
ReturnableProduct,
Selling,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@ -146,6 +155,69 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products" current_tab = "products"
class ReturnableProductListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):
model = ReturnableProduct
queryset = model.objects.select_related("product", "returned_product")
template_name = "counter/returnable_list.jinja"
current_tab = "returnable_products"
permission_required = "counter.view_returnableproduct"
class ReturnableProductCreateView(
CounterAdminTabsMixin, PermissionRequiredMixin, CreateView
):
form_class = ReturnableProductForm
template_name = "core/create.jinja"
current_tab = "returnable_products"
success_url = reverse_lazy("counter:returnable_list")
permission_required = "counter.add_returnableproduct"
class ReturnableProductUpdateView(
CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
):
model = ReturnableProduct
pk_url_kwarg = "returnable_id"
queryset = model.objects.select_related("product", "returned_product")
form_class = ReturnableProductForm
template_name = "core/edit.jinja"
current_tab = "returnable_products"
success_url = reverse_lazy("counter:returnable_list")
permission_required = "counter.change_returnableproduct"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": _("returnable product : %(returnable)s -> %(returned)s")
% {
"returnable": self.object.product.name,
"returned": self.object.returned_product.name,
}
}
class ReturnableProductDeleteView(
CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView
):
model = ReturnableProduct
pk_url_kwarg = "returnable_id"
queryset = model.objects.select_related("product", "returned_product")
template_name = "core/delete_confirm.jinja"
current_tab = "returnable_products"
success_url = reverse_lazy("counter:returnable_list")
permission_required = "counter.delete_returnableproduct"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": _("returnable product : %(returnable)s -> %(returned)s")
% {
"returnable": self.object.product.name,
"returned": self.object.returned_product.name,
}
}
class RefillingDeleteView(DeleteView): class RefillingDeleteView(DeleteView):
"""Delete a refilling (for the admins).""" """Delete a refilling (for the admins)."""

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

@ -98,6 +98,11 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "product_types", "slug": "product_types",
"name": _("Product types"), "name": _("Product types"),
}, },
{
"url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products",
"name": _("Returnable products"),
},
{ {
"url": reverse_lazy("counter:cash_summary_list"), "url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary", "slug": "cash_summary",

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 22:40+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"
@ -3198,6 +3199,10 @@ msgstr "Gestion des produits"
msgid "Product types management" msgid "Product types management"
msgstr "Gestion des types de produit" msgstr "Gestion des types de produit"
#: core/templates/core/user_tools.jinja
msgid "Returnable products management"
msgstr "Gestion des consignes"
#: core/templates/core/user_tools.jinja #: core/templates/core/user_tools.jinja
#: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py #: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py
msgid "Cash register summaries" msgid "Cash register summaries"
@ -3460,10 +3465,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 +3710,36 @@ 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 "produit déconsigné"
#: 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és."
#: counter/models.py
msgid "returnable products"
msgstr "produits consignés"
#: counter/models.py
msgid "The returnable product cannot be the same as the returned one"
msgstr ""
"Le produit consigné ne peut pas être le même "
"que le produit de déconsigne"
#: counter/templates/counter/activity.jinja #: counter/templates/counter/activity.jinja
#, python-format #, python-format
msgid "%(counter_name)s activity" msgid "%(counter_name)s activity"
@ -4076,6 +4107,14 @@ msgstr "Il n'y a pas de types de produit dans ce site web."
msgid "Seller" msgid "Seller"
msgstr "Vendeur" msgstr "Vendeur"
#: counter/templates/counter/returnable_list.jinja counter/views/mixins.py
msgid "Returnable products"
msgstr "Produits consignés"
#: counter/templates/counter/returnable_list.jinja
msgid "Returned product"
msgstr "Produit déconsigné"
#: counter/templates/counter/stats.jinja #: counter/templates/counter/stats.jinja
#, python-format #, python-format
msgid "%(counter_name)s stats" msgid "%(counter_name)s stats"
@ -4108,6 +4147,11 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)" msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
#, python-format
msgid "returnable product : %(returnable)s -> %(returned)s"
msgstr "produit consigné : %(returnable)s -> %(returned)s"
#: counter/views/cash.py #: counter/views/cash.py
msgid "10 cents" msgid "10 cents"
msgstr "10 centimes" msgstr "10 centimes"
@ -4161,12 +4205,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)"