From c016dbc8bc48ef2c627db5292b823a2fee5c557d Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 22 Aug 2025 10:36:57 +0200 Subject: [PATCH 1/5] Fix auto basket cleaning after refilling account --- eboutic/views.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/eboutic/views.py b/eboutic/views.py index 869fe7e9..8a196fcc 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -48,7 +48,14 @@ from django_countries.fields import Country from core.auth.mixins import CanViewMixin, IsSubscriberMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm -from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic +from counter.models import ( + BillingInfo, + Customer, + Product, + Refilling, + Selling, + get_eboutic, +) from eboutic.models import ( Basket, BasketItem, @@ -120,17 +127,39 @@ class EbouticMainView(LoginRequiredMixin, FormView): def customer(self) -> Customer: return Customer.get_or_create(self.request.user)[0] + def get_purchase_timestamp( + self, purchase: Selling | Refilling | None + ) -> int | None: + if purchase is None: + return None + return int(purchase.date.timestamp() * 1000) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["products"] = self.products context["customer_amount"] = self.request.user.account_balance - last_purchase: Selling | None = ( + + last_buying: Selling | None = ( self.customer.buyings.filter(counter__type="EBOUTIC") .order_by("-date") .first() ) + last_refilling: Refilling | None = ( + self.customer.refillings.filter(counter__type="EBOUTIC") + .order_by("-date") + .first() + ) + purchase_times = [ + timestamp + for timestamp in [ + self.get_purchase_timestamp(last_buying), + self.get_purchase_timestamp(last_refilling), + ] + if timestamp is not None + ] + context["last_purchase_time"] = ( - int(last_purchase.date.timestamp() * 1000) if last_purchase else "null" + max(*purchase_times) if len(purchase_times) > 0 else "null" ) return context From f44fe724233c1c8ce08cd8e6caffa090533bcfe7 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 23 Aug 2025 12:55:02 +0200 Subject: [PATCH 2/5] Get customer last purchases in one request --- eboutic/views.py | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/eboutic/views.py b/eboutic/views.py index 8a196fcc..f4762534 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -34,6 +34,7 @@ from django.contrib.auth.mixins import ( from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import SuspiciousOperation, ValidationError from django.db import DatabaseError, transaction +from django.db.models import Subquery from django.db.models.fields import forms from django.db.utils import cached_property from django.http import HttpResponse @@ -127,39 +128,38 @@ class EbouticMainView(LoginRequiredMixin, FormView): def customer(self) -> Customer: return Customer.get_or_create(self.request.user)[0] - def get_purchase_timestamp( - self, purchase: Selling | Refilling | None - ) -> int | None: - if purchase is None: - return None - return int(purchase.date.timestamp() * 1000) - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["products"] = self.products context["customer_amount"] = self.request.user.account_balance - last_buying: Selling | None = ( - self.customer.buyings.filter(counter__type="EBOUTIC") - .order_by("-date") - .first() - ) - last_refilling: Refilling | None = ( - self.customer.refillings.filter(counter__type="EBOUTIC") - .order_by("-date") - .first() - ) purchase_times = [ - timestamp - for timestamp in [ - self.get_purchase_timestamp(last_buying), - self.get_purchase_timestamp(last_refilling), - ] - if timestamp is not None + int(purchase.timestamp() * 1000) + for purchase in ( + Customer.objects.filter(pk=self.customer.pk) + .annotate( + last_refill=Subquery( + Refilling.objects.filter( + counter__type="EBOUTIC", customer_id=self.customer.pk + ) + .order_by("-date") + .values("date")[:1] + ), + last_purchase=Subquery( + Selling.objects.filter( + counter__type="EBOUTIC", customer_id=self.customer.pk + ) + .order_by("-date") + .values("date")[:1] + ), + ) + .values("last_refill", "last_purchase") + )[0].values() + if purchase is not None ] context["last_purchase_time"] = ( - max(*purchase_times) if len(purchase_times) > 0 else "null" + max(purchase_times) if len(purchase_times) > 0 else "null" ) return context From 0bc18be75efa731976dbca878d17e9116b9c4a56 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 23 Aug 2025 15:16:57 +0200 Subject: [PATCH 3/5] Add basket cleaning tests --- eboutic/tests/test_basket.py | 99 +++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/eboutic/tests/test_basket.py b/eboutic/tests/test_basket.py index 60081102..8ea45dbd 100644 --- a/eboutic/tests/test_basket.py +++ b/eboutic/tests/test_basket.py @@ -1,3 +1,5 @@ +from datetime import datetime + import pytest from django.http import HttpResponse from django.test import TestCase @@ -7,10 +9,18 @@ from django.utils.timezone import localdate from model_bakery import baker from pytest_django.asserts import assertRedirects +from club.models import Club from core.baker_recipes import subscriber_user from core.models import Group, User from counter.baker_recipes import product_recipe -from counter.models import Counter, ProductType, get_eboutic +from counter.models import ( + Counter, + Customer, + ProductType, + Refilling, + Selling, + get_eboutic, +) from counter.tests.test_counter import BasketItem from eboutic.models import Basket @@ -24,6 +34,93 @@ def test_get_eboutic(): assert Counter.objects.get(name="Eboutic") == get_eboutic() +@pytest.mark.django_db +def test_eboutic_access_unregistered(client: Client): + eboutic_url = reverse("eboutic:main") + assertRedirects( + client.get(eboutic_url), reverse("core:login", query={"next": eboutic_url}) + ) + + +@pytest.mark.django_db +def test_eboutic_access_new_customer(client: Client): + user = baker.make(User) + assert Customer.objects.filter(user=user).first() is None + + client.force_login(user) + + assert client.get(reverse("eboutic:main")).status_code == 200 + assert Customer.objects.filter(user=user).first() + + +@pytest.mark.django_db +def test_eboutic_access_old_customer(client: Client): + user = baker.make(User) + customer = Customer.get_or_create(user)[0] + + client.force_login(user) + + assert client.get(reverse("eboutic:main")).status_code == 200 + assert Customer.objects.filter(user=user).first() == customer + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("sellings", "refillings", "expected"), + ( + ([], [], None), + ([datetime(2025, 3, 7, 1, 2, 3)], [], datetime(2025, 3, 7, 1, 2, 3)), + ([], [datetime(2025, 3, 7, 1, 2, 3)], datetime(2025, 3, 7, 1, 2, 3)), + ( + [datetime(2025, 2, 7, 1, 2, 3)], + [datetime(2025, 3, 7, 1, 2, 3)], + datetime(2025, 3, 7, 1, 2, 3), + ), + ( + [datetime(2025, 3, 7, 1, 2, 3)], + [datetime(2025, 2, 7, 1, 2, 3)], + datetime(2025, 3, 7, 1, 2, 3), + ), + ( + [datetime(2025, 3, 7, 1, 2, 3), datetime(2025, 2, 7, 1, 2, 3)], + [datetime(2025, 3, 7, 1, 2, 3)], + datetime(2025, 3, 7, 1, 2, 3), + ), + ), +) +def test_eboutic_basket_expiry( + client: Client, + sellings: list[datetime], + refillings: list[datetime], + expected: datetime | None, +): + eboutic = get_eboutic() + + user = baker.make(User) + customer = Customer.get_or_create(user)[0] + + client.force_login(user) + + for date in sellings: + baker.make( + Selling, + customer=customer, + counter=eboutic, + club=baker.make(Club), + seller=user, + date=date, + is_validated=True, # Ignore not enough money warnings + ) + + for date in refillings: + baker.make(Refilling, customer=customer, counter=eboutic, date=date) + + assert ( + f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"' + in client.get(reverse("eboutic:main")).text + ) + + class TestEboutic(TestCase): @classmethod def setUpTestData(cls): From 25099528bf13a79926cc285899d43de05503ba31 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 24 Aug 2025 00:26:34 +0200 Subject: [PATCH 4/5] Improve eboutic readability --- eboutic/views.py | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/eboutic/views.py b/eboutic/views.py index f4762534..b52e8d83 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -133,28 +133,30 @@ class EbouticMainView(LoginRequiredMixin, FormView): context["products"] = self.products context["customer_amount"] = self.request.user.account_balance + purchases = ( + Customer.objects.filter(pk=self.customer.pk) + .annotate( + last_refill=Subquery( + Refilling.objects.filter( + counter__type="EBOUTIC", customer_id=self.customer.pk + ) + .order_by("-date") + .values("date")[:1] + ), + last_purchase=Subquery( + Selling.objects.filter( + counter__type="EBOUTIC", customer_id=self.customer.pk + ) + .order_by("-date") + .values("date")[:1] + ), + ) + .values_list("last_refill", "last_purchase") + )[0] + purchase_times = [ int(purchase.timestamp() * 1000) - for purchase in ( - Customer.objects.filter(pk=self.customer.pk) - .annotate( - last_refill=Subquery( - Refilling.objects.filter( - counter__type="EBOUTIC", customer_id=self.customer.pk - ) - .order_by("-date") - .values("date")[:1] - ), - last_purchase=Subquery( - Selling.objects.filter( - counter__type="EBOUTIC", customer_id=self.customer.pk - ) - .order_by("-date") - .values("date")[:1] - ), - ) - .values("last_refill", "last_purchase") - )[0].values() + for purchase in purchases if purchase is not None ] From ed9c718cf105da9c676d1e054b4d5b3f5352c8a3 Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 26 Aug 2025 10:30:08 +0200 Subject: [PATCH 5/5] Apply review comments --- eboutic/tests/test_basket.py | 62 ++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/eboutic/tests/test_basket.py b/eboutic/tests/test_basket.py index 8ea45dbd..ff7f2077 100644 --- a/eboutic/tests/test_basket.py +++ b/eboutic/tests/test_basket.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone import pytest from django.http import HttpResponse @@ -9,16 +9,13 @@ from django.utils.timezone import localdate from model_bakery import baker from pytest_django.asserts import assertRedirects -from club.models import Club from core.baker_recipes import subscriber_user from core.models import Group, User -from counter.baker_recipes import product_recipe +from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.models import ( Counter, Customer, ProductType, - Refilling, - Selling, get_eboutic, ) from counter.tests.test_counter import BasketItem @@ -45,12 +42,12 @@ def test_eboutic_access_unregistered(client: Client): @pytest.mark.django_db def test_eboutic_access_new_customer(client: Client): user = baker.make(User) - assert Customer.objects.filter(user=user).first() is None + assert not Customer.objects.filter(user=user).exists() client.force_login(user) assert client.get(reverse("eboutic:main")).status_code == 200 - assert Customer.objects.filter(user=user).first() + assert Customer.objects.filter(user=user).exists() @pytest.mark.django_db @@ -69,22 +66,33 @@ def test_eboutic_access_old_customer(client: Client): ("sellings", "refillings", "expected"), ( ([], [], None), - ([datetime(2025, 3, 7, 1, 2, 3)], [], datetime(2025, 3, 7, 1, 2, 3)), - ([], [datetime(2025, 3, 7, 1, 2, 3)], datetime(2025, 3, 7, 1, 2, 3)), ( - [datetime(2025, 2, 7, 1, 2, 3)], - [datetime(2025, 3, 7, 1, 2, 3)], - datetime(2025, 3, 7, 1, 2, 3), + [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], + [], + datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ( - [datetime(2025, 3, 7, 1, 2, 3)], - [datetime(2025, 2, 7, 1, 2, 3)], - datetime(2025, 3, 7, 1, 2, 3), + [], + [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], + datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ( - [datetime(2025, 3, 7, 1, 2, 3), datetime(2025, 2, 7, 1, 2, 3)], - [datetime(2025, 3, 7, 1, 2, 3)], - datetime(2025, 3, 7, 1, 2, 3), + [datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)], + [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], + datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), + ), + ( + [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], + [datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)], + datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), + ), + ( + [ + datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), + datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc), + ], + [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], + datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ), ) @@ -96,24 +104,16 @@ def test_eboutic_basket_expiry( ): eboutic = get_eboutic() - user = baker.make(User) - customer = Customer.get_or_create(user)[0] + customer = baker.make(Customer) - client.force_login(user) + client.force_login(customer.user) for date in sellings: - baker.make( - Selling, - customer=customer, - counter=eboutic, - club=baker.make(Club), - seller=user, - date=date, - is_validated=True, # Ignore not enough money warnings + sale_recipe.make( + customer=customer, counter=eboutic, date=date, is_validated=True ) - for date in refillings: - baker.make(Refilling, customer=customer, counter=eboutic, date=date) + refill_recipe.make(customer=customer, counter=eboutic, date=date) assert ( f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'