recipient skeleton

This commit is contained in:
Sayyid Hamid Mahdavi 2025-10-12 21:02:48 +03:30
parent 32f94178cd
commit a5854390cb
7 changed files with 285 additions and 69 deletions

View file

@ -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)

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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):

View file

@ -85,10 +85,6 @@ class ApplicationEventViewSet(
analyze_event_task.delay(event.uuid)
class UserPlanViewSet(mixins.RetrieveModelMixin,
mixins.ListModelMixin,
GenericViewSet):

View file

@ -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