From 75924dd2b614137974bc619984dad5d83d2f0d0c Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sun, 3 Aug 2025 13:28:40 +0330 Subject: [PATCH] basic promotion handler --- apps/{sample => }/__init__.py | 0 .../migrations => promotions}/__init__.py | 0 apps/promotions/admin.py | 10 +++ apps/{sample => promotions}/apps.py | 4 +- apps/{sample => promotions}/filters.py | 0 apps/{sample => promotions}/forms.py | 0 apps/promotions/handlers.py | 43 ++++++++++ apps/promotions/migrations/0001_initial.py | 52 ++++++++++++ apps/promotions/migrations/__init__.py | 0 apps/promotions/models.py | 68 +++++++++++++++ apps/promotions/serializers.py | 47 +++++++++++ apps/{sample => promotions}/tasks.py | 0 apps/{sample => promotions}/tests.py | 0 apps/{sample => promotions}/urls.py | 8 +- apps/promotions/views.py | 84 +++++++++++++++++++ apps/sample/admin.py | 9 -- apps/sample/models.py | 21 ----- apps/sample/serializers.py | 14 ---- apps/sample/views.py | 63 -------------- main/settings.py | 2 +- main/urls.py | 2 +- 21 files changed, 312 insertions(+), 115 deletions(-) rename apps/{sample => }/__init__.py (100%) rename apps/{sample/migrations => promotions}/__init__.py (100%) create mode 100644 apps/promotions/admin.py rename apps/{sample => promotions}/apps.py (59%) rename apps/{sample => promotions}/filters.py (100%) rename apps/{sample => promotions}/forms.py (100%) create mode 100644 apps/promotions/handlers.py create mode 100644 apps/promotions/migrations/0001_initial.py create mode 100644 apps/promotions/migrations/__init__.py create mode 100644 apps/promotions/models.py create mode 100644 apps/promotions/serializers.py rename apps/{sample => promotions}/tasks.py (100%) rename apps/{sample => promotions}/tests.py (100%) rename apps/{sample => promotions}/urls.py (68%) create mode 100644 apps/promotions/views.py delete mode 100644 apps/sample/admin.py delete mode 100644 apps/sample/models.py delete mode 100644 apps/sample/serializers.py delete mode 100644 apps/sample/views.py diff --git a/apps/sample/__init__.py b/apps/__init__.py similarity index 100% rename from apps/sample/__init__.py rename to apps/__init__.py diff --git a/apps/sample/migrations/__init__.py b/apps/promotions/__init__.py similarity index 100% rename from apps/sample/migrations/__init__.py rename to apps/promotions/__init__.py diff --git a/apps/promotions/admin.py b/apps/promotions/admin.py new file mode 100644 index 0000000..44d9a8a --- /dev/null +++ b/apps/promotions/admin.py @@ -0,0 +1,10 @@ +from functools import update_wrapper + +from django.contrib import admin + +from .models import Promotion, Plan + + +admin.site.register(Plan) +admin.site.register(Promotion) + diff --git a/apps/sample/apps.py b/apps/promotions/apps.py similarity index 59% rename from apps/sample/apps.py rename to apps/promotions/apps.py index 2760e8d..157620c 100644 --- a/apps/sample/apps.py +++ b/apps/promotions/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class SampleConfig(AppConfig): +class PromotionsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.sample' + name = 'apps.promotions' diff --git a/apps/sample/filters.py b/apps/promotions/filters.py similarity index 100% rename from apps/sample/filters.py rename to apps/promotions/filters.py diff --git a/apps/sample/forms.py b/apps/promotions/forms.py similarity index 100% rename from apps/sample/forms.py rename to apps/promotions/forms.py diff --git a/apps/promotions/handlers.py b/apps/promotions/handlers.py new file mode 100644 index 0000000..ab3cb95 --- /dev/null +++ b/apps/promotions/handlers.py @@ -0,0 +1,43 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from enum import property as enum_property # ?? + + +class PromotionTypeChoices(models.TextChoices): + WALLET_PERCENTAGE = 'wallet percentage', _('Wallet percentage') + OTHERS = 'others', _('others') + + @enum_property + def handler(self): + if self.name == self.WALLET_PERCENTAGE.name: + return WalletPercentageHandler + + elif self.name == self.OTHERS.name: + return OtherHandler + + else: + raise NotImplementedError() + + +class BasePromotionHandler: + def __init__(self, plan): + self.plan = plan + + def calculate(self, **kwargs): + raise NotImplementedError() + + +class WalletPercentageHandler(BasePromotionHandler): + def calculate(self, **kwargs): + base_amount = kwargs.get('base_amount', 0) + + if base_amount <= 1000 and self.plan.balance >= 1000: + promotion_amount = base_amount + + else: + promotion_amount = 1000 if self.plan.balance >= 1000 else self.plan.balance + return promotion_amount + + +class OtherHandler(BasePromotionHandler): + pass diff --git a/apps/promotions/migrations/0001_initial.py b/apps/promotions/migrations/0001_initial.py new file mode 100644 index 0000000..fcba557 --- /dev/null +++ b/apps/promotions/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.4 on 2025-08-02 16:18 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Plan', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('title', models.CharField(db_index=True, max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('promotion_type', models.CharField(choices=[('wallet percentage', 'Wallet percentage'), ('others', 'others')], db_index=True, max_length=64, verbose_name='promotion type')), + ('policy', models.JSONField(blank=True, default=dict, null=True, verbose_name='policy')), + ('balance', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='balance')), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Promotion', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('state', models.IntegerField(choices=[(0, 'Created'), (1, 'Assigned'), (10, 'Expired')], default=0)), + ('data', models.JSONField(blank=True, null=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='promotions', to='promotions.plan')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/promotions/migrations/__init__.py b/apps/promotions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/promotions/models.py b/apps/promotions/models.py new file mode 100644 index 0000000..0a846eb --- /dev/null +++ b/apps/promotions/models.py @@ -0,0 +1,68 @@ +from django.conf import settings +from django.db import models +from oauth2_provider.settings import oauth2_settings + +from apps.promotions.handlers import PromotionTypeChoices, BasePromotionHandler +from utils.models import BaseModel + +from django.utils.translation import gettext_lazy as _ + + +class PlanQuerySet(models.QuerySet): + def calculate(self, user, application=None): + for plan in self.filter(application=application)[:10]: + if not Promotion.objects.used(user): + plan.calculate(user) + + +class Plan(BaseModel): + # user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, + related_name='+', null=True, blank=True) + title = models.CharField(max_length=255, verbose_name=_('title'), db_index=True) + description = models.TextField(verbose_name=_('description'), blank=True) + promotion_type = models.CharField(max_length=64, verbose_name=_('promotion type'), db_index=True, choices=PromotionTypeChoices.choices) + policy = models.JSONField(default=dict, verbose_name=_('policy'), null=True, blank=True) + balance = models.DecimalField(max_digits=10, decimal_places=2, verbose_name=_('balance'), default=0) + + objects = PlanQuerySet.as_manager() + + def __str__(self): + return self.title + + @property + def handler(self) -> BasePromotionHandler: + return PromotionTypeChoices(self.promotion_type).handler( + application=self.application, + policy=self.policy, + balance=self.balance, + ) + + def calculate_promotion(self, **kwargs): + handler = self.handler + self.base_amount = kwargs.get('base_amount', 0) + self.promotion_amount = handler.calculate(**kwargs) + return self.promotion_amount + + +class PromotionQuerySet(models.QuerySet): + def used(self, user): + return self.filter(user=user, state__in=[Promotion.StateChoices.CREATED]) + + +class Promotion(BaseModel): + class StateChoices(models.IntegerChoices): + CREATED = 0, _('Created') + ASSIGNED = 1, _('Assigned') + EXPIRED = 10, _('Expired') + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, + related_name='+', null=True, blank=True) + plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name='promotions', null=True, blank=True) + state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED) + + data = models.JSONField(null=True, blank=True) + + objects = PromotionQuerySet.as_manager() + diff --git a/apps/promotions/serializers.py b/apps/promotions/serializers.py new file mode 100644 index 0000000..e430557 --- /dev/null +++ b/apps/promotions/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from .models import Promotion, Plan + + +class PlanSerializer(serializers.ModelSerializer): + promotion_amount = serializers.SerializerMethodField(method_name='get_promotion_amount') + + def get_promotion_amount(self, obj): + user = self.context['request'].user + return obj.calculate_promotion(user) + + class Meta: + model = Plan + fields = ( + "promotion_amount", + "title", + "description", + "application", + ) + read_only_fields = ('application', "promotion_amount") + +class PromotionSerializer(serializers.ModelSerializer): + class Meta: + model = Promotion + fields = ( + "user", + "application", + ) + read_only_fields = ('user','application') + +class CalculatedPlanSerializer(serializers.ModelSerializer): + base_amount = serializers.IntegerField(required=True) + promotion_amount = serializers.IntegerField(read_only=True) + class Meta: + model = Promotion + fields = ( + "base_amount", + "user", + "application", + "promotion_amount" + ) + read_only_fields = ('user','application', "promotion_amount") + + # def get_promotion_amount(self, obj): + # user = self.context['request'].user + # return obj.calculate_promotion(user) \ No newline at end of file diff --git a/apps/sample/tasks.py b/apps/promotions/tasks.py similarity index 100% rename from apps/sample/tasks.py rename to apps/promotions/tasks.py diff --git a/apps/sample/tests.py b/apps/promotions/tests.py similarity index 100% rename from apps/sample/tests.py rename to apps/promotions/tests.py diff --git a/apps/sample/urls.py b/apps/promotions/urls.py similarity index 68% rename from apps/sample/urls.py rename to apps/promotions/urls.py index 673b603..77ead90 100644 --- a/apps/sample/urls.py +++ b/apps/promotions/urls.py @@ -5,13 +5,13 @@ from utils.router import ProfileRouter from . import views from django.urls import NoReverseMatch, path, re_path, include -app_name = 'sample' +app_name = 'promotions' application_router = DefaultRouter() -application_router.register(r'', views.ApplicationSampleViewSet, basename='application' ) +application_router.register(r'', views.ApplicationPlanViewSet, basename='application' ) -user_router = ProfileRouter() -user_router.register('', views.UserSampleViewSet, basename='user') +user_router = DefaultRouter() +user_router.register('', views.UserPlanViewSet, basename='user') urlpatterns = [ path('api/v1/application//', include(application_router.urls), name='application'), diff --git a/apps/promotions/views.py b/apps/promotions/views.py new file mode 100644 index 0000000..94fe794 --- /dev/null +++ b/apps/promotions/views.py @@ -0,0 +1,84 @@ +from django.shortcuts import render + +from django.http import Http404 +from django.shortcuts import render +from drf_spectacular.utils import extend_schema +from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements +from rest_framework import viewsets, mixins +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 +from rest_framework.viewsets import GenericViewSet + +from .models import Promotion, Plan +from .serializers import PlanSerializer, PromotionSerializer, CalculatedPlanSerializer +from apps.gooyal_oauth2.models import Application +from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements +from apps.gooyal_oauth2.utils import get_application +from ..users.models import User + + +from django.shortcuts import render + +from django.http import Http404 +from django.shortcuts import render +from django.urls import reverse +from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements +from rest_framework import viewsets, mixins +from rest_framework.generics import get_object_or_404 +from rest_framework.viewsets import GenericViewSet + +from .models import Plan, Promotion +from apps.gooyal_oauth2.models import Application +from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements +from apps.gooyal_oauth2.utils import get_application +from ..users.models import User + + +class ApplicationPlanViewSet(mixins.ListModelMixin, + mixins.CreateModelMixin, + GenericViewSet): + queryset = Plan.objects.all() + serializer_class = PlanSerializer + + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "POST": [["promotions.application.user-plans:submit"]], + "GET": [["promotions.application.user-promotions:list-retrieve"]], + } + + # def perform_create(self, serializer): + # user_uuid = self.kwargs.get('user_uuid') + # user = User.objects.get(uuid=user_uuid) + # application = get_application(self.request) + # instance = serializer.save(user=user, application=application) + + +class UserPlanViewSet(mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): + + serializer_class = PlanSerializer + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "GET": [["promotions.user.self-plans:list-retrieve"]], + } + + def get_queryset(self): + user = self.request.user + application = get_application(self.request) + + queryset = Plan.objects.all() + return queryset + + @extend_schema(responses=CalculatedPlanSerializer(many=True)) + @action(detail=False, methods=['POST'], serializer_class=CalculatedPlanSerializer) + def calculate(self, request): + user = self.request.user + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + print(serializer.validated_data) + plans = self.paginate_queryset(self.filter_queryset(self.get_queryset())) + for plan in plans: + plan.calculate_promotion(**serializer.validated_data) + serializer = self.get_serializer(plans, many=True) + return self.get_paginated_response(serializer.data) diff --git a/apps/sample/admin.py b/apps/sample/admin.py deleted file mode 100644 index 54cc0fe..0000000 --- a/apps/sample/admin.py +++ /dev/null @@ -1,9 +0,0 @@ -from functools import update_wrapper - -from django.contrib import admin - -from .models import Sample - - -admin.site.register(Sample) - diff --git a/apps/sample/models.py b/apps/sample/models.py deleted file mode 100644 index 44bc886..0000000 --- a/apps/sample/models.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.conf import settings -from django.db import models -from oauth2_provider.settings import oauth2_settings - -from utils.models import BaseModel - -from django.utils.translation import gettext_lazy as _ - - -class SampleQuerySet(models.QuerySet): - pass - - -class Sample(BaseModel): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, - related_name='+', null=True, blank=True) - - data = models.JSONField(null=True, blank=True) - - objects = SampleQuerySet.as_manager() diff --git a/apps/sample/serializers.py b/apps/sample/serializers.py deleted file mode 100644 index 317aebc..0000000 --- a/apps/sample/serializers.py +++ /dev/null @@ -1,14 +0,0 @@ -from rest_framework import serializers - -from .models import Sample - - -class SampleSerializer(serializers.ModelSerializer): - class Meta: - model = Sample - fields = ( - "user", - "application", - "data" - ) - read_only_fields = ('user','application') \ No newline at end of file diff --git a/apps/sample/views.py b/apps/sample/views.py deleted file mode 100644 index 72095e3..0000000 --- a/apps/sample/views.py +++ /dev/null @@ -1,63 +0,0 @@ -from django.shortcuts import render - -from django.http import Http404 -from django.shortcuts import render -from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements -from rest_framework import viewsets, mixins -from rest_framework.generics import get_object_or_404 -from rest_framework.viewsets import GenericViewSet - -from .models import PushUser, PushMessage -from .serializers import PushUserSerializer, PushMessageSerializer -from apps.gooyal_oauth2.models import Application -from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements -from apps.gooyal_oauth2.utils import get_application -from ..users.models import User - - -from django.shortcuts import render - -from django.http import Http404 -from django.shortcuts import render -from django.urls import reverse -from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements -from rest_framework import viewsets, mixins -from rest_framework.generics import get_object_or_404 -from rest_framework.viewsets import GenericViewSet - -from .models import Sample -from .serializers import SampleSerializer -from apps.gooyal_oauth2.models import Application -from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements -from apps.gooyal_oauth2.utils import get_application -from ..users.models import User - - -class ApplicationSampleViewSet(mixins.ListModelMixin, - mixins.CreateModelMixin, - GenericViewSet): - queryset = Sample.objects.all() - serializer_class = SampleSerializer - - permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] - required_alternate_scopes = { - "POST": [["service_template.application.user-sample:submit"]], - "GET": [["service_template.application.user-sample:list-retrieve"]], - } - - def perform_create(self, serializer): - user_uuid = self.kwargs.get('user_uuid') - user = User.objects.get(uuid=user_uuid) - application = get_application(self.request) - instance = serializer.save(user=user, application=application) - - -class UserSampleViewSet(mixins.RetrieveModelMixin, - GenericViewSet): - queryset = Sample.objects.all() - serializer_class = SampleSerializer - permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] - required_alternate_scopes = { - "GET": [["service_template.user.self-sample:list-retrieve"]], - } - diff --git a/main/settings.py b/main/settings.py index f62ebe5..9cfb166 100644 --- a/main/settings.py +++ b/main/settings.py @@ -53,7 +53,7 @@ INSTALLED_APPS = [ 'utils', 'apps.users', 'apps.gooyal_oauth2', - # 'apps.sample', + 'apps.promotions', ] MIDDLEWARE = [ diff --git a/main/urls.py b/main/urls.py index 3a8b47a..0198a17 100644 --- a/main/urls.py +++ b/main/urls.py @@ -29,7 +29,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('', include('utils.urls')), path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')), - # path('sample/', include('apps.sample.urls', namespace='sample')), + path('promotions/', include('apps.promotions.urls', namespace='promotions')), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)