From cca9732925ee4eb01c47644e06dda992a0a9cdcf Mon Sep 17 00:00:00 2001 From: thomas girod Date: Sun, 28 Jul 2024 00:09:39 +0200 Subject: [PATCH 1/4] eboutic big refactor --- counter/models.py | 54 ++-- counter/tests.py | 271 +++++------------- counter/urls.py | 10 - counter/views.py | 52 ---- eboutic/admin.py | 13 +- eboutic/api.py | 38 +++ eboutic/forms.py | 101 ++----- eboutic/models.py | 91 ++---- eboutic/schemas.py | 33 +++ eboutic/static/eboutic/js/eboutic.js | 23 +- eboutic/static/eboutic/js/makecommand.js | 90 +++--- eboutic/templates/eboutic/eboutic_main.jinja | 1 - .../eboutic/eboutic_makecommand.jinja | 80 +++--- eboutic/tests/tests.py | 16 +- eboutic/views.py | 96 ++++--- locale/fr/LC_MESSAGES/django.po | 18 +- sith/settings.py | 11 +- 17 files changed, 414 insertions(+), 584 deletions(-) create mode 100644 eboutic/api.py create mode 100644 eboutic/schemas.py diff --git a/counter/models.py b/counter/models.py index 9d4e081b..d9dca99a 100644 --- a/counter/models.py +++ b/counter/models.py @@ -310,11 +310,26 @@ class Product(models.Model): Returns: True if the user can buy this product else False + + Warnings: + This performs a db query, thus you can quickly have + a N+1 queries problem if you call it in a loop. + Hopefully, you can avoid that if you prefetch the buying_groups : + + ```python + user = User.objects.get(username="foobar") + products = [ + p + for p in Product.objects.prefetch_related("buying_groups") + if p.can_be_sold_to(user) + ] + ``` """ - if not self.buying_groups.exists(): + buying_groups = list(self.buying_groups.all()) + if not buying_groups: return True - for group_id in self.buying_groups.values_list("pk", flat=True): - if user.is_in_group(pk=group_id): + for group in buying_groups: + if user.is_in_group(pk=group.id): return True return False @@ -690,14 +705,14 @@ class Selling(models.Model): self.customer.amount -= self.quantity * self.unit_price self.customer.save(allow_negative=allow_negative, is_selling=True) self.is_validated = True - u = User.objects.filter(id=self.customer.user.id).first() - if u.was_subscribed: + user = self.customer.user + if user.was_subscribed: if ( self.product and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER ): sub = Subscription( - member=u, + member=user, subscription_type="un-semestre", payment_method="EBOUTIC", location="EBOUTIC", @@ -719,9 +734,8 @@ class Selling(models.Model): self.product and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS ): - u = User.objects.filter(id=self.customer.user.id).first() sub = Subscription( - member=u, + member=user, subscription_type="deux-semestres", payment_method="EBOUTIC", location="EBOUTIC", @@ -739,13 +753,13 @@ class Selling(models.Model): start=sub.subscription_start, ) sub.save() - if self.customer.user.preferences.notify_on_click: + if user.preferences.notify_on_click: Notification( - user=self.customer.user, + user=user, url=reverse( "core:user_account_detail", kwargs={ - "user_id": self.customer.user.id, + "user_id": user.id, "year": self.date.year, "month": self.date.month, }, @@ -754,19 +768,15 @@ class Selling(models.Model): type="SELLING", ).save() super().save(*args, **kwargs) - try: - # The product has no id until it's saved - if self.product.eticket: - self.send_mail_customer() - except: - pass + if hasattr(self.product, "eticket"): + self.send_mail_customer() - def is_owned_by(self, user): + def is_owned_by(self, user: User) -> bool: if user.is_anonymous: return False - return user.is_owner(self.counter) and self.payment_method != "CARD" + return self.payment_method != "CARD" and user.is_owner(self.counter) - def can_be_viewed_by(self, user): + def can_be_viewed_by(self, user: User) -> bool: if ( not hasattr(self, "customer") or self.customer is None ): # Customer can be set to Null @@ -812,7 +822,9 @@ class Selling(models.Model): "url": self.customer.get_full_url(), "eticket": self.get_eticket_full_url(), } - self.customer.user.email_user(subject, message_txt, html_message=message_html) + self.customer.user.email_user( + subject, message_txt, html_message=message_html, fail_silently=True + ) def get_eticket_full_url(self): eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id}) diff --git a/counter/tests.py b/counter/tests.py index 996a3a2a..3dc0ec74 100644 --- a/counter/tests.py +++ b/counter/tests.py @@ -16,8 +16,9 @@ import json import re import string +import pytest from django.core.cache import cache -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone from django.utils.timezone import timedelta @@ -303,18 +304,11 @@ class TestCounterStats(TestCase): ] -class TestBillingInfo(TestCase): - @classmethod - def setUpTestData(cls): - cls.payload_1 = { - "first_name": "Subscribed", - "last_name": "User", - "address_1": "1 rue des Huns", - "zip_code": "90000", - "city": "Belfort", - "country": "FR", - } - cls.payload_2 = { +@pytest.mark.django_db +class TestBillingInfo: + @pytest.fixture + def payload(self): + return { "first_name": "Subscribed", "last_name": "User", "address_1": "3, rue de Troyes", @@ -322,213 +316,80 @@ class TestBillingInfo(TestCase): "city": "Sète", "country": "FR", } - cls.root = User.objects.get(username="root") - cls.subscriber = User.objects.get(username="subscriber") - def test_edit_infos(self): - user = self.subscriber - BillingInfo.objects.get_or_create( - customer=user.customer, defaults=self.payload_1 - ) - self.client.force_login(user) - response = self.client.post( - reverse("counter:edit_billing_info", args=[user.id]), - json.dumps(self.payload_2), + def test_edit_infos(self, client: Client, payload: dict): + user = subscriber_user.make() + baker.make(BillingInfo, customer=user.customer) + client.force_login(user) + response = client.put( + reverse("api:put_billing_info", args=[user.id]), + json.dumps(payload), content_type="application/json", ) - user = User.objects.get(username="subscriber") + user.refresh_from_db() infos = BillingInfo.objects.get(customer__user=user) assert response.status_code == 200 - self.assertJSONEqual(response.content, {"errors": None}) assert hasattr(user.customer, "billing_infos") assert infos.customer == user.customer - assert infos.first_name == "Subscribed" - assert infos.last_name == "User" - assert infos.address_1 == "3, rue de Troyes" - assert infos.address_2 is None - assert infos.zip_code == "34301" - assert infos.city == "Sète" - assert infos.country == "FR" + for key, val in payload.items(): + assert getattr(infos, key) == val - def test_create_infos_for_user_with_account(self): - user = User.objects.get(username="subscriber") - if hasattr(user.customer, "billing_infos"): - user.customer.billing_infos.delete() - self.client.force_login(user) - response = self.client.post( - reverse("counter:create_billing_info", args=[user.id]), - json.dumps(self.payload_1), + @pytest.mark.parametrize( + "user_maker", [subscriber_user.make, lambda: baker.make(User)] + ) + @pytest.mark.django_db + def test_create_infos(self, client: Client, user_maker, payload): + user = user_maker() + client.force_login(user) + assert not BillingInfo.objects.filter(customer__user=user).exists() + response = client.put( + reverse("api:put_billing_info", args=[user.id]), + json.dumps(payload), content_type="application/json", ) - user = User.objects.get(username="subscriber") - infos = BillingInfo.objects.get(customer__user=user) assert response.status_code == 200 - self.assertJSONEqual(response.content, {"errors": None}) - assert hasattr(user.customer, "billing_infos") - assert infos.customer == user.customer - assert infos.first_name == "Subscribed" - assert infos.last_name == "User" - assert infos.address_1 == "1 rue des Huns" - assert infos.address_2 is None - assert infos.zip_code == "90000" - assert infos.city == "Belfort" - assert infos.country == "FR" - - def test_create_infos_for_user_without_account(self): - user = User.objects.get(username="subscriber") - if hasattr(user, "customer"): - user.customer.delete() - self.client.force_login(user) - response = self.client.post( - reverse("counter:create_billing_info", args=[user.id]), - json.dumps(self.payload_1), - content_type="application/json", - ) - user = User.objects.get(username="subscriber") + user.refresh_from_db() assert hasattr(user, "customer") - assert hasattr(user.customer, "billing_infos") - assert response.status_code == 200 - self.assertJSONEqual(response.content, {"errors": None}) infos = BillingInfo.objects.get(customer__user=user) - self.assertEqual(user.customer, infos.customer) - assert infos.first_name == "Subscribed" - assert infos.last_name == "User" - assert infos.address_1 == "1 rue des Huns" - assert infos.address_2 is None - assert infos.zip_code == "90000" - assert infos.city == "Belfort" - assert infos.country == "FR" - - def test_create_invalid(self): - user = User.objects.get(username="subscriber") - if hasattr(user.customer, "billing_infos"): - user.customer.billing_infos.delete() - self.client.force_login(user) - # address_1, zip_code and country are missing - payload = { - "first_name": user.first_name, - "last_name": user.last_name, - "city": "Belfort", - } - response = self.client.post( - reverse("counter:create_billing_info", args=[user.id]), - json.dumps(payload), - content_type="application/json", - ) - user = User.objects.get(username="subscriber") - self.assertEqual(400, response.status_code) - assert not hasattr(user.customer, "billing_infos") - expected_errors = { - "errors": [ - {"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]}, - {"field": "Code postal", "messages": ["Ce champ est obligatoire."]}, - {"field": "Country", "messages": ["Ce champ est obligatoire."]}, - ] - } - self.assertJSONEqual(response.content, expected_errors) - - def test_edit_invalid(self): - user = User.objects.get(username="subscriber") - BillingInfo.objects.get_or_create( - customer=user.customer, defaults=self.payload_1 - ) - self.client.force_login(user) - # address_1, zip_code and country are missing - payload = { - "first_name": user.first_name, - "last_name": user.last_name, - "city": "Belfort", - } - response = self.client.post( - reverse("counter:edit_billing_info", args=[user.id]), - json.dumps(payload), - content_type="application/json", - ) - user = User.objects.get(username="subscriber") - self.assertEqual(400, response.status_code) - assert hasattr(user.customer, "billing_infos") - expected_errors = { - "errors": [ - {"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]}, - {"field": "Code postal", "messages": ["Ce champ est obligatoire."]}, - {"field": "Country", "messages": ["Ce champ est obligatoire."]}, - ] - } - self.assertJSONEqual(response.content, expected_errors) - - def test_edit_other_user(self): - user = User.objects.get(username="sli") - self.client.login(username="subscriber", password="plop") - BillingInfo.objects.get_or_create( - customer=user.customer, defaults=self.payload_1 - ) - response = self.client.post( - reverse("counter:edit_billing_info", args=[user.id]), - json.dumps(self.payload_2), - content_type="application/json", - ) - self.assertEqual(403, response.status_code) - - def test_edit_not_existing_infos(self): - user = User.objects.get(username="subscriber") - if hasattr(user.customer, "billing_infos"): - user.customer.billing_infos.delete() - self.client.force_login(user) - response = self.client.post( - reverse("counter:edit_billing_info", args=[user.id]), - json.dumps(self.payload_2), - content_type="application/json", - ) - self.assertEqual(404, response.status_code) - - def test_edit_by_root(self): - user = User.objects.get(username="subscriber") - BillingInfo.objects.get_or_create( - customer=user.customer, defaults=self.payload_1 - ) - self.client.force_login(self.root) - response = self.client.post( - reverse("counter:edit_billing_info", args=[user.id]), - json.dumps(self.payload_2), - content_type="application/json", - ) - assert response.status_code == 200 - user = User.objects.get(username="subscriber") - infos = BillingInfo.objects.get(customer__user=user) - self.assertJSONEqual(response.content, {"errors": None}) - assert hasattr(user.customer, "billing_infos") - self.assertEqual(user.customer, infos.customer) - self.assertEqual("Subscribed", infos.first_name) - self.assertEqual("User", infos.last_name) - self.assertEqual("3, rue de Troyes", infos.address_1) - self.assertEqual(None, infos.address_2) - self.assertEqual("34301", infos.zip_code) - self.assertEqual("Sète", infos.city) - self.assertEqual("FR", infos.country) - - def test_create_by_root(self): - user = User.objects.get(username="subscriber") - if hasattr(user.customer, "billing_infos"): - user.customer.billing_infos.delete() - self.client.force_login(self.root) - response = self.client.post( - reverse("counter:create_billing_info", args=[user.id]), - json.dumps(self.payload_2), - content_type="application/json", - ) - assert response.status_code == 200 - user = User.objects.get(username="subscriber") - infos = BillingInfo.objects.get(customer__user=user) - self.assertJSONEqual(response.content, {"errors": None}) assert hasattr(user.customer, "billing_infos") assert infos.customer == user.customer - assert infos.first_name == "Subscribed" - assert infos.last_name == "User" - assert infos.address_1 == "3, rue de Troyes" - assert infos.address_2 is None - assert infos.zip_code == "34301" - assert infos.city == "Sète" - assert infos.country == "FR" + for key, val in payload.items(): + assert getattr(infos, key) == val + + def test_invalid_data(self, client: Client, payload): + user = subscriber_user.make() + client.force_login(user) + # address_1, zip_code and country are missing + del payload["city"] + response = client.put( + reverse("api:put_billing_info", args=[user.id]), + json.dumps(payload), + content_type="application/json", + ) + assert response.status_code == 422 + user.customer.refresh_from_db() + assert not hasattr(user.customer, "billing_infos") + + @pytest.mark.parametrize( + ("operator_maker", "expected_code"), + [ + (subscriber_user.make, 403), + (lambda: baker.make(User), 403), + (lambda: baker.make(User, is_superuser=True), 200), + ], + ) + def test_edit_other_user( + self, client: Client, operator_maker, expected_code: int, payload: dict + ): + user = subscriber_user.make() + client.force_login(operator_maker()) + baker.make(BillingInfo, customer=user.customer) + response = client.put( + reverse("api:put_billing_info", args=[user.id]), + json.dumps(payload), + content_type="application/json", + ) + assert response.status_code == expected_code class TestBarmanConnection(TestCase): diff --git a/counter/urls.py b/counter/urls.py index 5be8e19d..72478fa0 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -57,16 +57,6 @@ urlpatterns = [ StudentCardDeleteView.as_view(), name="delete_student_card", ), - path( - "customer//billing_info/create", - create_billing_info, - name="create_billing_info", - ), - path( - "customer//billing_info/edit", - edit_billing_info, - name="edit_billing_info", - ), path("admin//", CounterEditView.as_view(), name="admin"), path( "admin//prop/", diff --git a/counter/views.py b/counter/views.py index 373539bf..f61bc179 100644 --- a/counter/views.py +++ b/counter/views.py @@ -12,7 +12,6 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -import json import re from datetime import datetime, timedelta from datetime import timezone as tz @@ -21,7 +20,6 @@ from urllib.parse import parse_qs from django import forms from django.conf import settings -from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.db import DataError, transaction from django.db.models import F @@ -56,7 +54,6 @@ from core.utils import get_semester_code, get_start_of_semester from core.views import CanEditMixin, CanViewMixin, TabedViewMixin from core.views.forms import LoginForm from counter.forms import ( - BillingInfoForm, CashSummaryFormBase, CounterEditForm, EticketForm, @@ -67,7 +64,6 @@ from counter.forms import ( StudentCardForm, ) from counter.models import ( - BillingInfo, CashRegisterSummary, CashRegisterSummaryItem, Counter, @@ -1569,51 +1565,3 @@ class StudentCardFormView(FormView): return reverse_lazy( "core:user_prefs", kwargs={"user_id": self.customer.user.pk} ) - - -def __manage_billing_info_req(request, user_id, *, delete_if_fail=False): - data = json.loads(request.body) - form = BillingInfoForm(data) - if not form.is_valid(): - if delete_if_fail: - Customer.objects.get(user__id=user_id).billing_infos.delete() - errors = [ - {"field": str(form.fields[k].label), "messages": v} - for k, v in form.errors.items() - ] - content = json.dumps({"errors": errors}) - return HttpResponse(status=400, content=content) - if form.is_valid(): - infos = Customer.objects.get(user__id=user_id).billing_infos - for field in form.fields: - infos.__dict__[field] = form[field].value() - infos.save() - content = json.dumps({"errors": None}) - return HttpResponse(status=200, content=content) - - -@login_required -@require_POST -def create_billing_info(request, user_id): - user = request.user - if user.id != user_id and not user.has_perm("counter:add_billinginfo"): - raise PermissionDenied() - user = get_object_or_404(User, pk=user_id) - customer, _ = Customer.get_or_create(user) - BillingInfo.objects.create(customer=customer) - return __manage_billing_info_req(request, user_id, delete_if_fail=True) - - -@login_required -@require_POST -def edit_billing_info(request, user_id): - user = request.user - if user.id != user_id and not user.has_perm("counter:change_billinginfo"): - raise PermissionDenied() - user = get_object_or_404(User, pk=user_id) - if not hasattr(user, "customer"): - raise Http404 - if not hasattr(user.customer, "billing_infos"): - raise Http404 - - return __manage_billing_info_req(request, user_id) diff --git a/eboutic/admin.py b/eboutic/admin.py index 83d33101..454ca5c3 100644 --- a/eboutic/admin.py +++ b/eboutic/admin.py @@ -19,9 +19,20 @@ from eboutic.models import * @admin.register(Basket) class BasketAdmin(admin.ModelAdmin): - list_display = ("user", "date", "get_total") + list_display = ("user", "date", "total") autocomplete_fields = ("user",) + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .annotate( + total=Sum( + F("items__quantity") * F("items__product_unit_price"), default=0 + ) + ) + ) + @admin.register(BasketItem) class BasketItemAdmin(admin.ModelAdmin): diff --git a/eboutic/api.py b/eboutic/api.py new file mode 100644 index 00000000..0054c02a --- /dev/null +++ b/eboutic/api.py @@ -0,0 +1,38 @@ +from django.shortcuts import get_object_or_404 +from ninja_extra import ControllerBase, api_controller, route +from ninja_extra.exceptions import NotFound, PermissionDenied +from ninja_extra.permissions import IsAuthenticated +from pydantic import NonNegativeInt + +from core.models import User +from counter.models import BillingInfo, Customer +from eboutic.models import Basket +from eboutic.schemas import BillingInfoSchema + + +@api_controller("/etransaction", permissions=[IsAuthenticated]) +class EtransactionInfoController(ControllerBase): + @route.put("/billing-info/{user_id}", url_name="put_billing_info") + def put_user_billing_info(self, user_id: NonNegativeInt, info: BillingInfoSchema): + """Update or create the billing info of this user.""" + if user_id == self.context.request.user.id: + user = self.context.request.user + elif self.context.request.user.is_root: + user = get_object_or_404(User, pk=user_id) + else: + raise PermissionDenied + customer, _ = Customer.get_or_create(user) + BillingInfo.objects.update_or_create( + customer=customer, defaults=info.model_dump(exclude_none=True) + ) + + @route.get("/data", url_name="etransaction_data", include_in_schema=False) + def fetch_etransaction_data(self): + """Generate the data to pay an eboutic command with paybox. + + The data is generated with the basket that is used by the current session. + """ + basket = Basket.from_session(self.context.request.session) + if basket is None: + raise NotFound + return dict(basket.get_e_transaction_data()) diff --git a/eboutic/forms.py b/eboutic/forms.py index 853436e8..84e4ad21 100644 --- a/eboutic/forms.py +++ b/eboutic/forms.py @@ -20,17 +20,15 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # - -import json -import re -import typing +from functools import cached_property from urllib.parse import unquote from django.http import HttpRequest from django.utils.translation import gettext as _ -from sentry_sdk import capture_message +from pydantic import ValidationError from eboutic.models import get_eboutic_products +from eboutic.schemas import PurchaseItemList, PurchaseItemSchema class BasketForm: @@ -43,8 +41,7 @@ class BasketForm: Thus this class is a pure standalone and performs its operations by its own means. However, it still tries to share some similarities with a standard django Form. - Example: - ------- + Examples: :: def my_view(request): @@ -62,28 +59,13 @@ class BasketForm: You can also use a little shortcut by directly calling `form.is_valid()` without calling `form.clean()`. In this case, the latter method shall be implicitly called. - """ - # check the json is an array containing non-nested objects. - # values must be strings or numbers - # this is matched : - # [{"id": 4, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}] - # but this is not : - # [{"id": {"nested_id": 10}, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}] - # and neither does this : - # [{"id": ["nested_id": 10], "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}] - # and neither does that : - # [{"id": null, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}] - json_cookie_re = re.compile( - r"^\[\s*(\{\s*(\"[^\"]*\":\s*(\"[^\"]{0,64}\"|\d{0,5}\.?\d+),?\s*)*\},?\s*)*\s*\]$" - ) - def __init__(self, request: HttpRequest): self.user = request.user self.cookies = request.COOKIES self.error_messages = set() - self.correct_cookie = [] + self.correct_items = [] def clean(self) -> None: """Perform all the checks, but return nothing. @@ -98,70 +80,29 @@ class BasketForm: - all the ids refer to products the user is allowed to buy - all the quantities are positive integers """ - # replace escaped double quotes by single quotes, as the RegEx used to check the json - # does not support escaped double quotes - basket = unquote(self.cookies.get("basket_items", "")).replace('\\"', "'") - - if basket in ("[]", ""): - self.error_messages.add(_("You have no basket.")) - return - - # check that the json is not nested before parsing it to make sure - # malicious user can't DDoS the server with deeply nested json - if not BasketForm.json_cookie_re.match(basket): - # As the validation of the cookie goes through a rather boring regex, - # we can regularly have to deal with subtle errors that we hadn't forecasted, - # so we explicitly lay a Sentry message capture here. - capture_message( - "Eboutic basket regex checking failed to validate basket json", - level="error", + try: + basket = PurchaseItemList.validate_json( + unquote(self.cookies.get("basket_items", "[]")) ) + except ValidationError: self.error_messages.add(_("The request was badly formatted.")) return - - try: - basket = json.loads(basket) - except json.JSONDecodeError: - self.error_messages.add(_("The basket cookie was badly formatted.")) - return - - if type(basket) is not list or len(basket) == 0: + if len(basket) == 0: self.error_messages.add(_("Your basket is empty.")) return - + existing_ids = {product.id for product in get_eboutic_products(self.user)} for item in basket: - expected_keys = {"id", "quantity", "name", "unit_price"} - if type(item) is not dict or set(item.keys()) != expected_keys: - self.error_messages.add("One or more items are badly formatted.") - continue - # check the id field is a positive integer - if type(item["id"]) is not int or item["id"] < 0: - self.error_messages.add( - _("%(name)s : this product does not exist.") - % {"name": item["name"]} - ) - continue # check a product with this id does exist - ids = {product.id for product in get_eboutic_products(self.user)} - if not item["id"] in ids: + if item.product_id in existing_ids: + self.correct_items.append(item) + else: self.error_messages.add( _( "%(name)s : this product does not exist or may no longer be available." ) - % {"name": item["name"]} + % {"name": item.name} ) continue - if type(item["quantity"]) is not int or item["quantity"] < 0: - self.error_messages.add( - _("You cannot buy %(nbr)d %(name)s.") - % {"nbr": item["quantity"], "name": item["name"]} - ) - continue - - # if we arrive here, it means this item has passed all tests - self.correct_cookie.append(item) - # for loop for item checking ends here - # this function does not return anything. # instead, it fills a set containing the collected error messages # an empty set means that no error was seen thus everything is ok @@ -174,16 +115,16 @@ class BasketForm: If the `clean()` method has not been called beforehand, call it. """ - if self.error_messages == set() and self.correct_cookie == []: + if not self.error_messages and not self.correct_items: self.clean() if self.error_messages: return False return True - def get_error_messages(self) -> typing.List[str]: + @cached_property + def errors(self) -> list[str]: return list(self.error_messages) - def get_cleaned_cookie(self) -> str: - if not self.correct_cookie: - return "" - return json.dumps(self.correct_cookie) + @cached_property + def cleaned_data(self) -> list[PurchaseItemSchema]: + return self.correct_items diff --git a/eboutic/models.py b/eboutic/models.py index 2ec826d5..d6594eca 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -16,6 +16,7 @@ from __future__ import annotations import hmac from datetime import datetime +from typing import Any from dict2xml import dict2xml from django.conf import settings @@ -38,6 +39,7 @@ def get_eboutic_products(user: User) -> list[Product]: .annotate(priority=F("product_type__priority")) .annotate(category=F("product_type__name")) .annotate(category_comment=F("product_type__comment")) + .prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to` ) return [p for p in products if p.can_be_sold_to(user)] @@ -57,66 +59,25 @@ class Basket(models.Model): def __str__(self): return f"{self.user}'s basket ({self.items.all().count()} items)" - def add_product(self, p: Product, q: int = 1): - """Given p an object of the Product model and q an integer, - add q items corresponding to this Product from the basket. - - If this function is called with a product not in the basket, no error will be raised - """ - item = self.items.filter(product_id=p.id).first() - if item is None: - BasketItem( - basket=self, - product_id=p.id, - product_name=p.name, - type_id=p.product_type.id, - quantity=q, - product_unit_price=p.selling_price, - ).save() - else: - item.quantity += q - item.save() - - def del_product(self, p: Product, q: int = 1): - """Given p an object of the Product model and q an integer - remove q items corresponding to this Product from the basket. - - If this function is called with a product not in the basket, no error will be raised - """ - try: - item = self.items.get(product_id=p.id) - except BasketItem.DoesNotExist: - return - item.quantity -= q - if item.quantity <= 0: - item.delete() - else: - item.save() - - def clear(self) -> None: - """Remove all items from this basket without deleting the basket.""" - self.items.all().delete() - @cached_property def contains_refilling_item(self) -> bool: return self.items.filter( type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING ).exists() - def get_total(self) -> float: - total = self.items.aggregate( - total=Sum(F("quantity") * F("product_unit_price")) - )["total"] - return float(total) if total is not None else 0 + @cached_property + def total(self) -> float: + return float( + self.items.aggregate( + total=Sum(F("quantity") * F("product_unit_price"), default=0) + )["total"] + ) @classmethod def from_session(cls, session) -> Basket | None: """The basket stored in the session object, if it exists.""" if "basket_id" in session: - try: - return cls.objects.get(id=session["basket_id"]) - except cls.DoesNotExist: - return None + return cls.objects.filter(id=session["basket_id"]).first() return None def generate_sales(self, counter, seller: User, payment_method: str): @@ -161,18 +122,24 @@ class Basket(models.Model): ) return sales - def get_e_transaction_data(self): + def get_e_transaction_data(self) -> list[tuple[str, Any]]: user = self.user if not hasattr(user, "customer"): raise Customer.DoesNotExist customer = user.customer if not hasattr(user.customer, "billing_infos"): raise BillingInfo.DoesNotExist + cart = { + "shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}} + } + cart = '' + dict2xml( + cart, newlines=False + ) data = [ ("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE), ("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG), ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), - ("PBX_TOTAL", str(int(self.get_total() * 100))), + ("PBX_TOTAL", str(int(self.total * 100))), ("PBX_DEVISE", "978"), # This is Euro ("PBX_CMD", str(self.id)), ("PBX_PORTEUR", user.email), @@ -181,14 +148,6 @@ class Basket(models.Model): ("PBX_TYPEPAIEMENT", "CARTE"), ("PBX_TYPECARTE", "CB"), ("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")), - ] - cart = { - "shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}} - } - cart = '' + dict2xml( - cart, newlines=False - ) - data += [ ("PBX_SHOPPINGCART", cart), ("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()), ] @@ -218,10 +177,11 @@ class Invoice(models.Model): return f"{self.user} - {self.get_total()} - {self.date}" def get_total(self) -> float: - total = self.items.aggregate( - total=Sum(F("quantity") * F("product_unit_price")) - )["total"] - return float(total) if total is not None else 0 + return float( + self.items.aggregate( + total=Sum(F("quantity") * F("product_unit_price"), default=0) + )["total"] + ) def validate(self): if self.validated: @@ -284,7 +244,7 @@ class BasketItem(AbstractBaseItem): ) @classmethod - def from_product(cls, product: Product, quantity: int): + def from_product(cls, product: Product, quantity: int, basket: Basket): """Create a BasketItem with the same characteristics as the product passed in parameters, with the specified quantity. @@ -293,9 +253,10 @@ class BasketItem(AbstractBaseItem): it yourself before saving the model. """ return cls( + basket=basket, product_id=product.id, product_name=product.name, - type_id=product.product_type.id, + type_id=product.product_type_id, quantity=quantity, product_unit_price=product.selling_price, ) diff --git a/eboutic/schemas.py b/eboutic/schemas.py new file mode 100644 index 00000000..c0333454 --- /dev/null +++ b/eboutic/schemas.py @@ -0,0 +1,33 @@ +from ninja import ModelSchema, Schema +from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter + +from counter.models import BillingInfo + + +class PurchaseItemSchema(Schema): + product_id: NonNegativeInt = Field(alias="id") + name: str + unit_price: float + quantity: PositiveInt + + +# The eboutic deals with data that is dict mixed with JSON. +# Hence it would be a hassle to manage it with a proper Schema class, +# and we use a TypeAdapter instead +PurchaseItemList = TypeAdapter(list[PurchaseItemSchema]) + + +class BillingInfoSchema(ModelSchema): + class Meta: + model = BillingInfo + fields = [ + "customer", + "first_name", + "last_name", + "address_1", + "address_2", + "zip_code", + "city", + "country", + ] + fields_optional = ["customer"] diff --git a/eboutic/static/eboutic/js/eboutic.js b/eboutic/static/eboutic/js/eboutic.js index a1a96db2..7ca1300b 100644 --- a/eboutic/static/eboutic/js/eboutic.js +++ b/eboutic/static/eboutic/js/eboutic.js @@ -33,13 +33,16 @@ function get_starting_items() { let output = []; try { - // Django cookie backend does an utter mess on non-trivial data types - // so we must perform a conversion of our own - const biscuit = JSON.parse(cookie.replace(/\\054/g, ',')); - output = Array.isArray(biscuit) ? biscuit : []; - - } catch (e) {} - + // Django cookie backend converts `,` to `\054` + let parsed = JSON.parse(cookie.replace(/\\054/g, ',')); + if (typeof parsed === "string") { + // In some conditions, a second parsing is needed + parsed = JSON.parse(parsed); + } + output = Array.isArray(parsed) ? parsed : []; + } catch (e) { + console.error(e); + } output.forEach(item => { let el = document.getElementById(item.id); el.classList.add("selected"); @@ -63,7 +66,7 @@ document.addEventListener('alpine:init', () => { /** * Add 1 to the quantity of an item in the basket - * @param {BasketItem} item + * @param {BasketItem} item */ add(item) { item.quantity++; @@ -72,11 +75,11 @@ document.addEventListener('alpine:init', () => { /** * Remove 1 to the quantity of an item in the basket - * @param {BasketItem} item_id + * @param {BasketItem} item_id */ remove(item_id) { const index = this.items.findIndex(e => e.id === item_id); - + if (index < 0) return; this.items[index].quantity -= 1; diff --git a/eboutic/static/eboutic/js/makecommand.js b/eboutic/static/eboutic/js/makecommand.js index 2326cf88..5fd40e1e 100644 --- a/eboutic/static/eboutic/js/makecommand.js +++ b/eboutic/static/eboutic/js/makecommand.js @@ -1,71 +1,77 @@ -document.addEventListener('alpine:init', () => { - Alpine.store('bank_payment_enabled', false) +/** + * @readonly + * @enum {number} + */ +const BillingInfoReqState = { + SUCCESS: 1, + FAILURE: 2 +}; - Alpine.store('billing_inputs', { - data: JSON.parse(et_data)["data"], + +document.addEventListener("alpine:init", () => { + Alpine.store("bank_payment_enabled", false) + + Alpine.store("billing_inputs", { + data: et_data, async fill() { document.getElementById("bank-submit-button").disabled = true; - const request = new Request(et_data_url, { - method: "GET", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - }); - const res = await fetch(request); + const res = await fetch(et_data_url); if (res.ok) { - const json = await res.json(); - if (json["data"]) { - this.data = json["data"]; - } + this.data = await res.json(); document.getElementById("bank-submit-button").disabled = false; } } }) - Alpine.data('billing_infos', () => ({ - errors: [], - successful: false, - url: billing_info_exist ? edit_billing_info_url : create_billing_info_url, + Alpine.data("billing_infos", () => ({ + /** @type {BillingInfoReqState | null} */ + req_state: null, async send_form() { const form = document.getElementById("billing_info_form"); const submit_button = form.querySelector("input[type=submit]") submit_button.disabled = true; document.getElementById("bank-submit-button").disabled = true; - this.successful = false + this.req_state = null; - let payload = {}; - for (const elem of form.querySelectorAll("input")) { - if (elem.type === "text" && elem.value) { - payload[elem.name] = elem.value; - } - } + let payload = form.querySelectorAll("input") + .values() + .filter((elem) => elem.type === "text" && elem.value) + .reduce((acc, curr) => acc[curr.name] = curr.value, {}); const country = form.querySelector("select"); if (country && country.value) { payload[country.name] = country.value; } - const request = new Request(this.url, { - method: "POST", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-CSRFToken': getCSRFToken(), - }, + const res = await fetch(billing_info_url, { + method: "PUT", body: JSON.stringify(payload), }); - const res = await fetch(request); - const json = await res.json(); - if (json["errors"]) { - this.errors = json["errors"]; - } else { - this.errors = []; - this.successful = true; - this.url = edit_billing_info_url; + this.req_state = res.ok ? BillingInfoReqState.SUCCESS : BillingInfoReqState.FAILURE; + if (res.ok) { Alpine.store("billing_inputs").fill(); } submit_button.disabled = false; + }, + + get_alert_color() { + if (this.req_state === BillingInfoReqState.SUCCESS) { + return "green"; + } + if (this.req_state === BillingInfoReqState.FAILURE) { + return "red"; + } + return ""; + }, + + get_alert_message() { + if (this.req_state === BillingInfoReqState.SUCCESS) { + return billing_info_success_message; + } + if (this.req_state === BillingInfoReqState.FAILURE) { + return billing_info_failure_message; + } + return ""; } })) }) diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index 10bff7cb..f372938e 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -29,7 +29,6 @@ {% for error in errors %}

{{ error }}

{% endfor %} - {% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %} {% endif %} diff --git a/eboutic/templates/eboutic/eboutic_makecommand.jinja b/eboutic/templates/eboutic/eboutic_makecommand.jinja index 24032be0..d83628d4 100644 --- a/eboutic/templates/eboutic/eboutic_makecommand.jinja +++ b/eboutic/templates/eboutic/eboutic_makecommand.jinja @@ -37,7 +37,7 @@

- {% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} € + {% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} € {% if customer_amount != None %}
@@ -47,49 +47,53 @@ {% if not basket.contains_refilling_item %}
{% trans %}Remaining account amount: {% endtrans %} - {{ "%0.2f"|format(customer_amount|float - basket.get_total()) }} € + {{ "%0.2f"|format(customer_amount|float - basket.total) }} € {% endif %} {% endif %}


{% if settings.SITH_EBOUTIC_CB_ENABLED %} -
+
- {% trans %}Edit billing information{% endtrans %} + {% trans %}Billing information{% endtrans %}
-
+ {% csrf_token %} {{ billing_form }}

-
-
- -
-
+
+
+
-
-
- Informations de facturation enregistrées -
-
- -
-
- +

@@ -102,12 +106,15 @@

{% endif %}
-