mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-10 03:49:24 +00:00
integration of 3D secure v2 for eboutic bank payment
This commit is contained in:
@ -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)
|
||||
|
Reference in New Issue
Block a user