diff --git a/core/static/core/style.scss b/core/static/core/style.scss index a87ed3a3..37217080 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -361,19 +361,22 @@ body { align-items: center; text-align: justify; - &.alert-yellow { + &.alert-yellow, + &.alert-warning { background-color: rgb(255, 255, 240); color: rgb(99, 87, 6); border: rgb(192, 180, 16) 1px solid; } - &.alert-green { + &.alert-green, + &.alert-success { background-color: rgb(245, 255, 245); color: rgb(3, 84, 63); border: rgb(14, 159, 110) 1px solid; } - &.alert-red { + &.alert-red, + &.alert-error { background-color: rgb(255, 245, 245); color: #c53030; border: #fc8181 1px solid; diff --git a/counter/tests/test_customer.py b/counter/tests/test_customer.py index 56daccb1..3676a701 100644 --- a/counter/tests/test_customer.py +++ b/counter/tests/test_customer.py @@ -1,5 +1,4 @@ import itertools -import json import string from datetime import timedelta @@ -16,7 +15,6 @@ from core.baker_recipes import board_user, subscriber_user from core.models import User from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.models import ( - BillingInfo, Counter, Customer, Refilling, @@ -26,149 +24,6 @@ from counter.models import ( ) -@pytest.mark.django_db -class TestBillingInfo: - @pytest.fixture - def payload(self): - return { - "first_name": "Subscribed", - "last_name": "User", - "address_1": "3, rue de Troyes", - "zip_code": "34301", - "city": "Sète", - "country": "FR", - "phone_number": "0612345678", - } - - 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.refresh_from_db() - infos = BillingInfo.objects.get(customer__user=user) - assert response.status_code == 200 - assert hasattr(user.customer, "billing_infos") - assert infos.customer == user.customer - for key, val in payload.items(): - assert getattr(infos, key) == val - - @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", - ) - assert response.status_code == 200 - user.refresh_from_db() - assert hasattr(user, "customer") - infos = BillingInfo.objects.get(customer__user=user) - assert hasattr(user.customer, "billing_infos") - assert infos.customer == user.customer - for key, val in payload.items(): - assert getattr(infos, key) == val - - def test_invalid_data(self, client: Client, payload: dict[str, str]): - 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 - - @pytest.mark.parametrize( - "phone_number", - ["+33612345678", "0612345678", "06 12 34 56 78", "06-12-34-56-78"], - ) - def test_phone_number_format( - self, client: Client, payload: dict, phone_number: str - ): - """Test that various formats of phone numbers are accepted.""" - user = subscriber_user.make() - client.force_login(user) - payload["phone_number"] = phone_number - response = client.put( - reverse("api:put_billing_info", args=[user.id]), - json.dumps(payload), - content_type="application/json", - ) - assert response.status_code == 200 - infos = BillingInfo.objects.get(customer__user=user) - assert infos.phone_number == "0612345678" - assert infos.phone_number.country_code == 33 - - def test_foreign_phone_number(self, client: Client, payload: dict): - """Test that a foreign phone number is accepted.""" - user = subscriber_user.make() - client.force_login(user) - payload["phone_number"] = "+49612345678" - response = client.put( - reverse("api:put_billing_info", args=[user.id]), - json.dumps(payload), - content_type="application/json", - ) - assert response.status_code == 200 - infos = BillingInfo.objects.get(customer__user=user) - assert infos.phone_number.as_national == "06123 45678" - assert infos.phone_number.country_code == 49 - - @pytest.mark.parametrize( - "phone_number", ["061234567a", "06 12 34 56", "061234567879", "azertyuiop"] - ) - def test_invalid_phone_number( - self, client: Client, payload: dict, phone_number: str - ): - """Test that invalid phone numbers are rejected.""" - user = subscriber_user.make() - client.force_login(user) - payload["phone_number"] = phone_number - response = client.put( - reverse("api:put_billing_info", args=[user.id]), - json.dumps(payload), - content_type="application/json", - ) - assert response.status_code == 422 - assert not BillingInfo.objects.filter(customer__user=user).exists() - - class TestStudentCard(TestCase): """Tests for adding and deleting Stundent Cards Test that an user can be found with it's student card. diff --git a/eboutic/api.py b/eboutic/api.py index 797adf20..3c2a1dc2 100644 --- a/eboutic/api.py +++ b/eboutic/api.py @@ -1,31 +1,13 @@ -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.exceptions import NotFound from ninja_extra.permissions import IsAuthenticated -from pydantic import NonNegativeInt -from core.models import User -from counter.models import BillingInfo, Customer +from counter.models import BillingInfo 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") def fetch_etransaction_data(self): """Generate the data to pay an eboutic command with paybox. @@ -35,4 +17,7 @@ class EtransactionInfoController(ControllerBase): basket = Basket.from_session(self.context.request.session) if basket is None: raise NotFound - return dict(basket.get_e_transaction_data()) + try: + return dict(basket.get_e_transaction_data()) + except BillingInfo.DoesNotExist as e: + raise NotFound from e diff --git a/eboutic/models.py b/eboutic/models.py index cd55d0ae..b6daaf34 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -16,6 +16,7 @@ from __future__ import annotations import hmac from datetime import datetime +from enum import Enum from typing import Any, Self from dict2xml import dict2xml @@ -44,6 +45,30 @@ def get_eboutic_products(user: User) -> list[Product]: return [p for p in products if p.can_be_sold_to(user)] +class BillingInfoState(Enum): + VALID = 1 + EMPTY = 2 + MISSING_PHONE_NUMBER = 3 + + @classmethod + def from_model(cls, info: BillingInfo | None) -> BillingInfoState: + if info is None: + return cls.EMPTY + for attr in [ + "first_name", + "last_name", + "address_1", + "zip_code", + "city", + "country", + ]: + if getattr(info, attr) == "": + return cls.EMPTY + if info.phone_number is None: + return cls.MISSING_PHONE_NUMBER + return cls.VALID + + class Basket(models.Model): """Basket is built when the user connects to an eboutic page.""" @@ -127,7 +152,11 @@ class Basket(models.Model): if not hasattr(user, "customer"): raise Customer.DoesNotExist customer = user.customer - if not hasattr(user.customer, "billing_infos"): + if ( + not hasattr(user.customer, "billing_infos") + or BillingInfoState.from_model(user.customer.billing_infos) + != BillingInfoState.VALID + ): raise BillingInfo.DoesNotExist cart = { "shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}} diff --git a/eboutic/static/bundled/eboutic/makecommand-index.ts b/eboutic/static/bundled/eboutic/makecommand-index.ts index c1e4b52f..47e81cb6 100644 --- a/eboutic/static/bundled/eboutic/makecommand-index.ts +++ b/eboutic/static/bundled/eboutic/makecommand-index.ts @@ -1,91 +1,17 @@ -import { exportToHtml } from "#core:utils/globals"; -import { - type BillingInfoSchema, - etransactioninfoFetchEtransactionData, - etransactioninfoPutUserBillingInfo, -} from "#openapi"; - -enum BillingInfoReqState { - Success = "0", - Failure = "1", - Sending = "2", -} - -exportToHtml("BillingInfoReqState", BillingInfoReqState); +import { etransactioninfoFetchEtransactionData } from "#openapi"; document.addEventListener("alpine:init", () => { - Alpine.data("etransactionData", (initialData) => ({ + Alpine.data("etransaction", (initialData) => ({ data: initialData, + isCbAvailable: Object.keys(initialData).length > 0, async fill() { - const button = document.getElementById("bank-submit-button") as HTMLButtonElement; - button.disabled = true; + this.isCbAvailable = false; const res = await etransactioninfoFetchEtransactionData(); if (res.response.ok) { this.data = res.data; - button.disabled = false; + this.isCbAvailable = true; } }, })); - - Alpine.data("billing_infos", (userId: number) => ({ - /** @type {BillingInfoReqState | null} */ - reqState: null, - - async sendForm() { - this.reqState = BillingInfoReqState.Sending; - const form = document.getElementById("billing_info_form"); - const submitButton = document.getElementById( - "bank-submit-button", - ) as HTMLButtonElement; - submitButton.disabled = true; - const payload = Object.fromEntries( - Array.from(form.querySelectorAll("input, select")) - .filter((elem: HTMLInputElement) => elem.type !== "submit" && elem.value) - .map((elem: HTMLInputElement) => [elem.name, elem.value]), - ); - const res = await etransactioninfoPutUserBillingInfo({ - // biome-ignore lint/style/useNamingConvention: API is snake_case - path: { user_id: userId }, - body: payload as unknown as BillingInfoSchema, - }); - this.reqState = res.response.ok - ? BillingInfoReqState.Success - : BillingInfoReqState.Failure; - if (res.response.status === 422) { - const errors = await res.response - .json() - .detail.flatMap((err: Record<"loc", string>) => err.loc); - for (const elem of Array.from(form.querySelectorAll("input")).filter((elem) => - errors.includes(elem.name), - )) { - elem.setCustomValidity(gettext("Incorrect value")); - elem.reportValidity(); - elem.oninput = () => elem.setCustomValidity(""); - } - } else if (res.response.ok) { - this.$dispatch("billing-infos-filled"); - } - }, - - getAlertColor() { - if (this.reqState === BillingInfoReqState.Success) { - return "green"; - } - if (this.reqState === BillingInfoReqState.Failure) { - return "red"; - } - return ""; - }, - - getAlertMessage() { - if (this.reqState === BillingInfoReqState.Success) { - return gettext("Billing info registration success"); - } - if (this.reqState === BillingInfoReqState.Failure) { - return gettext("Billing info registration failure"); - } - return ""; - }, - })); }); diff --git a/eboutic/templates/eboutic/eboutic_billing_info.jinja b/eboutic/templates/eboutic/eboutic_billing_info.jinja new file mode 100644 index 00000000..8a5c8a83 --- /dev/null +++ b/eboutic/templates/eboutic/eboutic_billing_info.jinja @@ -0,0 +1,42 @@ +
+
+
+ + {% trans %}Billing information{% endtrans %} + + + + +
+
+ {% csrf_token %} + {{ form.as_p() }} +
+ +
+
+ +
+ + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} +
diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index ee563dd0..1e070b95 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -66,7 +66,6 @@ {% trans %}Clear{% endtrans %}
- {% csrf_token %}