mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-22 08:50:17 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 186498d904 | |||
| d4a2b7ec33 | |||
| d35a2945fc | |||
| dbe29669e6 |
@@ -409,6 +409,7 @@ class ProductForm(forms.ModelForm):
|
|||||||
"club",
|
"club",
|
||||||
"limit_age",
|
"limit_age",
|
||||||
"tray",
|
"tray",
|
||||||
|
"clic_limit",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.13 on 2026-05-13 11:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0039_price")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(model_name="product", name="buying_groups"),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="product",
|
||||||
|
name="clic_limit",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text=(
|
||||||
|
"If a limit is set, the product won't be purchasable "
|
||||||
|
"anymore once the latter is reached."
|
||||||
|
),
|
||||||
|
null=True,
|
||||||
|
verbose_name="clic limit",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+47
-15
@@ -22,7 +22,7 @@ import string
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING, Literal, Self
|
from typing import Literal, Self
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -34,6 +34,7 @@ from django.forms import ValidationError
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
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 PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
@@ -47,9 +48,6 @@ from core.utils import get_start_of_semester
|
|||||||
from counter.fields import CurrencyField
|
from counter.fields import CurrencyField
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
|
|
||||||
def get_eboutic() -> Counter:
|
def get_eboutic() -> Counter:
|
||||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||||
@@ -353,6 +351,38 @@ class ProductType(OrderedModel):
|
|||||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductQuerySet(models.QuerySet):
|
||||||
|
def under_clic_limit(self) -> Self:
|
||||||
|
"""Filter product which clic limit isn't reached yet.
|
||||||
|
|
||||||
|
The clic limit is reached when the amount of sales
|
||||||
|
and of items in a basket for less than 15 minutes
|
||||||
|
is greater or equal than `Product.clic_limit`.
|
||||||
|
"""
|
||||||
|
# import here to avoid circular import
|
||||||
|
from eboutic.models import BasketItem
|
||||||
|
|
||||||
|
nb_click_subquery = Subquery(
|
||||||
|
Selling.objects.filter(product_id=OuterRef("id"))
|
||||||
|
.values("product_id")
|
||||||
|
.annotate(res=Sum("quantity", default=0))
|
||||||
|
.values("res")[:1]
|
||||||
|
)
|
||||||
|
nb_basket_items_subquery = Subquery(
|
||||||
|
BasketItem.objects.filter(
|
||||||
|
product_id=OuterRef("id"),
|
||||||
|
basket__date__gt=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT,
|
||||||
|
)
|
||||||
|
.values("product_id")
|
||||||
|
.annotate(res=Sum("quantity"))
|
||||||
|
.values("res")[:1]
|
||||||
|
)
|
||||||
|
return self.annotate(
|
||||||
|
clicked=Coalesce(nb_click_subquery, 0),
|
||||||
|
reserved=Coalesce(nb_basket_items_subquery, 0),
|
||||||
|
).filter(Q(clic_limit=None) | Q(clic_limit__gt=(F("clicked") + F("reserved"))))
|
||||||
|
|
||||||
|
|
||||||
class Product(models.Model):
|
class Product(models.Model):
|
||||||
"""A product, with all its related information."""
|
"""A product, with all its related information."""
|
||||||
|
|
||||||
@@ -370,8 +400,7 @@ class Product(models.Model):
|
|||||||
)
|
)
|
||||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||||
purchase_price = CurrencyField(
|
purchase_price = CurrencyField(
|
||||||
_("purchase price"),
|
_("purchase price"), help_text=_("Initial cost of purchasing the product")
|
||||||
help_text=_("Initial cost of purchasing the product"),
|
|
||||||
)
|
)
|
||||||
icon = ResizedImageField(
|
icon = ResizedImageField(
|
||||||
height=70,
|
height=70,
|
||||||
@@ -388,13 +417,21 @@ class Product(models.Model):
|
|||||||
tray = models.BooleanField(
|
tray = models.BooleanField(
|
||||||
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||||
)
|
)
|
||||||
buying_groups = models.ManyToManyField(
|
clic_limit = models.PositiveSmallIntegerField(
|
||||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
_("clic limit"),
|
||||||
|
help_text=_(
|
||||||
|
"If a limit is set, the product won't be purchasable "
|
||||||
|
"anymore on the eboutic once the latter is reached."
|
||||||
|
),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
archived = models.BooleanField(_("archived"), default=False)
|
archived = models.BooleanField(_("archived"), default=False)
|
||||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
|
|
||||||
|
objects = ProductQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("product")
|
verbose_name = _("product")
|
||||||
|
|
||||||
@@ -733,10 +770,8 @@ class Counter(models.Model):
|
|||||||
# but they share the same primary key
|
# but they share the same primary key
|
||||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||||
|
|
||||||
def get_prices_for(
|
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
|
||||||
self, customer: Customer, *, order_by: Sequence[str] | None = None
|
return (
|
||||||
) -> list[Price]:
|
|
||||||
qs = (
|
|
||||||
Price.objects.filter(
|
Price.objects.filter(
|
||||||
product__counters=self, product__product_type__isnull=False
|
product__counters=self, product__product_type__isnull=False
|
||||||
)
|
)
|
||||||
@@ -744,9 +779,6 @@ class Counter(models.Model):
|
|||||||
.select_related("product", "product__product_type")
|
.select_related("product", "product__product_type")
|
||||||
.prefetch_related("groups")
|
.prefetch_related("groups")
|
||||||
)
|
)
|
||||||
if order_by:
|
|
||||||
qs = qs.order_by(*order_by)
|
|
||||||
return list(qs)
|
|
||||||
|
|
||||||
|
|
||||||
class CounterSellers(models.Model):
|
class CounterSellers(models.Model):
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
|
||||||
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||||
|
|
||||||
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||||
groups=[group],
|
groups=[group],
|
||||||
)
|
)
|
||||||
customer_prices = counter.get_prices_for(customer)
|
customer_prices = list(counter.get_prices_for(customer))
|
||||||
assert unarchived_prices == customer_prices
|
assert unarchived_prices == customer_prices
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import itertools
|
||||||
|
from datetime import timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -8,6 +10,7 @@ from django.core.cache import cache
|
|||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -16,9 +19,10 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
|
|||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from counter.baker_recipes import product_recipe
|
from counter.baker_recipes import product_recipe, sale_recipe
|
||||||
from counter.forms import ProductForm, ProductPriceFormSet
|
from counter.forms import ProductForm, ProductPriceFormSet
|
||||||
from counter.models import Price, Product, ProductType
|
from counter.models import Price, Product, ProductType, Selling
|
||||||
|
from eboutic.models import Basket, BasketItem
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -222,3 +226,57 @@ def test_price_for_user():
|
|||||||
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
|
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
|
||||||
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
|
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
|
||||||
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
|
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductClicLimit(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.products = product_recipe.make(
|
||||||
|
clic_limit=itertools.chain([5, 10, 15], itertools.repeat(None)),
|
||||||
|
_quantity=6,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
cls.qs = Product.objects.filter(id__in=[p.id for p in cls.products])
|
||||||
|
|
||||||
|
def test_no_sales_or_basket(self):
|
||||||
|
"""Test that it works if no sales has been made yet"""
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products
|
||||||
|
|
||||||
|
def test_with_sales(self):
|
||||||
|
"""Test that it works when there are existing sales"""
|
||||||
|
sales = sale_recipe.make(
|
||||||
|
product=itertools.cycle(self.products),
|
||||||
|
_quantity=len(self.products) * 5,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=2)
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||||
|
|
||||||
|
def test_with_sales_and_basket(self):
|
||||||
|
"""Test that it works when there are existing sales and basket items."""
|
||||||
|
sales = sale_recipe.make(
|
||||||
|
product=itertools.cycle(self.products),
|
||||||
|
_quantity=len(self.products) * 5,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=1)
|
||||||
|
basket = baker.make(
|
||||||
|
Basket, date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT / 2
|
||||||
|
)
|
||||||
|
items = baker.make(
|
||||||
|
BasketItem,
|
||||||
|
product=itertools.cycle(self.products),
|
||||||
|
basket=basket,
|
||||||
|
_quantity=len(self.products) * 5,
|
||||||
|
)
|
||||||
|
BasketItem.objects.filter(id__in=[i.id for i in items]).update(quantity=1)
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||||
|
|
||||||
|
# expired basket items shouldn't be accounted when computing clic limit
|
||||||
|
item = BasketItem.objects.filter(product=self.products[1])[0]
|
||||||
|
item.basket = baker.make(
|
||||||
|
Basket,
|
||||||
|
date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
item.save()
|
||||||
|
assert list(self.qs.under_clic_limit()) == self.products[1:]
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class CounterClick(
|
|||||||
):
|
):
|
||||||
return redirect(obj) # Redirect to counter
|
return redirect(obj) # Redirect to counter
|
||||||
|
|
||||||
self.prices = obj.get_prices_for(self.customer)
|
self.prices = list(obj.get_prices_for(self.customer))
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -118,9 +118,11 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def prices(self) -> list[Price]:
|
def prices(self) -> list[Price]:
|
||||||
return get_eboutic().get_prices_for(
|
eboutic = get_eboutic()
|
||||||
self.customer,
|
return list(
|
||||||
order_by=["product__product_type__order", "product_id", "amount"],
|
eboutic.get_prices_for(self.customer)
|
||||||
|
.filter(product__in=eboutic.products.under_clic_limit())
|
||||||
|
.order_by("product__product_type__order", "product_id", "amount")
|
||||||
)
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
|
|||||||
Reference in New Issue
Block a user