mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-22 14:13:21 +00:00
Merge pull request #742 from ae-utbm/refactor-eboutic
Eboutic big refactor
This commit is contained in:
commit
d2ea8f2898
@ -310,11 +310,26 @@ class Product(models.Model):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the user can buy this product else False
|
True if the user can buy this product else False
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
This performs a db query, thus you can quickly have
|
||||||
|
a N+1 queries problem if you call it in a loop.
|
||||||
|
Hopefully, you can avoid that if you prefetch the buying_groups :
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = User.objects.get(username="foobar")
|
||||||
|
products = [
|
||||||
|
p
|
||||||
|
for p in Product.objects.prefetch_related("buying_groups")
|
||||||
|
if p.can_be_sold_to(user)
|
||||||
|
]
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
if not self.buying_groups.exists():
|
buying_groups = list(self.buying_groups.all())
|
||||||
|
if not buying_groups:
|
||||||
return True
|
return True
|
||||||
for group_id in self.buying_groups.values_list("pk", flat=True):
|
for group in buying_groups:
|
||||||
if user.is_in_group(pk=group_id):
|
if user.is_in_group(pk=group.id):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -690,14 +705,14 @@ class Selling(models.Model):
|
|||||||
self.customer.amount -= self.quantity * self.unit_price
|
self.customer.amount -= self.quantity * self.unit_price
|
||||||
self.customer.save(allow_negative=allow_negative, is_selling=True)
|
self.customer.save(allow_negative=allow_negative, is_selling=True)
|
||||||
self.is_validated = True
|
self.is_validated = True
|
||||||
u = User.objects.filter(id=self.customer.user.id).first()
|
user = self.customer.user
|
||||||
if u.was_subscribed:
|
if user.was_subscribed:
|
||||||
if (
|
if (
|
||||||
self.product
|
self.product
|
||||||
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER
|
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER
|
||||||
):
|
):
|
||||||
sub = Subscription(
|
sub = Subscription(
|
||||||
member=u,
|
member=user,
|
||||||
subscription_type="un-semestre",
|
subscription_type="un-semestre",
|
||||||
payment_method="EBOUTIC",
|
payment_method="EBOUTIC",
|
||||||
location="EBOUTIC",
|
location="EBOUTIC",
|
||||||
@ -719,9 +734,8 @@ class Selling(models.Model):
|
|||||||
self.product
|
self.product
|
||||||
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS
|
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS
|
||||||
):
|
):
|
||||||
u = User.objects.filter(id=self.customer.user.id).first()
|
|
||||||
sub = Subscription(
|
sub = Subscription(
|
||||||
member=u,
|
member=user,
|
||||||
subscription_type="deux-semestres",
|
subscription_type="deux-semestres",
|
||||||
payment_method="EBOUTIC",
|
payment_method="EBOUTIC",
|
||||||
location="EBOUTIC",
|
location="EBOUTIC",
|
||||||
@ -739,13 +753,13 @@ class Selling(models.Model):
|
|||||||
start=sub.subscription_start,
|
start=sub.subscription_start,
|
||||||
)
|
)
|
||||||
sub.save()
|
sub.save()
|
||||||
if self.customer.user.preferences.notify_on_click:
|
if user.preferences.notify_on_click:
|
||||||
Notification(
|
Notification(
|
||||||
user=self.customer.user,
|
user=user,
|
||||||
url=reverse(
|
url=reverse(
|
||||||
"core:user_account_detail",
|
"core:user_account_detail",
|
||||||
kwargs={
|
kwargs={
|
||||||
"user_id": self.customer.user.id,
|
"user_id": user.id,
|
||||||
"year": self.date.year,
|
"year": self.date.year,
|
||||||
"month": self.date.month,
|
"month": self.date.month,
|
||||||
},
|
},
|
||||||
@ -754,19 +768,15 @@ class Selling(models.Model):
|
|||||||
type="SELLING",
|
type="SELLING",
|
||||||
).save()
|
).save()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
try:
|
if hasattr(self.product, "eticket"):
|
||||||
# The product has no id until it's saved
|
self.send_mail_customer()
|
||||||
if self.product.eticket:
|
|
||||||
self.send_mail_customer()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
def is_owned_by(self, user: User) -> bool:
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return user.is_owner(self.counter) and self.payment_method != "CARD"
|
return self.payment_method != "CARD" and user.is_owner(self.counter)
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
def can_be_viewed_by(self, user: User) -> bool:
|
||||||
if (
|
if (
|
||||||
not hasattr(self, "customer") or self.customer is None
|
not hasattr(self, "customer") or self.customer is None
|
||||||
): # Customer can be set to Null
|
): # Customer can be set to Null
|
||||||
@ -812,7 +822,9 @@ class Selling(models.Model):
|
|||||||
"url": self.customer.get_full_url(),
|
"url": self.customer.get_full_url(),
|
||||||
"eticket": self.get_eticket_full_url(),
|
"eticket": self.get_eticket_full_url(),
|
||||||
}
|
}
|
||||||
self.customer.user.email_user(subject, message_txt, html_message=message_html)
|
self.customer.user.email_user(
|
||||||
|
subject, message_txt, html_message=message_html, fail_silently=True
|
||||||
|
)
|
||||||
|
|
||||||
def get_eticket_full_url(self):
|
def get_eticket_full_url(self):
|
||||||
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id})
|
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id})
|
||||||
|
271
counter/tests.py
271
counter/tests.py
@ -16,8 +16,9 @@ import json
|
|||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import timedelta
|
from django.utils.timezone import timedelta
|
||||||
@ -303,18 +304,11 @@ class TestCounterStats(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestBillingInfo(TestCase):
|
@pytest.mark.django_db
|
||||||
@classmethod
|
class TestBillingInfo:
|
||||||
def setUpTestData(cls):
|
@pytest.fixture
|
||||||
cls.payload_1 = {
|
def payload(self):
|
||||||
"first_name": "Subscribed",
|
return {
|
||||||
"last_name": "User",
|
|
||||||
"address_1": "1 rue des Huns",
|
|
||||||
"zip_code": "90000",
|
|
||||||
"city": "Belfort",
|
|
||||||
"country": "FR",
|
|
||||||
}
|
|
||||||
cls.payload_2 = {
|
|
||||||
"first_name": "Subscribed",
|
"first_name": "Subscribed",
|
||||||
"last_name": "User",
|
"last_name": "User",
|
||||||
"address_1": "3, rue de Troyes",
|
"address_1": "3, rue de Troyes",
|
||||||
@ -322,213 +316,80 @@ class TestBillingInfo(TestCase):
|
|||||||
"city": "Sète",
|
"city": "Sète",
|
||||||
"country": "FR",
|
"country": "FR",
|
||||||
}
|
}
|
||||||
cls.root = User.objects.get(username="root")
|
|
||||||
cls.subscriber = User.objects.get(username="subscriber")
|
|
||||||
|
|
||||||
def test_edit_infos(self):
|
def test_edit_infos(self, client: Client, payload: dict):
|
||||||
user = self.subscriber
|
user = subscriber_user.make()
|
||||||
BillingInfo.objects.get_or_create(
|
baker.make(BillingInfo, customer=user.customer)
|
||||||
customer=user.customer, defaults=self.payload_1
|
client.force_login(user)
|
||||||
)
|
response = client.put(
|
||||||
self.client.force_login(user)
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
response = self.client.post(
|
json.dumps(payload),
|
||||||
reverse("counter:edit_billing_info", args=[user.id]),
|
|
||||||
json.dumps(self.payload_2),
|
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
user = User.objects.get(username="subscriber")
|
user.refresh_from_db()
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
self.assertJSONEqual(response.content, {"errors": None})
|
|
||||||
assert hasattr(user.customer, "billing_infos")
|
assert hasattr(user.customer, "billing_infos")
|
||||||
assert infos.customer == user.customer
|
assert infos.customer == user.customer
|
||||||
assert infos.first_name == "Subscribed"
|
for key, val in payload.items():
|
||||||
assert infos.last_name == "User"
|
assert getattr(infos, key) == val
|
||||||
assert infos.address_1 == "3, rue de Troyes"
|
|
||||||
assert infos.address_2 is None
|
|
||||||
assert infos.zip_code == "34301"
|
|
||||||
assert infos.city == "Sète"
|
|
||||||
assert infos.country == "FR"
|
|
||||||
|
|
||||||
def test_create_infos_for_user_with_account(self):
|
@pytest.mark.parametrize(
|
||||||
user = User.objects.get(username="subscriber")
|
"user_maker", [subscriber_user.make, lambda: baker.make(User)]
|
||||||
if hasattr(user.customer, "billing_infos"):
|
)
|
||||||
user.customer.billing_infos.delete()
|
@pytest.mark.django_db
|
||||||
self.client.force_login(user)
|
def test_create_infos(self, client: Client, user_maker, payload):
|
||||||
response = self.client.post(
|
user = user_maker()
|
||||||
reverse("counter:create_billing_info", args=[user.id]),
|
client.force_login(user)
|
||||||
json.dumps(self.payload_1),
|
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
user = User.objects.get(username="subscriber")
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
self.assertJSONEqual(response.content, {"errors": None})
|
user.refresh_from_db()
|
||||||
assert hasattr(user.customer, "billing_infos")
|
|
||||||
assert infos.customer == user.customer
|
|
||||||
assert infos.first_name == "Subscribed"
|
|
||||||
assert infos.last_name == "User"
|
|
||||||
assert infos.address_1 == "1 rue des Huns"
|
|
||||||
assert infos.address_2 is None
|
|
||||||
assert infos.zip_code == "90000"
|
|
||||||
assert infos.city == "Belfort"
|
|
||||||
assert infos.country == "FR"
|
|
||||||
|
|
||||||
def test_create_infos_for_user_without_account(self):
|
|
||||||
user = User.objects.get(username="subscriber")
|
|
||||||
if hasattr(user, "customer"):
|
|
||||||
user.customer.delete()
|
|
||||||
self.client.force_login(user)
|
|
||||||
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")
|
|
||||||
assert hasattr(user, "customer")
|
assert hasattr(user, "customer")
|
||||||
assert hasattr(user.customer, "billing_infos")
|
|
||||||
assert response.status_code == 200
|
|
||||||
self.assertJSONEqual(response.content, {"errors": None})
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
self.assertEqual(user.customer, infos.customer)
|
|
||||||
assert infos.first_name == "Subscribed"
|
|
||||||
assert infos.last_name == "User"
|
|
||||||
assert infos.address_1 == "1 rue des Huns"
|
|
||||||
assert infos.address_2 is None
|
|
||||||
assert infos.zip_code == "90000"
|
|
||||||
assert infos.city == "Belfort"
|
|
||||||
assert infos.country == "FR"
|
|
||||||
|
|
||||||
def test_create_invalid(self):
|
|
||||||
user = User.objects.get(username="subscriber")
|
|
||||||
if hasattr(user.customer, "billing_infos"):
|
|
||||||
user.customer.billing_infos.delete()
|
|
||||||
self.client.force_login(user)
|
|
||||||
# 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)
|
|
||||||
assert not 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.force_login(user)
|
|
||||||
# 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)
|
|
||||||
assert 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.force_login(user)
|
|
||||||
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.force_login(self.root)
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("counter:edit_billing_info", args=[user.id]),
|
|
||||||
json.dumps(self.payload_2),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
user = User.objects.get(username="subscriber")
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
|
||||||
self.assertJSONEqual(response.content, {"errors": None})
|
|
||||||
assert 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.force_login(self.root)
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("counter:create_billing_info", args=[user.id]),
|
|
||||||
json.dumps(self.payload_2),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
user = User.objects.get(username="subscriber")
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
|
||||||
self.assertJSONEqual(response.content, {"errors": None})
|
|
||||||
assert hasattr(user.customer, "billing_infos")
|
assert hasattr(user.customer, "billing_infos")
|
||||||
assert infos.customer == user.customer
|
assert infos.customer == user.customer
|
||||||
assert infos.first_name == "Subscribed"
|
for key, val in payload.items():
|
||||||
assert infos.last_name == "User"
|
assert getattr(infos, key) == val
|
||||||
assert infos.address_1 == "3, rue de Troyes"
|
|
||||||
assert infos.address_2 is None
|
def test_invalid_data(self, client: Client, payload):
|
||||||
assert infos.zip_code == "34301"
|
user = subscriber_user.make()
|
||||||
assert infos.city == "Sète"
|
client.force_login(user)
|
||||||
assert infos.country == "FR"
|
# address_1, zip_code and country are missing
|
||||||
|
del payload["city"]
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
user.customer.refresh_from_db()
|
||||||
|
assert not hasattr(user.customer, "billing_infos")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("operator_maker", "expected_code"),
|
||||||
|
[
|
||||||
|
(subscriber_user.make, 403),
|
||||||
|
(lambda: baker.make(User), 403),
|
||||||
|
(lambda: baker.make(User, is_superuser=True), 200),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_edit_other_user(
|
||||||
|
self, client: Client, operator_maker, expected_code: int, payload: dict
|
||||||
|
):
|
||||||
|
user = subscriber_user.make()
|
||||||
|
client.force_login(operator_maker())
|
||||||
|
baker.make(BillingInfo, customer=user.customer)
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == expected_code
|
||||||
|
|
||||||
|
|
||||||
class TestBarmanConnection(TestCase):
|
class TestBarmanConnection(TestCase):
|
||||||
|
@ -57,16 +57,6 @@ urlpatterns = [
|
|||||||
StudentCardDeleteView.as_view(),
|
StudentCardDeleteView.as_view(),
|
||||||
name="delete_student_card",
|
name="delete_student_card",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"customer/<int:user_id>/billing_info/create",
|
|
||||||
create_billing_info,
|
|
||||||
name="create_billing_info",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"customer/<int:user_id>/billing_info/edit",
|
|
||||||
edit_billing_info,
|
|
||||||
name="edit_billing_info",
|
|
||||||
),
|
|
||||||
path("admin/<int:counter_id>/", CounterEditView.as_view(), name="admin"),
|
path("admin/<int:counter_id>/", CounterEditView.as_view(), name="admin"),
|
||||||
path(
|
path(
|
||||||
"admin/<int:counter_id>/prop/",
|
"admin/<int:counter_id>/prop/",
|
||||||
|
@ -12,7 +12,6 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
@ -21,7 +20,6 @@ from urllib.parse import parse_qs
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import DataError, transaction
|
from django.db import DataError, transaction
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
@ -56,7 +54,6 @@ from core.utils import get_semester_code, get_start_of_semester
|
|||||||
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
|
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
|
||||||
from core.views.forms import LoginForm
|
from core.views.forms import LoginForm
|
||||||
from counter.forms import (
|
from counter.forms import (
|
||||||
BillingInfoForm,
|
|
||||||
CashSummaryFormBase,
|
CashSummaryFormBase,
|
||||||
CounterEditForm,
|
CounterEditForm,
|
||||||
EticketForm,
|
EticketForm,
|
||||||
@ -67,7 +64,6 @@ from counter.forms import (
|
|||||||
StudentCardForm,
|
StudentCardForm,
|
||||||
)
|
)
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
BillingInfo,
|
|
||||||
CashRegisterSummary,
|
CashRegisterSummary,
|
||||||
CashRegisterSummaryItem,
|
CashRegisterSummaryItem,
|
||||||
Counter,
|
Counter,
|
||||||
@ -1569,51 +1565,3 @@ class StudentCardFormView(FormView):
|
|||||||
return reverse_lazy(
|
return reverse_lazy(
|
||||||
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
|
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def __manage_billing_info_req(request, user_id, *, delete_if_fail=False):
|
|
||||||
data = json.loads(request.body)
|
|
||||||
form = BillingInfoForm(data)
|
|
||||||
if not form.is_valid():
|
|
||||||
if delete_if_fail:
|
|
||||||
Customer.objects.get(user__id=user_id).billing_infos.delete()
|
|
||||||
errors = [
|
|
||||||
{"field": str(form.fields[k].label), "messages": v}
|
|
||||||
for k, v in form.errors.items()
|
|
||||||
]
|
|
||||||
content = json.dumps({"errors": errors})
|
|
||||||
return HttpResponse(status=400, content=content)
|
|
||||||
if form.is_valid():
|
|
||||||
infos = Customer.objects.get(user__id=user_id).billing_infos
|
|
||||||
for field in form.fields:
|
|
||||||
infos.__dict__[field] = form[field].value()
|
|
||||||
infos.save()
|
|
||||||
content = json.dumps({"errors": None})
|
|
||||||
return HttpResponse(status=200, content=content)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_POST
|
|
||||||
def create_billing_info(request, user_id):
|
|
||||||
user = request.user
|
|
||||||
if user.id != user_id and not user.has_perm("counter:add_billinginfo"):
|
|
||||||
raise PermissionDenied()
|
|
||||||
user = get_object_or_404(User, pk=user_id)
|
|
||||||
customer, _ = Customer.get_or_create(user)
|
|
||||||
BillingInfo.objects.create(customer=customer)
|
|
||||||
return __manage_billing_info_req(request, user_id, delete_if_fail=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)
|
|
||||||
|
@ -19,9 +19,20 @@ from eboutic.models import *
|
|||||||
|
|
||||||
@admin.register(Basket)
|
@admin.register(Basket)
|
||||||
class BasketAdmin(admin.ModelAdmin):
|
class BasketAdmin(admin.ModelAdmin):
|
||||||
list_display = ("user", "date", "get_total")
|
list_display = ("user", "date", "total")
|
||||||
autocomplete_fields = ("user",)
|
autocomplete_fields = ("user",)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset(request)
|
||||||
|
.annotate(
|
||||||
|
total=Sum(
|
||||||
|
F("items__quantity") * F("items__product_unit_price"), default=0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BasketItem)
|
@admin.register(BasketItem)
|
||||||
class BasketItemAdmin(admin.ModelAdmin):
|
class BasketItemAdmin(admin.ModelAdmin):
|
||||||
|
38
eboutic/api.py
Normal file
38
eboutic/api.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
|
from ninja_extra.exceptions import NotFound, PermissionDenied
|
||||||
|
from ninja_extra.permissions import IsAuthenticated
|
||||||
|
from pydantic import NonNegativeInt
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
from counter.models import BillingInfo, Customer
|
||||||
|
from eboutic.models import Basket
|
||||||
|
from eboutic.schemas import BillingInfoSchema
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/etransaction", permissions=[IsAuthenticated])
|
||||||
|
class EtransactionInfoController(ControllerBase):
|
||||||
|
@route.put("/billing-info/{user_id}", url_name="put_billing_info")
|
||||||
|
def put_user_billing_info(self, user_id: NonNegativeInt, info: BillingInfoSchema):
|
||||||
|
"""Update or create the billing info of this user."""
|
||||||
|
if user_id == self.context.request.user.id:
|
||||||
|
user = self.context.request.user
|
||||||
|
elif self.context.request.user.is_root:
|
||||||
|
user = get_object_or_404(User, pk=user_id)
|
||||||
|
else:
|
||||||
|
raise PermissionDenied
|
||||||
|
customer, _ = Customer.get_or_create(user)
|
||||||
|
BillingInfo.objects.update_or_create(
|
||||||
|
customer=customer, defaults=info.model_dump(exclude_none=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
@route.get("/data", url_name="etransaction_data", include_in_schema=False)
|
||||||
|
def fetch_etransaction_data(self):
|
||||||
|
"""Generate the data to pay an eboutic command with paybox.
|
||||||
|
|
||||||
|
The data is generated with the basket that is used by the current session.
|
||||||
|
"""
|
||||||
|
basket = Basket.from_session(self.context.request.session)
|
||||||
|
if basket is None:
|
||||||
|
raise NotFound
|
||||||
|
return dict(basket.get_e_transaction_data())
|
101
eboutic/forms.py
101
eboutic/forms.py
@ -20,17 +20,15 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from functools import cached_property
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import typing
|
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from sentry_sdk import capture_message
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from eboutic.models import get_eboutic_products
|
from eboutic.models import get_eboutic_products
|
||||||
|
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
||||||
|
|
||||||
|
|
||||||
class BasketForm:
|
class BasketForm:
|
||||||
@ -43,8 +41,7 @@ class BasketForm:
|
|||||||
Thus this class is a pure standalone and performs its operations by its own means.
|
Thus this class is a pure standalone and performs its operations by its own means.
|
||||||
However, it still tries to share some similarities with a standard django Form.
|
However, it still tries to share some similarities with a standard django Form.
|
||||||
|
|
||||||
Example:
|
Examples:
|
||||||
-------
|
|
||||||
::
|
::
|
||||||
|
|
||||||
def my_view(request):
|
def my_view(request):
|
||||||
@ -62,28 +59,13 @@ class BasketForm:
|
|||||||
You can also use a little shortcut by directly calling `form.is_valid()`
|
You can also use a little shortcut by directly calling `form.is_valid()`
|
||||||
without calling `form.clean()`. In this case, the latter method shall be
|
without calling `form.clean()`. In this case, the latter method shall be
|
||||||
implicitly called.
|
implicitly called.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# check the json is an array containing non-nested objects.
|
|
||||||
# values must be strings or numbers
|
|
||||||
# this is matched :
|
|
||||||
# [{"id": 4, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
|
|
||||||
# but this is not :
|
|
||||||
# [{"id": {"nested_id": 10}, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
|
|
||||||
# and neither does this :
|
|
||||||
# [{"id": ["nested_id": 10], "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
|
|
||||||
# and neither does that :
|
|
||||||
# [{"id": null, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
|
|
||||||
json_cookie_re = re.compile(
|
|
||||||
r"^\[\s*(\{\s*(\"[^\"]*\":\s*(\"[^\"]{0,64}\"|\d{0,5}\.?\d+),?\s*)*\},?\s*)*\s*\]$"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, request: HttpRequest):
|
def __init__(self, request: HttpRequest):
|
||||||
self.user = request.user
|
self.user = request.user
|
||||||
self.cookies = request.COOKIES
|
self.cookies = request.COOKIES
|
||||||
self.error_messages = set()
|
self.error_messages = set()
|
||||||
self.correct_cookie = []
|
self.correct_items = []
|
||||||
|
|
||||||
def clean(self) -> None:
|
def clean(self) -> None:
|
||||||
"""Perform all the checks, but return nothing.
|
"""Perform all the checks, but return nothing.
|
||||||
@ -98,70 +80,29 @@ class BasketForm:
|
|||||||
- all the ids refer to products the user is allowed to buy
|
- all the ids refer to products the user is allowed to buy
|
||||||
- all the quantities are positive integers
|
- all the quantities are positive integers
|
||||||
"""
|
"""
|
||||||
# replace escaped double quotes by single quotes, as the RegEx used to check the json
|
try:
|
||||||
# does not support escaped double quotes
|
basket = PurchaseItemList.validate_json(
|
||||||
basket = unquote(self.cookies.get("basket_items", "")).replace('\\"', "'")
|
unquote(self.cookies.get("basket_items", "[]"))
|
||||||
|
|
||||||
if basket in ("[]", ""):
|
|
||||||
self.error_messages.add(_("You have no basket."))
|
|
||||||
return
|
|
||||||
|
|
||||||
# check that the json is not nested before parsing it to make sure
|
|
||||||
# malicious user can't DDoS the server with deeply nested json
|
|
||||||
if not BasketForm.json_cookie_re.match(basket):
|
|
||||||
# As the validation of the cookie goes through a rather boring regex,
|
|
||||||
# we can regularly have to deal with subtle errors that we hadn't forecasted,
|
|
||||||
# so we explicitly lay a Sentry message capture here.
|
|
||||||
capture_message(
|
|
||||||
"Eboutic basket regex checking failed to validate basket json",
|
|
||||||
level="error",
|
|
||||||
)
|
)
|
||||||
|
except ValidationError:
|
||||||
self.error_messages.add(_("The request was badly formatted."))
|
self.error_messages.add(_("The request was badly formatted."))
|
||||||
return
|
return
|
||||||
|
if len(basket) == 0:
|
||||||
try:
|
|
||||||
basket = json.loads(basket)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.error_messages.add(_("The basket cookie was badly formatted."))
|
|
||||||
return
|
|
||||||
|
|
||||||
if type(basket) is not list or len(basket) == 0:
|
|
||||||
self.error_messages.add(_("Your basket is empty."))
|
self.error_messages.add(_("Your basket is empty."))
|
||||||
return
|
return
|
||||||
|
existing_ids = {product.id for product in get_eboutic_products(self.user)}
|
||||||
for item in basket:
|
for item in basket:
|
||||||
expected_keys = {"id", "quantity", "name", "unit_price"}
|
|
||||||
if type(item) is not dict or set(item.keys()) != expected_keys:
|
|
||||||
self.error_messages.add("One or more items are badly formatted.")
|
|
||||||
continue
|
|
||||||
# check the id field is a positive integer
|
|
||||||
if type(item["id"]) is not int or item["id"] < 0:
|
|
||||||
self.error_messages.add(
|
|
||||||
_("%(name)s : this product does not exist.")
|
|
||||||
% {"name": item["name"]}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
# check a product with this id does exist
|
# check a product with this id does exist
|
||||||
ids = {product.id for product in get_eboutic_products(self.user)}
|
if item.product_id in existing_ids:
|
||||||
if not item["id"] in ids:
|
self.correct_items.append(item)
|
||||||
|
else:
|
||||||
self.error_messages.add(
|
self.error_messages.add(
|
||||||
_(
|
_(
|
||||||
"%(name)s : this product does not exist or may no longer be available."
|
"%(name)s : this product does not exist or may no longer be available."
|
||||||
)
|
)
|
||||||
% {"name": item["name"]}
|
% {"name": item.name}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if type(item["quantity"]) is not int or item["quantity"] < 0:
|
|
||||||
self.error_messages.add(
|
|
||||||
_("You cannot buy %(nbr)d %(name)s.")
|
|
||||||
% {"nbr": item["quantity"], "name": item["name"]}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# if we arrive here, it means this item has passed all tests
|
|
||||||
self.correct_cookie.append(item)
|
|
||||||
# for loop for item checking ends here
|
|
||||||
|
|
||||||
# this function does not return anything.
|
# this function does not return anything.
|
||||||
# instead, it fills a set containing the collected error messages
|
# instead, it fills a set containing the collected error messages
|
||||||
# an empty set means that no error was seen thus everything is ok
|
# an empty set means that no error was seen thus everything is ok
|
||||||
@ -174,16 +115,16 @@ class BasketForm:
|
|||||||
|
|
||||||
If the `clean()` method has not been called beforehand, call it.
|
If the `clean()` method has not been called beforehand, call it.
|
||||||
"""
|
"""
|
||||||
if self.error_messages == set() and self.correct_cookie == []:
|
if not self.error_messages and not self.correct_items:
|
||||||
self.clean()
|
self.clean()
|
||||||
if self.error_messages:
|
if self.error_messages:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_error_messages(self) -> typing.List[str]:
|
@cached_property
|
||||||
|
def errors(self) -> list[str]:
|
||||||
return list(self.error_messages)
|
return list(self.error_messages)
|
||||||
|
|
||||||
def get_cleaned_cookie(self) -> str:
|
@cached_property
|
||||||
if not self.correct_cookie:
|
def cleaned_data(self) -> list[PurchaseItemSchema]:
|
||||||
return ""
|
return self.correct_items
|
||||||
return json.dumps(self.correct_cookie)
|
|
||||||
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -38,6 +39,7 @@ def get_eboutic_products(user: User) -> list[Product]:
|
|||||||
.annotate(priority=F("product_type__priority"))
|
.annotate(priority=F("product_type__priority"))
|
||||||
.annotate(category=F("product_type__name"))
|
.annotate(category=F("product_type__name"))
|
||||||
.annotate(category_comment=F("product_type__comment"))
|
.annotate(category_comment=F("product_type__comment"))
|
||||||
|
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||||
)
|
)
|
||||||
return [p for p in products if p.can_be_sold_to(user)]
|
return [p for p in products if p.can_be_sold_to(user)]
|
||||||
|
|
||||||
@ -57,66 +59,25 @@ class Basket(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||||
|
|
||||||
def add_product(self, p: Product, q: int = 1):
|
|
||||||
"""Given p an object of the Product model and q an integer,
|
|
||||||
add q items corresponding to this Product from the basket.
|
|
||||||
|
|
||||||
If this function is called with a product not in the basket, no error will be raised
|
|
||||||
"""
|
|
||||||
item = self.items.filter(product_id=p.id).first()
|
|
||||||
if item is None:
|
|
||||||
BasketItem(
|
|
||||||
basket=self,
|
|
||||||
product_id=p.id,
|
|
||||||
product_name=p.name,
|
|
||||||
type_id=p.product_type.id,
|
|
||||||
quantity=q,
|
|
||||||
product_unit_price=p.selling_price,
|
|
||||||
).save()
|
|
||||||
else:
|
|
||||||
item.quantity += q
|
|
||||||
item.save()
|
|
||||||
|
|
||||||
def del_product(self, p: Product, q: int = 1):
|
|
||||||
"""Given p an object of the Product model and q an integer
|
|
||||||
remove q items corresponding to this Product from the basket.
|
|
||||||
|
|
||||||
If this function is called with a product not in the basket, no error will be raised
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
item = self.items.get(product_id=p.id)
|
|
||||||
except BasketItem.DoesNotExist:
|
|
||||||
return
|
|
||||||
item.quantity -= q
|
|
||||||
if item.quantity <= 0:
|
|
||||||
item.delete()
|
|
||||||
else:
|
|
||||||
item.save()
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Remove all items from this basket without deleting the basket."""
|
|
||||||
self.items.all().delete()
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def contains_refilling_item(self) -> bool:
|
def contains_refilling_item(self) -> bool:
|
||||||
return self.items.filter(
|
return self.items.filter(
|
||||||
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
def get_total(self) -> float:
|
@cached_property
|
||||||
total = self.items.aggregate(
|
def total(self) -> float:
|
||||||
total=Sum(F("quantity") * F("product_unit_price"))
|
return float(
|
||||||
)["total"]
|
self.items.aggregate(
|
||||||
return float(total) if total is not None else 0
|
total=Sum(F("quantity") * F("product_unit_price"), default=0)
|
||||||
|
)["total"]
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_session(cls, session) -> Basket | None:
|
def from_session(cls, session) -> Basket | None:
|
||||||
"""The basket stored in the session object, if it exists."""
|
"""The basket stored in the session object, if it exists."""
|
||||||
if "basket_id" in session:
|
if "basket_id" in session:
|
||||||
try:
|
return cls.objects.filter(id=session["basket_id"]).first()
|
||||||
return cls.objects.get(id=session["basket_id"])
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
return None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def generate_sales(self, counter, seller: User, payment_method: str):
|
def generate_sales(self, counter, seller: User, payment_method: str):
|
||||||
@ -161,18 +122,24 @@ class Basket(models.Model):
|
|||||||
)
|
)
|
||||||
return sales
|
return sales
|
||||||
|
|
||||||
def get_e_transaction_data(self):
|
def get_e_transaction_data(self) -> list[tuple[str, Any]]:
|
||||||
user = self.user
|
user = self.user
|
||||||
if not hasattr(user, "customer"):
|
if not hasattr(user, "customer"):
|
||||||
raise Customer.DoesNotExist
|
raise Customer.DoesNotExist
|
||||||
customer = user.customer
|
customer = user.customer
|
||||||
if not hasattr(user.customer, "billing_infos"):
|
if not hasattr(user.customer, "billing_infos"):
|
||||||
raise BillingInfo.DoesNotExist
|
raise BillingInfo.DoesNotExist
|
||||||
|
cart = {
|
||||||
|
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
||||||
|
}
|
||||||
|
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
|
||||||
|
cart, newlines=False
|
||||||
|
)
|
||||||
data = [
|
data = [
|
||||||
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
|
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
|
||||||
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
|
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
|
||||||
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
||||||
("PBX_TOTAL", str(int(self.get_total() * 100))),
|
("PBX_TOTAL", str(int(self.total * 100))),
|
||||||
("PBX_DEVISE", "978"), # This is Euro
|
("PBX_DEVISE", "978"), # This is Euro
|
||||||
("PBX_CMD", str(self.id)),
|
("PBX_CMD", str(self.id)),
|
||||||
("PBX_PORTEUR", user.email),
|
("PBX_PORTEUR", user.email),
|
||||||
@ -181,14 +148,6 @@ class Basket(models.Model):
|
|||||||
("PBX_TYPEPAIEMENT", "CARTE"),
|
("PBX_TYPEPAIEMENT", "CARTE"),
|
||||||
("PBX_TYPECARTE", "CB"),
|
("PBX_TYPECARTE", "CB"),
|
||||||
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
|
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
|
||||||
]
|
|
||||||
cart = {
|
|
||||||
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
|
||||||
}
|
|
||||||
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
|
|
||||||
cart, newlines=False
|
|
||||||
)
|
|
||||||
data += [
|
|
||||||
("PBX_SHOPPINGCART", cart),
|
("PBX_SHOPPINGCART", cart),
|
||||||
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
|
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
|
||||||
]
|
]
|
||||||
@ -218,10 +177,11 @@ class Invoice(models.Model):
|
|||||||
return f"{self.user} - {self.get_total()} - {self.date}"
|
return f"{self.user} - {self.get_total()} - {self.date}"
|
||||||
|
|
||||||
def get_total(self) -> float:
|
def get_total(self) -> float:
|
||||||
total = self.items.aggregate(
|
return float(
|
||||||
total=Sum(F("quantity") * F("product_unit_price"))
|
self.items.aggregate(
|
||||||
)["total"]
|
total=Sum(F("quantity") * F("product_unit_price"), default=0)
|
||||||
return float(total) if total is not None else 0
|
)["total"]
|
||||||
|
)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
if self.validated:
|
if self.validated:
|
||||||
@ -284,7 +244,7 @@ class BasketItem(AbstractBaseItem):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_product(cls, product: Product, quantity: int):
|
def from_product(cls, product: Product, quantity: int, basket: Basket):
|
||||||
"""Create a BasketItem with the same characteristics as the
|
"""Create a BasketItem with the same characteristics as the
|
||||||
product passed in parameters, with the specified quantity.
|
product passed in parameters, with the specified quantity.
|
||||||
|
|
||||||
@ -293,9 +253,10 @@ class BasketItem(AbstractBaseItem):
|
|||||||
it yourself before saving the model.
|
it yourself before saving the model.
|
||||||
"""
|
"""
|
||||||
return cls(
|
return cls(
|
||||||
|
basket=basket,
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
product_name=product.name,
|
product_name=product.name,
|
||||||
type_id=product.product_type.id,
|
type_id=product.product_type_id,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
product_unit_price=product.selling_price,
|
product_unit_price=product.selling_price,
|
||||||
)
|
)
|
||||||
|
33
eboutic/schemas.py
Normal file
33
eboutic/schemas.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
|
||||||
|
|
||||||
|
from counter.models import BillingInfo
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseItemSchema(Schema):
|
||||||
|
product_id: NonNegativeInt = Field(alias="id")
|
||||||
|
name: str
|
||||||
|
unit_price: float
|
||||||
|
quantity: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
# The eboutic deals with data that is dict mixed with JSON.
|
||||||
|
# Hence it would be a hassle to manage it with a proper Schema class,
|
||||||
|
# and we use a TypeAdapter instead
|
||||||
|
PurchaseItemList = TypeAdapter(list[PurchaseItemSchema])
|
||||||
|
|
||||||
|
|
||||||
|
class BillingInfoSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = BillingInfo
|
||||||
|
fields = [
|
||||||
|
"customer",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"address_1",
|
||||||
|
"address_2",
|
||||||
|
"zip_code",
|
||||||
|
"city",
|
||||||
|
"country",
|
||||||
|
]
|
||||||
|
fields_optional = ["customer"]
|
@ -30,22 +30,17 @@ function getCookie(name) {
|
|||||||
*/
|
*/
|
||||||
function get_starting_items() {
|
function get_starting_items() {
|
||||||
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
||||||
let output = [];
|
if (!cookie) {
|
||||||
|
return []
|
||||||
try {
|
}
|
||||||
// Django cookie backend does an utter mess on non-trivial data types
|
// Django cookie backend converts `,` to `\054`
|
||||||
// so we must perform a conversion of our own
|
let parsed = JSON.parse(cookie.replace(/\\054/g, ','));
|
||||||
const biscuit = JSON.parse(cookie.replace(/\\054/g, ','));
|
if (typeof parsed === "string") {
|
||||||
output = Array.isArray(biscuit) ? biscuit : [];
|
// In some conditions, a second parsing is needed
|
||||||
|
parsed = JSON.parse(parsed);
|
||||||
} catch (e) {}
|
}
|
||||||
|
const res = Array.isArray(parsed) ? parsed : [];
|
||||||
output.forEach(item => {
|
return res.filter((i) => !!document.getElementById(i.id))
|
||||||
let el = document.getElementById(item.id);
|
|
||||||
el.classList.add("selected");
|
|
||||||
});
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
@ -81,9 +76,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.items[index].quantity -= 1;
|
this.items[index].quantity -= 1;
|
||||||
|
|
||||||
if (this.items[index].quantity === 0) {
|
if (this.items[index].quantity === 0) {
|
||||||
let el = document.getElementById(this.items[index].id);
|
|
||||||
el.classList.remove("selected");
|
|
||||||
|
|
||||||
this.items = this.items.filter((e) => e.id !== this.items[index].id);
|
this.items = this.items.filter((e) => e.id !== this.items[index].id);
|
||||||
}
|
}
|
||||||
this.set_cookies();
|
this.set_cookies();
|
||||||
@ -93,12 +85,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
* Remove all the items from the basket & cleans the catalog CSS classes
|
* Remove all the items from the basket & cleans the catalog CSS classes
|
||||||
*/
|
*/
|
||||||
clear_basket() {
|
clear_basket() {
|
||||||
// We remove the class "selected" from all the items in the catalog
|
|
||||||
this.items.forEach(item => {
|
|
||||||
let el = document.getElementById(item.id);
|
|
||||||
el.classList.remove("selected");
|
|
||||||
})
|
|
||||||
|
|
||||||
this.items = [];
|
this.items = [];
|
||||||
this.set_cookies();
|
this.set_cookies();
|
||||||
},
|
},
|
||||||
@ -108,8 +94,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
* ! the cookie survives an hour
|
* ! the cookie survives an hour
|
||||||
*/
|
*/
|
||||||
set_cookies() {
|
set_cookies() {
|
||||||
if (this.items.length === 0) document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
|
if (this.items.length === 0) {
|
||||||
else document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
|
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
|
||||||
|
} else {
|
||||||
|
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -145,12 +134,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// if the item is not in the basket, we create it
|
// if the item is not in the basket, we create it
|
||||||
// else we add + 1 to it
|
// else we add + 1 to it
|
||||||
if (item === undefined) item = this.create_item(id, name, price);
|
if (!item) {
|
||||||
else this.add(item);
|
item = this.create_item(id, name, price);
|
||||||
|
} else {
|
||||||
if (item.quantity > 0) {
|
this.add(item);
|
||||||
let el = document.getElementById(item.id);
|
|
||||||
el.classList.add("selected");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
@ -1,73 +1,70 @@
|
|||||||
document.addEventListener('alpine:init', () => {
|
/**
|
||||||
Alpine.store('bank_payment_enabled', false)
|
* @readonly
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
const BillingInfoReqState = {
|
||||||
|
SUCCESS: 1,
|
||||||
|
FAILURE: 2,
|
||||||
|
SENDING: 3,
|
||||||
|
};
|
||||||
|
|
||||||
Alpine.store('billing_inputs', {
|
document.addEventListener("alpine:init", () => {
|
||||||
data: JSON.parse(et_data)["data"],
|
Alpine.store("billing_inputs", {
|
||||||
|
data: et_data,
|
||||||
|
|
||||||
async fill() {
|
async fill() {
|
||||||
document.getElementById("bank-submit-button").disabled = true;
|
document.getElementById("bank-submit-button").disabled = true;
|
||||||
const request = new Request(et_data_url, {
|
const res = await fetch(et_data_url);
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const res = await fetch(request);
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = await res.json();
|
this.data = await res.json();
|
||||||
if (json["data"]) {
|
|
||||||
this.data = json["data"];
|
|
||||||
}
|
|
||||||
document.getElementById("bank-submit-button").disabled = false;
|
document.getElementById("bank-submit-button").disabled = false;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
Alpine.data('billing_infos', () => ({
|
Alpine.data("billing_infos", () => ({
|
||||||
errors: [],
|
/** @type {BillingInfoReqState | null} */
|
||||||
successful: false,
|
req_state: null,
|
||||||
url: billing_info_exist ? edit_billing_info_url : create_billing_info_url,
|
|
||||||
|
|
||||||
async send_form() {
|
async send_form() {
|
||||||
|
this.req_state = BillingInfoReqState.SENDING;
|
||||||
const form = document.getElementById("billing_info_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;
|
document.getElementById("bank-submit-button").disabled = true;
|
||||||
this.successful = false
|
let payload = Object.fromEntries(
|
||||||
|
Array.from(form.querySelectorAll("input, select"))
|
||||||
let payload = {};
|
.filter((elem) => elem.type !== "submit" && elem.value)
|
||||||
for (const elem of form.querySelectorAll("input")) {
|
.map((elem) => [elem.name, elem.value]),
|
||||||
if (elem.type === "text" && elem.value) {
|
);
|
||||||
payload[elem.name] = elem.value;
|
const res = await fetch(billing_info_url, {
|
||||||
}
|
method: "PUT",
|
||||||
}
|
|
||||||
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),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const res = await fetch(request);
|
this.req_state = res.ok
|
||||||
const json = await res.json();
|
? BillingInfoReqState.SUCCESS
|
||||||
if (json["errors"]) {
|
: BillingInfoReqState.FAILURE;
|
||||||
this.errors = json["errors"];
|
if (res.ok) {
|
||||||
} else {
|
|
||||||
this.errors = [];
|
|
||||||
this.successful = true;
|
|
||||||
this.url = edit_billing_info_url;
|
|
||||||
Alpine.store("billing_inputs").fill();
|
Alpine.store("billing_inputs").fill();
|
||||||
}
|
}
|
||||||
submit_button.disabled = false;
|
},
|
||||||
}
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
|
get_alert_color() {
|
||||||
|
if (this.req_state === BillingInfoReqState.SUCCESS) {
|
||||||
|
return "green";
|
||||||
|
}
|
||||||
|
if (this.req_state === BillingInfoReqState.FAILURE) {
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
|
||||||
|
get_alert_message() {
|
||||||
|
if (this.req_state === BillingInfoReqState.SUCCESS) {
|
||||||
|
return billing_info_success_message;
|
||||||
|
}
|
||||||
|
if (this.req_state === BillingInfoReqState.FAILURE) {
|
||||||
|
return billing_info_failure_message;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
@ -29,7 +29,6 @@
|
|||||||
{% for error in errors %}
|
{% for error in errors %}
|
||||||
<p style="margin: 0">{{ error }}</p>
|
<p style="margin: 0">{{ error }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -103,8 +102,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="product-group">
|
<div class="product-group">
|
||||||
{% for p in items %}
|
{% for p in items %}
|
||||||
<button id="{{ p.id }}" class="product-button"
|
<button
|
||||||
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
|
id="{{ p.id }}"
|
||||||
|
class="product-button"
|
||||||
|
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
|
||||||
|
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||||
|
>
|
||||||
{% if p.icon %}
|
{% if p.icon %}
|
||||||
<img class="product-image" src="{{ p.icon.url }}"
|
<img class="product-image" src="{{ p.icon.url }}"
|
||||||
alt="image de {{ p.name }}">
|
alt="image de {{ p.name }}">
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} €</strong>
|
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} €</strong>
|
||||||
|
|
||||||
{% if customer_amount != None %}
|
{% if customer_amount != None %}
|
||||||
<br>
|
<br>
|
||||||
@ -47,49 +47,54 @@
|
|||||||
{% if not basket.contains_refilling_item %}
|
{% if not basket.contains_refilling_item %}
|
||||||
<br>
|
<br>
|
||||||
{% trans %}Remaining account amount: {% endtrans %}
|
{% trans %}Remaining account amount: {% endtrans %}
|
||||||
<strong>{{ "%0.2f"|format(customer_amount|float - basket.get_total()) }} €</strong>
|
<strong>{{ "%0.2f"|format(customer_amount|float - basket.total) }} €</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<br>
|
<br>
|
||||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
||||||
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
|
<div
|
||||||
|
class="collapse"
|
||||||
|
:class="{'shadow': collapsed}"
|
||||||
|
x-data="{collapsed: !billing_info_exist}"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
<div class="collapse-header clickable" @click="collapsed = !collapsed">
|
<div class="collapse-header clickable" @click="collapsed = !collapsed">
|
||||||
<span class="collapse-header-text">
|
<span class="collapse-header-text">
|
||||||
{% trans %}Edit billing information{% endtrans %}
|
{% trans %}Billing information{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
|
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
|
||||||
<i class="fa fa-caret-down"></i>
|
<i class="fa fa-caret-down"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<form class="collapse-body" id="billing_info_form" method="post"
|
<form
|
||||||
x-show="collapsed" x-data="billing_infos"
|
class="collapse-body"
|
||||||
x-transition.scale.origin.top
|
id="billing_info_form"
|
||||||
@submit.prevent="send_form()">
|
x-data="billing_infos"
|
||||||
|
x-show="collapsed"
|
||||||
|
x-transition.scale.origin.top
|
||||||
|
@submit.prevent="await send_form()"
|
||||||
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ billing_form }}
|
{{ billing_form }}
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
<div x-show="errors.length > 0" class="alert alert-red" x-transition>
|
<div
|
||||||
<div class="alert-main">
|
x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(req_state)"
|
||||||
<template x-for="error in errors">
|
class="alert"
|
||||||
<div x-text="error.field + ' : ' + error.messages.join(', ')"></div>
|
:class="'alert-' + get_alert_color()"
|
||||||
</template>
|
x-transition
|
||||||
</div>
|
>
|
||||||
<div class="clickable" @click="errors = []">
|
<div class="alert-main" x-text="get_alert_message()"></div>
|
||||||
|
<div class="clickable" @click="req_state = null">
|
||||||
<i class="fa fa-close"></i>
|
<i class="fa fa-close"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="successful" class="alert alert-green" x-transition>
|
<input
|
||||||
<div class="alert-main">
|
type="submit" class="btn btn-blue clickable"
|
||||||
Informations de facturation enregistrées
|
value="{% trans %}Validate{% endtrans %}"
|
||||||
</div>
|
:disabled="req_state === BillingInfoReqState.SENDING"
|
||||||
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
@ -102,16 +107,21 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
|
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
|
||||||
<template x-data x-for="input in $store.billing_inputs.data">
|
<template x-data x-for="[key, value] in Object.entries($store.billing_inputs.data)">
|
||||||
<input type="hidden" :name="input['key']" :value="input['value']">
|
<input type="hidden" :name="key" :value="value">
|
||||||
</template>
|
</template>
|
||||||
<input type="submit" id="bank-submit-button"
|
<input
|
||||||
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
|
type="submit"
|
||||||
value="{% trans %}Pay with credit card{% endtrans %}"/>
|
id="bank-submit-button"
|
||||||
|
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
|
||||||
|
value="{% trans %}Pay with credit card{% endtrans %}"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if basket.contains_refilling_item %}
|
{% if basket.contains_refilling_item %}
|
||||||
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
||||||
|
{% elif basket.total > user.account_balance %}
|
||||||
|
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -124,15 +134,16 @@
|
|||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
const create_billing_info_url = '{{ url("counter:create_billing_info", user_id=request.user.id) }}'
|
const billing_info_url = '{{ url("api:put_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("api:etransaction_data") }}';
|
||||||
const et_data_url = '{{ url("eboutic:et_data") }}'
|
const billing_info_exist = {{ "true" if billing_infos else "false" }};
|
||||||
let billing_info_exist = {{ "true" if billing_infos else "false" }}
|
const billing_info_success_message = "{% trans %}Billing info registration success{% endtrans %}";
|
||||||
|
const billing_info_failure_message = "{% trans %}Billing info registration failure{% endtrans %}";
|
||||||
|
|
||||||
{% if billing_infos %}
|
{% if billing_infos %}
|
||||||
const et_data = {{ billing_infos|tojson }}
|
const et_data = {{ billing_infos|safe }}
|
||||||
{% else %}
|
{% else %}
|
||||||
const et_data = '{"data": []}'
|
const et_data = {}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
@ -36,7 +36,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import Counter, Customer, Product, Selling
|
from counter.models import Counter, Customer, Product, Selling
|
||||||
from eboutic.models import Basket
|
from eboutic.models import Basket, BasketItem
|
||||||
|
|
||||||
|
|
||||||
class TestEboutic(TestCase):
|
class TestEboutic(TestCase):
|
||||||
@ -60,14 +60,14 @@ class TestEboutic(TestCase):
|
|||||||
basket = Basket.objects.create(user=user)
|
basket = Basket.objects.create(user=user)
|
||||||
session["basket_id"] = basket.id
|
session["basket_id"] = basket.id
|
||||||
session.save()
|
session.save()
|
||||||
basket.add_product(self.barbar, 3)
|
BasketItem.from_product(self.barbar, 3, basket).save()
|
||||||
basket.add_product(self.cotis)
|
BasketItem.from_product(self.cotis, 1, basket).save()
|
||||||
return basket
|
return basket
|
||||||
|
|
||||||
def generate_bank_valid_answer(self) -> str:
|
def generate_bank_valid_answer(self) -> str:
|
||||||
basket = Basket.from_session(self.client.session)
|
basket = Basket.from_session(self.client.session)
|
||||||
basket_id = basket.id
|
basket_id = basket.id
|
||||||
amount = int(basket.get_total() * 100)
|
amount = int(basket.total * 100)
|
||||||
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
|
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
|
||||||
with open("./eboutic/tests/private_key.pem", "br") as f:
|
with open("./eboutic/tests/private_key.pem", "br") as f:
|
||||||
PRIVKEY = f.read()
|
PRIVKEY = f.read()
|
||||||
@ -88,7 +88,7 @@ class TestEboutic(TestCase):
|
|||||||
self.subscriber.customer.amount = 100 # give money before test
|
self.subscriber.customer.amount = 100 # give money before test
|
||||||
self.subscriber.customer.save()
|
self.subscriber.customer.save()
|
||||||
basket = self.get_busy_basket(self.subscriber)
|
basket = self.get_busy_basket(self.subscriber)
|
||||||
amount = basket.get_total()
|
amount = basket.total
|
||||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
||||||
self.assertRedirects(response, "/eboutic/pay/success/")
|
self.assertRedirects(response, "/eboutic/pay/success/")
|
||||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
new_balance = Customer.objects.get(user=self.subscriber).amount
|
||||||
@ -99,7 +99,7 @@ class TestEboutic(TestCase):
|
|||||||
def test_buy_with_sith_account_no_money(self):
|
def test_buy_with_sith_account_no_money(self):
|
||||||
self.client.force_login(self.subscriber)
|
self.client.force_login(self.subscriber)
|
||||||
basket = self.get_busy_basket(self.subscriber)
|
basket = self.get_busy_basket(self.subscriber)
|
||||||
initial = basket.get_total() - 1 # just not enough to complete the sale
|
initial = basket.total - 1 # just not enough to complete the sale
|
||||||
self.subscriber.customer.amount = initial
|
self.subscriber.customer.amount = initial
|
||||||
self.subscriber.customer.save()
|
self.subscriber.customer.save()
|
||||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
||||||
@ -135,7 +135,7 @@ class TestEboutic(TestCase):
|
|||||||
cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
|
cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
|
||||||
assert cotis is not None
|
assert cotis is not None
|
||||||
assert cotis.quantity == 1
|
assert cotis.quantity == 1
|
||||||
assert basket.get_total() == 3 * 1.7 + 28
|
assert basket.total == 3 * 1.7 + 28
|
||||||
|
|
||||||
def test_submit_empty_basket(self):
|
def test_submit_empty_basket(self):
|
||||||
self.client.force_login(self.subscriber)
|
self.client.force_login(self.subscriber)
|
||||||
@ -151,7 +151,7 @@ class TestEboutic(TestCase):
|
|||||||
]"""
|
]"""
|
||||||
response = self.client.get(reverse("eboutic:command"))
|
response = self.client.get(reverse("eboutic:command"))
|
||||||
cookie = self.client.cookies["basket_items"].OutputString()
|
cookie = self.client.cookies["basket_items"].OutputString()
|
||||||
assert 'basket_items=""' in cookie
|
assert 'basket_items="[]"' in cookie
|
||||||
assert "Path=/eboutic" in cookie
|
assert "Path=/eboutic" in cookie
|
||||||
self.assertRedirects(response, "/eboutic/")
|
self.assertRedirects(response, "/eboutic/")
|
||||||
|
|
||||||
|
@ -16,7 +16,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
@ -26,6 +25,7 @@ from cryptography.hazmat.primitives.hashes import SHA1
|
|||||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.db import DatabaseError, transaction
|
from django.db import DatabaseError, transaction
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
@ -37,7 +37,14 @@ from django.views.generic import TemplateView, View
|
|||||||
from counter.forms import BillingInfoForm
|
from counter.forms import BillingInfoForm
|
||||||
from counter.models import Counter, Customer, Product
|
from counter.models import Counter, Customer, Product
|
||||||
from eboutic.forms import BasketForm
|
from eboutic.forms import BasketForm
|
||||||
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products
|
from eboutic.models import (
|
||||||
|
Basket,
|
||||||
|
BasketItem,
|
||||||
|
Invoice,
|
||||||
|
InvoiceItem,
|
||||||
|
get_eboutic_products,
|
||||||
|
)
|
||||||
|
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -75,43 +82,46 @@ def payment_result(request, result: str) -> HttpResponse:
|
|||||||
return render(request, "eboutic/eboutic_payment_result.jinja", context)
|
return render(request, "eboutic/eboutic_payment_result.jinja", context)
|
||||||
|
|
||||||
|
|
||||||
class EbouticCommand(TemplateView):
|
class EbouticCommand(LoginRequiredMixin, TemplateView):
|
||||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
template_name = "eboutic/eboutic_makecommand.jinja"
|
||||||
|
basket: Basket
|
||||||
|
|
||||||
@method_decorator(login_required)
|
@method_decorator(login_required)
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
return redirect("eboutic:main")
|
return redirect("eboutic:main")
|
||||||
|
|
||||||
@method_decorator(login_required)
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs):
|
def get(self, request: HttpRequest, *args, **kwargs):
|
||||||
form = BasketForm(request)
|
form = BasketForm(request)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
request.session["errors"] = form.get_error_messages()
|
request.session["errors"] = form.errors
|
||||||
request.session.modified = True
|
request.session.modified = True
|
||||||
res = redirect("eboutic:main")
|
res = redirect("eboutic:main")
|
||||||
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic")
|
res.set_cookie(
|
||||||
|
"basket_items",
|
||||||
|
PurchaseItemList.dump_json(form.cleaned_data, by_alias=True).decode(),
|
||||||
|
path="/eboutic",
|
||||||
|
)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
basket = Basket.from_session(request.session)
|
basket = Basket.from_session(request.session)
|
||||||
if basket is not None:
|
if basket is not None:
|
||||||
basket.clear()
|
basket.items.all().delete()
|
||||||
else:
|
else:
|
||||||
basket = Basket.objects.create(user=request.user)
|
basket = Basket.objects.create(user=request.user)
|
||||||
request.session["basket_id"] = basket.id
|
request.session["basket_id"] = basket.id
|
||||||
request.session.modified = True
|
request.session.modified = True
|
||||||
|
|
||||||
items = json.loads(unquote(request.COOKIES["basket_items"]))
|
items: list[PurchaseItemSchema] = form.cleaned_data
|
||||||
items.sort(key=lambda item: item["id"])
|
pks = {item.product_id for item in items}
|
||||||
ids = [item["id"] for item in items]
|
products = {p.pk: p for p in Product.objects.filter(pk__in=pks)}
|
||||||
quantities = [item["quantity"] for item in items]
|
db_items = []
|
||||||
products = Product.objects.filter(id__in=ids)
|
for pk in pks:
|
||||||
for product, qty in zip(products, quantities):
|
quantity = sum(i.quantity for i in items if i.product_id == pk)
|
||||||
basket.add_product(product, qty)
|
db_items.append(BasketItem.from_product(products[pk], quantity, basket))
|
||||||
kwargs["basket"] = basket
|
BasketItem.objects.bulk_create(db_items)
|
||||||
return self.render_to_response(self.get_context_data(**kwargs))
|
self.basket = basket
|
||||||
|
return super().get(request)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
# basket is already in kwargs when the method is called
|
|
||||||
default_billing_info = None
|
default_billing_info = None
|
||||||
if hasattr(self.request.user, "customer"):
|
if hasattr(self.request.user, "customer"):
|
||||||
customer = self.request.user.customer
|
customer = self.request.user.customer
|
||||||
@ -124,9 +134,8 @@ class EbouticCommand(TemplateView):
|
|||||||
if not kwargs["must_fill_billing_infos"]:
|
if not kwargs["must_fill_billing_infos"]:
|
||||||
# the user has already filled its billing_infos, thus we can
|
# the user has already filled its billing_infos, thus we can
|
||||||
# get it without expecting an error
|
# get it without expecting an error
|
||||||
data = kwargs["basket"].get_e_transaction_data()
|
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
|
||||||
data = {"data": [{"key": key, "value": val} for key, val in data]}
|
kwargs["basket"] = self.basket
|
||||||
kwargs["billing_infos"] = json.dumps(data)
|
|
||||||
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
|
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@ -149,29 +158,32 @@ def pay_with_sith(request):
|
|||||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
if basket is None or basket.items.filter(type_id=refilling).exists():
|
if basket is None or basket.items.filter(type_id=refilling).exists():
|
||||||
return redirect("eboutic:main")
|
return redirect("eboutic:main")
|
||||||
c = Customer.objects.filter(user__id=basket.user.id).first()
|
c = Customer.objects.filter(user__id=basket.user_id).first()
|
||||||
if c is None:
|
if c is None:
|
||||||
return redirect("eboutic:main")
|
return redirect("eboutic:main")
|
||||||
if c.amount < basket.get_total():
|
if c.amount < basket.total:
|
||||||
|
res = redirect("eboutic:payment_result", "failure")
|
||||||
|
res.delete_cookie("basket_items", "/eboutic")
|
||||||
|
return res
|
||||||
|
eboutic = Counter.objects.get(type="EBOUTIC")
|
||||||
|
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# Selling.save has some important business logic in it.
|
||||||
|
# Do not bulk_create this
|
||||||
|
for sale in sales:
|
||||||
|
sale.save()
|
||||||
|
basket.delete()
|
||||||
|
request.session.pop("basket_id", None)
|
||||||
|
res = redirect("eboutic:payment_result", "success")
|
||||||
|
except DatabaseError as e:
|
||||||
|
with sentry_sdk.push_scope() as scope:
|
||||||
|
scope.user = {"username": request.user.username}
|
||||||
|
scope.set_extra("someVariable", e.__repr__())
|
||||||
|
sentry_sdk.capture_message(
|
||||||
|
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
||||||
|
)
|
||||||
res = redirect("eboutic:payment_result", "failure")
|
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 sale in sales:
|
|
||||||
sale.save()
|
|
||||||
basket.delete()
|
|
||||||
request.session.pop("basket_id", None)
|
|
||||||
res = redirect("eboutic:payment_result", "success")
|
|
||||||
except DatabaseError as e:
|
|
||||||
with sentry_sdk.push_scope() as scope:
|
|
||||||
scope.user = {"username": request.user.username}
|
|
||||||
scope.set_extra("someVariable", e.__repr__())
|
|
||||||
sentry_sdk.capture_message(
|
|
||||||
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
|
||||||
)
|
|
||||||
res = redirect("eboutic:payment_result", "failure")
|
|
||||||
res.delete_cookie("basket_items", "/eboutic")
|
res.delete_cookie("basket_items", "/eboutic")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@ -205,7 +217,7 @@ class EtransactionAutoAnswer(View):
|
|||||||
)
|
)
|
||||||
if b is None:
|
if b is None:
|
||||||
raise SuspiciousOperation("Basket does not exists")
|
raise SuspiciousOperation("Basket does not exists")
|
||||||
if int(b.get_total() * 100) != int(request.GET["Amount"]):
|
if int(b.total * 100) != int(request.GET["Amount"]):
|
||||||
raise SuspiciousOperation(
|
raise SuspiciousOperation(
|
||||||
"Basket total and amount do not match"
|
"Basket total and amount do not match"
|
||||||
)
|
)
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -618,11 +618,14 @@ SITH_EBOUTIC_CB_ENABLED = True
|
|||||||
SITH_EBOUTIC_ET_URL = (
|
SITH_EBOUTIC_ET_URL = (
|
||||||
"https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
|
"https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
|
||||||
)
|
)
|
||||||
SITH_EBOUTIC_PBX_SITE = "4000666"
|
SITH_EBOUTIC_PBX_SITE = "1999888"
|
||||||
SITH_EBOUTIC_PBX_RANG = "42"
|
SITH_EBOUTIC_PBX_RANG = "32"
|
||||||
SITH_EBOUTIC_PBX_IDENTIFIANT = "123456789"
|
SITH_EBOUTIC_PBX_IDENTIFIANT = "2"
|
||||||
SITH_EBOUTIC_HMAC_KEY = binascii.unhexlify(
|
SITH_EBOUTIC_HMAC_KEY = binascii.unhexlify(
|
||||||
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"
|
"0123456789ABCDEF0123456789ABCDEF"
|
||||||
|
"0123456789ABCDEF0123456789ABCDEF"
|
||||||
|
"0123456789ABCDEF0123456789ABCDEF"
|
||||||
|
"0123456789ABCDEF0123456789ABCDEF"
|
||||||
)
|
)
|
||||||
SITH_EBOUTIC_PUB_KEY = ""
|
SITH_EBOUTIC_PUB_KEY = ""
|
||||||
with open(os.path.join(os.path.dirname(__file__), "et_keys/pubkey.pem")) as f:
|
with open(os.path.join(os.path.dirname(__file__), "et_keys/pubkey.pem")) as f:
|
||||||
|
Loading…
Reference in New Issue
Block a user