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",
|
||||
"limit_age",
|
||||
"tray",
|
||||
"clic_limit",
|
||||
"archived",
|
||||
]
|
||||
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 timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Literal, Self
|
||||
from typing import Literal, Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
@@ -34,6 +34,7 @@ from django.forms import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django_countries.fields import CountryField
|
||||
@@ -47,9 +48,6 @@ from core.utils import get_start_of_semester
|
||||
from counter.fields import CurrencyField
|
||||
from subscription.models import Subscription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def get_eboutic() -> Counter:
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""A product, with all its related information."""
|
||||
|
||||
@@ -370,8 +400,7 @@ class Product(models.Model):
|
||||
)
|
||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||
purchase_price = CurrencyField(
|
||||
_("purchase price"),
|
||||
help_text=_("Initial cost of purchasing the product"),
|
||||
_("purchase price"), help_text=_("Initial cost of purchasing the product")
|
||||
)
|
||||
icon = ResizedImageField(
|
||||
height=70,
|
||||
@@ -388,13 +417,21 @@ class Product(models.Model):
|
||||
tray = models.BooleanField(
|
||||
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||
)
|
||||
buying_groups = models.ManyToManyField(
|
||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||
clic_limit = models.PositiveSmallIntegerField(
|
||||
_("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)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||
|
||||
objects = ProductQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("product")
|
||||
|
||||
@@ -733,10 +770,8 @@ class Counter(models.Model):
|
||||
# but they share the same primary key
|
||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||
|
||||
def get_prices_for(
|
||||
self, customer: Customer, *, order_by: Sequence[str] | None = None
|
||||
) -> list[Price]:
|
||||
qs = (
|
||||
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
|
||||
return (
|
||||
Price.objects.filter(
|
||||
product__counters=self, product__product_type__isnull=False
|
||||
)
|
||||
@@ -744,9 +779,6 @@ class Counter(models.Model):
|
||||
.select_related("product", "product__product_type")
|
||||
.prefetch_related("groups")
|
||||
)
|
||||
if order_by:
|
||||
qs = qs.order_by(*order_by)
|
||||
return list(qs)
|
||||
|
||||
|
||||
class CounterSellers(models.Model):
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
|
||||
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||
|
||||
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||
|
||||
@@ -596,7 +596,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||
groups=[group],
|
||||
)
|
||||
customer_prices = counter.get_prices_for(customer)
|
||||
customer_prices = list(counter.get_prices_for(customer))
|
||||
assert unarchived_prices == customer_prices
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
@@ -8,6 +10,7 @@ from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from PIL import Image
|
||||
@@ -16,9 +19,10 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
from club.models import Club
|
||||
from core.baker_recipes import board_user, subscriber_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.models import Price, Product, ProductType
|
||||
from counter.models import Price, Product, ProductType, Selling
|
||||
from eboutic.models import Basket, BasketItem
|
||||
|
||||
|
||||
@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[1])) == [prices[0], prices[4]]
|
||||
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
|
||||
|
||||
self.prices = obj.get_prices_for(self.customer)
|
||||
self.prices = list(obj.get_prices_for(self.customer))
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
+5
-3
@@ -118,9 +118,11 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
|
||||
@cached_property
|
||||
def prices(self) -> list[Price]:
|
||||
return get_eboutic().get_prices_for(
|
||||
self.customer,
|
||||
order_by=["product__product_type__order", "product_id", "amount"],
|
||||
eboutic = get_eboutic()
|
||||
return list(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user