mirror of
https://github.com/ae-utbm/sith.git
synced 2026-06-13 11:39:25 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 519a7758c5 |
@@ -200,11 +200,7 @@ class TestFilterInactive(TestCase):
|
|||||||
]
|
]
|
||||||
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
|
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
|
||||||
baker.make(
|
baker.make(
|
||||||
Refilling,
|
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
|
||||||
customer=cls.users[4].customer,
|
|
||||||
date=time_active,
|
|
||||||
counter=counter,
|
|
||||||
amount=1,
|
|
||||||
)
|
)
|
||||||
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
|
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
|
||||||
|
|
||||||
@@ -459,9 +455,7 @@ def test_user_preferences(client: Client):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_user_stats(client: Client):
|
def test_user_stats(client: Client):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(
|
baker.make(Refilling, customer=user.customer, amount=99999)
|
||||||
Refilling, customer=user.customer, amount=settings.SITH_ACCOUNT_MAX_MONEY
|
|
||||||
)
|
|
||||||
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
|
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
|
||||||
baker.make(
|
baker.make(
|
||||||
Permanency,
|
Permanency,
|
||||||
|
|||||||
+4
-50
@@ -1,68 +1,22 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import checks
|
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.functional import cached_property
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyField(models.DecimalField):
|
class CurrencyField(models.DecimalField):
|
||||||
"""Custom database field used for currency."""
|
"""Custom database field used for currency."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, *args, **kwargs):
|
||||||
self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs
|
kwargs["max_digits"] = 12
|
||||||
):
|
kwargs["decimal_places"] = 2
|
||||||
kwargs.update({"max_digits": 12, "decimal_places": 2})
|
super().__init__(*args, **kwargs)
|
||||||
self.min_value = min_value
|
|
||||||
self.max_value = max_value
|
|
||||||
super().__init__(verbose_name, name, **kwargs)
|
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return super().to_python(value).quantize(Decimal("0.01"))
|
return super().to_python(value).quantize(Decimal("0.01"))
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def validators(self):
|
|
||||||
res = []
|
|
||||||
if self.max_value:
|
|
||||||
res.append(MaxValueValidator(self.max_value))
|
|
||||||
if self.min_value:
|
|
||||||
res.append(MinValueValidator(self.min_value))
|
|
||||||
return [*super().validators, *res]
|
|
||||||
|
|
||||||
def check(self, **kwargs): # pragma: no cover
|
|
||||||
# this is executed during runserver, but won't run in prod
|
|
||||||
errors = super().check(**kwargs)
|
|
||||||
for name, val in ("min_value", self.min_value), ("max_value", self.max_value):
|
|
||||||
if not val:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
float(val)
|
|
||||||
except ValueError:
|
|
||||||
errors.append(
|
|
||||||
checks.Error(
|
|
||||||
f"CurrencyField.{name} must be a valid float",
|
|
||||||
obj=self,
|
|
||||||
id="sith.E001",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return errors
|
|
||||||
|
|
||||||
def formfield(self, **kwargs):
|
|
||||||
return super().formfield(
|
|
||||||
**{"min_value": self.min_value, "max_value": self.max_value, **kwargs}
|
|
||||||
)
|
|
||||||
|
|
||||||
def deconstruct(self):
|
|
||||||
name, path, args, kwargs = super().deconstruct()
|
|
||||||
if self.min_value is not None:
|
|
||||||
kwargs["min_value"] = self.min_value
|
|
||||||
if self.max_value is not None:
|
|
||||||
kwargs["max_value"] = self.max_value
|
|
||||||
return name, path, args, kwargs
|
|
||||||
|
|
||||||
|
|
||||||
if settings.TESTING:
|
if settings.TESTING:
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|||||||
+30
-58
@@ -3,16 +3,13 @@ import math
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.forms import BaseModelFormSet
|
from django.forms import BaseModelFormSet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.functional import cached_property
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_celery_beat.models import ClockedSchedule
|
from django_celery_beat.models import ClockedSchedule
|
||||||
@@ -42,6 +39,7 @@ from counter.models import (
|
|||||||
Customer,
|
Customer,
|
||||||
Eticket,
|
Eticket,
|
||||||
InvoiceCall,
|
InvoiceCall,
|
||||||
|
Permanency,
|
||||||
Price,
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
@@ -154,12 +152,13 @@ class CounterLoginForm(LoginForm):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
message=_("You are not a barman of this counter."), code="not_barman"
|
message=_("You are not a barman of this counter."), code="not_barman"
|
||||||
)
|
)
|
||||||
if user in self.request.barmen:
|
if Permanency.objects.filter(end=None, user=user).exists():
|
||||||
message = (
|
if user in self.request.barmen:
|
||||||
_("You are already logged in this counter.")
|
message = _("You are already logged in this counter.")
|
||||||
if user in self.counter.barmen_list
|
elif user in self.counter.barmen_list:
|
||||||
else _("You are already logged in another counter.")
|
message = _("You are already logged in another counter.")
|
||||||
)
|
else:
|
||||||
|
message = _("You are already logged on another device")
|
||||||
raise ValidationError(message=message, code="already_logged_in")
|
raise ValidationError(message=message, code="already_logged_in")
|
||||||
|
|
||||||
|
|
||||||
@@ -171,19 +170,18 @@ class RefillForm(forms.ModelForm):
|
|||||||
|
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
|
amount = forms.FloatField(
|
||||||
|
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Refilling
|
model = Refilling
|
||||||
fields = ["amount", "payment_method"]
|
fields = ["amount", "payment_method"]
|
||||||
widgets = {"payment_method": forms.RadioSelect}
|
widgets = {"payment_method": forms.RadioSelect}
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, *args, **kwargs):
|
||||||
self, *args, counter: Counter, operator: User, customer: Customer, **kwargs
|
|
||||||
):
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
max_value = settings.SITH_ACCOUNT_MAX_MONEY - customer.amount
|
|
||||||
# server-side max_value validation is done by Refilling.clean
|
|
||||||
self.fields["amount"].widget.attrs["max"] = max_value
|
|
||||||
self.fields["payment_method"].choices = (
|
self.fields["payment_method"].choices = (
|
||||||
method
|
method
|
||||||
for method in self.fields["payment_method"].choices
|
for method in self.fields["payment_method"].choices
|
||||||
@@ -191,9 +189,6 @@ class RefillForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
|
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
|
||||||
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
|
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
|
||||||
self.instance.counter = counter
|
|
||||||
self.instance.operator = operator
|
|
||||||
self.instance.customer = customer
|
|
||||||
|
|
||||||
|
|
||||||
class CounterEditForm(forms.ModelForm):
|
class CounterEditForm(forms.ModelForm):
|
||||||
@@ -567,7 +562,16 @@ class BasketItemForm(forms.Form):
|
|||||||
quantity = forms.IntegerField(min_value=1, required=True)
|
quantity = forms.IntegerField(min_value=1, required=True)
|
||||||
price_id = forms.IntegerField(min_value=0, required=True)
|
price_id = forms.IntegerField(min_value=0, required=True)
|
||||||
|
|
||||||
def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
customer: Customer,
|
||||||
|
counter: Counter,
|
||||||
|
allowed_prices: dict[int, Price],
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self.customer = customer # Used by formset
|
||||||
|
self.counter = counter # Used by formset
|
||||||
self.allowed_prices = allowed_prices
|
self.allowed_prices = allowed_prices
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@@ -602,15 +606,6 @@ class BasketItemForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class BaseBasketForm(forms.BaseFormSet):
|
class BaseBasketForm(forms.BaseFormSet):
|
||||||
# Minimum amount of money there must be on the account after the transaction
|
|
||||||
# If None, the min balance check is skipped
|
|
||||||
min_result_balance: ClassVar[int | None] = 0
|
|
||||||
|
|
||||||
def __init__(self, *args, customer: Customer, counter: Counter, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.customer = customer
|
|
||||||
self.counter = counter
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
||||||
|
|
||||||
@@ -619,8 +614,8 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
|
|
||||||
self._check_forms_have_errors()
|
self._check_forms_have_errors()
|
||||||
self._check_product_are_unique()
|
self._check_product_are_unique()
|
||||||
self._check_recorded_products()
|
self._check_recorded_products(self[0].customer)
|
||||||
self._check_account_balance()
|
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):
|
||||||
@@ -631,35 +626,12 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
if len(price_ids) != len(self.forms):
|
if len(price_ids) != len(self.forms):
|
||||||
raise forms.ValidationError(_("Duplicated product entries."))
|
raise forms.ValidationError(_("Duplicated product entries."))
|
||||||
|
|
||||||
@cached_property
|
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||||
def total_price(self):
|
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||||
refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
if self.total_price > customer.amount:
|
||||||
total_other = sum(
|
|
||||||
form.cleaned_data["total_price"]
|
|
||||||
for form in self.forms
|
|
||||||
if form.price.product.product_type_id != refill
|
|
||||||
)
|
|
||||||
total_refill = sum(
|
|
||||||
form.cleaned_data["total_price"]
|
|
||||||
for form in self.forms
|
|
||||||
if form.price.product.product_type_id == refill
|
|
||||||
)
|
|
||||||
return total_other - total_refill
|
|
||||||
|
|
||||||
def _check_account_balance(self):
|
|
||||||
result_balance = self.customer.amount - self.total_price
|
|
||||||
if (
|
|
||||||
self.min_result_balance is not None
|
|
||||||
and self.min_result_balance > result_balance
|
|
||||||
):
|
|
||||||
raise forms.ValidationError(_("Not enough money"))
|
raise forms.ValidationError(_("Not enough money"))
|
||||||
if result_balance > settings.SITH_ACCOUNT_MAX_MONEY:
|
|
||||||
raise ValidationError(
|
|
||||||
_("There cannot be more than %(money)d€ on an AE account")
|
|
||||||
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
|
|
||||||
)
|
|
||||||
|
|
||||||
def _check_recorded_products(self):
|
def _check_recorded_products(self, customer: Customer):
|
||||||
"""Check for, among other things, ecocups and pitchers"""
|
"""Check for, among other things, ecocups and pitchers"""
|
||||||
items = defaultdict(int)
|
items = defaultdict(int)
|
||||||
for form in self.forms:
|
for form in self.forms:
|
||||||
@@ -668,7 +640,7 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
returnables = list(
|
returnables = list(
|
||||||
ReturnableProduct.objects.filter(
|
ReturnableProduct.objects.filter(
|
||||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||||
).annotate_balance_for(self.customer)
|
).annotate_balance_for(customer)
|
||||||
)
|
)
|
||||||
limit_reached = []
|
limit_reached = []
|
||||||
for returnable in returnables:
|
for returnable in returnables:
|
||||||
|
|||||||
+21
-19
@@ -1,8 +1,7 @@
|
|||||||
from typing import TYPE_CHECKING, Callable
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
from django.db.models import Exists, OuterRef
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.utils.functional import SimpleLazyObject, empty
|
from django.utils.functional import SimpleLazyObject
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import Permanency
|
from counter.models import Permanency
|
||||||
@@ -11,20 +10,31 @@ if TYPE_CHECKING:
|
|||||||
from django.contrib.sessions.backends.base import SessionBase
|
from django.contrib.sessions.backends.base import SessionBase
|
||||||
|
|
||||||
|
|
||||||
SESSION_BARMEN_KEY = "barmen_ids"
|
SESSION_PERMANENCES_KEY = "permanence_ids"
|
||||||
|
|
||||||
|
|
||||||
def get_cached_barmen(request: HttpRequest) -> set[User]:
|
def get_cached_barmen(request: HttpRequest) -> set[User]:
|
||||||
if not hasattr(request, "_cached_barmen"):
|
if not hasattr(request, "_cached_barmen"):
|
||||||
session: SessionBase = request.session
|
session: SessionBase = request.session
|
||||||
barmen_ids = session.get(SESSION_BARMEN_KEY, [])
|
|
||||||
if barmen_ids:
|
if session_ids := session.get(SESSION_PERMANENCES_KEY, None):
|
||||||
request._cached_barmen = set(
|
# Get ongoing permanences which id is in session.
|
||||||
User.objects.filter(
|
# Note : we store permanence ids rather than user id to be sure
|
||||||
Exists(Permanency.objects.filter(user=OuterRef("pk"), end=None)),
|
# not to wrongfully mark someone as logged here,
|
||||||
id__in=barmen_ids,
|
# even if it logged out then logged in elsewhere.
|
||||||
)
|
permanences = (
|
||||||
|
Permanency.objects.filter(end=None, id__in=session_ids)
|
||||||
|
.order_by("id")
|
||||||
|
.select_related("user")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# if the list of permanences occurring on this device has changed
|
||||||
|
# since the last page load, change the ids stored in session
|
||||||
|
real_ids = [p.id for p in permanences]
|
||||||
|
if real_ids != session_ids:
|
||||||
|
session[SESSION_PERMANENCES_KEY] = real_ids
|
||||||
|
|
||||||
|
request._cached_barmen = {p.user for p in permanences}
|
||||||
else:
|
else:
|
||||||
request._cached_barmen = set()
|
request._cached_barmen = set()
|
||||||
|
|
||||||
@@ -53,12 +63,4 @@ class BarmenMiddleware:
|
|||||||
def __call__(self, request: HttpRequest):
|
def __call__(self, request: HttpRequest):
|
||||||
request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request))
|
request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request))
|
||||||
|
|
||||||
response = self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
if request.barmen._wrapped is not empty and {
|
|
||||||
b.id for b in request.barmen
|
|
||||||
} != set(request.session.get(SESSION_BARMEN_KEY, [])):
|
|
||||||
# update the session data only if `session.barmen`
|
|
||||||
# has been accessed and modified.
|
|
||||||
request.session[SESSION_BARMEN_KEY] = [b.id for b in request.barmen]
|
|
||||||
return response
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# Generated by Django 5.2.15 on 2026-06-07 12:08
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
import counter.fields
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("counter", "0041_alter_billinginfo_country_and_more")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="customer",
|
|
||||||
name="amount",
|
|
||||||
field=counter.fields.CurrencyField(
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
max_digits=12,
|
|
||||||
max_value=250,
|
|
||||||
verbose_name="amount",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="refilling",
|
|
||||||
name="amount",
|
|
||||||
field=counter.fields.CurrencyField(
|
|
||||||
decimal_places=2, max_digits=12, min_value=0.01, verbose_name="amount"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+8
-20
@@ -28,7 +28,7 @@ from dict2xml import dict2xml
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, F, Max, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
||||||
from django.db.models.functions import Coalesce, Concat, Length
|
from django.db.models.functions import Coalesce, Concat, Length
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -99,9 +99,7 @@ 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 = CurrencyField(
|
amount = CurrencyField(_("amount"), default=0)
|
||||||
_("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0
|
|
||||||
)
|
|
||||||
|
|
||||||
objects = CustomerQuerySet.as_manager()
|
objects = CustomerQuerySet.as_manager()
|
||||||
|
|
||||||
@@ -158,15 +156,13 @@ class Customer(models.Model):
|
|||||||
unique_fields=["customer", "returnable"],
|
unique_fields=["customer", "returnable"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def can_buy(self) -> bool:
|
def can_buy(self) -> bool:
|
||||||
"""Check if whether this customer has the right to purchase any item."""
|
"""Check if whether this customer has the right to purchase any item."""
|
||||||
subscription_end = self.user.subscriptions.aggregate(
|
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
||||||
res=Max("subscription_end")
|
if subscription is None:
|
||||||
).get("res")
|
|
||||||
if subscription_end is None:
|
|
||||||
return False
|
return False
|
||||||
return (date.today() - subscription_end) < timedelta(days=90)
|
return (date.today() - subscription.subscription_end) < timedelta(days=90)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
|
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
|
||||||
@@ -827,7 +823,7 @@ class Refilling(models.Model):
|
|||||||
counter = models.ForeignKey(
|
counter = models.ForeignKey(
|
||||||
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
|
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01)
|
amount = CurrencyField(_("amount"))
|
||||||
operator = models.ForeignKey(
|
operator = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
related_name="refillings_as_operator",
|
related_name="refillings_as_operator",
|
||||||
@@ -881,14 +877,6 @@ class Refilling(models.Model):
|
|||||||
return False
|
return False
|
||||||
return user.is_owner(self.counter) and self.payment_method != "CARD"
|
return user.is_owner(self.counter) and self.payment_method != "CARD"
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
super().clean()
|
|
||||||
if (self.amount + self.customer.amount) > settings.SITH_ACCOUNT_MAX_MONEY:
|
|
||||||
raise ValidationError(
|
|
||||||
_("There cannot be more than %(money)d€ on an AE account")
|
|
||||||
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self.customer.amount -= self.amount
|
self.customer.amount -= self.amount
|
||||||
self.customer.save()
|
self.customer.save()
|
||||||
@@ -1117,7 +1105,7 @@ class Permanency(models.Model):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
start = models.DateTimeField(_("start date"))
|
start = models.DateTimeField(_("start date"))
|
||||||
end = models.DateTimeField(_("end date"), null=True, db_index=True)
|
end = models.DateTimeField(_("end date"), null=True, blank=True, db_index=True)
|
||||||
activity = models.DateTimeField(_("last activity date"), auto_now=True)
|
activity = models.DateTimeField(_("last activity date"), auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const productParsingRegex = /^(\d+x)?(.*)/i;
|
|||||||
const codeParsingRegex = / \((\w+)\)$/;
|
const codeParsingRegex = / \((\w+)\)$/;
|
||||||
|
|
||||||
function parseProduct(query: string): [number, string] {
|
function parseProduct(query: string): [number, string] {
|
||||||
const parsed = productParsingRegex.exec(query) as RegExpExecArray;
|
const parsed = productParsingRegex.exec(query);
|
||||||
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
|
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BasketItem } from "#counter:counter/basket";
|
|||||||
import type {
|
import type {
|
||||||
CounterConfig,
|
CounterConfig,
|
||||||
CounterItem,
|
CounterItem,
|
||||||
|
ErrorMessage,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
} from "#counter:counter/types";
|
} from "#counter:counter/types";
|
||||||
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
||||||
@@ -23,7 +24,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.codeField = this.$refs.codeField as CounterProductSelect;
|
this.codeField = this.$refs.codeField;
|
||||||
this.codeField.widget.hook("after", "onOptionSelect", () => {
|
this.codeField.widget.hook("after", "onOptionSelect", () => {
|
||||||
this.handleCode();
|
this.handleCode();
|
||||||
});
|
});
|
||||||
@@ -33,14 +34,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// of a formset so we dynamically apply it here
|
// of a formset so we dynamically apply it here
|
||||||
this.$refs.basketManagementForm
|
this.$refs.basketManagementForm
|
||||||
.querySelector("#id_form-TOTAL_FORMS")
|
.querySelector("#id_form-TOTAL_FORMS")
|
||||||
?.setAttribute(":value", "getBasketSize()");
|
.setAttribute(":value", "getBasketSize()");
|
||||||
},
|
},
|
||||||
|
|
||||||
removeFromBasket(id: string) {
|
removeFromBasket(id: string) {
|
||||||
delete this.basket[id];
|
delete this.basket[id];
|
||||||
},
|
},
|
||||||
|
|
||||||
addToBasket(id: string, quantity: number) {
|
addToBasket(id: string, quantity: number): ErrorMessage {
|
||||||
const item: BasketItem =
|
const item: BasketItem =
|
||||||
this.basket[id] || new BasketItem(config.products[id], 0);
|
this.basket[id] || new BasketItem(config.products[id], 0);
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
if (item.quantity <= 0) {
|
if (item.quantity <= 0) {
|
||||||
delete this.basket[id];
|
delete this.basket[id];
|
||||||
return;
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.basket[id] = item;
|
this.basket[id] = item;
|
||||||
@@ -71,7 +72,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const products = new Set(
|
const products = new Set(
|
||||||
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
||||||
);
|
);
|
||||||
const formula = config.formulas.find((f: ProductFormula) => {
|
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
||||||
return f.products.every((p: number) => products.has(p));
|
return f.products.every((p: number) => products.has(p));
|
||||||
});
|
});
|
||||||
if (formula === undefined) {
|
if (formula === undefined) {
|
||||||
@@ -79,13 +80,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
// Now that the formula is found, remove the items composing it from the basket
|
// Now that the formula is found, remove the items composing it from the basket
|
||||||
for (const product of formula.products) {
|
for (const product of formula.products) {
|
||||||
const item = Object.entries(this.basket).find(
|
const key = Object.entries(this.basket).find(
|
||||||
([_, i]: [string, BasketItem]) => i.product.productId === product,
|
([_, i]: [string, BasketItem]) => i.product.productId === product,
|
||||||
);
|
)[0];
|
||||||
if (item === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = item[0];
|
|
||||||
this.basket[key].quantity -= 1;
|
this.basket[key].quantity -= 1;
|
||||||
if (this.basket[key].quantity <= 0) {
|
if (this.basket[key].quantity <= 0) {
|
||||||
this.removeFromBasket(key);
|
this.removeFromBasket(key);
|
||||||
@@ -95,7 +92,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const result = Object.values(config.products)
|
const result = Object.values(config.products)
|
||||||
.filter((item: CounterItem) => item.productId === formula.result)
|
.filter((item: CounterItem) => item.productId === formula.result)
|
||||||
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
|
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
|
||||||
this.addToBasket(result.price.id.toString(), 1);
|
this.addToBasket(result.price.id, 1);
|
||||||
this.alertMessage.display(
|
this.alertMessage.display(
|
||||||
interpolate(
|
interpolate(
|
||||||
gettext("Formula %(formula)s applied"),
|
gettext("Formula %(formula)s applied"),
|
||||||
@@ -122,18 +119,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onRefillingSuccess(event: CustomEvent) {
|
onRefillingSuccess(event: CustomEvent) {
|
||||||
if (
|
if (event.type !== "htmx:after-request" || event.detail.failed) {
|
||||||
event.type !== "htmx:after-swap" ||
|
|
||||||
event.detail.failed ||
|
|
||||||
event.detail.elt.querySelector(".errorlist")
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.customerBalance += Number.parseFloat(
|
this.customerBalance += Number.parseFloat(
|
||||||
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
||||||
);
|
);
|
||||||
document.getElementById("selling-accordion")?.setAttribute("open", "");
|
document.getElementById("selling-accordion").setAttribute("open", "");
|
||||||
this.codeField?.widget.focus();
|
this.codeField.widget.focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
finish() {
|
finish() {
|
||||||
@@ -143,7 +136,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(this.$refs.basketForm as HTMLFormElement).submit();
|
this.$refs.basketForm.submit();
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
@@ -151,8 +144,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleCode() {
|
handleCode() {
|
||||||
if (!this.codeField) throw Error("Unexpected null codeField.");
|
|
||||||
|
|
||||||
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
|
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
|
||||||
|
|
||||||
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
|
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
|
||||||
|
|||||||
@@ -176,17 +176,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details
|
<details class="accordion" name="selling">
|
||||||
class="accordion"
|
|
||||||
name="selling"
|
|
||||||
@toggle="if ($event.newState === 'open') $el.querySelector('input[type=number]')?.focus()"
|
|
||||||
>
|
|
||||||
<summary>{% trans %}Refilling{% endtrans %}</summary>
|
<summary>{% trans %}Refilling{% endtrans %}</summary>
|
||||||
{% if object.type == "BAR" %}
|
{% if object.type == "BAR" %}
|
||||||
{% if refilling_fragment %}
|
{% if refilling_fragment %}
|
||||||
<div
|
<div
|
||||||
class="accordion-content"
|
class="accordion-content"
|
||||||
@htmx:after-swap="onRefillingSuccess"
|
@htmx:after-request="onRefillingSuccess"
|
||||||
>
|
>
|
||||||
{{ refilling_fragment }}
|
{{ refilling_fragment }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -144,8 +144,6 @@ class TestRefilling(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
def test_refilling_no_refer_fail(self):
|
def test_refilling_no_refer_fail(self):
|
||||||
"""Check that the refill fails is the HTTP_REFERER header is missing"""
|
|
||||||
|
|
||||||
def refill():
|
def refill():
|
||||||
return self.client.post(
|
return self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
@@ -159,13 +157,13 @@ class TestRefilling(TestFullClickBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.client.force_login(self.club_admin)
|
self.client.force_login(self.club_admin)
|
||||||
assert refill().status_code == 403
|
assert refill()
|
||||||
|
|
||||||
self.client.force_login(self.root)
|
self.client.force_login(self.root)
|
||||||
assert refill().status_code == 403
|
assert refill()
|
||||||
|
|
||||||
self.client.force_login(self.subscriber)
|
self.client.force_login(self.subscriber)
|
||||||
assert refill().status_code == 403
|
assert refill()
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
@@ -201,17 +199,6 @@ class TestRefilling(TestFullClickBase):
|
|||||||
== 404
|
== 404
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_refilling_above_limit_fails(self):
|
|
||||||
"""Test that it's forbidden to refill a customer above the limit."""
|
|
||||||
self.login_in_bar()
|
|
||||||
limit = settings.SITH_ACCOUNT_MAX_MONEY
|
|
||||||
# create a refilling to check that current balance is taken into account
|
|
||||||
baker.make(Refilling, customer=self.customer.customer, amount=limit // 2)
|
|
||||||
response = self.refill_user(self.customer, self.counter, (limit // 2) + 1)
|
|
||||||
assert response.status_code == 200 # no redirect = failure
|
|
||||||
self.customer.customer.refresh_from_db()
|
|
||||||
assert self.updated_amount(self.customer) == limit // 2
|
|
||||||
|
|
||||||
def test_refilling_counter_success(self):
|
def test_refilling_counter_success(self):
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
@@ -535,19 +522,6 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
assert self.updated_amount(self.customer) == Decimal(10)
|
assert self.updated_amount(self.customer) == Decimal(10)
|
||||||
|
|
||||||
def test_unrecord_above_limit_fails(self):
|
|
||||||
"""Test that it's forbidden to give back a recorded product
|
|
||||||
if it puts the account balance above the limit.
|
|
||||||
"""
|
|
||||||
self.login_in_bar()
|
|
||||||
limit = settings.SITH_ACCOUNT_MAX_MONEY
|
|
||||||
# put the account balance just at the limit
|
|
||||||
baker.make(Refilling, customer=self.customer.customer, amount=limit)
|
|
||||||
response = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
|
||||||
assert response.status_code == 200 # no redirect = failure
|
|
||||||
self.customer.customer.refresh_from_db()
|
|
||||||
assert self.updated_amount(self.customer) == limit
|
|
||||||
|
|
||||||
def test_annotate_has_barman_queryset(self):
|
def test_annotate_has_barman_queryset(self):
|
||||||
"""Test if the custom queryset method `annotate_has_barman` works as intended."""
|
"""Test if the custom queryset method `annotate_has_barman` works as intended."""
|
||||||
counters = Counter.objects.annotate_has_barman(self.barmen)
|
counters = Counter.objects.annotate_has_barman(self.barmen)
|
||||||
@@ -786,10 +760,10 @@ class TestBarmanConnection(TestCase):
|
|||||||
assert last_perm.counter == self.counter
|
assert last_perm.counter == self.counter
|
||||||
assert last_perm.user == self.barman
|
assert last_perm.user == self.barman
|
||||||
assert last_perm.end is None
|
assert last_perm.end is None
|
||||||
assert self.barman in response.wsgi_request.barmen
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
||||||
)
|
)
|
||||||
|
assert self.barman in response.wsgi_request.barmen
|
||||||
assert response.context_data.get("barmen") == [self.barman]
|
assert response.context_data.get("barmen") == [self.barman]
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
assert soup.find("form", id="select-user-form") is not None
|
assert soup.find("form", id="select-user-form") is not None
|
||||||
@@ -830,6 +804,41 @@ class TestBarmanConnection(TestCase):
|
|||||||
)
|
)
|
||||||
self.assert_counter_login_fails(self.barman)
|
self.assert_counter_login_fails(self.barman)
|
||||||
|
|
||||||
|
def test_barman_already_logged_in_another_device(self):
|
||||||
|
"""Test when the barman is already logged in the current counter on another device."""
|
||||||
|
other_client = Client()
|
||||||
|
other_client.post(
|
||||||
|
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||||
|
)
|
||||||
|
self.assert_counter_login_fails(self.barman)
|
||||||
|
|
||||||
|
def test_barman_login_elsewhere(self):
|
||||||
|
"""Test when the barman log himself out then log in on another device."""
|
||||||
|
self.client.post(
|
||||||
|
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||||
|
)
|
||||||
|
other_client = Client()
|
||||||
|
other_client.post(
|
||||||
|
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
|
||||||
|
data={"user_id": self.barman.id},
|
||||||
|
)
|
||||||
|
response = other_client.post(
|
||||||
|
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["HX-Redirect"] == self.detail_url
|
||||||
|
# the barmen should now be logged in `other_client`...
|
||||||
|
response = other_client.get(
|
||||||
|
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
||||||
|
)
|
||||||
|
assert self.barman in response.wsgi_request.barmen
|
||||||
|
|
||||||
|
# ... but not in `self.client`
|
||||||
|
response = self.client.get(
|
||||||
|
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
||||||
|
)
|
||||||
|
assert self.barman not in response.wsgi_request.barmen
|
||||||
|
|
||||||
def test_barman_already_logged_elsewhere(self):
|
def test_barman_already_logged_elsewhere(self):
|
||||||
"""Test when the barman is already logged in another counter."""
|
"""Test when the barman is already logged in another counter."""
|
||||||
other_counter = baker.make(Counter, type="BAR")
|
other_counter = baker.make(Counter, type="BAR")
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from core.models import User
|
|||||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
CounterSellers,
|
|
||||||
Customer,
|
Customer,
|
||||||
Refilling,
|
Refilling,
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
@@ -39,7 +38,7 @@ class TestStudentCard(TestCase):
|
|||||||
cls.subscriber = subscriber_user.make()
|
cls.subscriber = subscriber_user.make()
|
||||||
|
|
||||||
cls.counter = baker.make(Counter, type="BAR")
|
cls.counter = baker.make(Counter, type="BAR")
|
||||||
CounterSellers.objects.create(counter=cls.counter, user=cls.barmen)
|
cls.counter.sellers.add(cls.barmen)
|
||||||
|
|
||||||
cls.club_counter = baker.make(Counter)
|
cls.club_counter = baker.make(Counter)
|
||||||
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
||||||
|
|||||||
+17
-22
@@ -24,7 +24,7 @@ from django.shortcuts import get_object_or_404, redirect, resolve_url
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic import CreateView, FormView
|
from django.views.generic import FormView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from ninja.main import HttpRequest
|
from ninja.main import HttpRequest
|
||||||
|
|
||||||
@@ -32,14 +32,7 @@ from core.auth.mixins import CanViewMixin
|
|||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import BasketForm, RefillForm
|
from counter.forms import BasketForm, RefillForm
|
||||||
from counter.models import (
|
from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling
|
||||||
Counter,
|
|
||||||
Customer,
|
|
||||||
ProductFormula,
|
|
||||||
Refilling,
|
|
||||||
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 StudentCardFormFragment
|
from counter.views.student_card import StudentCardFormFragment
|
||||||
@@ -73,13 +66,13 @@ class CounterClick(
|
|||||||
current_tab = "counter"
|
current_tab = "counter"
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
return super().get_form_kwargs() | {
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs["form_kwargs"] = {
|
||||||
"customer": self.customer,
|
"customer": self.customer,
|
||||||
"counter": self.object,
|
"counter": self.object,
|
||||||
"form_kwargs": {
|
"allowed_prices": {price.id: price for price in self.prices},
|
||||||
"allowed_prices": {price.id: price for price in self.prices}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
return kwargs
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"])
|
self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"])
|
||||||
@@ -226,10 +219,9 @@ class CounterClick(
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class RefillingCreateView(FragmentMixin, CreateView):
|
class RefillingCreateView(FragmentMixin, FormView):
|
||||||
"""This is a fragment only view which integrates with counter_click.jinja"""
|
"""This is a fragment only view which integrates with counter_click.jinja"""
|
||||||
|
|
||||||
model = Refilling
|
|
||||||
form_class = RefillForm
|
form_class = RefillForm
|
||||||
template_name = "counter/fragments/create_refill.jinja"
|
template_name = "counter/fragments/create_refill.jinja"
|
||||||
|
|
||||||
@@ -250,20 +242,23 @@ class RefillingCreateView(FragmentMixin, CreateView):
|
|||||||
):
|
):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
|
self.operator = get_operator(request, self.counter, self.customer)
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||||
self.customer = kwargs.pop("customer")
|
self.customer = kwargs.pop("customer")
|
||||||
self.counter = kwargs.pop("counter")
|
self.counter = kwargs.pop("counter")
|
||||||
self.object = None
|
|
||||||
return super().render_fragment(request, **kwargs)
|
return super().render_fragment(request, **kwargs)
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def form_valid(self, form):
|
||||||
return super().get_form_kwargs() | {
|
res = super().form_valid(form)
|
||||||
"counter": self.counter,
|
form.clean()
|
||||||
"operator": get_operator(self.request, self.counter, self.customer),
|
form.instance.counter = self.counter
|
||||||
"customer": self.customer,
|
form.instance.operator = self.operator
|
||||||
}
|
form.instance.customer = self.customer
|
||||||
|
form.instance.save()
|
||||||
|
return res
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from django.views.generic.edit import FormView
|
|||||||
from core.auth.mixins import CanViewMixin
|
from core.auth.mixins import CanViewMixin
|
||||||
from core.views import FragmentMixin, UseFragmentsMixin
|
from core.views import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import CounterLoginForm, GetUserForm
|
from counter.forms import CounterLoginForm, GetUserForm
|
||||||
|
from counter.middleware import SESSION_PERMANENCES_KEY
|
||||||
from counter.models import Counter, Permanency
|
from counter.models import Counter, Permanency
|
||||||
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
|
||||||
@@ -58,8 +59,8 @@ class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
|
|||||||
|
|
||||||
def form_valid(self, form: CounterLoginForm):
|
def form_valid(self, form: CounterLoginForm):
|
||||||
user = form.get_user()
|
user = form.get_user()
|
||||||
self.object.permanencies.create(user=user, start=timezone.now())
|
perm = self.object.permanencies.create(user=user, start=timezone.now())
|
||||||
self.request.barmen.add(user)
|
self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id)
|
||||||
self.success_url = reverse(
|
self.success_url = reverse(
|
||||||
"counter:details", kwargs={"counter_id": self.object.id}
|
"counter:details", kwargs={"counter_id": self.object.id}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ interface BasketItem {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
isRefill: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASKET_CACHE_KEY = "basket";
|
const BASKET_CACHE_KEY = "basket";
|
||||||
const BASKET_CACHE_VERSION = 2;
|
const BASKET_CACHE_VERSION = 1;
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
|
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
|
||||||
@@ -22,7 +21,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
});
|
});
|
||||||
document
|
document
|
||||||
.getElementById("id_form-TOTAL_FORMS")
|
.getElementById("id_form-TOTAL_FORMS")
|
||||||
?.setAttribute(":value", "basket.length");
|
.setAttribute(":value", "basket.length");
|
||||||
},
|
},
|
||||||
|
|
||||||
loadBasket(): BasketItem[] {
|
loadBasket(): BasketItem[] {
|
||||||
@@ -33,8 +32,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
lastPurchaseTime &&
|
lastPurchaseTime !== null &&
|
||||||
localStorage.basketTimestamp &&
|
localStorage.basketTimestamp !== undefined &&
|
||||||
new Date(lastPurchaseTime) >=
|
new Date(lastPurchaseTime) >=
|
||||||
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
|
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
|
||||||
) {
|
) {
|
||||||
@@ -65,19 +64,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total of money that would be added to the AE account on basket purchase.
|
|
||||||
*/
|
|
||||||
getTotalAdded() {
|
|
||||||
return this.basket
|
|
||||||
.filter((item) => item.isRefill || item.unitPrice < 0)
|
|
||||||
.reduce(
|
|
||||||
(acc: number, item: BasketItem) =>
|
|
||||||
acc + Math.abs(item.quantity * item.unitPrice),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add 1 to the quantity of an item in the basket
|
* Add 1 to the quantity of an item in the basket
|
||||||
* @param {BasketItem} item
|
* @param {BasketItem} item
|
||||||
@@ -100,7 +86,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
if (this.basket[index].quantity === 0) {
|
if (this.basket[index].quantity === 0) {
|
||||||
this.basket = this.basket.filter(
|
this.basket = this.basket.filter(
|
||||||
(e: BasketItem) => e.priceId !== this.basket[index].priceId,
|
(e: BasketItem) => e.priceId !== this.basket[index].id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -117,16 +103,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param id The id of the product to add
|
* @param id The id of the product to add
|
||||||
* @param name The name of the product
|
* @param name The name of the product
|
||||||
* @param price The unit price of the product
|
* @param price The unit price of the product
|
||||||
* @param isRefill true if the product is a refill bond
|
|
||||||
* @returns The created item
|
* @returns The created item
|
||||||
*/
|
*/
|
||||||
createItem(id: number, name: string, price: number, isRefill: boolean): BasketItem {
|
createItem(id: number, name: string, price: number): BasketItem {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
priceId: id,
|
priceId: id,
|
||||||
name,
|
name,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
unitPrice: price,
|
unitPrice: price,
|
||||||
isRefill,
|
|
||||||
} as BasketItem;
|
} as BasketItem;
|
||||||
|
|
||||||
this.basket.push(newItem);
|
this.basket.push(newItem);
|
||||||
@@ -141,17 +125,16 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param id The id of the product to add
|
* @param id The id of the product to add
|
||||||
* @param name The name of the product
|
* @param name The name of the product
|
||||||
* @param price The unit price of the product
|
* @param price The unit price of the product
|
||||||
* @param isRefill true if the product is a refill bond
|
|
||||||
*/
|
*/
|
||||||
addFromCatalog(id: number, name: string, price: number, isRefill: boolean) {
|
addFromCatalog(id: number, name: string, price: number) {
|
||||||
const item = this.basket.find((e: BasketItem) => e.priceId === id);
|
let item = this.basket.find((e: BasketItem) => e.priceId === id);
|
||||||
|
|
||||||
// if the item is not in the basket, we create it
|
// if the item is not in the basket, we create it
|
||||||
// else we add + 1 to it
|
// else we add + 1 to it
|
||||||
if (item) {
|
if (item) {
|
||||||
this.add(item);
|
this.add(item);
|
||||||
} else {
|
} else {
|
||||||
this.createItem(id, name, price, isRefill);
|
item = this.createItem(id, name, price);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -58,17 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<template x-if="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}">
|
|
||||||
<div class="alert alert-red">
|
|
||||||
<div class="alert-main">
|
|
||||||
{% trans trimmed limit=settings.SITH_ACCOUNT_MAX_MONEY %}
|
|
||||||
You cannot purchase the current basket,
|
|
||||||
because it would put your AE account balance
|
|
||||||
above the {{ limit }}€ limit
|
|
||||||
{% endtrans %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<ul class="item-list">
|
<ul class="item-list">
|
||||||
{# Starting money #}
|
{# Starting money #}
|
||||||
<li>
|
<li>
|
||||||
@@ -120,12 +109,9 @@
|
|||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
{% trans %}Clear{% endtrans %}
|
{% trans %}Clear{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="btn btn-blue">
|
||||||
class="btn btn-blue"
|
|
||||||
:disabled="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}"
|
|
||||||
>
|
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
{% trans %}Validate{% endtrans %}
|
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -213,12 +199,7 @@
|
|||||||
id="{{ price.id }}"
|
id="{{ price.id }}"
|
||||||
class="card clickable shadow"
|
class="card clickable shadow"
|
||||||
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
||||||
@click='addFromCatalog(
|
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
|
||||||
{{ price.id }},
|
|
||||||
{{ price.full_label|tojson }},
|
|
||||||
{{ price.amount }},
|
|
||||||
{{ (price.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING)|lower }}
|
|
||||||
)'
|
|
||||||
{% if price.sold_out %}disabled{% endif %}
|
{% if price.sold_out %}disabled{% endif %}
|
||||||
>
|
>
|
||||||
{% if price.product.icon %}
|
{% if price.product.icon %}
|
||||||
|
|||||||
@@ -278,27 +278,6 @@ class TestEboutic(TestCase):
|
|||||||
)
|
)
|
||||||
assert Basket.objects.count() == 2
|
assert Basket.objects.count() == 2
|
||||||
|
|
||||||
def test_refill_limit(self):
|
|
||||||
"""Test that an eboutic basket cannot refill an account above the limit."""
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
product = product_recipe.make(
|
|
||||||
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
|
|
||||||
counters=[self.eboutic],
|
|
||||||
)
|
|
||||||
price = price_recipe.make(
|
|
||||||
product=product,
|
|
||||||
groups=[self.group_cotiz],
|
|
||||||
amount=settings.SITH_ACCOUNT_MAX_MONEY // 10,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.submit_basket([BasketItem(price.id, 10)])
|
|
||||||
assert Basket.objects.count() == 1
|
|
||||||
assertRedirects(response, reverse("eboutic:checkout", kwargs={"basket_id": 1}))
|
|
||||||
|
|
||||||
response = self.submit_basket([BasketItem(price.id, 11)])
|
|
||||||
assert Basket.objects.count() == 1
|
|
||||||
assert response.status_code == 200 # no redirect = form validation failed
|
|
||||||
|
|
||||||
def test_create_basket(self):
|
def test_create_basket(self):
|
||||||
self.client.force_login(self.new_customer)
|
self.client.force_login(self.new_customer)
|
||||||
assertRedirects(
|
assertRedirects(
|
||||||
|
|||||||
+8
-6
@@ -66,7 +66,9 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class BaseEbouticBasketForm(BaseBasketForm):
|
class BaseEbouticBasketForm(BaseBasketForm):
|
||||||
min_result_balance = None # user can pay by card, so no minimum enforced
|
def _check_enough_money(self, *args, **kwargs):
|
||||||
|
# Disable money check
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
EbouticBasketForm = forms.formset_factory(
|
EbouticBasketForm = forms.formset_factory(
|
||||||
@@ -86,15 +88,15 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
form_class = EbouticBasketForm
|
form_class = EbouticBasketForm
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
return super().get_form_kwargs() | {
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs["form_kwargs"] = {
|
||||||
"customer": self.customer,
|
"customer": self.customer,
|
||||||
"counter": get_eboutic(),
|
"counter": get_eboutic(),
|
||||||
"form_kwargs": {
|
"allowed_prices": {
|
||||||
"allowed_prices": {
|
price.id: price for price in self.prices if not price.sold_out
|
||||||
price.id: price for price in self.prices if not price.sold_out
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return kwargs
|
||||||
|
|
||||||
def form_valid(self, formset):
|
def form_valid(self, formset):
|
||||||
if len(formset) == 0:
|
if len(formset) == 0:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-06-10 20:18+0200\n"
|
"POT-Creation-Date: 2026-06-05 13:39+0200\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"
|
||||||
@@ -3217,6 +3217,10 @@ msgstr "Vous êtes déjà connecté à ce comptoir."
|
|||||||
msgid "You are already logged in another counter."
|
msgid "You are already logged in another counter."
|
||||||
msgstr "Vous êtes déjà connecté à un autre comptoir."
|
msgstr "Vous êtes déjà connecté à un autre comptoir."
|
||||||
|
|
||||||
|
#: counter/forms.py
|
||||||
|
msgid "You are already logged on another device"
|
||||||
|
msgstr "Vous êtes déjà connecté sur un autre appareil"
|
||||||
|
|
||||||
#: counter/forms.py
|
#: counter/forms.py
|
||||||
msgid "Regular barmen"
|
msgid "Regular barmen"
|
||||||
msgstr "Barmen réguliers"
|
msgstr "Barmen réguliers"
|
||||||
@@ -3306,11 +3310,6 @@ msgstr "Saisie de produit dupliquée"
|
|||||||
msgid "Not enough money"
|
msgid "Not enough money"
|
||||||
msgstr "Solde insuffisant"
|
msgstr "Solde insuffisant"
|
||||||
|
|
||||||
#: counter/forms.py counter/models.py
|
|
||||||
#, python-format
|
|
||||||
msgid "There cannot be more than %(money)d€ on an AE account"
|
|
||||||
msgstr "Il ne peut pas y avoir plus de %(money)d€ sur un compte AE"
|
|
||||||
|
|
||||||
#: counter/forms.py
|
#: counter/forms.py
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
@@ -4433,15 +4432,6 @@ msgstr "Payer avec un compte AE"
|
|||||||
msgid "The online shop of the association."
|
msgid "The online shop of the association."
|
||||||
msgstr "La boutique en ligne de l'association."
|
msgstr "La boutique en ligne de l'association."
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
"You cannot purchase the current basket, because it would put your AE account "
|
|
||||||
"balance above the %(limit)s€ limit"
|
|
||||||
msgstr ""
|
|
||||||
"Vous ne pouvez pas finaliser le panier actuel, parce que le solde de votre "
|
|
||||||
"compte AE passerait au-dessus de la limite de %(limit)s€."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid "Clear"
|
msgid "Clear"
|
||||||
msgstr "Vider"
|
msgstr "Vider"
|
||||||
|
|||||||
@@ -503,12 +503,6 @@ SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
|
|||||||
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
|
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
|
||||||
"""timedelta between the warning mail and the actual account dump"""
|
"""timedelta between the warning mail and the actual account dump"""
|
||||||
|
|
||||||
SITH_ACCOUNT_MAX_MONEY = 250 # €
|
|
||||||
"""Maximum amount of money a sith account can hold.
|
|
||||||
|
|
||||||
This amount is defined by the AE's Terms and Conditions of Sale.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Defines which product type is the refilling type,
|
# Defines which product type is the refilling type,
|
||||||
# and thus increases the account amount
|
# and thus increases the account amount
|
||||||
SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int(
|
SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int(
|
||||||
|
|||||||
Reference in New Issue
Block a user