From 814ce48a49c36de06722e8125a10f95d77cb96bf Mon Sep 17 00:00:00 2001 From: Mahal Date: Wed, 6 Sep 2023 13:08:15 +0200 Subject: [PATCH 01/62] adding payment application --- insalan/payment/__init__.py | 0 insalan/payment/admin.py | 3 +++ insalan/payment/apps.py | 6 ++++++ insalan/payment/migrations/__init__.py | 0 insalan/payment/models.py | 3 +++ insalan/payment/tests.py | 3 +++ insalan/payment/urls.py | 8 ++++++++ insalan/payment/views.py | 3 +++ 8 files changed, 26 insertions(+) create mode 100644 insalan/payment/__init__.py create mode 100644 insalan/payment/admin.py create mode 100644 insalan/payment/apps.py create mode 100644 insalan/payment/migrations/__init__.py create mode 100644 insalan/payment/models.py create mode 100644 insalan/payment/tests.py create mode 100644 insalan/payment/urls.py create mode 100644 insalan/payment/views.py diff --git a/insalan/payment/__init__.py b/insalan/payment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/insalan/payment/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/insalan/payment/apps.py b/insalan/payment/apps.py new file mode 100644 index 00000000..b4a45c3a --- /dev/null +++ b/insalan/payment/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'payment' diff --git a/insalan/payment/migrations/__init__.py b/insalan/payment/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/insalan/payment/models.py b/insalan/payment/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/insalan/payment/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/insalan/payment/tests.py b/insalan/payment/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/insalan/payment/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/insalan/payment/urls.py b/insalan/payment/urls.py new file mode 100644 index 00000000..f46ae54f --- /dev/null +++ b/insalan/payment/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + + +app_name = "payment" +urlpatterns = [ +] diff --git a/insalan/payment/views.py b/insalan/payment/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/insalan/payment/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 4a168815ea73f410d8cbf5f2cde7094e20b92578 Mon Sep 17 00:00:00 2001 From: Mahal Date: Wed, 6 Sep 2023 18:50:22 +0200 Subject: [PATCH 02/62] adding transaction model 1/n --- insalan/payment/models.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 71a83623..59a745c4 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -1,3 +1,25 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ +from insalan.user.models import User + +class TransactionStatus(models.TextChoices): + """Information about the current transaction status""" + + FAILED = "FAILED", _("Fail") + SUCCEDED = "SUCCEEDED", _("Success") + + +class Transaction(models.Model): + """A transaction""" + payer = models.ForeignKey(User, on_delete=models.CASCADE) + amount = models.DecimalField(null=False) + payment_status = models.CharField( + max_length=10, + blank=True, + default=TransactionStatus.FAILED, + choices=TransactionStatus.choices, + null=False, + verbose_name="Transaction status", + ) + date = models.DateField() -# Create your models here. From c72c47d9ae7114bbd55a328adc8906cf100e2bfa Mon Sep 17 00:00:00 2001 From: Khagana Date: Tue, 12 Sep 2023 11:21:49 +0200 Subject: [PATCH 03/62] starting the payment view --- insalan/payment/models.py | 2 ++ insalan/payment/serializers.py | 7 ++++ insalan/payment/views.py | 60 ++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 insalan/payment/serializers.py diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 59a745c4..aaf81f11 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from insalan.user.models import User +import uuid class TransactionStatus(models.TextChoices): """Information about the current transaction status""" @@ -11,6 +12,7 @@ class TransactionStatus(models.TextChoices): class Transaction(models.Model): """A transaction""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) payer = models.ForeignKey(User, on_delete=models.CASCADE) amount = models.DecimalField(null=False) payment_status = models.CharField( diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py new file mode 100644 index 00000000..6727784a --- /dev/null +++ b/insalan/payment/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import Transaction, TransactionStatus + +class TransactionSerializer(serializers.ModelSerializer): + class Meta: + model=Transaction + fields = ['payer', 'amount', 'payment_status', 'date'] \ No newline at end of file diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 91ea44a2..852fe715 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -1,3 +1,63 @@ +import json +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned +from os import getenv +import requests +from .models import Transaction +from datetime import date + from django.shortcuts import render # Create your views here. + + +def pay(request): + # lets parse the request + user_request_body = json.loads(request.body) + product_list=[] + amount=0 + name="" + user=request.user #not that but this is the idea + for asked_product in user_request_body: + try: + product = Product.objects.get(pk=asked_product[id]) + product_list.append(product) + if asked_product == user_request_body.pop(): + name+=product.name + else : + name+=product.name + ", " + # need that all product implement a Product Model (with an id as pk, and a price) + amount += product.price + except (ObjectDoesNotExist, MultipleObjectsReturned): + pass # do something + + transaction=Transaction(amount=amount, payer=user, products=product_list, date=date.today()) + # need to put a list field of product in Transaction model + + # lets init a checkout to helloasso + url = f"https://api.helloasso.com/v5/organizations/{getenv('HELLOASSO_NAME')}/checkout-intents" + body = { + "totalAmount": amount, + "initialAmount": amount, + "itemName": name[:255], + "backUrl": getenv("BACK_URL"), + "errorUrl": getenv("ERROR_URL"), + "returnUrl": getenv("RETURN_URL"), + "containsDonation": False, + "payer": { + "firstName": user.first_name, + "lastName": user.last_name, + "email": user.email, + }, + "metadata" :{ + "id": transaction.id, + }, + } + token=request. + + # need to put BACK_URL, ERROR_URL and RETURN_URL in .env + + + + + + From c94c670978602f5b23b82f9a0e7fe619cf9009d4 Mon Sep 17 00:00:00 2001 From: Khagana Date: Tue, 12 Sep 2023 14:27:36 +0200 Subject: [PATCH 04/62] implement token class, managing access token to helloasso --- insalan/payment/tokens.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 insalan/payment/tokens.py diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py new file mode 100644 index 00000000..345fee66 --- /dev/null +++ b/insalan/payment/tokens.py @@ -0,0 +1,34 @@ +import json +import requests +from os import getenv + +class tokens : + def __init__(self, bearer, refresh): + self.bearer_token=bearer + self.refresh_token=refresh + def get_token(self): + return self.bearer + def refresh(self): + request = requests.post( + url="https://api.helloasso-sandbox.com/oauth2/token", + headers={'Content-Type': "application/x-www-form-urlencoded"}, + data={ + 'client_id': getenv("CLIENT_ID"), + 'client_secret': self.refresh_token, + 'grant_type': "refresh_token", + }, + ) + self.bearer_token=json.loads(request.text)["access_token"] + self.refresh_token=json.loads(request.text)["refresh_token"] + def init(self): + request = requests.post( + url="https://api.helloasso-sandbox.com/oauth2/token", + headers={'Content-Type': "application/x-www-form-urlencoded"}, + data={ + 'client_id': getenv("CLIENT_ID"), + 'client_secret': self.refresh_token, + 'grant_type': "client_credentials", + }, + ) + self.bearer_token=json.loads(request.text)["access_token"] + self.bearer_token = json.loads(request.text)["refresh_token"] \ No newline at end of file From 9d46cd6fc2a8b3684968d54bfcc3ca9d07522561 Mon Sep 17 00:00:00 2001 From: Khagana Date: Tue, 12 Sep 2023 16:39:33 +0200 Subject: [PATCH 05/62] correcting some bugs on the pay method --- insalan/payment/tokens.py | 29 ++++++++++++++--------------- insalan/payment/views.py | 32 +++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index 345fee66..aad04f5c 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -2,33 +2,32 @@ import requests from os import getenv + class tokens : - def __init__(self, bearer, refresh): - self.bearer_token=bearer - self.refresh_token=refresh - def get_token(self): - return self.bearer - def refresh(self): + def __init__(self): request = requests.post( url="https://api.helloasso-sandbox.com/oauth2/token", headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ - 'client_id': getenv("CLIENT_ID"), - 'client_secret': self.refresh_token, - 'grant_type': "refresh_token", + 'client_id': "44c0e9bd5b214b5296cacac2e748b927", + 'client_secret': "GXZxQnob508Cnj+szBpqoXONtJYzknIU", + 'grant_type': "client_credentials", }, ) - self.bearer_token=json.loads(request.text)["access_token"] - self.refresh_token=json.loads(request.text)["refresh_token"] - def init(self): + self.bearer_token = json.loads(request.text)["access_token"] + self.refresh_token = json.loads(request.text)["refresh_token"] + def get_token(self): + return self.bearer_token + + def refresh(self): request = requests.post( url="https://api.helloasso-sandbox.com/oauth2/token", headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ - 'client_id': getenv("CLIENT_ID"), + 'client_id': "44c0e9bd5b214b5296cacac2e748b927", 'client_secret': self.refresh_token, - 'grant_type': "client_credentials", + 'grant_type': "refresh_token", }, ) self.bearer_token=json.loads(request.text)["access_token"] - self.bearer_token = json.loads(request.text)["refresh_token"] \ No newline at end of file + self.refresh_token=json.loads(request.text)["refresh_token"] \ No newline at end of file diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 852fe715..584157ab 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -4,6 +4,7 @@ import requests from .models import Transaction from datetime import date +from .tokens import tokens from django.shortcuts import render @@ -16,7 +17,7 @@ def pay(request): product_list=[] amount=0 name="" - user=request.user #not that but this is the idea + user=request.user for asked_product in user_request_body: try: product = Product.objects.get(pk=asked_product[id]) @@ -34,14 +35,14 @@ def pay(request): # need to put a list field of product in Transaction model # lets init a checkout to helloasso - url = f"https://api.helloasso.com/v5/organizations/{getenv('HELLOASSO_NAME')}/checkout-intents" + url = f"https://{getenv('HELLOASSO_URL')}/organizations/{getenv('ASSOCIATION_NAME')}/checkout-intents" body = { "totalAmount": amount, "initialAmount": amount, "itemName": name[:255], - "backUrl": getenv("BACK_URL"), - "errorUrl": getenv("ERROR_URL"), - "returnUrl": getenv("RETURN_URL"), + "backUrl": back_url, + "errorUrl": error_url, + "returnUrl": return_url, "containsDonation": False, "payer": { "firstName": user.first_name, @@ -52,9 +53,26 @@ def pay(request): "id": transaction.id, }, } - token=request. + headers = { + 'authorization': 'Bearer ' + tokens.get_token(), + 'Content-Type': 'application/json', + } + request_status=False + while request_status!=True: + checkout_init=requests.post(url = url, headers=headers, data=json.dumps(body)) + if checkout_init.status_code==200: + request_status=True + elif checkout_init.status_code==401: + tokens.refresh() + elif checkout_init.status_code==403: + pass # cry, problem concerning the perms of the token + elif checkout_init.status_code==400: + pass # the value are false + else: + pass + # redirect to json.loads(checkout_init.text)['id'] + - # need to put BACK_URL, ERROR_URL and RETURN_URL in .env From 5df59dc6ef8f40017c77d8addba8443428be07f6 Mon Sep 17 00:00:00 2001 From: Khagana Date: Thu, 28 Sep 2023 17:40:14 +0200 Subject: [PATCH 06/62] added product model, work on views --- insalan/payment/models.py | 3 +++ insalan/payment/tokens.py | 3 +++ insalan/payment/views.py | 22 +++++++++++++--------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index aaf81f11..6d6e2624 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -25,3 +25,6 @@ class Transaction(models.Model): ) date = models.DateField() +class Product(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + amount = models.DecimalField(null=False) diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index aad04f5c..3afaecc8 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -4,7 +4,10 @@ class tokens : + instance=None def __init__(self): + if tokens.instance is None: + tokens.instance = self request = requests.post( url="https://api.helloasso-sandbox.com/oauth2/token", headers={'Content-Type': "application/x-www-form-urlencoded"}, diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 584157ab..d5c996ea 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -2,7 +2,10 @@ from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from os import getenv import requests -from .models import Transaction +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from .models import Transaction, TransactionStatus, Product +from .serializers import TransactionSerializer from datetime import date from .tokens import tokens @@ -40,18 +43,15 @@ def pay(request): "totalAmount": amount, "initialAmount": amount, "itemName": name[:255], - "backUrl": back_url, - "errorUrl": error_url, - "returnUrl": return_url, + "backUrl": "back_url"+"/"+transaction.id, + "errorUrl": "error_url", + "returnUrl": "return_url", "containsDonation": False, "payer": { "firstName": user.first_name, "lastName": user.last_name, "email": user.email, }, - "metadata" :{ - "id": transaction.id, - }, } headers = { 'authorization': 'Bearer ' + tokens.get_token(), @@ -71,9 +71,13 @@ def pay(request): else: pass # redirect to json.loads(checkout_init.text)['id'] +@csrf_exempt +def validate_payment(request, id): + Transaction.objects.get(id=id).payment_status=TransactionStatus.SUCCEDED - - +def get_transactions(request): + transactions=TransactionSerializer(Transaction.objects.all(), many=True) + return JsonResponse(transactions.data) From f39c06dc38fe7d97cd01f7321a51518220e4a002 Mon Sep 17 00:00:00 2001 From: Khagana Date: Thu, 28 Sep 2023 18:11:56 +0200 Subject: [PATCH 07/62] fixed tokens and init url --- insalan/payment/tokens.py | 6 +++--- insalan/payment/views.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index 3afaecc8..1203a2ff 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -12,8 +12,8 @@ def __init__(self): url="https://api.helloasso-sandbox.com/oauth2/token", headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ - 'client_id': "44c0e9bd5b214b5296cacac2e748b927", - 'client_secret': "GXZxQnob508Cnj+szBpqoXONtJYzknIU", + 'client_id': getenv("CLIENT_ID"), + 'client_secret': getenv("CLIENT_SECRET"), 'grant_type': "client_credentials", }, ) @@ -27,7 +27,7 @@ def refresh(self): url="https://api.helloasso-sandbox.com/oauth2/token", headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ - 'client_id': "44c0e9bd5b214b5296cacac2e748b927", + 'client_id': getenv("CLIENT_ID"), 'client_secret': self.refresh_token, 'grant_type': "refresh_token", }, diff --git a/insalan/payment/views.py b/insalan/payment/views.py index d5c996ea..aca9f469 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -2,7 +2,7 @@ from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from os import getenv import requests -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponseRedirect from django.views.decorators.csrf import csrf_exempt from .models import Transaction, TransactionStatus, Product from .serializers import TransactionSerializer @@ -38,7 +38,7 @@ def pay(request): # need to put a list field of product in Transaction model # lets init a checkout to helloasso - url = f"https://{getenv('HELLOASSO_URL')}/organizations/{getenv('ASSOCIATION_NAME')}/checkout-intents" + url = "https://api.helloasso-sandbox.com/organizations/insalan-test/checkout-intents" body = { "totalAmount": amount, "initialAmount": amount, @@ -70,7 +70,7 @@ def pay(request): pass # the value are false else: pass - # redirect to json.loads(checkout_init.text)['id'] + return HttpResponseRedirect(redirect_to=json.loads(checkout_init.text)['id']) @csrf_exempt def validate_payment(request, id): Transaction.objects.get(id=id).payment_status=TransactionStatus.SUCCEDED From 57859cb69c0bf3170f896ba53b1667c04f52fa6a Mon Sep 17 00:00:00 2001 From: Khagana Date: Thu, 28 Sep 2023 18:25:27 +0200 Subject: [PATCH 08/62] added static urls object --- insalan/payment/static_urls.py | 26 ++++++++++++++++++++++++++ insalan/payment/tokens.py | 6 +++--- insalan/payment/views.py | 9 +++++---- 3 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 insalan/payment/static_urls.py diff --git a/insalan/payment/static_urls.py b/insalan/payment/static_urls.py new file mode 100644 index 00000000..e909991a --- /dev/null +++ b/insalan/payment/static_urls.py @@ -0,0 +1,26 @@ +class static_urls: + instance = None + + def __init__(self): + if static_urls.instance is None: + static_urls.instance = self + self.tokens_url="https://api.helloasso-sandbox.com/oauth2/token" + self.checkout_url="https://api.helloasso-sandbox.com/organizations/insalan-test/checkout-intents" + self.back_url="" + self.error_url="" + self.return_url="" + + def get_tokens_url(self): + return self.tokens_url + + def get_checkout_url(self): + return self.checkout_url + + def get_back_url(self): + return self.back_url + + def get_error_url(self): + return self.error_url + + def get_return_url(self): + return self.return_url \ No newline at end of file diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index 1203a2ff..ed2d6802 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -1,7 +1,7 @@ import json import requests from os import getenv - +from .static_urls import static_urls class tokens : instance=None @@ -9,7 +9,7 @@ def __init__(self): if tokens.instance is None: tokens.instance = self request = requests.post( - url="https://api.helloasso-sandbox.com/oauth2/token", + url=static_urls.get_tokens_url(), headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ 'client_id': getenv("CLIENT_ID"), @@ -24,7 +24,7 @@ def get_token(self): def refresh(self): request = requests.post( - url="https://api.helloasso-sandbox.com/oauth2/token", + url=static_urls.get_tokens_url(), headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ 'client_id': getenv("CLIENT_ID"), diff --git a/insalan/payment/views.py b/insalan/payment/views.py index aca9f469..dfaeb1ac 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -8,6 +8,7 @@ from .serializers import TransactionSerializer from datetime import date from .tokens import tokens +from .static_urls import static_urls from django.shortcuts import render @@ -38,14 +39,14 @@ def pay(request): # need to put a list field of product in Transaction model # lets init a checkout to helloasso - url = "https://api.helloasso-sandbox.com/organizations/insalan-test/checkout-intents" + url = static_urls.get_checkout_url() body = { "totalAmount": amount, "initialAmount": amount, "itemName": name[:255], - "backUrl": "back_url"+"/"+transaction.id, - "errorUrl": "error_url", - "returnUrl": "return_url", + "backUrl": static_urls.get_back_url()+"/"+transaction.id, + "errorUrl": static_urls.get_error_url(), + "returnUrl": static_urls.get_return_url(), "containsDonation": False, "payer": { "firstName": user.first_name, From 0d5ec99b8b0e04e5f0e383c47cf6fd249ec00018 Mon Sep 17 00:00:00 2001 From: Mahal Date: Mon, 9 Oct 2023 10:30:59 +0200 Subject: [PATCH 09/62] adding more routes, add requirements requests, adding Models (not working yet) --- insalan/payment/apps.py | 2 +- insalan/payment/models.py | 13 ++++++---- insalan/payment/serializers.py | 10 ++++++-- insalan/payment/static_urls.py | 26 -------------------- insalan/payment/tokens.py | 5 ++-- insalan/payment/urls.py | 6 +++++ insalan/payment/views.py | 45 +++++++++++++++++++++++++++------- insalan/settings.py | 3 ++- insalan/urls.py | 5 ++-- requirements.txt | 1 + 10 files changed, 67 insertions(+), 49 deletions(-) delete mode 100644 insalan/payment/static_urls.py diff --git a/insalan/payment/apps.py b/insalan/payment/apps.py index b4a45c3a..544fe6a8 100644 --- a/insalan/payment/apps.py +++ b/insalan/payment/apps.py @@ -3,4 +3,4 @@ class PaymentConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'payment' + name = 'insalan.payment' diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 6d6e2624..70972551 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -10,21 +10,24 @@ class TransactionStatus(models.TextChoices): SUCCEDED = "SUCCEEDED", _("Success") +class Product(models.Model): + """ Object to represent in database anything sellable""" + price = models.DecimalField(null=False, max_digits=5, decimal_places=2) + name = models.CharField(max_length=50) + desc = models.CharField(max_length=50, verbose_name=_("description")) + class Transaction(models.Model): """A transaction""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) payer = models.ForeignKey(User, on_delete=models.CASCADE) - amount = models.DecimalField(null=False) + products = models.ManyToManyField(Product) # A transaction can be composed of n products payment_status = models.CharField( max_length=10, blank=True, default=TransactionStatus.FAILED, choices=TransactionStatus.choices, null=False, - verbose_name="Transaction status", + verbose_name=_("Transaction status"), ) date = models.DateField() -class Product(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - amount = models.DecimalField(null=False) diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py index 6727784a..ab700944 100644 --- a/insalan/payment/serializers.py +++ b/insalan/payment/serializers.py @@ -1,7 +1,13 @@ from rest_framework import serializers -from .models import Transaction, TransactionStatus +from .models import Transaction, TransactionStatus, Product class TransactionSerializer(serializers.ModelSerializer): + products = serializers.ListField(required=True) class Meta: model=Transaction - fields = ['payer', 'amount', 'payment_status', 'date'] \ No newline at end of file + fields = ['payer', 'amount', 'payment_status', 'date'] + +class ProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = "__all__" diff --git a/insalan/payment/static_urls.py b/insalan/payment/static_urls.py deleted file mode 100644 index e909991a..00000000 --- a/insalan/payment/static_urls.py +++ /dev/null @@ -1,26 +0,0 @@ -class static_urls: - instance = None - - def __init__(self): - if static_urls.instance is None: - static_urls.instance = self - self.tokens_url="https://api.helloasso-sandbox.com/oauth2/token" - self.checkout_url="https://api.helloasso-sandbox.com/organizations/insalan-test/checkout-intents" - self.back_url="" - self.error_url="" - self.return_url="" - - def get_tokens_url(self): - return self.tokens_url - - def get_checkout_url(self): - return self.checkout_url - - def get_back_url(self): - return self.back_url - - def get_error_url(self): - return self.error_url - - def get_return_url(self): - return self.return_url \ No newline at end of file diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index ed2d6802..b636bb8a 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -1,7 +1,6 @@ import json import requests from os import getenv -from .static_urls import static_urls class tokens : instance=None @@ -9,7 +8,7 @@ def __init__(self): if tokens.instance is None: tokens.instance = self request = requests.post( - url=static_urls.get_tokens_url(), + url="https://google.fr", headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ 'client_id': getenv("CLIENT_ID"), @@ -33,4 +32,4 @@ def refresh(self): }, ) self.bearer_token=json.loads(request.text)["access_token"] - self.refresh_token=json.loads(request.text)["refresh_token"] \ No newline at end of file + self.refresh_token=json.loads(request.text)["refresh_token"] diff --git a/insalan/payment/urls.py b/insalan/payment/urls.py index f46ae54f..6ba4229d 100644 --- a/insalan/payment/urls.py +++ b/insalan/payment/urls.py @@ -5,4 +5,10 @@ app_name = "payment" urlpatterns = [ + path("pay/", views.PayView.as_view(), name="pay"), + path("product/", views.ProductList.as_view(), name="list-product"), + path("product//", views.ProductDetails.as_view(), name="product-details"), + path("new/", views.CreateProduct.as_view(), name="new-product"), + path("transaction/", views.TransactionList.as_view(), name="transactions"), + path("transaction/", views.TransactionPerId.as_view(), name="transactions/id"), ] diff --git a/insalan/payment/views.py b/insalan/payment/views.py index dfaeb1ac..93cb1fad 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -8,19 +8,46 @@ from .serializers import TransactionSerializer from datetime import date from .tokens import tokens -from .static_urls import static_urls +from rest_framework import generics, permissions from django.shortcuts import render +import insalan.payment.serializers as serializers +from .models import Product, Transaction # Create your views here. +class ProductList(generics.ListAPIView): + pagination = None + serializer_class = serializers.ProductSerializer + queryset = Product.objects.all() + permission_classes = [permissions.IsAdminUser] -def pay(request): +class ProductDetails(generics.RetrieveUpdateDestroyAPIView): + pagination = None + serializer_class= serializers.ProductSerializer + queryset = Product.objects.all() + permission_classes = [permissions.IsAdminUser] + +class TransactionList(generics.ListAPIView): + pagination = None + serializer_class =serializers.TransactionSerializer + queryset = Transaction.objects.all() + permission_classes = [permissions.IsAdminUser] + +class TransactionPerId(generics.RetrieveAPIView): + pagination = None + serializer_class = serializers.TransactionSerializer + queryset = Transaction.objects.all().order_by('date') + permission_classes = [permissions.IsAdminUser] + + +class CreateProduct(generics.CreateAPIView): + pass + +class PayView(generics.CreateAPIView): + pass + """ # lets parse the request - user_request_body = json.loads(request.body) - product_list=[] - amount=0 - name="" user=request.user for asked_product in user_request_body: try: @@ -73,14 +100,14 @@ def pay(request): pass return HttpResponseRedirect(redirect_to=json.loads(checkout_init.text)['id']) @csrf_exempt -def validate_payment(request, id): +class validate_payment(request, id): Transaction.objects.get(id=id).payment_status=TransactionStatus.SUCCEDED -def get_transactions(request): +class get_transactions(request): transactions=TransactionSerializer(Transaction.objects.all(), many=True) return JsonResponse(transactions.data) - +""" diff --git a/insalan/settings.py b/insalan/settings.py index f1b0761a..36dbc1d1 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -68,7 +68,8 @@ "insalan.partner", "insalan.tournament", "insalan.tickets", - "insalan.cms" + "insalan.cms", + "insalan.payment", ] MIDDLEWARE = [ diff --git a/insalan/urls.py b/insalan/urls.py index 96d7dbc5..10cd5518 100644 --- a/insalan/urls.py +++ b/insalan/urls.py @@ -31,9 +31,10 @@ path("v1/tickets/", include("insalan.tickets.urls")), path("v1/langate/authenticate", langate_views.LangateUserView.as_view()), path("v1/content/", include("insalan.cms.urls")), - path("v1/admin/", admin.site.urls) - + path("v1/admin/", admin.site.urls), + path("v1/payment/", include("insalan.payment.urls")), ] + if not int(getenv("DEV", "1")): urlpatterns.insert(1, path("v1/admin/login/", RedirectView.as_view(url=f"{getenv('HTTP_PROTOCOL')}://{getenv('WEBSITE_HOST')}/register"))) diff --git a/requirements.txt b/requirements.txt index a5132e19..43b97dd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ tzdata==2023.3 qrcode==7.4.2 pymongo==3.12.3 djongo==1.3.6 +requests==2.31.0 From e5290639fde5a482e700afa4ae0a6b38e1817bdd Mon Sep 17 00:00:00 2001 From: Mahal Date: Mon, 9 Oct 2023 23:42:01 +0200 Subject: [PATCH 10/62] struggling against the serializer (not working yet) --- insalan/payment/models.py | 4 ++-- insalan/payment/serializers.py | 6 ++++-- insalan/payment/urls.py | 2 +- insalan/payment/views.py | 21 ++++++++++++++++----- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 70972551..d38764ad 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -28,6 +28,6 @@ class Transaction(models.Model): choices=TransactionStatus.choices, null=False, verbose_name=_("Transaction status"), - ) + ) date = models.DateField() - + amount = models.DecimalField(null=False, max_digits=5, decimal_places=2) diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py index ab700944..eb6e4e86 100644 --- a/insalan/payment/serializers.py +++ b/insalan/payment/serializers.py @@ -2,12 +2,14 @@ from .models import Transaction, TransactionStatus, Product class TransactionSerializer(serializers.ModelSerializer): - products = serializers.ListField(required=True) class Meta: model=Transaction - fields = ['payer', 'amount', 'payment_status', 'date'] + fields = ['payer', 'amount', 'payment_status', 'date', 'products'] class ProductSerializer(serializers.ModelSerializer): class Meta: model = Product fields = "__all__" + + + diff --git a/insalan/payment/urls.py b/insalan/payment/urls.py index 6ba4229d..cc776e2f 100644 --- a/insalan/payment/urls.py +++ b/insalan/payment/urls.py @@ -8,7 +8,7 @@ path("pay/", views.PayView.as_view(), name="pay"), path("product/", views.ProductList.as_view(), name="list-product"), path("product//", views.ProductDetails.as_view(), name="product-details"), - path("new/", views.CreateProduct.as_view(), name="new-product"), + path("product/new/", views.CreateProduct.as_view(), name="new-product"), path("transaction/", views.TransactionList.as_view(), name="transactions"), path("transaction/", views.TransactionPerId.as_view(), name="transactions/id"), ] diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 93cb1fad..e16cf55e 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -8,8 +8,10 @@ from .serializers import TransactionSerializer from datetime import date from .tokens import tokens -from rest_framework import generics, permissions - +from rest_framework import generics, permissions, status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.authentication import SessionAuthentication from django.shortcuts import render import insalan.payment.serializers as serializers from .models import Product, Transaction @@ -40,12 +42,21 @@ class TransactionPerId(generics.RetrieveAPIView): queryset = Transaction.objects.all().order_by('date') permission_classes = [permissions.IsAdminUser] - class CreateProduct(generics.CreateAPIView): - pass + serializer_class = serializers.ProductSerializer + queryset = Product.objects.all() + permission_classes = [permissions.IsAdminUser] class PayView(generics.CreateAPIView): - pass + permission_classes = [permissions.IsAuthenticated] + authentication_classes = [SessionAuthentication] + serializer_class = serializers.TransactionSerializer + + def create(self, request): + product_list = serializers.ProductSerializer(request.data['products'], many=True) + transaction = Transaction(payer=request.user) + transaction.products.set(product_list) + return Response(TransactionSerializer(transaction), status=status.HTTP_200_OK) """ # lets parse the request user=request.user From a76b5cfab59fbf20b00ba482f368ae9b050e5971 Mon Sep 17 00:00:00 2001 From: Mahal Date: Wed, 11 Oct 2023 17:42:33 +0200 Subject: [PATCH 11/62] trying to debug pay View --- insalan/payment/models.py | 15 ++++++++++----- insalan/payment/serializers.py | 18 ++++++++++++++++++ insalan/payment/urls.py | 2 +- insalan/payment/views.py | 6 ++---- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index d38764ad..d0476417 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -6,8 +6,9 @@ class TransactionStatus(models.TextChoices): """Information about the current transaction status""" - FAILED = "FAILED", _("Fail") - SUCCEDED = "SUCCEEDED", _("Success") + FAILED = "FAILED", _("échouée") + SUCCEDED = "SUCCEEDED", _("Réussie") + PENDING = "PENDING", _("En attente") class Product(models.Model): @@ -16,6 +17,7 @@ class Product(models.Model): name = models.CharField(max_length=50) desc = models.CharField(max_length=50, verbose_name=_("description")) + class Transaction(models.Model): """A transaction""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -24,10 +26,13 @@ class Transaction(models.Model): payment_status = models.CharField( max_length=10, blank=True, - default=TransactionStatus.FAILED, + default=TransactionStatus.PENDING, choices=TransactionStatus.choices, null=False, verbose_name=_("Transaction status"), ) - date = models.DateField() - amount = models.DecimalField(null=False, max_digits=5, decimal_places=2) + date = models.DateField() + amount = models.DecimalField(null=False, default=0.00, max_digits=5, decimal_places=2) + + def get_products_id(self): + return self.products.id diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py index eb6e4e86..a207ebb0 100644 --- a/insalan/payment/serializers.py +++ b/insalan/payment/serializers.py @@ -1,10 +1,28 @@ from rest_framework import serializers from .models import Transaction, TransactionStatus, Product +import logging class TransactionSerializer(serializers.ModelSerializer): + products = serializers.ListField(required=True, source="get_products_id") + class Meta: model=Transaction fields = ['payer', 'amount', 'payment_status', 'date', 'products'] + read_only_fields = ['amount'] + + def create(self, validated_data): + """ Create a transaction with products based on the request""" + amount = 0 + transaction_obj = Transaction.objects.create(**validated_data) + products = validated_data.pop("get_products_id", []) + + print(prodcuts) + for product in products: + prod_obj = Product.objects.get(id=product) + amount += prod_obj.price + + transaction_obj.amount = amount + return transaction_obj class ProductSerializer(serializers.ModelSerializer): class Meta: diff --git a/insalan/payment/urls.py b/insalan/payment/urls.py index cc776e2f..42ace6b8 100644 --- a/insalan/payment/urls.py +++ b/insalan/payment/urls.py @@ -11,4 +11,4 @@ path("product/new/", views.CreateProduct.as_view(), name="new-product"), path("transaction/", views.TransactionList.as_view(), name="transactions"), path("transaction/", views.TransactionPerId.as_view(), name="transactions/id"), -] + ] diff --git a/insalan/payment/views.py b/insalan/payment/views.py index e16cf55e..13a6ce8d 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -16,8 +16,6 @@ import insalan.payment.serializers as serializers from .models import Product, Transaction -# Create your views here. - class ProductList(generics.ListAPIView): pagination = None serializer_class = serializers.ProductSerializer @@ -55,8 +53,8 @@ class PayView(generics.CreateAPIView): def create(self, request): product_list = serializers.ProductSerializer(request.data['products'], many=True) transaction = Transaction(payer=request.user) - transaction.products.set(product_list) - return Response(TransactionSerializer(transaction), status=status.HTTP_200_OK) + #transaction.products.set(product_list.data) + return Response(product_list.data, status=status.HTTP_200_OK) """ # lets parse the request user=request.user From 5f1623b359e19d8cdbc501d7e109f88365a8d025 Mon Sep 17 00:00:00 2001 From: Mahal Date: Thu, 12 Oct 2023 09:22:02 +0200 Subject: [PATCH 12/62] this commit adds: - logging debug capability - payment /pay route - handles helloasso intent UNSTABLE, far from working as indented --- insalan/payment/models.py | 47 ++++++++++++++++++--- insalan/payment/serializers.py | 22 ++++------ insalan/payment/tokens.py | 11 +++-- insalan/payment/urls.py | 3 ++ insalan/payment/views.py | 75 ++++++++++++++++++++++++---------- insalan/settings.py | 18 +++++++- 6 files changed, 131 insertions(+), 45 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index d0476417..a0d31983 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -1,8 +1,12 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from insalan.user.models import User +from datetime import datetime import uuid - +from django.utils import timezone +import logging +from decimal import Decimal +logger = logging.getLogger(__name__) class TransactionStatus(models.TextChoices): """Information about the current transaction status""" @@ -21,7 +25,7 @@ class Product(models.Model): class Transaction(models.Model): """A transaction""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - payer = models.ForeignKey(User, on_delete=models.CASCADE) + payer = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) products = models.ManyToManyField(Product) # A transaction can be composed of n products payment_status = models.CharField( max_length=10, @@ -31,8 +35,41 @@ class Transaction(models.Model): null=False, verbose_name=_("Transaction status"), ) - date = models.DateField() - amount = models.DecimalField(null=False, default=0.00, max_digits=5, decimal_places=2) + creation_date = models.DateTimeField() + last_modification_date = models.DateTimeField() + amount = models.DecimalField(null=False, default=0.00, max_digits=5, decimal_places=2) + + @staticmethod + def new(**data): + """ create a new transaction based on products id list and a payer """ + logger.debug(f"in the constructor {data}") + fields = {} + fields['creation_date'] = timezone.make_aware(datetime.now()) + fields['last_modification_date'] = fields['creation_date'] + fields['payer'] = data['payer'] + products = data['products'] + fields['amount'] = Decimal(0.00) + for product in data['products']: + fields['amount'] += product.price + transaction = Transaction.objects.create(**fields) + transaction.products.set(data['products']) + return transaction + + def validate_transaction(self): + """ set payment_statut to validated """ + + self.payment_status = TransactionStatus.SUCCEDED + self.last_modification_date = timezone.make_aware(datetime.now()) + self.save() + + def fail_transaction(self): + """ set payment_statut to failed and update last_modification_date """ + self.payment_status = TransactionStatus.FAILED + self.last_modification_date = timezone.make_aware(datetime.now()) + self.save() + + def get_products(self): + return self.products def get_products_id(self): - return self.products.id + return [product.id for product in self.products] diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py index a207ebb0..8eb8b447 100644 --- a/insalan/payment/serializers.py +++ b/insalan/payment/serializers.py @@ -1,27 +1,21 @@ from rest_framework import serializers from .models import Transaction, TransactionStatus, Product +from insalan.user.models import User import logging -class TransactionSerializer(serializers.ModelSerializer): - products = serializers.ListField(required=True, source="get_products_id") +logger = logging.getLogger(__name__) +class TransactionSerializer(serializers.ModelSerializer): + payer = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) class Meta: model=Transaction - fields = ['payer', 'amount', 'payment_status', 'date', 'products'] - read_only_fields = ['amount'] + fields = "__all__" + read_only_fields = ['amount', 'payer', 'payment_status', 'creation_date', 'last_modification_date'] def create(self, validated_data): """ Create a transaction with products based on the request""" - amount = 0 - transaction_obj = Transaction.objects.create(**validated_data) - products = validated_data.pop("get_products_id", []) - - print(prodcuts) - for product in products: - prod_obj = Product.objects.get(id=product) - amount += prod_obj.price - - transaction_obj.amount = amount + logger.debug(f"in the serializer {validated_data}") + transaction_obj = Transaction.new(**validated_data) return transaction_obj class ProductSerializer(serializers.ModelSerializer): diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index b636bb8a..76f74041 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -1,21 +1,24 @@ import json import requests from os import getenv - +import logging +logger = logging.getLogger(__name__) class tokens : instance=None def __init__(self): if tokens.instance is None: tokens.instance = self + logger.debug(getenv("HELLOASSO_ENDPOINT")) request = requests.post( - url="https://google.fr", + url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ - 'client_id': getenv("CLIENT_ID"), - 'client_secret': getenv("CLIENT_SECRET"), + 'client_id': getenv("HELLOASSO_CLIENTID"), + 'client_secret': getenv("HELLOASSO_CLIENT_SECRET"), 'grant_type': "client_credentials", }, ) + logger.debug(request.text) self.bearer_token = json.loads(request.text)["access_token"] self.refresh_token = json.loads(request.text)["refresh_token"] def get_token(self): diff --git a/insalan/payment/urls.py b/insalan/payment/urls.py index 42ace6b8..98c52112 100644 --- a/insalan/payment/urls.py +++ b/insalan/payment/urls.py @@ -11,4 +11,7 @@ path("product/new/", views.CreateProduct.as_view(), name="new-product"), path("transaction/", views.TransactionList.as_view(), name="transactions"), path("transaction/", views.TransactionPerId.as_view(), name="transactions/id"), + path("back/", views.BackView.as_view(), name="transaction/back"), + path("return/", views.ReturnView.as_view(), name="transaction/return"), + path("error/", views.ErrorView.as_view(), name="transaction/error"), ] diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 13a6ce8d..8617f65b 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -14,8 +14,11 @@ from rest_framework.authentication import SessionAuthentication from django.shortcuts import render import insalan.payment.serializers as serializers +from django.http import HttpResponseRedirect from .models import Product, Transaction +import logging +logger = logging.getLogger(__name__) class ProductList(generics.ListAPIView): pagination = None serializer_class = serializers.ProductSerializer @@ -37,24 +40,68 @@ class TransactionList(generics.ListAPIView): class TransactionPerId(generics.RetrieveAPIView): pagination = None serializer_class = serializers.TransactionSerializer - queryset = Transaction.objects.all().order_by('date') + queryset = Transaction.objects.all().order_by('last_modification_date') permission_classes = [permissions.IsAdminUser] class CreateProduct(generics.CreateAPIView): serializer_class = serializers.ProductSerializer queryset = Product.objects.all() permission_classes = [permissions.IsAdminUser] - + +class BackView(generics.ListAPIView): + pass +class ReturnView(generics.ListAPIView): + pass +class ErrorView(generics.ListAPIView): + pass class PayView(generics.CreateAPIView): permission_classes = [permissions.IsAuthenticated] authentication_classes = [SessionAuthentication] - serializer_class = serializers.TransactionSerializer + queryset = Transaction.objects.all() + serializer_class = serializers.TransactionSerializer def create(self, request): - product_list = serializers.ProductSerializer(request.data['products'], many=True) - transaction = Transaction(payer=request.user) - #transaction.products.set(product_list.data) - return Response(product_list.data, status=status.HTTP_200_OK) + token = tokens() + payer = request.user + data = request.data.copy() + data['payer'] = payer.id + logger.debug(f"data in view = {data}") # contient des données + + transaction = serializers.TransactionSerializer(data=data) + transaction.is_valid() + logger.debug(transaction.validated_data) + if transaction.is_valid(raise_exception=True): + transaction_obj = transaction.save() + # helloasso intent + HELLOASSO_URL = getenv('HELLOASSO_ENDPOINT') + intent_body = { + "totalAmount": int(transaction_obj.amount*10), + "initialAmount": int(transaction_obj.amount*10), + "itemName": str(transaction_obj.id), + "backUrl": f"{getenv('HELLOASSO_BACK_URL')}?id={transaction_obj.id}", + "errorUrl": f"{getenv('HELLOASSO_ERROR_URL')}?id={transaction_obj.id}", + "returnUrl": f"{getenv('HELLOASSO_RETURN_URL')}?id={transaction_obj.id}", + "containsDonation": False, + "payer": { + "firstName": payer.first_name, + "lastName": payer.last_name, + "email": payer.email, + }, + } + headers = { + 'authorization': 'Bearer ' + token.get_token(), + 'Content-Type': 'application/json', + } + + checkout_init = requests.post(f"{HELLOASSO_URL}/v5/organizations/insalan-test/checkout-intents", data=json.dumps(intent_body), headers=headers)# initiate a helloasso intent + logger.debug(checkout_init.text) + redirect_url = checkout_init.json()['redirectUrl'] + logger.debug(intent_body) + return HttpResponseRedirect(redirect_to=redirect_url) + return JsonResponse({'problem': 'oui'}) + #return HttpResponseRedirect(checkout_init.redirectUrl) + + """ # lets parse the request user=request.user @@ -76,20 +123,6 @@ def create(self, request): # lets init a checkout to helloasso url = static_urls.get_checkout_url() - body = { - "totalAmount": amount, - "initialAmount": amount, - "itemName": name[:255], - "backUrl": static_urls.get_back_url()+"/"+transaction.id, - "errorUrl": static_urls.get_error_url(), - "returnUrl": static_urls.get_return_url(), - "containsDonation": False, - "payer": { - "firstName": user.first_name, - "lastName": user.last_name, - "email": user.email, - }, - } headers = { 'authorization': 'Bearer ' + tokens.get_token(), 'Content-Type': 'application/json', diff --git a/insalan/settings.py b/insalan/settings.py index 36dbc1d1..ac409fd2 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -41,6 +41,21 @@ PROTOCOL = getenv("HTTP_PROTOCOL", "http") +# LOGGING Setup +LOGGING = { + "version":1, + "disable_existing_loggers":False, + "handlers":{ + "console":{ + "class":"logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level":"DEBUG" + }, +} + # Allow itself and the frontend ALLOWED_HOSTS = [ "api." + getenv("WEBSITE_HOST", "localhost"), @@ -199,7 +214,8 @@ "https://" + getenv("WEBSITE_HOST", "localhost"), "https://api." + getenv("WEBSITE_HOST", "localhost"), "http://" + getenv("WEBSITE_HOST", "localhost"), - "http://api." + getenv("WEBSITE_HOST", "localhost") + "http://api." + getenv("WEBSITE_HOST", "localhost"), + "https://www.helloasso-sandbox.com" # helloasso has be to trusted ] CSRF_COOKIE_DOMAIN = '.' + getenv("WEBSITE_HOST", "localhost") From 369bdd6b512b9feab7f6a659adc8f75bc0a0045c Mon Sep 17 00:00:00 2001 From: Mahal Date: Thu, 12 Oct 2023 14:20:01 +0200 Subject: [PATCH 13/62] adding verbose_name --- insalan/payment/apps.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/insalan/payment/apps.py b/insalan/payment/apps.py index 544fe6a8..eb5f9e68 100644 --- a/insalan/payment/apps.py +++ b/insalan/payment/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ class PaymentConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'insalan.payment' + name ='insalan.payment' + verbose_name = _('Paiement') From 08b58698855ab282e9b567271d296a75a9a270ee Mon Sep 17 00:00:00 2001 From: Mahal Date: Fri, 13 Oct 2023 13:39:06 +0200 Subject: [PATCH 14/62] Fixing typo in tokens.py, correct amount computation, add ordering in transaction and remove pagination --- insalan/payment/models.py | 1 + insalan/payment/serializers.py | 1 + insalan/payment/tokens.py | 8 ++++---- insalan/payment/views.py | 22 +++++++++++----------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index a0d31983..292ef5b7 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -51,6 +51,7 @@ def new(**data): fields['amount'] = Decimal(0.00) for product in data['products']: fields['amount'] += product.price + logger.debug(f"{fields['amount']} and {product.price}") transaction = Transaction.objects.create(**fields) transaction.products.set(data['products']) return transaction diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py index 8eb8b447..029790ee 100644 --- a/insalan/payment/serializers.py +++ b/insalan/payment/serializers.py @@ -7,6 +7,7 @@ class TransactionSerializer(serializers.ModelSerializer): payer = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) + products = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all(), many=True) class Meta: model=Transaction fields = "__all__" diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index 76f74041..c6d45f68 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -3,11 +3,11 @@ from os import getenv import logging logger = logging.getLogger(__name__) -class tokens : +class Tokens : instance=None def __init__(self): - if tokens.instance is None: - tokens.instance = self + if Tokens.instance is None: + Tokens.instance = self logger.debug(getenv("HELLOASSO_ENDPOINT")) request = requests.post( url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", @@ -26,7 +26,7 @@ def get_token(self): def refresh(self): request = requests.post( - url=static_urls.get_tokens_url(), + url=static_urls.get_Tokens_url(), headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ 'client_id': getenv("CLIENT_ID"), diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 8617f65b..fa0a8aea 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -7,7 +7,7 @@ from .models import Transaction, TransactionStatus, Product from .serializers import TransactionSerializer from datetime import date -from .tokens import tokens +from .tokens import Tokens from rest_framework import generics, permissions, status from rest_framework.views import APIView from rest_framework.response import Response @@ -20,27 +20,27 @@ logger = logging.getLogger(__name__) class ProductList(generics.ListAPIView): - pagination = None + paginator = None serializer_class = serializers.ProductSerializer queryset = Product.objects.all() permission_classes = [permissions.IsAdminUser] class ProductDetails(generics.RetrieveUpdateDestroyAPIView): - pagination = None + paginator = None serializer_class= serializers.ProductSerializer queryset = Product.objects.all() permission_classes = [permissions.IsAdminUser] class TransactionList(generics.ListAPIView): - pagination = None + paginator = None serializer_class =serializers.TransactionSerializer - queryset = Transaction.objects.all() + queryset = Transaction.objects.all().order_by('last_modification_date') permission_classes = [permissions.IsAdminUser] class TransactionPerId(generics.RetrieveAPIView): - pagination = None + paginator = None serializer_class = serializers.TransactionSerializer - queryset = Transaction.objects.all().order_by('last_modification_date') + queryset = Transaction.objects.all() permission_classes = [permissions.IsAdminUser] class CreateProduct(generics.CreateAPIView): @@ -61,22 +61,22 @@ class PayView(generics.CreateAPIView): serializer_class = serializers.TransactionSerializer def create(self, request): - token = tokens() + token = Tokens() payer = request.user data = request.data.copy() data['payer'] = payer.id logger.debug(f"data in view = {data}") # contient des données - transaction = serializers.TransactionSerializer(data=data) transaction.is_valid() logger.debug(transaction.validated_data) if transaction.is_valid(raise_exception=True): transaction_obj = transaction.save() # helloasso intent + helloasso_amount = int(transaction_obj.amount * 100) # helloasso reads prices in cents HELLOASSO_URL = getenv('HELLOASSO_ENDPOINT') intent_body = { - "totalAmount": int(transaction_obj.amount*10), - "initialAmount": int(transaction_obj.amount*10), + "totalAmount": helloasso_amount, + "initialAmount": helloasso_amount, "itemName": str(transaction_obj.id), "backUrl": f"{getenv('HELLOASSO_BACK_URL')}?id={transaction_obj.id}", "errorUrl": f"{getenv('HELLOASSO_ERROR_URL')}?id={transaction_obj.id}", From 6b1adce288abaad3a212982a9edef182e18893fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Fri, 13 Oct 2023 21:24:10 +0200 Subject: [PATCH 15/62] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Add=20admin=20models?= =?UTF-8?q?=20for=20payment,=20centralize=20amount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centralize amount calculation in a function of the `Transaction` model - Add admin entries for Transaction and Product, and tweak the Transaction form so that it can compute the amount itself on save. NOTE: currently, if you save and keep editing, the amount will not change. See #69 --- insalan/payment/admin.py | 48 ++++++++++++++++++++++++++++++++++++++- insalan/payment/models.py | 15 +++++++----- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 8c38f3f3..930f3456 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -1,3 +1,49 @@ +# Disable lints: +# "Too few public methods" +# pylint: disable=R0903 + +from django import forms from django.contrib import admin -# Register your models here. +from .models import Product, Transaction + + +class ProductAdmin(admin.ModelAdmin): + """Admin handler for Products""" + + list_display = ("price", "name", "desc") + search_fields = ["price", "name"] + + +admin.site.register(Product, ProductAdmin) + + +class TransactionAdmin(admin.ModelAdmin): + """Admin handler for Transactions""" + + list_display = ( + "id", + "payer", + "payment_status", + "creation_date", + "last_modification_date", + "amount", + ) + search_fields = [ + "id", + "payer", + "products", + "payment_status", + "creation_date", + "last_modification_date", + "amount", + ] + readonly_fields = ["amount"] + + def save_model(self, request, obj, form, change): + """Save the model, recomputing the amount""" + obj.synchronize_amount() + super().save_model(request, obj, form, change) + + +admin.site.register(Transaction, TransactionAdmin) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 292ef5b7..81aaae0d 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -47,15 +47,18 @@ def new(**data): fields['creation_date'] = timezone.make_aware(datetime.now()) fields['last_modification_date'] = fields['creation_date'] fields['payer'] = data['payer'] - products = data['products'] - fields['amount'] = Decimal(0.00) - for product in data['products']: - fields['amount'] += product.price - logger.debug(f"{fields['amount']} and {product.price}") transaction = Transaction.objects.create(**fields) transaction.products.set(data['products']) + transaction.synchronize_amount() return transaction - + + def synchronize_amount(self): + """Recompute the amount from the product list""" + self.amount = Decimal(0.00) + for product in self.products.all(): + self.amount += product.price + logger.debug(f"{self.amount} and {product.price}") + def validate_transaction(self): """ set payment_statut to validated """ From 2bdb1650d05f4aa732f565d43bea9129b4b31523 Mon Sep 17 00:00:00 2001 From: Mahal Date: Fri, 13 Oct 2023 23:41:09 +0200 Subject: [PATCH 16/62] cleaning code --- insalan/payment/tokens.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index c6d45f68..e2fcf818 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -19,14 +19,15 @@ def __init__(self): }, ) logger.debug(request.text) - self.bearer_token = json.loads(request.text)["access_token"] - self.refresh_token = json.loads(request.text)["refresh_token"] + self.bearer_token = request.json["access_token"] + self.refresh_token = request.json["refresh_token"] + def get_token(self): return self.bearer_token def refresh(self): request = requests.post( - url=static_urls.get_Tokens_url(), + url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", headers={'Content-Type': "application/x-www-form-urlencoded"}, data={ 'client_id': getenv("CLIENT_ID"), @@ -34,5 +35,5 @@ def refresh(self): 'grant_type': "refresh_token", }, ) - self.bearer_token=json.loads(request.text)["access_token"] - self.refresh_token=json.loads(request.text)["refresh_token"] + self.bearer_token=request.json["access_token"] + self.refresh_token=request.json["refresh_token"] From b7b7e0b2d3cac9b5fab35380fb9b855b9af39c25 Mon Sep 17 00:00:00 2001 From: Mahal Date: Fri, 13 Oct 2023 23:42:45 +0200 Subject: [PATCH 17/62] forget () --- insalan/payment/tokens.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index e2fcf818..bc85a34a 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -19,8 +19,8 @@ def __init__(self): }, ) logger.debug(request.text) - self.bearer_token = request.json["access_token"] - self.refresh_token = request.json["refresh_token"] + self.bearer_token = request.json()["access_token"] + self.refresh_token = request.json()["refresh_token"] def get_token(self): return self.bearer_token @@ -35,5 +35,5 @@ def refresh(self): 'grant_type': "refresh_token", }, ) - self.bearer_token=request.json["access_token"] - self.refresh_token=request.json["refresh_token"] + self.bearer_token=request.json()["access_token"] + self.refresh_token=request.json()["refresh_token"] From 0cbdc3bc62b103d34946f7acb9800b56d35f97b4 Mon Sep 17 00:00:00 2001 From: Mahal Date: Sat, 14 Oct 2023 00:08:13 +0200 Subject: [PATCH 18/62] clarify doc --- insalan/payment/models.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 81aaae0d..252a3e03 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -23,7 +23,7 @@ class Product(models.Model): class Transaction(models.Model): - """A transaction""" + """A transaction is a record from helloasso intent. A transaction cannot exist alone and should be used only to reflect helloasso payment""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) payer = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) products = models.ManyToManyField(Product) # A transaction can be composed of n products @@ -52,13 +52,6 @@ def new(**data): transaction.synchronize_amount() return transaction - def synchronize_amount(self): - """Recompute the amount from the product list""" - self.amount = Decimal(0.00) - for product in self.products.all(): - self.amount += product.price - logger.debug(f"{self.amount} and {product.price}") - def validate_transaction(self): """ set payment_statut to validated """ From c0b51a446a7905b91b50ffb0f3ffb9239c5272d5 Mon Sep 17 00:00:00 2001 From: Mahal Date: Sat, 14 Oct 2023 00:18:59 +0200 Subject: [PATCH 19/62] Remove adding/editing/deleting abilities from the admin panel --- insalan/payment/admin.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 930f3456..2186b221 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -19,7 +19,10 @@ class ProductAdmin(admin.ModelAdmin): class TransactionAdmin(admin.ModelAdmin): - """Admin handler for Transactions""" + """ + Admin handler for Transactions + In the backoffice, Transactions can only be seen, they cannot be add, removed or changed this way + """ list_display = ( "id", @@ -38,12 +41,17 @@ class TransactionAdmin(admin.ModelAdmin): "last_modification_date", "amount", ] - readonly_fields = ["amount"] - - def save_model(self, request, obj, form, change): - """Save the model, recomputing the amount""" - obj.synchronize_amount() - super().save_model(request, obj, form, change) + def has_add_permission(self, request): + """Remove the ability to add a transaction from the backoffice """ + return False + + def has_change_permission(self, request, obj=None): + """ Remove the ability to edit a transaction from the backoffice """ + return False + + def has_delete_permission(self, request, obj=None): + """ Remove the ability to edit a transaction from the backoffice """ + return False admin.site.register(Transaction, TransactionAdmin) From 39e0731a481f2fb699e8ef32dc7558c499031cd0 Mon Sep 17 00:00:00 2001 From: Mahal Date: Sat, 14 Oct 2023 00:25:48 +0200 Subject: [PATCH 20/62] =?UTF-8?q?=F0=9F=A5=96translating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- insalan/payment/models.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 252a3e03..ccd8a6ad 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -17,15 +17,15 @@ class TransactionStatus(models.TextChoices): class Product(models.Model): """ Object to represent in database anything sellable""" - price = models.DecimalField(null=False, max_digits=5, decimal_places=2) - name = models.CharField(max_length=50) + price = models.DecimalField(null=False, max_digits=5, decimal_places=2, verbose_name=_("prix")) + name = models.CharField(max_length=50, verbose_name=_("intitulé")) desc = models.CharField(max_length=50, verbose_name=_("description")) class Transaction(models.Model): """A transaction is a record from helloasso intent. A transaction cannot exist alone and should be used only to reflect helloasso payment""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - payer = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + payer = models.ForeignKey(User, null=True, on_delete=models.SET_NULL,verbose_name=_("Utilisateur")) products = models.ManyToManyField(Product) # A transaction can be composed of n products payment_status = models.CharField( max_length=10, @@ -33,11 +33,11 @@ class Transaction(models.Model): default=TransactionStatus.PENDING, choices=TransactionStatus.choices, null=False, - verbose_name=_("Transaction status"), + verbose_name=_("État de la Transaction"), ) - creation_date = models.DateTimeField() - last_modification_date = models.DateTimeField() - amount = models.DecimalField(null=False, default=0.00, max_digits=5, decimal_places=2) + creation_date = models.DateTimeField(verbose_name=_("Date de creation")) + last_modification_date = models.DateTimeField(verbose_name=_("Date de dernière modification")) + amount = models.DecimalField(null=False, default=0.00, max_digits=5, decimal_places=2, verbose_name=_("Montant")) @staticmethod def new(**data): From eac5b84ee7ea3adbfedc8b2b4e792872ed272c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sat, 14 Oct 2023 00:49:01 +0200 Subject: [PATCH 21/62] Modify product M2M to use a through field Use a M2M through table that also stores the amount of each product used in a transaction, sorting out the issue of not being able to have duplicate entries for a product in a Transaction. --- insalan/payment/models.py | 100 ++++++++++++++++++++++++++++++++------ insalan/payment/views.py | 98 ++++++++++++++++++++++--------------- 2 files changed, 144 insertions(+), 54 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index ccd8a6ad..d9ef0132 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -5,8 +5,12 @@ import uuid from django.utils import timezone import logging +import itertools from decimal import Decimal + logger = logging.getLogger(__name__) + + class TransactionStatus(models.TextChoices): """Information about the current transaction status""" @@ -16,17 +20,34 @@ class TransactionStatus(models.TextChoices): class Product(models.Model): - """ Object to represent in database anything sellable""" - price = models.DecimalField(null=False, max_digits=5, decimal_places=2, verbose_name=_("prix")) + """Object to represent in database anything sellable""" + + price = models.DecimalField( + null=False, max_digits=5, decimal_places=2, verbose_name=_("prix") + ) name = models.CharField(max_length=50, verbose_name=_("intitulé")) desc = models.CharField(max_length=50, verbose_name=_("description")) class Transaction(models.Model): - """A transaction is a record from helloasso intent. A transaction cannot exist alone and should be used only to reflect helloasso payment""" + """ + A transaction is a record from helloasso intent. + + A transaction cannot exist alone and should be used only to reflect helloasso payment + """ + + class Meta: + """Meta information""" + + ordering = ["-last_modification_date"] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - payer = models.ForeignKey(User, null=True, on_delete=models.SET_NULL,verbose_name=_("Utilisateur")) - products = models.ManyToManyField(Product) # A transaction can be composed of n products + payer = models.ForeignKey( + User, null=True, on_delete=models.SET_NULL, verbose_name=_("Utilisateur") + ) + products = models.ManyToManyField( + Product, through="ProductCount" + ) # A transaction can be composed of n products payment_status = models.CharField( max_length=10, blank=True, @@ -34,33 +55,56 @@ class Transaction(models.Model): choices=TransactionStatus.choices, null=False, verbose_name=_("État de la Transaction"), - ) + ) creation_date = models.DateTimeField(verbose_name=_("Date de creation")) - last_modification_date = models.DateTimeField(verbose_name=_("Date de dernière modification")) - amount = models.DecimalField(null=False, default=0.00, max_digits=5, decimal_places=2, verbose_name=_("Montant")) + last_modification_date = models.DateTimeField( + verbose_name=_("Date de dernière modification") + ) + amount = models.DecimalField( + null=False, + default=0.00, + max_digits=5, + decimal_places=2, + verbose_name=_("Montant"), + ) @staticmethod def new(**data): - """ create a new transaction based on products id list and a payer """ + """create a new transaction based on products id list and a payer""" logger.debug(f"in the constructor {data}") fields = {} - fields['creation_date'] = timezone.make_aware(datetime.now()) - fields['last_modification_date'] = fields['creation_date'] - fields['payer'] = data['payer'] + fields["creation_date"] = timezone.make_aware(datetime.now()) + fields["last_modification_date"] = fields["creation_date"] + fields["payer"] = data["payer"] transaction = Transaction.objects.create(**fields) - transaction.products.set(data['products']) + data["products"].sort() + for pid, grouper in itertools.groupby(data["products"]): + count = len(list(grouper)) + proc = ProductCount.objects.create( + transaction=transaction, + product=Product.objects.get(id=pid), + count=count, + ) + proc.save() transaction.synchronize_amount() return transaction + def synchronize_amount(self): + """Recompute the amount from the product list""" + self.amount = Decimal(0.00) + for proc in ProductCount.objects.filter(transaction=self): + self.amount += proc.product.price * proc.count + self.save() + def validate_transaction(self): - """ set payment_statut to validated """ + """set payment_statut to validated""" self.payment_status = TransactionStatus.SUCCEDED self.last_modification_date = timezone.make_aware(datetime.now()) self.save() def fail_transaction(self): - """ set payment_statut to failed and update last_modification_date """ + """set payment_statut to failed and update last_modification_date""" self.payment_status = TransactionStatus.FAILED self.last_modification_date = timezone.make_aware(datetime.now()) self.save() @@ -70,3 +114,29 @@ def get_products(self): def get_products_id(self): return [product.id for product in self.products] + + +class ProductCount(models.Model): + """M2M-Through class to store the amount of a product for a Transaction""" + + class Meta: + """Meta information""" + + verbose_name = _("Nombre d'un produit") + verbose_name_plural = _("Nombres d'un produit") + constraints = [ + models.UniqueConstraint( + fields=["transaction", "product"], name="product_count_m2m_through" + ) + ] + + transaction = models.ForeignKey( + Transaction, + on_delete=models.CASCADE, + editable=False, + verbose_name=_("Transaction"), + ) + product = models.ForeignKey( + Product, on_delete=models.CASCADE, editable=False, verbose_name=_("Produit") + ) + count = models.IntegerField(default=1, editable=True, verbose_name=_("Quantité")) diff --git a/insalan/payment/views.py b/insalan/payment/views.py index fa0a8aea..c97187cd 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -1,59 +1,77 @@ +"""Views for the Payment module""" + import json -from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned +import logging + +from datetime import date from os import getenv + import requests + +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.http import JsonResponse, HttpResponseRedirect +from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt -from .models import Transaction, TransactionStatus, Product -from .serializers import TransactionSerializer -from datetime import date -from .tokens import Tokens from rest_framework import generics, permissions, status from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.authentication import SessionAuthentication -from django.shortcuts import render + import insalan.payment.serializers as serializers -from django.http import HttpResponseRedirect + +from .models import Transaction, TransactionStatus, Product +from .tokens import Tokens from .models import Product, Transaction -import logging logger = logging.getLogger(__name__) + + class ProductList(generics.ListAPIView): paginator = None - serializer_class = serializers.ProductSerializer + serializer_class = serializers.ProductSerializer queryset = Product.objects.all() permission_classes = [permissions.IsAdminUser] + class ProductDetails(generics.RetrieveUpdateDestroyAPIView): paginator = None - serializer_class= serializers.ProductSerializer + serializer_class = serializers.ProductSerializer queryset = Product.objects.all() permission_classes = [permissions.IsAdminUser] + class TransactionList(generics.ListAPIView): paginator = None - serializer_class =serializers.TransactionSerializer - queryset = Transaction.objects.all().order_by('last_modification_date') + serializer_class = serializers.TransactionSerializer + queryset = Transaction.objects.all().order_by("last_modification_date") permission_classes = [permissions.IsAdminUser] + class TransactionPerId(generics.RetrieveAPIView): paginator = None serializer_class = serializers.TransactionSerializer queryset = Transaction.objects.all() permission_classes = [permissions.IsAdminUser] + class CreateProduct(generics.CreateAPIView): serializer_class = serializers.ProductSerializer queryset = Product.objects.all() permission_classes = [permissions.IsAdminUser] - + + class BackView(generics.ListAPIView): pass + + class ReturnView(generics.ListAPIView): pass + + class ErrorView(generics.ListAPIView): pass + + class PayView(generics.CreateAPIView): permission_classes = [permissions.IsAuthenticated] authentication_classes = [SessionAuthentication] @@ -64,43 +82,48 @@ def create(self, request): token = Tokens() payer = request.user data = request.data.copy() - data['payer'] = payer.id - logger.debug(f"data in view = {data}") # contient des données + data["payer"] = payer.id + logger.debug(f"data in view = {data}") # contient des données transaction = serializers.TransactionSerializer(data=data) transaction.is_valid() logger.debug(transaction.validated_data) if transaction.is_valid(raise_exception=True): transaction_obj = transaction.save() # helloasso intent - helloasso_amount = int(transaction_obj.amount * 100) # helloasso reads prices in cents - HELLOASSO_URL = getenv('HELLOASSO_ENDPOINT') + helloasso_amount = int( + transaction_obj.amount * 100 + ) # helloasso reads prices in cents + HELLOASSO_URL = getenv("HELLOASSO_ENDPOINT") intent_body = { - "totalAmount": helloasso_amount, - "initialAmount": helloasso_amount, - "itemName": str(transaction_obj.id), - "backUrl": f"{getenv('HELLOASSO_BACK_URL')}?id={transaction_obj.id}", - "errorUrl": f"{getenv('HELLOASSO_ERROR_URL')}?id={transaction_obj.id}", - "returnUrl": f"{getenv('HELLOASSO_RETURN_URL')}?id={transaction_obj.id}", - "containsDonation": False, - "payer": { - "firstName": payer.first_name, - "lastName": payer.last_name, - "email": payer.email, - }, + "totalAmount": helloasso_amount, + "initialAmount": helloasso_amount, + "itemName": str(transaction_obj.id), + "backUrl": f"{getenv('HELLOASSO_BACK_URL')}?id={transaction_obj.id}", + "errorUrl": f"{getenv('HELLOASSO_ERROR_URL')}?id={transaction_obj.id}", + "returnUrl": f"{getenv('HELLOASSO_RETURN_URL')}?id={transaction_obj.id}", + "containsDonation": False, + "payer": { + "firstName": payer.first_name, + "lastName": payer.last_name, + "email": payer.email, + }, } headers = { - 'authorization': 'Bearer ' + token.get_token(), - 'Content-Type': 'application/json', + "authorization": "Bearer " + token.get_token(), + "Content-Type": "application/json", } - checkout_init = requests.post(f"{HELLOASSO_URL}/v5/organizations/insalan-test/checkout-intents", data=json.dumps(intent_body), headers=headers)# initiate a helloasso intent + checkout_init = requests.post( + f"{HELLOASSO_URL}/v5/organizations/insalan-test/checkout-intents", + data=json.dumps(intent_body), + headers=headers, + ) # initiate a helloasso intent logger.debug(checkout_init.text) - redirect_url = checkout_init.json()['redirectUrl'] + redirect_url = checkout_init.json()["redirectUrl"] logger.debug(intent_body) return HttpResponseRedirect(redirect_to=redirect_url) - return JsonResponse({'problem': 'oui'}) - #return HttpResponseRedirect(checkout_init.redirectUrl) - + return JsonResponse({"problem": "oui"}) + # return HttpResponseRedirect(checkout_init.redirectUrl) """ # lets parse the request @@ -150,6 +173,3 @@ class get_transactions(request): return JsonResponse(transactions.data) """ - - - From dcd79a5c660729e5002251cb2d05a5c158361b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sat, 14 Oct 2023 02:59:19 +0200 Subject: [PATCH 22/62] Implement basic handling of payment return Implement the basic logic that verifies that a payment succeeded or failed on the main return path provided by HelloAsso. --- insalan/payment/admin.py | 2 + insalan/payment/models.py | 14 +++++- insalan/payment/serializers.py | 2 +- insalan/payment/views.py | 83 ++++++++++++---------------------- 4 files changed, 45 insertions(+), 56 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 2186b221..3faec6dd 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -29,6 +29,7 @@ class TransactionAdmin(admin.ModelAdmin): "payer", "payment_status", "creation_date", + "intent_id", "last_modification_date", "amount", ) @@ -38,6 +39,7 @@ class TransactionAdmin(admin.ModelAdmin): "products", "payment_status", "creation_date", + "intent_id", "last_modification_date", "amount", ] diff --git a/insalan/payment/models.py b/insalan/payment/models.py index d9ef0132..2cce7971 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -60,6 +60,12 @@ class Meta: last_modification_date = models.DateTimeField( verbose_name=_("Date de dernière modification") ) + intent_id = models.IntegerField( + blank=False, + null=True, + editable=False, + verbose_name=_("Identifiant de paiement"), + ) amount = models.DecimalField( null=False, default=0.00, @@ -77,12 +83,12 @@ def new(**data): fields["last_modification_date"] = fields["creation_date"] fields["payer"] = data["payer"] transaction = Transaction.objects.create(**fields) - data["products"].sort() + data["products"].sort(key=lambda x: int(x.id)) for pid, grouper in itertools.groupby(data["products"]): count = len(list(grouper)) proc = ProductCount.objects.create( transaction=transaction, - product=Product.objects.get(id=pid), + product=pid, count=count, ) proc.save() @@ -96,6 +102,10 @@ def synchronize_amount(self): self.amount += proc.product.price * proc.count self.save() + def touch(self): + """Update the last modification date of the transaction""" + self.last_modification_date = timezone.make_aware(datetime.now()) + def validate_transaction(self): """set payment_statut to validated""" diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py index 029790ee..296304f6 100644 --- a/insalan/payment/serializers.py +++ b/insalan/payment/serializers.py @@ -11,7 +11,7 @@ class TransactionSerializer(serializers.ModelSerializer): class Meta: model=Transaction fields = "__all__" - read_only_fields = ['amount', 'payer', 'payment_status', 'creation_date', 'last_modification_date'] + read_only_fields = ['amount', 'payer', 'payment_status', 'intent_id', 'creation_date', 'last_modification_date'] def create(self, validated_data): """ Create a transaction with products based on the request""" diff --git a/insalan/payment/views.py b/insalan/payment/views.py index c97187cd..d1997ce8 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -63,10 +63,33 @@ class CreateProduct(generics.CreateAPIView): class BackView(generics.ListAPIView): pass +class ReturnView(APIView): + """View for the return""" + def get(self, request, **kwargs): + trans_id = request.query_params.get("id") + checkout_id = request.query_params.get("checkoutIntentId") + code = request.query_params.get("code") -class ReturnView(generics.ListAPIView): - pass + if None in [trans_id, checkout_id, code]: + return Response(status=status.HTTP_400_BAD_REQUEST) + + transaction_obj = Transaction.objects.filter(payment_status=TransactionStatus.PENDING, id=trans_id, intent_id=checkout_id) + if len(transaction_obj) == 0: + return Response(status=status.HTTP_403_FORBIDDEN) + + transaction_obj = transaction_obj[0] + + if code != "success": + transaction_obj.payment_status = TransactionStatus.FAILED + transaction_obj.touch() + transaction_obj.save() + return Response(status=status.HTTP_403_FORBIDDEN) + + transaction_obj.payment_status = TransactionStatus.SUCCEEDED + transaction_obj.touch() + transaction_obj.save() + return Response(transaction_obj) class ErrorView(generics.ListAPIView): pass @@ -119,57 +142,11 @@ def create(self, request): headers=headers, ) # initiate a helloasso intent logger.debug(checkout_init.text) - redirect_url = checkout_init.json()["redirectUrl"] + checkout_json = checkout_init.json() + redirect_url = checkout_json["redirectUrl"] + intent_id = checkout_json["id"] + transaction_obj.intent_id = intent_id + transaction_obj.save() logger.debug(intent_body) return HttpResponseRedirect(redirect_to=redirect_url) return JsonResponse({"problem": "oui"}) - # return HttpResponseRedirect(checkout_init.redirectUrl) - - """ - # lets parse the request - user=request.user - for asked_product in user_request_body: - try: - product = Product.objects.get(pk=asked_product[id]) - product_list.append(product) - if asked_product == user_request_body.pop(): - name+=product.name - else : - name+=product.name + ", " - # need that all product implement a Product Model (with an id as pk, and a price) - amount += product.price - except (ObjectDoesNotExist, MultipleObjectsReturned): - pass # do something - - transaction=Transaction(amount=amount, payer=user, products=product_list, date=date.today()) - # need to put a list field of product in Transaction model - - # lets init a checkout to helloasso - url = static_urls.get_checkout_url() - headers = { - 'authorization': 'Bearer ' + tokens.get_token(), - 'Content-Type': 'application/json', - } - request_status=False - while request_status!=True: - checkout_init=requests.post(url = url, headers=headers, data=json.dumps(body)) - if checkout_init.status_code==200: - request_status=True - elif checkout_init.status_code==401: - tokens.refresh() - elif checkout_init.status_code==403: - pass # cry, problem concerning the perms of the token - elif checkout_init.status_code==400: - pass # the value are false - else: - pass - return HttpResponseRedirect(redirect_to=json.loads(checkout_init.text)['id']) -@csrf_exempt -class validate_payment(request, id): - Transaction.objects.get(id=id).payment_status=TransactionStatus.SUCCEDED - -class get_transactions(request): - transactions=TransactionSerializer(Transaction.objects.all(), many=True) - return JsonResponse(transactions.data) - -""" From 9c6df4df14a4b503bae710124d49cd2fe5f622ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 20:51:05 +0200 Subject: [PATCH 23/62] =?UTF-8?q?=F0=9F=92=B0=20Formatting=20of=20payment?= =?UTF-8?q?=20models=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the ordering of headers and fix a typo in payment models (typo is in the payment status for a transaction) --- insalan/payment/models.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 2cce7971..3b57907d 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -1,12 +1,17 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ -from insalan.user.models import User -from datetime import datetime -import uuid -from django.utils import timezone import logging import itertools + +import uuid + from decimal import Decimal +from datetime import datetime + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone + +from insalan.tournament.models import Tournament +from insalan.user.models import User logger = logging.getLogger(__name__) @@ -15,7 +20,7 @@ class TransactionStatus(models.TextChoices): """Information about the current transaction status""" FAILED = "FAILED", _("échouée") - SUCCEDED = "SUCCEEDED", _("Réussie") + SUCCEEDED = "SUCCEEDED", _("Réussie") PENDING = "PENDING", _("En attente") From 696c5cb5babf1886518d00b0feef7d7a2db4e341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 20:52:53 +0200 Subject: [PATCH 24/62] =?UTF-8?q?=F0=9F=92=B8=20Introduct=20more=20product?= =?UTF-8?q?=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce: - Product category, that allows the system to know what category the product belongs to - Associated tournament, a potential foreign key to a tournament when the product is a tournament registration - Allow a product to be a null key in a ProductCount pair, and set it to null when it is deleted --- insalan/payment/models.py | 25 ++++++++++++++++++++++++- insalan/payment/views.py | 4 ++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 3b57907d..78ed7db1 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -24,6 +24,14 @@ class TransactionStatus(models.TextChoices): PENDING = "PENDING", _("En attente") +class ProductCategory(models.TextChoices): + """Different recognized categories of products""" + + REGISTRATION_PLAYER = "PLAYER_REG", _("Inscription joueur⋅euse") + REGISTRATION_MANAGER = "MANAGER_REG", _("Inscription manager") + PIZZA = "PIZZA", _("Pizza") + + class Product(models.Model): """Object to represent in database anything sellable""" @@ -32,6 +40,21 @@ class Product(models.Model): ) name = models.CharField(max_length=50, verbose_name=_("intitulé")) desc = models.CharField(max_length=50, verbose_name=_("description")) + category = models.CharField( + max_length=20, + blank=False, + null=False, + verbose_name=_("Catégorie de produit"), + default=ProductCategory.PIZZA, + choices=ProductCategory.choices, + ) + associated_tournament = models.ForeignKey( + Tournament, + on_delete=models.CASCADE, + verbose_name=_("Tournoi associé"), + null=True, + blank=True, + ) class Transaction(models.Model): @@ -152,6 +175,6 @@ class Meta: verbose_name=_("Transaction"), ) product = models.ForeignKey( - Product, on_delete=models.CASCADE, editable=False, verbose_name=_("Produit") + Product, on_delete=models.SET_NULL, editable=False, verbose_name=_("Produit"), null=True ) count = models.IntegerField(default=1, editable=True, verbose_name=_("Quantité")) diff --git a/insalan/payment/views.py b/insalan/payment/views.py index d1997ce8..d4fd5670 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -19,9 +19,9 @@ import insalan.payment.serializers as serializers -from .models import Transaction, TransactionStatus, Product +from .hooks import PaymentCallbackSystem +from .models import Transaction, TransactionStatus, Product, ProductCount from .tokens import Tokens -from .models import Product, Transaction logger = logging.getLogger(__name__) From 217019620cb82476b17fb93ded8377abbd2a2a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 20:55:19 +0200 Subject: [PATCH 25/62] =?UTF-8?q?=F0=9F=92=B6=20Introduce=20the=20product?= =?UTF-8?q?=20category=20hook=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces the product category hook system, which allows applications to create a class derived from an interface that implements callbacks for payment success and failure handling. A module can simply inherit the `PaymentHooks` class in one of its classes, and register it with the `PaymentCallbackSystem` class. An example is provided with the tournament class, which now implements a way to validate a pending registration with a payment. --- insalan/payment/hooks.py | 106 +++++++++++++++++++++++++++++++++ insalan/tournament/apps.py | 7 ++- insalan/tournament/payment.py | 107 ++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 insalan/payment/hooks.py create mode 100644 insalan/tournament/payment.py diff --git a/insalan/payment/hooks.py b/insalan/payment/hooks.py new file mode 100644 index 00000000..6a04a300 --- /dev/null +++ b/insalan/payment/hooks.py @@ -0,0 +1,106 @@ +""" +Hooks module for payment + +This file is not part of the Django rest framework. Rather, it is a component +designed for other components of the application to come and register custom +handlers for payment success or failure. +""" + +import logging + +from django.utils.translation import gettext_lazy as _ + +from .models import ProductCategory + + +class PaymentCallbackSystem: + """ + Interface to register your hooks with, and get them + """ + + # The dictionary of hooks + __HOOKS = {} + __logger = logging.getLogger("insalan.payment.hooks.PaymentCallbackSystem") + + @classmethod + def register_handler(cls, prodcat, handler, overwrite=False): + """ + Register a handler for a product category + + You give it a product (any instance of + `payment.models.ProductCategory`), and a handler (any subclass of + `payment.hooks.PaymentHooks`). + + Overwriting an existing hook will trigger an error, unless you set + `overwrite=False`. + """ + + # Verify arguments + # 1. product is an instance of Product + if not isinstance(prodcat, ProductCategory): + raise ValueError(_("Produit qui n'est pas une instance de Produit")) + + if not issubclass(handler, PaymentHooks): + raise ValueError(_("Descripteur qui ne dérive pas de PaymentHooks")) + + if cls.__HOOKS.get(prodcat): + if overwrite: + cls.__logger.warning("Overwriting handler for product category %s", prodcat) + else: + raise ValueError(_(f"Descripteur déjà défini pour {prodcat}")) + + cls.__HOOKS[prodcat] = handler + + + @classmethod + def retrieve_handler(cls, prodcat): + """ + Retrieve a handler class for a product category + + If a handler is not registered, returns None + """ + return cls.__HOOKS.get(prodcat) + +# Base class/interface +class PaymentHooks: + """ + Payment Hooks Class + + This is a base class that must be derived by all hooks implementers, who + will then implement their way of handling payment success and failure. + """ + + @staticmethod + def prepare_transaction(_transaction, _product, _count): + """ + Prepare things that may have to be created prior to payment + + Arguments are the preliminary transaction, product and count. + This hook is ran by the payment view exactly right before forwarding the + user to HelloAsso. + """ + + @staticmethod + def payment_success(_transaction, _product, _count): + """ + Payment Success Handler + + This method handles the process of validating a transaction, with the + transaction object, product and count given. + By that point, you can safely assume that the payment succeeded, and + that the `.payment_status` field of the transaction is set to + `SUCCEEDED`. + """ + + @staticmethod + def payment_failure(_transaction, _product, _count): + """ + Payment Failure Handler + + This method handles the process of cleaning up after a failed + transaction. By this point you can safely assume that the payment failed + and that `.payment_status` on the transaction object is set to `FAILED`. + """ + + +# vim: set tw=80: diff --git a/insalan/tournament/apps.py b/insalan/tournament/apps.py index 381eadd6..cf8b2142 100644 --- a/insalan/tournament/apps.py +++ b/insalan/tournament/apps.py @@ -2,10 +2,15 @@ from django.apps import AppConfig from django.utils.translation import gettext_lazy as _ - class TournamentConfig(AppConfig): """Tournament app config""" default_auto_field = 'django.db.models.BigAutoField' name = "insalan.tournament" verbose_name = _("Tournois") + + def ready(self): + """Called when the module is ready""" + from .payment import payment_handler_register + + payment_handler_register() diff --git a/insalan/tournament/payment.py b/insalan/tournament/payment.py new file mode 100644 index 00000000..48d42a8e --- /dev/null +++ b/insalan/tournament/payment.py @@ -0,0 +1,107 @@ +"""Handling of the payment of a registration (player and manager)""" + +from insalan.payment.models import ProductCategory +from insalan.payment.hooks import PaymentHooks, PaymentCallbackSystem + +from django.utils.translation import gettext_lazy as _ + +from insalan.tickets.models import Ticket +from insalan.tournament.models import Player, Manager, PaymentStatus + + +class PaymentHandler(PaymentHooks): + """Handler of the payment of a ticket/registration""" + + @staticmethod + def fetch_registration(tourney, user): + """ + Fetch a registration for a user in a tournament. + + Returns a tuple (reg, is_manager), which is pretty explicit. + """ + # Find a registration on that user within the tournament + # Could they be player? + reg = Player.objects.filter( + team__tournament=tourney, user=user, payment_status=PaymentStatus.NOT_PAID + ) + if len(reg) > 1: + raise RuntimeError(_("Plusieurs inscription joueur⋅euse à un même tournoi")) + if len(reg) == 1: + return (reg[0], False) + + reg = Manager.objects.filter( + team__tournament=tourney, user=user, payment_status=PaymentStatus.NOT_PAID + ) + if len(reg) > 1: + raise RuntimeError(_("Plusieurs inscription manager à un même tournoi")) + if len(reg) == 0: + raise RuntimeError( + _(f"Pas d'inscription à valider au paiement pour {user}") + ) + return (reg[0], True) + + @staticmethod + def payment_success(transaction, product, _count): + """Handle success of the registration""" + + assoc_tourney = product.associated_tournament + if assoc_tourney is None: + raise RuntimeError(_("Tournoi associé à un produit acheté nul!")) + + user_obj = transaction.payer + (reg, is_manager) = PaymentHandler.fetch_registration(assoc_tourney, user_obj) + + if is_manager: + PaymentHandler.handle_player_reg(reg) + else: + PaymentHandler.handle_manager_reg(reg) + + @staticmethod + def handle_player_reg(reg: Player): + """ + Handle validation of a Player registration + """ + reg.payment_status = PaymentStatus.PAID + tick = Ticket.objects.create(user=reg.user) + tick.save() + + reg.ticket = tick + reg.save() + + @staticmethod + def handle_manager_reg(reg: Manager): + """ + Handle validation of a Manager registration + """ + reg.payment_status = PaymentStatus.PAID + tick = Ticket.objects.create(user=reg.user) + tick.save() + + reg.ticket = tick + reg.save() + + @staticmethod + def payment_failure(transaction, product, _count): + """Handle the failure of a registration""" + + # Find a registration that was ongoing for the user + assoc_tourney = product.associated_tournament + if assoc_tourney is None: + raise RuntimeError(_("Tournoi associé à un produit acheté nul!")) + + user_obj = transaction.payer + (reg, _is_manager) = PaymentHandler.fetch_registration(assoc_tourney, user_obj) + + # Whatever happens, just delete the registration + reg.delete() + +def payment_handler_register(): + """Register the callbacks""" + PaymentCallbackSystem.register_handler( + ProductCategory.REGISTRATION_PLAYER, PaymentHandler, + overwrite = True + ) + PaymentCallbackSystem.register_handler( + ProductCategory.REGISTRATION_MANAGER, PaymentHandler, + overwrite = True + ) From 2c540c8eaebec5c46a2075347407a90393121b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 20:57:23 +0200 Subject: [PATCH 26/62] =?UTF-8?q?=F0=9F=AB=B0=20Show=20new=20fields=20in?= =?UTF-8?q?=20the=20admin=20view=20of=20a=20product?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show all fields introduced earlier in the product admin page --- insalan/payment/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 3faec6dd..6b8f89a6 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -11,7 +11,7 @@ class ProductAdmin(admin.ModelAdmin): """Admin handler for Products""" - list_display = ("price", "name", "desc") + list_display = ("price", "name", "desc", "category", "associated_tournament") search_fields = ["price", "name"] From 2c31f3dea72fe27e556a10c69922bfbc424e9dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 20:59:06 +0200 Subject: [PATCH 27/62] =?UTF-8?q?=F0=9F=AA=99=20Execute=20product=20callba?= =?UTF-8?q?ck=20hooks=20on=20payment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the payment API, when a payment is initiated, call the preparation hooks of a payment hook class. In the return view, call the accept hooks. --- insalan/payment/views.py | 24 +++++++++++++++++++++++- insalan/tournament/models.py | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/insalan/payment/views.py b/insalan/payment/views.py index d4fd5670..f4bf965f 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -83,13 +83,24 @@ def get(self, request, **kwargs): transaction_obj.payment_status = TransactionStatus.FAILED transaction_obj.touch() transaction_obj.save() + return Response(status=status.HTTP_403_FORBIDDEN) transaction_obj.payment_status = TransactionStatus.SUCCEEDED transaction_obj.touch() transaction_obj.save() - return Response(transaction_obj) + # Execute hooks + for proccount in ProductCount.objects.filter(transaction=transaction_obj): + # Get callback class + cls = PaymentCallbackSystem.retrieve_handler(proccount.product.category) + if cls is None: + logger.warning("No handler found for payment of %s", proccount.product) + return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR) + # Call callback class + cls.payment_success(transaction_obj, proccount.product, proccount.count) + + return Response(status=status.HTTP_200_OK) class ErrorView(generics.ListAPIView): pass @@ -148,5 +159,16 @@ def create(self, request): transaction_obj.intent_id = intent_id transaction_obj.save() logger.debug(intent_body) + + # Execute hooks + for proccount in ProductCount.objects.filter(transaction=transaction_obj): + # Get callback class + cls = PaymentCallbackSystem.retrieve_handler(proccount.product.category) + if cls is None: + logger.warning("No handler found for payment of %s", proccount.product) + return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR) + # Call callback class + cls.prepare_transaction(transaction_obj, proccount.product, proccount.count) + return HttpResponseRedirect(redirect_to=redirect_url) return JsonResponse({"problem": "oui"}) diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index 6f9ca49e..a1c2bc47 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -19,6 +19,7 @@ ) from django.utils.translation import gettext_lazy as _ +from insalan.tickets.models import Ticket from insalan.user.models import User From 35e0e9a312700d8a04f376fe92b16f0b12a8fede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 21:01:32 +0200 Subject: [PATCH 28/62] =?UTF-8?q?=F0=9F=93=92=20Several=20tournament=20mod?= =?UTF-8?q?els=20modifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models in the tournament field are modified thusly: - The whole file is formatted - A team is now always attached to a tournament, including when it is not validated. A boolean is added in `Team` to know if it has been validated - Both player and manager registrations now have a foreign key to their ticket, which is null until the payment of their ticket has succeeded - Admin view updated - Tests updated --- insalan/tournament/admin.py | 6 ++-- insalan/tournament/models.py | 25 +++++++++++++++-- insalan/tournament/tests.py | 54 +++++++++++++----------------------- 3 files changed, 45 insertions(+), 40 deletions(-) diff --git a/insalan/tournament/admin.py b/insalan/tournament/admin.py index 2ce710ba..5cb1f4ab 100644 --- a/insalan/tournament/admin.py +++ b/insalan/tournament/admin.py @@ -43,7 +43,7 @@ class TournamentAdmin(admin.ModelAdmin): class TeamAdmin(admin.ModelAdmin): """Admin handler for Team""" - list_display = ("id", "name", "tournament") + list_display = ("id", "name", "tournament", "validated") search_fields = ["name", "tournament"] @@ -53,7 +53,7 @@ class TeamAdmin(admin.ModelAdmin): class PlayerAdmin(admin.ModelAdmin): """Admin handler for Player Registrations""" - list_display = ("id", "user", "team", "payment_status") + list_display = ("id", "user", "team", "payment_status", "ticket") search_fields = ["user", "team", "payment_status"] @@ -63,7 +63,7 @@ class PlayerAdmin(admin.ModelAdmin): class ManagerAdmin(admin.ModelAdmin): """Admin handler for Manager Registrations""" - list_display = ("id", "user", "team", "payment_status") + list_display = ("id", "user", "team", "payment_status", "ticket") search_fields = ["user", "team", "payment_status"] diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index a1c2bc47..f0eaa1d2 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -200,9 +200,9 @@ class Team(models.Model): tournament = models.ForeignKey( Tournament, - null=True, - blank=True, - on_delete=models.SET_NULL, + null=False, + blank=False, + on_delete=models.CASCADE, verbose_name=_("Tournoi"), ) name = models.CharField( @@ -211,6 +211,9 @@ class Team(models.Model): null=False, verbose_name=_("Nom d'équipe"), ) + validated = models.BooleanField( + default=False, blank=True, verbose_name=_("Équipe validée") + ) class Meta: """Meta Options""" @@ -322,6 +325,14 @@ class Meta: null=False, verbose_name=_("Statut du paiement"), ) + ticket = models.ForeignKey( + Ticket, + on_delete=models.SET_NULL, + verbose_name=_("Ticket"), + null=True, + blank=True, + default=None, + ) def __str__(self) -> str: """Format this player registration to a str""" @@ -384,6 +395,14 @@ class Manager(models.Model): choices=PaymentStatus.choices, null=False, ) + ticket = models.ForeignKey( + Ticket, + on_delete=models.SET_NULL, + verbose_name=_("Ticket"), + null=True, + blank=True, + default=None, + ) class Meta: """Meta Options""" diff --git a/insalan/tournament/tests.py b/insalan/tournament/tests.py index 27935bf4..c6c6e82b 100644 --- a/insalan/tournament/tests.py +++ b/insalan/tournament/tests.py @@ -539,23 +539,6 @@ def test_payment_status_set(self): self.assertEqual(PaymentStatus.PAY_LATER, play_reg.payment_status) - def test_get_full_null_tournament(self): - """Get a team with a null tournament""" - team = Team.objects.create(name="LaZone", tournament=None) - - team.full_clean() - - self.assertIsNone(team.get_tournament()) - - def test_team_null_tourney_repr(self): - """ - Test that the representation of a Team when its tournament is null is as - expectde. - """ - team = Team.objects.create(name="LaZone", tournament=None) - - self.assertEqual(str(team), "LaZone (???)") - def test_get_team_players(self): """Get the players of a Team""" team = Team.objects.get(name="LaLooze") @@ -616,18 +599,6 @@ def test_team_name_too_long(self): team.name = "C" * 42 team.full_clean() - def test_tournament_deletion_set_null(self): - """Verify that a Team is deleted when its Tournament is""" - team = Team.objects.all()[0] - tourney = team.tournament - - Team.objects.get(id=team.id) - - # Delete and verify - tourney.delete() - - self.assertIsInstance(Team.objects.get(id=team.id).tournament, NoneType) - # Player Class Tests class PlayerTestCase(TestCase): @@ -839,8 +810,10 @@ def test_get_player_team_correct(self): def test_player_team_deletion(self): """Verify the behaviour of a Player when their team gets deleted""" user_obj = User.objects.get(username="testplayer") + event = Event.objects.get(year=2023, month=8) + trnm = Tournament.objects.get(event=event) # Create a team and player - team_obj = Team.objects.create(name="La Team Test", tournament=None) + team_obj = Team.objects.create(name="La Team Test Player", tournament=trnm) play_obj = Player.objects.create(team=team_obj, user=user_obj) Player.objects.get(id=play_obj.id) @@ -853,8 +826,10 @@ def test_player_team_deletion(self): def test_user_deletion(self): """Verify that a Player registration is deleted along with its user""" user_obj = User.objects.get(username="testplayer") + event = Event.objects.get(year=2023, month=8) + trnm = Tournament.objects.get(event=event) # Create a Player registration - team_obj = Team.objects.create(name="La Team Test", tournament=None) + team_obj = Team.objects.create(name="La Team Test User", tournament=trnm) play_obj = Player.objects.create(team=team_obj, user=user_obj) # Test @@ -1232,8 +1207,13 @@ def test_one_manager_many_teams_diff_event_diff_tournament_diff_team(self): def test_manager_team_deletion(self): """Verify the behaviour of a Manager when their team gets deleted""" user_obj = User.objects.get(username="testplayer") + event = Event.objects.create( + name="InsaLan Test", year=2023, month=8, description="" + ) + game = Game.objects.create(name="Test Game") + trnm = Tournament.objects.create(game=game, event=event) # Create a team and player - team_obj = Team.objects.create(name="La Team Test", tournament=None) + team_obj = Team.objects.create(name="La Team Test", tournament=trnm) play_obj = Manager.objects.create(team=team_obj, user=user_obj) Manager.objects.get(id=play_obj.id) @@ -1246,8 +1226,14 @@ def test_manager_team_deletion(self): def test_user_deletion(self): """Verify that a Manager registration is deleted along with its user""" user_obj = User.objects.get(username="testplayer") - # Create a Manager registration - team_obj = Team.objects.create(name="La Team Test", tournament=None) + event = Event.objects.create( + name="InsaLan Test", year=2023, month=8, description="" + ) + game = Game.objects.create(name="Test Game") + trnm = Tournament.objects.create( + game=game, event=event + ) # Create a Manager registration + team_obj = Team.objects.create(name="La Team Test", tournament=trnm) man_obj = Manager.objects.create(team=team_obj, user=user_obj) # Test From 0b724a3304d080220dee79dcf688ae5d5f5447fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 22:11:57 +0200 Subject: [PATCH 29/62] =?UTF-8?q?=F0=9F=94=84=20Move=20payment=20callbacks?= =?UTF-8?q?=20in=20Transaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move payment callbacks in the transaction model, such that all of the iteration work is abstracted away from views. Also add the "id" field of a product to be shown in the admin view. --- insalan/payment/admin.py | 4 ++-- insalan/payment/models.py | 20 ++++++++++++++++++++ insalan/payment/views.py | 19 ++----------------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 6b8f89a6..5800d8a6 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -11,8 +11,8 @@ class ProductAdmin(admin.ModelAdmin): """Admin handler for Products""" - list_display = ("price", "name", "desc", "category", "associated_tournament") - search_fields = ["price", "name"] + list_display = ("id", "price", "name", "desc", "category", "associated_tournament") + search_fields = ["id", "price", "name"] admin.site.register(Product, ProductAdmin) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 78ed7db1..609c939b 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -123,6 +123,26 @@ def new(**data): transaction.synchronize_amount() return transaction + def product_callback(self, key): + """Call a product callback on the list of product""" + from insalan.payment.hooks import PaymentCallbackSystem + for proccount in ProductCount.objects.filter(transaction=self): + # Get callback class + cls = PaymentCallbackSystem.retrieve_handler(proccount.product.category) + if cls is None: + logger.warning("No handler found for payment of %s", proccount.product) + raise RuntimeError(_("Pas de handler trouvé pour un paiement")) + # Call callback class + key(cls)(self, proccount.product, proccount.count) + + def run_prepare_hooks(self): + """Run the preparation hook on all products""" + self.product_callback(lambda cls: cls.prepare_transaction) + + def run_success_hooks(self): + """Run the success hooks on all products""" + self.product_callback(lambda cls: cls.payment_success) + def synchronize_amount(self): """Recompute the amount from the product list""" self.amount = Decimal(0.00) diff --git a/insalan/payment/views.py b/insalan/payment/views.py index f4bf965f..a4197ffd 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -19,7 +19,6 @@ import insalan.payment.serializers as serializers -from .hooks import PaymentCallbackSystem from .models import Transaction, TransactionStatus, Product, ProductCount from .tokens import Tokens @@ -91,14 +90,7 @@ def get(self, request, **kwargs): transaction_obj.save() # Execute hooks - for proccount in ProductCount.objects.filter(transaction=transaction_obj): - # Get callback class - cls = PaymentCallbackSystem.retrieve_handler(proccount.product.category) - if cls is None: - logger.warning("No handler found for payment of %s", proccount.product) - return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # Call callback class - cls.payment_success(transaction_obj, proccount.product, proccount.count) + transaction_obj.run_success_hooks() return Response(status=status.HTTP_200_OK) @@ -161,14 +153,7 @@ def create(self, request): logger.debug(intent_body) # Execute hooks - for proccount in ProductCount.objects.filter(transaction=transaction_obj): - # Get callback class - cls = PaymentCallbackSystem.retrieve_handler(proccount.product.category) - if cls is None: - logger.warning("No handler found for payment of %s", proccount.product) - return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # Call callback class - cls.prepare_transaction(transaction_obj, proccount.product, proccount.count) + transaction_obj.run_prepare_hooks() return HttpResponseRedirect(redirect_to=redirect_url) return JsonResponse({"problem": "oui"}) From 03e83b443cfc8dc4b2615af58d92b47111021ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 22:24:01 +0200 Subject: [PATCH 30/62] =?UTF-8?q?=F0=9F=8C=90=20Use=20HELLOASSO=5FHOSTNAME?= =?UTF-8?q?=20to=20compute=20CSRF=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the environment variable given to us as `HELLOASSO_HOSTNAME` to compute the hostname used for helloasso in our CSRF trusted origins. --- insalan/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insalan/settings.py b/insalan/settings.py index ac409fd2..5f4228bd 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -215,7 +215,7 @@ "https://api." + getenv("WEBSITE_HOST", "localhost"), "http://" + getenv("WEBSITE_HOST", "localhost"), "http://api." + getenv("WEBSITE_HOST", "localhost"), - "https://www.helloasso-sandbox.com" # helloasso has be to trusted + "https://" + getenv("HELLOASSO_HOSTNAME", "") # helloasso has be to trusted ] CSRF_COOKIE_DOMAIN = '.' + getenv("WEBSITE_HOST", "localhost") From 2ea997c20cc83cc0a27dac6ad928538bb8942ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 22:27:06 +0200 Subject: [PATCH 31/62] =?UTF-8?q?=F0=9F=93=9C=20Set=20root=20logger=20leve?= =?UTF-8?q?l=20based=20on=20DEBUG=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the environment variable indicating debug is set, the root logger is set to "DEBUG" levels of verbosity. Otherwise, it's set to "INFO". --- insalan/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insalan/settings.py b/insalan/settings.py index 5f4228bd..2e5817c8 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -52,7 +52,7 @@ }, "root": { "handlers": ["console"], - "level":"DEBUG" + "level": ["INFO", "DEBUG"][DEBUG] }, } From 892962048afacd9fd6804f2bdef807d48a82e957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 22:31:24 +0200 Subject: [PATCH 32/62] =?UTF-8?q?=F0=9F=8E=9F=EF=B8=8F=20Refactor=20and=20?= =?UTF-8?q?document=20the=20HA=20OAuth2=20token=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor and document the helloasso oauth2 token retrieval class, renaming it in the process from Tokens to Token (singular). --- insalan/payment/tokens.py | 52 +++++++++++++++++++++++++-------------- insalan/payment/views.py | 4 +-- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index bc85a34a..0a52b1e8 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -1,21 +1,35 @@ -import json -import requests -from os import getenv +"""Module that helps retrieve a OAuth2 token from HelloAsso""" import logging + +from os import getenv + +import requests + logger = logging.getLogger(__name__) -class Tokens : - instance=None + + +class Token: + """ + HelloAsso OAuth2 Token Singleton + + This class simply holds the token as a singleton, and is used to refresh it + when needed. + """ + + instance = None + def __init__(self): - if Tokens.instance is None: - Tokens.instance = self + """Initialize the Token retrieval instance""" + if Token.instance is None: + Token.instance = self logger.debug(getenv("HELLOASSO_ENDPOINT")) request = requests.post( - url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", - headers={'Content-Type': "application/x-www-form-urlencoded"}, + url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ - 'client_id': getenv("HELLOASSO_CLIENTID"), - 'client_secret': getenv("HELLOASSO_CLIENT_SECRET"), - 'grant_type': "client_credentials", + "client_id": getenv("HELLOASSO_CLIENTID"), + "client_secret": getenv("HELLOASSO_CLIENT_SECRET"), + "grant_type": "client_credentials", }, ) logger.debug(request.text) @@ -23,17 +37,19 @@ def __init__(self): self.refresh_token = request.json()["refresh_token"] def get_token(self): + """Return the singleton's token""" return self.bearer_token def refresh(self): + """Refresh our HelloAsso token""" request = requests.post( url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", - headers={'Content-Type': "application/x-www-form-urlencoded"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ - 'client_id': getenv("CLIENT_ID"), - 'client_secret': self.refresh_token, - 'grant_type': "refresh_token", + "client_id": getenv("CLIENT_ID"), + "client_secret": self.refresh_token, + "grant_type": "refresh_token", }, ) - self.bearer_token=request.json()["access_token"] - self.refresh_token=request.json()["refresh_token"] + self.bearer_token = request.json()["access_token"] + self.refresh_token = request.json()["refresh_token"] diff --git a/insalan/payment/views.py b/insalan/payment/views.py index a4197ffd..8bedc586 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -20,7 +20,7 @@ import insalan.payment.serializers as serializers from .models import Transaction, TransactionStatus, Product, ProductCount -from .tokens import Tokens +from .tokens import Token logger = logging.getLogger(__name__) @@ -105,7 +105,7 @@ class PayView(generics.CreateAPIView): serializer_class = serializers.TransactionSerializer def create(self, request): - token = Tokens() + token = Token() payer = request.user data = request.data.copy() data["payer"] = payer.id From 6bf27279fc43c5f45f097ec576336dbccb47ca1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Sun, 15 Oct 2023 22:35:29 +0200 Subject: [PATCH 33/62] FIX: default to localhost for helloasso hostname Otherwise the CI cannot work --- insalan/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insalan/settings.py b/insalan/settings.py index 2e5817c8..bf14dc4c 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -215,7 +215,7 @@ "https://api." + getenv("WEBSITE_HOST", "localhost"), "http://" + getenv("WEBSITE_HOST", "localhost"), "http://api." + getenv("WEBSITE_HOST", "localhost"), - "https://" + getenv("HELLOASSO_HOSTNAME", "") # helloasso has be to trusted + "https://" + getenv("HELLOASSO_HOSTNAME", "localhost") # helloasso has be to trusted ] CSRF_COOKIE_DOMAIN = '.' + getenv("WEBSITE_HOST", "localhost") From 6d7781cba58fca6aee296bf4b45a49ead3da92bd Mon Sep 17 00:00:00 2001 From: Mahal <12588690+ShiroUsagi-san@users.noreply.github.com> Date: Thu, 19 Oct 2023 22:56:10 +0200 Subject: [PATCH 34/62] Creating associated products This commit makes the final connection between Tournament object and Product object by creating on save() the Products associated to the Tournament --- insalan/tournament/models.py | 54 +++++++++++++++++++++++++++++-- insalan/tournament/serializers.py | 3 +- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index f0eaa1d2..57718f92 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -6,8 +6,6 @@ # "Too few public methods" # pylint: disable=R0903 -import os.path - from typing import List, Optional from django.db import models from django.core.exceptions import ValidationError @@ -22,7 +20,6 @@ from insalan.tickets.models import Ticket from insalan.user.models import User - class Event(models.Model): """ An Event is any single event that is characterized by the following: @@ -140,6 +137,8 @@ class Tournament(models.Model): validators=[MinLengthValidator(3)], max_length=40, ) + is_announced = models.BooleanField(verbose_name=_("Annoncé"), + default=False) rules = models.TextField( verbose_name=_("Règlement du tournoi"), max_length=50000, @@ -156,13 +155,62 @@ class Tournament(models.Model): FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "svg"]) ], ) + # Tournament player slot prices + # These prices are used at the tournament creation to create associated + # products + player_price_online = models.DecimalField( + null=False, max_digits=5, decimal_places=2, verbose_name=_("prix joueur\ + en ligne") + ) # when paying on the website + + player_price_onsite = models.DecimalField( + null=False, max_digits=5, decimal_places=2, verbose_name=_("prix joueur\ + sur place") + ) # when paying on site + + # Tournament manager slot prices + manager_price_online = models.DecimalField( + null=False, max_digits=5, decimal_places=2, verbose_name=_("prix manager\ + en ligne") + ) # when paying on the website + + manager_price_onsite = models.DecimalField( + null=False, max_digits=5, decimal_places=2, verbose_name=_("prix manager\ + sur place") + ) # when paying on site class Meta: """Meta options""" verbose_name = _("Tournoi") verbose_name_plural = _("Tournois") + + def save(self): + """ + Override default save of Tournament. + When a Tournament object is created, it creates 2 products, its associated + products to allow players and managers to pay the entry fee + """ + + from insalan.payment.models import Product, ProductCategory + super().save() # Get the self accessible to the products + Product.objects.create( + price=self.player_price_online, + name=_(f"Place {self.name} Joueur en ligne"), + desc=_(f"Inscription au tournoi {self.name} joueur"), + category = ProductCategory.REGISTRATION_PLAYER, + associated_tournament = self + ) + + Product.objects.create( + price=self.manager_price_online, + name=_(f"Place {self.name} manager en ligne"), + desc=_(f"Inscription au tournoi {self.name} manager"), + category = ProductCategory.REGISTRATION_MANAGER, + associated_tournament = self + ) + def __str__(self) -> str: """Format this Tournament to a str""" return f"{self.name} (@ {self.event})" diff --git a/insalan/tournament/serializers.py b/insalan/tournament/serializers.py index 1a58dec1..e3fb7171 100644 --- a/insalan/tournament/serializers.py +++ b/insalan/tournament/serializers.py @@ -54,7 +54,8 @@ class Meta: """Meta options of the serializer""" model = Tournament - read_only_fields = ("id",) + read_only_fields = ("id","manager_price_onsite", "manager_price_onsite", + "player_price_online", "player_price_onsite") fields = ["id", "event", "game", "name", "teams", "logo"] From 42e2a16e441269038f1df3aa3dbf4ec1fbf6d44e Mon Sep 17 00:00:00 2001 From: Mahal <12588690+ShiroUsagi-san@users.noreply.github.com> Date: Thu, 19 Oct 2023 23:13:02 +0200 Subject: [PATCH 35/62] Writing test and fixing broken test --- insalan/tournament/models.py | 18 +++++++++--------- insalan/tournament/tests.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index 57718f92..075819f1 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -159,24 +159,24 @@ class Tournament(models.Model): # These prices are used at the tournament creation to create associated # products player_price_online = models.DecimalField( - null=False, max_digits=5, decimal_places=2, verbose_name=_("prix joueur\ - en ligne") + null=False, default=0.0, max_digits=5, decimal_places=2, + verbose_name=_("prix joueur en ligne") ) # when paying on the website player_price_onsite = models.DecimalField( - null=False, max_digits=5, decimal_places=2, verbose_name=_("prix joueur\ - sur place") + null=False, default=0.0, max_digits=5, decimal_places=2, + verbose_name=_("prix joueur sur place") ) # when paying on site # Tournament manager slot prices manager_price_online = models.DecimalField( - null=False, max_digits=5, decimal_places=2, verbose_name=_("prix manager\ - en ligne") + null=False, default=0.0, max_digits=5, decimal_places=2, + verbose_name=_("prix manager en ligne") ) # when paying on the website manager_price_onsite = models.DecimalField( - null=False, max_digits=5, decimal_places=2, verbose_name=_("prix manager\ - sur place") + null=False, default=0.0, max_digits=5, decimal_places=2, + verbose_name=_("prix manager sur place") ) # when paying on site class Meta: @@ -185,7 +185,7 @@ class Meta: verbose_name = _("Tournoi") verbose_name_plural = _("Tournois") - def save(self): + def save(self, *args, **kwargs): """ Override default save of Tournament. When a Tournament object is created, it creates 2 products, its associated diff --git a/insalan/tournament/tests.py b/insalan/tournament/tests.py index c6c6e82b..758bca6d 100644 --- a/insalan/tournament/tests.py +++ b/insalan/tournament/tests.py @@ -445,6 +445,22 @@ def test_rules_size_limit(self): tourney.rules = "C" * 50001 tourney.full_clean() + def test_product_creation(self): + + event_one = Event.objects.create( + name="Insalan Test One", year=2023, month=2, description="" + ) + + game = Game.objects.create(name="Fortnite") + + trnm_one = Tournament.objects.create(event=event_one, game=game, + player_price_online=23.3, + manager_price_online=3) + self.assertEqual(trnm_one.player_price_online,23.3) + + self.assertEqual(trnm_one.manager_price_online,3) + + class TeamTestCase(TestCase): """ From bbc7eb59117eedd97e73dcf04cd7b49ac54ea4bd Mon Sep 17 00:00:00 2001 From: Mahal <12588690+ShiroUsagi-san@users.noreply.github.com> Date: Fri, 20 Oct 2023 12:56:52 +0200 Subject: [PATCH 36/62] Use the is_announced field (#81) * Use the is_announced field If this field is set to false, the /tournament/tournament//full returns only the requested id. This allows the creation of tournaments before the announcement date. * adding is_announced in the list display --- insalan/tournament/admin.py | 2 +- insalan/tournament/tests.py | 45 +++++++++++++++++++++++++++++++++++-- insalan/tournament/views.py | 5 +++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/insalan/tournament/admin.py b/insalan/tournament/admin.py index 5cb1f4ab..417aab39 100644 --- a/insalan/tournament/admin.py +++ b/insalan/tournament/admin.py @@ -33,7 +33,7 @@ class GameAdmin(admin.ModelAdmin): class TournamentAdmin(admin.ModelAdmin): """Admin handler for Tournaments""" - list_display = ("id", "name", "event", "game") + list_display = ("id", "name", "event", "game", "is_announced") search_fields = ["name", "event", "game"] diff --git a/insalan/tournament/tests.py b/insalan/tournament/tests.py index 758bca6d..09f488a7 100644 --- a/insalan/tournament/tests.py +++ b/insalan/tournament/tests.py @@ -893,10 +893,11 @@ def test_example(self): description="This is a test", year=2021, month=12, - ongoing=False, + ongoing=False ) tourneyobj_one = Tournament.objects.create( - event=evobj, name="Test Tournament", rules="have fun!", game=game_obj + event=evobj, name="Test Tournament", rules="have fun!", game=game_obj, + is_announced=True ) team_one = Team.objects.create(name="Team One", tournament=tourneyobj_one) Player.objects.create(user=uobj_one, team=team_one) @@ -939,6 +940,46 @@ def test_example(self): }, ) + def test_not_announced(self): + """Test a simple example""" + uobj_one = User.objects.create( + username="test_user_one", email="one@example.com" + ) + uobj_two = User.objects.create( + username="test_user_two", email="two@example.com" + ) + uobj_three = User.objects.create( + username="test_user_three", email="three@example.com" + ) + + game_obj = Game.objects.create(name="Test Game", short_name="TFG") + + evobj = Event.objects.create( + name="Test Event", + description="This is a test", + year=2021, + month=12, + ongoing=False + ) + tourneyobj_one = Tournament.objects.create( + event=evobj, name="Test Tournament", rules="have fun!", game=game_obj, + is_announced=False + ) + team_one = Team.objects.create(name="Team One", tournament=tourneyobj_one) + Player.objects.create(user=uobj_one, team=team_one) + Player.objects.create(user=uobj_two, team=team_one) + Manager.objects.create(user=uobj_three, team=team_one) + + request = self.client.get( + reverse("tournament/details-full", args=[tourneyobj_one.id]), format="json" + ) + self.assertEqual(request.status_code, 200) + self.assertEqual( + request.data, + { + "id": tourneyobj_one.id, + } + ) class EventDerefAndGroupingEndpoints(TestCase): """Test endpoints for dereferencing/fetching grouped events""" diff --git a/insalan/tournament/views.py b/insalan/tournament/views.py index e50ed0ea..632847df 100644 --- a/insalan/tournament/views.py +++ b/insalan/tournament/views.py @@ -143,9 +143,10 @@ def get(self, request, primary_key: int): raise Http404 if len(tourneys) > 1: return Response("", status=status.HTTP_400_BAD_REQUEST) - tourney = tourneys[0] - + #if the tournament hasn't been yet announced, we don't want to return details of it + if not tourney.is_announced: + return Response({"id": primary_key}, status=status.HTTP_200_OK) tourney_serialized = serializers.TournamentSerializer( tourney, context={"request": request} ).data From 6e510761d280491bf65b507322550843c010187a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 19:56:19 +0200 Subject: [PATCH 37/62] =?UTF-8?q?=F0=9F=8E=9F=EF=B8=8F=20Link=20a=20ticket?= =?UTF-8?q?=20with=20a=20tournament?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `tournament` field to a Ticket that lets us know what tournament it is linked to. --- insalan/tickets/admin.py | 2 +- insalan/tickets/models.py | 4 ++++ insalan/tickets/tests.py | 48 ++++++++++++++++++++++++++++++++------- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/insalan/tickets/admin.py b/insalan/tickets/admin.py index ccedbe7c..e5f688a7 100644 --- a/insalan/tickets/admin.py +++ b/insalan/tickets/admin.py @@ -4,7 +4,7 @@ class TicketAdmin(admin.ModelAdmin): - list_display = ("id", "user", "status", "token") + list_display = ("id", "user", "status", "tournament", "token") search_fields = ["user"] diff --git a/insalan/tickets/models.py b/insalan/tickets/models.py index 9131681d..8deb2fa7 100644 --- a/insalan/tickets/models.py +++ b/insalan/tickets/models.py @@ -30,3 +30,7 @@ class Meta: choices=Status.choices, default=Status.VALID, ) + tournament = models.ForeignKey( + "tournament.Tournament", verbose_name=_("Tournoi"), + on_delete=models.CASCADE, blank=False, null=False + ) diff --git a/insalan/tickets/tests.py b/insalan/tickets/tests.py index 5bc43a5c..0d170e05 100644 --- a/insalan/tickets/tests.py +++ b/insalan/tickets/tests.py @@ -7,23 +7,41 @@ from rest_framework.test import APITestCase from .models import Ticket +from insalan.tournament.models import Event, Game, Tournament from insalan.user.models import User def create_ticket( - username: str, token: str, status: Ticket.Status = Ticket.Status.VALID + username: str, + token: str, + tourney: Tournament, + status: Ticket.Status = Ticket.Status.VALID, ) -> None: user = User.objects.create_user( - username=username, password=username, email=f"{username}@example.com" + username=username, + password=username, + email=f"{username}@example.com", + ) + Ticket.objects.create( + user=user, token=uuid.UUID(token), status=status, tournament=tourney ) - Ticket.objects.create(user=user, token=uuid.UUID(token), status=status) class TicketTestCase(TestCase): def setUp(self) -> None: - create_ticket("user1", "00000000-0000-0000-0000-000000000001") + event_one = Event.objects.create( + name="InsaLan Test", description="Test", month=2023, year=12, ongoing=True + ) + game_one = Game.objects.create(name="Counter-Strike 2", short_name="CS2") + tourney_one = Tournament.objects.create( + name="Tournament", event=event_one, game=game_one, rules="" + ) + create_ticket("user1", "00000000-0000-0000-0000-000000000001", tourney_one) create_ticket( - "user2", "00000000-0000-0000-0000-000000000002", Ticket.Status.CANCELLED + "user2", + "00000000-0000-0000-0000-000000000002", + tourney_one, + Ticket.Status.CANCELLED, ) def test_get_existing_tickets(self) -> None: @@ -53,12 +71,20 @@ def setUp(self) -> None: User.objects.create_user( username="admin", password="admin", email="admin@example.com", is_staff=True ) + event_one = Event.objects.create( + name="InsaLan Test", description="Test", month=2023, year=12, ongoing=True + ) + game_one = Game.objects.create(name="Counter-Strike 2", short_name="CS2") + Tournament.objects.create( + name="Tournament", event=event_one, game=game_one, rules="" + ) def login(self, username: str) -> None: self.assertTrue(self.client.login(username=username, password=username)) def test_get(self) -> None: - create_ticket("user1", "00000000-0000-0000-0000-000000000001") + tourney = Tournament.objects.all()[0] + create_ticket("user1", "00000000-0000-0000-0000-000000000001", tourney) response = self.client.get( reverse( @@ -116,15 +142,18 @@ def test_get(self) -> None: ) def test_scan(self) -> None: - create_ticket("user1", "00000000-0000-0000-0000-000000000001") + tourney = Tournament.objects.all()[0] + create_ticket("user1", "00000000-0000-0000-0000-000000000001", tourney) create_ticket( "user2", "00000000-0000-0000-0000-000000000002", + tourney, status=Ticket.Status.CANCELLED, ) create_ticket( "user3", "00000000-0000-0000-0000-000000000003", + tourney, status=Ticket.Status.SCANNED, ) @@ -171,7 +200,10 @@ def test_scan(self) -> None: self.assertEqual(response.json(), {"err": _("Ticket déjà scanné")}) def test_qrcode(self) -> None: - create_ticket("user1", "00000000-0000-0000-0000-000000000001") + tourney = Tournament.objects.all()[0] + create_ticket( + "user1", "00000000-0000-0000-0000-000000000001", tourney + ) response = self.client.get( reverse("tickets:qrcode", args=["00000000-0000-0000-0000-000000000001"]) From 21972a7b7ba90fc7e931b71ddb531f47fddcdbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 20:32:27 +0200 Subject: [PATCH 38/62] =?UTF-8?q?=F0=9F=92=B0=20Add=20cashprizes=20to=20th?= =?UTF-8?q?e=20Tournament=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use an ArrayField, which is Postgresql specific, to store a list of Decimal fields that have a minimal value of 0. Also adds tests, and is needed for #49. --- insalan/tournament/admin.py | 2 +- insalan/tournament/models.py | 89 +++++++++++++++++++++++------------- insalan/tournament/tests.py | 39 ++++++++++++++++ 3 files changed, 97 insertions(+), 33 deletions(-) diff --git a/insalan/tournament/admin.py b/insalan/tournament/admin.py index 417aab39..454b265f 100644 --- a/insalan/tournament/admin.py +++ b/insalan/tournament/admin.py @@ -33,7 +33,7 @@ class GameAdmin(admin.ModelAdmin): class TournamentAdmin(admin.ModelAdmin): """Admin handler for Tournaments""" - list_display = ("id", "name", "event", "game", "is_announced") + list_display = ("id", "name", "event", "game", "is_announced", "cashprizes") search_fields = ["name", "event", "game"] diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index 075819f1..06c67fa5 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -16,10 +16,12 @@ MinLengthValidator, ) from django.utils.translation import gettext_lazy as _ +from django.contrib.postgres.fields import ArrayField from insalan.tickets.models import Ticket from insalan.user.models import User + class Event(models.Model): """ An Event is any single event that is characterized by the following: @@ -137,8 +139,7 @@ class Tournament(models.Model): validators=[MinLengthValidator(3)], max_length=40, ) - is_announced = models.BooleanField(verbose_name=_("Annoncé"), - default=False) + is_announced = models.BooleanField(verbose_name=_("Annoncé"), default=False) rules = models.TextField( verbose_name=_("Règlement du tournoi"), max_length=50000, @@ -155,62 +156,86 @@ class Tournament(models.Model): FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "svg"]) ], ) - # Tournament player slot prices + # Tournament player slot prices # These prices are used at the tournament creation to create associated # products player_price_online = models.DecimalField( - null=False, default=0.0, max_digits=5, decimal_places=2, - verbose_name=_("prix joueur en ligne") - ) # when paying on the website + null=False, + default=0.0, + max_digits=5, + decimal_places=2, + verbose_name=_("prix joueur en ligne"), + ) # when paying on the website player_price_onsite = models.DecimalField( - null=False, default=0.0, max_digits=5, decimal_places=2, - verbose_name=_("prix joueur sur place") - ) # when paying on site + null=False, + default=0.0, + max_digits=5, + decimal_places=2, + verbose_name=_("prix joueur sur place"), + ) # when paying on site - # Tournament manager slot prices + # Tournament manager slot prices manager_price_online = models.DecimalField( - null=False, default=0.0, max_digits=5, decimal_places=2, - verbose_name=_("prix manager en ligne") - ) # when paying on the website + null=False, + default=0.0, + max_digits=5, + decimal_places=2, + verbose_name=_("prix manager en ligne"), + ) # when paying on the website manager_price_onsite = models.DecimalField( - null=False, default=0.0, max_digits=5, decimal_places=2, - verbose_name=_("prix manager sur place") - ) # when paying on site + null=False, + default=0.0, + max_digits=5, + decimal_places=2, + verbose_name=_("prix manager sur place"), + ) # when paying on site + cashprizes = ArrayField( + models.DecimalField( + null=False, + default=0.0, + decimal_places=2, + max_digits=6, + validators=[MinValueValidator(0)], + ), + default=list, + blank=True, + verbose_name=_("Cashprizes"), + ) class Meta: """Meta options""" verbose_name = _("Tournoi") verbose_name_plural = _("Tournois") - + def save(self, *args, **kwargs): """ Override default save of Tournament. When a Tournament object is created, it creates 2 products, its associated products to allow players and managers to pay the entry fee """ - + from insalan.payment.models import Product, ProductCategory - super().save() # Get the self accessible to the products + + super().save() # Get the self accessible to the products Product.objects.create( - price=self.player_price_online, - name=_(f"Place {self.name} Joueur en ligne"), - desc=_(f"Inscription au tournoi {self.name} joueur"), - category = ProductCategory.REGISTRATION_PLAYER, - associated_tournament = self - ) + price=self.player_price_online, + name=_(f"Place {self.name} Joueur en ligne"), + desc=_(f"Inscription au tournoi {self.name} joueur"), + category=ProductCategory.REGISTRATION_PLAYER, + associated_tournament=self, + ) Product.objects.create( - price=self.manager_price_online, - name=_(f"Place {self.name} manager en ligne"), - desc=_(f"Inscription au tournoi {self.name} manager"), - category = ProductCategory.REGISTRATION_MANAGER, - associated_tournament = self - ) + price=self.manager_price_online, + name=_(f"Place {self.name} manager en ligne"), + desc=_(f"Inscription au tournoi {self.name} manager"), + category=ProductCategory.REGISTRATION_MANAGER, + associated_tournament=self, + ) - def __str__(self) -> str: """Format this Tournament to a str""" return f"{self.name} (@ {self.event})" diff --git a/insalan/tournament/tests.py b/insalan/tournament/tests.py index 09f488a7..8b78b70b 100644 --- a/insalan/tournament/tests.py +++ b/insalan/tournament/tests.py @@ -1,5 +1,6 @@ """Tournament Module Tests""" +from decimal import Decimal from io import BytesIO from types import NoneType @@ -347,6 +348,44 @@ def test_get_teams(self): self.assertTrue(team_two in teams) self.assertFalse(team_three in teams) + def test_default_cashprizes(self): + """Test that the default for cashprizes is an empty list""" + tourney = Tournament.objects.all()[0] + self.assertEqual([], tourney.cashprizes) + + def test_get_set_cashprizes(self): + """Verify that getting and setting cashprizes is possible""" + tourney = Tournament.objects.all()[0] + + # One price + tourney.cashprizes = [Decimal(28)] + tourney.save() + self.assertEqual(1, len(tourney.cashprizes)) + self.assertEqual(Decimal(28), tourney.cashprizes[0]) + + # Many prices + tourney.cashprizes = [Decimal(18), Decimal(22), Decimal(89)] + tourney.save() + self.assertEqual(3, len(tourney.cashprizes)) + self.assertEqual(Decimal(18), tourney.cashprizes[0]) + self.assertEqual(Decimal(22), tourney.cashprizes[1]) + self.assertEqual(Decimal(89), tourney.cashprizes[2]) + + # Back to zero + tourney.cashprizes = [] + tourney.save() + self.assertEqual(0, len(tourney.cashprizes)) + + def test_cashprizes_cannot_be_strictly_negative(self): + """Test that a cashprize cannot be strictly negative""" + tourney = Tournament.objects.all()[0] + + tourney.cashprizes = [Decimal(278), Decimal(-1), Decimal(0)] + self.assertRaises(ValidationError, tourney.full_clean) + + tourney.cashprizes = [Decimal(278), Decimal(0), Decimal(0)] + tourney.full_clean() + def test_name_too_short(self): """Verify that a tournament name cannot be too short""" tourneyobj = Tournament.objects.all()[0] From 803f01b4fa193f715453f2dce494f828933795b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 21:28:00 +0200 Subject: [PATCH 39/62] =?UTF-8?q?=F0=9F=93=85=20Add=20product=20and=20tour?= =?UTF-8?q?nament=20expiry/origin=20times?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fields to a Product and a Tournament that reflect the starting moment at which they can be bought/you can register, and the time limit to do so. Also add foreign keys to a tournament to store the products it creates, so as to not created them multiple times. These products are updated whenever their Tournament gets changed, but not the other way back (that will be done later). The API also enforces time constraints, as well as whether or not a tournament is announced. --- insalan/payment/models.py | 26 ++++++++++- insalan/tournament/models.py | 90 ++++++++++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 609c939b..513b471c 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -9,6 +9,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django.utils import timezone +from rest_framework.serializers import ValidationError from insalan.tournament.models import Tournament from insalan.user.models import User @@ -55,6 +56,19 @@ class Product(models.Model): null=True, blank=True, ) + available_from = models.DateTimeField( + blank=True, + null=False, + default=timezone.now, + verbose_name=_("Disponible à partir de"), + ) + available_until = models.DateTimeField( + null=False, verbose_name=_("Disponible jusqu'à") + ) + + def can_be_bought_now(self) -> bool: + """Returns whether or not the product can be bought now""" + return self.available_from <= timezone.now() <= self.available_until class Transaction(models.Model): @@ -113,6 +127,11 @@ def new(**data): transaction = Transaction.objects.create(**fields) data["products"].sort(key=lambda x: int(x.id)) for pid, grouper in itertools.groupby(data["products"]): + # Validate that the products can be bought + if not pid.can_be_bought_now(): + raise ValidationError({"error": f"Product {pid.id} cannot be bought right now"}) + if pid.associated_tournament and not pid.associated_tournament.is_announced: + raise ValidationError({"error": f"Tournament {pid.associated_tournament.id} not announced"}) count = len(list(grouper)) proc = ProductCount.objects.create( transaction=transaction, @@ -126,6 +145,7 @@ def new(**data): def product_callback(self, key): """Call a product callback on the list of product""" from insalan.payment.hooks import PaymentCallbackSystem + for proccount in ProductCount.objects.filter(transaction=self): # Get callback class cls = PaymentCallbackSystem.retrieve_handler(proccount.product.category) @@ -195,6 +215,10 @@ class Meta: verbose_name=_("Transaction"), ) product = models.ForeignKey( - Product, on_delete=models.SET_NULL, editable=False, verbose_name=_("Produit"), null=True + Product, + on_delete=models.SET_NULL, + editable=False, + verbose_name=_("Produit"), + null=True, ) count = models.IntegerField(default=1, editable=True, verbose_name=_("Quantité")) diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index 06c67fa5..a0d82d04 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -6,6 +6,7 @@ # "Too few public methods" # pylint: disable=R0903 +from datetime import datetime, timedelta from typing import List, Optional from django.db import models from django.core.exceptions import ValidationError @@ -15,6 +16,7 @@ MinValueValidator, MinLengthValidator, ) +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.contrib.postgres.fields import ArrayField @@ -125,6 +127,11 @@ def get_short_name(self) -> str: return self.short_name +def in_thirty_days(): + """Return now + 30 days""" + return timezone.now() + timedelta(days=30) + + class Tournament(models.Model): """ A Tournament happening during an event that Teams of players register for. @@ -147,6 +154,18 @@ class Tournament(models.Model): blank=True, default="", ) + registration_open = models.DateTimeField( + verbose_name=_("Ouverture des inscriptions"), + default=timezone.now, + blank=True, + null=False, + ) + registration_close = models.DateTimeField( + verbose_name=_("Fermeture des inscriptions"), + blank=True, + default=in_thirty_days, + null=False, + ) logo: models.FileField = models.FileField( verbose_name=_("Logo"), blank=True, @@ -203,6 +222,22 @@ class Tournament(models.Model): blank=True, verbose_name=_("Cashprizes"), ) + manager_online_product = models.ForeignKey( + "payment.Product", + related_name="manager_product_reference", + null=True, + blank=True, + verbose_name=_("Produit manager"), + on_delete=models.SET_NULL, + ) + player_online_product = models.ForeignKey( + "payment.Product", + related_name="player_product_reference", + null=True, + blank=True, + verbose_name=_("Produit joueur"), + on_delete=models.SET_NULL, + ) class Meta: """Meta options""" @@ -219,22 +254,47 @@ def save(self, *args, **kwargs): from insalan.payment.models import Product, ProductCategory - super().save() # Get the self accessible to the products - Product.objects.create( - price=self.player_price_online, - name=_(f"Place {self.name} Joueur en ligne"), - desc=_(f"Inscription au tournoi {self.name} joueur"), - category=ProductCategory.REGISTRATION_PLAYER, - associated_tournament=self, - ) + super().save(*args, **kwargs) # Get the self accessible to the products - Product.objects.create( - price=self.manager_price_online, - name=_(f"Place {self.name} manager en ligne"), - desc=_(f"Inscription au tournoi {self.name} manager"), - category=ProductCategory.REGISTRATION_MANAGER, - associated_tournament=self, - ) + need_save = False + update_fields = kwargs.get("update_fields", []) + + if self.player_online_product is None: + prod = Product.objects.create( + price=self.player_price_online, + name=_(f"Place {self.name} Joueur en ligne"), + desc=_(f"Inscription au tournoi {self.name} joueur"), + category=ProductCategory.REGISTRATION_PLAYER, + associated_tournament=self, + available_from=self.registration_open, + available_until=self.registration_close, + ) + self.player_online_product = prod + need_save = True + + self.player_online_product.available_from = self.registration_open + self.player_online_product.available_until = self.registration_close + self.player_online_product.save() + + if self.manager_online_product is None: + prod = Product.objects.create( + price=self.manager_price_online, + name=_(f"Place {self.name} manager en ligne"), + desc=_(f"Inscription au tournoi {self.name} manager"), + category=ProductCategory.REGISTRATION_MANAGER, + associated_tournament=self, + available_from=self.registration_open, + available_until=self.registration_close, + ) + self.manager_online_product = prod + need_save = True + + self.manager_online_product.available_from = self.registration_open + self.manager_online_product.available_until = self.registration_close + self.manager_online_product.save() + + if need_save: + super().save(*args, **kwargs) def __str__(self) -> str: """Format this Tournament to a str""" From d8cd51279691dc8d5e6720248e75fca91ec2d6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 22:00:08 +0200 Subject: [PATCH 40/62] =?UTF-8?q?=E2=9D=8C=20Do=20not=20reuse=20args/kwarg?= =?UTF-8?q?s=20in=20second=20save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not reuse the same args and kwargs used to create the first instance of a Tournament in a second call so as to not try and re-insert the same object with the same primary key a second time. --- insalan/tournament/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index a0d82d04..09dcbc6f 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -294,7 +294,7 @@ def save(self, *args, **kwargs): self.manager_online_product.save() if need_save: - super().save(*args, **kwargs) + super().save() def __str__(self) -> str: """Format this Tournament to a str""" From c4f0331dd581ac537e627471ba8d5859617f5bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 22:07:14 +0200 Subject: [PATCH 41/62] =?UTF-8?q?=F0=9F=8C=8D=20Translate=20tiem=20check?= =?UTF-8?q?=20validation=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate the strings used for validation errors introduced in the second to last commit, c357aaf. --- insalan/payment/models.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 513b471c..ea0c971a 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -129,9 +129,19 @@ def new(**data): for pid, grouper in itertools.groupby(data["products"]): # Validate that the products can be bought if not pid.can_be_bought_now(): - raise ValidationError({"error": f"Product {pid.id} cannot be bought right now"}) + raise ValidationError( + { + "error": _("Le produit %(id)s est actuellement indisponible") + % {"id": pid.id} + } + ) if pid.associated_tournament and not pid.associated_tournament.is_announced: - raise ValidationError({"error": f"Tournament {pid.associated_tournament.id} not announced"}) + raise ValidationError( + { + "error": _("Le tournoi %(id)s est actuellement indisponible") + % {"id": pid.associated_tournament.id} + } + ) count = len(list(grouper)) proc = ProductCount.objects.create( transaction=transaction, From dee681102bcaaf30874fbd753e5b7cdeb0401c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Mon, 23 Oct 2023 23:37:07 +0200 Subject: [PATCH 42/62] =?UTF-8?q?=F0=9F=8F=86=20Add=20a=20method=20to=20co?= =?UTF-8?q?mpute=20the=20validity=20of=20a=20Team?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a method that computes whether or not a team can be considered valid at any given point, based on quotas of people who paid. For now, the parameter `n` used is the number of people who joined the team. Very soon, it will be changed by the number of slots in a team (which is tournament dependent). --- insalan/tournament/models.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/insalan/tournament/models.py b/insalan/tournament/models.py index 09dcbc6f..e3907c21 100644 --- a/insalan/tournament/models.py +++ b/insalan/tournament/models.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from typing import List, Optional +from math import ceil from django.db import models from django.core.exceptions import ValidationError from django.core.validators import ( @@ -401,6 +402,17 @@ def get_managers_id(self) -> List[int]: """ return self.get_managers().values_list("user_id", flat=True) + def refresh_validation(self): + """Refreshes the validation state of a tournament""" + # Condition 1: ceil((n+1)/2) players have paid/will pay + players = self.get_players() + + threshold = ceil((len(players)+1)/2) + + paid_seats = len(players.exclude(payment_status=PaymentStatus.NOT_PAID)) + + self.is_valid = paid_seats >= threshold + class PaymentStatus(models.TextChoices): """Information about the current payment status of a Player/Manager""" From ddd7423b42e424c7ae954f1c9ab6fd6733f9e0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Mon, 23 Oct 2023 23:40:07 +0200 Subject: [PATCH 43/62] =?UTF-8?q?=F0=9F=9B=91=20Add=20django=20admin=20act?= =?UTF-8?q?ion=20to=20refund=20a=20transaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Django admin action that triggers a refund of a transaction. Consequently, add a hook to the payment callback class that performs post-refund actions. For tournament, this post-refund hook finds any registration paid linked to the tournament and user in question, destroys it, and cancels associated tickets. --- insalan/payment/admin.py | 16 +++++++++++- insalan/payment/hooks.py | 10 ++++++++ insalan/payment/models.py | 44 +++++++++++++++++++++++++++++++++ insalan/payment/views.py | 1 + insalan/tournament/payment.py | 46 ++++++++++++++++++++++++++++++++--- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 5800d8a6..d6bf4f0f 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -3,7 +3,9 @@ # pylint: disable=R0903 from django import forms -from django.contrib import admin +from django.contrib import admin, messages + +from django.utils.translation import gettext_lazy as _ from .models import Product, Transaction @@ -18,6 +20,16 @@ class ProductAdmin(admin.ModelAdmin): admin.site.register(Product, ProductAdmin) +@admin.action(description=_("Rembourser la transaction")) +def reimburse_transactions(modeladmin, request, queryset): + """Reimburse all selected actions""" + for transaction in queryset: + (is_err, msg) = transaction.refund(request.user.username) + if is_err: + modeladmin.message_user(request, _("Erreur: %s") % msg, messages.ERROR) + break + + class TransactionAdmin(admin.ModelAdmin): """ Admin handler for Transactions @@ -44,6 +56,8 @@ class TransactionAdmin(admin.ModelAdmin): "amount", ] + actions = [reimburse_transactions] + def has_add_permission(self, request): """Remove the ability to add a transaction from the backoffice """ return False diff --git a/insalan/payment/hooks.py b/insalan/payment/hooks.py index 6a04a300..eb38065d 100644 --- a/insalan/payment/hooks.py +++ b/insalan/payment/hooks.py @@ -102,5 +102,15 @@ def payment_failure(_transaction, _product, _count): and that `.payment_status` on the transaction object is set to `FAILED`. """ + @staticmethod + def payment_refunded(_transaction, _product, _count): + """ + Payment Refund Handler + + This method handles the process of cleaning up after a refund of a + transaction. By this point, you can safely assume that the payment has + been refunded on the side of helloasso. + """ + # vim: set tw=80: diff --git a/insalan/payment/models.py b/insalan/payment/models.py index ea0c971a..5d05be40 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -1,10 +1,12 @@ import logging import itertools +import requests import uuid from decimal import Decimal from datetime import datetime +from os import getenv from django.db import models from django.utils.translation import gettext_lazy as _ @@ -14,6 +16,8 @@ from insalan.tournament.models import Tournament from insalan.user.models import User +from .tokens import Token + logger = logging.getLogger(__name__) @@ -23,6 +27,7 @@ class TransactionStatus(models.TextChoices): FAILED = "FAILED", _("échouée") SUCCEEDED = "SUCCEEDED", _("Réussie") PENDING = "PENDING", _("En attente") + REFUNDED = "REFUNDED", _("Remboursé") class ProductCategory(models.TextChoices): @@ -173,6 +178,45 @@ def run_success_hooks(self): """Run the success hooks on all products""" self.product_callback(lambda cls: cls.payment_success) + def run_refunded_hooks(self): + """Run the refund hooks on all products""" + self.product_callback(lambda cls: cls.payment_refunded) + + def refund(self, requester) -> tuple[bool, str]: + """Refund this transaction""" + if self.payment_status == TransactionStatus.REFUNDED: + return (False, "") + + helloasso_url = getenv("HELLOASS_ENDPOINT") + token = Token() + body_refund = {"comment": f"Refunded by {requester}"} + headers_refund = { + "authorization": "Bearer " + token.get_token(), + "Content-Type": "application/json", + } + + refund_init = requests.post( + f"{helloasso_url}/v5/payment/{self.intent_id}/refund", + data=body_refund, + headers=headers_refund, + timeout=1, + ) + + if refund_init.status_code != 200: + return ( + False, + _("Erreur de remboursement: code %s obtenu via l'API") + % refund_init.status_code, + ) + + self.payment_status = TransactionStatus.REFUNDED + + self.run_refunded_hooks() + self.touch() + self.save() + + return (False, "") + def synchronize_amount(self): """Recompute the amount from the product list""" self.amount = Decimal(0.00) diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 8bedc586..f5acacb6 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -143,6 +143,7 @@ def create(self, request): f"{HELLOASSO_URL}/v5/organizations/insalan-test/checkout-intents", data=json.dumps(intent_body), headers=headers, + timeout=1 ) # initiate a helloasso intent logger.debug(checkout_init.text) checkout_json = checkout_init.json() diff --git a/insalan/tournament/payment.py b/insalan/tournament/payment.py index 48d42a8e..c9206e3a 100644 --- a/insalan/tournament/payment.py +++ b/insalan/tournament/payment.py @@ -1,5 +1,7 @@ """Handling of the payment of a registration (player and manager)""" +import logging + from insalan.payment.models import ProductCategory from insalan.payment.hooks import PaymentHooks, PaymentCallbackSystem @@ -9,6 +11,9 @@ from insalan.tournament.models import Player, Manager, PaymentStatus +logger = logging.getLogger("insalan.tournament.hooks") + + class PaymentHandler(PaymentHooks): """Handler of the payment of a ticket/registration""" @@ -95,13 +100,46 @@ def payment_failure(transaction, product, _count): # Whatever happens, just delete the registration reg.delete() + @staticmethod + def payment_refunded(transaction, product, _count): + """Handle a refund of a registration""" + + # Find a registration that was ongoing for the user + assoc_tourney = product.associated_tournament + if assoc_tourney is None: + raise RuntimeError(_("Tournoi associé à un produit acheté nul!")) + + reg_list = Player.objects.filter( + user=transaction.payer, team__tournament=product.associated_tournament + ) + if len(reg_list) == 0: + reg_list = Manager.objects.filter( + user=transaction.payer, team__tournament=product.associated_tournament + ) + if len(reg_list) == 0: + logger.warn( + _("Aucune inscription à détruire trouvée pour le refund de %s") + % transaction.id + ) + return + + reg = reg_list[0] + team = reg.team + ticket = reg.ticket + reg.delete() + + team.refresh_validation() + + if ticket is not None: + ticket.status = Ticket.Status.CANCELLED + ticket.save() + + def payment_handler_register(): """Register the callbacks""" PaymentCallbackSystem.register_handler( - ProductCategory.REGISTRATION_PLAYER, PaymentHandler, - overwrite = True + ProductCategory.REGISTRATION_PLAYER, PaymentHandler, overwrite=True ) PaymentCallbackSystem.register_handler( - ProductCategory.REGISTRATION_MANAGER, PaymentHandler, - overwrite = True + ProductCategory.REGISTRATION_MANAGER, PaymentHandler, overwrite=True ) From 644d6e80a29ec9f1e8f923e221664bbd3a67a5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 13:57:37 +0200 Subject: [PATCH 44/62] Remove usage of `%` string formatting --- insalan/payment/admin.py | 5 +++-- insalan/payment/models.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index d6bf4f0f..8f93bede 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -26,14 +26,15 @@ def reimburse_transactions(modeladmin, request, queryset): for transaction in queryset: (is_err, msg) = transaction.refund(request.user.username) if is_err: - modeladmin.message_user(request, _("Erreur: %s") % msg, messages.ERROR) + modeladmin.message_user(request, _("Erreur: %s").format(msg), messages.ERROR) break class TransactionAdmin(admin.ModelAdmin): """ Admin handler for Transactions - In the backoffice, Transactions can only be seen, they cannot be add, removed or changed this way + In the backoffice, Transactions can only be seen, they cannot be add, + removed or changed this way """ list_display = ( diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 5d05be40..9431f450 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -136,15 +136,17 @@ def new(**data): if not pid.can_be_bought_now(): raise ValidationError( { - "error": _("Le produit %(id)s est actuellement indisponible") - % {"id": pid.id} + "error": _( + "Le produit %(id)s est actuellement indisponible" + ).format(id=pid.id) } ) if pid.associated_tournament and not pid.associated_tournament.is_announced: raise ValidationError( { - "error": _("Le tournoi %(id)s est actuellement indisponible") - % {"id": pid.associated_tournament.id} + "error": _( + "Le tournoi %(id)s est actuellement indisponible" + ).format(id=pid.associated_tournament.id) } ) count = len(list(grouper)) @@ -205,8 +207,9 @@ def refund(self, requester) -> tuple[bool, str]: if refund_init.status_code != 200: return ( False, - _("Erreur de remboursement: code %s obtenu via l'API") - % refund_init.status_code, + _("Erreur de remboursement: code %s obtenu via l'API").format( + refund_init.status_code + ), ) self.payment_status = TransactionStatus.REFUNDED From 447e2b8156ce01d199afa7795e63ebf6154241a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 14:05:50 +0200 Subject: [PATCH 45/62] Formatting and proper errors --- insalan/payment/models.py | 2 +- insalan/payment/serializers.py | 2 +- insalan/payment/views.py | 21 +++++++++++++++------ insalan/tournament/payment.py | 4 +++- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 9431f450..865bec2a 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -189,7 +189,7 @@ def refund(self, requester) -> tuple[bool, str]: if self.payment_status == TransactionStatus.REFUNDED: return (False, "") - helloasso_url = getenv("HELLOASS_ENDPOINT") + helloasso_url = getenv("HELLOASSO_ENDPOINT") token = Token() body_refund = {"comment": f"Refunded by {requester}"} headers_refund = { diff --git a/insalan/payment/serializers.py b/insalan/payment/serializers.py index 296304f6..6db50597 100644 --- a/insalan/payment/serializers.py +++ b/insalan/payment/serializers.py @@ -11,7 +11,7 @@ class TransactionSerializer(serializers.ModelSerializer): class Meta: model=Transaction fields = "__all__" - read_only_fields = ['amount', 'payer', 'payment_status', 'intent_id', 'creation_date', 'last_modification_date'] + read_only_fields = ["amount", "payer", "payment_status", "intent_id", "creation_date", "last_modification_date"] def create(self, validated_data): """ Create a transaction with products based on the request""" diff --git a/insalan/payment/views.py b/insalan/payment/views.py index f5acacb6..e0b986e6 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.http import JsonResponse, HttpResponseRedirect from django.shortcuts import render +from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from rest_framework import generics, permissions, status from rest_framework.views import APIView @@ -62,8 +63,10 @@ class CreateProduct(generics.CreateAPIView): class BackView(generics.ListAPIView): pass + class ReturnView(APIView): """View for the return""" + def get(self, request, **kwargs): trans_id = request.query_params.get("id") checkout_id = request.query_params.get("checkoutIntentId") @@ -72,7 +75,9 @@ def get(self, request, **kwargs): if None in [trans_id, checkout_id, code]: return Response(status=status.HTTP_400_BAD_REQUEST) - transaction_obj = Transaction.objects.filter(payment_status=TransactionStatus.PENDING, id=trans_id, intent_id=checkout_id) + transaction_obj = Transaction.objects.filter( + payment_status=TransactionStatus.PENDING, id=trans_id, intent_id=checkout_id + ) if len(transaction_obj) == 0: return Response(status=status.HTTP_403_FORBIDDEN) @@ -94,6 +99,7 @@ def get(self, request, **kwargs): return Response(status=status.HTTP_200_OK) + class ErrorView(generics.ListAPIView): pass @@ -124,9 +130,9 @@ def create(self, request): "totalAmount": helloasso_amount, "initialAmount": helloasso_amount, "itemName": str(transaction_obj.id), - "backUrl": f"{getenv('HELLOASSO_BACK_URL')}?id={transaction_obj.id}", - "errorUrl": f"{getenv('HELLOASSO_ERROR_URL')}?id={transaction_obj.id}", - "returnUrl": f"{getenv('HELLOASSO_RETURN_URL')}?id={transaction_obj.id}", + "backUrl": f"{getenv('HELLOASSO_BACK_URL')}", + "errorUrl": f"{getenv('HELLOASSO_ERROR_URL')}", + "returnUrl": f"{getenv('HELLOASSO_RETURN_URL')}", "containsDonation": False, "payer": { "firstName": payer.first_name, @@ -143,7 +149,7 @@ def create(self, request): f"{HELLOASSO_URL}/v5/organizations/insalan-test/checkout-intents", data=json.dumps(intent_body), headers=headers, - timeout=1 + timeout=1, ) # initiate a helloasso intent logger.debug(checkout_init.text) checkout_json = checkout_init.json() @@ -157,4 +163,7 @@ def create(self, request): transaction_obj.run_prepare_hooks() return HttpResponseRedirect(redirect_to=redirect_url) - return JsonResponse({"problem": "oui"}) + return JsonResponse( + {"err": _("Données de transaction invalides")}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/insalan/tournament/payment.py b/insalan/tournament/payment.py index c9206e3a..b7bf1396 100644 --- a/insalan/tournament/payment.py +++ b/insalan/tournament/payment.py @@ -41,7 +41,9 @@ def fetch_registration(tourney, user): raise RuntimeError(_("Plusieurs inscription manager à un même tournoi")) if len(reg) == 0: raise RuntimeError( - _(f"Pas d'inscription à valider au paiement pour {user}") + _("Pas d'inscription à valider au paiement pour %(user}s").format( + user=user.username + ) ) return (reg[0], True) From 7c24b9e018dad11af31f5b1aff42aad0f78eed5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 14:32:55 +0200 Subject: [PATCH 46/62] Improve Token class, add refresh on demand - Actually use the singleton object - Implement an expiration date - Check the expiration date when we get the token, refreshing it if need be - Add timeout parameters to the request calls, and propagate/log on errors if need be - Refactor some code --- insalan/payment/models.py | 2 +- insalan/payment/tokens.py | 81 ++++++++++++++++++++++++++++----------- insalan/payment/views.py | 2 +- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 865bec2a..f9819b3a 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -190,7 +190,7 @@ def refund(self, requester) -> tuple[bool, str]: return (False, "") helloasso_url = getenv("HELLOASSO_ENDPOINT") - token = Token() + token = Token.get_instance() body_refund = {"comment": f"Refunded by {requester}"} headers_refund = { "authorization": "Bearer " + token.get_token(), diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index 0a52b1e8..f624b3cb 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -1,10 +1,13 @@ """Module that helps retrieve a OAuth2 token from HelloAsso""" import logging +import time from os import getenv import requests +from django.utils.translation import gettext_lazy as _ + logger = logging.getLogger(__name__) @@ -23,33 +26,65 @@ def __init__(self): if Token.instance is None: Token.instance = self logger.debug(getenv("HELLOASSO_ENDPOINT")) - request = requests.post( - url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={ - "client_id": getenv("HELLOASSO_CLIENTID"), - "client_secret": getenv("HELLOASSO_CLIENT_SECRET"), - "grant_type": "client_credentials", - }, - ) - logger.debug(request.text) - self.bearer_token = request.json()["access_token"] - self.refresh_token = request.json()["refresh_token"] + + self.expiration_date = None + self.bearer_token = None + self.refresh_token = None + + self.obtain_token() + + @classmethod + def get_instance(cls): + """Get the potential singleinstance of the Token""" + if cls.instance is None: + token = cls() + return token + + return cls.instance + + def obtain_token(self, secret=None): + """ + Obtain a token, either from the original secret, or from the previous + refresh token + """ + c_secret = secret if secret is not None else getenv("HELLOASSO_CLIENT_SECRET") + try: + request = requests.post( + url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "client_id": getenv("CLIENT_ID"), + "client_secret": c_secret, + "grant_type": "refresh_token", + }, + timeout = 1, + ) + except requests.exceptions.RequestException as err: + logger.error("Unable to obtain token: %s", err) + # Clean everything + Token.instance = None + self.expiration_date = None + self.bearer_token = None + self.refresh_token = None + # Propagate errors + raise RuntimeError(_("Unable to refresh HelloAsso token")) from err + + self.assign_token_data(request.json()) + + def assign_token_data(self, data): + """Assign data from the json body""" + # Store our tokens, but also keep track of the refresh time + self.expiration_date = time.time() + int(data["expires_in"]) + self.bearer_token = data["access_token"] + self.refresh_token = data["refresh_token"] def get_token(self): """Return the singleton's token""" + # Should we refresh? + if time.time() >= self.expiration_date: + self.refresh() return self.bearer_token def refresh(self): """Refresh our HelloAsso token""" - request = requests.post( - url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={ - "client_id": getenv("CLIENT_ID"), - "client_secret": self.refresh_token, - "grant_type": "refresh_token", - }, - ) - self.bearer_token = request.json()["access_token"] - self.refresh_token = request.json()["refresh_token"] + self.obtain_token(secret=self.refresh_token) diff --git a/insalan/payment/views.py b/insalan/payment/views.py index e0b986e6..cc410b03 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -111,7 +111,7 @@ class PayView(generics.CreateAPIView): serializer_class = serializers.TransactionSerializer def create(self, request): - token = Token() + token = Token.get_instance() payer = request.user data = request.data.copy() data["payer"] = payer.id From 53a4f7e07ac14316cd5985e9c7b3527bde8862f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 15:39:39 +0200 Subject: [PATCH 47/62] Clean up HA vars: stop reading env all day long Transform all HelloAsso variables into constants in the settings of the whole project. Also adds organization slug, and normalizes variable names. --- insalan/payment/models.py | 16 ++++++++-------- insalan/payment/tokens.py | 11 +++++------ insalan/payment/views.py | 16 ++++++---------- insalan/settings.py | 20 ++++++++++++++++---- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index f9819b3a..7132b7f6 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -1,18 +1,19 @@ -import logging +"""Payment Models""" +from decimal import Decimal +from datetime import datetime import itertools - -import requests +import logging import uuid -from decimal import Decimal -from datetime import datetime -from os import getenv +import requests from django.db import models from django.utils.translation import gettext_lazy as _ from django.utils import timezone from rest_framework.serializers import ValidationError +import insalan.settings as app_settings + from insalan.tournament.models import Tournament from insalan.user.models import User @@ -189,7 +190,6 @@ def refund(self, requester) -> tuple[bool, str]: if self.payment_status == TransactionStatus.REFUNDED: return (False, "") - helloasso_url = getenv("HELLOASSO_ENDPOINT") token = Token.get_instance() body_refund = {"comment": f"Refunded by {requester}"} headers_refund = { @@ -198,7 +198,7 @@ def refund(self, requester) -> tuple[bool, str]: } refund_init = requests.post( - f"{helloasso_url}/v5/payment/{self.intent_id}/refund", + f"{app_settings.HA_URL}/v5/payment/{self.intent_id}/refund", data=body_refund, headers=headers_refund, timeout=1, diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index f624b3cb..4269c119 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -2,12 +2,12 @@ import logging import time -from os import getenv - import requests from django.utils.translation import gettext_lazy as _ +import insalan.settings as app_settings + logger = logging.getLogger(__name__) @@ -25,7 +25,6 @@ def __init__(self): """Initialize the Token retrieval instance""" if Token.instance is None: Token.instance = self - logger.debug(getenv("HELLOASSO_ENDPOINT")) self.expiration_date = None self.bearer_token = None @@ -47,13 +46,13 @@ def obtain_token(self, secret=None): Obtain a token, either from the original secret, or from the previous refresh token """ - c_secret = secret if secret is not None else getenv("HELLOASSO_CLIENT_SECRET") + c_secret = secret if secret is not None else app_settings.HA_OAUTH_CLIENT_SECRET try: request = requests.post( - url=f"{getenv('HELLOASSO_ENDPOINT')}/oauth2/token", + url=f"{app_settings.HA_URL}/oauth2/token", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ - "client_id": getenv("CLIENT_ID"), + "client_id": app_settings.HA_OAUTH_CLIENT_ID, "client_secret": c_secret, "grant_type": "refresh_token", }, diff --git a/insalan/payment/views.py b/insalan/payment/views.py index cc410b03..12c842fd 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -3,9 +3,6 @@ import json import logging -from datetime import date -from os import getenv - import requests from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned @@ -18,9 +15,10 @@ from rest_framework.response import Response from rest_framework.authentication import SessionAuthentication +import insalan.settings as app_settings import insalan.payment.serializers as serializers -from .models import Transaction, TransactionStatus, Product, ProductCount +from .models import Transaction, TransactionStatus, Product from .tokens import Token logger = logging.getLogger(__name__) @@ -115,7 +113,6 @@ def create(self, request): payer = request.user data = request.data.copy() data["payer"] = payer.id - logger.debug(f"data in view = {data}") # contient des données transaction = serializers.TransactionSerializer(data=data) transaction.is_valid() logger.debug(transaction.validated_data) @@ -125,14 +122,13 @@ def create(self, request): helloasso_amount = int( transaction_obj.amount * 100 ) # helloasso reads prices in cents - HELLOASSO_URL = getenv("HELLOASSO_ENDPOINT") intent_body = { "totalAmount": helloasso_amount, "initialAmount": helloasso_amount, "itemName": str(transaction_obj.id), - "backUrl": f"{getenv('HELLOASSO_BACK_URL')}", - "errorUrl": f"{getenv('HELLOASSO_ERROR_URL')}", - "returnUrl": f"{getenv('HELLOASSO_RETURN_URL')}", + "backUrl": app_settings.HA_BACK_URL, + "errorUrl": app_settings.HA_ERROR_URL, + "returnUrl": app_settings.HA_RETURN_URL, "containsDonation": False, "payer": { "firstName": payer.first_name, @@ -146,7 +142,7 @@ def create(self, request): } checkout_init = requests.post( - f"{HELLOASSO_URL}/v5/organizations/insalan-test/checkout-intents", + f"{app_settings.HA_URL}/v5/organizations/{app_settings.HA_ORG_SLUG}/checkout-intents", data=json.dumps(intent_body), headers=headers, timeout=1, diff --git a/insalan/settings.py b/insalan/settings.py index bf14dc4c..d305a212 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -57,10 +57,11 @@ } # Allow itself and the frontend +WEBSITE_HOST = getenv("WEBSITE_HOST", "localhost") ALLOWED_HOSTS = [ - "api." + getenv("WEBSITE_HOST", "localhost"), - getenv("WEBSITE_HOST", "localhost"), - "dev." + getenv("WEBSITE_HOST", "localhost"), + "api." + WEBSITE_HOST, + WEBSITE_HOST, + "dev." + WEBSITE_HOST, ] CSRF_TRUSTED_ORIGINS = [ @@ -226,6 +227,17 @@ EMAIL_HOST_PASSWORD = getenv("MAIL_PASS", "") EMAIL_HOST_USER = getenv("MAIL_FROM", "insalan@localhost") DEFAULT_FROM_EMAIL = getenv("MAIL_FROM", "email@localhost") -EMAIL_PORT = int(getenv("MAIL_PORT", 465)) +EMAIL_PORT = int(getenv("MAIL_PORT", "465")) EMAIL_USE_SSL = getenv("MAIL_SSL", "true").lower() in ["true", "1", "t", "y", "yes"] EMAIL_SUBJECT_PREFIX = "[InsaLan] " + +# Payment variables +HA_URL = f"https://{format(getenv('HELLOASSO_HOSTNAME', 'api.helloasso-sandbox.com'))}" +HA_ORG_SLUG = getenv("HELLOASSO_ORGANIZATION_SLUG", "insalan-test") +# View URLs +HA_RETURN_URL = getenv("HELLOASSO_RETURN_URL", f"https://api.{WEBSITE_HOST}/v1/payment/return/") +HA_ERROR_URL = getenv("HELLOASSO_ERROR_URL", f"https://api.{WEBSITE_HOST}/v1/payment/error/") +HA_BACK_URL = getenv("HELLOASSO_BACK_URL", f"https://api.{WEBSITE_HOST}/v1/payment/back/") +# OAuth Credentials +HA_OAUTH_CLIENT_SECRET = getenv("HELLOASSO_CLIENT_SECRET") +HA_OAUTH_CLIENT_ID = getenv("HELLOASSO_CLIENT_ID") From 2e0f90eabce6992aa13c6dfd2ce23d3227f6b380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 17:49:16 +0200 Subject: [PATCH 48/62] Display all fields in a serialized tournament --- insalan/tournament/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insalan/tournament/serializers.py b/insalan/tournament/serializers.py index e3fb7171..10e272f5 100644 --- a/insalan/tournament/serializers.py +++ b/insalan/tournament/serializers.py @@ -56,7 +56,7 @@ class Meta: model = Tournament read_only_fields = ("id","manager_price_onsite", "manager_price_onsite", "player_price_online", "player_price_onsite") - fields = ["id", "event", "game", "name", "teams", "logo"] + fields = "__all__" class TeamSerializer(serializers.ModelSerializer): From 07a7ea22077fd2546bcca1f78e251828548b0b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 18:11:50 +0200 Subject: [PATCH 49/62] Foundations of the notification system So far, it does nothing --- insalan/payment/models.py | 26 ++++++++++++++++++++++++++ insalan/payment/urls.py | 5 +++++ insalan/payment/views.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 7132b7f6..ae87fe0b 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -77,6 +77,32 @@ def can_be_bought_now(self) -> bool: return self.available_from <= timezone.now() <= self.available_until +class Payment(models.Model): + """ + A single installment of a payment made through HelloAsso + """ + + class Meta: + """Meta information""" + + verbose_name = _("Paiement") + verbose_name_plural = _("Paiements") + + id = models.IntegerField( + primary_key=True, editable=False, verbose_name=_("Identifiant du paiement") + ) + transaction = models.ForeignKey( + "Transaction", on_delete=models.CASCADE, verbose_name=_("Transaction") + ) + amount = models.DecimalField( + blank=False, + null=False, + decimal_places=2, + max_digits=6, + verbose_name=_("Montant"), + ) + + class Transaction(models.Model): """ A transaction is a record from helloasso intent. diff --git a/insalan/payment/urls.py b/insalan/payment/urls.py index 98c52112..d03597b2 100644 --- a/insalan/payment/urls.py +++ b/insalan/payment/urls.py @@ -5,12 +5,17 @@ app_name = "payment" urlpatterns = [ + # Flow URLs path("pay/", views.PayView.as_view(), name="pay"), + path("notifications/", views.Notifications.as_view(), name="notifications"), + + # REST URLs path("product/", views.ProductList.as_view(), name="list-product"), path("product//", views.ProductDetails.as_view(), name="product-details"), path("product/new/", views.CreateProduct.as_view(), name="new-product"), path("transaction/", views.TransactionList.as_view(), name="transactions"), path("transaction/", views.TransactionPerId.as_view(), name="transactions/id"), + # User View Pages path("back/", views.BackView.as_view(), name="transaction/back"), path("return/", views.ReturnView.as_view(), name="transaction/return"), path("error/", views.ErrorView.as_view(), name="transaction/error"), diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 12c842fd..804a7862 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -58,6 +58,41 @@ class CreateProduct(generics.CreateAPIView): permission_classes = [permissions.IsAdminUser] +class Notifications(APIView): + """ + Notifications view + """ + def post(self, request): + data = request.data + if not data.get("metadata") or not data["metadata"].get("uuid"): + return Response(status=status.HTTP_400_BAD_REQUEST) + + uuid = data["metadata"]["uuid"] + trans_obj = Transaction.objects.get(id=uuid) + if trans_obj is None: + return Response(status=status.HTTP_400_BAD_REQUEST) + + ntype = data["eventType"] + data = data["data"] + + logger.warn("NTYPE: %s", ntype) + logger.warn("DATA: %s", data) + + if ntype == "Order": + # Check that the order is still unfinished + pass + elif ntype == "Payment": + # Check how the payments are going, this should signal a completed + # or cancelled/refunded payment + pass + elif ntype == "Form": + # Those notifications are mostly useless, it's about changes to the + # org + pass + + return Response(status=status.HTTP_200_OK) + + class BackView(generics.ListAPIView): pass @@ -135,6 +170,9 @@ def create(self, request): "lastName": payer.last_name, "email": payer.email, }, + "metadata": { + "uuid": str(transaction_obj.id), + } } headers = { "authorization": "Bearer " + token.get_token(), From 2539fa33dded7af11203aa469ec9fbaface0816e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 19:14:19 +0200 Subject: [PATCH 50/62] Fix #74 by matching external port to protocol --- insalan/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/insalan/settings.py b/insalan/settings.py index d305a212..a57f77cd 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -33,14 +33,14 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(getenv("DEV", 0)) == 1 +PROTOCOL = getenv("HTTP_PROTOCOL", "http") + OUTSIDE_PORT = getenv("NGINX_PORT", "80") -if OUTSIDE_PORT == "80": +if (OUTSIDE_PORT == "80" and PROTOCOL == "http") or (OUTSIDE_PORT == "443" and PROTOCOL == "https"): OUTSIDE_PORT = "" # Don't specify it else: OUTSIDE_PORT = f":{OUTSIDE_PORT}" -PROTOCOL = getenv("HTTP_PROTOCOL", "http") - # LOGGING Setup LOGGING = { "version":1, From 4c4ac8010afb85406e9a8b65e7c14c0317ac6451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 22:30:54 +0200 Subject: [PATCH 51/62] Fix tests and data leak in event deref route - Fix tests (I hate timezones) - Fix a leak of data that resulted in all tournament information being visible in the `event//tournaments/` dereferencement route, which did not take into account the `is_announced` key --- insalan/tournament/tests.py | 202 ++++++++++++++++++++++++------------ insalan/tournament/views.py | 7 +- 2 files changed, 143 insertions(+), 66 deletions(-) diff --git a/insalan/tournament/tests.py b/insalan/tournament/tests.py index 8b78b70b..e82e2291 100644 --- a/insalan/tournament/tests.py +++ b/insalan/tournament/tests.py @@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, TransactionTestCase +from django.utils import timezone from django.urls import reverse from insalan.tournament.models import ( @@ -485,20 +486,18 @@ def test_rules_size_limit(self): tourney.full_clean() def test_product_creation(self): - event_one = Event.objects.create( name="Insalan Test One", year=2023, month=2, description="" ) game = Game.objects.create(name="Fortnite") - trnm_one = Tournament.objects.create(event=event_one, game=game, - player_price_online=23.3, - manager_price_online=3) - self.assertEqual(trnm_one.player_price_online,23.3) - - self.assertEqual(trnm_one.manager_price_online,3) + trnm_one = Tournament.objects.create( + event=event_one, game=game, player_price_online=23.3, manager_price_online=3 + ) + self.assertEqual(trnm_one.player_price_online, 23.3) + self.assertEqual(trnm_one.manager_price_online, 3) class TeamTestCase(TestCase): @@ -932,11 +931,14 @@ def test_example(self): description="This is a test", year=2021, month=12, - ongoing=False + ongoing=False, ) tourneyobj_one = Tournament.objects.create( - event=evobj, name="Test Tournament", rules="have fun!", game=game_obj, - is_announced=True + event=evobj, + name="Test Tournament", + rules="have fun!", + game=game_obj, + is_announced=True, ) team_one = Team.objects.create(name="Team One", tournament=tourneyobj_one) Player.objects.create(user=uobj_one, team=team_one) @@ -947,37 +949,52 @@ def test_example(self): reverse("tournament/details-full", args=[tourneyobj_one.id]), format="json" ) self.assertEqual(request.status_code, 200) - self.assertEqual( - request.data, - { - "id": tourneyobj_one.id, - "event": { - "id": evobj.id, - "name": "Test Event", - "description": "This is a test", - "year": 2021, - "month": 12, - "ongoing": False, - "logo": None, - }, - "game": {"id": game_obj.id, "name": "Test Game", "short_name": "TFG"}, - "name": "Test Tournament", - "teams": [ - { - "id": team_one.id, - "name": "Team One", - "players": [ - "test_user_one", - "test_user_two", - ], - "managers": [ - "test_user_three", - ], - } - ], + model = { + "id": tourneyobj_one.id, + "event": { + "id": evobj.id, + "name": "Test Event", + "description": "This is a test", + "year": 2021, + "month": 12, + "ongoing": False, "logo": None, }, - ) + "game": {"id": game_obj.id, "name": "Test Game", "short_name": "TFG"}, + "name": "Test Tournament", + "rules": "have fun!", + "is_announced": True, + # I don't know what's happenin with timezones here + "registration_open": timezone.make_aware( + timezone.make_naive(tourneyobj_one.registration_open) + ).isoformat(), + "registration_close": timezone.make_aware( + timezone.make_naive(tourneyobj_one.registration_close) + ).isoformat(), + "player_price_online": "0.00", + "player_price_onsite": "0.00", + "manager_price_online": "0.00", + "manager_price_onsite": "0.00", + "cashprizes": [], + "player_online_product": tourneyobj_one.player_online_product.id, + "manager_online_product": tourneyobj_one.manager_online_product.id, + "teams": [ + { + "id": team_one.id, + "name": "Team One", + "players": [ + "test_user_one", + "test_user_two", + ], + "managers": [ + "test_user_three", + ], + } + ], + "logo": None, + } + + self.assertEqual(request.data, model) def test_not_announced(self): """Test a simple example""" @@ -998,11 +1015,14 @@ def test_not_announced(self): description="This is a test", year=2021, month=12, - ongoing=False + ongoing=False, ) tourneyobj_one = Tournament.objects.create( - event=evobj, name="Test Tournament", rules="have fun!", game=game_obj, - is_announced=False + event=evobj, + name="Test Tournament", + rules="have fun!", + game=game_obj, + is_announced=False, ) team_one = Team.objects.create(name="Team One", tournament=tourneyobj_one) Player.objects.create(user=uobj_one, team=team_one) @@ -1017,9 +1037,10 @@ def test_not_announced(self): request.data, { "id": tourneyobj_one.id, - } + }, ) + class EventDerefAndGroupingEndpoints(TestCase): """Test endpoints for dereferencing/fetching grouped events""" @@ -1076,12 +1097,16 @@ def test_deref_not_found(self): self.assertEqual(request.status_code, 404) - def test_deref(self): + def test_deref_not_announced(self): """Test a simple example of a dereference""" evobj = Event.objects.create(name="Test", year=2023, month=3, ongoing=True) gobj = Game.objects.create(name="Test Game", short_name="TG") tourney = Tournament.objects.create( - name="Test Tournament", game=gobj, event=evobj, rules="have fun!" + name="Test Tournament", + game=gobj, + event=evobj, + rules="have fun!", + is_announced=False, ) request = self.client.get( @@ -1090,28 +1115,75 @@ def test_deref(self): self.assertEqual(request.status_code, 200) - self.assertEqual( - request.data, - { - "id": evobj.id, - "name": "Test", - "description": "", - "year": 2023, - "month": 3, - "ongoing": True, - "logo": None, - "tournaments": [ - { - "id": tourney.id, - "name": "Test Tournament", - "game": gobj.id, - "teams": [], - "logo": None, - } - ], - }, + model = { + "id": evobj.id, + "name": "Test", + "description": "", + "year": 2023, + "month": 3, + "ongoing": True, + "tournaments": [ + { + "id": tourney.id, + } + ], + "logo": None, + } + self.assertEqual(request.data, model) + + def test_deref(self): + """Test a simple example of a dereference""" + evobj = Event.objects.create(name="Test", year=2023, month=3, ongoing=True) + gobj = Game.objects.create(name="Test Game", short_name="TG") + tourney = Tournament.objects.create( + name="Test Tournament", + game=gobj, + event=evobj, + rules="have fun!", + is_announced=True, ) + request = self.client.get( + f"/v1/tournament/event/{evobj.id}/tournaments", format="json" + ) + + self.assertEqual(request.status_code, 200) + + model = { + "id": evobj.id, + "name": "Test", + "description": "", + "year": 2023, + "month": 3, + "ongoing": True, + "tournaments": [ + { + "id": tourney.id, + "teams": [], + "name": "Test Tournament", + "is_announced": True, + "rules": "have fun!", + "registration_open": timezone.make_aware( + timezone.make_naive(tourney.registration_open) + ).isoformat(), + "registration_close": timezone.make_aware( + timezone.make_naive(tourney.registration_close) + ).isoformat(), + "logo": None, + "player_price_online": "0.00", + "player_price_onsite": "0.00", + "manager_price_online": "0.00", + "manager_price_onsite": "0.00", + "cashprizes": [], + "game": gobj.id, + "manager_online_product": tourney.manager_online_product.id, + "player_online_product": tourney.player_online_product.id, + } + ], + "logo": None, + } + self.assertEqual(request.data, model) + # Manager Class Tests class ManagerTestCase(TestCase): diff --git a/insalan/tournament/views.py b/insalan/tournament/views.py index 632847df..76e1b752 100644 --- a/insalan/tournament/views.py +++ b/insalan/tournament/views.py @@ -81,6 +81,11 @@ def get(self, request, primary_key: int): for tourney in event_serialized["tournaments"]: del tourney["event"] + event_serialized["tournaments"] = [ + data if data["is_announced"] else {"id": data["id"]} + for data in event_serialized["tournaments"] + ] + return Response(event_serialized, status=status.HTTP_200_OK) @@ -144,7 +149,7 @@ def get(self, request, primary_key: int): if len(tourneys) > 1: return Response("", status=status.HTTP_400_BAD_REQUEST) tourney = tourneys[0] - #if the tournament hasn't been yet announced, we don't want to return details of it + # if the tournament hasn't been yet announced, we don't want to return details of it if not tourney.is_announced: return Response({"id": primary_key}, status=status.HTTP_200_OK) tourney_serialized = serializers.TournamentSerializer( From ba5ff57560edbe14ef2d62472bc2abddb27b6609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 22:53:13 +0200 Subject: [PATCH 52/62] Payment return routes moved to the back --- insalan/payment/urls.py | 11 ++++------- insalan/payment/views.py | 19 +++++-------------- insalan/settings.py | 8 ++++---- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/insalan/payment/urls.py b/insalan/payment/urls.py index d03597b2..0887d445 100644 --- a/insalan/payment/urls.py +++ b/insalan/payment/urls.py @@ -8,15 +8,12 @@ # Flow URLs path("pay/", views.PayView.as_view(), name="pay"), path("notifications/", views.Notifications.as_view(), name="notifications"), - # REST URLs path("product/", views.ProductList.as_view(), name="list-product"), path("product//", views.ProductDetails.as_view(), name="product-details"), path("product/new/", views.CreateProduct.as_view(), name="new-product"), path("transaction/", views.TransactionList.as_view(), name="transactions"), - path("transaction/", views.TransactionPerId.as_view(), name="transactions/id"), - # User View Pages - path("back/", views.BackView.as_view(), name="transaction/back"), - path("return/", views.ReturnView.as_view(), name="transaction/return"), - path("error/", views.ErrorView.as_view(), name="transaction/error"), - ] + path( + "transaction/", views.TransactionPerId.as_view(), name="transactions/id" + ), +] diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 804a7862..2c2eeaa4 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -62,6 +62,7 @@ class Notifications(APIView): """ Notifications view """ + def post(self, request): data = request.data if not data.get("metadata") or not data["metadata"].get("uuid"): @@ -92,15 +93,9 @@ def post(self, request): return Response(status=status.HTTP_200_OK) - -class BackView(generics.ListAPIView): - pass - - -class ReturnView(APIView): - """View for the return""" - - def get(self, request, **kwargs): + # This is voluntarily unreachable code!!! + # It was moved from a removed view pending reverse engineering of the + # API trans_id = request.query_params.get("id") checkout_id = request.query_params.get("checkoutIntentId") code = request.query_params.get("code") @@ -133,10 +128,6 @@ def get(self, request, **kwargs): return Response(status=status.HTTP_200_OK) -class ErrorView(generics.ListAPIView): - pass - - class PayView(generics.CreateAPIView): permission_classes = [permissions.IsAuthenticated] authentication_classes = [SessionAuthentication] @@ -172,7 +163,7 @@ def create(self, request): }, "metadata": { "uuid": str(transaction_obj.id), - } + }, } headers = { "authorization": "Bearer " + token.get_token(), diff --git a/insalan/settings.py b/insalan/settings.py index a57f77cd..08d48cff 100644 --- a/insalan/settings.py +++ b/insalan/settings.py @@ -234,10 +234,10 @@ # Payment variables HA_URL = f"https://{format(getenv('HELLOASSO_HOSTNAME', 'api.helloasso-sandbox.com'))}" HA_ORG_SLUG = getenv("HELLOASSO_ORGANIZATION_SLUG", "insalan-test") -# View URLs -HA_RETURN_URL = getenv("HELLOASSO_RETURN_URL", f"https://api.{WEBSITE_HOST}/v1/payment/return/") -HA_ERROR_URL = getenv("HELLOASSO_ERROR_URL", f"https://api.{WEBSITE_HOST}/v1/payment/error/") -HA_BACK_URL = getenv("HELLOASSO_BACK_URL", f"https://api.{WEBSITE_HOST}/v1/payment/back/") +# View URLs (fall back on the front page if needed) +HA_RETURN_URL = getenv("HELLOASSO_RETURN_URL", f"https://{WEBSITE_HOST}/") +HA_ERROR_URL = getenv("HELLOASSO_ERROR_URL", f"https://{WEBSITE_HOST}/") +HA_BACK_URL = getenv("HELLOASSO_BACK_URL", f"https://{WEBSITE_HOST}/") # OAuth Credentials HA_OAUTH_CLIENT_SECRET = getenv("HELLOASSO_CLIENT_SECRET") HA_OAUTH_CLIENT_ID = getenv("HELLOASSO_CLIENT_ID") From e58831e4f3baa908743fac63bfc7ba55f8adc68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 02:13:57 +0200 Subject: [PATCH 53/62] Refactor tournament payment hooks, add prepare - Fix ticket creation missing a mandatory key (tournament) - Fix bug where despite buying a player ticket, you could end up validating a manager one - Change fetch_registration method to determine your type of registration from your product, not from the fact that a registration is available - Introduct the preparation hook to detect if a single registration of the correct type is ready to be paid before payment is initiated --- insalan/tournament/payment.py | 105 +++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 40 deletions(-) diff --git a/insalan/tournament/payment.py b/insalan/tournament/payment.py index b7bf1396..512888db 100644 --- a/insalan/tournament/payment.py +++ b/insalan/tournament/payment.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ +from insalan.payment.models import ProductCategory from insalan.tickets.models import Ticket from insalan.tournament.models import Player, Manager, PaymentStatus @@ -18,45 +19,69 @@ class PaymentHandler(PaymentHooks): """Handler of the payment of a ticket/registration""" @staticmethod - def fetch_registration(tourney, user): + def fetch_registration(product, user): """ - Fetch a registration for a user in a tournament. + Fetch a registration for a user and product. - Returns a tuple (reg, is_manager), which is pretty explicit. + Returns a tuple (reg, is_manager), which is pretty explicit. Raises + RuntimeError otherwise. """ + # Is there even a tournament? + tourney = product.associated_tournament + if tourney is None: + raise RuntimeError(_("Aucun tournoi associé")) + + is_manager = product.category == ProductCategory.REGISTRATION_MANAGER # Find a registration on that user within the tournament - # Could they be player? - reg = Player.objects.filter( - team__tournament=tourney, user=user, payment_status=PaymentStatus.NOT_PAID - ) - if len(reg) > 1: - raise RuntimeError(_("Plusieurs inscription joueur⋅euse à un même tournoi")) - if len(reg) == 1: - return (reg[0], False) - reg = Manager.objects.filter( - team__tournament=tourney, user=user, payment_status=PaymentStatus.NOT_PAID - ) - if len(reg) > 1: - raise RuntimeError(_("Plusieurs inscription manager à un même tournoi")) - if len(reg) == 0: - raise RuntimeError( - _("Pas d'inscription à valider au paiement pour %(user}s").format( - user=user.username + if is_manager: + reg = Manager.objects.filter( + team__tournament=tourney, + user=user, + payment_status=PaymentStatus.NOT_PAID, + ) + if len(reg) > 1: + raise RuntimeError(_("Plusieurs inscription manager à un même tournoi")) + if len(reg) == 0: + raise RuntimeError( + _("Pas d'inscription à valider au paiement pour %(user)s").format( + user=user.username + ) ) + return (reg[0], True) + + else: + reg = Player.objects.filter( + team__tournament=tourney, + user=user, + payment_status=PaymentStatus.NOT_PAID, ) - return (reg[0], True) + if len(reg) > 1: + raise RuntimeError( + _("Plusieurs inscription joueur⋅euse à un même tournoi") + ) + if len(reg) == 0: + raise RuntimeError(_("Aucune inscription joueur⋅euse trouvée")) + return (reg[0], False) + + @staticmethod + def prepare_transaction(transaction, product, _count) -> bool: + """See if you can actually buy this""" + + user_obj = transaction.payer + try: + (reg, _) = PaymentHandler.fetch_registration(product, user_obj) + except RuntimeError: + # Not gonna work out + return False + return True @staticmethod def payment_success(transaction, product, _count): """Handle success of the registration""" - assoc_tourney = product.associated_tournament - if assoc_tourney is None: - raise RuntimeError(_("Tournoi associé à un produit acheté nul!")) - user_obj = transaction.payer - (reg, is_manager) = PaymentHandler.fetch_registration(assoc_tourney, user_obj) + (reg, is_manager) = PaymentHandler.fetch_registration(product, user_obj) if is_manager: PaymentHandler.handle_player_reg(reg) @@ -69,7 +94,7 @@ def handle_player_reg(reg: Player): Handle validation of a Player registration """ reg.payment_status = PaymentStatus.PAID - tick = Ticket.objects.create(user=reg.user) + tick = Ticket.objects.create(user=reg.user, tournament=reg.team.tournament) tick.save() reg.ticket = tick @@ -81,7 +106,7 @@ def handle_manager_reg(reg: Manager): Handle validation of a Manager registration """ reg.payment_status = PaymentStatus.PAID - tick = Ticket.objects.create(user=reg.user) + tick = Ticket.objects.create(user=reg.user, tournament=reg.team.tournament) tick.save() reg.ticket = tick @@ -91,13 +116,8 @@ def handle_manager_reg(reg: Manager): def payment_failure(transaction, product, _count): """Handle the failure of a registration""" - # Find a registration that was ongoing for the user - assoc_tourney = product.associated_tournament - if assoc_tourney is None: - raise RuntimeError(_("Tournoi associé à un produit acheté nul!")) - user_obj = transaction.payer - (reg, _is_manager) = PaymentHandler.fetch_registration(assoc_tourney, user_obj) + (reg, _is_manager) = PaymentHandler.fetch_registration(product, user_obj) # Whatever happens, just delete the registration reg.delete() @@ -111,17 +131,22 @@ def payment_refunded(transaction, product, _count): if assoc_tourney is None: raise RuntimeError(_("Tournoi associé à un produit acheté nul!")) - reg_list = Player.objects.filter( - user=transaction.payer, team__tournament=product.associated_tournament - ) - if len(reg_list) == 0: + is_manager = product.category == ProductCategory.REGISTRATION_MANAGER + if is_manager: reg_list = Manager.objects.filter( + user=transaction.payer, team__tournament=assoc_tourney + ) + + else: + reg_list = Player.objects.filter( user=transaction.payer, team__tournament=product.associated_tournament ) + if len(reg_list) == 0: logger.warn( - _("Aucune inscription à détruire trouvée pour le refund de %s") - % transaction.id + _("Aucune inscription à détruire trouvée pour le refund de %s").format( + transaction.id + ) ) return From 8b3de701ebec4cf79c4fcb217486bdd19cff846b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 02:15:55 +0200 Subject: [PATCH 54/62] Payment admin: add Payment admin view Similar to Transactions, Payments cannot be deleted or manipulated, only viewed. --- insalan/payment/admin.py | 47 +++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 8f93bede..1c0578cc 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -1,3 +1,4 @@ +"""Payment Admin Panel Code""" # Disable lints: # "Too few public methods" # pylint: disable=R0903 @@ -7,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ -from .models import Product, Transaction +from .models import Product, Transaction, Payment class ProductAdmin(admin.ModelAdmin): @@ -24,12 +25,37 @@ class ProductAdmin(admin.ModelAdmin): def reimburse_transactions(modeladmin, request, queryset): """Reimburse all selected actions""" for transaction in queryset: - (is_err, msg) = transaction.refund(request.user.username) + (is_err, msg) = transaction.refund_transaction(request.user.username) if is_err: - modeladmin.message_user(request, _("Erreur: %s").format(msg), messages.ERROR) + modeladmin.message_user( + request, _("Erreur: %(msg)s") % {"msg": msg}, messages.ERROR + ) break +class PaymentAdmin(admin.ModelAdmin): + """ + Admin handler for payments" + """ + + list_display = ("id", "amount", "transaction") + + def has_add_permission(self, _request): + """Remove the ability to add a payment from the backoffice""" + return False + + def has_change_permission(self, _request, _obj=None): + """Remove the ability to edit a payment from the backoffice""" + return False + + def has_delete_permission(self, _request, _obj=None): + """Remove the ability to edit a payment from the backoffice""" + return False + + +admin.site.register(Payment, PaymentAdmin) + + class TransactionAdmin(admin.ModelAdmin): """ Admin handler for Transactions @@ -59,16 +85,17 @@ class TransactionAdmin(admin.ModelAdmin): actions = [reimburse_transactions] - def has_add_permission(self, request): - """Remove the ability to add a transaction from the backoffice """ + def has_add_permission(self, _request): + """Remove the ability to add a transaction from the backoffice""" return False - - def has_change_permission(self, request, obj=None): - """ Remove the ability to edit a transaction from the backoffice """ + + def has_change_permission(self, _request, _obj=None): + """Remove the ability to edit a transaction from the backoffice""" return False - def has_delete_permission(self, request, obj=None): - """ Remove the ability to edit a transaction from the backoffice """ + def has_delete_permission(self, _request, _obj=None): + """Remove the ability to edit a transaction from the backoffice""" return False + admin.site.register(Transaction, TransactionAdmin) From 76c8bfb31bb213d3b4402416f99f97279cde31f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 02:17:01 +0200 Subject: [PATCH 55/62] Introduct order_id into Transaction Register the order_id (which is not the same as the intent id) into a Transaction, also displaying it in the admin view --- insalan/payment/admin.py | 2 ++ insalan/payment/models.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 1c0578cc..1a9a3ae3 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -69,6 +69,7 @@ class TransactionAdmin(admin.ModelAdmin): "payment_status", "creation_date", "intent_id", + "order_id", "last_modification_date", "amount", ) @@ -79,6 +80,7 @@ class TransactionAdmin(admin.ModelAdmin): "payment_status", "creation_date", "intent_id", + "order_id", "last_modification_date", "amount", ] diff --git a/insalan/payment/models.py b/insalan/payment/models.py index ae87fe0b..6f801d1a 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -138,7 +138,12 @@ class Meta: blank=False, null=True, editable=False, - verbose_name=_("Identifiant de paiement"), + verbose_name=_("Identifiant du formulaire de paiement"), + ) + order_id = models.IntegerField( + null=True, + editable=False, + verbose_name=_("Identifiant de commande"), ) amount = models.DecimalField( null=False, From 419b296d10e5877557ab6c9c4b5a29e69c26eb3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 02:17:35 +0200 Subject: [PATCH 56/62] Fix initial HA token retrieval using wrong grant The grant_type used to retrieve an OAuth token is different whether or not you are using client credentials (first login), or refreshing a token (refreshes). Without this patch, initial obtention of tokens is impossible. --- insalan/payment/tokens.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/insalan/payment/tokens.py b/insalan/payment/tokens.py index 4269c119..b630eb64 100644 --- a/insalan/payment/tokens.py +++ b/insalan/payment/tokens.py @@ -47,6 +47,11 @@ def obtain_token(self, secret=None): refresh token """ c_secret = secret if secret is not None else app_settings.HA_OAUTH_CLIENT_SECRET + grant_type = ( + "client_credentials" + if c_secret == app_settings.HA_OAUTH_CLIENT_SECRET + else "refresh_token" + ) try: request = requests.post( url=f"{app_settings.HA_URL}/oauth2/token", @@ -54,9 +59,9 @@ def obtain_token(self, secret=None): data={ "client_id": app_settings.HA_OAUTH_CLIENT_ID, "client_secret": c_secret, - "grant_type": "refresh_token", + "grant_type": grant_type, }, - timeout = 1, + timeout=1, ) except requests.exceptions.RequestException as err: logger.error("Unable to obtain token: %s", err) From ad23c6972bf96e093d6762db601ffe2575ba1f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 02:20:18 +0200 Subject: [PATCH 57/62] Implement proper use of prepare_hook + redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properly redirect the user… by not redirecting them. Redirects in Django only really work within the same domain, so instead we return the URL in a JSON reply, and will let the frontend do its magic. At the same time, we move the call to the prepare_hook after creation of the transaction object (for validation purposes), but before the intent creation on HelloAsso's side. If the hooks return `False`, then something is wrong, and we cannot proceed with payment, needing instead to delete the Transaction. --- insalan/payment/hooks.py | 8 +++++--- insalan/payment/models.py | 23 ++++++++++++++++++++--- insalan/payment/views.py | 22 +++++++++++++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/insalan/payment/hooks.py b/insalan/payment/hooks.py index eb38065d..2d8d3931 100644 --- a/insalan/payment/hooks.py +++ b/insalan/payment/hooks.py @@ -45,13 +45,14 @@ def register_handler(cls, prodcat, handler, overwrite=False): if cls.__HOOKS.get(prodcat): if overwrite: - cls.__logger.warning("Overwriting handler for product category %s", prodcat) + cls.__logger.warning( + "Overwriting handler for product category %s", prodcat + ) else: raise ValueError(_(f"Descripteur déjà défini pour {prodcat}")) cls.__HOOKS[prodcat] = handler - @classmethod def retrieve_handler(cls, prodcat): """ @@ -61,6 +62,7 @@ def retrieve_handler(cls, prodcat): """ return cls.__HOOKS.get(prodcat) + # Base class/interface class PaymentHooks: """ @@ -71,7 +73,7 @@ class PaymentHooks: """ @staticmethod - def prepare_transaction(_transaction, _product, _count): + def prepare_transaction(_transaction, _product, _count) -> bool: """ Prepare things that may have to be created prior to payment diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 6f801d1a..53566759 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -204,9 +204,22 @@ def product_callback(self, key): # Call callback class key(cls)(self, proccount.product, proccount.count) - def run_prepare_hooks(self): + def run_prepare_hooks(self) -> bool: """Run the preparation hook on all products""" - self.product_callback(lambda cls: cls.prepare_transaction) + from insalan.payment.hooks import PaymentCallbackSystem + + for proccount in ProductCount.objects.filter(transaction=self): + # Get callback class + cls = PaymentCallbackSystem.retrieve_handler(proccount.product.category) + if cls is None: + logger.warning("No handler found for payment of %s", proccount.product) + raise RuntimeError(_("Pas de handler trouvé pour un paiement")) + # Call callback class + res = cls.prepare_transaction(self, proccount.product, proccount.count) + if not res: + return False + + return True def run_success_hooks(self): """Run the success hooks on all products""" @@ -216,7 +229,11 @@ def run_refunded_hooks(self): """Run the refund hooks on all products""" self.product_callback(lambda cls: cls.payment_refunded) - def refund(self, requester) -> tuple[bool, str]: + def run_failure_hooks(self): + """Run the failure hooks on all products""" + self.product_callback(lambda cls: cls.payment_failure) + + def refund_transaction(self, requester) -> tuple[bool, str]: """Refund this transaction""" if self.payment_status == TransactionStatus.REFUNDED: return (False, "") diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 2c2eeaa4..beb06b4f 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -144,6 +144,20 @@ def create(self, request): logger.debug(transaction.validated_data) if transaction.is_valid(raise_exception=True): transaction_obj = transaction.save() + + # Execute hooks + go_ahead = transaction_obj.run_prepare_hooks() + if not go_ahead: + transaction_obj.fail_transaction() + logger.error( + "Failed pre-condition on payment %s. Deleting.", transaction_obj.id + ) + transaction_obj.delete() + return JsonResponse( + {"err": _("Préconditions de paiement non remplies")}, + status=status.HTTP_400_BAD_REQUEST, + ) + # helloasso intent helloasso_amount = int( transaction_obj.amount * 100 @@ -184,10 +198,12 @@ def create(self, request): transaction_obj.save() logger.debug(intent_body) - # Execute hooks - transaction_obj.run_prepare_hooks() + logger.info("Redirectory payment to %s", redirect_url) - return HttpResponseRedirect(redirect_to=redirect_url) + return JsonResponse( + {"success": True, "redirect_url": redirect_url}, + status=status.HTTP_200_OK, + ) return JsonResponse( {"err": _("Données de transaction invalides")}, status=status.HTTP_400_BAD_REQUEST, From e0120a00a4bd125ccfd36699dba987774ac9d6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 02:24:10 +0200 Subject: [PATCH 58/62] Implement the notification system + refund Implement the notification channel that receives and handles two kinds of messages: - Order: those messages give us the layout of payments (typically here for us only one of them) and order number associated with our transaction, which we can add and update into the database - Payment: those messages let us known the status of a payment, which can trigger validation or invalidation of a transaction (invalidation has not been tested yet, as notifications only come when a payment succeeds) Properly implement refunding by iterating over defined payments for a transaction and refunding them individually. Note that that part has not been entirely tested yet, because our credentials do not allow us to refund yet. --- insalan/payment/models.py | 54 +++++++++++++------- insalan/payment/views.py | 104 +++++++++++++++++++++++++------------- 2 files changed, 104 insertions(+), 54 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 53566759..7473af17 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -235,34 +235,52 @@ def run_failure_hooks(self): def refund_transaction(self, requester) -> tuple[bool, str]: """Refund this transaction""" - if self.payment_status == TransactionStatus.REFUNDED: - return (False, "") + if self.payment_status != TransactionStatus.SUCCEEDED: + return (True, _("Transaction %(id)s en état invalide") % {"id": self.id}) token = Token.get_instance() - body_refund = {"comment": f"Refunded by {requester}"} + body_refund = { + "comment": f"Refunded by {requester}", + "sendRefundEmail": True, + "cancelOrder": True, + } headers_refund = { "authorization": "Bearer " + token.get_token(), "Content-Type": "application/json", } - refund_init = requests.post( - f"{app_settings.HA_URL}/v5/payment/{self.intent_id}/refund", - data=body_refund, - headers=headers_refund, - timeout=1, - ) - - if refund_init.status_code != 200: - return ( - False, - _("Erreur de remboursement: code %s obtenu via l'API").format( - refund_init.status_code - ), + refunded_amount = Decimal("0.0") + for pay_obj in Payment.objects.filter(transaction=self): + refund_init = requests.post( + f"{app_settings.HA_URL}/v5/payments/{pay_obj.id}/refund/", + data=body_refund, + headers=headers_refund, + timeout=1, ) - self.payment_status = TransactionStatus.REFUNDED + if refund_init.status_code != 200: + return ( + True, + _("Erreur de remboursement: code %(code)s obtenu via l'API") + % { + "code": refund_init.status_code, + }, + ) + refunded_amount += pay_obj.amount + pay_obj.delete() + + if refunded_amount == self.amount: + logger.info("Transaction %s refunded for %s€", self.id, refunded_amount) + self.payment_status = TransactionStatus.REFUNDED + self.run_refunded_hooks() + else: + logger.warn( + "Only refunded %s€/%s€ of transaction %s", + refunded_amount, + self.amount, + self.id, + ) - self.run_refunded_hooks() self.touch() self.save() diff --git a/insalan/payment/views.py b/insalan/payment/views.py index beb06b4f..fb0aa5b3 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -1,5 +1,6 @@ """Views for the Payment module""" +from decimal import Decimal import json import logging @@ -18,7 +19,7 @@ import insalan.settings as app_settings import insalan.payment.serializers as serializers -from .models import Transaction, TransactionStatus, Product +from .models import Transaction, TransactionStatus, Product, Payment from .tokens import Token logger = logging.getLogger(__name__) @@ -76,54 +77,85 @@ def post(self, request): ntype = data["eventType"] data = data["data"] - logger.warn("NTYPE: %s", ntype) - logger.warn("DATA: %s", data) + # logger.debug("NTYPE: %s", ntype) + # logger.debug("DATA: %s", data) if ntype == "Order": - # Check that the order is still unfinished - pass - elif ntype == "Payment": - # Check how the payments are going, this should signal a completed - # or cancelled/refunded payment - pass + # From "Order", get the payments + order_id = data["id"] + logger.info("Tied transaction %s to order ID %s", trans_obj.id, order_id) + trans_obj.order_id = order_id + trans_obj.touch() + trans_obj.save() + + for pay_data in data.get("payments", []): + pid = pay_data["id"] + amount = Decimal(pay_data["amount"]) / 100 + Payment.objects.create(id=pid, amount=amount, transaction=trans_obj) + logger.info( + "Created payment %d tied to transaction %s", pid, trans_obj.id + ) + elif ntype == "Form": # Those notifications are mostly useless, it's about changes to the # org pass + elif ntype == "Payment": + # Because we are in single payment, this is our signal to validate + pay_id = data["id"] + # The payment could be "None" if we haven't received "Order" yet + pay_objs = list(Payment.objects.filter(id=pay_id)) + [None] + pay_obj = pay_objs[0] + if pay_obj is not None and pay_obj.transaction != trans_obj: + logger.error( + "Mismatch! Payment %d is known to belong to transaction %s but HA metadata says %s", + pay_id, + trans_obj.id, + pay_obj.transaction.id, + ) + return Response(status=status.HTTP_400_BAD_REQUEST) + if pay_obj is not None and trans_obj.order_id != int(data["order"]["id"]): + logger.error( + "Mismatch! Payment %d is known to belong to transaction %s but HA data says %s", + pay_id, + trans_obj.order_id, + data["order"]["id"], + ) + return Response(status=status.HTTP_400_BAD_REQUEST) + if pay_obj is None: + logger.warning( + "Validating transaction %s based on payment %d to be generated later", + trans_obj.id, + pay_id, + ) - return Response(status=status.HTTP_200_OK) - - # This is voluntarily unreachable code!!! - # It was moved from a removed view pending reverse engineering of the - # API - trans_id = request.query_params.get("id") - checkout_id = request.query_params.get("checkoutIntentId") - code = request.query_params.get("code") - - if None in [trans_id, checkout_id, code]: - return Response(status=status.HTTP_400_BAD_REQUEST) + # Check the state + if data["state"] == "Authorized": + # Ok we should be good now + trans_obj.payment_status = TransactionStatus.SUCCEEDED + trans_obj.touch() + trans_obj.save() - transaction_obj = Transaction.objects.filter( - payment_status=TransactionStatus.PENDING, id=trans_id, intent_id=checkout_id - ) - if len(transaction_obj) == 0: - return Response(status=status.HTTP_403_FORBIDDEN) + logger.info("Transaction %s succeeded", trans_obj.id) - transaction_obj = transaction_obj[0] + # Execute hooks + trans_obj.run_success_hooks() - if code != "success": - transaction_obj.payment_status = TransactionStatus.FAILED - transaction_obj.touch() - transaction_obj.save() + elif data["state"] in ["Refused", "Unknown"]: + # This code should show that a payment failed + trans_obj.payment_status = TransactionStatus.FAILED + trans_obj.touch() + trans_obj.save() - return Response(status=status.HTTP_403_FORBIDDEN) + logger.info("Transaction %s failed", trans_obj.id) - transaction_obj.payment_status = TransactionStatus.SUCCEEDED - transaction_obj.touch() - transaction_obj.save() + # Execute hooks + trans_obj.run_failure_hooks() - # Execute hooks - transaction_obj.run_success_hooks() + else: + logger.warning( + "Payment %d shows status %s unknown", pay_id, data["state"] + ) return Response(status=status.HTTP_200_OK) From 41645af58e8e72538342f35bd0969204e7fe15ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 02:29:24 +0200 Subject: [PATCH 59/62] Properly handle unknown transactions notified Properly handle the fact that we could get notified about a transaction we do not know --- insalan/payment/views.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/insalan/payment/views.py b/insalan/payment/views.py index fb0aa5b3..14ffe9cf 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -65,13 +65,17 @@ class Notifications(APIView): """ def post(self, request): + """Notification POST""" + data = request.data if not data.get("metadata") or not data["metadata"].get("uuid"): return Response(status=status.HTTP_400_BAD_REQUEST) uuid = data["metadata"]["uuid"] - trans_obj = Transaction.objects.get(id=uuid) - if trans_obj is None: + try: + trans_obj = Transaction.objects.get(id=uuid) + except Transaction.DoesNotExist: + logger.error("Unable to find transaction %s", uuid) return Response(status=status.HTTP_400_BAD_REQUEST) ntype = data["eventType"] From 3de359780d14189e8c003dc77020c98b766451e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 18:15:51 +0200 Subject: [PATCH 60/62] Remove refund action, normalize state methods - Remove the transaction refund admin action, mostly by commenting it out - Normalize state methods, so that if you want to change the state of an action, you do not have to run the hooks yourself or touch the transaction or verify states or anything (it also logs stuff) - Add Refunded as a supported Payment message type --- insalan/payment/admin.py | 2 +- insalan/payment/models.py | 113 +++++++++++++++++++++++--------------- insalan/payment/views.py | 22 ++------ 3 files changed, 76 insertions(+), 61 deletions(-) diff --git a/insalan/payment/admin.py b/insalan/payment/admin.py index 1a9a3ae3..5f3c1bf4 100644 --- a/insalan/payment/admin.py +++ b/insalan/payment/admin.py @@ -85,7 +85,7 @@ class TransactionAdmin(admin.ModelAdmin): "amount", ] - actions = [reimburse_transactions] + # actions = [reimburse_transactions] def has_add_permission(self, _request): """Remove the ability to add a transaction from the backoffice""" diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 7473af17..623151aa 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -233,53 +233,65 @@ def run_failure_hooks(self): """Run the failure hooks on all products""" self.product_callback(lambda cls: cls.payment_failure) - def refund_transaction(self, requester) -> tuple[bool, str]: - """Refund this transaction""" + def refund_transaction(self, _requester="") -> tuple[bool, str]: + """ + Refund this transaction + + Refund a succeeded transaction, deleting all associated payments and + running associated hooks. + """ if self.payment_status != TransactionStatus.SUCCEEDED: + if self.payment_status != TransactionStatus.REFUNDED: + logger.warning("Attempt to refund %s in invalid state", self.id) return (True, _("Transaction %(id)s en état invalide") % {"id": self.id}) - token = Token.get_instance() - body_refund = { - "comment": f"Refunded by {requester}", - "sendRefundEmail": True, - "cancelOrder": True, - } - headers_refund = { - "authorization": "Bearer " + token.get_token(), - "Content-Type": "application/json", - } - - refunded_amount = Decimal("0.0") - for pay_obj in Payment.objects.filter(transaction=self): - refund_init = requests.post( - f"{app_settings.HA_URL}/v5/payments/{pay_obj.id}/refund/", - data=body_refund, - headers=headers_refund, - timeout=1, - ) - - if refund_init.status_code != 200: - return ( - True, - _("Erreur de remboursement: code %(code)s obtenu via l'API") - % { - "code": refund_init.status_code, - }, - ) - refunded_amount += pay_obj.amount - pay_obj.delete() - - if refunded_amount == self.amount: - logger.info("Transaction %s refunded for %s€", self.id, refunded_amount) - self.payment_status = TransactionStatus.REFUNDED - self.run_refunded_hooks() - else: - logger.warn( - "Only refunded %s€/%s€ of transaction %s", - refunded_amount, - self.amount, - self.id, - ) + # A lot of the code here is legacy from when this method initiated the + # refund, instead of changing the model to reflect it + + # token = Token.get_instance() + # body_refund = { + # "comment": f"Refunded by {requester}", + # "sendRefundEmail": True, + # "cancelOrder": True, + # } + # headers_refund = { + # "authorization": "Bearer " + token.get_token(), + # "Content-Type": "application/json", + # } + + # refunded_amount = Decimal("0.0") + # for pay_obj in Payment.objects.filter(transaction=self): + # # This bit is legacy, from when this was an action + # refund_init = requests.post( + # f"{app_settings.HA_URL}/v5/payments/{pay_obj.id}/refund/", + # data=body_refund, + # headers=headers_refund, + # timeout=1, + # ) + + # if refund_init.status_code != 200: + # return ( + # True, + # _("Erreur de remboursement: code %(code)s obtenu via l'API") + # % { + # "code": refund_init.status_code, + # }, + # ) + # refunded_amount += pay_obj.amount + # pay_obj.delete() + + # if refunded_amount == self.amount: + Payment.objects.filter(transaction=self).delete() + logger.info("Transaction %s refunded for %s€", self.id, self.amount) + self.payment_status = TransactionStatus.REFUNDED + self.run_refunded_hooks() + # else: + # logger.warn( + # "Only refunded %s€/%s€ of transaction %s", + # refunded_amount, + # self.amount, + # self.id, + # ) self.touch() self.save() @@ -299,16 +311,29 @@ def touch(self): def validate_transaction(self): """set payment_statut to validated""" + if self.payment_status != TransactionStatus.PENDING: + if self.payment_status != TransactionStatus.SUCCEEDED: + logger.warning("Attempted to validate %s in invalid state", self.id) + return self.payment_status = TransactionStatus.SUCCEDED self.last_modification_date = timezone.make_aware(datetime.now()) self.save() + logger.info("Transaction %s succeeded", self.id) + self.run_success_hooks() def fail_transaction(self): """set payment_statut to failed and update last_modification_date""" + if self.payment_status != TransactionStatus.PENDING: + if self.payment_status != TransactionStatus.FAILED: + logger.warning("Attempted to fail %s in invalid state", self.id) + return + self.payment_status = TransactionStatus.FAILED self.last_modification_date = timezone.make_aware(datetime.now()) self.save() + logger.info("Transaction %s failed", self.id) + self.run_failure_hooks() def get_products(self): return self.products diff --git a/insalan/payment/views.py b/insalan/payment/views.py index 14ffe9cf..5c88b293 100644 --- a/insalan/payment/views.py +++ b/insalan/payment/views.py @@ -136,29 +136,19 @@ def post(self, request): # Check the state if data["state"] == "Authorized": # Ok we should be good now - trans_obj.payment_status = TransactionStatus.SUCCEEDED - trans_obj.touch() - trans_obj.save() - - logger.info("Transaction %s succeeded", trans_obj.id) - - # Execute hooks - trans_obj.run_success_hooks() + trans_obj.validate_transaction() elif data["state"] in ["Refused", "Unknown"]: # This code should show that a payment failed - trans_obj.payment_status = TransactionStatus.FAILED - trans_obj.touch() - trans_obj.save() - - logger.info("Transaction %s failed", trans_obj.id) + trans_obj.fail_transaction() - # Execute hooks - trans_obj.run_failure_hooks() + elif data["state"] in ["Refunded"]: + # Refund + trans_obj.refund_transaction() else: logger.warning( - "Payment %d shows status %s unknown", pay_id, data["state"] + "Payment %d shows status %s unknown or already assigned", pay_id, data["state"] ) return Response(status=status.HTTP_200_OK) From f7774279b91f4d590c9cb83bc12206e0698fae9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 18:25:03 +0200 Subject: [PATCH 61/62] Re-add use of `%` for gettext_lazy interpolation --- insalan/payment/models.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/insalan/payment/models.py b/insalan/payment/models.py index 623151aa..003697bc 100644 --- a/insalan/payment/models.py +++ b/insalan/payment/models.py @@ -156,7 +156,6 @@ class Meta: @staticmethod def new(**data): """create a new transaction based on products id list and a payer""" - logger.debug(f"in the constructor {data}") fields = {} fields["creation_date"] = timezone.make_aware(datetime.now()) fields["last_modification_date"] = fields["creation_date"] @@ -168,17 +167,15 @@ def new(**data): if not pid.can_be_bought_now(): raise ValidationError( { - "error": _( - "Le produit %(id)s est actuellement indisponible" - ).format(id=pid.id) + "error": _("Le produit %(id)s est actuellement indisponible") + % {"id": pid.id} } ) if pid.associated_tournament and not pid.associated_tournament.is_announced: raise ValidationError( { - "error": _( - "Le tournoi %(id)s est actuellement indisponible" - ).format(id=pid.associated_tournament.id) + "error": _("Le tournoi %(id)s est actuellement indisponible") + % {"id": pid.associated_tournament.id} } ) count = len(list(grouper)) From 9a45e6e4a2ff4ca97bd44a59b55230bf0f568c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20=22Lymkwi=22=20Gonz=C3=A1lez?= Date: Fri, 27 Oct 2023 11:13:28 +0200 Subject: [PATCH 62/62] Fix typo in tourney serializer RO fields We had `manager_price_onsite` in there twice. --- insalan/tournament/serializers.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/insalan/tournament/serializers.py b/insalan/tournament/serializers.py index 10e272f5..1f137b33 100644 --- a/insalan/tournament/serializers.py +++ b/insalan/tournament/serializers.py @@ -30,7 +30,7 @@ class Meta: "month", "ongoing", "tournaments", - "logo" + "logo", ] @@ -54,8 +54,13 @@ class Meta: """Meta options of the serializer""" model = Tournament - read_only_fields = ("id","manager_price_onsite", "manager_price_onsite", - "player_price_online", "player_price_onsite") + read_only_fields = ( + "id", + "manager_price_online", + "manager_price_onsite", + "player_price_online", + "player_price_onsite", + ) fields = "__all__"