Merge pull request #1076 from ae-utbm/eboutic-billing

Use htmx to fill up billing info
This commit is contained in:
Bartuccio Antoine 2025-04-13 09:52:49 +02:00 committed by GitHub
commit b3eb6a945f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 361 additions and 408 deletions

View File

@ -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;

View File

@ -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.

View File

@ -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
try:
return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e:
raise NotFound from e

View File

@ -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)}}

View File

@ -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 "";
},
}));
});

View File

@ -0,0 +1,42 @@
<div id=billing-infos-fragment>
<div
class="collapse"
:class="{'shadow': collapsed}"
x-data="{collapsed: {{ "true" if messages or form.errors else "false" }}}"
>
<div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text">
{% trans %}Billing information{% endtrans %}
</span>
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
<i class="fa fa-caret-down"></i>
</span>
</div>
<form
class="collapse-body"
hx-trigger="submit"
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#billing-infos-fragment"
x-show="collapsed"
>
{% csrf_token %}
{{ form.as_p() }}
<br>
<input
type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}"
>
</form>
</div>
<br>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
</div>

View File

@ -66,7 +66,6 @@
{% trans %}Clear{% endtrans %}
</button>
<form method="get" action="{{ url('eboutic:command') }}">
{% csrf_token %}
<button class="btn btn-blue">
<i class="fa fa-check"></i>
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>

View File

@ -15,7 +15,11 @@
{% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<div>
<script type="text/javascript">
let billingInfos = {{ billing_infos|safe }};
</script>
<div x-data="etransaction(billingInfos)">
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
@ -53,80 +57,22 @@
</p>
<br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div
class="collapse"
:class="{'shadow': collapsed}"
x-data="{collapsed: !{{ "true" if billing_infos else "false" }}}"
x-cloak
>
<div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text">
{% trans %}Billing information{% endtrans %}
</span>
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
<i class="fa fa-caret-down"></i>
</span>
<div @htmx:after-request="fill">
{{ billing_infos_form }}
</div>
<form
class="collapse-body"
id="billing_info_form"
x-data="billing_infos({{ user.id }})"
x-show="collapsed"
x-transition.scale.origin.top
@submit.prevent="await sendForm()"
>
{% csrf_token %}
{{ billing_form }}
<br />
<div
x-show="[BillingInfoReqState.Success, BillingInfoReqState.Failure].includes(reqState)"
class="alert"
:class="'alert-' + getAlertColor()"
x-transition
>
<div class="alert-main" x-text="getAlertMessage()"></div>
<div class="clickable" @click="reqState = null">
<i class="fa fa-close"></i>
</div>
</div>
<input
type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}"
:disabled="reqState === BillingInfoReqState.Sending"
>
</form>
</div>
<br>
{% if billing_infos_state == BillingInfoState.EMPTY %}
<div class="alert alert-yellow">
{% trans trimmed %}
You must fill your billing infos if you want to pay with your credit card
{% endtrans %}
</div>
{% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
<div class="alert alert-yellow">
{% 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 %}
</div>
{% endif %}
<form
method="post"
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
name="bank-pay-form"
x-data="etransactionData(initialEtData)"
@billing-infos-filled.window="await fill()"
>
<template x-for="[key, value] in Object.entries(data)" :key="key">
<input type="hidden" :name="key" :value="value">
</template>
<input
x-cloak
type="submit"
id="bank-submit-button"
{% if billing_infos_state != BillingInfoState.VALID %}disabled="disabled"{% endif %}
:disabled="!isCbAvailable"
class="btn btn-blue"
value="{% trans %}Pay with credit card{% endtrans %}"
/>
</form>
@ -139,20 +85,8 @@
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
{% csrf_token %}
<input type="hidden" name="action" value="pay_with_sith_account">
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
</form>
{% endif %}
</div>
{% endblock %}
{% block script %}
<script>
{% if billing_infos -%}
const initialEtData = {{ billing_infos|safe }}
{%- else -%}
const initialEtData = {}
{%- endif %}
</script>
{{ super() }}
{% endblock %}

View File

@ -0,0 +1,138 @@
from typing import Callable
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import User
from counter.models import BillingInfo
@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_not_authorized(self, client: Client, payload: dict[str, str]):
response = client.post(
reverse("eboutic:billing_infos"),
payload,
)
assertRedirects(
response,
reverse("core:login", query={"next": reverse("eboutic:billing_infos")}),
)
def test_edit_infos(self, client: Client, payload: dict[str, str]):
user = subscriber_user.make()
baker.make(BillingInfo, customer=user.customer)
client.force_login(user)
response = client.post(
reverse("eboutic:billing_infos"),
payload,
)
user.refresh_from_db()
infos = BillingInfo.objects.get(customer__user=user)
assert response.status_code == 302
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)]
)
def test_create_infos(
self, client: Client, user_maker: Callable[[], User], payload: dict[str, str]
):
user = user_maker()
client.force_login(user)
assert not BillingInfo.objects.filter(customer__user=user).exists()
response = client.post(
reverse("eboutic:billing_infos"),
payload,
)
assert response.status_code == 302
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.post(
reverse("eboutic:billing_infos"),
payload,
)
assert response.status_code == 200
user.customer.refresh_from_db()
assert not hasattr(user.customer, "billing_infos")
@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[str, str], 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.post(
reverse("eboutic:billing_infos"),
payload,
)
assert response.status_code == 302
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[str, str]):
"""Test that a foreign phone number is accepted."""
user = subscriber_user.make()
client.force_login(user)
payload["phone_number"] = "+49612345678"
response = client.post(
reverse("eboutic:billing_infos"),
payload,
)
assert response.status_code == 302
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[str, str], 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.post(
reverse("eboutic:billing_infos"),
payload,
)
assert response.status_code == 200
assert not BillingInfo.objects.filter(customer__user=user).exists()

