Compare commits

..

11 Commits

Author SHA1 Message Date
dependabot[bot] 7dabbce4df [UPDATE] Update redis requirement from <8.0.0,>=3.3.1 to >=8.0.0,<9.0.0
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/3.3.1...v8.0.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 8.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 01:40:18 +00:00
thomas girod e811aeaecd Merge pull request #1412 from ae-utbm/improve-mobile-counter
Improve counter click on smartphones
2026-05-31 11:48:07 +02:00
thomas girod 549a778be0 Merge pull request #1411 from ae-utbm/fix-club-role
fix: forgotten group assignation on club role update
2026-05-31 11:47:40 +02:00
thomas girod 5c42da273b Merge pull request #1392 from ae-utbm/basket-timeout
Basket timeout
2026-05-30 12:56:35 +02:00
thomas girod b8e0294df6 Merge pull request #1410 from ae-utbm/fix-payment-method
fix: wrong payment method for refills with eboutic
2026-05-30 12:41:44 +02:00
imperosol 78b24dc1e7 fix: product research with code 2026-05-28 18:10:56 +02:00
imperosol ebf0196bef improve counter basket item style 2026-05-27 18:22:07 +02:00
imperosol 362b9eea06 automatically add item to basket on counter product search 2026-05-27 18:22:07 +02:00
imperosol 3b3e33ed80 fix: forgotten group assignation on club role update 2026-05-27 12:24:27 +02:00
imperosol 649190debe fix: wrong payment method for refills with eboutic 2026-05-26 23:46:38 +02:00
imperosol 50c880719a feat: basket timeout 2026-05-22 11:38:03 +02:00
18 changed files with 292 additions and 68 deletions
+24
View File
@@ -392,6 +392,30 @@ class ClubRoleForm(forms.ModelForm):
self.instance.order = cleaned_data["ORDER"] - 1 self.instance.order = cleaned_data["ORDER"] - 1
return cleaned_data return cleaned_data
def save(self, commit=True): # noqa: FBT002
instance: ClubRole = super().save(commit=commit)
if commit and "is_board" in self.changed_data:
# if the role was moved from board to simple member,
# remove all users with that role from the club board group.
# If the role became a board role, add users with
# that role to the club board group.
group_id = instance.club.board_group_id
if self.cleaned_data["is_board"]:
User.groups.through.objects.bulk_create(
[
User.groups.through(user_id=u, group_id=group_id)
for u in Membership.objects.ongoing()
.filter(role=instance)
.values_list("user_id", flat=True)
],
ignore_conflicts=True,
)
else:
User.groups.through.objects.filter(
user__memberships__role=instance, group_id=group_id
).delete()
return instance
class ClubRoleCreateForm(forms.ModelForm): class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role. """Form to create a club role.
+28 -1
View File
@@ -4,6 +4,7 @@ import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -239,7 +240,7 @@ class TestClubRoleUpdate(TestCase):
def test_president_moves_itself_out_of_the_presidency(self): def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency, """Test that if the user moves its own role out of the presidency,
then it's redirected to another page and loses access to the update page.""" then it loses access to the update page."""
self.payload["roles-0-is_presidency"] = False self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload) res = self.client.post(self.url, data=self.payload)
@@ -251,3 +252,29 @@ class TestClubRoleUpdate(TestCase):
res = self.client.get(self.url) res = self.client.get(self.url)
assert res.status_code == 403 assert res.status_code == 403
def test_role_stops_being_board(self):
"""Test that if a role stops being a board role,
its users lose the club board group."""
self.payload["roles-0-is_board"] = False
self.payload["roles-0-is_presidency"] = False
self.payload["roles-1-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert not self.user.groups.contains(self.club.board_group)
def test_role_becomes_board(self):
"""Test that if a role becomes a board role,
its active users get the club board group"""
members = [
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
]
self.payload["roles-2-is_board"] = True
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
# the second membership is finished, so its user shouldn't get the role
assert members[0].user.groups.contains(self.club.board_group)
assert not members[1].user.groups.contains(self.club.board_group)
+4
View File
@@ -46,6 +46,10 @@ details.accordion>.accordion-content {
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
overflow: hidden; overflow: hidden;
@media screen and (max-width: 600px) {
padding: .75em 1.5em;
}
} }
@mixin animation($selector) { @mixin animation($selector) {
@@ -1,6 +1,6 @@
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types"; import type { RecursivePartial, TomSettings } from "tom-select/src/types";
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts"; import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
const productParsingRegex = /^(\d+x)?(.*)/i; const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
@@ -63,13 +63,6 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
); );
}, },
); );
this.widget.hook("after", "onOptionSelect", () => {
/* Focus the next element if it's an input */
if (this.nextElementSibling.nodeName === "INPUT") {
(this.nextElementSibling as HTMLInputElement).focus();
}
});
} }
protected tomSelectSettings(): RecursivePartial<TomSettings> { protected tomSelectSettings(): RecursivePartial<TomSettings> {
/* We disable the dropdown on focus because we're going to always autofocus the widget */ /* We disable the dropdown on focus because we're going to always autofocus the widget */
@@ -80,9 +73,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
// We need to manually set weights or it results on an inconsistent // We need to manually set weights or it results on an inconsistent
// behavior between production and development environment // behavior between production and development environment
searchField: [ searchField: [
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 }, { field: "text", weight: 0.5 },
], ],
}; };
@@ -25,6 +25,9 @@ document.addEventListener("alpine:init", () => {
} }
this.codeField = this.$refs.codeField; this.codeField = this.$refs.codeField;
this.codeField.widget.hook("after", "onOptionSelect", () => {
this.handleCode();
});
this.codeField.widget.focus(); this.codeField.widget.focus();
// It's quite tricky to manually apply attributes to the management part // It's quite tricky to manually apply attributes to the management part
@@ -154,6 +157,7 @@ document.addEventListener("alpine:init", () => {
this.addToBasket(code, quantity); this.addToBasket(code, quantity);
} }
this.codeField.widget.clear(); this.codeField.widget.clear();
this.codeField.widget.setTextboxValue("");
this.codeField.widget.focus(); this.codeField.widget.focus();
}, },
})); }));
+22 -1
View File
@@ -42,7 +42,28 @@
min-width: 350px; min-width: 350px;
ul { ul {
list-style-type: none; list-style: none;
display: flex;
flex-direction: column;
gap: .5rem;
margin-left: 0;
.basket-row {
display: flex;
align-items: center;
gap: 1rem;
.product-name {
flex: 1 2 0;
min-width: 0;
text-wrap: wrap;
}
}
}
form {
margin-top: .5rem;
margin-bottom: .5rem;
} }
} }
+27 -18
View File
@@ -56,10 +56,15 @@
<div class="accordion-content"> <div class="accordion-content">
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} {% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
<form method="post" action="" <form method="post" action="" @submit.prevent="handleCode">
class="code_form" @submit.prevent="handleCode">
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}"> <counter-product-select
name="code"
x-ref="codeField"
autofocus
required
placeholder="{% trans %}Select a product...{% endtrans %}"
>
<option value=""></option> <option value=""></option>
<optgroup label="{% trans %}Operations{% endtrans %}"> <optgroup label="{% trans %}Operations{% endtrans %}">
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option> <option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
@@ -68,13 +73,11 @@
{%- for category, prices in categories.items() -%} {%- for category, prices in categories.items() -%}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{%- for price in prices -%} {%- for price in prices -%}
<option value="{{ price.id }}">{{ price.full_label }}</option> <option value="{{ price.id }}">{{ price.full_label }} ({{ price.product.code }})</option>
{%- endfor -%} {%- endfor -%}
</optgroup> </optgroup>
{%- endfor -%} {%- endfor -%}
</counter-product-select> </counter-product-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% for error in form.non_form_errors() %} {% for error in form.non_form_errors() %}
@@ -102,7 +105,9 @@
{{ form.management_form }} {{ form.management_form }}
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li> <li x-show="getBasketSize() === 0">
<em>{% trans %}This basket is empty{% endtrans %}</em>
</li>
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id"> <template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
<li> <li>
<template x-for="error in item.errors"> <template x-for="error in item.errors">
@@ -110,19 +115,23 @@
</div> </div>
</template> </template>
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button> <div class="basket-row">
<span class="quantity" x-text="item.quantity"></span> <div>
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button> <button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
</div>
<span x-text="item.product.name"></span> : <span class="product-name" x-text="item.product.name"></span>
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })"></span> <span x-text="`${item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })} €`"></span>
<span x-show="item.getBonusQuantity() > 0" <span x-show="item.getBonusQuantity() > 0"
x-text="`${item.getBonusQuantity()} x P`"></span> x-text="`${item.getBonusQuantity()} x P`"></span>
<button <button
class="remove-item" class="remove-item"
@click.prevent="removeFromBasket(item.product.price.id)" @click.prevent="removeFromBasket(item.product.price.id)"
><i class="fa fa-trash-can delete-action"></i></button> ><i class="fa fa-trash-can delete-action"></i></button>
</div>
<input <input
type="hidden" type="hidden"
+10 -1
View File
@@ -1,3 +1,6 @@
from typing import Any
from ninja import Status
from ninja_extra import ControllerBase, api_controller, route from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound from ninja_extra.exceptions import NotFound
@@ -8,13 +11,19 @@ from eboutic.models import Basket
@api_controller("/etransaction", permissions=[CanView]) @api_controller("/etransaction", permissions=[CanView])
class EtransactionInfoController(ControllerBase): class EtransactionInfoController(ControllerBase):
@route.get("/data/{basket_id}", url_name="etransaction_data") @route.get(
"/data/{basket_id}",
url_name="etransaction_data",
response={200: dict[str, Any], 410: str},
)
def fetch_etransaction_data(self, basket_id: int): def fetch_etransaction_data(self, basket_id: int):
"""Generate the data to pay an eboutic command with paybox. """Generate the data to pay an eboutic command with paybox.
The data is generated with the basket that is used by the current session. The data is generated with the basket that is used by the current session.
""" """
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id) basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
if basket.is_expired:
return Status(410, "This basket is expired.")
try: try:
return dict(basket.get_e_transaction_data()) return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e: except BillingInfo.DoesNotExist as e:
+26 -7
View File
@@ -24,6 +24,7 @@ from django.conf import settings
from django.db import DataError, models from django.db import DataError, models
from django.db.models import F, OuterRef, Subquery, Sum from django.db.models import F, OuterRef, Subquery, Sum
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
@@ -95,6 +96,10 @@ class Basket(models.Model):
] ]
) )
@property
def is_expired(self) -> bool:
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales( def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod self, counter, seller: User, payment_method: Selling.PaymentMethod
): ):
@@ -133,9 +138,20 @@ class Basket(models.Model):
] ]
def get_e_transaction_data(self) -> list[tuple[str, str]]: def get_e_transaction_data(self) -> list[tuple[str, str]]:
"""Get data for etransaction payment.
Raises:
Customer.DoesNotExist: if the user linked to this basket
has no customer account
BillingInfo.DoesNotExist: if the user linked to this basket has no
billing infos, or incorrect billing infos.
ValueError: if this is called on a basket which payment delay is expired.
"""
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
if self.is_expired:
raise ValueError("This method cannot be called on an expired basket.")
customer = user.customer customer = user.customer
if ( if (
not hasattr(user.customer, "billing_infos") not hasattr(user.customer, "billing_infos")
@@ -155,6 +171,10 @@ class Basket(models.Model):
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.total * 100))), ("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro ("PBX_DEVISE", "978"), # This is Euro
(
"PBX_DISPLAY",
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
),
("PBX_CMD", str(self.id)), ("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email), ("PBX_PORTEUR", user.email),
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"), ("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
@@ -219,16 +239,14 @@ class Invoice(models.Model):
if self.validated: if self.validated:
raise DataError(_("Invoice already validated")) raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user) customer, _created = Customer.get_or_create(user=self.user)
kwargs = { kwargs = {"counter": get_eboutic(), "customer": customer, "date": self.date}
"counter": get_eboutic(),
"customer": customer,
"date": self.date,
"payment_method": Selling.PaymentMethod.CARD,
}
for i in self.items.select_related("product"): for i in self.items.select_related("product"):
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
Refilling.objects.create( Refilling.objects.create(
**kwargs, operator=self.user, amount=i.unit_price * i.quantity **kwargs,
operator=self.user,
amount=i.unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD,
) )
else: else:
Selling.objects.create( Selling.objects.create(
@@ -239,6 +257,7 @@ class Invoice(models.Model):
seller=self.user, seller=self.user,
unit_price=i.unit_price, unit_price=i.unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD,
) )
self.validated = True self.validated = True
self.save() self.save()
@@ -1,21 +1,71 @@
import { type Notification, NotificationLevel } from "#core:utils/notifications";
import { etransactioninfoFetchEtransactionData } from "#openapi"; import { etransactioninfoFetchEtransactionData } from "#openapi";
interface Basket {
id: number;
timeout: Date;
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("etransaction", (initialData, basketId: number) => ({ Alpine.data("etransaction", (initialData, basket: Basket) => ({
data: initialData, data: initialData,
isCbAvailable: Object.keys(initialData).length > 0, isCbAvailable: Object.keys(initialData).length > 0,
isSithAvailable: true,
init() {
const now = new Date();
const timeout = basket.timeout.getTime() - now.getTime();
if (timeout <= 0) {
// basket was already outdated at initial page load
this.timeoutBasket();
} else {
setTimeout(() => this.timeoutBasket(), timeout);
}
},
/**
* Make this basket into a timeout state.
* All submission inputs are disabled, and an error message is displayed.
*/
timeoutBasket() {
this.isCbAvailable = false;
this.isSithAvailable = false;
const message = gettext("Basket expired");
const existingNotif: Notification | undefined = this.$notifications
.getAll()
.find(
(n: Notification) =>
n.tag === NotificationLevel.Error && n.message === message,
);
if (existingNotif === undefined) {
this.$notifications.error(message);
}
},
/**
* Refresh the data used for etransaction.
*
* Note: if this is called while the basket is expired, it will be a no-op
*/
async fill() { async fill() {
if (new Date() > basket.timeout) {
// refresh etransaction data only if the basket is still valid.
this.timeoutBasket();
return;
}
this.isCbAvailable = false; this.isCbAvailable = false;
const res = await etransactioninfoFetchEtransactionData({ const res = await etransactioninfoFetchEtransactionData({
path: { // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case path: { basket_id: basket.id },
basket_id: basketId,
},
}); });
if (res.response.ok) { if (res.response.ok) {
this.data = res.data; this.data = res.data;
this.isCbAvailable = true; this.isCbAvailable = true;
} else if (res.response.status === 410) {
// The basket is expired, so no payment method should be available at all.
// This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry
this.timeoutBasket();
} }
}, },
})); }));
@@ -21,6 +21,7 @@
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#billing-infos-fragment" hx-target="#billing-infos-fragment"
x-show="collapsed" x-show="collapsed"
x-cloak
> >
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}
@@ -15,11 +15,10 @@
{% block content %} {% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript"> <div x-data='etransaction(
let billingInfos = {{ billing_infos|safe }}; {{ billing_infos|tojson }},
</script> { id: {{ basket.id }}, timeout: new Date('{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}') }
)'>
<div x-data="etransaction(billingInfos, {{ basket.id }})">
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<table> <table>
<thead> <thead>
@@ -72,7 +71,11 @@
x-cloak x-cloak
type="submit" type="submit"
id="bank-submit-button" id="bank-submit-button"
:disabled="!isCbAvailable" {% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isCbAvailable"
{% endif %}
class="btn btn-blue" class="btn btn-blue"
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
@@ -93,7 +96,16 @@
{% else %} {% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form"> <form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %} {% csrf_token %}
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/> <input
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isSithAvailable"
{% endif %}
class="btn btn-blue"
type="submit"
value="{% trans %}Pay with Sith account{% endtrans %}"
/>
</form> </form>
{% endif %} {% endif %}
</div> </div>
+27 -7
View File
@@ -3,6 +3,7 @@ import urllib
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import freezegun
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.hashes import SHA1
@@ -17,7 +18,7 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from counter.baker_recipes import price_recipe, product_recipe from counter.baker_recipes import price_recipe, product_recipe
from counter.models import Product, ProductType, Selling from counter.models import Product, ProductType, Refilling, Selling
from counter.tests.test_counter import force_refill_user from counter.tests.test_counter import force_refill_user
from eboutic.models import Basket, BasketItem from eboutic.models import Basket, BasketItem
@@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase):
), ),
reverse("eboutic:payment_result", kwargs={"result": "success"}), reverse("eboutic:payment_result", kwargs={"result": "success"}),
) )
assert Basket.objects.filter(id=self.basket.id).first() is None assert not Basket.objects.filter(id=self.basket.id).exists()
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal(1) assert self.customer.customer.amount == Decimal(1)
@@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase):
assert len(messages) == 1 assert len(messages) == 1
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Solde insuffisant" assert messages[0].message == "Solde insuffisant"
assert not Basket.objects.filter(id=self.basket.id).exists()
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
def test_refilling_in_basket(self): def test_refilling_in_basket(self):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save() BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
@@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase):
response, response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}), reverse("eboutic:payment_result", kwargs={"result": "failure"}),
) )
assert Basket.objects.filter(id=self.basket.id).first() is not None assert not Basket.objects.filter(id=self.basket.id).exists()
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert ( assert (
@@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase):
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance assert self.customer.customer.amount == initial_account_balance
def test_basket_expired(self):
self.client.force_login(self.customer)
initial_account_balance = self.customer.customer.amount
with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT):
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assertRedirects(
response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
)
messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Panier expiré"
assert not Basket.objects.filter(id=self.basket.id).exists()
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance
class TestPaymentCard(TestPaymentBase): class TestPaymentCard(TestPaymentBase):
def generate_bank_valid_answer(self, basket: Basket): def generate_bank_valid_answer(self, basket: Basket):
@@ -236,6 +252,10 @@ class TestPaymentCard(TestPaymentBase):
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == price.amount * 2 assert self.customer.customer.amount == price.amount * 2
refill = self.customer.customer.refillings.last()
assert refill is not None
assert refill.amount == price.amount * 2
assert refill.payment_method == Refilling.PaymentMethod.CARD
def test_multiple_responses(self): def test_multiple_responses(self):
bank_response = self.generate_bank_valid_answer(self.basket) bank_response = self.generate_bank_valid_answer(self.basket)
+21 -6
View File
@@ -39,6 +39,8 @@ from django.db.utils import cached_property
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.formats import localize
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
@@ -187,9 +189,7 @@ class BillingInfoFormFragment(
def get_initial(self): def get_initial(self):
if self.object is None: if self.object is None:
return { return {"country": Country(code="FR")}
"country": Country(code="FR"),
}
return {} return {}
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
@@ -255,10 +255,19 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
kwargs["customer_amount"] = None kwargs["customer_amount"] = None
kwargs["billing_infos"] = {} kwargs["billing_infos"] = {}
with contextlib.suppress(BillingInfo.DoesNotExist): if self.object.is_expired:
kwargs["billing_infos"] = json.dumps( messages.error(self.request, _("Basket expired"))
dict(self.object.get_e_transaction_data()) else:
timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT
messages.warning(
self.request,
_("Basket available until %(until)s")
% {"until": localize(localtime(timeout).time())},
) )
with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data())
)
return kwargs return kwargs
@@ -268,9 +277,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
basket = self.get_object() basket = self.get_object()
if basket.is_expired:
messages.error(self.request, _("Basket expired"))
basket.delete()
return redirect("eboutic:payment_result", "failure")
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket.items.filter(product__product_type_id=refilling).exists(): if basket.items.filter(product__product_type_id=refilling).exists():
messages.error(self.request, _("You can't buy a refilling with sith money")) messages.error(self.request, _("You can't buy a refilling with sith money"))
basket.delete()
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
@@ -288,6 +302,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
except DatabaseError as e: except DatabaseError as e:
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(e)
except ValidationError as e: except ValidationError as e:
basket.delete()
messages.error(self.request, e.message) messages.error(self.request, e.message)
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
+10 -1
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-12 11:12+0200\n" "POT-Creation-Date: 2026-05-15 11:46+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -4505,6 +4505,15 @@ msgstr ""
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux " "souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni." "données que vous aviez déjà fourni."
#: eboutic/views.py
msgid "Basket expired"
msgstr "Panier expiré"
#: eboutic/views.py
#, python-format
msgid "Basket available until %(until)s"
msgstr "Panier disponible jusqu'à %(until)s"
#: eboutic/views.py #: eboutic/views.py
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
+5 -1
View File
@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-17 22:42+0200\n" "POT-Creation-Date: 2026-05-17 10:03+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -263,6 +263,10 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/bundled/eboutic/checkout-index.ts
msgid "Basket expired"
msgstr "Panier expiré"
#: sas/static/bundled/sas/pictures-download-index.ts #: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s" msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s" msgstr "photos.%(extension)s"
+1 -1
View File
@@ -44,7 +44,7 @@ dependencies = [
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0", "pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=12.0.0,<14.0.0", "ical>=12.0.0,<14.0.0",
"redis[hiredis]>=3.3.1,<8.0.0", "redis[hiredis]>=8.0.0,<9.0.0",
"environs[django]>=15.0.1,<16", "environs[django]>=15.0.1,<16",
"requests>=2.34.2,<3.0.0", "requests>=2.34.2,<3.0.0",
"honcho>=2.0.0", "honcho>=2.0.0",
+5
View File
@@ -571,6 +571,11 @@ SITH_BARMAN_TIMEOUT = 30
# Minutes to delete the last operations # Minutes to delete the last operations
SITH_LAST_OPERATIONS_LIMIT = 10 SITH_LAST_OPERATIONS_LIMIT = 10
# time before a basket is considered expired
SITH_EBOUTIC_BASKET_TIMEOUT = timedelta(minutes=10)
# time that a user can spend on the CB payment page before it to timeout
SITH_EBOUTIC_ETRANSACTION_TIMEOUT = timedelta(minutes=10)
# ET variables # ET variables
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True) SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
SITH_EBOUTIC_ET_URL = env.str( SITH_EBOUTIC_ET_URL = env.str(