mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-23 08:11:19 +00:00
Merge pull request #500 from ae-utbm/eboutic-3DSv2-patch
integration of 3D secure v2 for eboutic bank payment
This commit is contained in:
commit
b58116b023
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ db.sqlite3
|
|||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
dist/
|
dist/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea
|
||||||
env/
|
env/
|
||||||
doc/html
|
doc/html
|
||||||
data/
|
data/
|
||||||
|
@ -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",
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
177
counter/forms.py
Normal 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
|
||||||
|
)
|
55
counter/migrations/0019_billinginfo.py
Normal file
55
counter/migrations/0019_billinginfo.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
|
267
counter/tests.py
267
counter/tests.py
@ -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")
|
||||||
|
@ -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$",
|
||||||
|
231
counter/views.py
231
counter/views.py
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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(
|
||||||
|
@ -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;
|
73
eboutic/static/eboutic/js/makecommand.js
Normal file
73
eboutic/static/eboutic/js/makecommand.js
Normal 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;
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -25,12 +25,14 @@
|
|||||||
<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">
|
||||||
|
<div class="alert-main">
|
||||||
{% for error in errors %}
|
{% for error in errors %}
|
||||||
<p>{{ error }}</p>
|
<p style="margin: 0">{{ error }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
|
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul class="item-list">
|
<ul class="item-list">
|
||||||
{# Starting money #}
|
{# Starting money #}
|
||||||
@ -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) }}">
|
||||||
|
@ -1,13 +1,22 @@
|
|||||||
{% 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>
|
||||||
@ -33,7 +42,8 @@
|
|||||||
|
|
||||||
{% if customer_amount != None %}
|
{% if customer_amount != None %}
|
||||||
<br>
|
<br>
|
||||||
{% trans %}Current account amount: {% endtrans %}<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
{% trans %}Current account amount: {% endtrans %}
|
||||||
|
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||||
|
|
||||||
{% if not basket.contains_refilling_item %}
|
{% if not basket.contains_refilling_item %}
|
||||||
<br>
|
<br>
|
||||||
@ -42,28 +52,92 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<br>
|
||||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
||||||
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}">
|
<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>
|
<p>
|
||||||
{% for (field_name,field_value) in et_request.items() -%}
|
<i>
|
||||||
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}">
|
{% trans %}You must fill your billing infos if you want to pay with your credit
|
||||||
{% endfor %}
|
card{% endtrans %}
|
||||||
<input type="submit" value="{% trans %}Pay with credit card{% endtrans %}" />
|
</i>
|
||||||
</p>
|
</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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if basket.contains_refilling_item %}
|
{% if basket.contains_refilling_item %}
|
||||||
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}">
|
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="pay_with_sith_account">
|
<input type="hidden" name="action" value="pay_with_sith_account">
|
||||||
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}" />
|
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
||||||
|
|
||||||
|
@ -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")
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
|
115
eboutic/views.py
115
eboutic/views.py
@ -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)
|
||||||
|
|
||||||
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["basket_id"] = basket.id
|
||||||
request.session.modified = True
|
request.session.modified = True
|
||||||
|
|
||||||
|
items = json.loads(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
|
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,21 +160,11 @@ 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")
|
||||||
@ -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()
|
||||||
|
@ -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
652
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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 }
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user