From 4c10930d5aed4a86dfcaf576d64f6044f55aa112 Mon Sep 17 00:00:00 2001 From: Amirhesam Adibinia Date: Mon, 4 Dec 2023 11:16:01 +0330 Subject: [PATCH] feat(backend): add discount code (#52) * feat(backend): add discount code * fix(backend): only count paid payments in discount remaining capacity * feat(backend): rollback discount on payment threshold --- backend/backend_api/admin.py | 11 ++++ .../0054_discount_payment_discount.py | 31 ++++++++++ backend/backend_api/models.py | 56 +++++++++++++++++-- backend/backend_api/views.py | 32 +++++++---- 4 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 backend/backend_api/migrations/0054_discount_payment_discount.py diff --git a/backend/backend_api/admin.py b/backend/backend_api/admin.py index cbe6174..9189618 100644 --- a/backend/backend_api/admin.py +++ b/backend/backend_api/admin.py @@ -3,6 +3,7 @@ from backend_api import models from backend_api.email import MailerThread +from backend_api.models import Discount def desc_creator(selected_model): @@ -101,6 +102,15 @@ def execute_mailer(self, request, obj): actions = ['execute_mailer'] +class DiscountAdmin(admin.ModelAdmin): + list_display = ('__str__', 'is_active', 'discount_percent', 'capacity', 'expiration_date') + readonly_fields = ('participants',) + + class Meta: + model = Discount + fields = '__all__' + + class PaymentAdmin(admin.ModelAdmin): rdfields = [] for field in models.Payment._meta.get_fields(): @@ -123,6 +133,7 @@ class Meta: admin.site.register(models.Presenter, PresenterAdmin) admin.site.register(models.User, UserAdmin) admin.site.register(models.Mailer, MailerAdmin) +admin.site.register(models.Discount, DiscountAdmin) admin.site.register(models.Payment, PaymentAdmin) admin.site.register(models.WorkshopRegistration) admin.site.register(models.PresentationParticipation) diff --git a/backend/backend_api/migrations/0054_discount_payment_discount.py b/backend/backend_api/migrations/0054_discount_payment_discount.py new file mode 100644 index 0000000..65c041c --- /dev/null +++ b/backend/backend_api/migrations/0054_discount_payment_discount.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.4 on 2023-12-03 20:08 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('backend_api', '0053_alter_staff_role_alter_staff_section_name'), + ] + + operations = [ + migrations.CreateModel( + name='Discount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default='BTusxOmw', max_length=8, unique=True)), + ('discount_percent', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=False)), + ('capacity', models.IntegerField(default=0)), + ('expiration_date', models.DateTimeField(default=django.utils.timezone.now)), + ], + ), + migrations.AddField( + model_name='payment', + name='discount', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='backend_api.discount'), + ), + ] diff --git a/backend/backend_api/models.py b/backend/backend_api/models.py index e61ab37..47f033b 100644 --- a/backend/backend_api/models.py +++ b/backend/backend_api/models.py @@ -9,6 +9,7 @@ from django.db import models from django.template.loader import render_to_string from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError from rest_framework import status @@ -21,6 +22,7 @@ from utils.random import create_random_string from utils.renderers import new_detailed_response from urllib.parse import quote + SMALL_MAX_LENGTH = 255 BIG_MAX_LENGTH = 65535 @@ -173,7 +175,7 @@ class Presentation(models.Model): cost = models.PositiveIntegerField(default=0) capacity = models.PositiveIntegerField(default=50) has_project = models.BooleanField(default=False, blank=False) - + NOT_ASSIGNED = 'NOT_ASSIGNED' ELEMENTARY = 'Elementary' INTERMEDIATE = 'Intermediate' @@ -341,6 +343,44 @@ def __str__(self): return f"Mailer with id {self.id}: subject= {self.subject}" +class Discount(models.Model): + _CODE_LENGTH = 8 + code = models.CharField(max_length=_CODE_LENGTH, null=False, default=create_random_string(_CODE_LENGTH), + unique=True) + discount_percent = models.IntegerField(default=0) + is_active = models.BooleanField(default=False) + capacity = models.IntegerField(default=0) + expiration_date = models.DateTimeField(default=timezone.now) + + def __str__(self): + return self.code + + @property + def participants(self): + users = [] + for payment in self.payment_set.all(): + users.append(payment.user) + return users + + def _remaining_capacity(self) -> int: + return self.capacity - self.payment_set.filter(status=Payment.PaymentStatus.PAYMENT_CONFIRMED).count() + + def is_usable(self, user: User) -> bool: + if not self.is_active: + raise ValidationError(new_detailed_response(status.HTTP_400_BAD_REQUEST, + "Discount is not active")) + if self._remaining_capacity() <= 0: + raise ValidationError(new_detailed_response(status.HTTP_400_BAD_REQUEST, + "Discount capacity is full")) + if self.expiration_date < timezone.now(): + raise ValidationError(new_detailed_response(status.HTTP_400_BAD_REQUEST, + "Discount has expired")) + if self.payment_set.filter(user=user).exists(): + raise ValidationError(new_detailed_response(status.HTTP_400_BAD_REQUEST, + "Discount has already been used by this user")) + return True + + class Payment(models.Model): class PaymentStatus(models.IntegerChoices): AWAITING_PAYMENT = 0, _('Awaiting payment') @@ -361,6 +401,7 @@ class PaymentStatus(models.IntegerChoices): default=datetime.datetime(year=2020, month=7, day=1, hour=0, minute=0, second=0, microsecond=0)) track_id = models.CharField(max_length=20, null=True, default=None) + discount = models.ForeignKey(Discount, on_delete=models.SET_NULL, null=True, default=None) def __str__(self): return f"Payment for {self.user.account} ({self.amount}) in {str(self.date)}" @@ -385,7 +426,7 @@ def update_payment_status(self, payment_status: PaymentStatus): self.save() @staticmethod - def create_payment_for_user(user: User): + def create_payment_for_user(user: User, discount: Discount): total_cost = 0 workshops: list[Workshop] = [] presentations: list[Presentation] = [] @@ -419,15 +460,20 @@ def create_payment_for_user(user: User): if len(workshops) == 0 and len(presentations) == 0: raise ValidationError( new_detailed_response(status.HTTP_400_BAD_REQUEST, f"User {user} has no unpaid registrations")) - if total_cost >= Payment._DISCOUNT_THRESHOLD: - total_cost = int(total_cost * (1 - Payment._DISCOUNT_PERCENTAGE / 100)) payment = Payment.objects.create(user=user, amount=total_cost, year=datetime.date.today().year, - date=datetime.datetime.now()) + date=datetime.datetime.now(), discount=discount) payment.workshops.set(workshops) payment.presentations.set(presentations) payment.save() return payment + @property + def discounted_amount(self): + if self.discount is None: + return self.amount if self.amount < self._DISCOUNT_THRESHOLD else int( + self.amount * (1 - self._DISCOUNT_PERCENTAGE / 100)) + return int(self.amount * (1 - self.discount.discount_percent / 100)) + class Committee(models.Model): profile = models.ImageField(verbose_name='profile', null=True, blank=True) diff --git a/backend/backend_api/views.py b/backend/backend_api/views.py index 62df0ed..4c4c8b6 100644 --- a/backend/backend_api/views.py +++ b/backend/backend_api/views.py @@ -2,7 +2,7 @@ import os import urllib.parse -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.shortcuts import get_object_or_404, redirect from rest_framework import status, mixins from rest_framework import viewsets @@ -17,7 +17,7 @@ from aaiss_backend.settings import BASE_URL from backend_api import models from backend_api import serializers -from backend_api.models import User, Account, Payment, Staff, WorkshopRegistration, PresentationParticipation +from backend_api.models import User, Account, Payment, Staff, WorkshopRegistration, PresentationParticipation, Discount from backend_api.serializers import WorkshopRegistrationSerializer, PresentationParticipationSerializer from payment_backends.zify import ZIFYRequest, ZIFY_STATUS_OK from utils.renderers import new_detailed_response @@ -215,17 +215,29 @@ class PaymentViewSet(viewsets.GenericViewSet): @action(methods=['POST'], detail=False, permission_classes=[IsAuthenticated]) def payment(self, request): account = request.user - call_back = request.data.get('call_back') - if call_back is None: - return Response(new_detailed_response(status.HTTP_400_BAD_REQUEST, "call_back field is required")) try: user = User.objects.get(account=account) except ObjectDoesNotExist: return Response(new_detailed_response( status.HTTP_400_BAD_REQUEST, "User not found")) - - payment = Payment.create_payment_for_user(user) - response = ZIFYRequest().create_payment(str(payment.pk), payment.amount, user.name, user.phone_number, + call_back = request.data.get('call_back') + if call_back is None: + return Response(new_detailed_response(status.HTTP_400_BAD_REQUEST, "call_back field is required")) + discount = None + discount_code = request.data.get('discount_code') + if discount_code is not None: + try: + discount = Discount.objects.get(code=discount_code) + except ObjectDoesNotExist: + return Response(new_detailed_response( + status.HTTP_400_BAD_REQUEST, "Discount code not found")) + try: + discount.is_usable(user) + except ValidationError as e: + return Response(e) + payment = Payment.create_payment_for_user(user, discount) + response = ZIFYRequest().create_payment(str(payment.pk), payment.discounted_amount, user.name, + user.phone_number, user.account.email, call_back) if response['status'] == ZIFY_STATUS_OK: payment.track_id = response['data']['order'] @@ -250,7 +262,7 @@ def verify(self, request): response = ZIFYRequest().verify_payment(payment.track_id) if response['status'] == ZIFY_STATUS_OK: payment.update_payment_status(Payment.PaymentStatus.PAYMENT_CONFIRMED) - return Response(new_detailed_response(status.HTTP_200_OK, "Payment verified successfully", payment.pk)) + return Response(new_detailed_response(status.HTTP_200_OK, "Payment verified successfully", payment.pk)) else: payment.update_payment_status(Payment.PaymentStatus.PAYMENT_REJECTED) return Response( @@ -278,7 +290,7 @@ def list(self, request, *args, **kwargs): } section_data['people'].append(person_data) - + if len(section_data['people']) != 0: section_data['people'] = sorted(section_data['people'], key=lambda x: x['role'])[::-1] data.append(section_data)