mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 07:41:14 +00:00
integration of 3D secure v2 for eboutic bank payment
This commit is contained in:
parent
e43d53e564
commit
fe9e5ce861
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ db.sqlite3
|
||||
pyrightconfig.json
|
||||
dist/
|
||||
.vscode/
|
||||
.idea
|
||||
env/
|
||||
doc/html
|
||||
data/
|
||||
|
@ -26,6 +26,7 @@ import os
|
||||
from datetime import date, datetime, timedelta
|
||||
from io import StringIO, BytesIO
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management import call_command
|
||||
from django.conf import settings
|
||||
@ -73,7 +74,7 @@ class Command(BaseCommand):
|
||||
root_path = os.path.dirname(
|
||||
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="Subscribers").save()
|
||||
Group(name="Old subscribers").save()
|
||||
@ -87,6 +88,11 @@ class Command(BaseCommand):
|
||||
Group(name="Forum admin").save()
|
||||
Group(name="Pedagogy admin").save()
|
||||
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(
|
||||
id=0,
|
||||
username="root",
|
||||
|
@ -49,36 +49,27 @@ body {
|
||||
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;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
box-shadow: $shadow-color 0px 0px 1px;
|
||||
&:hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
input[type=button], input[type=submit], input[type=reset],input[type=file] {
|
||||
font-weight: bold;
|
||||
}
|
||||
button{
|
||||
border: none;
|
||||
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;
|
||||
|
||||
button:not(:disabled), input[type=button]:not(:disabled), input[type=submit]:not(:disabled), input[type=reset]:not(:disabled),input[type=file]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
}
|
||||
|
||||
input,textarea[type=text],[type=number]{
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
@ -123,6 +114,38 @@ a {
|
||||
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 {
|
||||
width: 75%;
|
||||
}
|
||||
@ -135,10 +158,12 @@ a {
|
||||
width: 23%;
|
||||
}
|
||||
|
||||
.clickable:hover {
|
||||
.clickable:not(:disabled):hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/*--------------------------------HEADER-------------------------------*/
|
||||
|
||||
#header_language_chooser {
|
||||
@ -170,21 +195,11 @@ header {
|
||||
background-color: $primary-neutral-dark-color;
|
||||
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 {
|
||||
background-color: $white-color;
|
||||
padding: 0.2em;
|
||||
border-radius: 0px 0px 0px 9px;
|
||||
|
||||
//PINKTOBER
|
||||
// border-bottom: 5px solid $shadow-color;
|
||||
// border-radius: 0px 0px 0px 5px;
|
||||
// margin-bottom: -5px;
|
||||
border-radius: 0 0 0 9px;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
@ -211,14 +226,8 @@ header {
|
||||
width: 100%;
|
||||
label {
|
||||
display: inline;
|
||||
|
||||
// PINKTOBER
|
||||
// color: $pinktober-primary-text;
|
||||
}
|
||||
}
|
||||
a {
|
||||
display: button;
|
||||
}
|
||||
}
|
||||
|
||||
#header_bar {
|
||||
@ -243,16 +252,6 @@ header {
|
||||
flex: initial;
|
||||
list-style-type: none;
|
||||
margin: 0.2em 0.2em;
|
||||
|
||||
/*
|
||||
PINKTOBER
|
||||
& .fa.fa-times {
|
||||
color: $pinktober-bar-closed !important;
|
||||
}
|
||||
|
||||
& .fa.fa-check {
|
||||
color: $pinktober-bar-opened !important;
|
||||
}*/
|
||||
}
|
||||
|
||||
#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------------------------------*/
|
||||
#quick_notif {
|
||||
width: 100%;
|
||||
@ -465,10 +494,7 @@ header {
|
||||
|
||||
.alert {
|
||||
margin: 10px;
|
||||
border: #fc8181 1px solid;
|
||||
background-color: rgb(255,245,245);
|
||||
border-radius: 4px;
|
||||
color: #c53030;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@ -476,6 +502,18 @@ header {
|
||||
align-items: center;
|
||||
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 {
|
||||
flex: 2;
|
||||
}
|
||||
@ -1496,7 +1534,7 @@ textarea {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: 20p;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.search_check {
|
||||
|
@ -67,7 +67,7 @@ from core.views.forms import (
|
||||
)
|
||||
from core.models import User, SithFile, Preferences, Gift
|
||||
from subscription.models import Subscription
|
||||
from counter.views import StudentCardForm
|
||||
from counter.forms import StudentCardForm
|
||||
from trombi.views import UserTrombiForm
|
||||
|
||||
|
||||
|
@ -36,6 +36,11 @@ class CustomerAdmin(SearchModelAdmin):
|
||||
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(Product, ProductAdmin)
|
||||
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.
|
||||
#
|
||||
#
|
||||
from django.db.models.functions import Length
|
||||
|
||||
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
||||
from django.db import models
|
||||
@ -38,16 +39,19 @@ import string
|
||||
import os
|
||||
import base64
|
||||
import datetime
|
||||
from dict2xml import dict2xml
|
||||
|
||||
from club.models import Club, Membership
|
||||
from accounting.models import CurrencyField
|
||||
from core.models import Group, User, Notification
|
||||
from subscription.models import Subscription
|
||||
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@ -89,13 +93,28 @@ class Customer(models.Model):
|
||||
.subscription_end
|
||||
) < timedelta(days=90)
|
||||
|
||||
@staticmethod
|
||||
def generate_account_id(number):
|
||||
number = str(number)
|
||||
letter = random.choice(string.ascii_lowercase)
|
||||
while Customer.objects.filter(account_id=number + letter).exists():
|
||||
letter = random.choice(string.ascii_lowercase)
|
||||
return number + letter
|
||||
@classmethod
|
||||
def new_for_user(cls, user: User):
|
||||
"""
|
||||
Create a new Customer instance for the user given in parameter without saving it
|
||||
The account if is automatically generated and the amount set at 0
|
||||
"""
|
||||
# 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):
|
||||
"""
|
||||
@ -122,6 +141,53 @@ class Customer(models.Model):
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
#
|
||||
#
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.test import TestCase
|
||||
@ -29,7 +29,7 @@ from django.urls import reverse
|
||||
from django.core.management import call_command
|
||||
|
||||
from core.models import User
|
||||
from counter.models import Counter
|
||||
from counter.models import Counter, Customer, BillingInfo
|
||||
|
||||
|
||||
class CounterTest(TestCase):
|
||||
@ -67,7 +67,7 @@ class CounterTest(TestCase):
|
||||
response = self.client.get(response.get("location"))
|
||||
self.assertTrue(">Richard Batsbak</" in str(response.content))
|
||||
|
||||
response = self.client.post(
|
||||
self.client.post(
|
||||
location,
|
||||
{
|
||||
"action": "refill",
|
||||
@ -76,15 +76,11 @@ class CounterTest(TestCase):
|
||||
"bank": "OTHER",
|
||||
},
|
||||
)
|
||||
response = self.client.post(location, {"action": "code", "code": "BARB"})
|
||||
response = self.client.post(
|
||||
location, {"action": "add_product", "product_id": "4"}
|
||||
)
|
||||
response = self.client.post(
|
||||
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"})
|
||||
self.client.post(location, {"action": "code", "code": "BARB"})
|
||||
self.client.post(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"})
|
||||
self.client.post(location, {"action": "code", "code": "1xbarb"})
|
||||
response = self.client.post(location, {"action": "code", "code": "fin"})
|
||||
|
||||
response_get = self.client.get(response.get("location"))
|
||||
@ -96,7 +92,7 @@ class CounterTest(TestCase):
|
||||
in str(response_content)
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
self.client.post(
|
||||
reverse("counter:login", kwargs={"counter_id": self.mde.id}),
|
||||
{"username": self.sli.username, "password": "plop"},
|
||||
)
|
||||
@ -154,6 +150,234 @@ class CounterStatsTest(TestCase):
|
||||
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):
|
||||
def setUp(self):
|
||||
call_command("populate")
|
||||
@ -519,3 +743,20 @@ class StudentCardTest(TestCase):
|
||||
{"uid": "8B90734A802A8F"},
|
||||
)
|
||||
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 *
|
||||
|
||||
@ -66,6 +66,16 @@ urlpatterns = [
|
||||
StudentCardDeleteView.as_view(),
|
||||
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]+)/prop$",
|
||||
|
231
counter/views.py
231
counter/views.py
@ -21,10 +21,13 @@
|
||||
# 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.http import Http404
|
||||
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.base import View
|
||||
from django.views.generic.edit import (
|
||||
@ -49,12 +52,20 @@ import re
|
||||
import pytz
|
||||
from datetime import date, timedelta, datetime
|
||||
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.forms import LoginForm, SelectDate, SelectDateTime
|
||||
from core.views.forms import LoginForm
|
||||
from core.models import User
|
||||
from counter.forms import (
|
||||
BillingInfoForm,
|
||||
StudentCardForm,
|
||||
GetUserForm,
|
||||
RefillForm,
|
||||
CounterEditForm,
|
||||
ProductEditForm,
|
||||
CashSummaryFormBase,
|
||||
EticketForm,
|
||||
)
|
||||
from subscription.models import Subscription
|
||||
from counter.models import (
|
||||
Counter,
|
||||
@ -68,9 +79,9 @@ from counter.models import (
|
||||
CashRegisterSummaryItem,
|
||||
Eticket,
|
||||
Permanency,
|
||||
BillingInfo,
|
||||
)
|
||||
from accounting.models import CurrencyField
|
||||
from core.views.forms import TzAwareDateTimeField
|
||||
|
||||
|
||||
class CounterAdminMixin(View):
|
||||
@ -103,24 +114,6 @@ class CounterAdminMixin(View):
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
def get_tabs_title(self):
|
||||
if hasattr(self.object, "stock_owner"):
|
||||
@ -867,15 +807,6 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
||||
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):
|
||||
"""
|
||||
Edit a counter's main informations (for the counter's manager)
|
||||
@ -995,66 +926,6 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
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):
|
||||
"""
|
||||
A create view for the admins
|
||||
@ -1482,7 +1353,7 @@ class CounterStatView(DetailView, CounterAdminMixin):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""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["Customer"] = Customer
|
||||
@ -1585,11 +1456,6 @@ class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
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):
|
||||
"""Display a list of cash summaries"""
|
||||
|
||||
@ -1669,7 +1535,7 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||
end_date = (start_date + timedelta(days=32)).replace(
|
||||
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(
|
||||
[
|
||||
@ -1725,17 +1591,6 @@ class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
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):
|
||||
"""
|
||||
Create an eticket
|
||||
@ -1895,3 +1750,55 @@ class StudentCardFormView(FormView):
|
||||
return reverse_lazy(
|
||||
"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
|
||||
if type(item["quantity"]) is not int or item["quantity"] < 0:
|
||||
self.error_messages.add(
|
||||
_("You cannot buy %(nbr)d %(name)%s.")
|
||||
_("You cannot buy %(nbr)d %(name)s.")
|
||||
% {"nbr": item["quantity"], "name": item["name"]}
|
||||
)
|
||||
continue
|
||||
@ -166,7 +166,6 @@ class BasketForm:
|
||||
return True
|
||||
|
||||
def get_error_messages(self) -> typing.List[str]:
|
||||
# return [msg for msg in self.error_messages]
|
||||
return list(self.error_messages)
|
||||
|
||||
def get_cleaned_cookie(self) -> str:
|
||||
|
@ -21,9 +21,12 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import hmac
|
||||
import typing
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
from django.db import models, DataError
|
||||
from django.db.models import Sum, F
|
||||
@ -32,7 +35,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounting.models import CurrencyField
|
||||
from core.models import Group, User
|
||||
from counter.models import Counter, Product, Selling, Refilling
|
||||
from counter.models import Counter, Product, Selling, Refilling, BillingInfo, Customer
|
||||
|
||||
|
||||
def get_eboutic_products(user: User) -> List[Product]:
|
||||
@ -104,7 +107,7 @@ class Basket(models.Model):
|
||||
"""
|
||||
Remove all items from this basket without deleting the basket
|
||||
"""
|
||||
BasketItem.objects.filter(basket=self).delete()
|
||||
self.items.all().delete()
|
||||
|
||||
@cached_property
|
||||
def contains_refilling_item(self) -> bool:
|
||||
@ -122,7 +125,7 @@ class Basket(models.Model):
|
||||
def from_session(cls, session) -> typing.Union["Basket", None]:
|
||||
"""
|
||||
Given an HttpRequest django object, return the basket used in the current session
|
||||
if it exists else create a new one and return it
|
||||
if it exists else None
|
||||
"""
|
||||
if "basket_id" in session:
|
||||
try:
|
||||
@ -131,6 +134,93 @@ class Basket(models.Model):
|
||||
return None
|
||||
return None
|
||||
|
||||
def generate_sales(self, counter, seller: User, payment_method: str):
|
||||
"""
|
||||
Generate a list of sold items corresponding to the items
|
||||
of this basket WITHOUT saving them NOR deleting the basket
|
||||
|
||||
Example:
|
||||
::
|
||||
|
||||
counter = Counter.objects.get(name="Eboutic")
|
||||
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
|
||||
# here the basket is in the same state as before the method call
|
||||
|
||||
with transaction.atomic():
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
# all the basket items are deleted by the on_delete=CASCADE relation
|
||||
# thus only the sales remain
|
||||
"""
|
||||
# I must proceed with two distinct requests instead of
|
||||
# only one with a join because the AbstractBaseItem model has been
|
||||
# poorly designed. If you refactor the model, please refactor this too.
|
||||
items = self.items.order_by("product_id")
|
||||
ids = [item.product_id for item in items]
|
||||
products = Product.objects.filter(id__in=ids).order_by("id")
|
||||
# items and products are sorted in the same order
|
||||
sales = []
|
||||
for item, product in zip(items, products):
|
||||
sales.append(
|
||||
Selling(
|
||||
label=product.name,
|
||||
counter=counter,
|
||||
club=product.club,
|
||||
product=product,
|
||||
seller=seller,
|
||||
customer=self.user.customer,
|
||||
unit_price=item.product_unit_price,
|
||||
quantity=item.quantity,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
)
|
||||
return sales
|
||||
|
||||
def get_e_transaction_data(self):
|
||||
user = self.user
|
||||
if not hasattr(user, "customer"):
|
||||
raise Customer.DoesNotExist
|
||||
customer = user.customer
|
||||
if not hasattr(user.customer, "billing_infos"):
|
||||
raise BillingInfo.DoesNotExist
|
||||
data = [
|
||||
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
|
||||
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
|
||||
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
||||
("PBX_TOTAL", str(int(self.get_total() * 100))),
|
||||
("PBX_DEVISE", "978"), # This is Euro
|
||||
("PBX_CMD", str(self.id)),
|
||||
("PBX_PORTEUR", user.email),
|
||||
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
|
||||
("PBX_HASH", "SHA512"),
|
||||
("PBX_TYPEPAIEMENT", "CARTE"),
|
||||
("PBX_TYPECARTE", "CB"),
|
||||
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
|
||||
("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):
|
||||
return "%s's basket (%d items)" % (self.user, self.items.all().count())
|
||||
|
||||
@ -156,18 +246,9 @@ class Invoice(models.Model):
|
||||
)["total"]
|
||||
return float(total) if total is not None else 0
|
||||
|
||||
def validate(self, *args, **kwargs):
|
||||
def validate(self):
|
||||
if self.validated:
|
||||
raise DataError(_("Invoice already validated"))
|
||||
from counter.models import Customer
|
||||
|
||||
if not Customer.objects.filter(user=self.user).exists():
|
||||
number = Customer.objects.count() + 1
|
||||
Customer(
|
||||
user=self.user,
|
||||
account_id=Customer.generate_account_id(number),
|
||||
amount=0,
|
||||
).save()
|
||||
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
||||
for i in self.items.all():
|
||||
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||
@ -227,6 +308,22 @@ class BasketItem(AbstractBaseItem):
|
||||
Basket, related_name="items", verbose_name=_("basket"), on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, product: Product, quantity: int):
|
||||
"""
|
||||
Create a BasketItem with the same characteristics as the
|
||||
product passed in parameters, with the specified quantity
|
||||
WARNING : the basket field is not filled, so you must set
|
||||
it yourself before saving the model
|
||||
"""
|
||||
return cls(
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
type_id=product.product_type.id,
|
||||
quantity=quantity,
|
||||
product_unit_price=product.selling_price,
|
||||
)
|
||||
|
||||
|
||||
class InvoiceItem(AbstractBaseItem):
|
||||
invoice = models.ForeignKey(
|
||||
|
@ -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 {
|
||||
margin-left: 0;
|
||||
list-style: none;
|
||||
@ -162,7 +148,7 @@
|
||||
}
|
||||
|
||||
#eboutic .catalog-buttons button {
|
||||
font-size: 15px;
|
||||
font-size: 15px!important;
|
||||
font-weight: normal;
|
||||
color: white;
|
||||
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">
|
||||
<h3>Panier</h3>
|
||||
{% if errors %}
|
||||
<div class="error-message">
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% for error in errors %}
|
||||
<p>{{ error }}</p>
|
||||
<p style="margin: 0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
@ -64,7 +66,7 @@
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<form method="post" action="{{ url('eboutic:command') }}">
|
||||
<form method="get" action="{{ url('eboutic:command') }}">
|
||||
{% csrf_token %}
|
||||
<button class="validate">
|
||||
<i class="fa fa-check"></i>
|
||||
@ -75,7 +77,7 @@
|
||||
</div>
|
||||
<div id="catalog">
|
||||
{% if not request.user.date_of_birth %}
|
||||
<div class="alert" x-data="{show_alert: true}" x-show="show_alert" x-transition>
|
||||
<div class="alert alert-red" x-data="{show_alert: true}" x-show="show_alert" x-transition>
|
||||
<span class="alert-main">
|
||||
{% trans %}You have not filled in your date of birth. As a result, you may not have access to all the products in the online shop. To fill in your date of birth, you can go to{% endtrans %}
|
||||
<a href="{{ url("core:user_edit", user_id=request.user.id) }}">
|
||||
|
@ -1,13 +1,22 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Basket state{% endtrans %}
|
||||
{% trans %}Basket state{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Remove jquery css #}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script src="{{ static('eboutic/js/makecommand.js') }}" defer></script>
|
||||
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
<table>
|
||||
<thead>
|
||||
@ -33,7 +42,8 @@
|
||||
|
||||
{% if customer_amount != None %}
|
||||
<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 %}
|
||||
<br>
|
||||
@ -42,28 +52,92 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<br>
|
||||
{% 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>
|
||||
{% for (field_name,field_value) in et_request.items() -%}
|
||||
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}">
|
||||
{% endfor %}
|
||||
<input type="submit" value="{% trans %}Pay with credit card{% endtrans %}" />
|
||||
<i>
|
||||
{% trans %}You must fill your billing infos if you want to pay with your credit
|
||||
card{% endtrans %}
|
||||
</i>
|
||||
</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
|
||||
{% csrf_token %}
|
||||
<template x-data x-for="input in $store.billing_inputs.data">
|
||||
<input type="hidden" :name="input['key']" :value="input['value']">
|
||||
</template>
|
||||
<input type="submit" id="bank-submit-button"
|
||||
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
|
||||
value="{% trans %}Pay with credit card{% endtrans %}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if basket.contains_refilling_item %}
|
||||
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}">
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="pay_with_sith_account">
|
||||
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}" />
|
||||
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const create_billing_info_url = '{{ url("counter:create_billing_info", user_id=request.user.id) }}'
|
||||
const edit_billing_info_url = '{{ url("counter:edit_billing_info", user_id=request.user.id) }}';
|
||||
const et_data_url = '{{ url("eboutic:et_data") }}'
|
||||
let billing_info_exist =
|
||||
{{ "true" if billing_infos else "false" }}
|
||||
|
||||
|
||||
{% if billing_infos %}
|
||||
const et_data = {{ billing_infos|tojson }}
|
||||
{% else %}
|
||||
const et_data = '{"data": []}'
|
||||
{% endif %}
|
||||
</script>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -24,7 +24,6 @@
|
||||
#
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import urllib
|
||||
|
||||
from OpenSSL import crypto
|
||||
@ -40,18 +39,19 @@ from eboutic.models import Basket
|
||||
|
||||
|
||||
class EbouticTest(TestCase):
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
call_command("populate")
|
||||
self.skia = User.objects.filter(username="skia").first()
|
||||
self.subscriber = User.objects.filter(username="subscriber").first()
|
||||
self.old_subscriber = User.objects.filter(username="old_subscriber").first()
|
||||
self.public = User.objects.filter(username="public").first()
|
||||
self.barbar = Product.objects.filter(code="BARB").first()
|
||||
self.refill = Product.objects.filter(code="15REFILL").first()
|
||||
self.cotis = Product.objects.filter(code="1SCOTIZ").first()
|
||||
self.eboutic = Counter.objects.filter(name="Eboutic").first()
|
||||
cls.barbar = Product.objects.filter(code="BARB").first()
|
||||
cls.refill = Product.objects.filter(code="15REFILL").first()
|
||||
cls.cotis = Product.objects.filter(code="1SCOTIZ").first()
|
||||
cls.eboutic = Counter.objects.filter(name="Eboutic").first()
|
||||
cls.skia = User.objects.filter(username="skia").first()
|
||||
cls.subscriber = User.objects.filter(username="subscriber").first()
|
||||
cls.old_subscriber = User.objects.filter(username="old_subscriber").first()
|
||||
cls.public = User.objects.filter(username="public").first()
|
||||
|
||||
def get_busy_basket(self, user):
|
||||
def get_busy_basket(self, user) -> Basket:
|
||||
"""
|
||||
Create and return a basket with 3 barbar and 1 cotis in it.
|
||||
Edit the client session to store the basket id in it
|
||||
@ -64,11 +64,11 @@ class EbouticTest(TestCase):
|
||||
basket.add_product(self.cotis)
|
||||
return basket
|
||||
|
||||
def generate_bank_valid_answer_from_page_content(self, content):
|
||||
content = str(content)
|
||||
basket_id = re.search(r"PBX_CMD\" value=\"(\d*)\"", content).group(1)
|
||||
amount = re.search(r"PBX_TOTAL\" value=\"(\d*)\"", content).group(1)
|
||||
query = "Amount=%s&BasketID=%s&Auto=42&Error=00000" % (amount, basket_id)
|
||||
def generate_bank_valid_answer(self) -> str:
|
||||
basket = Basket.from_session(self.client.session)
|
||||
basket_id = basket.id
|
||||
amount = int(basket.get_total() * 100)
|
||||
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
|
||||
with open("./eboutic/tests/private_key.pem") as f:
|
||||
PRIVKEY = f.read()
|
||||
with open("./eboutic/tests/public_key.pem") as f:
|
||||
@ -81,8 +81,7 @@ class EbouticTest(TestCase):
|
||||
query,
|
||||
urllib.parse.quote_plus(b64sig),
|
||||
)
|
||||
response = self.client.get(url)
|
||||
return response
|
||||
return url
|
||||
|
||||
def test_buy_with_sith_account(self):
|
||||
self.client.login(username="subscriber", password="plop")
|
||||
@ -102,7 +101,7 @@ class EbouticTest(TestCase):
|
||||
def test_buy_with_sith_account_no_money(self):
|
||||
self.client.login(username="subscriber", password="plop")
|
||||
basket = self.get_busy_basket(self.subscriber)
|
||||
initial = basket.get_total() - 1
|
||||
initial = basket.get_total() - 1 # just not enough to complete the sale
|
||||
self.subscriber.customer.amount = initial
|
||||
self.subscriber.customer.save()
|
||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
||||
@ -122,7 +121,7 @@ class EbouticTest(TestCase):
|
||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
|
||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
||||
]"""
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertInHTML(
|
||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
||||
@ -146,7 +145,7 @@ class EbouticTest(TestCase):
|
||||
def test_submit_empty_basket(self):
|
||||
self.client.login(username="subscriber", password="plop")
|
||||
self.client.cookies["basket_items"] = "[]"
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertRedirects(response, "/eboutic/")
|
||||
|
||||
def test_submit_invalid_basket(self):
|
||||
@ -157,7 +156,7 @@ class EbouticTest(TestCase):
|
||||
] = f"""[
|
||||
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
|
||||
]"""
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertIn(
|
||||
'basket_items=""',
|
||||
self.client.cookies["basket_items"].OutputString(),
|
||||
@ -175,7 +174,7 @@ class EbouticTest(TestCase):
|
||||
] = """[
|
||||
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
|
||||
]"""
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertRedirects(response, "/eboutic/")
|
||||
|
||||
def test_buy_subscribe_product_with_credit_card(self):
|
||||
@ -189,14 +188,14 @@ class EbouticTest(TestCase):
|
||||
] = """[
|
||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
|
||||
]"""
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertInHTML(
|
||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
||||
response.content.decode(),
|
||||
)
|
||||
basket = Basket.objects.get(id=self.client.session["basket_id"])
|
||||
self.assertEqual(basket.items.count(), 1)
|
||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
||||
response = self.client.get(self.generate_bank_valid_answer())
|
||||
self.assertTrue(response.status_code == 200)
|
||||
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
|
||||
|
||||
@ -215,9 +214,10 @@ class EbouticTest(TestCase):
|
||||
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
|
||||
)
|
||||
initial_balance = self.subscriber.customer.amount
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
|
||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
||||
url = self.generate_bank_valid_answer()
|
||||
response = self.client.get(url)
|
||||
self.assertTrue(response.status_code == 200)
|
||||
self.assertTrue(response.content.decode() == "Payment successful")
|
||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
||||
@ -228,14 +228,15 @@ class EbouticTest(TestCase):
|
||||
self.client.cookies["basket_items"] = json.dumps(
|
||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
||||
)
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
et_answer_url = self.generate_bank_valid_answer()
|
||||
self.client.cookies["basket_items"] = json.dumps(
|
||||
[ # alter basket
|
||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
||||
]
|
||||
)
|
||||
self.client.post(reverse("eboutic:command"))
|
||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
response = self.client.get(et_answer_url)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIn(
|
||||
"Basket processing failed with error: SuspiciousOperation('Basket total and amount do not match'",
|
||||
@ -247,8 +248,9 @@ class EbouticTest(TestCase):
|
||||
self.client.cookies["basket_items"] = json.dumps(
|
||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
||||
)
|
||||
response = self.client.post(reverse("eboutic:command"))
|
||||
response = self.generate_bank_valid_answer_from_page_content(response.content)
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
et_answer_url = self.generate_bank_valid_answer()
|
||||
response = self.client.get(et_answer_url)
|
||||
self.assertTrue(response.status_code == 200)
|
||||
self.assertTrue(response.content.decode("utf-8") == "Payment successful")
|
||||
|
||||
|
@ -34,8 +34,9 @@ urlpatterns = [
|
||||
# Subscription views
|
||||
path("", eboutic_main, name="main"),
|
||||
path("command/", EbouticCommand.as_view(), name="command"),
|
||||
path("pay/", pay_with_sith, name="pay_with_sith"),
|
||||
path("pay/sith/", pay_with_sith, name="pay_with_sith"),
|
||||
path("pay/<res:result>/", payment_result, name="payment_result"),
|
||||
path("et_data/", e_transaction_data, name="et_data"),
|
||||
path(
|
||||
"et_autoanswer",
|
||||
EtransactionAutoAnswer.as_view(),
|
||||
|
115
eboutic/views.py
115
eboutic/views.py
@ -23,13 +23,10 @@
|
||||
#
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
|
||||
import sentry_sdk
|
||||
|
||||
|
||||
from OpenSSL import crypto
|
||||
from django.conf import settings
|
||||
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.generic import TemplateView, View
|
||||
|
||||
from counter.models import Customer, Counter, Selling
|
||||
from counter.forms import BillingInfoForm
|
||||
from counter.models import Customer, Counter, Product
|
||||
from eboutic.forms import BasketForm
|
||||
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
|
||||
|
||||
@ -85,11 +83,11 @@ class EbouticCommand(TemplateView):
|
||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
||||
|
||||
@method_decorator(login_required)
|
||||
def get(self, request, *args, **kwargs):
|
||||
def post(self, request, *args, **kwargs):
|
||||
return redirect("eboutic:main")
|
||||
|
||||
@method_decorator(login_required)
|
||||
def post(self, request: HttpRequest, *args, **kwargs):
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
form = BasketForm(request)
|
||||
if not form.is_valid():
|
||||
request.session["errors"] = form.get_error_messages()
|
||||
@ -98,65 +96,56 @@ class EbouticCommand(TemplateView):
|
||||
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
|
||||
return res
|
||||
|
||||
if "basket_id" in request.session:
|
||||
basket, _ = Basket.objects.get_or_create(
|
||||
id=request.session["basket_id"], user=request.user
|
||||
)
|
||||
basket = Basket.from_session(request.session)
|
||||
if basket is not None:
|
||||
basket.clear()
|
||||
else:
|
||||
basket = Basket.objects.create(user=request.user)
|
||||
|
||||
basket.save()
|
||||
eboutique = Counter.objects.get(type="EBOUTIC")
|
||||
for item in json.loads(request.COOKIES["basket_items"]):
|
||||
basket.add_product(
|
||||
eboutique.products.get(id=(item["id"])), item["quantity"]
|
||||
)
|
||||
request.session["basket_id"] = basket.id
|
||||
request.session.modified = True
|
||||
|
||||
items = json.loads(request.COOKIES["basket_items"])
|
||||
items.sort(key=lambda item: item["id"])
|
||||
ids = [item["id"] for item in items]
|
||||
quantities = [item["quantity"] for item in items]
|
||||
products = Product.objects.filter(id__in=ids)
|
||||
for product, qty in zip(products, quantities):
|
||||
basket.add_product(product, qty)
|
||||
kwargs["basket"] = basket
|
||||
return self.render_to_response(self.get_context_data(**kwargs))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super(EbouticCommand, self).get_context_data(**kwargs)
|
||||
# basket is already in kwargs when the method is called
|
||||
default_billing_info = None
|
||||
if hasattr(self.request.user, "customer"):
|
||||
kwargs["customer_amount"] = self.request.user.customer.amount
|
||||
customer = self.request.user.customer
|
||||
kwargs["customer_amount"] = customer.amount
|
||||
if hasattr(customer, "billing_infos"):
|
||||
default_billing_info = customer.billing_infos
|
||||
else:
|
||||
kwargs["customer_amount"] = None
|
||||
kwargs["et_request"] = OrderedDict()
|
||||
kwargs["et_request"]["PBX_SITE"] = settings.SITH_EBOUTIC_PBX_SITE
|
||||
kwargs["et_request"]["PBX_RANG"] = settings.SITH_EBOUTIC_PBX_RANG
|
||||
kwargs["et_request"]["PBX_IDENTIFIANT"] = settings.SITH_EBOUTIC_PBX_IDENTIFIANT
|
||||
kwargs["et_request"]["PBX_TOTAL"] = int(kwargs["basket"].get_total() * 100)
|
||||
kwargs["et_request"][
|
||||
"PBX_DEVISE"
|
||||
] = 978 # This is Euro. ET support only this value anyway
|
||||
kwargs["et_request"]["PBX_CMD"] = kwargs["basket"].id
|
||||
kwargs["et_request"]["PBX_PORTEUR"] = kwargs["basket"].user.email
|
||||
kwargs["et_request"]["PBX_RETOUR"] = "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"
|
||||
kwargs["et_request"]["PBX_HASH"] = "SHA512"
|
||||
kwargs["et_request"]["PBX_TYPEPAIEMENT"] = "CARTE"
|
||||
kwargs["et_request"]["PBX_TYPECARTE"] = "CB"
|
||||
kwargs["et_request"]["PBX_TIME"] = str(
|
||||
datetime.now().replace(microsecond=0).isoformat("T")
|
||||
)
|
||||
kwargs["et_request"]["PBX_HMAC"] = (
|
||||
hmac.new(
|
||||
settings.SITH_EBOUTIC_HMAC_KEY,
|
||||
bytes(
|
||||
"&".join(
|
||||
["%s=%s" % (k, v) for k, v in kwargs["et_request"].items()]
|
||||
),
|
||||
"utf-8",
|
||||
),
|
||||
"sha512",
|
||||
)
|
||||
.hexdigest()
|
||||
.upper()
|
||||
)
|
||||
kwargs["must_fill_billing_infos"] = default_billing_info is None
|
||||
if not kwargs["must_fill_billing_infos"]:
|
||||
# the user has already filled its billing_infos, thus we can
|
||||
# get it without expecting an error
|
||||
data = kwargs["basket"].get_e_transaction_data()
|
||||
data = {"data": [{"key": key, "value": val} for key, val in data]}
|
||||
kwargs["billing_infos"] = json.dumps(data)
|
||||
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
|
||||
return kwargs
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def e_transaction_data(request):
|
||||
basket = Basket.from_session(request.session)
|
||||
if basket is None:
|
||||
return HttpResponse(status=404, content=json.dumps({"data": []}))
|
||||
data = basket.get_e_transaction_data()
|
||||
data = {"data": [{"key": key, "value": val} for key, val in data]}
|
||||
return HttpResponse(status=200, content=json.dumps(data))
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def pay_with_sith(request):
|
||||
@ -171,21 +160,11 @@ def pay_with_sith(request):
|
||||
res = redirect("eboutic:payment_result", "failure")
|
||||
else:
|
||||
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
||||
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
for it in basket.items.all():
|
||||
product = eboutic.products.get(id=it.product_id)
|
||||
Selling(
|
||||
label=it.product_name,
|
||||
counter=eboutic,
|
||||
club=product.club,
|
||||
product=product,
|
||||
seller=c.user,
|
||||
customer=c,
|
||||
unit_price=it.product_unit_price,
|
||||
quantity=it.quantity,
|
||||
payment_method="SITH_ACCOUNT",
|
||||
).save()
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
request.session.pop("basket_id", None)
|
||||
res = redirect("eboutic:payment_result", "success")
|
||||
@ -205,12 +184,8 @@ class EtransactionAutoAnswer(View):
|
||||
# Response documentation http://www1.paybox.com/espace-integrateur-documentation
|
||||
# /la-solution-paybox-system/gestion-de-la-reponse/
|
||||
def get(self, request, *args, **kwargs):
|
||||
if (
|
||||
not "Amount" in request.GET.keys()
|
||||
or not "BasketID" in request.GET.keys()
|
||||
or not "Error" in request.GET.keys()
|
||||
or not "Sig" in request.GET.keys()
|
||||
):
|
||||
required = {"Amount", "BasketID", "Error", "Sig"}
|
||||
if not required.issubset(set(request.GET.keys())):
|
||||
return HttpResponse("Bad arguments", status=400)
|
||||
key = crypto.load_publickey(crypto.FILETYPE_PEM, settings.SITH_EBOUTIC_PUB_KEY)
|
||||
cert = crypto.X509()
|
||||
|
@ -41,7 +41,8 @@ from club.models import Club
|
||||
from core.views import CanViewMixin, CanEditMixin, CanEditPropMixin, CanCreateMixin
|
||||
from launderette.models import Launderette, Token, Machine, Slot
|
||||
from counter.models import Counter, Customer, Selling
|
||||
from counter.views import GetUserForm
|
||||
from counter.forms import GetUserForm
|
||||
|
||||
|
||||
# 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"
|
||||
pygraphviz = "^1.9"
|
||||
Jinja2 = "^3.1"
|
||||
django-countries = "^7.4.2"
|
||||
dict2xml = "^1.7.2"
|
||||
|
||||
# Extra optional dependencies
|
||||
mysqlclient = { version = "^2.0.3", optional = true }
|
||||
|
@ -97,19 +97,12 @@ class Subscription(models.Model):
|
||||
# TODO see SubscriptionForm's clean method
|
||||
raise ValidationError(_("Subscription error"))
|
||||
|
||||
def save(self):
|
||||
def save(self, *args, **kwargs):
|
||||
super(Subscription, self).save()
|
||||
from counter.models import Customer
|
||||
|
||||
if not Customer.objects.filter(user=self.member).exists():
|
||||
last_id = (
|
||||
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()
|
||||
Customer.new_for_user(self.member).save()
|
||||
form = PasswordResetForm({"email": self.member.email})
|
||||
if form.is_valid():
|
||||
form.save(
|
||||
|
Loading…
Reference in New Issue
Block a user