import logging from contextlib import suppress from django.conf import settings from django.core.exceptions import ValidationError 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 django.utils.functional import cached_property 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 CHOICES = ( (OTHER_DEPOSIT, 'Другое начисление'), (SIGNUP, 'Бонус за регистрацию'), (DEFAULT_PURCHASE, 'Бонус за покупку'), (FOR_INVITER, 'Бонус за первую покупку приглашенного'), (INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'), (OTHER_WITHDRAWAL, 'Другое списание'), (SPENT_PURCHASE, 'Списание бонусов за заказ'), ) LOG_NAMES = { OTHER_DEPOSIT: 'OTHER_DEPOSIT', SIGNUP: 'SIGNUP', DEFAULT_PURCHASE: 'DEFAULT_PURCHASE', FOR_INVITER: 'FOR_INVITER', INVITED_FIRST_PURCHASE: 'INVITED_FIRST_PURCHASE', OTHER_WITHDRAWAL: 'OTHER_WITHDRAWAL', SPENT_PURCHASE: 'SPENT_PURCHASE', } ONE_TIME_TYPES = {SIGNUP} ORDER_TYPES = {DEFAULT_PURCHASE, FOR_INVITER, INVITED_FIRST_PURCHASE, SPENT_PURCHASE} class BonusProgramTransactionQuerySet(models.QuerySet): # TODO: optimize queries def with_base_related(self): return self.select_related('order') def cancel(self): for t in self: t.cancel() 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: comment = self.comment or "—" msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment) case BonusType.OTHER_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(self): # Skip transactions that refers to cancelled ones if self.was_cancelled: 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( user_id=self.user_id, type=bonus_type, amount=self.amount * -1, comment=comment, order=self.order, was_cancelled=True ) transaction.save() self.was_cancelled = True self.save() def delete(self, *args, **kwargs): # Don't delete transaction, cancel it instead self.cancel() def clean(self): # No underflow or dummy transactions allowed. Fail loudly if self.amount == 0 or (self.user.balance + self.amount) < 0: raise ValidationError("No underflow or dummy transactions allowed") # Check for uniqueness for given user qs = self.user.bonus_history.filter(type=self.type) if self.id: qs = qs.exclude(id=self.id) uniqueness_err = None bonus_name = BonusType.LOG_NAMES.get(self.type, self.type) match self.type: case t if t in BonusType.ONE_TIME_TYPES: if qs.exists(): uniqueness_err = f"User {self.user_id} already got {bonus_name} one-time bonus" case t if t in BonusType.ORDER_TYPES: # Check that order is defined if self.order_id is None: raise ValidationError("Order is required for that type") # Check for duplicates for the same order already_exists = qs.filter(order_id=self.order_id).exists() if already_exists: uniqueness_err = f"User {self.user_id} already got {bonus_name} bonus for order #{self.order_id}" if uniqueness_err: logger.info(uniqueness_err) raise ValidationError(uniqueness_err) def save(self, *args, **kwargs): self.full_clean() 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 @cached_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, fail silently 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() self.refresh_from_db(fields=['balance']) def recalculate_balance(self): total_balance = self.bonus_history \ .aggregate(total_balance=Sum('amount'))['total_balance'] or 0 self.balance = max(0, total_balance) self.save(update_fields=['balance']) def spend_bonuses(self, order: Checklist): if order is None or order.customer_id is None: return # Always use fresh balance self.recalculate_balance() # Spend full_price bonuses or nothing to_spend = min(self.balance, order.full_price) order.customer.update_balance(-to_spend, BonusType.SPENT_PURCHASE, order=order) def add_signup_bonus(self): bonus_type = BonusType.SIGNUP amount = self._get_bonus_amount("signup") 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 not in Checklist.Status.BONUS_ORDER_STATUSES: 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 # 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)