From 5c2f324e13e6ba2d79e239664b7d0b7446e22ba4 Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 11 Apr 2025 00:48:13 +0200 Subject: [PATCH] Use htmx to fill up billing info --- counter/forms.py | 1 + eboutic/api.py | 27 ++---- eboutic/models.py | 29 ++++++- .../bundled/eboutic/makecommand-index.ts | 84 ++---------------- .../eboutic/eboutic_billing_info.jinja | 50 +++++++++++ .../eboutic/eboutic_makecommand.jinja | 87 +++---------------- eboutic/urls.py | 6 +- eboutic/views.py | 72 ++++++++------- 8 files changed, 143 insertions(+), 213 deletions(-) create mode 100644 eboutic/templates/eboutic/eboutic_billing_info.jinja diff --git a/counter/forms.py b/counter/forms.py index 27dc74d7..10109b4d 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -43,6 +43,7 @@ class BillingInfoForm(forms.ModelForm): ] widgets = { "phone_number": RegionalPhoneNumberWidget, + "country": AutoCompleteSelect, } 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..60dddb91 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,28 @@ 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) -> BillingInfoState: + 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 +150,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..b498e234 --- /dev/null +++ b/eboutic/templates/eboutic/eboutic_billing_info.jinja @@ -0,0 +1,50 @@ + +
+
+ + {% trans %}Billing information{% endtrans %} + + + + +
+
+ {% csrf_token %} + {{ form.as_p() }} + +
+
+ +
+ + {% if billing_infos_state == BillingInfoState.EMPTY %} +
+ {% trans trimmed %} + You must fill your billing infos if you want to pay with your credit card + {% endtrans %} +
+ {% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %} +
+ {% trans trimmed %} + The Crédit Agricole changed its policy related to the billing + information that must be provided in order to pay with a credit card. + If you want to pay with your credit card, you must add a phone number + to the data you already provided. + {% endtrans %} +
+ {% endif %} +
diff --git a/eboutic/templates/eboutic/eboutic_makecommand.jinja b/eboutic/templates/eboutic/eboutic_makecommand.jinja index 62053af5..d8e7dac2 100644 --- a/eboutic/templates/eboutic/eboutic_makecommand.jinja +++ b/eboutic/templates/eboutic/eboutic_makecommand.jinja @@ -15,7 +15,11 @@ {% block content %}

{% trans %}Eboutic{% endtrans %}

-
+ + +

{% trans %}Basket: {% endtrans %}

@@ -53,80 +57,21 @@


