integration of 3D secure v2 for eboutic bank payment

This commit is contained in:
Thomas Girod 2022-11-28 17:03:46 +01:00
parent e43d53e564
commit fe9e5ce861
26 changed files with 1896 additions and 763 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ db.sqlite3
pyrightconfig.json pyrightconfig.json
dist/ dist/
.vscode/ .vscode/
.idea
env/ env/
doc/html doc/html
data/ data/

View File

@ -26,6 +26,7 @@ import os
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from io import StringIO, BytesIO from io import StringIO, BytesIO
from django.contrib.auth.models import Permission
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.management import call_command from django.core.management import call_command
from django.conf import settings from django.conf import settings
@ -73,7 +74,7 @@ class Command(BaseCommand):
root_path = os.path.dirname( root_path = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))) os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
) )
Group(name="Root").save() root_group, _ = Group.objects.get_or_create(name="Root")
Group(name="Public").save() Group(name="Public").save()
Group(name="Subscribers").save() Group(name="Subscribers").save()
Group(name="Old subscribers").save() Group(name="Old subscribers").save()
@ -87,6 +88,11 @@ class Command(BaseCommand):
Group(name="Forum admin").save() Group(name="Forum admin").save()
Group(name="Pedagogy admin").save() Group(name="Pedagogy admin").save()
self.reset_index("core", "auth") self.reset_index("core", "auth")
change_billing = Permission.objects.get(codename="change_billinginfo")
add_billing = Permission.objects.get(codename="add_billinginfo")
root_group.permissions.add(change_billing, add_billing)
root = User( root = User(
id=0, id=0,
username="root", username="root",

View File

@ -49,36 +49,27 @@ body {
font-family: sans-serif; font-family: sans-serif;
} }
input[type=button], input[type=submit], input[type=reset],input[type=file] { button, input[type=button], input[type=submit], input[type=reset],input[type=file] {
border: none; border: none;
text-decoration: none; text-decoration: none;
background-color: $background-button-color; background-color: $background-button-color;
padding: 0.4em; padding: 0.4em;
margin: 0.1em; margin: 0.1em;
font-weight: bold;
font-size: 1.2em; font-size: 1.2em;
border-radius: 5px; border-radius: 5px;
cursor: pointer; &:hover {
box-shadow: $shadow-color 0px 0px 1px; background: hsl(0, 0%, 83%);
}
}
&:hover { input[type=button], input[type=submit], input[type=reset],input[type=file] {
background: hsl(0, 0%, 83%); font-weight: bold;
}
} }
button{
border: none; button:not(:disabled), input[type=button]:not(:disabled), input[type=submit]:not(:disabled), input[type=reset]:not(:disabled),input[type=file]:not(:disabled) {
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.18em;
border-radius: 5px;
box-shadow: $shadow-color 0px 0px 1px;
cursor: pointer; cursor: pointer;
&:hover {
background: hsl(0, 0%, 83%);
}
} }
input,textarea[type=text],[type=number]{ input,textarea[type=text],[type=number]{
border: none; border: none;
text-decoration: none; text-decoration: none;
@ -123,6 +114,38 @@ a {
margin: 1px; margin: 1px;
} }
.collapse {
border-radius: 5px;
overflow: hidden;
.collapse-header {
color: white;
background-color: #354a5f;
padding: 5px 10px;
display: flex;
align-items: center;
gap: 10px;
.collapse-header-text {
flex: 2;
}
.collapse-header-icon {
transition: all ease-in-out 150ms;
&.reverse {
transform: rotate(180deg);
}
}
}
.collapse-body {
padding: 10px;
}
}
.shadow {
box-shadow: rgba(60, 64, 67, .3) 0 1px 3px 0, rgba(60, 64, 67, .15) 0 4px 8px 3px;
}
.w_big { .w_big {
width: 75%; width: 75%;
} }
@ -135,10 +158,12 @@ a {
width: 23%; width: 23%;
} }
.clickable:hover { .clickable:not(:disabled):hover {
cursor: pointer; cursor: pointer;
} }
[x-cloak] { display: none !important; }
/*--------------------------------HEADER-------------------------------*/ /*--------------------------------HEADER-------------------------------*/
#header_language_chooser { #header_language_chooser {
@ -170,21 +195,11 @@ header {
background-color: $primary-neutral-dark-color; background-color: $primary-neutral-dark-color;
border-radius: 0px 0px 10px 10px; border-radius: 0px 0px 10px 10px;
// PINKTOBER
// background-color: $pinktober;
// border-bottom: 5px solid $pinktober-secondary;
// margin-bottom: -5px;
// border-radius: 0 0 5px 7px;
#header_logo { #header_logo {
background-color: $white-color; background-color: $white-color;
padding: 0.2em; padding: 0.2em;
border-radius: 0px 0px 0px 9px; border-radius: 0 0 0 9px;
//PINKTOBER
// border-bottom: 5px solid $shadow-color;
// border-radius: 0px 0px 0px 5px;
// margin-bottom: -5px;
a { a {
display: flex; display: flex;
@ -211,14 +226,8 @@ header {
width: 100%; width: 100%;
label { label {
display: inline; display: inline;
// PINKTOBER
// color: $pinktober-primary-text;
} }
} }
a {
display: button;
}
} }
#header_bar { #header_bar {
@ -243,16 +252,6 @@ header {
flex: initial; flex: initial;
list-style-type: none; list-style-type: none;
margin: 0.2em 0.2em; margin: 0.2em 0.2em;
/*
PINKTOBER
& .fa.fa-times {
color: $pinktober-bar-closed !important;
}
& .fa.fa-check {
color: $pinktober-bar-opened !important;
}*/
} }
#header_search { #header_search {
@ -444,6 +443,36 @@ header {
} }
} }
.btn {
font-size: 15px;
font-weight: normal;
color: white;
min-width: 60px;
padding: 5px 10px;
border: none;
text-decoration: none;
&.btn-blue {
background-color: #354a5f;
}
&.btn-blue:disabled {
background-color: rgba(70, 90, 126, 0.4);
}
&.btn-blue.clickable:not(:disabled):hover {
background-color: #2c3646;
}
&.btn-grey {
background-color: grey;
}
&.btn-grey.clickable:not(:disabled):hover {
background-color:hsl(210,5%,30%);
}
}
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#quick_notif { #quick_notif {
width: 100%; width: 100%;
@ -465,10 +494,7 @@ header {
.alert { .alert {
margin: 10px; margin: 10px;
border: #fc8181 1px solid;
background-color: rgb(255,245,245);
border-radius: 4px; border-radius: 4px;
color: #c53030;
padding: 12px 16px; padding: 12px 16px;
display: flex; display: flex;
gap: 16px; gap: 16px;
@ -476,6 +502,18 @@ header {
align-items: center; align-items: center;
text-align: justify; text-align: justify;
&.alert-green {
background-color: rgb(245, 255, 245);
color: rgb(3, 84, 63);
border: rgb(14, 159, 110) 1px solid;
}
&.alert-red {
background-color: rgb(255,245,245);
color: #c53030;
border: #fc8181 1px solid;
}
.alert-main { .alert-main {
flex: 2; flex: 2;
} }
@ -1496,7 +1534,7 @@ textarea {
margin: 10px 0; margin: 10px 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
height: 20p; height: 20px;
align-items: center; align-items: center;
} }
.search_check { .search_check {

View File

@ -67,7 +67,7 @@ from core.views.forms import (
) )
from core.models import User, SithFile, Preferences, Gift from core.models import User, SithFile, Preferences, Gift
from subscription.models import Subscription from subscription.models import Subscription
from counter.views import StudentCardForm from counter.forms import StudentCardForm
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm

View File

@ -36,6 +36,11 @@ class CustomerAdmin(SearchModelAdmin):
search_fields = ["account_id"] search_fields = ["account_id"]
@admin.register(BillingInfo)
class BillingInfoAdmin(admin.ModelAdmin):
list_display = ("first_name", "last_name", "address_1", "city", "country")
admin.site.register(Customer, CustomerAdmin) admin.site.register(Customer, CustomerAdmin)
admin.site.register(Product, ProductAdmin) admin.site.register(Product, ProductAdmin)
admin.site.register(ProductType) admin.site.register(ProductType)

177
counter/forms.py Normal file
View File

@ -0,0 +1,177 @@
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from django import forms
from django.utils.translation import gettext_lazy as _
from core.views.forms import TzAwareDateTimeField, SelectDate
from counter.models import (
BillingInfo,
StudentCard,
Customer,
Refilling,
Counter,
Product,
Eticket,
)
class BillingInfoForm(forms.ModelForm):
class Meta:
model = BillingInfo
exclude = ["customer"]
class StudentCardForm(forms.ModelForm):
"""
Form for adding student cards
Only used for user profile since CounterClick is to complicated
"""
class Meta:
model = StudentCard
fields = ["uid"]
def clean(self):
cleaned_data = super(StudentCardForm, self).clean()
uid = cleaned_data.get("uid", None)
if not uid or not StudentCard.is_valid(uid):
raise forms.ValidationError(_("This UID is invalid"), code="invalid")
return cleaned_data
class GetUserForm(forms.Form):
"""
The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
some nickname, first name, or last name (TODO)
"""
code = forms.CharField(
label="Code", max_length=StudentCard.UID_SIZE, required=False
)
id = AutoCompleteSelectField(
"users", required=False, label=_("Select user"), help_text=None
)
def as_p(self):
self.fields["code"].widget.attrs["autofocus"] = True
return super(GetUserForm, self).as_p()
def clean(self):
cleaned_data = super(GetUserForm, self).clean()
cus = None
if cleaned_data["code"] != "":
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = StudentCard.objects.filter(uid=cleaned_data["code"])
if card.exists():
cus = card.first().customer
if cus is None:
cus = Customer.objects.filter(
account_id__iexact=cleaned_data["code"]
).first()
elif cleaned_data["id"] is not None:
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
if cus is None or not cus.can_buy:
raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = cus.user.id
cleaned_data["user"] = cus.user
return cleaned_data
class RefillForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
amount = forms.FloatField(
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
)
class Meta:
model = Refilling
fields = ["amount", "payment_method", "bank"]
class CounterEditForm(forms.ModelForm):
class Meta:
model = Counter
fields = ["sellers", "products"]
sellers = make_ajax_field(Counter, "sellers", "users", help_text="")
products = make_ajax_field(Counter, "products", "products", help_text="")
class ProductEditForm(forms.ModelForm):
class Meta:
model = Product
fields = [
"name",
"description",
"product_type",
"code",
"parent_product",
"buying_groups",
"purchase_price",
"selling_price",
"special_selling_price",
"icon",
"club",
"limit_age",
"tray",
"archived",
]
parent_product = AutoCompleteSelectField(
"products", show_help_text=False, label=_("Parent product"), required=False
)
buying_groups = AutoCompleteSelectMultipleField(
"groups",
show_help_text=False,
help_text="",
label=_("Buying groups"),
required=True,
)
club = AutoCompleteSelectField("clubs", show_help_text=False)
counters = AutoCompleteSelectMultipleField(
"counters",
show_help_text=False,
help_text="",
label=_("Counters"),
required=False,
)
def __init__(self, *args, **kwargs):
super(ProductEditForm, self).__init__(*args, **kwargs)
if self.instance.id:
self.fields["counters"].initial = [
str(c.id) for c in self.instance.counters.all()
]
def save(self, *args, **kwargs):
ret = super(ProductEditForm, self).save(*args, **kwargs)
if self.fields["counters"].initial:
for cid in self.fields["counters"].initial:
c = Counter.objects.filter(id=int(cid)).first()
c.products.remove(self.instance)
c.save()
for cid in self.cleaned_data["counters"]:
c = Counter.objects.filter(id=int(cid)).first()
c.products.add(self.instance)
c.save()
return ret
class CashSummaryFormBase(forms.Form):
begin_date = TzAwareDateTimeField(label=_("Begin date"), required=False)
end_date = TzAwareDateTimeField(label=_("End date"), required=False)
class EticketForm(forms.ModelForm):
class Meta:
model = Eticket
fields = ["product", "banner", "event_title", "event_date"]
widgets = {"event_date": SelectDate}
product = AutoCompleteSelectField(
"products", show_help_text=False, label=_("Product"), required=True
)

View File

@ -0,0 +1,55 @@
# Generated by Django 3.2.15 on 2022-11-14 13:26
from django.db import migrations, models
import django.db.models.deletion
import django_countries.fields
class Migration(migrations.Migration):
dependencies = [
("counter", "0018_producttype_priority"),
]
operations = [
migrations.CreateModel(
name="BillingInfo",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("first_name", models.CharField(max_length=30)),
("last_name", models.CharField(max_length=30)),
(
"address_1",
models.CharField(max_length=50, verbose_name="address line 1"),
),
(
"address_2",
models.CharField(
blank=True,
max_length=50,
null=True,
verbose_name="address line 2",
),
),
("zip_code", models.CharField(max_length=16, verbose_name="zip code")),
("city", models.CharField(max_length=50, verbose_name="city")),
("country", django_countries.fields.CountryField(max_length=2)),
(
"customer",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="billing_infos",
to="counter.customer",
),
),
],
),
]

View File

@ -21,6 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from django.db.models.functions import Length
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
from django.db import models from django.db import models
@ -38,16 +39,19 @@ import string
import os import os
import base64 import base64
import datetime import datetime
from dict2xml import dict2xml
from club.models import Club, Membership from club.models import Club, Membership
from accounting.models import CurrencyField from accounting.models import CurrencyField
from core.models import Group, User, Notification from core.models import Group, User, Notification
from subscription.models import Subscription from subscription.models import Subscription
from django_countries.fields import CountryField
class Customer(models.Model): class Customer(models.Model):
""" """
This class extends a user to make a customer. It adds some basic customers informations, such as the accound ID, and This class extends a user to make a customer. It adds some basic customers' information, such as the account ID, and
is used by other accounting classes as reference to the customer, rather than using User is used by other accounting classes as reference to the customer, rather than using User
""" """
@ -89,13 +93,28 @@ class Customer(models.Model):
.subscription_end .subscription_end
) < timedelta(days=90) ) < timedelta(days=90)
@staticmethod @classmethod
def generate_account_id(number): def new_for_user(cls, user: User):
number = str(number) """
letter = random.choice(string.ascii_lowercase) Create a new Customer instance for the user given in parameter without saving it
while Customer.objects.filter(account_id=number + letter).exists(): The account if is automatically generated and the amount set at 0
letter = random.choice(string.ascii_lowercase) """
return number + letter # account_id are number with a letter appended
account_id = (
Customer.objects.order_by(Length("account_id"), "account_id")
.values("account_id")
.last()
)
if account_id is None:
# legacy from the old site
return cls(user=user, account_id="1504a", amount=0)
account_id = account_id["account_id"]
num = int(account_id[:-1])
while Customer.objects.filter(account_id=account_id).exists():
num += 1
account_id = str(num) + random.choice(string.ascii_lowercase)
return cls(user=user, account_id=account_id, amount=0)
def save(self, allow_negative=False, is_selling=False, *args, **kwargs): def save(self, allow_negative=False, is_selling=False, *args, **kwargs):
""" """
@ -122,6 +141,53 @@ class Customer(models.Model):
return "".join(["https://", settings.SITH_URL, self.get_absolute_url()]) return "".join(["https://", settings.SITH_URL, self.get_absolute_url()])
class BillingInfo(models.Model):
"""
Represent the billing information of a user, which are required
by the 3D-Secure v2 system used by the etransaction module
"""
customer = models.OneToOneField(
Customer, related_name="billing_infos", on_delete=models.CASCADE
)
# declaring surname and name even though they are already defined
# in User add some redundancy, but ensures that the billing infos
# shall stay correct, whatever shenanigans the user commits on its profile
first_name = models.CharField(_("First name"), max_length=30)
last_name = models.CharField(_("Last name"), max_length=30)
address_1 = models.CharField(_("Address 1"), max_length=50)
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
city = models.CharField(_("City"), max_length=50)
country = CountryField(blank_label=_("Country"))
def to_3dsv2_xml(self) -> str:
"""
Convert the data from this model into a xml usable
by the online paying service of the Crédit Agricole bank.
see : `https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/manuel-dintegration-focus-3ds-v2/principes-generaux/#boutique-cms-utilisation-des-modules-up2pay-e-transactions-mise-a-jour-module`
"""
data = {
"Billing": {
"Address": {
"FirstName": self.first_name,
"LastName": self.last_name,
"Address1": self.address_1,
"ZipCode": self.zip_code,
"City": self.city,
"CountryCode": self.country,
}
}
}
if self.address_2:
data["Billing"]["Address"]["Address2"] = self.address_2
return dict2xml(data)
def __str__(self):
return f"{self.first_name} {self.last_name}"
class ProductType(models.Model): class ProductType(models.Model):
""" """
This describes a product type This describes a product type

