+ 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
This commit is contained in:
Phil Zhitnikov 2024-05-20 21:46:24 +04:00
parent 12ca631d15
commit 1f4d693c81
11 changed files with 187 additions and 70 deletions

View File

@ -17,5 +17,5 @@ class BonusProgramTransactionAdmin(admin.ModelAdmin):
def delete_queryset(self, request, queryset):
for obj in queryset:
obj.cancel_transaction()
obj.cancel()

View File

@ -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='Тип транзакции'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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='Статус заказа'),
),
]

View File

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

View File

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

View File

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

View File

@ -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!
Для завершения регистрации, поделитесь номером телефона:
"""