From 1f4d693c8125ad716f15f59c93c6224f50844679 Mon Sep 17 00:00:00 2001 From: phzhik Date: Mon, 20 May 2024 21:46:24 +0400 Subject: [PATCH] + More bonus validation + Bonus transaction & order cancellation + Spend bonus via API + New status for order: DELETED * Fixed bug with not actual bonus balance returned * Order bonus can be added in several statuses * Fixed TG templates a bit --- account/admin.py | 2 +- ...0017_alter_bonusprogramtransaction_type.py | 18 +++ account/models/bonus.py | 141 +++++++++++------- account/models/user.py | 4 +- account/serializers.py | 3 +- account/views.py | 5 +- .../migrations/0004_alter_checklist_status.py | 18 +++ store/models.py | 33 ++++ store/serializers.py | 13 +- store/views.py | 16 +- tg_bot/messages.py | 4 +- 11 files changed, 187 insertions(+), 70 deletions(-) create mode 100644 account/migrations/0017_alter_bonusprogramtransaction_type.py create mode 100644 store/migrations/0004_alter_checklist_status.py diff --git a/account/admin.py b/account/admin.py index 52eccfe..f28ef32 100644 --- a/account/admin.py +++ b/account/admin.py @@ -17,5 +17,5 @@ class BonusProgramTransactionAdmin(admin.ModelAdmin): def delete_queryset(self, request, queryset): for obj in queryset: - obj.cancel_transaction() + obj.cancel() diff --git a/account/migrations/0017_alter_bonusprogramtransaction_type.py b/account/migrations/0017_alter_bonusprogramtransaction_type.py new file mode 100644 index 0000000..1e77ceb --- /dev/null +++ b/account/migrations/0017_alter_bonusprogramtransaction_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-05-20 17:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0016_alter_user_role'), + ] + + operations = [ + migrations.AlterField( + model_name='bonusprogramtransaction', + name='type', + field=models.PositiveSmallIntegerField(choices=[(0, 'Другое начисление'), (1, 'Бонус за регистрацию'), (2, 'Бонус за покупку'), (3, 'Бонус за первую покупку приглашенного'), (4, 'Бонус за первую покупку'), (10, 'Другое списание'), (11, 'Списание бонусов за заказ')], verbose_name='Тип транзакции'), + ), + ] diff --git a/account/models/bonus.py b/account/models/bonus.py index 8a9eda2..b3a4c80 100644 --- a/account/models/bonus.py +++ b/account/models/bonus.py @@ -2,11 +2,13 @@ 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 @@ -36,12 +38,6 @@ class BonusType: # Клиент потратил баллы на заказ SPENT_PURCHASE = 11 - # Отмена начисления - CANCELLED_DEPOSIT = 20 - - # Отмена списания - CANCELLED_WITHDRAWAL = 21 - CHOICES = ( (OTHER_DEPOSIT, 'Другое начисление'), (SIGNUP, 'Бонус за регистрацию'), @@ -51,16 +47,31 @@ class BonusType: (OTHER_WITHDRAWAL, 'Другое списание'), (SPENT_PURCHASE, 'Списание бонусов за заказ'), - - (CANCELLED_DEPOSIT, 'Отмена начисления'), - (CANCELLED_WITHDRAWAL, 'Отмена списания'), ) + 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 + return self.select_related('order') + + def cancel(self): + for t in self: + t.cancel() class BonusProgramTransaction(models.Model): @@ -99,11 +110,11 @@ class BonusProgramTransaction(models.Model): 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: + case BonusType.OTHER_DEPOSIT: comment = self.comment or "—" msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment) - case BonusType.OTHER_WITHDRAWAL | BonusType.CANCELLED_WITHDRAWAL: + case BonusType.OTHER_WITHDRAWAL: comment = self.comment or "—" msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment) @@ -116,10 +127,9 @@ class BonusProgramTransaction(models.Model): 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): + def cancel(self): + # Skip transactions that refers to cancelled ones + if self.was_cancelled: return date_formatted = localize(timezone.localtime(self.date)) @@ -134,13 +144,14 @@ class BonusProgramTransaction(models.Model): 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 = 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 @@ -148,9 +159,43 @@ class BonusProgramTransaction(models.Model): def delete(self, *args, **kwargs): # Don't delete transaction, cancel it instead - self.cancel_transaction() + 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() @@ -181,12 +226,12 @@ class BonusProgramMixin(models.Model): class Meta: abstract = True - @property + @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 + # No underflow or dummy transactions allowed, fail silently if amount == 0 or (self.balance + amount) < 0: return @@ -195,27 +240,30 @@ class BonusProgramMixin(models.Model): amount=amount, type=bonus_type, comment=comment, order=order) transaction.save() + self.refresh_from_db(fields=['balance']) 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 + 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") - 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): @@ -224,15 +272,7 @@ class BonusProgramMixin(models.Model): 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}") + if order.status not in Checklist.Status.BONUS_ORDER_STATUSES: return self.update_balance(amount, bonus_type, order=order) @@ -252,15 +292,6 @@ class BonusProgramMixin(models.Model): 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) diff --git a/account/models/user.py b/account/models/user.py index ea2b92c..29626df 100644 --- a/account/models/user.py +++ b/account/models/user.py @@ -68,7 +68,7 @@ class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager): logger.warning(f"User #{inviter.id} already invited user #{user_id}") return - async def bind_tg_user(self, tg_user_id, phone, referral_code=None): + async def bind_tg_user(self, tg_user_id, phone, referral_code=None) -> bool: # Normalize phone: 79111234567 -> +79111234567 phone = PhoneNumber.from_string(phone).as_e164 @@ -109,6 +109,8 @@ class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager): logger.info(f"tgbot: Telegram user #{tg_user_id} was bound to user #{user.id}") + return freshly_created + class User(BonusProgramMixin, AbstractUser): ADMIN = "admin" diff --git a/account/serializers.py b/account/serializers.py index fd6624c..919ee9f 100644 --- a/account/serializers.py +++ b/account/serializers.py @@ -20,11 +20,12 @@ class UserSerializer(serializers.ModelSerializer): class BonusProgramTransactionSerializer(serializers.ModelSerializer): + order_id = serializers.StringRelatedField(source='order.id', allow_null=True) type = serializers.CharField(source='get_type_display') class Meta: model = BonusProgramTransaction - fields = ('id', 'type', 'date', 'amount', 'comment', 'was_cancelled') + fields = ('id', 'type', 'date', 'amount', 'order_id', 'comment', 'was_cancelled') def non_zero_validator(value): diff --git a/account/views.py b/account/views.py index 1c4263e..abe2f13 100644 --- a/account/views.py +++ b/account/views.py @@ -15,12 +15,9 @@ from account.serializers import SetInitialPasswordSerializer, BonusProgramTransa UserBalanceUpdateSerializer, TelegramCallbackSerializer from tg_bot.handlers.start import request_phone_sync from tg_bot.messages import TGCoreMessage -from tg_bot.bot import bot_sync class UserViewSet(djoser_views.UserViewSet): - """ Replacement for Djoser's UserViewSet """ - def permission_denied(self, request, **kwargs): if ( djoser_settings.HIDE_USERS @@ -147,7 +144,7 @@ class TelegramLoginForm(views.APIView): # Sign up user user = User.objects.create_draft_user(tg_user_id=tg_user_id) # Request the phone through the bot - request_phone_sync(tg_user_id, TGCoreMessage.SIGN_UP_SHARE_PHONE) + request_phone_sync(tg_user_id, TGCoreMessage.TG_AUTH_SHARE_PHONE) token = login_user(request, user) return Response({"auth_token": token.key}) diff --git a/store/migrations/0004_alter_checklist_status.py b/store/migrations/0004_alter_checklist_status.py new file mode 100644 index 0000000..89d2042 --- /dev/null +++ b/store/migrations/0004_alter_checklist_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-05-20 17:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0003_remove_checklist_buyer_name_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='checklist', + name='status', + field=models.CharField(choices=[('deleted', 'Удален'), ('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('split_waiting', 'Сплит: ожидание оплаты 2й части'), ('split_paid', 'Сплит: полностью оплачено'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'), + ), + ] diff --git a/store/models.py b/store/models.py index 25e9200..ee3af58 100644 --- a/store/models.py +++ b/store/models.py @@ -14,6 +14,7 @@ from django.db import models from django.db.models import F, Case, When, DecimalField, Prefetch, Max, Q from django.db.models.functions import Ceil from django.db.models.lookups import GreaterThan +from django.db.transaction import atomic from django.utils import timezone from django_cleanup import cleanup from mptt.fields import TreeForeignKey @@ -251,6 +252,7 @@ class PriceSnapshot(models.Model): class Checklist(models.Model): # Statuses class Status: + DELETED = "deleted" DRAFT = "draft" NEW = "neworder" PAYMENT = "payment" @@ -267,7 +269,11 @@ class Checklist(models.Model): PDF_AVAILABLE_STATUSES = (RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED) CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK) + CANCELLABLE_ORDER_STATUSES = (DRAFT, NEW, PAYMENT, BUYING) + BONUS_ORDER_STATUSES = (CHINA_RUSSIA, RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED) + CHOICES = ( + (DELETED, 'Удален'), (DRAFT, 'Черновик'), (NEW, 'Новый заказ'), (PAYMENT, 'Проверка оплаты'), @@ -516,6 +522,32 @@ class Checklist(models.Model): return commission + @property + def bonus_used(self): + if self.customer_id is None: + return 0 + + # It is guaranteed that there is only one SPENT_PURCHASE for this order + transaction = self.customer.bonus_history.filter(order_id=self.id).first() + return abs(transaction.amount) if transaction else 0 + + @atomic() + def cancel(self): + """ Cancel the order and return all bonuses """ + + # Don't delete orders entirely, just change the status + if self.status == Checklist.Status.DRAFT: + self.status = Checklist.Status.DELETED + else: + self.status = Checklist.Status.DRAFT + + self.save() + + if self.customer_id is None: + return + + self.customer.bonus_history.filter(order_id=self.id, was_cancelled=False).cancel() + @property def preview_image(self): # Prefer annotated field @@ -589,6 +621,7 @@ class Checklist(models.Model): if tg_message: self.customer.notify_tg_bot(tg_message) + @atomic def _check_eligible_for_order_bonus(self): if self.customer_id is None: return diff --git a/store/serializers.py b/store/serializers.py index 7e1a927..94b4557 100644 --- a/store/serializers.py +++ b/store/serializers.py @@ -1,3 +1,4 @@ +from django.db.transaction import atomic from drf_extra_fields.fields import Base64ImageField from rest_framework import serializers @@ -75,6 +76,7 @@ class ChecklistSerializer(serializers.ModelSerializer): promocode = serializers.SlugRelatedField(slug_field='name', queryset=Promocode.objects.active(), required=False, allow_null=True) + use_bonuses = serializers.BooleanField(write_only=True, default=False) gift = get_primary_key_related_model(GiftSerializer, required=False, allow_null=True) yuan_rate = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) @@ -126,6 +128,7 @@ class ChecklistSerializer(serializers.ModelSerializer): instance.images.set(img_objs) instance.generate_preview(next(iter(img_objs), None)) + @atomic def create(self, validated_data): images = self._collect_images_by_fields(validated_data) @@ -135,8 +138,14 @@ class ChecklistSerializer(serializers.ModelSerializer): if not user.is_manager or validated_data.get('customer') is None: validated_data['customer'] = user + use_bonuses = validated_data.pop('use_bonuses') + instance = super().create(validated_data) self._create_main_images(instance, images.get('main_images')) + + if use_bonuses: + instance.customer.spend_bonuses(order=instance) + return instance def update(self, instance, validated_data): @@ -170,7 +179,7 @@ class ChecklistSerializer(serializers.ModelSerializer): 'image', 'preview_image_url', 'yuan_rate', 'price_yuan', 'price_rub', 'delivery_price_CN', 'delivery_price_CN_RU', 'commission_rub', - 'promocode', 'gift', + 'promocode', 'gift', 'use_bonuses', 'bonus_used', 'comment', 'full_price', 'real_price', 'customer', @@ -203,7 +212,7 @@ class ClientCreateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistS writable_fields = { 'link', 'brand', 'model', 'size', 'category', - 'price_yuan', + 'price_yuan', 'use_bonuses', 'comment', } read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - writable_fields) diff --git a/store/views.py b/store/views.py index e36c932..ad44883 100644 --- a/store/views.py +++ b/store/views.py @@ -78,23 +78,31 @@ class ChecklistAPI(viewsets.ModelViewSet): if self.action == "create": return ClientCreateChecklistSerializer - # Then, clients can update small set of fields - elif self.action in ['update', 'partial_update']: + # Then, clients can update small set of fields and cancel orders + elif self.action in ['update', 'partial_update', 'destroy']: return ClientUpdateChecklistSerializer # Fallback to error self.permission_denied(self.request, **self.kwargs) def get_permissions(self): - if self.action in ['list', 'update', 'partial_update', 'destroy']: + if self.action in ['list', 'update', 'partial_update']: self.permission_classes = [IsManager] elif self.action == 'retrieve': self.permission_classes = [AllowAny] - elif self.action == 'create': + elif self.action in ['create', 'destroy']: self.permission_classes = [IsAuthenticated] return super().get_permissions() + def perform_destroy(self, obj: Checklist): + # Non-managers can cancel orders only in certain statuses + if (not getattr(self.request.user, 'is_manager', False) + and obj.status not in Checklist.Status.CANCELLABLE_ORDER_STATUSES): + raise CRMException("Can't delete the order") + + obj.cancel() + def get_queryset(self): return Checklist.objects.all().with_base_related() \ .annotate_price_rub().annotate_commission_rub() \ diff --git a/tg_bot/messages.py b/tg_bot/messages.py index f251d9a..489ad29 100644 --- a/tg_bot/messages.py +++ b/tg_bot/messages.py @@ -3,7 +3,7 @@ class TGBonusMessage: SIGNUP = "Вам начислено {amount}Р баллов за привязку номера телефона 🎊" - PURCHASE_ADDED = "Ура! Вам начислено {amount}P баллов за оформление заказа №{order_id}} 🎉" + PURCHASE_ADDED = "Ура! Вам начислено {amount}P баллов за оформление заказа №{order_id} 🎉" PURCHASE_SPENT = "Вы потратили {amount}P баллов на оформление заказа №{order_id}" FOR_INVITER = """ Спасибо, твой друг оформил заказ и получил {amount}Р бонусов ✅ @@ -75,7 +75,7 @@ class TGCoreMessage: SHARE_PHONE_KEYBOARD = "Отправить номер телефона" SHARE_PHONE = "Поделитесь своим номером, чтобы авторизоваться:" - SIGN_UP_SHARE_PHONE = """ + TG_AUTH_SHARE_PHONE = """ Добро пожаловать в PoizonStore! Для завершения регистрации, поделитесь номером телефона: """