View File

@ -21,7 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import json
import re import re
from django.test import TestCase from django.test import TestCase
@ -29,7 +29,7 @@ from django.urls import reverse
from django.core.management import call_command from django.core.management import call_command
from core.models import User from core.models import User
from counter.models import Counter from counter.models import Counter, Customer, BillingInfo
class CounterTest(TestCase): class CounterTest(TestCase):
@ -67,7 +67,7 @@ class CounterTest(TestCase):
response = self.client.get(response.get("location")) response = self.client.get(response.get("location"))
self.assertTrue(">Richard Batsbak</" in str(response.content)) self.assertTrue(">Richard Batsbak</" in str(response.content))
response = self.client.post( self.client.post(
location, location,
{ {
"action": "refill", "action": "refill",
@ -76,15 +76,11 @@ class CounterTest(TestCase):
"bank": "OTHER", "bank": "OTHER",
}, },
) )
response = self.client.post(location, {"action": "code", "code": "BARB"}) self.client.post(location, {"action": "code", "code": "BARB"})
response = self.client.post( self.client.post(location, {"action": "add_product", "product_id": "4"})
location, {"action": "add_product", "product_id": "4"} self.client.post(location, {"action": "del_product", "product_id": "4"})
) self.client.post(location, {"action": "code", "code": "2xdeco"})
response = self.client.post( self.client.post(location, {"action": "code", "code": "1xbarb"})
location, {"action": "del_product", "product_id": "4"}
)
response = self.client.post(location, {"action": "code", "code": "2xdeco"})
response = self.client.post(location, {"action": "code", "code": "1xbarb"})
response = self.client.post(location, {"action": "code", "code": "fin"}) response = self.client.post(location, {"action": "code", "code": "fin"})
response_get = self.client.get(response.get("location")) response_get = self.client.get(response.get("location"))
@ -96,7 +92,7 @@ class CounterTest(TestCase):
in str(response_content) in str(response_content)
) )
response = self.client.post( self.client.post(
reverse("counter:login", kwargs={"counter_id": self.mde.id}), reverse("counter:login", kwargs={"counter_id": self.mde.id}),
{"username": self.sli.username, "password": "plop"}, {"username": self.sli.username, "password": "plop"},
) )
@ -154,6 +150,234 @@ class CounterStatsTest(TestCase):
self.assertTrue(response.status_code == 403) self.assertTrue(response.status_code == 403)
class BillingInfoTest(TestCase):
@classmethod
def setUpClass(cls):
cls.payload_1 = {
"first_name": "Subscribed",
"last_name": "User",
"address_1": "1 rue des Huns",
"zip_code": "90000",
"city": "Belfort",
"country": "FR",
}
cls.payload_2 = {
"first_name": "Subscribed",
"last_name": "User",
"address_1": "3, rue de Troyes",
"zip_code": "34301",
"city": "Sète",
"country": "FR",
}
super().setUpClass()
call_command("populate")
def test_edit_infos(self):
user = User.objects.get(username="subscriber")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.login(username=user.username, password="plop")
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertEqual(200, response.status_code)
self.assertJSONEqual(response.content, {"errors": None})
self.assertTrue(hasattr(user.customer, "billing_infos"))
self.assertEqual(user.customer, infos.customer)
self.assertEqual("Subscribed", infos.first_name)
self.assertEqual("User", infos.last_name)
self.assertEqual("3, rue de Troyes", infos.address_1)
self.assertEqual(None, infos.address_2)
self.assertEqual("34301", infos.zip_code)
self.assertEqual("Sète", infos.city)
self.assertEqual("FR", infos.country)
def test_create_infos_for_user_with_account(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.login(username=user.username, password="plop")
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_1),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertEqual(200, response.status_code)
self.assertJSONEqual(response.content, {"errors": None})
self.assertTrue(hasattr(user.customer, "billing_infos"))
self.assertEqual(user.customer, infos.customer)
self.assertEqual("Subscribed", infos.first_name)
self.assertEqual("User", infos.last_name)
self.assertEqual("1 rue des Huns", infos.address_1)
self.assertEqual(None, infos.address_2)
self.assertEqual("90000", infos.zip_code)
self.assertEqual("Belfort", infos.city)
self.assertEqual("FR", infos.country)
def test_create_infos_for_user_without_account(self):
user = User.objects.get(username="subscriber")
if hasattr(user, "customer"):
user.customer.delete()
self.client.login(username=user.username, password="plop")
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_1),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
self.assertTrue(hasattr(user, "customer"))
self.assertTrue(hasattr(user.customer, "billing_infos"))
self.assertEqual(200, response.status_code)
self.assertJSONEqual(response.content, {"errors": None})
infos = BillingInfo.objects.get(customer__user=user)
self.assertEqual(user.customer, infos.customer)
self.assertEqual("Subscribed", infos.first_name)
self.assertEqual("User", infos.last_name)
self.assertEqual("1 rue des Huns", infos.address_1)
self.assertEqual(None, infos.address_2)
self.assertEqual("90000", infos.zip_code)
self.assertEqual("Belfort", infos.city)
self.assertEqual("FR", infos.country)
def test_create_invalid(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.login(username=user.username, password="plop")
# address_1, zip_code and country are missing
payload = {
"first_name": user.first_name,
"last_name": user.last_name,
"city": "Belfort",
}
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
self.assertEqual(400, response.status_code)
self.assertFalse(hasattr(user.customer, "billing_infos"))
expected_errors = {
"errors": [
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
]
}
self.assertJSONEqual(response.content, expected_errors)
def test_edit_invalid(self):
user = User.objects.get(username="subscriber")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.login(username=user.username, password="plop")
# address_1, zip_code and country are missing
payload = {
"first_name": user.first_name,
"last_name": user.last_name,
"city": "Belfort",
}
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
self.assertEqual(400, response.status_code)
self.assertTrue(hasattr(user.customer, "billing_infos"))
expected_errors = {
"errors": [
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
]
}
self.assertJSONEqual(response.content, expected_errors)
def test_edit_other_user(self):
user = User.objects.get(username="sli")
self.client.login(username="subscriber", password="plop")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(403, response.status_code)
def test_edit_not_existing_infos(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.login(username=user.username, password="plop")
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(404, response.status_code)
def test_edit_by_root(self):
user = User.objects.get(username="subscriber")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.login(username="root", password="plop")
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(200, response.status_code)
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertJSONEqual(response.content, {"errors": None})
self.assertTrue(hasattr(user.customer, "billing_infos"))
self.assertEqual(user.customer, infos.customer)
self.assertEqual("Subscribed", infos.first_name)
self.assertEqual("User", infos.last_name)
self.assertEqual("3, rue de Troyes", infos.address_1)
self.assertEqual(None, infos.address_2)
self.assertEqual("34301", infos.zip_code)
self.assertEqual("Sète", infos.city)
self.assertEqual("FR", infos.country)
def test_create_by_root(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.login(username="root", password="plop")
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(200, response.status_code)
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertJSONEqual(response.content, {"errors": None})
self.assertTrue(hasattr(user.customer, "billing_infos"))
self.assertEqual(user.customer, infos.customer)
self.assertEqual("Subscribed", infos.first_name)
self.assertEqual("User", infos.last_name)
self.assertEqual("3, rue de Troyes", infos.address_1)
self.assertEqual(None, infos.address_2)
self.assertEqual("34301", infos.zip_code)
self.assertEqual("Sète", infos.city)
self.assertEqual("FR", infos.country)
class BarmanConnectionTest(TestCase): class BarmanConnectionTest(TestCase):
def setUp(self): def setUp(self):
call_command("populate") call_command("populate")
@ -519,3 +743,20 @@ class StudentCardTest(TestCase):
{"uid": "8B90734A802A8F"}, {"uid": "8B90734A802A8F"},
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class AccountIdTest(TestCase):
def setUp(self):
user_a = User.objects.create(username="a", password="plop", email="a.a@a.fr")
user_b = User.objects.create(username="b", password="plop", email="b.b@b.fr")
user_c = User.objects.create(username="c", password="plop", email="c.c@c.fr")
Customer.objects.create(user=user_a, amount=0, account_id="1111a")
Customer.objects.create(user=user_b, amount=0, account_id="9999z")
Customer.objects.create(user=user_c, amount=0, account_id="12345f")
def test_create_customer(self):
user_d = User.objects.create(username="d", password="plop")
customer_d = Customer.new_for_user(user_d)
customer_d.save()
number = customer_d.account_id[:-1]
self.assertEqual(number, "12346")

View File

@ -22,7 +22,7 @@
# #
# #
from django.urls import re_path from django.urls import re_path, path
from counter.views import * from counter.views import *
@ -66,6 +66,16 @@ urlpatterns = [
StudentCardDeleteView.as_view(), StudentCardDeleteView.as_view(),
name="delete_student_card", name="delete_student_card",
), ),
path(
"customer/<int:user_id>/billing_info/create",
create_billing_info,
name="create_billing_info",
),
path(
"customer/<int:user_id>/billing_info/edit",
edit_billing_info,
name="edit_billing_info",
),
re_path(r"^admin/(?P<counter_id>[0-9]+)$", CounterEditView.as_view(), name="admin"), re_path(r"^admin/(?P<counter_id>[0-9]+)$", CounterEditView.as_view(), name="admin"),
re_path( re_path(
r"^admin/(?P<counter_id>[0-9]+)/prop$", r"^admin/(?P<counter_id>[0-9]+)/prop$",

View File

@ -21,10 +21,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import json
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.http import Http404 from django.http import Http404
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.views.decorators.http import require_POST
from django.views.generic import ListView, DetailView, RedirectView, TemplateView from django.views.generic import ListView, DetailView, RedirectView, TemplateView
from django.views.generic.base import View from django.views.generic.base import View
from django.views.generic.edit import ( from django.views.generic.edit import (
@ -49,12 +52,20 @@ import re
import pytz import pytz
from datetime import date, timedelta, datetime from datetime import date, timedelta, datetime
from http import HTTPStatus from http import HTTPStatus
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from ajax_select import make_ajax_field
from core.views import CanViewMixin, TabedViewMixin, CanEditMixin from core.views import CanViewMixin, TabedViewMixin, CanEditMixin
from core.views.forms import LoginForm, SelectDate, SelectDateTime from core.views.forms import LoginForm
from core.models import User from core.models import User
from counter.forms import (
BillingInfoForm,
StudentCardForm,
GetUserForm,
RefillForm,
CounterEditForm,
ProductEditForm,
CashSummaryFormBase,
EticketForm,
)
from subscription.models import Subscription from subscription.models import Subscription
from counter.models import ( from counter.models import (
Counter, Counter,
@ -68,9 +79,9 @@ from counter.models import (
CashRegisterSummaryItem, CashRegisterSummaryItem,
Eticket, Eticket,
Permanency, Permanency,
BillingInfo,
) )
from accounting.models import CurrencyField from accounting.models import CurrencyField
from core.views.forms import TzAwareDateTimeField
class CounterAdminMixin(View): class CounterAdminMixin(View):
@ -103,24 +114,6 @@ class CounterAdminMixin(View):
return super(CounterAdminMixin, self).dispatch(request, *args, **kwargs) return super(CounterAdminMixin, self).dispatch(request, *args, **kwargs)
class StudentCardForm(forms.ModelForm):
"""
Form for adding student cards
Only used for user profile since CounterClick is to complicated
"""
class Meta:
model = StudentCard
fields = ["uid"]
def clean(self):
cleaned_data = super(StudentCardForm, self).clean()
uid = cleaned_data.get("uid", None)
if not uid or not StudentCard.is_valid(uid):
raise forms.ValidationError(_("This UID is invalid"), code="invalid")
return cleaned_data
class StudentCardDeleteView(DeleteView, CanEditMixin): class StudentCardDeleteView(DeleteView, CanEditMixin):
""" """
View used to delete a card from a user View used to delete a card from a user
@ -140,59 +133,6 @@ class StudentCardDeleteView(DeleteView, CanEditMixin):
) )
class GetUserForm(forms.Form):
"""
The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
some nickname, first name, or last name (TODO)
"""
code = forms.CharField(
label="Code", max_length=StudentCard.UID_SIZE, required=False
)
id = AutoCompleteSelectField(
"users", required=False, label=_("Select user"), help_text=None
)
def as_p(self):
self.fields["code"].widget.attrs["autofocus"] = True
return super(GetUserForm, self).as_p()
def clean(self):
cleaned_data = super(GetUserForm, self).clean()
cus = None
if cleaned_data["code"] != "":
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = StudentCard.objects.filter(uid=cleaned_data["code"])
if card.exists():
cus = card.first().customer
if cus is None:
cus = Customer.objects.filter(
account_id__iexact=cleaned_data["code"]
).first()
elif cleaned_data["id"] is not None:
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
if cus is None or not cus.can_buy:
raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = cus.user.id
cleaned_data["user"] = cus.user
return cleaned_data
class RefillForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
amount = forms.FloatField(
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
)
class Meta:
model = Refilling
fields = ["amount", "payment_method", "bank"]
class CounterTabsMixin(TabedViewMixin): class CounterTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
if hasattr(self.object, "stock_owner"): if hasattr(self.object, "stock_owner"):
@ -867,15 +807,6 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
current_tab = "counters" current_tab = "counters"
class CounterEditForm(forms.ModelForm):
class Meta:
model = Counter
fields = ["sellers", "products"]
sellers = make_ajax_field(Counter, "sellers", "users", help_text="")
products = make_ajax_field(Counter, "products", "products", help_text="")
class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
""" """
Edit a counter's main informations (for the counter's manager) Edit a counter's main informations (for the counter's manager)
@ -995,66 +926,6 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
current_tab = "products" current_tab = "products"
class ProductEditForm(forms.ModelForm):
class Meta:
model = Product
fields = [
"name",
"description",
"product_type",
"code",
"parent_product",
"buying_groups",
"purchase_price",
"selling_price",
"special_selling_price",
"icon",
"club",
"limit_age",
"tray",
"archived",
]
parent_product = AutoCompleteSelectField(
"products", show_help_text=False, label=_("Parent product"), required=False
)
buying_groups = AutoCompleteSelectMultipleField(
"groups",
show_help_text=False,
help_text="",
label=_("Buying groups"),
required=True,
)
club = AutoCompleteSelectField("clubs", show_help_text=False)
counters = AutoCompleteSelectMultipleField(
"counters",
show_help_text=False,
help_text="",
label=_("Counters"),
required=False,
)
def __init__(self, *args, **kwargs):
super(ProductEditForm, self).__init__(*args, **kwargs)
if self.instance.id:
self.fields["counters"].initial = [
str(c.id) for c in self.instance.counters.all()
]
def save(self, *args, **kwargs):
ret = super(ProductEditForm, self).save(*args, **kwargs)
if self.fields["counters"].initial:
for cid in self.fields["counters"].initial:
c = Counter.objects.filter(id=int(cid)).first()
c.products.remove(self.instance)
c.save()
for cid in self.cleaned_data["counters"]:
c = Counter.objects.filter(id=int(cid)).first()
c.products.add(self.instance)
c.save()
return ret
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
""" """
A create view for the admins A create view for the admins
@ -1482,7 +1353,7 @@ class CounterStatView(DetailView, CounterAdminMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add stats to the context""" """Add stats to the context"""
from django.db.models import Sum, Case, When, F, DecimalField from django.db.models import Sum, Case, When, F
kwargs = super(CounterStatView, self).get_context_data(**kwargs) kwargs = super(CounterStatView, self).get_context_data(**kwargs)
kwargs["Customer"] = Customer kwargs["Customer"] = Customer
@ -1585,11 +1456,6 @@ class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
return reverse("counter:cash_summary_list") return reverse("counter:cash_summary_list")
class CashSummaryFormBase(forms.Form):
begin_date = TzAwareDateTimeField(label=_("Begin date"), required=False)
end_date = TzAwareDateTimeField(label=_("End date"), required=False)
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""Display a list of cash summaries""" """Display a list of cash summaries"""
@ -1669,7 +1535,7 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
end_date = (start_date + timedelta(days=32)).replace( end_date = (start_date + timedelta(days=32)).replace(
day=1, hour=0, minute=0, microsecond=0 day=1, hour=0, minute=0, microsecond=0
) )
from django.db.models import Sum, Case, When, F, DecimalField from django.db.models import Sum, Case, When, F
kwargs["sum_cb"] = sum( kwargs["sum_cb"] = sum(
[ [
@ -1725,17 +1591,6 @@ class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
current_tab = "etickets" current_tab = "etickets"
class EticketForm(forms.ModelForm):
class Meta:
model = Eticket
fields = ["product", "banner", "event_title", "event_date"]
widgets = {"event_date": SelectDate}
product = AutoCompleteSelectField(
"products", show_help_text=False, label=_("Product"), required=True
)
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
""" """
Create an eticket Create an eticket
@ -1895,3 +1750,55 @@ class StudentCardFormView(FormView):
return reverse_lazy( return reverse_lazy(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk} "core:user_prefs", kwargs={"user_id": self.customer.user.pk}
) )
def __manage_billing_info_req(request, user_id, delete_if_fail=False):
data = json.loads(request.body)
form = BillingInfoForm(data)
if not form.is_valid():
if delete_if_fail:
Customer.objects.get(user__id=user_id).billing_infos.delete()
errors = [
{"field": str(form.fields[k].label), "messages": v}
for k, v in form.errors.items()
]
content = json.dumps({"errors": errors})
return HttpResponse(status=400, content=content)
if form.is_valid():
infos = Customer.objects.get(user__id=user_id).billing_infos
for field in form.fields:
infos.__dict__[field] = form[field].value()
infos.save()
content = json.dumps({"errors": None})
return HttpResponse(status=200, content=content)
@login_required
@require_POST
def create_billing_info(request, user_id):
user = request.user
if user.id != user_id and not user.has_perm("counter:add_billinginfo"):
raise PermissionDenied()
user = get_object_or_404(User, pk=user_id)
if not hasattr(user, "customer"):
customer = Customer.new_for_user(user)
customer.save()
else:
customer = get_object_or_404(Customer, user_id=user_id)
BillingInfo.objects.create(customer=customer)
return __manage_billing_info_req(request, user_id, True)
@login_required
@require_POST
def edit_billing_info(request, user_id):
user = request.user
if user.id != user_id and not user.has_perm("counter:change_billinginfo"):
raise PermissionDenied()
user = get_object_or_404(User, pk=user_id)
if not hasattr(user, "customer"):
raise Http404
if not hasattr(user.customer, "billing_infos"):
raise Http404
return __manage_billing_info_req(request, user_id)

View File

@ -138,7 +138,7 @@ class BasketForm:
continue continue
if type(item["quantity"]) is not int or item["quantity"] < 0: if type(item["quantity"]) is not int or item["quantity"] < 0:
self.error_messages.add( self.error_messages.add(
_("You cannot buy %(nbr)d %(name)%s.") _("You cannot buy %(nbr)d %(name)s.")
% {"nbr": item["quantity"], "name": item["name"]} % {"nbr": item["quantity"], "name": item["name"]}
) )
continue continue
@ -166,7 +166,6 @@ class BasketForm:
return True return True
def get_error_messages(self) -> typing.List[str]: def get_error_messages(self) -> typing.List[str]:
# return [msg for msg in self.error_messages]
return list(self.error_messages) return list(self.error_messages)
def get_cleaned_cookie(self) -> str: def get_cleaned_cookie(self) -> str:

View File

@ -21,9 +21,12 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import hmac
import typing import typing
from datetime import datetime
from typing import List from typing import List
from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
from django.db import models, DataError from django.db import models, DataError
from django.db.models import Sum, F from django.db.models import Sum, F
@ -32,7 +35,7 @@ from django.utils.translation import gettext_lazy as _
from accounting.models import CurrencyField from accounting.models import CurrencyField
from core.models import Group, User 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]: def get_eboutic_products(user: User) -> List[Product]:
@ -104,7 +107,7 @@ class Basket(models.Model):
""" """
Remove all items from this basket without deleting the basket Remove all items from this basket without deleting the basket
""" """
BasketItem.objects.filter(basket=self).delete() self.items.all().delete()
@cached_property @cached_property
def contains_refilling_item(self) -> bool: def contains_refilling_item(self) -> bool:
@ -122,7 +125,7 @@ class Basket(models.Model):
def from_session(cls, session) -> typing.Union["Basket", None]: def from_session(cls, session) -> typing.Union["Basket", None]:
""" """
Given an HttpRequest django object, return the basket used in the current session 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: if "basket_id" in session:
try: try:
@ -131,6 +134,93 @@ class Basket(models.Model):
return None return None
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")),
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
(
"PBX_SHOPPINGCART",
dict2xml({"shoppingcart": {"total": {min(self.items.count(), 99)}}}),
),
]
data.append(
(
"PBX_HMAC",
(
hmac.new(
settings.SITH_EBOUTIC_HMAC_KEY,
bytes("&".join("=".join(d) for d in data), "utf-8"),
"sha512",
)
.hexdigest()
.upper()
),
)
)
return data
# def validate(self, exclude=None):
def __str__(self): def __str__(self):
return "%s's basket (%d items)" % (self.user, self.items.all().count()) return "%s's basket (%d items)" % (self.user, self.items.all().count())
@ -156,18 +246,9 @@ class Invoice(models.Model):
)["total"] )["total"]
return float(total) if total is not None else 0 return float(total) if total is not None else 0
def validate(self, *args, **kwargs): def validate(self):
if self.validated: if self.validated:
raise DataError(_("Invoice already 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() eboutic = Counter.objects.filter(type="EBOUTIC").first()
for i in self.items.all(): for i in self.items.all():
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
@ -227,6 +308,22 @@ class BasketItem(AbstractBaseItem):
Basket, related_name="items", verbose_name=_("basket"), on_delete=models.CASCADE 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): class InvoiceItem(AbstractBaseItem):
invoice = models.ForeignKey( invoice = models.ForeignKey(

View File

@ -41,20 +41,6 @@
} }
} }
#eboutic #basket .error-message {
margin-top: 5px;
background-color: #f8d7da;
border: #f5c6cb 1px solid;
border-radius: 4px;
padding: 10px;
display: flex;
flex-direction: column;
row-gap: 7px;
}
#eboutic #basket .error-message p {
margin: 0;
}
#eboutic .item-list { #eboutic .item-list {
margin-left: 0; margin-left: 0;
list-style: none; list-style: none;
@ -162,7 +148,7 @@
} }
#eboutic .catalog-buttons button { #eboutic .catalog-buttons button {
font-size: 15px; font-size: 15px!important;
font-weight: normal; font-weight: normal;
color: white; color: white;
min-width: 60px; min-width: 60px;

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"> <div id="basket">
<h3>Panier</h3> <h3>Panier</h3>
{% if errors %} {% if errors %}
<div class="error-message"> <div class="alert alert-red">
{% for error in errors %} <div class="alert-main">
<p>{{ error }}</p> {% for error in errors %}
{% endfor %} <p style="margin: 0">{{ error }}</p>
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %} {% endfor %}
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
</div>
</div> </div>
{% endif %} {% endif %}
<ul class="item-list"> <ul class="item-list">
@ -64,7 +66,7 @@
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
{% trans %}Clear{% endtrans %} {% trans %}Clear{% endtrans %}
</button> </button>
<form method="post" action="{{ url('eboutic:command') }}"> <form method="get" action="{{ url('eboutic:command') }}">
{% csrf_token %} {% csrf_token %}
<button class="validate"> <button class="validate">
<i class="fa fa-check"></i> <i class="fa fa-check"></i>
@ -75,7 +77,7 @@
</div> </div>
<div id="catalog"> <div id="catalog">
{% if not request.user.date_of_birth %} {% 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"> <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 %} {% 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) }}"> <a href="{{ url("core:user_edit", user_id=request.user.id) }}">

View File

@ -1,69 +1,143 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title %} {% 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 %} {% endblock %}
{% block content %} {% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<div> <div>
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<table> <table>
<thead> <thead>
<tr> <tr>
<td>Article</td> <td>Article</td>
<td>Quantity</td> <td>Quantity</td>
<td>Unit price</td> <td>Unit price</td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in basket.items.all() %} {% for item in basket.items.all() %}
<tr> <tr>
<td>{{ item.product_name }}</td> <td>{{ item.product_name }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.product_unit_price }} €</td> <td>{{ item.product_unit_price }} €</td>
</tr> </tr>
{% endfor %} {% endfor %}
<tbody> <tbody>
</table> </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> <p>
{% for (field_name,field_value) in et_request.items() -%} <strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} €</strong>
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}">
{% endfor %} {% if customer_amount != None %}
<input type="submit" value="{% trans %}Pay with credit card{% endtrans %}" /> <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> </p>
</form> <br>
{% endif %} {% if settings.SITH_EBOUTIC_CB_ENABLED %}
{% if basket.contains_refilling_item %} <div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p> <div class="collapse-header clickable" @click="collapsed = !collapsed">
{% else %} <span class="collapse-header-text">
<form method="post" action="{{ url('eboutic:pay_with_sith') }}"> {% trans %}Edit billing information{% endtrans %}
{% csrf_token %} </span>
<input type="hidden" name="action" value="pay_with_sith_account"> <span class="collapse-header-icon" :class="{'reverse': collapsed}">
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}" /> <i class="fa fa-caret-down"></i>
</form> </span>
{% endif %} </div>
</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">
{% csrf_token %}
<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 %} {% 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 base64
import json import json
import re
import urllib import urllib
from OpenSSL import crypto from OpenSSL import crypto
@ -40,18 +39,19 @@ from eboutic.models import Basket
class EbouticTest(TestCase): class EbouticTest(TestCase):
def setUp(self): @classmethod
def setUpTestData(cls):
call_command("populate") call_command("populate")
self.skia = User.objects.filter(username="skia").first() cls.barbar = Product.objects.filter(code="BARB").first()
self.subscriber = User.objects.filter(username="subscriber").first() cls.refill = Product.objects.filter(code="15REFILL").first()
self.old_subscriber = User.objects.filter(username="old_subscriber").first() cls.cotis = Product.objects.filter(code="1SCOTIZ").first()
self.public = User.objects.filter(username="public").first() cls.eboutic = Counter.objects.filter(name="Eboutic").first()
self.barbar = Product.objects.filter(code="BARB").first() cls.skia = User.objects.filter(username="skia").first()
self.refill = Product.objects.filter(code="15REFILL").first() cls.subscriber = User.objects.filter(username="subscriber").first()
self.cotis = Product.objects.filter(code="1SCOTIZ").first() cls.old_subscriber = User.objects.filter(username="old_subscriber").first()
self.eboutic = Counter.objects.filter(name="Eboutic").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. Create and return a basket with 3 barbar and 1 cotis in it.
Edit the client session to store the basket id 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) basket.add_product(self.cotis)
return basket return basket
def generate_bank_valid_answer_from_page_content(self, content): def generate_bank_valid_answer(self) -> str:
content = str(content) basket = Basket.from_session(self.client.session)
basket_id = re.search(r"PBX_CMD\" value=\"(\d*)\"", content).group(1) basket_id = basket.id
amount = re.search(r"PBX_TOTAL\" value=\"(\d*)\"", content).group(1) amount = int(basket.get_total() * 100)
query = "Amount=%s&BasketID=%s&Auto=42&Error=00000" % (amount, basket_id) query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
with open("./eboutic/tests/private_key.pem") as f: with open("./eboutic/tests/private_key.pem") as f:
PRIVKEY = f.read() PRIVKEY = f.read()
with open("./eboutic/tests/public_key.pem") as f: with open("./eboutic/tests/public_key.pem") as f:
@ -81,8 +81,7 @@ class EbouticTest(TestCase):
query, query,
urllib.parse.quote_plus(b64sig), urllib.parse.quote_plus(b64sig),
) )
response = self.client.get(url) return url
return response
def test_buy_with_sith_account(self): def test_buy_with_sith_account(self):
self.client.login(username="subscriber", password="plop") self.client.login(username="subscriber", password="plop")
@ -102,7 +101,7 @@ class EbouticTest(TestCase):
def test_buy_with_sith_account_no_money(self): def test_buy_with_sith_account_no_money(self):
self.client.login(username="subscriber", password="plop") self.client.login(username="subscriber", password="plop")
basket = self.get_busy_basket(self.subscriber) 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.amount = initial
self.subscriber.customer.save() self.subscriber.customer.save()
response = self.client.post(reverse("eboutic:pay_with_sith")) 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": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7} {"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.assertEqual(response.status_code, 200)
self.assertInHTML( self.assertInHTML(
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>", "<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): def test_submit_empty_basket(self):
self.client.login(username="subscriber", password="plop") self.client.login(username="subscriber", password="plop")
self.client.cookies["basket_items"] = "[]" self.client.cookies["basket_items"] = "[]"
response = self.client.post(reverse("eboutic:command")) response = self.client.get(reverse("eboutic:command"))
self.assertRedirects(response, "/eboutic/") self.assertRedirects(response, "/eboutic/")
def test_submit_invalid_basket(self): def test_submit_invalid_basket(self):
@ -157,7 +156,7 @@ class EbouticTest(TestCase):
] = f"""[ ] = f"""[
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}} {{"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( self.assertIn(
'basket_items=""', 'basket_items=""',
self.client.cookies["basket_items"].OutputString(), self.client.cookies["basket_items"].OutputString(),
@ -175,7 +174,7 @@ class EbouticTest(TestCase):
] = """[ ] = """[
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7} {"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/") self.assertRedirects(response, "/eboutic/")
def test_buy_subscribe_product_with_credit_card(self): 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} {"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( self.assertInHTML(
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>", "<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
response.content.decode(), response.content.decode(),
) )
basket = Basket.objects.get(id=self.client.session["basket_id"]) basket = Basket.objects.get(id=self.client.session["basket_id"])
self.assertEqual(basket.items.count(), 1) 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.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "Payment successful") 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}] [{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
) )
initial_balance = self.subscriber.customer.amount 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.status_code == 200)
self.assertTrue(response.content.decode() == "Payment successful") self.assertTrue(response.content.decode() == "Payment successful")
new_balance = Customer.objects.get(user=self.subscriber).amount new_balance = Customer.objects.get(user=self.subscriber).amount
@ -228,14 +228,15 @@ class EbouticTest(TestCase):
self.client.cookies["basket_items"] = json.dumps( self.client.cookies["basket_items"] = json.dumps(
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}] [{"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( self.client.cookies["basket_items"] = json.dumps(
[ # alter basket [ # alter basket
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7} {"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
] ]
) )
self.client.post(reverse("eboutic:command")) self.client.get(reverse("eboutic:command"))
response = self.generate_bank_valid_answer_from_page_content(response.content) response = self.client.get(et_answer_url)
self.assertEqual(response.status_code, 500) self.assertEqual(response.status_code, 500)
self.assertIn( self.assertIn(
"Basket processing failed with error: SuspiciousOperation('Basket total and amount do not match'", "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( self.client.cookies["basket_items"] = json.dumps(
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}] [{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
) )
response = self.client.post(reverse("eboutic:command")) self.client.get(reverse("eboutic:command"))
response = self.generate_bank_valid_answer_from_page_content(response.content) et_answer_url = self.generate_bank_valid_answer()
response = self.client.get(et_answer_url)
self.assertTrue(response.status_code == 200) self.assertTrue(response.status_code == 200)
self.assertTrue(response.content.decode("utf-8") == "Payment successful") self.assertTrue(response.content.decode("utf-8") == "Payment successful")

