Merge pull request #1407 from ae-utbm/clic-limit

Clic limit
This commit is contained in:
thomas girod
2026-06-02 13:12:28 +02:00
committed by GitHub
18 changed files with 295 additions and 66 deletions
+6 -1
View File
@@ -29,7 +29,12 @@
align-items: center; align-items: center;
gap: 20px; gap: 20px;
&.clickable:hover { &:disabled {
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
background-color: darken($primary-neutral-light-color, 5%); background-color: darken($primary-neutral-light-color, 5%);
} }
+1 -1
View File
@@ -23,7 +23,7 @@
border-radius: 5px; border-radius: 5px;
color: black; color: black;
&:hover { &:not(.link-like):not(:disabled):hover {
background: hsl(0, 0%, 83%); background: hsl(0, 0%, 83%);
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@
<template x-for="(message, index) in $notifications.getAll()"> <template x-for="(message, index) in $notifications.getAll()">
<div class="alert" :class="`alert-${message.tag}`" x-transition> <div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span> <span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)"> <span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</span> </span>
</div> </div>
+1
View File
@@ -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",
),
),
]
+49 -15
View File
@@ -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,40 @@ 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
- settings.SITH_EBOUTIC_ETRANSACTION_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 +402,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 +419,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 +772,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 +781,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>
+1 -1
View File
@@ -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
+61 -2
View File
@@ -1,3 +1,4 @@
import itertools
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 +9,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 +18,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 +225,59 @@ 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
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
)
item.save()
assert list(self.qs.under_clic_limit()) == self.products[1:]
+1 -1
View File
@@ -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)
+31
View File
@@ -1,4 +1,6 @@
## Fonctionnement général
La boutique en ligne nécessite une interaction La boutique en ligne nécessite une interaction
avec la banque pour son fonctionnement. avec la banque pour son fonctionnement.
@@ -9,3 +11,32 @@ Nous ne pouvons donc que vous redirigez vers la doc du crédit
agricole : agricole :
[https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/](https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/) [https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/](https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/)
## Limite de clic et expiration des paniers
Certains produits peuvent avoir un quota de vente.
Une fois ce dernier atteint, il ne doit plus être possible de les acheter.
Pour éviter que cette limite soit dépassée si jamais plusieurs utilisateurs
commandent et achètent ce produit à peu près en même temps,
un produit est considéré comme « réservé » une fois placé dans un panier.
La création du panier s'effectue lors de la soumission du formulaire sur l'eboutic.
Une fois la transaction accomplie, le panier est supprimé.
Cependant, il reste un problème :
que faire des utilisateurs qui créent un panier, mais ne terminent
pas la transaction ?
Pour résoudre ce cas, les paniers ont une durée de validité,
définie dans le `settings.py`, grâce à deux variables :
- `settings.SITH_EBOUTIC_BASKET_TIMEOUT` :
le temps pendant lequel un utilisateur peut payer avec son compte AE
ou démarrer une etransaction
- `settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT` :
le temps alloué à l'utilisateur pour effectuer une etransaction ;
au-delà de cette durée, la banque refusera le paiement
et notifiera le sith de l'erreur.
Une fois expiré le temps défini par
`settings.SITH_EBOUTIC_BASKET_TIMEOUT + settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT`,
les produits contenus dans le panier sont à nouveau
disponibles à la vente.
+9
View File
@@ -98,6 +98,15 @@ class Basket(models.Model):
@property @property
def is_expired(self) -> bool: def is_expired(self) -> bool:
"""Return True if this basket is expired.
An expired basket can no longer be used tp pay with sith account
or to start an etransaction.
Warnings:
Users have an additional time if they pay with an etransaction,
so an expired basket may be purchased after its expiration in that case.
"""
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now() return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales( def generate_sales(
+17 -11
View File
@@ -11,7 +11,7 @@ const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1; const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({ Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
basket: [] as BasketItem[], basket: [] as BasketItem[],
init() { init() {
@@ -19,15 +19,6 @@ document.addEventListener("alpine:init", () => {
this.$watch("basket", () => { this.$watch("basket", () => {
this.saveBasket(); this.saveBasket();
}); });
// Invalidate basket if a purchase was made
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
if (
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
this.basket = [];
}
}
document document
.getElementById("id_form-TOTAL_FORMS") .getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
@@ -37,7 +28,22 @@ document.addEventListener("alpine:init", () => {
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, { const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
version: BASKET_CACHE_VERSION, version: BASKET_CACHE_VERSION,
}); });
return cached ?? []; if (!cached) {
return [];
}
if (
lastPurchaseTime !== null &&
localStorage.basketTimestamp !== undefined &&
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
// Invalidate basket if a purchase was made
return [];
}
// The basket is cached and not expired, so return it,
// but without items that are invalid
// (e.g. because the product is archived, or sold out)
return cached.filter((item) => validPrices.includes(item.priceId));
}, },
saveBasket() { saveBasket() {
@@ -17,7 +17,7 @@
<div x-data='etransaction( <div x-data='etransaction(
{{ billing_infos|tojson }}, {{ billing_infos|tojson }},
{ id: {{ basket.id }}, timeout: new Date('{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}') } { id: {{ basket.id }}, timeout: new Date("{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}") }
)'> )'>
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<table> <table>
+16 -2
View File
@@ -30,7 +30,17 @@
{% block content %} {% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1> <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div id="eboutic" x-data="basket({{ last_purchase_time }})"> <div
id="eboutic"
x-data="basket(
[{%- for prices in categories -%}
{%- for p in prices -%}
{% if not p.sold_out %}{{ p.id }},{% endif %}
{%- endfor -%}
{%- endfor -%}],
{{ last_purchase_time }},
)"
>
<div id="basket"> <div id="basket">
<h3>Panier</h3> <h3>Panier</h3>
<form method="post" action=""> <form method="post" action="">
@@ -187,9 +197,10 @@
{% for price in prices %} {% for price in prices %}
<button <button
id="{{ price.id }}" id="{{ price.id }}"
class="card product-button 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({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})' @click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
{% if price.sold_out %}disabled{% endif %}
> >
{% if price.product.icon %} {% if price.product.icon %}
<img <img
@@ -202,6 +213,9 @@
{% endif %} {% endif %}
<div class="card-content"> <div class="card-content">
<h4 class="card-title">{{ price.full_label }}</h4> <h4 class="card-title">{{ price.full_label }}</h4>
{% if price.sold_out -%}
<p><em>{% trans %}Product sold out{% endtrans %}</em></p>
{%- endif %}
<p>{{ price.amount }} €</p> <p>{{ price.amount }} €</p>
</div> </div>
</button> </button>
+46 -20
View File
@@ -1,14 +1,19 @@
import re
from datetime import datetime, timezone from datetime import datetime, timezone
import freezegun
import pytest import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate, now
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
import eboutic.models
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import ( from counter.baker_recipes import (
@@ -130,9 +135,11 @@ def test_eboutic_basket_expiry(
_bulk_create=True, _bulk_create=True,
) )
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
assert ( assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"' # remove any space from the value before asserting
in client.get(reverse("eboutic:main")).text re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"])
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
) )
@@ -231,26 +238,45 @@ class TestEboutic(TestCase):
def test_add_forbidden_product(self): def test_add_forbidden_product(self):
self.client.force_login(self.new_customer) self.client.force_login(self.new_customer)
response = self.submit_basket([BasketItem(self.beer.id, 1)]) for product in self.beer, self.cotiz, self.not_in_counter:
response = self.submit_basket([BasketItem(product.id, 1)])
assert response.status_code == 200 assert response.status_code == 200
assert Basket.objects.first() is None assert not Basket.objects.exists()
response = self.submit_basket([BasketItem(self.cotiz.id, 1)]) def test_sold_out_product(self):
sold_out = product_recipe.make(
clic_limit=3, counters=[self.eboutic], product_type=baker.make(ProductType)
)
price = price_recipe.make(product=sold_out, groups=[self.group_cotiz], amount=0)
sale_recipe.make(
product=sold_out,
customer=self.subscriber.customer,
unit_price=0,
quantity=1,
)
baker.make(
eboutic.models.BasketItem,
basket=baker.make(Basket),
product=sold_out,
quantity=2,
)
self.client.force_login(self.subscriber)
response = self.submit_basket([BasketItem(price.id, 1)])
assert response.status_code == 200 assert response.status_code == 200
assert Basket.objects.first() is None assert Basket.objects.count() == 1
with freezegun.freeze_time(
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)]) now()
assert response.status_code == 200 + settings.SITH_EBOUTIC_BASKET_TIMEOUT
assert Basket.objects.first() is None + settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT
):
self.client.force_login(self.new_customer) # after a while, unpaid basket items should expire and make the
response = self.submit_basket([BasketItem(self.cotiz.id, 1)]) # product available again.
assert response.status_code == 200 response = self.submit_basket([BasketItem(price.id, 1)])
assert Basket.objects.first() is None assertRedirects(
response,
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)]) reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}),
assert response.status_code == 200 )
assert Basket.objects.first() is None assert Basket.objects.count() == 2
def test_create_basket(self): def test_create_basket(self):
self.client.force_login(self.new_customer) self.client.force_login(self.new_customer)
+12 -5
View File
@@ -33,7 +33,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Subquery from django.db.models import Exists, OuterRef, Subquery
from django.db.models.fields import forms from django.db.models.fields import forms
from django.db.utils import cached_property from django.db.utils import cached_property
from django.http import HttpResponse from django.http import HttpResponse
@@ -92,7 +92,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"allowed_prices": {price.id: price for price in self.prices}, "allowed_prices": {
price.id: price for price in self.prices if not price.sold_out
},
} }
return kwargs return kwargs
@@ -118,9 +120,14 @@ 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, sold_out_subquery = ~Exists(
order_by=["product__product_type__order", "product_id", "amount"], eboutic.products.under_clic_limit().filter(id=OuterRef("product_id"))
)
return list(
eboutic.get_prices_for(self.customer)
.annotate(sold_out=sold_out_subquery)
.order_by("product__product_type__order", "product_id", "amount")
) )
@cached_property @cached_property
+15 -3
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 11:46+0200\n" "POT-Creation-Date: 2026-05-23 12:15+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"
@@ -3408,8 +3408,16 @@ msgid "Buy five, get the sixth free"
msgstr "Pour cinq achetés, le sixième offert" msgstr "Pour cinq achetés, le sixième offert"
#: counter/models.py #: counter/models.py
msgid "buying groups" msgid "clic limit"
msgstr "groupe d'achat" msgstr "limite de clic"
#: counter/models.py
msgid ""
"If a limit is set, the product won't be purchasable anymore on the eboutic "
"once the latter is reached."
msgstr ""
"Si une limite est donnée, le produit ne sera plus achetable sur l'eboutic "
"une fois celle-ci atteinte."
#: counter/models.py election/models.py #: counter/models.py election/models.py
msgid "archived" msgid "archived"
@@ -4462,6 +4470,10 @@ msgstr ""
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, " "billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche." "du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Product sold out"
msgstr "Produit épuisé"
#: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale" msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente" msgstr "Aucun article n'est disponible à la vente"