Implémentation 3DSv2 + résolution bugs eboutic + amélioration pages admin (#558)

Eboutic :
- Implémentation de la norme 3DSecure v2 pour les paiement par carte bancaire
- Amélioration générale de l'interface utilisateur
- Résolution du problème avec les caractères spéciaux dans le panier sur Safari
- Réparation du cookie du panier de l'eboutic qui n'était pas fonctionnel

Autre :
- Mise à jour de la documentation
- Mise à jour des dépendances Javascript
- Suppression du code inutilisé dans `subscription/models.py`
- Amélioration des pages administrateur (back-office Django)

Co-authored-by: thomas girod <56346771+imperosol@users.noreply.github.com>
Co-authored-by: Théo DURR <git@theodurr.fr>
Co-authored-by: Julien Constant <julienconstant190@gmail.com>
This commit is contained in:
thomas girod
2023-01-09 20:53:12 +01:00
committed by GitHub
parent 310f1a2283
commit 73305c0b28
53 changed files with 3461 additions and 2248 deletions

View File

@ -21,11 +21,32 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from ajax_select import make_ajax_form
from django.contrib import admin
from eboutic.models import *
admin.site.register(Basket)
admin.site.register(Invoice)
admin.site.register(BasketItem)
@admin.register(Basket)
class BasketAdmin(admin.ModelAdmin):
list_display = ("user", "date", "get_total")
form = make_ajax_form(Basket, {"user": "users"})
@admin.register(BasketItem)
class BasketItemAdmin(admin.ModelAdmin):
list_display = ("basket", "product_name", "product_unit_price", "quantity")
search_fields = ("product_name",)
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = ("user", "date", "validated")
search_fields = ("user__username", "user__first_name", "user__last_name")
form = make_ajax_form(Invoice, {"user": "users"})
@admin.register(InvoiceItem)
class InvoiceItemAdmin(admin.ModelAdmin):
list_display = ("invoice", "product_name", "product_unit_price", "quantity")
search_fields = ("product_name",)

View File

@ -26,6 +26,7 @@ import json
import re
import typing
from urllib.parse import unquote
from django.http import HttpRequest
from django.utils.translation import gettext as _
from sentry_sdk import capture_message
@ -98,12 +99,16 @@ class BasketForm:
- all the ids refer to products the user is allowed to buy
- all the quantities are positive integers
"""
basket = self.cookies.get("basket_items", None)
if basket is None or basket in ("[]", ""):
# replace escaped double quotes by single quotes, as the RegEx used to check the json
# does not support escaped double quotes
basket = unquote(self.cookies.get("basket_items", "")).replace('\\"', "'")
if basket in ("[]", ""):
self.error_messages.add(_("You have no basket."))
return
# check that the json is not nested before parsing it to make sure
# malicious user can't ddos the server with deeply nested json
# malicious user can't DDoS the server with deeply nested json
if not BasketForm.json_cookie_re.match(basket):
# As the validation of the cookie goes through a rather boring regex,
# we can regularly have to deal with subtle errors that we hadn't forecasted,
@ -114,14 +119,17 @@ class BasketForm:
)
self.error_messages.add(_("The request was badly formatted."))
return
try:
basket = json.loads(basket)
except json.JSONDecodeError:
self.error_messages.add(_("The basket cookie was badly formatted."))
return
if type(basket) is not list or len(basket) == 0:
self.error_messages.add(_("Your basket is empty."))
return
for item in basket:
expected_keys = {"id", "quantity", "name", "unit_price"}
if type(item) is not dict or set(item.keys()) != expected_keys:
@ -146,7 +154,7 @@ class BasketForm:
continue
if type(item["quantity"]) is not int or item["quantity"] < 0:
self.error_messages.add(
_("You cannot buy %(nbr)d %(name)%s.")
_("You cannot buy %(nbr)d %(name)s.")
% {"nbr": item["quantity"], "name": item["name"]}
)
continue
@ -174,7 +182,6 @@ class BasketForm:
return True
def get_error_messages(self) -> typing.List[str]:
# return [msg for msg in self.error_messages]
return list(self.error_messages)
def get_cleaned_cookie(self) -> str:

View File

@ -21,9 +21,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import hmac
import html
import typing
from datetime import datetime
from typing import List
from dict2xml import dict2xml
from django.conf import settings
from django.db import models, DataError
from django.db.models import Sum, F
@ -32,7 +36,7 @@ from django.utils.translation import gettext_lazy as _
from accounting.models import CurrencyField
from core.models import Group, User
from counter.models import Counter, Product, Selling, Refilling
from counter.models import Counter, Product, Selling, Refilling, BillingInfo, Customer
def get_eboutic_products(user: User) -> List[Product]:
@ -104,7 +108,7 @@ class Basket(models.Model):
"""
Remove all items from this basket without deleting the basket
"""
BasketItem.objects.filter(basket=self).delete()
self.items.all().delete()
@cached_property
def contains_refilling_item(self) -> bool:
@ -122,7 +126,7 @@ class Basket(models.Model):
def from_session(cls, session) -> typing.Union["Basket", None]:
"""
Given an HttpRequest django object, return the basket used in the current session
if it exists else create a new one and return it
if it exists else None
"""
if "basket_id" in session:
try:
@ -131,6 +135,88 @@ class Basket(models.Model):
return None
return None
def generate_sales(self, counter, seller: User, payment_method: str):
"""
Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket
Example:
::
counter = Counter.objects.get(name="Eboutic")
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
# here the basket is in the same state as before the method call
with transaction.atomic():
for sale in sales:
sale.save()
basket.delete()
# all the basket items are deleted by the on_delete=CASCADE relation
# thus only the sales remain
"""
# I must proceed with two distinct requests instead of
# only one with a join because the AbstractBaseItem model has been
# poorly designed. If you refactor the model, please refactor this too.
items = self.items.order_by("product_id")
ids = [item.product_id for item in items]
products = Product.objects.filter(id__in=ids).order_by("id")
# items and products are sorted in the same order
sales = []
for item, product in zip(items, products):
sales.append(
Selling(
label=product.name,
counter=counter,
club=product.club,
product=product,
seller=seller,
customer=self.user.customer,
unit_price=item.product_unit_price,
quantity=item.quantity,
payment_method=payment_method,
)
)
return sales
def get_e_transaction_data(self):
user = self.user
if not hasattr(user, "customer"):
raise Customer.DoesNotExist
customer = user.customer
if not hasattr(user.customer, "billing_infos"):
raise BillingInfo.DoesNotExist
data = [
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.get_total() * 100))),
("PBX_DEVISE", "978"), # This is Euro
("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email),
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
("PBX_HASH", "SHA512"),
("PBX_TYPEPAIEMENT", "CARTE"),
("PBX_TYPECARTE", "CB"),
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
]
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
}
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
cart, newlines=False
)
data += [
("PBX_SHOPPINGCART", cart),
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
]
pbx_hmac = hmac.new(
settings.SITH_EBOUTIC_HMAC_KEY,
bytes("&".join("=".join(d) for d in data), "utf-8"),
"sha512",
)
data.append(("PBX_HMAC", pbx_hmac.hexdigest().upper()))
return data
def __str__(self):
return "%s's basket (%d items)" % (self.user, self.items.all().count())
@ -156,18 +242,9 @@ class Invoice(models.Model):
)["total"]
return float(total) if total is not None else 0
def validate(self, *args, **kwargs):
def validate(self):
if self.validated:
raise DataError(_("Invoice already validated"))
from counter.models import Customer
if not Customer.objects.filter(user=self.user).exists():
number = Customer.objects.count() + 1
Customer(
user=self.user,
account_id=Customer.generate_account_id(number),
amount=0,
).save()
eboutic = Counter.objects.filter(type="EBOUTIC").first()
for i in self.items.all():
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
@ -227,6 +304,22 @@ class BasketItem(AbstractBaseItem):
Basket, related_name="items", verbose_name=_("basket"), on_delete=models.CASCADE
)
@classmethod
def from_product(cls, product: Product, quantity: int):
"""
Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity
WARNING : the basket field is not filled, so you must set
it yourself before saving the model
"""
return cls(
product_id=product.id,
product_name=product.name,
type_id=product.product_type.id,
quantity=quantity,
product_unit_price=product.selling_price,
)
class InvoiceItem(AbstractBaseItem):
invoice = models.ForeignKey(

View File

@ -0,0 +1,263 @@
#eboutic {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
column-gap: 20px;
margin: 0 20px 20px;
}
#eboutic-title {
margin-left: 20px;
}
#eboutic h3 {
margin-left: 0;
margin-right: 0;
}
#basket {
min-width: 300px;
border-radius: 8px;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px;
}
#basket h3 {
margin-top: 0;
}
@media screen and (max-width: 765px) {
#eboutic {
flex-direction: column-reverse;
align-items: center;
margin: 10px;
row-gap: 20px;
}
#eboutic-title {
margin-bottom: 20px;
margin-top: 4px;
}
#basket {
width: -webkit-fill-available;
}
}
#eboutic .item-list {
margin-left: 0;
list-style: none;
}
#eboutic .item-list li {
display: flex;
align-items: center;
margin-bottom: 10px
}
#eboutic .item-row {
gap: 10px;
}
#eboutic .item-name {
word-break: break-word;
width: 100%;
line-height: 100%;
}
#eboutic .fa-plus,
#eboutic .fa-minus {
cursor: pointer;
background-color: #354a5f;
color: white;
border-radius: 50%;
padding: 5px;
font-size: 10px;
line-height: 10px;
width: 10px;
text-align: center;
}
#eboutic .item-quantity {
min-width: 65px;
justify-content: space-between;
align-items: center;
display: flex;
gap: 5px;
}
#eboutic .item-price {
min-width: 65px;
text-align: right;
}
/* CSS du catalogue */
#eboutic #catalog {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 30px;
}
#eboutic .category-header {
margin-bottom: 15px;
}
#eboutic .product-group {
display: flex;
flex-wrap: wrap;
column-gap: 15px;
row-gap: 15px;
}
#eboutic .product-button {
position: relative;
box-sizing: border-box;
min-height: 180px;
height: fit-content;
width: 150px;
padding: 15px;
overflow: hidden;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
display: flex;
flex-direction: column;
align-items: center;
row-gap: 5px;
justify-content: flex-start;
}
#eboutic .product-button.selected {
animation: bg-in-out 1s ease;
background-color: rgb(216, 236, 255);
}
#eboutic .product-button.selected::after {
content: "🛒";
position: absolute;
top: 5px;
right: 5px;
padding: 5px;
border-radius: 50%;
box-shadow: 0px 0px 12px 2px rgb(0 0 0 / 14%);
background-color: white;
width: 20px;
height: 20px;
font-size: 16px;
line-height: 20px;
}
#eboutic .product-button:active {
box-shadow: none;
}
#eboutic .product-image {
width: 100%;
height: 100%;
min-height: 70px;
max-height: 70px;
object-fit: contain;
border-radius: 4px;
line-height: 70px;
margin-bottom: 15px;
}
#eboutic i.product-image {
background-color: rgba(173, 173, 173, 0.2);
}
#eboutic .product-description h4 {
font-size: .75em;
word-break: break-word;
margin: 0 0 5px 0;
}
#eboutic .product-button p {
font-size: 13px;
margin: 0;
}
#eboutic .catalog-buttons {
display: flex;
justify-content: center;
column-gap: 30px;
margin: 30px 0 0;
}
#eboutic input {
all: unset;
}
#eboutic .catalog-buttons button {
font-size: 15px!important;
font-weight: normal;
color: white;
min-width: 60px;
padding: 10px 15px;
}
#eboutic .catalog-buttons .validate {
background-color: #354a5f;
}
#eboutic .catalog-buttons .clear {
background-color: gray;
}
#eboutic .catalog-buttons button i {
margin-right: 4px;
}
#eboutic .catalog-buttons button.validate:hover {
background-color: #2c3646;
}
#eboutic .catalog-buttons button.clear:hover {
background-color:hsl(210,5%,30%);
}
#eboutic .catalog-buttons form {
margin: 0;
}
@media screen and (max-width: 765px) {
#eboutic #catalog {
row-gap: 15px;
width: 100%;
}
#eboutic section {
text-align: center;
}
#eboutic .product-group {
justify-content: space-around;
flex-direction: column;
}
#eboutic .product-group .product-button {
min-height: 100px;
width: 100%;
max-width: 100%;
display: flex;
flex-direction: row;
gap: 10px;
}
#eboutic .product-group .product-description {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
#eboutic .product-description h4 {
text-align: left;
max-width: 90%;
}
#eboutic .product-image {
margin-bottom: 0px;
max-width: 70px;
}
}
@keyframes bg-in-out {
0% { background-color: white; }
100% { background-color: rgb(216, 236, 255); }
}

View File

@ -0,0 +1,157 @@
/**
* @typedef {Object} BasketItem An item in the basket
* @property {number} id The id of the product
* @property {string} name The name of the product
* @property {number} quantity The quantity of the product
* @property {number} unit_price The unit price of the product
*/
const BASKET_ITEMS_COOKIE_NAME = "basket_items";
/**
* Search for a cookie by name
* @param {string} name Name of the cookie to get
* @returns {string|null|undefined} the value of the cookie or null if it does not exist, undefined if not found
*/
function getCookie(name) {
if (!document.cookie || document.cookie.length === 0) return null;
let found = document.cookie
.split(';')
.map(c => c.trim())
.find(c => c.startsWith(name + '='));
return found === undefined ? undefined : decodeURIComponent(found.split('=')[1]);
}
/**
* Fetch the basket items from the associated cookie
* @returns {BasketItem[]|[]} the items in the basket
*/
function get_starting_items() {
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
let output = [];
try {
// Django cookie backend does an utter mess on non-trivial data types
// so we must perform a conversion of our own
const biscuit = JSON.parse(cookie.replace(/\\054/g, ','));
output = Array.isArray(biscuit) ? biscuit : [];
} catch (e) {}
output.forEach(item => {
let el = document.getElementById(item.id);
el.classList.add("selected");
});
return output;
}
document.addEventListener('alpine:init', () => {
Alpine.data('basket', () => ({
items: get_starting_items(),
/**
* Get the total price of the basket
* @returns {number} The total price of the basket
*/
get_total() {
return this.items
.reduce((acc, item) => acc + item["quantity"] * item["unit_price"], 0);
},
/**
* Add 1 to the quantity of an item in the basket
* @param {BasketItem} item
*/
add(item) {
item.quantity++;
this.set_cookies();
},
/**
* Remove 1 to the quantity of an item in the basket
* @param {BasketItem} item_id
*/
remove(item_id) {
const index = this.items.findIndex(e => e.id === item_id);
if (index < 0) return;
this.items[index].quantity -= 1;
if (this.items[index].quantity === 0) {
let el = document.getElementById(this.items[index].id);
el.classList.remove("selected");
this.items = this.items.filter((e) => e.id !== this.items[index].id);
}
this.set_cookies();
},
/**
* Remove all the items from the basket & cleans the catalog CSS classes
*/
clear_basket() {
// We remove the class "selected" from all the items in the catalog
this.items.forEach(item => {
let el = document.getElementById(item.id);
el.classList.remove("selected");
})
this.items = [];
this.set_cookies();
},
/**
* Set the cookie in the browser with the basket items
* ! the cookie survives an hour
*/
set_cookies() {
if (this.items.length === 0) document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
else document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
},
/**
* Create an item in the basket if it was not already in
* @param {number} id The id of the product to add
* @param {string} name The name of the product
* @param {number} price The unit price of the product
* @returns {BasketItem} The created item
*/
create_item(id, name, price) {
let new_item = {
id: id,
name: name,
quantity: 0,
unit_price: price
};
this.items.push(new_item);
this.add(new_item);
return new_item;
},
/**
* Add an item to the basket.
* This is called when the user click on a button in the catalog
* @param {number} id The id of the product to add
* @param {string} name The name of the product
* @param {number} price The unit price of the product
*/
add_from_catalog(id, name, price) {
let item = this.items.find(e => e.id === id)
// if the item is not in the basket, we create it
// else we add + 1 to it
if (item === undefined) item = this.create_item(id, name, price);
else this.add(item);
if (item.quantity > 0) {
let el = document.getElementById(item.id);
el.classList.add("selected");
}
},
}))
})

View File

@ -0,0 +1,73 @@
document.addEventListener('alpine:init', () => {
Alpine.store('bank_payment_enabled', false)
Alpine.store('billing_inputs', {
data: JSON.parse(et_data)["data"],
async fill() {
document.getElementById("bank-submit-button").disabled = true;
const request = new Request(et_data_url, {
method: "GET",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
});
const res = await fetch(request);
if (res.ok) {
const json = await res.json();
if (json["data"]) {
this.data = json["data"];
}
document.getElementById("bank-submit-button").disabled = false;
}
}
})
Alpine.data('billing_infos', () => ({
errors: [],
successful: false,
url: billing_info_exist ? edit_billing_info_url : create_billing_info_url,
async send_form() {
const form = document.getElementById("billing_info_form");
const submit_button = form.querySelector("input[type=submit]")
submit_button.disabled = true;
document.getElementById("bank-submit-button").disabled = true;
this.successful = false
let payload = {};
for (const elem of form.querySelectorAll("input")) {
if (elem.type === "text" && elem.value) {
payload[elem.name] = elem.value;
}
}
const country = form.querySelector("select");
if (country && country.value) {
payload[country.name] = country.value;
}
const request = new Request(this.url, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken(),
},
body: JSON.stringify(payload),
});
const res = await fetch(request);
const json = await res.json();
if (json["errors"]) {
this.errors = json["errors"];
} else {
this.errors = [];
this.successful = true;
this.url = edit_billing_info_url;
Alpine.store("billing_inputs").fill();
}
submit_button.disabled = false;
}
}))
})

View File

@ -25,11 +25,13 @@
<div id="basket">
<h3>Panier</h3>
{% if errors %}
<div class="error-message">
{% for error in errors %}
<p>{{ error }}</p>
{% endfor %}
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
<div class="alert alert-red">
<div class="alert-main">
{% for error in errors %}
<p style="margin: 0">{{ error }}</p>
{% endfor %}
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
</div>
</div>
{% endif %}
<ul class="item-list">
@ -44,12 +46,12 @@
</li>
<template x-for="item in items" :key="item.id">
<li class="item-row" x-show="item.quantity > 0">
<span class="item-name" x-text="item.name"></span>
<div class="item-quantity">
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
<span x-text="item.quantity"></span>
<i class="fa fa-plus" @click="add(item)"></i>
</div>
<span class="item-name" x-text="item.name"></span>
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
</li>
</template>
@ -64,7 +66,7 @@
<i class="fa fa-trash"></i>
{% trans %}Clear{% endtrans %}
</button>
<form method="post" action="{{ url('eboutic:command') }}">
<form method="get" action="{{ url('eboutic:command') }}">
{% csrf_token %}
<button class="validate">
<i class="fa fa-check"></i>
@ -75,7 +77,7 @@
</div>
<div id="catalog">
{% if not request.user.date_of_birth %}
<div class="alert" x-data="{show_alert: true}" x-show="show_alert" x-transition>
<div class="alert alert-red" x-data="{show_alert: true}" x-show="show_alert" x-transition>
<span class="alert-main">
{% trans %}You have not filled in your date of birth. As a result, you may not have access to all the products in the online shop. To fill in your date of birth, you can go to{% endtrans %}
<a href="{{ url("core:user_edit", user_id=request.user.id) }}">
@ -102,16 +104,16 @@
</div>
<div class="product-group">
{% for p in items %}
<button class="product-button"
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
<button id="{{ p.id }}" class="product-button" @click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
{% if p.icon %}
<img src="{{ p.icon.url }}" alt="image de {{ p.name }}"
width="40px" height="40px">
<img class="product-image" src="{{ p.icon.url }}" alt="image de {{ p.name }}">
{% else %}
<i class="fa fa-2x fa-picture-o"></i>
<i class="fa fa-2x fa-picture-o product-image" ></i>
{% endif %}
<p><strong>{{ p.name }}</strong></p>
<p>{{ p.selling_price }} €</p>
<div class="product-description">
<h4>{{ p.name }}</strong></h4>
<p>{{ p.selling_price }} €</p>
</div>
</button>
{% endfor %}
</div>

View File

@ -1,69 +1,141 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Basket state{% endtrans %}
{% trans %}Basket state{% endtrans %}
{% endblock %}
{% block jquery_css %}
{# Remove jquery css #}
{% endblock %}
{% block additional_js %}
<script src="{{ static('eboutic/js/makecommand.js') }}" defer></script>
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<div>
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
<div>
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
<tr>
<td>Article</td>
<td>Quantity</td>
<td>Unit price</td>
</tr>
</thead>
<tbody>
</thead>
<tbody>
{% for item in basket.items.all() %}
<tr>
<td>{{ item.product_name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.product_unit_price }} €</td>
</tr>
<tr>
<td>{{ item.product_name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.product_unit_price }} €</td>
</tr>
{% endfor %}
<tbody>
</table>
<tbody>
</table>
<p>
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} €</strong>
{% if customer_amount != None %}
<br>
{% trans %}Current account amount: {% endtrans %}<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
{% if not basket.contains_refilling_item %}
<br>
{% trans %}Remaining account amount: {% endtrans %}
<strong>{{ "%0.2f"|format(customer_amount|float - basket.get_total()) }} €</strong>
{% endif %}
{% endif %}
</p>
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}">
<p>
{% for (field_name,field_value) in et_request.items() -%}
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}">
{% endfor %}
<input type="submit" value="{% trans %}Pay with credit card{% endtrans %}" />
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} €</strong>
{% if customer_amount != None %}
<br>
{% trans %}Current account amount: {% endtrans %}
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
{% if not basket.contains_refilling_item %}
<br>
{% trans %}Remaining account amount: {% endtrans %}
<strong>{{ "%0.2f"|format(customer_amount|float - basket.get_total()) }} €</strong>
{% endif %}
{% endif %}
</p>
</form>
{% endif %}
{% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
{% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith') }}">
{% csrf_token %}
<input type="hidden" name="action" value="pay_with_sith_account">
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}" />
</form>
{% endif %}
</div>
<br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
<div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text">
{% trans %}Edit 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" id="billing_info_form" method="post"
x-show="collapsed" x-data="billing_infos"
x-transition.scale.origin.top
@submit.prevent="send_form()">
{% csrf_token %}
{{ billing_form }}
<br>
<br>
<div x-show="errors.length > 0" class="alert alert-red" x-transition>
<div class="alert-main">
<template x-for="error in errors">
<div x-text="error.field + ' : ' + error.messages.join(', ')"></div>
</template>
</div>
<div class="clickable" @click="errors = []">
<i class="fa fa-close"></i>
</div>
</div>
<div x-show="successful" class="alert alert-green" x-transition>
<div class="alert-main">
Informations de facturation enregistrées
</div>
<div class="clickable" @click="successful = false">
<i class="fa fa-close"></i>
</div>
</div>
<input type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}">
</form>
</div>
<br>
{% if must_fill_billing_infos %}
<p>
<i>
{% trans %}You must fill your billing infos if you want to pay with your credit
card{% endtrans %}
</i>
</p>
{% endif %}
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
<template x-data x-for="input in $store.billing_inputs.data">
<input type="hidden" :name="input['key']" :value="input['value']">
</template>
<input type="submit" id="bank-submit-button"
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
value="{% trans %}Pay with credit card{% endtrans %}"/>
</form>
{% endif %}
{% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
{% else %}
<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 %}"/>
</form>
{% endif %}
</div>
{% endblock %}
{% block script %}
<script>
const create_billing_info_url = '{{ url("counter:create_billing_info", user_id=request.user.id) }}'
const edit_billing_info_url = '{{ url("counter:edit_billing_info", user_id=request.user.id) }}';
const et_data_url = '{{ url("eboutic:et_data") }}'
let billing_info_exist = {{ "true" if billing_infos else "false" }}
{% if billing_infos %}
const et_data = {{ billing_infos|tojson }}
{% else %}
const et_data = '{"data": []}'
{% endif %}
</script>
{{ super() }}
{% endblock %}

View File

@ -24,7 +24,6 @@
#
import base64
import json
import re
import urllib
from OpenSSL import crypto
@ -40,18 +39,19 @@ from eboutic.models import Basket
class EbouticTest(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
call_command("populate")
self.skia = User.objects.filter(username="skia").first()
self.subscriber = User.objects.filter(username="subscriber").first()
self.old_subscriber = User.objects.filter(username="old_subscriber").first()
self.public = User.objects.filter(username="public").first()
self.barbar = Product.objects.filter(code="BARB").first()
self.refill = Product.objects.filter(code="15REFILL").first()
self.cotis = Product.objects.filter(code="1SCOTIZ").first()
self.eboutic = Counter.objects.filter(name="Eboutic").first()
cls.barbar = Product.objects.filter(code="BARB").first()
cls.refill = Product.objects.filter(code="15REFILL").first()
cls.cotis = Product.objects.filter(code="1SCOTIZ").first()
cls.eboutic = Counter.objects.filter(name="Eboutic").first()
cls.skia = User.objects.filter(username="skia").first()
cls.subscriber = User.objects.filter(username="subscriber").first()
cls.old_subscriber = User.objects.filter(username="old_subscriber").first()
cls.public = User.objects.filter(username="public").first()
def get_busy_basket(self, user):
def get_busy_basket(self, user) -> Basket:
"""
Create and return a basket with 3 barbar and 1 cotis in it.
Edit the client session to store the basket id in it
@ -64,11 +64,11 @@ class EbouticTest(TestCase):
basket.add_product(self.cotis)
return basket
def generate_bank_valid_answer_from_page_content(self, content):
content = str(content)
basket_id = re.search(r"PBX_CMD\" value=\"(\d*)\"", content).group(1)
amount = re.search(r"PBX_TOTAL\" value=\"(\d*)\"", content).group(1)
query = "Amount=%s&BasketID=%s&Auto=42&Error=00000" % (amount, basket_id)
def generate_bank_valid_answer(self) -> str:
basket = Basket.from_session(self.client.session)
basket_id = basket.id
amount = int(basket.get_total() * 100)
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
with open("./eboutic/tests/private_key.pem") as f:
PRIVKEY = f.read()
with open("./eboutic/tests/public_key.pem") as f:
@ -81,8 +81,7 @@ class EbouticTest(TestCase):
query,
urllib.parse.quote_plus(b64sig),
)
response = self.client.get(url)
return response
return url
def test_buy_with_sith_account(self):
self.client.login(username="subscriber", password="plop")
@ -102,7 +101,7 @@ class EbouticTest(TestCase):
def test_buy_with_sith_account_no_money(self):
self.client.login(username="subscriber", password="plop")
basket = self.get_busy_basket(self.subscriber)
initial = basket.get_total() - 1
initial = basket.get_total() - 1 # just not enough to complete the sale
self.subscriber.customer.amount = initial
self.subscriber.customer.save()
response = self.client.post(reverse("eboutic:pay_with_sith"))
@ -122,7 +121,7 @@ class EbouticTest(TestCase):
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
]"""
response = self.client.post(reverse("eboutic:command"))
response = self.client.get(reverse("eboutic:command"))
self.assertEqual(response.status_code, 200)
self.assertInHTML(
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
@ -146,7 +145,7 @@ class EbouticTest(TestCase):
def test_submit_empty_basket(self):
self.client.login(username="subscriber", password="plop")
self.client.cookies["basket_items"] = "[]"
response = self.client.post(reverse("eboutic:command"))
response = self.client.get(reverse("eboutic:command"))
self.assertRedirects(response, "/eboutic/")
def test_submit_invalid_basket(self):
@ -157,7 +156,7 @@ class EbouticTest(TestCase):
] = f"""[
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
]"""
response = self.client.post(reverse("eboutic:command"))
response = self.client.get(reverse("eboutic:command"))
self.assertIn(
'basket_items=""',
self.client.cookies["basket_items"].OutputString(),
@ -175,7 +174,7 @@ class EbouticTest(TestCase):
] = """[
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
]"""
response = self.client.post(reverse("eboutic:command"))
response = self.client.get(reverse("eboutic:command"))
self.assertRedirects(response, "/eboutic/")
def test_buy_subscribe_product_with_credit_card(self):
@ -189,14 +188,14 @@ class EbouticTest(TestCase):
] = """[
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
]"""
response = self.client.post(reverse("eboutic:command"))
response = self.client.get(reverse("eboutic:command"))
self.assertInHTML(
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
response.content.decode(),
)
basket = Basket.objects.get(id=self.client.session["basket_id"])
self.assertEqual(basket.items.count(), 1)
response = self.generate_bank_valid_answer_from_page_content(response.content)
response = self.client.get(self.generate_bank_valid_answer())
self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
@ -215,9 +214,10 @@ class EbouticTest(TestCase):
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
)
initial_balance = self.subscriber.customer.amount
response = self.client.post(reverse("eboutic:command"))
self.client.get(reverse("eboutic:command"))
response = self.generate_bank_valid_answer_from_page_content(response.content)
url = self.generate_bank_valid_answer()
response = self.client.get(url)
self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode() == "Payment successful")
new_balance = Customer.objects.get(user=self.subscriber).amount
@ -228,14 +228,15 @@ class EbouticTest(TestCase):
self.client.cookies["basket_items"] = json.dumps(
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
)
response = self.client.post(reverse("eboutic:command"))
self.client.get(reverse("eboutic:command"))
et_answer_url = self.generate_bank_valid_answer()
self.client.cookies["basket_items"] = json.dumps(
[ # alter basket
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
]
)
self.client.post(reverse("eboutic:command"))
response = self.generate_bank_valid_answer_from_page_content(response.content)
self.client.get(reverse("eboutic:command"))
response = self.client.get(et_answer_url)
self.assertEqual(response.status_code, 500)
self.assertIn(
"Basket processing failed with error: SuspiciousOperation('Basket total and amount do not match'",
@ -247,8 +248,9 @@ class EbouticTest(TestCase):
self.client.cookies["basket_items"] = json.dumps(
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
)
response = self.client.post(reverse("eboutic:command"))
response = self.generate_bank_valid_answer_from_page_content(response.content)
self.client.get(reverse("eboutic:command"))
et_answer_url = self.generate_bank_valid_answer()
response = self.client.get(et_answer_url)
self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "Payment successful")

View File

@ -34,8 +34,9 @@ urlpatterns = [
# Subscription views
path("", eboutic_main, name="main"),
path("command/", EbouticCommand.as_view(), name="command"),
path("pay/", pay_with_sith, name="pay_with_sith"),
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

@ -23,13 +23,11 @@
#
import base64
import hmac
import json
from collections import OrderedDict
from datetime import datetime
import sentry_sdk
from datetime import datetime
from urllib.parse import unquote
from OpenSSL import crypto
from django.conf import settings
from django.contrib.auth.decorators import login_required
@ -41,7 +39,8 @@ from django.utils.decorators import method_decorator
from django.views.decorators.http import require_GET, require_POST
from django.views.generic import TemplateView, View
from counter.models import Customer, Counter, Selling
from counter.forms import BillingInfoForm
from counter.models import Customer, Counter, Product
from eboutic.forms import BasketForm
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
@ -85,11 +84,11 @@ class EbouticCommand(TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja"
@method_decorator(login_required)
def get(self, request, *args, **kwargs):
def post(self, request, *args, **kwargs):
return redirect("eboutic:main")
@method_decorator(login_required)
def post(self, request: HttpRequest, *args, **kwargs):
def get(self, request: HttpRequest, *args, **kwargs):
form = BasketForm(request)
if not form.is_valid():
request.session["errors"] = form.get_error_messages()
@ -98,65 +97,56 @@ class EbouticCommand(TemplateView):
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
return res
if "basket_id" in request.session:
basket, _ = Basket.objects.get_or_create(
id=request.session["basket_id"], user=request.user
)
basket = Basket.from_session(request.session)
if basket is not None:
basket.clear()
else:
basket = Basket.objects.create(user=request.user)
request.session["basket_id"] = basket.id
request.session.modified = True
basket.save()
eboutique = Counter.objects.get(type="EBOUTIC")
for item in json.loads(request.COOKIES["basket_items"]):
basket.add_product(
eboutique.products.get(id=(item["id"])), item["quantity"]
)
request.session["basket_id"] = basket.id
request.session.modified = True
items = json.loads(unquote(request.COOKIES["basket_items"]))
items.sort(key=lambda item: item["id"])
ids = [item["id"] for item in items]
quantities = [item["quantity"] for item in items]
products = Product.objects.filter(id__in=ids)
for product, qty in zip(products, quantities):
basket.add_product(product, qty)
kwargs["basket"] = basket
return self.render_to_response(self.get_context_data(**kwargs))
def get_context_data(self, **kwargs):
kwargs = super(EbouticCommand, self).get_context_data(**kwargs)
# basket is already in kwargs when the method is called
default_billing_info = None
if hasattr(self.request.user, "customer"):
kwargs["customer_amount"] = self.request.user.customer.amount
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
kwargs["et_request"] = OrderedDict()
kwargs["et_request"]["PBX_SITE"] = settings.SITH_EBOUTIC_PBX_SITE
kwargs["et_request"]["PBX_RANG"] = settings.SITH_EBOUTIC_PBX_RANG
kwargs["et_request"]["PBX_IDENTIFIANT"] = settings.SITH_EBOUTIC_PBX_IDENTIFIANT
kwargs["et_request"]["PBX_TOTAL"] = int(kwargs["basket"].get_total() * 100)
kwargs["et_request"][
"PBX_DEVISE"
] = 978 # This is Euro. ET support only this value anyway
kwargs["et_request"]["PBX_CMD"] = kwargs["basket"].id
kwargs["et_request"]["PBX_PORTEUR"] = kwargs["basket"].user.email
kwargs["et_request"]["PBX_RETOUR"] = "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"
kwargs["et_request"]["PBX_HASH"] = "SHA512"
kwargs["et_request"]["PBX_TYPEPAIEMENT"] = "CARTE"
kwargs["et_request"]["PBX_TYPECARTE"] = "CB"
kwargs["et_request"]["PBX_TIME"] = str(
datetime.now().replace(microsecond=0).isoformat("T")
)
kwargs["et_request"]["PBX_HMAC"] = (
hmac.new(
settings.SITH_EBOUTIC_HMAC_KEY,
bytes(
"&".join(
["%s=%s" % (k, v) for k, v in kwargs["et_request"].items()]
),
"utf-8",
),
"sha512",
)
.hexdigest()
.upper()
)
kwargs["must_fill_billing_infos"] = default_billing_info is None
if not kwargs["must_fill_billing_infos"]:
# the user has already filled its billing_infos, thus we can
# get it without expecting an error
data = kwargs["basket"].get_e_transaction_data()
data = {"data": [{"key": key, "value": val} for key, val in data]}
kwargs["billing_infos"] = json.dumps(data)
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
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):
@ -171,24 +161,14 @@ def pay_with_sith(request):
res = redirect("eboutic:payment_result", "failure")
else:
eboutic = Counter.objects.filter(type="EBOUTIC").first()
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
try:
with transaction.atomic():
for it in basket.items.all():
product = eboutic.products.get(id=it.product_id)
Selling(
label=it.product_name,
counter=eboutic,
club=product.club,
product=product,
seller=c.user,
customer=c,
unit_price=it.product_unit_price,
quantity=it.quantity,
payment_method="SITH_ACCOUNT",
).save()
for sale in sales:
sale.save()
basket.delete()
request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success")
request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success")
except DatabaseError as e:
with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username}
@ -205,12 +185,8 @@ class EtransactionAutoAnswer(View):
# Response documentation http://www1.paybox.com/espace-integrateur-documentation
# /la-solution-paybox-system/gestion-de-la-reponse/
def get(self, request, *args, **kwargs):
if (
not "Amount" in request.GET.keys()
or not "BasketID" in request.GET.keys()
or not "Error" in request.GET.keys()
or not "Sig" in request.GET.keys()
):
required = {"Amount", "BasketID", "Error", "Sig"}
if not required.issubset(set(request.GET.keys())):
return HttpResponse("Bad arguments", status=400)
key = crypto.load_publickey(crypto.FILETYPE_PEM, settings.SITH_EBOUTIC_PUB_KEY)
cert = crypto.X509()