mirror of
https://github.com/ae-utbm/sith.git
synced 2025-04-17 03:20:20 +00:00
Merge pull request #1076 from ae-utbm/eboutic-billing
Use htmx to fill up billing info
This commit is contained in:
commit
b3eb6a945f
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)}}
|
||||
|
@ -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 "";
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
42
eboutic/templates/eboutic/eboutic_billing_info.jinja
Normal file
42
eboutic/templates/eboutic/eboutic_billing_info.jinja
Normal 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>
|
@ -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 %}"/>
|
||||
|
@ -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 %}
|
||||
|
||||
|
138
eboutic/tests/test_billing_info.py
Normal file
138
eboutic/tests/test_billing_info.py
Normal 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()
|
@ -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(),
|
||||
|
120
eboutic/views.py
120
eboutic/views.py
@ -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):
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user