View File

@ -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/<res:result>/", payment_result, name="payment_result"),
path("et_data/", e_transaction_data, name="et_data"),
path(
"et_autoanswer",
EtransactionAutoAnswer.as_view(),

View File

@ -13,10 +13,12 @@
#
#
from __future__ import annotations
import base64
import contextlib
import json
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
import sentry_sdk
@ -25,24 +27,32 @@ from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import (
LoginRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation
from django.db import DatabaseError, transaction
from django.db.utils import cached_property
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.utils.translation import gettext_lazy as _
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 django_countries.fields import Country
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,
@ -51,6 +61,7 @@ from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from django.utils.html import SafeString
@login_required
@ -88,15 +99,74 @@ 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, SuccessMessageMixin, UpdateView
):
"""Update billing info"""
model = BillingInfo
form_class = BillingInfoForm
template_name = "eboutic/eboutic_billing_info.jinja"
success_message = _("Billing info registration success")
def get_initial(self):
if self.object is None:
return {
"country": Country(code="FR"),
}
return {}
def render_fragment(self, request, **kwargs) -> SafeString:
self.object = self.get_object()
return super().render_fragment(request, **kwargs)
@cached_property
def customer(self) -> Customer:
return Customer.get_or_create(self.request.user)[0]
def form_valid(self, form: BillingInfoForm):
form.instance.customer = self.customer
return super().form_valid(form)
def get_object(self, *args, **kwargs):
# if a BillingInfo already exists, this view will behave like an UpdateView
# otherwise, it will behave like a CreateView.
return getattr(self.customer, "billing_infos", None)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
kwargs["action"] = reverse("eboutic:billing_infos")
match BillingInfoState.from_model(self.object):
case BillingInfoState.EMPTY:
messages.warning(
self.request,
_(
"You must fill your billing infos if you want to pay with your credit card"
),
)
case BillingInfoState.MISSING_PHONE_NUMBER:
messages.warning(
self.request,
_(
"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.",
),
)
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,42 +204,22 @@ class EbouticCommand(LoginRequiredMixin, TemplateView):
return super().get(request)
def get_context_data(self, **kwargs):
default_billing_info = None
kwargs = super().get_context_data(**kwargs)
if hasattr(self.request.user, "customer"):
customer = self.request.user.customer
kwargs["customer_amount"] = customer.amount
if hasattr(customer, "billing_infos"):
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"] = json.dumps(
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):

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-08 16:20+0200\n"
"POT-Creation-Date: 2025-04-13 00:18+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -3770,6 +3770,15 @@ msgstr "panier"
msgid "invoice"
msgstr "facture"
#: eboutic/templates/eboutic/eboutic_billing_info.jinja
msgid "Billing information"
msgstr "Informations de facturation"
#: eboutic/templates/eboutic/eboutic_billing_info.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Validate"
msgstr "Valider"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Current account amount: "
@ -3784,11 +3793,6 @@ msgstr "Valeur du panier : "
msgid "Clear"
msgstr "Vider"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Validate"
msgstr "Valider"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You have not filled in your date of birth. As a result, you may not have "
@ -3837,29 +3841,6 @@ msgstr "État du panier"
msgid "Remaining account amount: "
msgstr "Solde restant : "
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Billing information"
msgstr "Informations de facturation"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"You must fill your billing infos if you want to pay with your credit card"
msgstr ""
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
"par carte bancaire"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"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."
msgstr ""
"Le Crédit Agricole a changé sa politique relative aux informations à "
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire"
@ -3893,6 +3874,29 @@ msgstr "Le paiement a échoué"
msgid "Return to eboutic"
msgstr "Retourner à l'eboutic"
#: eboutic/views.py
msgid "Billing info registration success"
msgstr "Informations de facturation enregistrées"
#: eboutic/views.py
msgid ""
"You must fill your billing infos if you want to pay with your credit card"
msgstr ""
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
"par carte bancaire"
#: eboutic/views.py
msgid ""
"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."
msgstr ""
"Le Crédit Agricole a changé sa politique relative aux informations à "
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni."
#: election/models.py
msgid "start candidature"
msgstr "début des candidatures"

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-09 23:23+0200\n"
"POT-Creation-Date: 2025-04-13 00:18+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -247,18 +247,6 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/bundled/eboutic/makecommand-index.ts
msgid "Incorrect value"
msgstr "Valeur incorrecte"
#: eboutic/static/bundled/eboutic/makecommand-index.ts
msgid "Billing info registration success"
msgstr "Informations de facturation enregistrées"
#: eboutic/static/bundled/eboutic/makecommand-index.ts
msgid "Billing info registration failure"
msgstr "Echec de l'enregistrement des informations de facturation."
#: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"