+ Telegram bot: sign up, sign in, notifications + Anonymous users can't see yuan_rate_commission * Only logged in customers can create/update orders * Customer info migrated to separate User model * Renamed legacy fields in serializers * Cleanup in API classes
279 lines
10 KiB
Python
279 lines
10 KiB
Python
import logging
|
||
from contextlib import suppress
|
||
|
||
from django.conf import settings
|
||
from django.db import models
|
||
from django.db.models import Sum
|
||
from django.utils import timezone
|
||
from django.utils.crypto import get_random_string
|
||
from django.utils.formats import localize
|
||
|
||
from store.models import Checklist
|
||
from tg_bot.messages import TGBonusMessage
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BonusType:
|
||
# Другое начисление
|
||
OTHER_DEPOSIT = 0
|
||
|
||
# Клиент передал номер ТГ-боту
|
||
SIGNUP = 1
|
||
|
||
# Клиент сделал заказ
|
||
DEFAULT_PURCHASE = 2
|
||
|
||
# Приглашенный клиент сделал свою первую покупку, бонус реферреру
|
||
FOR_INVITER = 3
|
||
|
||
# Клиент сделал заказ и получил бонус за первую покупку от реферрера
|
||
INVITED_FIRST_PURCHASE = 4
|
||
|
||
# Другое списание
|
||
OTHER_WITHDRAWAL = 10
|
||
|
||
# Клиент потратил баллы на заказ
|
||
SPENT_PURCHASE = 11
|
||
|
||
# Отмена начисления
|
||
CANCELLED_DEPOSIT = 20
|
||
|
||
# Отмена списания
|
||
CANCELLED_WITHDRAWAL = 21
|
||
|
||
CHOICES = (
|
||
(OTHER_DEPOSIT, 'Другое начисление'),
|
||
(SIGNUP, 'Бонус за регистрацию'),
|
||
(DEFAULT_PURCHASE, 'Бонус за покупку'),
|
||
(FOR_INVITER, 'Бонус за первую покупку приглашенного'),
|
||
(INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'),
|
||
|
||
(OTHER_WITHDRAWAL, 'Другое списание'),
|
||
(SPENT_PURCHASE, 'Списание бонусов за заказ'),
|
||
|
||
(CANCELLED_DEPOSIT, 'Отмена начисления'),
|
||
(CANCELLED_WITHDRAWAL, 'Отмена списания'),
|
||
)
|
||
|
||
|
||
class BonusProgramTransactionQuerySet(models.QuerySet):
|
||
# TODO: optimize queries
|
||
def with_base_related(self):
|
||
return self
|
||
|
||
|
||
class BonusProgramTransaction(models.Model):
|
||
""" Represents the history of all bonus program transactions """
|
||
|
||
type = models.PositiveSmallIntegerField('Тип транзакции', choices=BonusType.CHOICES)
|
||
user = models.ForeignKey('User', verbose_name='Пользователь транзакции', on_delete=models.CASCADE)
|
||
date = models.DateTimeField('Дата транзакции', auto_now_add=True)
|
||
amount = models.SmallIntegerField('Количество, руб')
|
||
comment = models.CharField('Комментарий', max_length=200, null=True, blank=True)
|
||
was_cancelled = models.BooleanField('Была отменена', editable=False, default=False)
|
||
|
||
# Bound objects
|
||
order = models.ForeignKey('store.Checklist', verbose_name="Связанный заказ", null=True, blank=True, on_delete=models.SET_NULL)
|
||
|
||
objects = BonusProgramTransactionQuerySet.as_manager()
|
||
|
||
class Meta:
|
||
ordering = ['-date']
|
||
verbose_name = "История баланса"
|
||
verbose_name_plural = "История баланса"
|
||
|
||
def _notify_user_about_new_transaction(self):
|
||
msg = None
|
||
|
||
match self.type:
|
||
case BonusType.SIGNUP:
|
||
msg = TGBonusMessage.SIGNUP.format(amount=self.amount)
|
||
|
||
case BonusType.DEFAULT_PURCHASE:
|
||
msg = TGBonusMessage.PURCHASE_ADDED.format(amount=self.amount, order_id=self.order.id)
|
||
|
||
case BonusType.FOR_INVITER:
|
||
msg = TGBonusMessage.FOR_INVITER.format(amount=self.amount)
|
||
|
||
case BonusType.INVITED_FIRST_PURCHASE:
|
||
msg = TGBonusMessage.INVITED_FIRST_PURCHASE.format(amount=self.amount, order_id=self.order.id)
|
||
|
||
case BonusType.OTHER_DEPOSIT | BonusType.CANCELLED_DEPOSIT:
|
||
comment = self.comment or "—"
|
||
msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment)
|
||
|
||
case BonusType.OTHER_WITHDRAWAL | BonusType.CANCELLED_WITHDRAWAL:
|
||
comment = self.comment or "—"
|
||
msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment)
|
||
|
||
case BonusType.SPENT_PURCHASE:
|
||
msg = TGBonusMessage.PURCHASE_SPENT.format(amount=abs(self.amount), order_id=self.order_id)
|
||
|
||
case _:
|
||
pass
|
||
|
||
if msg is not None:
|
||
self.user.notify_tg_bot(msg)
|
||
|
||
def cancel_transaction(self):
|
||
# Skip already cancelled transactions
|
||
# TODO: if reverse transaction is being deleted, revert the source one?
|
||
if self.was_cancelled or self.type in (BonusType.OTHER_WITHDRAWAL, BonusType.OTHER_DEPOSIT):
|
||
return
|
||
|
||
date_formatted = localize(timezone.localtime(self.date))
|
||
|
||
if self.amount > 0:
|
||
comment = f"Отмена начисления #{self.id} от {date_formatted}"
|
||
bonus_type = BonusType.OTHER_WITHDRAWAL
|
||
elif self.amount < 0:
|
||
comment = f"Отмена списания #{self.id} от {date_formatted}"
|
||
bonus_type = BonusType.OTHER_DEPOSIT
|
||
else:
|
||
return
|
||
|
||
# Create reverse transaction, user's balance will be recalculated in post_save signal
|
||
transaction = BonusProgramTransaction()
|
||
|
||
transaction.user_id = self.user_id
|
||
transaction.type = bonus_type
|
||
transaction.amount = self.amount * -1
|
||
transaction.comment = comment
|
||
transaction.order = self.order
|
||
transaction.save()
|
||
|
||
self.was_cancelled = True
|
||
self.save()
|
||
|
||
def delete(self, *args, **kwargs):
|
||
# Don't delete transaction, cancel it instead
|
||
self.cancel_transaction()
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.id is None:
|
||
self._notify_user_about_new_transaction()
|
||
|
||
return super().save(*args, **kwargs)
|
||
|
||
|
||
def generate_referral_code():
|
||
""" Generate unique numeric referral code for User """
|
||
from account.models import User
|
||
|
||
while True:
|
||
allowed_chars = "0123456789"
|
||
code = get_random_string(settings.REFERRAL_CODE_LENGTH, allowed_chars)
|
||
|
||
# Hacky code for migrations
|
||
if "referral_code" not in User._meta.fields:
|
||
return code
|
||
|
||
if not User.objects.filter(referral_code=code).exists():
|
||
return code
|
||
|
||
|
||
class BonusProgramMixin(models.Model):
|
||
balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False)
|
||
referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code,
|
||
editable=False)
|
||
|
||
class Meta:
|
||
abstract = True
|
||
|
||
@property
|
||
def bonus_history(self):
|
||
return BonusProgramTransaction.objects.filter(user_id=self.id)
|
||
|
||
def update_balance(self, amount, bonus_type, comment=None, order=None):
|
||
# No underflow or dummy transactions allowed
|
||
if amount == 0 or (self.balance + amount) < 0:
|
||
return
|
||
|
||
# Create bonus transaction, user's balance will be recalculated in post_save signal
|
||
transaction = BonusProgramTransaction(user_id=self.id,
|
||
amount=amount, type=bonus_type,
|
||
comment=comment, order=order)
|
||
transaction.save()
|
||
|
||
def recalculate_balance(self):
|
||
# TODO: use this method when checking the available balance upon order creation
|
||
total_balance = BonusProgramTransaction.objects \
|
||
.filter(user_id=self.id) \
|
||
.aggregate(total_balance=Sum('amount'))['total_balance'] or 0
|
||
|
||
self.balance = max(0, total_balance)
|
||
self.save(update_fields=['balance'])
|
||
|
||
def add_signup_bonus(self):
|
||
bonus_type = BonusType.SIGNUP
|
||
amount = self._get_bonus_amount("signup")
|
||
|
||
already_exists = (BonusProgramTransaction.objects
|
||
.filter(user_id=self.id, type=bonus_type)
|
||
.exists())
|
||
if already_exists:
|
||
self._log(logging.INFO, "User already had signup bonus")
|
||
return
|
||
|
||
self.update_balance(amount, bonus_type)
|
||
|
||
def add_order_bonus(self, order):
|
||
from store.models import Checklist
|
||
|
||
bonus_type = BonusType.DEFAULT_PURCHASE
|
||
amount = self._get_bonus_amount("default_purchase")
|
||
|
||
if order.status != Checklist.Status.CHINA_RUSSIA:
|
||
return
|
||
|
||
already_exists = (BonusProgramTransaction.objects
|
||
.filter(user_id=self.id, type=bonus_type, order_id=order.id)
|
||
.exists())
|
||
|
||
if already_exists:
|
||
self._log(logging.INFO, f"User already got bonus for order #{order.id}")
|
||
return
|
||
|
||
self.update_balance(amount, bonus_type, order=order)
|
||
|
||
def add_referral_bonus(self, order: Checklist, for_inviter: bool):
|
||
amount = self._get_bonus_amount("referral")
|
||
|
||
# Check if data is sufficient
|
||
if order.customer_id is None or order.customer.inviter is None:
|
||
return
|
||
|
||
# Check if eligible
|
||
# Bonus is only for first purchase and only for orders that reached the CHINA_RUSSIA status
|
||
if order.status != Checklist.Status.CHINA_RUSSIA or order.customer.customer_orders.count() != 1:
|
||
return
|
||
|
||
user = order.customer.inviter if for_inviter else order.customer
|
||
bonus_type = BonusType.FOR_INVITER if for_inviter else BonusType.INVITED_FIRST_PURCHASE
|
||
|
||
# Check if user didn't receive bonus yet
|
||
already_exists = (BonusProgramTransaction.objects
|
||
.filter(user_id=user.id, type=bonus_type, order_id=order.id)
|
||
.exists())
|
||
|
||
if already_exists:
|
||
self._log(logging.INFO, f"User already got referral bonus for order #{order.id}")
|
||
return
|
||
|
||
# Add bonuses
|
||
user.update_balance(amount, bonus_type, order=order)
|
||
|
||
@staticmethod
|
||
def _get_bonus_amount(config_key) -> int:
|
||
amount = 0
|
||
with suppress(KeyError):
|
||
amount = settings.BONUS_PROGRAM_CONFIG["amounts"][config_key]
|
||
|
||
return amount
|
||
|
||
# TODO: move to custom logger
|
||
def _log(self, level, message: str):
|
||
message = f"[BonusProgram #{self.id}] {message}"
|
||
logger.log(level, message)
|