mirror of
https://github.com/ae-utbm/sith.git
synced 2025-03-27 07:37:09 +00:00
Merge e2f1610c2ac8fe64c7263a5da7cc939b0b2646ab into bb3dfb7e8a87e4c4ca61d2ee095bb6c3f7ffc115
This commit is contained in:
commit
f5a62e5ad5
@ -50,7 +50,7 @@ from com.ics_calendar import IcsCalendar
|
||||
from com.models import News, NewsDate, Sith, Weekmail
|
||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||
from core.utils import resize_image
|
||||
from counter.models import Counter, Product, ProductType, StudentCard
|
||||
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
|
||||
from election.models import Candidature, Election, ElectionList, Role
|
||||
from forum.models import Forum
|
||||
from pedagogy.models import UV
|
||||
@ -470,7 +470,6 @@ Welcome to the wiki page!
|
||||
limit_age=18,
|
||||
)
|
||||
cons = Product.objects.create(
|
||||
id=settings.SITH_ECOCUP_CONS,
|
||||
name="Consigne Eco-cup",
|
||||
code="CONS",
|
||||
product_type=verre,
|
||||
@ -480,7 +479,6 @@ Welcome to the wiki page!
|
||||
club=main_club,
|
||||
)
|
||||
dcons = Product.objects.create(
|
||||
id=settings.SITH_ECOCUP_DECO,
|
||||
name="Déconsigne Eco-cup",
|
||||
code="DECO",
|
||||
product_type=verre,
|
||||
@ -529,6 +527,9 @@ Welcome to the wiki page!
|
||||
special_selling_price="0",
|
||||
club=refound,
|
||||
)
|
||||
ReturnableProduct.objects.create(
|
||||
product=cons, returned_product=dcons, max_return=3
|
||||
)
|
||||
|
||||
# Accounting test values:
|
||||
BankAccount.objects.create(name="AE TG", club=main_club)
|
||||
|
@ -55,6 +55,14 @@
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.card-top-left {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: black;
|
||||
display: flex;
|
||||
|
@ -10,10 +10,17 @@
|
||||
{% block nav %}
|
||||
{% 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 %}
|
||||
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
|
||||
<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 %}" />
|
||||
</form>
|
||||
<form method="GET" action="javascript:history.back();">
|
||||
|
@ -62,6 +62,11 @@
|
||||
{% trans %}Product types management{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url("counter:returnable_list") }}">
|
||||
{% trans %}Returnable products management{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('counter:cash_summary_list') }}">
|
||||
{% trans %}Cash register summaries{% endtrans %}
|
||||
|
@ -1,3 +1,5 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.views import View
|
||||
@ -6,20 +8,24 @@ from django.views import View
|
||||
class TabedViewMixin(View):
|
||||
"""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):
|
||||
if hasattr(self, "tabs_title"):
|
||||
return self.tabs_title
|
||||
raise ImproperlyConfigured("tabs_title is required")
|
||||
if not self.tabs_title:
|
||||
raise ImproperlyConfigured("tabs_title is required")
|
||||
return self.tabs_title
|
||||
|
||||
def get_current_tab(self):
|
||||
if hasattr(self, "current_tab"):
|
||||
return self.current_tab
|
||||
raise ImproperlyConfigured("current_tab is required")
|
||||
if not self.current_tab:
|
||||
raise ImproperlyConfigured("current_tab is required")
|
||||
return self.current_tab
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
if hasattr(self, "list_of_tabs"):
|
||||
return self.list_of_tabs
|
||||
raise ImproperlyConfigured("list_of_tabs is required")
|
||||
if not self.list_of_tabs:
|
||||
raise ImproperlyConfigured("list_of_tabs is required")
|
||||
return self.list_of_tabs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
|
@ -26,6 +26,7 @@ from counter.models import (
|
||||
Product,
|
||||
ProductType,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
|
||||
@ -43,6 +44,18 @@ class ProductAdmin(SearchModelAdmin):
|
||||
search_fields = ("name", "code")
|
||||
|
||||
|
||||
@admin.register(ReturnableProduct)
|
||||
class ReturnableProductAdmin(admin.ModelAdmin):
|
||||
list_display = ("product", "returned_product", "max_return")
|
||||
search_fields = (
|
||||
"product__name",
|
||||
"product__code",
|
||||
"returned_product__name",
|
||||
"returned_product__code",
|
||||
)
|
||||
autocomplete_fields = ("product", "returned_product")
|
||||
|
||||
|
||||
@admin.register(Customer)
|
||||
class CustomerAdmin(SearchModelAdmin):
|
||||
list_display = ("user", "account_id", "amount")
|
||||
|
@ -17,6 +17,7 @@ from counter.models import (
|
||||
Eticket,
|
||||
Product,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
StudentCard,
|
||||
)
|
||||
from counter.widgets.ajax_select import (
|
||||
@ -213,6 +214,25 @@ class ProductEditForm(forms.ModelForm):
|
||||
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):
|
||||
begin_date = forms.DateTimeField(
|
||||
label=_("Begin date"), widget=SelectDateTime, required=False
|
||||
|
@ -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 timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import Self
|
||||
from typing import Literal, Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
@ -94,7 +94,6 @@ class Customer(models.Model):
|
||||
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
|
||||
account_id = models.CharField(_("account id"), max_length=10, unique=True)
|
||||
amount = CurrencyField(_("amount"), default=0)
|
||||
recorded_products = models.IntegerField(_("recorded product"), default=0)
|
||||
|
||||
objects = CustomerQuerySet.as_manager()
|
||||
|
||||
@ -106,24 +105,50 @@ class Customer(models.Model):
|
||||
def __str__(self):
|
||||
return "%s - %s" % (self.user.username, self.account_id)
|
||||
|
||||
def save(self, *args, allow_negative=False, is_selling=False, **kwargs):
|
||||
def save(self, *args, allow_negative=False, **kwargs):
|
||||
"""is_selling : tell if the current action is a selling
|
||||
allow_negative : ignored if not a selling. Allow a selling to put the account in negative
|
||||
Those two parameters avoid blocking the save method of a customer if his account is negative.
|
||||
"""
|
||||
if self.amount < 0 and (is_selling and not allow_negative):
|
||||
if self.amount < 0 and not allow_negative:
|
||||
raise ValidationError(_("Not enough money"))
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("core:user_account", kwargs={"user_id": self.user.pk})
|
||||
|
||||
@property
|
||||
def can_record(self):
|
||||
return self.recorded_products > -settings.SITH_ECOCUP_LIMIT
|
||||
def update_returnable_balance(self):
|
||||
"""Update all returnable balances of this user to their real amount."""
|
||||
|
||||
def can_record_more(self, number):
|
||||
return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT
|
||||
def purchases_qs(outer_ref: Literal["product_id", "returned_product_id"]):
|
||||
return (
|
||||
Selling.objects.filter(customer=self, product=OuterRef(outer_ref))
|
||||
.values("product")
|
||||
.annotate(quantity=Sum("quantity", default=0))
|
||||
.values("quantity")
|
||||
)
|
||||
|
||||
balances = (
|
||||
ReturnableProduct.objects.annotate_balance_for(self)
|
||||
.annotate(
|
||||
nb_cons=Coalesce(Subquery(purchases_qs("product_id")), 0),
|
||||
nb_dcons=Coalesce(Subquery(purchases_qs("returned_product_id")), 0),
|
||||
)
|
||||
.annotate(new_balance=F("nb_cons") - F("nb_dcons"))
|
||||
.values("id", "new_balance")
|
||||
)
|
||||
updated_balances = [
|
||||
ReturnableProductBalance(
|
||||
customer=self, returnable_id=b["id"], balance=b["new_balance"]
|
||||
)
|
||||
for b in balances
|
||||
]
|
||||
ReturnableProductBalance.objects.bulk_create(
|
||||
updated_balances,
|
||||
update_conflicts=True,
|
||||
update_fields=["balance"],
|
||||
unique_fields=["customer", "returnable"],
|
||||
)
|
||||
|
||||
@property
|
||||
def can_buy(self) -> bool:
|
||||
@ -379,14 +404,6 @@ class Product(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse("counter:product_list")
|
||||
|
||||
@property
|
||||
def is_record_product(self):
|
||||
return self.id == settings.SITH_ECOCUP_CONS
|
||||
|
||||
@property
|
||||
def is_unrecord_product(self):
|
||||
return self.id == settings.SITH_ECOCUP_DECO
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
@ -860,7 +877,7 @@ class Selling(models.Model):
|
||||
self.full_clean()
|
||||
if not self.is_validated:
|
||||
self.customer.amount -= self.quantity * self.unit_price
|
||||
self.customer.save(allow_negative=allow_negative, is_selling=True)
|
||||
self.customer.save(allow_negative=allow_negative)
|
||||
self.is_validated = True
|
||||
user = self.customer.user
|
||||
if user.was_subscribed:
|
||||
@ -945,6 +962,7 @@ class Selling(models.Model):
|
||||
self.customer.amount += self.quantity * self.unit_price
|
||||
self.customer.save()
|
||||
super().delete(*args, **kwargs)
|
||||
self.customer.update_returnable_balance()
|
||||
|
||||
def send_mail_customer(self):
|
||||
event = self.product.eticket.event_title or _("Unknown event")
|
||||
@ -1211,3 +1229,134 @@ class StudentCard(models.Model):
|
||||
if isinstance(obj, User):
|
||||
return StudentCard.can_create(self.customer, obj)
|
||||
return False
|
||||
|
||||
|
||||
class ReturnableProductQuerySet(models.QuerySet):
|
||||
def annotate_balance_for(self, customer: Customer):
|
||||
return self.annotate(
|
||||
balance=Coalesce(
|
||||
Subquery(
|
||||
ReturnableProductBalance.objects.filter(
|
||||
returnable=OuterRef("pk"), customer=customer
|
||||
).values("balance")
|
||||
),
|
||||
0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ReturnableProduct(models.Model):
|
||||
"""A returnable relation between two products (*consigne/déconsigne*)."""
|
||||
|
||||
product = models.OneToOneField(
|
||||
to=Product,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cons",
|
||||
verbose_name=_("returnable product"),
|
||||
)
|
||||
returned_product = models.OneToOneField(
|
||||
to=Product,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="dcons",
|
||||
verbose_name=_("returned product"),
|
||||
)
|
||||
max_return = models.PositiveSmallIntegerField(
|
||||
_("maximum returns"),
|
||||
default=0,
|
||||
help_text=_(
|
||||
"The maximum number of items a customer can return "
|
||||
"without having actually bought them."
|
||||
),
|
||||
)
|
||||
|
||||
objects = ReturnableProductQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("returnable product")
|
||||
verbose_name_plural = _("returnable products")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=~Q(product=F("returned_product")),
|
||||
name="returnableproduct_product_different_from_returned",
|
||||
violation_error_message=_(
|
||||
"The returnable product cannot be the same as the returned one"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"returnable product ({self.product_id} -> {self.returned_product_id})"
|
||||
|
||||
def update_balances(self):
|
||||
"""Update all returnable balances linked to this object.
|
||||
|
||||
Call this when a ReturnableProduct is created or updated.
|
||||
|
||||
Warning:
|
||||
This function is expensive (around a few seconds),
|
||||
so try not to run it outside a management command
|
||||
or a task.
|
||||
"""
|
||||
|
||||
def product_balance_subquery(product_id: int):
|
||||
return Subquery(
|
||||
Selling.objects.filter(customer=OuterRef("pk"), product_id=product_id)
|
||||
.values("customer")
|
||||
.annotate(res=Sum("quantity"))
|
||||
.values("res")
|
||||
)
|
||||
|
||||
old_balance_subquery = Subquery(
|
||||
ReturnableProductBalance.objects.filter(
|
||||
customer=OuterRef("pk"), returnable=self
|
||||
).values("balance")
|
||||
)
|
||||
new_balances = (
|
||||
Customer.objects.annotate(
|
||||
nb_cons=Coalesce(product_balance_subquery(self.product_id), 0),
|
||||
nb_dcons=Coalesce(
|
||||
product_balance_subquery(self.returned_product_id), 0
|
||||
),
|
||||
)
|
||||
.annotate(new_balance=F("nb_cons") - F("nb_dcons"))
|
||||
.exclude(new_balance=Coalesce(old_balance_subquery, 0))
|
||||
.values("pk", "new_balance")
|
||||
)
|
||||
updates = [
|
||||
ReturnableProductBalance(
|
||||
customer_id=c["pk"], returnable=self, balance=c["new_balance"]
|
||||
)
|
||||
for c in new_balances
|
||||
]
|
||||
ReturnableProductBalance.objects.bulk_create(
|
||||
updates,
|
||||
update_conflicts=True,
|
||||
update_fields=["balance"],
|
||||
unique_fields=["customer_id", "returnable"],
|
||||
)
|
||||
|
||||
|
||||
class ReturnableProductBalance(models.Model):
|
||||
"""The returnable products balances of a customer"""
|
||||
|
||||
customer = models.ForeignKey(
|
||||
to=Customer, on_delete=models.CASCADE, related_name="return_balances"
|
||||
)
|
||||
returnable = models.ForeignKey(
|
||||
to=ReturnableProduct, on_delete=models.CASCADE, related_name="balances"
|
||||
)
|
||||
balance = models.SmallIntegerField(blank=True, default=0)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["customer", "returnable"],
|
||||
name="returnable_product_unique_type_per_customer",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"return balance of {self.customer} "
|
||||
f"for {self.returnable.product_id} : {self.balance}"
|
||||
)
|
||||
|
67
counter/templates/counter/returnable_list.jinja
Normal file
67
counter/templates/counter/returnable_list.jinja
Normal 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 %}
|
@ -28,17 +28,19 @@ from django.utils import timezone
|
||||
from django.utils.timezone import localdate, now
|
||||
from freezegun import freeze_time
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||
from core.models import BanGroup, User
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Permanency,
|
||||
Product,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
|
||||
@ -97,7 +99,7 @@ class TestRefilling(TestFullClickBase):
|
||||
self,
|
||||
user: User | Customer,
|
||||
counter: Counter,
|
||||
amount: int,
|
||||
amount: int | float,
|
||||
client: Client | None = None,
|
||||
) -> HttpResponse:
|
||||
used_client = client if client is not None else self.client
|
||||
@ -241,31 +243,31 @@ class TestCounterClick(TestFullClickBase):
|
||||
special_selling_price="-1.5",
|
||||
)
|
||||
cls.beer = product_recipe.make(
|
||||
limit_age=18, selling_price="1.5", special_selling_price="1"
|
||||
limit_age=18, selling_price=1.5, special_selling_price=1
|
||||
)
|
||||
cls.beer_tap = product_recipe.make(
|
||||
limit_age=18,
|
||||
tray=True,
|
||||
selling_price="1.5",
|
||||
special_selling_price="1",
|
||||
limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
|
||||
)
|
||||
|
||||
cls.snack = product_recipe.make(
|
||||
limit_age=0, selling_price="1.5", special_selling_price="1"
|
||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
||||
)
|
||||
cls.stamps = product_recipe.make(
|
||||
limit_age=0, selling_price="1.5", special_selling_price="1"
|
||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
||||
)
|
||||
ReturnableProduct.objects.all().delete()
|
||||
cls.cons = baker.make(Product, selling_price=1)
|
||||
cls.dcons = baker.make(Product, selling_price=-1)
|
||||
baker.make(
|
||||
ReturnableProduct,
|
||||
product=cls.cons,
|
||||
returned_product=cls.dcons,
|
||||
max_return=3,
|
||||
)
|
||||
|
||||
cls.cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
|
||||
cls.dcons = Product.objects.get(id=settings.SITH_ECOCUP_DECO)
|
||||
|
||||
cls.counter.products.add(
|
||||
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
|
||||
)
|
||||
|
||||
cls.other_counter.products.add(cls.snack)
|
||||
|
||||
cls.club_counter.products.add(cls.stamps)
|
||||
|
||||
def login_in_bar(self, barmen: User | None = None):
|
||||
@ -309,57 +311,36 @@ class TestCounterClick(TestFullClickBase):
|
||||
def test_click_eboutic_failure(self):
|
||||
eboutic = baker.make(Counter, type="EBOUTIC")
|
||||
self.client.force_login(self.club_admin)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.stamps.id, 5)],
|
||||
counter=eboutic,
|
||||
).status_code
|
||||
== 404
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.stamps.id, 5)], counter=eboutic
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_click_office_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.client.force_login(self.club_admin)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.stamps.id, 5)],
|
||||
counter=self.club_counter,
|
||||
).status_code
|
||||
== 302
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
|
||||
)
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == Decimal("2.5")
|
||||
|
||||
# Test no special price on office counter
|
||||
self.refill_user(self.club_admin, 10)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.club_admin,
|
||||
[BasketItem(self.stamps.id, 1)],
|
||||
counter=self.club_counter,
|
||||
).status_code
|
||||
== 302
|
||||
res = self.submit_basket(
|
||||
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
|
||||
)
|
||||
assert res.status_code == 302
|
||||
|
||||
assert self.updated_amount(self.club_admin) == Decimal("8.5")
|
||||
|
||||
def test_click_bar_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer.id, 2),
|
||||
BasketItem(self.snack.id, 1),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||
)
|
||||
assert res.status_code == 302
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("5.5")
|
||||
|
||||
@ -378,29 +359,13 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
# Not applying tray price
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer_tap.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == Decimal("17")
|
||||
|
||||
# Applying tray price
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer_tap.id, 7),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == Decimal("8")
|
||||
|
||||
def test_click_alcool_unauthorized(self):
|
||||
@ -410,28 +375,14 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.refill_user(user, 10)
|
||||
|
||||
# Buy product without age limit
|
||||
assert (
|
||||
self.submit_basket(
|
||||
user,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||
assert res.status_code == 302
|
||||
|
||||
assert self.updated_amount(user) == Decimal("7")
|
||||
|
||||
# Buy product without age limit
|
||||
assert (
|
||||
self.submit_basket(
|
||||
user,
|
||||
[
|
||||
BasketItem(self.beer.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
res = self.submit_basket(user, [BasketItem(self.beer.id, 2)])
|
||||
assert res.status_code == 200
|
||||
|
||||
assert self.updated_amount(user) == Decimal("7")
|
||||
|
||||
@ -443,12 +394,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.customer_old_can_not_buy,
|
||||
]:
|
||||
self.refill_user(user, 10)
|
||||
resp = self.submit_basket(
|
||||
user,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
)
|
||||
resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||
assert resp.status_code == 302
|
||||
assert resp.url == resolve_url(self.counter)
|
||||
|
||||
@ -456,44 +402,28 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
def test_click_user_without_customer(self):
|
||||
self.login_in_bar()
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer_can_not_buy,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 404
|
||||
res = self.submit_basket(
|
||||
self.customer_can_not_buy, [BasketItem(self.snack.id, 2)]
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_click_allowed_old_subscriber(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer_old_can_buy, 10)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer_old_can_buy,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
res = self.submit_basket(
|
||||
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
|
||||
)
|
||||
assert res.status_code == 302
|
||||
|
||||
assert self.updated_amount(self.customer_old_can_buy) == Decimal("7")
|
||||
|
||||
def test_click_wrong_counter(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer, 10)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
counter=self.other_counter,
|
||||
).status_code
|
||||
== 302 # Redirect to counter main
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
|
||||
)
|
||||
assertRedirects(res, self.other_counter.get_absolute_url())
|
||||
|
||||
# We want to test sending requests from another counter while
|
||||
# we are currently registered to another counter
|
||||
@ -502,42 +432,25 @@ class TestCounterClick(TestFullClickBase):
|
||||
# that using a client not logged to a counter
|
||||
# where another client is logged still isn't authorized.
|
||||
client = Client()
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
counter=self.counter,
|
||||
client=client,
|
||||
).status_code
|
||||
== 302 # Redirect to counter main
|
||||
res = self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.snack.id, 2)],
|
||||
counter=self.counter,
|
||||
client=client,
|
||||
)
|
||||
assertRedirects(res, self.counter.get_absolute_url())
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_not_connected(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302 # Redirect to counter main
|
||||
)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||
assertRedirects(res, self.counter.get_absolute_url())
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
counter=self.club_counter,
|
||||
).status_code
|
||||
== 403
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
@ -545,15 +458,8 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.stamps.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
|
||||
assert res.status_code == 200
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_product_invalid(self):
|
||||
@ -561,36 +467,24 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.login_in_bar()
|
||||
|
||||
for item in [
|
||||
BasketItem("-1", 2),
|
||||
BasketItem(-1, 2),
|
||||
BasketItem(self.beer.id, -1),
|
||||
BasketItem(None, 1),
|
||||
BasketItem(self.beer.id, None),
|
||||
BasketItem(None, None),
|
||||
]:
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[item],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_not_enough_money(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer_tap.id, 5),
|
||||
BasketItem(self.beer.id, 10),
|
||||
],
|
||||
).status_code
|
||||
== 200
|
||||
res = self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.beer_tap.id, 5), BasketItem(self.beer.id, 10)],
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
@ -606,116 +500,73 @@ class TestCounterClick(TestFullClickBase):
|
||||
def test_selling_ordering(self):
|
||||
# Cheaper items should be processed with a higher priority
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer.id, 1),
|
||||
BasketItem(self.gift.id, 1),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
|
||||
)
|
||||
assert res.status_code == 302
|
||||
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_recordings(self):
|
||||
self.refill_user(self.customer, self.cons.selling_price * 3)
|
||||
self.login_in_bar(self.barmen)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.cons.id, 3)],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
assert list(
|
||||
self.customer.customer.return_balances.values("returnable", "balance")
|
||||
) == [{"returnable": self.cons.cons.id, "balance": 3}]
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.dcons.id, 3)],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)],
|
||||
).status_code
|
||||
== 302
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
|
||||
)
|
||||
# from now on, the user amount should not change
|
||||
expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return)
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == expected_amount
|
||||
|
||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * (
|
||||
-3 - settings.SITH_ECOCUP_LIMIT
|
||||
)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
||||
assert res.status_code == 200
|
||||
assert self.updated_amount(self.customer) == expected_amount
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.dcons.id, 1)],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * (
|
||||
-3 - settings.SITH_ECOCUP_LIMIT
|
||||
)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.cons.id, 1),
|
||||
BasketItem(self.dcons.id, 1),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * (
|
||||
-3 - settings.SITH_ECOCUP_LIMIT
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.cons.id, 1), BasketItem(self.dcons.id, 1)]
|
||||
)
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == expected_amount
|
||||
|
||||
def test_recordings_when_negative(self):
|
||||
self.refill_user(
|
||||
self.customer,
|
||||
self.cons.selling_price * 3 + Decimal(self.beer.selling_price),
|
||||
sale_recipe.make(
|
||||
customer=self.customer.customer,
|
||||
product=self.dcons,
|
||||
unit_price=self.dcons.selling_price,
|
||||
quantity=10,
|
||||
)
|
||||
self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10
|
||||
self.customer.customer.save()
|
||||
self.customer.customer.update_returnable_balance()
|
||||
self.login_in_bar(self.barmen)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.dcons.id, 1)],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert self.updated_amount(
|
||||
self.customer
|
||||
) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.cons.id, 3)],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
||||
assert res.status_code == 200
|
||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.beer.id, 1)],
|
||||
).status_code
|
||||
== 302
|
||||
self.updated_amount(self.customer)
|
||||
== self.dcons.selling_price * -10 - self.cons.selling_price * 3
|
||||
)
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
|
||||
assert res.status_code == 302
|
||||
assert (
|
||||
self.updated_amount(self.customer)
|
||||
== self.dcons.selling_price * -10
|
||||
- self.cons.selling_price * 3
|
||||
- self.beer.selling_price
|
||||
)
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
|
||||
class TestCounterStats(TestCase):
|
||||
|
@ -14,12 +14,13 @@ from model_bakery import baker
|
||||
from club.models import Membership
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import User
|
||||
from counter.baker_recipes import refill_recipe, sale_recipe
|
||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Counter,
|
||||
Customer,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
StudentCard,
|
||||
)
|
||||
@ -482,3 +483,31 @@ def test_update_balance():
|
||||
for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False):
|
||||
customer.refresh_from_db()
|
||||
assert customer.amount == amount
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_returnable_balance():
|
||||
ReturnableProduct.objects.all().delete()
|
||||
customer = baker.make(Customer)
|
||||
products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True)
|
||||
returnables = [
|
||||
baker.make(
|
||||
ReturnableProduct, product=products[0], returned_product=products[1]
|
||||
),
|
||||
baker.make(
|
||||
ReturnableProduct, product=products[2], returned_product=products[3]
|
||||
),
|
||||
]
|
||||
balance_qs = ReturnableProduct.objects.annotate_balance_for(customer)
|
||||
assert not customer.return_balances.exists()
|
||||
assert list(balance_qs.values_list("balance", flat=True)) == [0, 0]
|
||||
|
||||
sale_recipe.make(customer=customer, product=products[0], unit_price=0, quantity=5)
|
||||
sale_recipe.make(customer=customer, product=products[2], unit_price=0, quantity=1)
|
||||
sale_recipe.make(customer=customer, product=products[3], unit_price=0, quantity=3)
|
||||
customer.update_returnable_balance()
|
||||
assert list(customer.return_balances.values("returnable_id", "balance")) == [
|
||||
{"returnable_id": returnables[0].id, "balance": 5},
|
||||
{"returnable_id": returnables[1].id, "balance": -2},
|
||||
]
|
||||
assert set(balance_qs.values_list("balance", flat=True)) == {-2, 5}
|
||||
|
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},
|
||||
]
|
@ -30,6 +30,10 @@ from counter.views.admin import (
|
||||
ProductTypeEditView,
|
||||
ProductTypeListView,
|
||||
RefillingDeleteView,
|
||||
ReturnableProductCreateView,
|
||||
ReturnableProductDeleteView,
|
||||
ReturnableProductListView,
|
||||
ReturnableProductUpdateView,
|
||||
SellingDeleteView,
|
||||
)
|
||||
from counter.views.auth import counter_login, counter_logout
|
||||
@ -51,10 +55,7 @@ from counter.views.home import (
|
||||
CounterMain,
|
||||
)
|
||||
from counter.views.invoice import InvoiceCallView
|
||||
from counter.views.student_card import (
|
||||
StudentCardDeleteView,
|
||||
StudentCardFormView,
|
||||
)
|
||||
from counter.views.student_card import StudentCardDeleteView, StudentCardFormView
|
||||
|
||||
urlpatterns = [
|
||||
path("<int:counter_id>/", CounterMain.as_view(), name="details"),
|
||||
@ -129,6 +130,24 @@ urlpatterns = [
|
||||
ProductTypeEditView.as_view(),
|
||||
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/new/", EticketCreateView.as_view(), name="new_eticket"),
|
||||
path(
|
||||
|
@ -15,19 +15,28 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.forms.models import modelform_factory
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
|
||||
from core.auth.mixins import CanEditMixin, CanViewMixin
|
||||
from core.utils import get_semester_code, get_start_of_semester
|
||||
from counter.forms import CounterEditForm, ProductEditForm
|
||||
from counter.models import Counter, Product, ProductType, Refilling, Selling
|
||||
from counter.forms import CounterEditForm, ProductEditForm, ReturnableProductForm
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Product,
|
||||
ProductType,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||
|
||||
@ -146,6 +155,69 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
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):
|
||||
"""Delete a refilling (for the admins)."""
|
||||
|
||||
|
@ -16,6 +16,7 @@ import math
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.forms import (
|
||||
BaseFormSet,
|
||||
Form,
|
||||
@ -35,7 +36,13 @@ from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from core.utils import FormFragmentTemplateData
|
||||
from counter.forms import RefillForm
|
||||
from counter.models import Counter, Customer, Product, Selling
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Product,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterTabsMixin
|
||||
from counter.views.student_card import StudentCardFormView
|
||||
@ -99,17 +106,22 @@ class ProductForm(Form):
|
||||
|
||||
class BaseBasketForm(BaseFormSet):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if len(self) == 0:
|
||||
if len(self.forms) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
raise ValidationError(_("Submmited basket is invalid"))
|
||||
raise ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
raise ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
@ -118,21 +130,32 @@ class BaseBasketForm(BaseFormSet):
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
self.total_recordings = 0
|
||||
for form in self:
|
||||
# form.product is stored by the clean step of each formset form
|
||||
if form.product.is_record_product:
|
||||
self.total_recordings -= form.cleaned_data["quantity"]
|
||||
if form.product.is_unrecord_product:
|
||||
self.total_recordings += form.cleaned_data["quantity"]
|
||||
|
||||
# We don't want to block an user that have negative recordings
|
||||
# if he isn't recording anything or reducing it's recording count
|
||||
if self.total_recordings <= 0:
|
||||
return
|
||||
|
||||
if not customer.can_record_more(self.total_recordings):
|
||||
raise ValidationError(_("This user have reached his recording limit"))
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
returnable.balance += items.get(returnable.product_id, 0)
|
||||
for returnable in returnables:
|
||||
dcons = items.get(returnable.returned_product_id, 0)
|
||||
returnable.balance -= dcons
|
||||
if dcons and returnable.balance < -returnable.max_return:
|
||||
limit_reached.append(returnable.returned_product)
|
||||
if limit_reached:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This user have reached his recording limit "
|
||||
"for the following products : %s"
|
||||
)
|
||||
% ", ".join([str(p) for p in limit_reached])
|
||||
)
|
||||
|
||||
|
||||
BasketForm = formset_factory(
|
||||
@ -238,8 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
|
||||
customer=self.customer,
|
||||
).save()
|
||||
|
||||
self.customer.recorded_products -= formset.total_recordings
|
||||
self.customer.save()
|
||||
self.customer.update_returnable_balance()
|
||||
|
||||
# Add some info for the main counter view to display
|
||||
self.request.session["last_customer"] = self.customer.user.get_display_name()
|
||||
@ -248,6 +270,37 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
|
||||
|
||||
return ret
|
||||
|
||||
def _update_returnable_balance(self, formset):
|
||||
ids = [form.cleaned_data["id"] for form in formset]
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(self.customer)
|
||||
)
|
||||
for returnable in returnables:
|
||||
cons_quantity = next(
|
||||
(
|
||||
form.cleaned_data["quantity"]
|
||||
for form in formset
|
||||
if form.cleaned_data["id"] == returnable.product_id
|
||||
),
|
||||
0,
|
||||
)
|
||||
dcons_quantity = next(
|
||||
(
|
||||
form.cleaned_data["quantity"]
|
||||
for form in formset
|
||||
if form.cleaned_data["id"] == returnable.returned_product_id
|
||||
),
|
||||
0,
|
||||
)
|
||||
self.customer.return_balances.update_or_create(
|
||||
returnable=returnable,
|
||||
defaults={
|
||||
"balance": returnable.balance + cons_quantity - dcons_quantity
|
||||
},
|
||||
)
|
||||
|
||||
def get_success_url(self):
|
||||
return resolve_url(self.object)
|
||||
|
||||
|
@ -98,6 +98,11 @@ class CounterAdminTabsMixin(TabedViewMixin):
|
||||
"slug": "product_types",
|
||||
"name": _("Product types"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:returnable_list"),
|
||||
"slug": "returnable_products",
|
||||
"name": _("Returnable products"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:cash_summary_list"),
|
||||
"slug": "cash_summary",
|
||||
|
@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-25 16:38+0100\n"
|
||||
"POT-Creation-Date: 2025-03-06 22:40+0100\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -2837,6 +2837,7 @@ msgid "Users"
|
||||
msgstr "Utilisateurs"
|
||||
|
||||
#: core/templates/core/search.jinja core/views/user.py
|
||||
#: counter/templates/counter/product_list.jinja
|
||||
msgid "Clubs"
|
||||
msgstr "Clubs"
|
||||
|
||||
@ -3182,7 +3183,7 @@ msgid "Bans"
|
||||
msgstr "Bans"
|
||||
|
||||
#: core/templates/core/user_tools.jinja counter/forms.py
|
||||
#: counter/views/mixins.py
|
||||
#: counter/templates/counter/product_list.jinja counter/views/mixins.py
|
||||
msgid "Counters"
|
||||
msgstr "Comptoirs"
|
||||
|
||||
@ -3198,6 +3199,10 @@ msgstr "Gestion des produits"
|
||||
msgid "Product types management"
|
||||
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
|
||||
#: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py
|
||||
msgid "Cash register summaries"
|
||||
@ -3460,10 +3465,6 @@ msgstr "Vidange de votre compte AE"
|
||||
msgid "account id"
|
||||
msgstr "numéro de compte"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "recorded product"
|
||||
msgstr "produits consignés"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "customer"
|
||||
msgstr "client"
|
||||
@ -3709,6 +3710,36 @@ msgstr "carte étudiante"
|
||||
msgid "student cards"
|
||||
msgstr "cartes étudiantes"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "returnable product"
|
||||
msgstr "produit consigné"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "returned product"
|
||||
msgstr "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
|
||||
#, python-format
|
||||
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"
|
||||
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
|
||||
#, python-format
|
||||
msgid "%(counter_name)s stats"
|
||||
@ -4108,6 +4147,11 @@ msgstr "Temps"
|
||||
msgid "Top 100 barman %(counter_name)s (all semesters)"
|
||||
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
|
||||
msgid "10 cents"
|
||||
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"
|
||||
|
||||
#: counter/views/click.py
|
||||
msgid "Submmited basket is invalid"
|
||||
msgid "Submitted basket is invalid"
|
||||
msgstr "Le panier envoyé est invalide"
|
||||
|
||||
#: counter/views/click.py
|
||||
msgid "This user have reached his recording limit"
|
||||
msgstr "Cet utilisateur a atteint sa limite de déconsigne"
|
||||
msgid "Duplicated product entries."
|
||||
msgstr "Saisie de produit dupliquée"
|
||||
|
||||
#: counter/views/click.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This user have reached his recording limit for the following products : %s"
|
||||
msgstr ""
|
||||
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
|
||||
"suivants : %s"
|
||||
|
||||
#: counter/views/eticket.py
|
||||
msgid "people(s)"
|
||||
|
Loading…
x
Reference in New Issue
Block a user