{% if settings.SITH_EBOUTIC_CB_ENABLED %} -
-
- - {% trans %}Billing information{% endtrans %} - - - - -
-
- {% csrf_token %} - {{ billing_form }} -
-
-
-
- -
-
- - +
+ {{ billing_infos_form }}
-
- {% if billing_infos_state == BillingInfoState.EMPTY %} -
- {% trans trimmed %} - You must fill your billing infos if you want to pay with your credit card - {% endtrans %} -
- {% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %} -
- {% trans trimmed %} - The Crédit Agricole changed its policy related to the billing - information that must be provided in order to pay with a credit card. - If you want to pay with your credit card, you must add a phone number - to the data you already provided. - {% endtrans %} -
- {% endif %}
@@ -143,16 +88,4 @@ {% endif %}
-{% endblock %} - -{% block script %} - - {{ super() }} -{% endblock %} - +{% endblock %} \ No newline at end of file diff --git a/eboutic/urls.py b/eboutic/urls.py index 968f814e..a8029b1c 100644 --- a/eboutic/urls.py +++ b/eboutic/urls.py @@ -17,7 +17,7 @@ # details. # # You should have received a copy of the GNU General Public License along with -# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple +# this program; if not, write to the Free Software Foundation, Inc., 59 Temple # Place - Suite 330, Boston, MA 02111-1307, USA. # # @@ -26,9 +26,9 @@ from django.urls import path, register_converter from eboutic.converters import PaymentResultConverter from eboutic.views import ( + BillingInfoFormFragment, EbouticCommand, EtransactionAutoAnswer, - e_transaction_data, eboutic_main, pay_with_sith, payment_result, @@ -40,9 +40,9 @@ urlpatterns = [ # Subscription views path("", eboutic_main, name="main"), path("command/", EbouticCommand.as_view(), name="command"), + path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"), path("pay/sith/", pay_with_sith, name="pay_with_sith"), path("pay//", payment_result, name="payment_result"), - path("et_data/", e_transaction_data, name="et_data"), path( "et_autoanswer", EtransactionAutoAnswer.as_view(), diff --git a/eboutic/views.py b/eboutic/views.py index dfa79c22..1bf0d12f 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -13,10 +13,11 @@ # # +from __future__ import annotations + import base64 -import json +import contextlib from datetime import datetime -from enum import Enum from typing import TYPE_CHECKING import sentry_sdk @@ -33,16 +34,19 @@ from django.core.exceptions import SuspiciousOperation from django.db import DatabaseError, transaction from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect, render +from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.http import require_GET, require_POST -from django.views.generic import TemplateView, View +from django.views.generic import TemplateView, UpdateView, View +from core.views.mixins import FragmentMixin, UseFragmentsMixin from counter.forms import BillingInfoForm -from counter.models import Counter, Customer, Product +from counter.models import BillingInfo, Counter, Customer, Product from eboutic.forms import BasketForm from eboutic.models import ( Basket, BasketItem, + BillingInfoState, Invoice, InvoiceItem, get_eboutic_products, @@ -88,15 +92,38 @@ def payment_result(request, result: str) -> HttpResponse: return render(request, "eboutic/eboutic_payment_result.jinja", context) -class BillingInfoState(Enum): - VALID = 1 - EMPTY = 2 - MISSING_PHONE_NUMBER = 3 +class BillingInfoFormFragment(LoginRequiredMixin, FragmentMixin, UpdateView): + """Update billing info""" + + model = BillingInfo + form_class = BillingInfoForm + template_name = "eboutic/eboutic_billing_info.jinja" + + def get_object(self, *args, **kwargs): + customer, _ = Customer.get_or_create(self.request.user) + if not hasattr(customer, "billing_infos"): + customer.billing_infos = BillingInfo() + return customer.billing_infos + + def get_context_data(self, **kwargs): + if not hasattr(self, "object"): + self.object = self.get_object() + kwargs = super().get_context_data(**kwargs) + kwargs["action"] = reverse("eboutic:billing_infos") + kwargs["BillingInfoState"] = BillingInfoState + kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object) + return kwargs + + def get_success_url(self, **kwargs): + return self.request.path -class EbouticCommand(LoginRequiredMixin, TemplateView): +class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView): template_name = "eboutic/eboutic_makecommand.jinja" basket: Basket + fragments = { + "billing_infos_form": BillingInfoFormFragment, + } @method_decorator(login_required) def post(self, request, *args, **kwargs): @@ -134,6 +161,7 @@ class EbouticCommand(LoginRequiredMixin, TemplateView): return super().get(request) def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) default_billing_info = None if hasattr(self.request.user, "customer"): customer = self.request.user.customer @@ -142,34 +170,14 @@ class EbouticCommand(LoginRequiredMixin, TemplateView): default_billing_info = customer.billing_infos else: kwargs["customer_amount"] = None - # make the enum available in the template - kwargs["BillingInfoState"] = BillingInfoState - if default_billing_info is None: - kwargs["billing_infos_state"] = BillingInfoState.EMPTY - elif default_billing_info.phone_number is None: - kwargs["billing_infos_state"] = BillingInfoState.MISSING_PHONE_NUMBER - else: - kwargs["billing_infos_state"] = BillingInfoState.VALID - if kwargs["billing_infos_state"] == BillingInfoState.VALID: - # the user has already filled all of its billing_infos, thus we can - # get it without expecting an error - kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data()) kwargs["basket"] = self.basket kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info) + kwargs["billing_infos"] = {} + with contextlib.suppress(BillingInfo.DoesNotExist): + kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data()) return kwargs -@login_required -@require_GET -def e_transaction_data(request): - basket = Basket.from_session(request.session) - if basket is None: - return HttpResponse(status=404, content=json.dumps({"data": []})) - data = basket.get_e_transaction_data() - data = {"data": [{"key": key, "value": val} for key, val in data]} - return HttpResponse(status=200, content=json.dumps(data)) - - @login_required @require_POST def pay_with_sith(request):