diff --git a/apps/promotions/admin.py b/apps/promotions/admin.py index 2551214..e64b0cc 100644 --- a/apps/promotions/admin.py +++ b/apps/promotions/admin.py @@ -2,10 +2,11 @@ from functools import update_wrapper from django.contrib import admin -from .models import Promotion, Plan, Event +from .models import Promotion, Plan, Event, Recipient admin.site.register(Plan) admin.site.register(Promotion) admin.site.register(Event) +admin.site.register(Recipient) diff --git a/apps/promotions/handlers.py b/apps/promotions/handlers.py index dac6713..81315be 100644 --- a/apps/promotions/handlers.py +++ b/apps/promotions/handlers.py @@ -10,32 +10,24 @@ logger = logging.getLogger("promotion_handler") class PromotionTypeChoices(models.TextChoices): PERCENTAGE = 'percentage', _('percentage') + REFERRAL = 'referral', _('referral') + OTHERS = 'others', _('others') - @enum_property - def handler(self): - if self.name == self.PERCENTAGE.name: - return PercentageHandler - - elif self.name == self.OTHERS.name: - return OtherHandler - - else: - raise NotImplementedError() - - class BasePromotionHandler: - def __init__(self, application=None, policy=None, balance=None): - self.application = application - self.policy = policy - self.balance = balance + def __init__(self, plan): + self.plan = plan def calculate(self, **kwargs): + # condition and amount raise NotImplementedError() def promote(self, **kwargs): raise NotImplementedError() + # def get_recipient_uuid(self, recipient, **kwargs): + # raise NotImplementedError() + class PercentageHandler(BasePromotionHandler): def calculate(self, **kwargs): @@ -48,8 +40,8 @@ class PercentageHandler(BasePromotionHandler): promotion_amount = 1000 if self.balance >= 1000 else self.balance return promotion_amount - def promote(self, promotion, **kwargs): - pass + def promote(self, **kwargs): + return payee_type = self.policy.get('payee_type') payee_uuid = self.policy.get('payee_uuid') @@ -89,5 +81,63 @@ class PercentageHandler(BasePromotionHandler): logger.exception('error in finish visit:') raise e + +class ReferralHandler(BasePromotionHandler): + def calculate(self, **kwargs): + event = kwargs.get('event') + plan = self.plan + + + if base_amount <= 1000 and self.balance >= 1000: + promotion_amount = base_amount + + else: + promotion_amount = 1000 if self.balance >= 1000 else self.balance + return promotion_amount + + def promote(self, **kwargs): + return + payee_type = self.policy.get('payee_type') + payee_uuid = self.policy.get('payee_uuid') + + # promotion_amount = self.calculate(**kwargs) + try: + data = { + "uuid": str(promotion.uuid), + "payee_id": str(payee_uuid), + "payee_type": payee_type, + "amount": promotion.promotion_amount, + "details": { + 'description': "for promotion", + 'reference_id': str(promotion.uuid), + 'application_details_url': '' + }, + } + + submit_response = deposit_to_user_wallet_submit(data) + try: + submit_response.uuid + except Exception as e: + logger.exception('Failed to submit payment') + raise Exception('Failed to submit payment') + + verify_response = deposit_to_user_wallet_verify(str(promotion.uuid)) + logger.debug(f'verify response: {verify_response}') + try: + if not verify_response.uuid or verify_response.state != 5: + logger.debug('Failed to verify payment') + raise Exception('Failed to verify payment') + else: + return True + except Exception as e: + logger.exception('Failed to verify payment') + raise Exception('Failed to verify payment') + except Exception as e: + logger.exception('error in finish visit:') + raise e + + # def get_recipient_uuid(self, recipient, **kwargs): + # return recipient.uuid + class OtherHandler(BasePromotionHandler): pass diff --git a/apps/promotions/migrations/0009_alter_event_user_recipient_promotion_recipient.py b/apps/promotions/migrations/0009_alter_event_user_recipient_promotion_recipient.py new file mode 100644 index 0000000..3d7a950 --- /dev/null +++ b/apps/promotions/migrations/0009_alter_event_user_recipient_promotion_recipient.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.6 on 2025-09-21 14:19 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('promotions', '0008_event_application_event_user'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Recipient', + 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)), + ('label', models.CharField(db_index=True, max_length=255)), + ('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='promotions.plan')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='promotion', + name='recipient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='promotions', to='promotions.recipient'), + ), + ] diff --git a/apps/promotions/migrations/0010_remove_plan_max_amount_remove_plan_min_amount_and_more.py b/apps/promotions/migrations/0010_remove_plan_max_amount_remove_plan_min_amount_and_more.py new file mode 100644 index 0000000..e0756bf --- /dev/null +++ b/apps/promotions/migrations/0010_remove_plan_max_amount_remove_plan_min_amount_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 5.1.7 on 2025-10-12 13:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('promotions', '0009_alter_event_user_recipient_promotion_recipient'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='plan', + name='max_amount', + ), + migrations.RemoveField( + model_name='plan', + name='min_amount', + ), + migrations.RemoveField( + model_name='plan', + name='policy', + ), + migrations.AddField( + model_name='plan', + name='data', + field=models.JSONField(blank=True, default=dict, null=True, verbose_name='data'), + ), + migrations.AddField( + model_name='plan', + name='user', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='recipient', + name='base_amount', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='recipient', + name='data', + field=models.JSONField(blank=True, default=dict, null=True), + ), + migrations.AddField( + model_name='recipient', + name='promotion_type', + field=models.CharField(choices=[('percentage', 'percentage'), ('referral', 'referral'), ('others', 'others')], db_index=True, default=None, max_length=64, verbose_name='promotion type'), + preserve_default=False, + ), + migrations.AddField( + model_name='recipient', + name='recipient_uuid_field', + field=models.CharField(db_index=True, default=None, max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='recipient', + name='wallet_uuid', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AlterField( + model_name='plan', + name='promotion_type', + field=models.CharField(choices=[('percentage', 'percentage'), ('referral', 'referral'), ('others', 'others')], db_index=True, max_length=64, verbose_name='promotion type'), + ), + migrations.AlterField( + model_name='recipient', + name='plan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='recipients', to='promotions.plan'), + ), + ] diff --git a/apps/promotions/models.py b/apps/promotions/models.py index 4bea0f7..ee9dc0d 100644 --- a/apps/promotions/models.py +++ b/apps/promotions/models.py @@ -5,6 +5,7 @@ from django.utils import timezone from oauth2_provider.settings import oauth2_settings +from apps.promotions import handlers from apps.promotions.handlers import PromotionTypeChoices, BasePromotionHandler from apps.users.models import User from utils.models import BaseModel @@ -17,7 +18,7 @@ class EventQuerySet(models.QuerySet): class Event(BaseModel): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, related_name='+', null=True, blank=True) label = models.CharField(max_length=255, db_index=True) @@ -29,35 +30,32 @@ class Event(BaseModel): return self.label def analyze(self): - for plan in Plan.objects.get_related_to_event(self): - print(plan) - plan.process_event(self) + for plan in Plan.objects.related_to_event(self): + plan.process_event(event=self) class PlanQuerySet(models.QuerySet): - # this is for list of discount code for example - def calculate(self, user, application=None): - for plan in self.filter(application=application)[:10]: - if not Promotion.objects.used(user): - plan.calculate(user) + # # this is for list of discount code for example + # def calculate(self, user, application=None): + # for plan in self.filter(application=application)[:10]: + # if not Promotion.objects.used(user): + # plan.calculate(user) - def get_related_to_event(self, event): + def related_to_event(self, event): return self.filter(event_list__icontains=event.label) 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) + 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) event_list = models.JSONField(default=list) 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) - min_amount = models.FloatField(null=True, blank=True) - max_amount = models.FloatField(null=True, blank=True) - policy = models.JSONField(default=dict, verbose_name=_('policy'), null=True, blank=True) + data = models.JSONField(default=dict, verbose_name=_('data'), null=True, blank=True) balance = models.IntegerField(verbose_name=_('balance'), default=0) objects = PlanQuerySet.as_manager() @@ -65,18 +63,26 @@ class Plan(BaseModel): 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, - ) + # @property + # def handler(self): + # if self.promotion_type == PromotionTypeChoices.PERCENTAGE.name: + # return handlers.PercentageHandler(self) + # + # elif self.promotion_type == PromotionTypeChoices.REFERRAL.name: + # return handlers.ReferralHandler(self) + # + # elif self.promotion_type == PromotionTypeChoices.OTHERS.name: + # return handlers.OtherHandler(self) + # + # else: + # raise NotImplementedError() - def calculate_promotion(self, **kwargs): - handler = self.handler - # self.base_amount = kwargs.get('base_amount', 0) - return handler.calculate(**kwargs) + def process_event(self, event=None): + self.promote(event=event) + + def promote(self, event=None): + for recipient in self.recipients.all(): + recipient.promote(plan=self, event=event) def reserve_promotion_amount(self, amount): updated = False @@ -86,25 +92,68 @@ class Plan(BaseModel): ) return updated - def process_event(self, event=None): - print(event) - referral_code = event.data.get('referral_code') - user_uuid = event.data.get('user_uuid') - promotion_amount = self.calculate_promotion(**self.policy) - if referral_code and promotion_amount and not self.promotions.filter(user_id=referral_code, - data__user_uuid=user_uuid).exists(): - referral_user, created = User.objects.get_or_create(uuid=referral_code) - user = User.objects.get_or_create(uuid=user_uuid) - with transaction.atomic(): - if self.reserve_promotion_amount(promotion_amount): - promotion, created = Promotion.objects.get_or_create(plan=self, user=referral_user, defaults={ - "data": event.data - }) - if not created: - raise Exception('Promotion already reserved') + +class Recipient(BaseModel): + label = models.CharField(max_length=255, db_index=True) + plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name='recipients', null=True, blank=True) + recipient_uuid_field = models.CharField(max_length=255, db_index=True) + # wallet_uuid = models.UUIDField(null=True, blank=True) + promotion_type = models.CharField(max_length=64, verbose_name=_('promotion type'), db_index=True, + choices=PromotionTypeChoices.choices) + + base_amount = models.FloatField(null=True, blank=True) + data = models.JSONField(null=True, blank=True, default=dict) + + def get_wallet_category_uuid(self): + return settings.WALLET_PROMOTION_CATEGORY_UUID + + def get_recipient_uuid(self, event=None): + model_name, key = self.recipient_uuid_field.split('::') + if model_name == 'event': + obj = event + elif model_name == 'plan': + obj = self.plan + else: + raise NotImplementedError() + + return obj.data.get(key) + + def calculate(self, plan=None, event=None): + base_amount = self.base_amount + + if base_amount <= base_amount and self.plan.balance >= base_amount: + promotion_amount = base_amount + + elif self.plan.balance >= base_amount: + promotion_amount = base_amount + + else: + raise + return promotion_amount + + # @property + # def handler(self) -> BasePromotionHandler: + # return PromotionTypeChoices(self.promotion_type).handler(self) + + def promote(self, plan=None, event=None): + with transaction.atomic(): + promotion_amount = self.calculate(plan=plan, event=event) + reserved = self.plan.reserve_promotion_amount(promotion_amount) + if reserved: + promotion, created = Promotion.objects.get_or_create( + promotion_amount=promotion_amount, + user_id=self.get_recipient_uuid(event), + plan=plan, + recipient=self, + base_amount=self.base_amount, + ) + if created: + promotion.promote() else: - raise Exception('Promotion already reserved') - promotion.promote() + raise + else: + raise + class PromotionQuerySet(models.QuerySet): @@ -123,6 +172,7 @@ class Promotion(BaseModel): 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) + recipient = models.ForeignKey(Recipient, on_delete=models.PROTECT, related_name='promotions', null=True, blank=True) state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED) base_amount = models.IntegerField(null=True, blank=True) promotion_amount = models.IntegerField(null=True, blank=True) @@ -153,7 +203,7 @@ class Promotion(BaseModel): def promote(self, **kwargs): self.change_state(from_states=[Promotion.StateChoices.CREATED], to_state=Promotion.StateChoices.ASSIGNED, - same_ok=False, raise_exception=True) + same_ok=False, raise_exception=True) handler = self.plan.handler if handler.promote(promotion=self, **kwargs): diff --git a/apps/promotions/views.py b/apps/promotions/views.py index 4e3adfd..38bc19f 100644 --- a/apps/promotions/views.py +++ b/apps/promotions/views.py @@ -85,10 +85,6 @@ class ApplicationEventViewSet( analyze_event_task.delay(event.uuid) - - - - class UserPlanViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet): diff --git a/main/settings.py b/main/settings.py index 9cfb166..d63a6d6 100644 --- a/main/settings.py +++ b/main/settings.py @@ -371,3 +371,6 @@ CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TIME_LIMIT = 30 * 60 CELERY_BROKER_URL = REDIS_BASE_URL CELERY_RESULT_BACKEND = REDIS_BASE_URL + +WALLET_SERVICE_CATEGORY_UUID = config('WALLET_SERVICE_CATEGORY_UUID', '') # TODO: better naming +WALLET_PROMOTION_CATEGORY_UUID = config('WALLET_PROMOTION_CATEGORY_UUID', '') # TODO: may be configurable \ No newline at end of file