View File

@ -34,8 +34,9 @@ urlpatterns = [
# Subscription views # Subscription views
path("", eboutic_main, name="main"), path("", eboutic_main, name="main"),
path("command/", EbouticCommand.as_view(), name="command"), 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("pay/<res:result>/", payment_result, name="payment_result"),
path("et_data/", e_transaction_data, name="et_data"),
path( path(
"et_autoanswer", "et_autoanswer",
EtransactionAutoAnswer.as_view(), EtransactionAutoAnswer.as_view(),

View File

@ -23,13 +23,10 @@
# #
import base64 import base64
import hmac
import json import json
from collections import OrderedDict
from datetime import datetime from datetime import datetime
import sentry_sdk import sentry_sdk
from OpenSSL import crypto from OpenSSL import crypto
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -41,7 +38,8 @@ from django.utils.decorators import method_decorator
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from django.views.generic import TemplateView, View 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.forms import BasketForm
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
@ -85,11 +83,11 @@ class EbouticCommand(TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja" template_name = "eboutic/eboutic_makecommand.jinja"
@method_decorator(login_required) @method_decorator(login_required)
def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
return redirect("eboutic:main") return redirect("eboutic:main")
@method_decorator(login_required) @method_decorator(login_required)
def post(self, request: HttpRequest, *args, **kwargs): def get(self, request: HttpRequest, *args, **kwargs):
form = BasketForm(request) form = BasketForm(request)
if not form.is_valid(): if not form.is_valid():
request.session["errors"] = form.get_error_messages() request.session["errors"] = form.get_error_messages()
@ -98,65 +96,56 @@ class EbouticCommand(TemplateView):
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic") res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
return res return res
if "basket_id" in request.session: basket = Basket.from_session(request.session)
basket, _ = Basket.objects.get_or_create( if basket is not None:
id=request.session["basket_id"], user=request.user
)
basket.clear() basket.clear()
else: else:
basket = Basket.objects.create(user=request.user) basket = Basket.objects.create(user=request.user)
request.session["basket_id"] = basket.id
request.session.modified = True
basket.save() items = json.loads(request.COOKIES["basket_items"])
eboutique = Counter.objects.get(type="EBOUTIC") items.sort(key=lambda item: item["id"])
for item in json.loads(request.COOKIES["basket_items"]): ids = [item["id"] for item in items]
basket.add_product( quantities = [item["quantity"] for item in items]
eboutique.products.get(id=(item["id"])), item["quantity"] products = Product.objects.filter(id__in=ids)
) for product, qty in zip(products, quantities):
request.session["basket_id"] = basket.id basket.add_product(product, qty)
request.session.modified = True
kwargs["basket"] = basket kwargs["basket"] = basket
return self.render_to_response(self.get_context_data(**kwargs)) return self.render_to_response(self.get_context_data(**kwargs))
def get_context_data(self, **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"): 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: else:
kwargs["customer_amount"] = None kwargs["customer_amount"] = None
kwargs["et_request"] = OrderedDict() kwargs["must_fill_billing_infos"] = default_billing_info is None
kwargs["et_request"]["PBX_SITE"] = settings.SITH_EBOUTIC_PBX_SITE if not kwargs["must_fill_billing_infos"]:
kwargs["et_request"]["PBX_RANG"] = settings.SITH_EBOUTIC_PBX_RANG # the user has already filled its billing_infos, thus we can
kwargs["et_request"]["PBX_IDENTIFIANT"] = settings.SITH_EBOUTIC_PBX_IDENTIFIANT # get it without expecting an error
kwargs["et_request"]["PBX_TOTAL"] = int(kwargs["basket"].get_total() * 100) data = kwargs["basket"].get_e_transaction_data()
kwargs["et_request"][ data = {"data": [{"key": key, "value": val} for key, val in data]}
"PBX_DEVISE" kwargs["billing_infos"] = json.dumps(data)
] = 978 # This is Euro. ET support only this value anyway kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
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()
)
return kwargs 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 @login_required
@require_POST @require_POST
def pay_with_sith(request): def pay_with_sith(request):
@ -171,24 +160,14 @@ def pay_with_sith(request):
res = redirect("eboutic:payment_result", "failure") res = redirect("eboutic:payment_result", "failure")
else: else:
eboutic = Counter.objects.filter(type="EBOUTIC").first() eboutic = Counter.objects.filter(type="EBOUTIC").first()
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
try: try:
with transaction.atomic(): with transaction.atomic():
for it in basket.items.all(): for sale in sales:
product = eboutic.products.get(id=it.product_id) sale.save()
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()
basket.delete() basket.delete()
request.session.pop("basket_id", None) request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success") res = redirect("eboutic:payment_result", "success")
except DatabaseError as e: except DatabaseError as e:
with sentry_sdk.push_scope() as scope: with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username} scope.user = {"username": request.user.username}
@ -205,12 +184,8 @@ class EtransactionAutoAnswer(View):
# Response documentation http://www1.paybox.com/espace-integrateur-documentation # Response documentation http://www1.paybox.com/espace-integrateur-documentation
# /la-solution-paybox-system/gestion-de-la-reponse/ # /la-solution-paybox-system/gestion-de-la-reponse/
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if ( required = {"Amount", "BasketID", "Error", "Sig"}
not "Amount" in request.GET.keys() if not required.issubset(set(request.GET.keys())):
or not "BasketID" in request.GET.keys()
or not "Error" in request.GET.keys()
or not "Sig" in request.GET.keys()
):
return HttpResponse("Bad arguments", status=400) return HttpResponse("Bad arguments", status=400)
key = crypto.load_publickey(crypto.FILETYPE_PEM, settings.SITH_EBOUTIC_PUB_KEY) key = crypto.load_publickey(crypto.FILETYPE_PEM, settings.SITH_EBOUTIC_PUB_KEY)
cert = crypto.X509() cert = crypto.X509()

View File

@ -41,7 +41,8 @@ from club.models import Club
from core.views import CanViewMixin, CanEditMixin, CanEditPropMixin, CanCreateMixin from core.views import CanViewMixin, CanEditMixin, CanEditPropMixin, CanCreateMixin
from launderette.models import Launderette, Token, Machine, Slot from launderette.models import Launderette, Token, Machine, Slot
from counter.models import Counter, Customer, Selling from counter.models import Counter, Customer, Selling
from counter.views import GetUserForm from counter.forms import GetUserForm
# For users # For users

File diff suppressed because it is too large Load Diff

652
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,8 @@ psycopg2-binary = "2.9.3"
sentry-sdk = "^1.4.3" sentry-sdk = "^1.4.3"
pygraphviz = "^1.9" pygraphviz = "^1.9"
Jinja2 = "^3.1" Jinja2 = "^3.1"
django-countries = "^7.4.2"
dict2xml = "^1.7.2"
# Extra optional dependencies # Extra optional dependencies
mysqlclient = { version = "^2.0.3", optional = true } mysqlclient = { version = "^2.0.3", optional = true }

View File

@ -97,19 +97,12 @@ class Subscription(models.Model):
# TODO see SubscriptionForm's clean method # TODO see SubscriptionForm's clean method
raise ValidationError(_("Subscription error")) raise ValidationError(_("Subscription error"))
def save(self): def save(self, *args, **kwargs):
super(Subscription, self).save() super(Subscription, self).save()
from counter.models import Customer from counter.models import Customer
if not Customer.objects.filter(user=self.member).exists(): if not Customer.objects.filter(user=self.member).exists():
last_id = ( Customer.new_for_user(self.member).save()
Customer.objects.count() + 1504
) # Number to keep a continuity with the old site
Customer(
user=self.member,
account_id=Customer.generate_account_id(last_id + 1),
amount=0,
).save()
form = PasswordResetForm({"email": self.member.email}) form = PasswordResetForm({"email": self.member.email})
if form.is_valid(): if form.is_valid():
form.save( form.save(