451 lines
17 KiB
Python
451 lines
17 KiB
Python
import uuid
|
|
|
|
from django.conf import settings
|
|
from django.contrib.postgres.indexes import GinIndex
|
|
from django.db import models, transaction
|
|
from django.db.models import F
|
|
from django.utils import timezone
|
|
|
|
from oauth2_provider.settings import oauth2_settings
|
|
|
|
from apps.promotions import handlers
|
|
import logging
|
|
from apps.promotions.handlers import ProcessorTypeChoices, BasePromotionHandler, PromotionTypeChoices
|
|
|
|
from apps.users.models import User
|
|
from utils.clients.notifications_client import notifications_push_user
|
|
from utils.clients.wallet_client import deposit_to_user_wallet_submit, deposit_to_user_wallet_verify
|
|
from utils.models import BaseModel
|
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EventSaverQuerySet(models.QuerySet):
|
|
pass
|
|
|
|
|
|
class EventSaver(BaseModel):
|
|
title = models.CharField(_('title'), max_length=255)
|
|
event_label = models.CharField(_('label'), max_length=255, unique=True, db_index=True)
|
|
save_once = models.BooleanField(_('save once'), default=False)
|
|
|
|
objects = EventSaverQuerySet.as_manager()
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def save_event(self, **kwargs):
|
|
user = kwargs.get('user')
|
|
application = kwargs.get('application')
|
|
label = kwargs.get('label')
|
|
data = kwargs.get('data')
|
|
|
|
if self.save_once:
|
|
event, created = Event.objects.get_or_create(user=user, label=label,
|
|
defaults={"data": data, "application": application})
|
|
if created:
|
|
return event
|
|
else:
|
|
# TODO: good exception
|
|
raise Exception(_('already saved'))
|
|
|
|
else:
|
|
event = Event.objects.create(user=user, application=application, label=label, data=data)
|
|
return event
|
|
|
|
|
|
class EventQuerySet(models.QuerySet):
|
|
pass
|
|
|
|
|
|
class Event(BaseModel):
|
|
user = models.UUIDField(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)
|
|
data = models.JSONField() # from to amount application user etc
|
|
|
|
objects = EventQuerySet.as_manager()
|
|
|
|
class Meta:
|
|
indexes = [
|
|
GinIndex(
|
|
fields=['data'],
|
|
# hashlib.md5("promotions_event_data_GinIndex".encode()).hexdigest()[:8] used for hash
|
|
name='promotions_event_data_bc656abc'
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.label
|
|
|
|
def analyze(self):
|
|
for plan in Plan.objects.related_to_event(self):
|
|
yield 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)
|
|
|
|
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)
|
|
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)
|
|
processor = models.CharField(max_length=64, verbose_name=_('processor'), db_index=True,
|
|
choices=ProcessorTypeChoices.choices) # TODO: processor type
|
|
data = models.JSONField(default=dict, verbose_name=_('data'), null=True, blank=True)
|
|
balance = models.IntegerField(verbose_name=_('balance'), default=0)
|
|
|
|
objects = PlanQuerySet.as_manager()
|
|
|
|
class Meta:
|
|
indexes = [
|
|
GinIndex(
|
|
fields=['event_list'],
|
|
# hashlib.md5("promotions_plan_event_list_GinIndex".encode()).hexdigest()[:8] used for hash
|
|
name='promotions_plan_event_783bb033'
|
|
),
|
|
GinIndex(
|
|
fields=['data'],
|
|
# hashlib.md5("promotions_plan_data_GinIndex".encode()).hexdigest()[:8] used for hash
|
|
name='promotions_plan_data_b632bc41'
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def process_event(self, event=None):
|
|
# TODO: processor
|
|
return self.promote_all(event=event)
|
|
|
|
def promote_all(self, event=None):
|
|
for recipient in self.recipients.all():
|
|
yield recipient.promote(plan=self, event=event)
|
|
|
|
def reserve_promotion_amount(self, amount):
|
|
updated = False
|
|
if amount and self.balance >= amount:
|
|
updated = Plan.objects.filter(pk=self.pk, balance__gte=amount).update(
|
|
balance=F('balance') - amount
|
|
)
|
|
return updated
|
|
|
|
|
|
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)
|
|
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, null=True, blank=True)
|
|
|
|
recipient_uuid_field = models.CharField(max_length=255, db_index=True)
|
|
base_amount_field = models.CharField(max_length=255, db_index=True)
|
|
|
|
data = models.JSONField(null=True, blank=True, default=dict)
|
|
|
|
class Meta:
|
|
indexes = [
|
|
GinIndex(
|
|
fields=['data'],
|
|
# hashlib.md5("promotions_recipient_data_GinIndex".encode()).hexdigest()[:8] used for hash
|
|
name='promotions_recipient_fba22446'
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.label} --> {self.plan}"
|
|
|
|
def get_wallet_category_uuid(self):
|
|
return self.wallet_uuid or settings.WALLET_PROMOTION_CATEGORY_UUID
|
|
|
|
def get_recipient_uuid(self, plan=None, event=None):
|
|
if ':' in self.recipient_uuid_field and not self.recipient_uuid_field.startswith('QS:'):
|
|
|
|
model_name, key = self.recipient_uuid_field.split(':')
|
|
if model_name == 'event':
|
|
obj = event
|
|
elif model_name == 'plan':
|
|
obj = plan
|
|
else:
|
|
raise NotImplementedError()
|
|
|
|
return obj.data.get(key)
|
|
elif ':' in self.recipient_uuid_field and self.recipient_uuid_field.startswith('QS:'):
|
|
params, recipient_candidate = self.recipient_uuid_field.split('->')
|
|
query_set_flag, model, filters, = params.split(':')
|
|
filters_key_values = filters.split('&')
|
|
filter_params = {}
|
|
for key_value in filters_key_values:
|
|
key, value = key_value.split('=')
|
|
filter_params[key] = value
|
|
if model.lower() == 'event':
|
|
model_class = Event
|
|
elif model.lower() == 'plan':
|
|
model_class = Plan
|
|
elif model.lower() == 'promotion':
|
|
model_class = Promotion
|
|
else:
|
|
raise NotImplementedError()
|
|
|
|
if model_class.objects.filter(**filter_params).exists():
|
|
return recipient_candidate
|
|
|
|
else:
|
|
try:
|
|
uuid.UUID(self.recipient_uuid_field)
|
|
return self.recipient_uuid_field
|
|
except:
|
|
pass
|
|
|
|
def get_promotion_amount(self, plan=None, event=None):
|
|
# TODO: get base amount from policy_choice
|
|
base_amount = 0
|
|
max_promotion_amount = int(self.data.get('max_promotion_amount', 0))
|
|
promotion_percentage = int(self.data.get('promotion_percentage', 100))
|
|
try:
|
|
model_name, key = self.base_amount_field.split(':')
|
|
if model_name == 'event':
|
|
obj = event
|
|
elif model_name == 'plan':
|
|
obj = plan
|
|
else:
|
|
raise NotImplementedError()
|
|
|
|
base_amount = int(obj.data.get(key))
|
|
except Exception as e:
|
|
try:
|
|
base_amount = int(self.base_amount_field) # todo: to float
|
|
except:
|
|
logger.exception('can not pars base amount')
|
|
raise Exception('can not pars base amount')
|
|
|
|
promotion_amount = int(base_amount * (promotion_percentage / 100))
|
|
|
|
if max_promotion_amount:
|
|
if promotion_amount > max_promotion_amount:
|
|
return max_promotion_amount
|
|
else:
|
|
return promotion_amount
|
|
|
|
else:
|
|
return base_amount, promotion_amount
|
|
|
|
|
|
# @property
|
|
# def handler(self) -> BasePromotionHandler:
|
|
# return PromotionTypeChoices(self.promotion_type).handler(self)
|
|
|
|
def promote(self, plan=None, event=None):
|
|
recipient = self.get_recipient_uuid(plan, event)
|
|
base_amount, promotion_amount = self.get_promotion_amount(plan=plan, event=event)
|
|
promotion, created = Promotion.objects.get_or_create(
|
|
promotion_amount=promotion_amount,
|
|
user_uuid=self.get_recipient_uuid(plan, event),
|
|
event=event,
|
|
plan=plan,
|
|
recipient=self,
|
|
base_amount=base_amount,
|
|
)
|
|
if recipient and promotion_amount and created:
|
|
try:
|
|
with transaction.atomic():
|
|
reserved = self.plan.reserve_promotion_amount(promotion_amount)
|
|
if reserved:
|
|
promotion.promote()
|
|
else:
|
|
raise Exception('promotion reward not reserved')
|
|
except Exception as e:
|
|
promotion.change_state(from_states=[promotion.state], to_state=PaymentStateChoices.EXPECTED_FAILURE, same_ok=True)
|
|
promotion.refresh_from_db()
|
|
|
|
return promotion
|
|
return None
|
|
|
|
|
|
class PaymentStateChoices(models.IntegerChoices):
|
|
CREATED = 1, _('created')
|
|
DELAYED = 2, _('delayed') # delayed as user wish
|
|
PENDING = 3, _('pending') # wait for external service response (or action)
|
|
INCOMPLETE = 4, _('incomplete') # started and wait for internal progress to complete
|
|
SUCCESS = 5, _('success')
|
|
FAILED = 6, _('failed')
|
|
EXPECTED_FAILURE = 7, _('expected_failure') # no exact data available but guessed to be failed
|
|
|
|
|
|
class PromotionQuerySet(models.QuerySet):
|
|
def used(self, user):
|
|
return self.filter(user=user, state__in=[Promotion.PaymentStateChoices.CREATED])
|
|
|
|
|
|
class Promotion(BaseModel):
|
|
user_uuid = models.UUIDField() # user_uuid
|
|
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)
|
|
event = models.ForeignKey(Event, 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=PaymentStateChoices.choices, default=PaymentStateChoices.CREATED)
|
|
base_amount = models.IntegerField(null=True, blank=True)
|
|
promotion_amount = models.IntegerField(null=True, blank=True)
|
|
data = models.JSONField(null=True, blank=True)
|
|
|
|
objects = PromotionQuerySet.as_manager()
|
|
|
|
class Meta:
|
|
indexes = [
|
|
GinIndex(
|
|
fields=['data'],
|
|
# hashlib.md5("promotions_promotion_data_GinIndex".encode()).hexdigest()[:8] used for hash
|
|
name='promotions_promotion_cb76da3e'
|
|
),
|
|
]
|
|
|
|
def change_state(self, from_states: list | tuple, to_state, same_ok=True, raise_exception=False,
|
|
error_message=None,
|
|
data: None | dict = None):
|
|
data = data or {}
|
|
data['updated_at'] = timezone.now()
|
|
|
|
if same_ok and self.state == to_state:
|
|
return self
|
|
|
|
if self.state in from_states:
|
|
updated = Promotion.objects.filter(pk=self.pk, state__in=from_states).update(
|
|
state=to_state,
|
|
**data
|
|
)
|
|
if updated: # double check in db level
|
|
self.state = to_state
|
|
return self
|
|
|
|
if raise_exception:
|
|
raise Exception(error_message)
|
|
|
|
def promote(self, **kwargs):
|
|
plan_data = kwargs.get('plan_data', {})
|
|
plan_data['updated_at'] = timezone.now()
|
|
# plan_data = {"balance": 0}
|
|
|
|
logger.debug(f"start promotions: {self}")
|
|
|
|
if self.state not in [PaymentStateChoices.CREATED]:
|
|
raise Exception(_('Cannot promote. not in correct state'))
|
|
|
|
if not self.promotion_amount:
|
|
to_not_payed = self.change_state(
|
|
from_states=[PaymentStateChoices.CREATED],
|
|
to_state=PaymentStateChoices.SUCCESS,
|
|
same_ok=False,
|
|
raise_exception=True,
|
|
error_message='Failed to update state',
|
|
)
|
|
return bool(to_not_payed)
|
|
|
|
to_pending = self.change_state(
|
|
from_states=[self.state],
|
|
to_state=PaymentStateChoices.PENDING,
|
|
same_ok=False,
|
|
raise_exception=True,
|
|
error_message='Cannot promote. not in current state'
|
|
)
|
|
|
|
payment_uuid = str(self.uuid)
|
|
|
|
data = {
|
|
"uuid": payment_uuid,
|
|
"payee": str(self.user_uuid),
|
|
"payee_type": 1,
|
|
"payee_wallet": settings.WALLET_REWARD,
|
|
"amount": self.promotion_amount,
|
|
"details": {
|
|
'description': str(_("promotions")),
|
|
'reference_id': str(self.pk),
|
|
'application_details_url': ''
|
|
},
|
|
}
|
|
|
|
try:
|
|
submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data)
|
|
if not submit_response.uuid:
|
|
raise Exception('Failed to submit promotion. 1')
|
|
|
|
except Exception as e:
|
|
self.change_state(
|
|
from_states=[self.state],
|
|
to_state=PaymentStateChoices.FAILED,
|
|
same_ok=False,
|
|
raise_exception=True,
|
|
error_message='Failed to update state promotions'
|
|
)
|
|
# return False
|
|
raise Exception('Failed to submit promotion. 2')
|
|
|
|
try:
|
|
verify_response = deposit_to_user_wallet_verify(settings.WALLET_REWARD, submit_response.uuid)
|
|
if not verify_response.uuid:
|
|
raise Exception('Failed to verify promotion. 1')
|
|
|
|
except Exception as e:
|
|
self.change_state(
|
|
from_states=[self.state],
|
|
to_state=PaymentStateChoices.EXPECTED_FAILURE,
|
|
same_ok=False,
|
|
raise_exception=True,
|
|
error_message='Failed to update state'
|
|
)
|
|
# return False
|
|
raise Exception('Failed to verify promition. 2')
|
|
|
|
if verify_response.state != 5:
|
|
to_pay_failed = self.change_state(
|
|
from_states=[self.state],
|
|
to_state=PaymentStateChoices.FAILED,
|
|
same_ok=False,
|
|
raise_exception=True,
|
|
error_message='Failed to update state payment',
|
|
)
|
|
return False
|
|
|
|
try:
|
|
notifications_push_user(self.user_uuid, self.recipient.label, self.recipient.label, 5, extras={})
|
|
except:
|
|
# TODO: log
|
|
pass
|
|
|
|
with transaction.atomic():
|
|
# updated = Plan.objects.filter(pk=self.plan.pk).update(balance=F('balance') - self.promotion_amount, **plan_data)
|
|
updated = Plan.objects.filter(pk=self.plan.pk).update(**plan_data)
|
|
if updated:
|
|
to_pay_success = self.change_state(
|
|
from_states=[self.state],
|
|
to_state=PaymentStateChoices.SUCCESS,
|
|
same_ok=False,
|
|
raise_exception=True,
|
|
error_message='Failed to update state refund',
|
|
)
|
|
return bool(to_pay_success)
|
|
else:
|
|
# TODO: is it possible?
|
|
to_pay_failed = self.change_state(
|
|
from_states=[self.state],
|
|
to_state=PaymentStateChoices.EXPECTED_FAILURE,
|
|
same_ok=False,
|
|
raise_exception=True,
|
|
error_message='Failed to update state payment',
|
|
)
|
|
return False
|