Compare commits

...

5 Commits

Author SHA1 Message Date
d0d637051d * Cleanup 2024-05-20 23:09:49 +04:00
4a987b9646 * Renamed fields in PromocodeSerializer 2024-05-20 23:08:30 +04:00
4a821faee9 + PriceField 2024-05-20 23:08:11 +04:00
81a3e15418 - DISABLE_PERMISSIONS 2024-05-20 23:05:27 +04:00
1f4d693c81 + 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
2024-05-20 21:46:24 +04:00
15 changed files with 223 additions and 114 deletions

View File

@ -17,5 +17,5 @@ class BonusProgramTransactionAdmin(admin.ModelAdmin):
def delete_queryset(self, request, queryset): def delete_queryset(self, request, queryset):
for obj in 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 contextlib import suppress
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
from django.utils import timezone from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.functional import cached_property
from store.models import Checklist from store.models import Checklist
from tg_bot.messages import TGBonusMessage from tg_bot.messages import TGBonusMessage
@ -36,12 +38,6 @@ class BonusType:
# Клиент потратил баллы на заказ # Клиент потратил баллы на заказ
SPENT_PURCHASE = 11 SPENT_PURCHASE = 11
# Отмена начисления
CANCELLED_DEPOSIT = 20
# Отмена списания
CANCELLED_WITHDRAWAL = 21
CHOICES = ( CHOICES = (
(OTHER_DEPOSIT, 'Другое начисление'), (OTHER_DEPOSIT, 'Другое начисление'),
(SIGNUP, 'Бонус за регистрацию'), (SIGNUP, 'Бонус за регистрацию'),
@ -51,16 +47,31 @@ class BonusType:
(OTHER_WITHDRAWAL, 'Другое списание'), (OTHER_WITHDRAWAL, 'Другое списание'),
(SPENT_PURCHASE, 'Списание бонусов за заказ'), (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): class BonusProgramTransactionQuerySet(models.QuerySet):
# TODO: optimize queries # TODO: optimize queries
def with_base_related(self): 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): class BonusProgramTransaction(models.Model):
@ -99,11 +110,11 @@ class BonusProgramTransaction(models.Model):
case BonusType.INVITED_FIRST_PURCHASE: case BonusType.INVITED_FIRST_PURCHASE:
msg = TGBonusMessage.INVITED_FIRST_PURCHASE.format(amount=self.amount, order_id=self.order.id) 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 "" comment = self.comment or ""
msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment) 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 "" comment = self.comment or ""
msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment) msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment)
@ -116,10 +127,9 @@ class BonusProgramTransaction(models.Model):
if msg is not None: if msg is not None:
self.user.notify_tg_bot(msg) self.user.notify_tg_bot(msg)
def cancel_transaction(self): def cancel(self):
# Skip already cancelled transactions # Skip transactions that refers to cancelled ones
# TODO: if reverse transaction is being deleted, revert the source one? if self.was_cancelled:
if self.was_cancelled or self.type in (BonusType.OTHER_WITHDRAWAL, BonusType.OTHER_DEPOSIT):
return return
date_formatted = localize(timezone.localtime(self.date)) date_formatted = localize(timezone.localtime(self.date))
@ -134,13 +144,14 @@ class BonusProgramTransaction(models.Model):
return return
# Create reverse transaction, user's balance will be recalculated in post_save signal # Create reverse transaction, user's balance will be recalculated in post_save signal
transaction = BonusProgramTransaction() transaction = BonusProgramTransaction(
user_id=self.user_id,
transaction.user_id = self.user_id type=bonus_type,
transaction.type = bonus_type amount=self.amount * -1,
transaction.amount = self.amount * -1 comment=comment,
transaction.comment = comment order=self.order,
transaction.order = self.order was_cancelled=True
)
transaction.save() transaction.save()
self.was_cancelled = True self.was_cancelled = True
@ -148,9 +159,43 @@ class BonusProgramTransaction(models.Model):
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
# Don't delete transaction, cancel it instead # 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): def save(self, *args, **kwargs):
self.full_clean()
if self.id is None: if self.id is None:
self._notify_user_about_new_transaction() self._notify_user_about_new_transaction()
@ -181,12 +226,12 @@ class BonusProgramMixin(models.Model):
class Meta: class Meta:
abstract = True abstract = True
@property @cached_property
def bonus_history(self): def bonus_history(self):
return BonusProgramTransaction.objects.filter(user_id=self.id) return BonusProgramTransaction.objects.filter(user_id=self.id)
def update_balance(self, amount, bonus_type, comment=None, order=None): 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: if amount == 0 or (self.balance + amount) < 0:
return return
@ -195,27 +240,30 @@ class BonusProgramMixin(models.Model):
amount=amount, type=bonus_type, amount=amount, type=bonus_type,
comment=comment, order=order) comment=comment, order=order)
transaction.save() transaction.save()
self.refresh_from_db(fields=['balance'])
def recalculate_balance(self): def recalculate_balance(self):
# TODO: use this method when checking the available balance upon order creation total_balance = self.bonus_history \
total_balance = BonusProgramTransaction.objects \ .aggregate(total_balance=Sum('amount'))['total_balance'] or 0
.filter(user_id=self.id) \
.aggregate(total_balance=Sum('amount'))['total_balance'] or 0
self.balance = max(0, total_balance) self.balance = max(0, total_balance)
self.save(update_fields=['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): def add_signup_bonus(self):
bonus_type = BonusType.SIGNUP bonus_type = BonusType.SIGNUP
amount = self._get_bonus_amount("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) self.update_balance(amount, bonus_type)
def add_order_bonus(self, order): def add_order_bonus(self, order):
@ -224,15 +272,7 @@ class BonusProgramMixin(models.Model):
bonus_type = BonusType.DEFAULT_PURCHASE bonus_type = BonusType.DEFAULT_PURCHASE
amount = self._get_bonus_amount("default_purchase") amount = self._get_bonus_amount("default_purchase")
if order.status != Checklist.Status.CHINA_RUSSIA: if order.status not in Checklist.Status.BONUS_ORDER_STATUSES:
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 return
self.update_balance(amount, bonus_type, order=order) 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 user = order.customer.inviter if for_inviter else order.customer
bonus_type = BonusType.FOR_INVITER if for_inviter else BonusType.INVITED_FIRST_PURCHASE 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 # Add bonuses
user.update_balance(amount, bonus_type, order=order) 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}") logger.warning(f"User #{inviter.id} already invited user #{user_id}")
return 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 # Normalize phone: 79111234567 -> +79111234567
phone = PhoneNumber.from_string(phone).as_e164 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}") logger.info(f"tgbot: Telegram user #{tg_user_id} was bound to user #{user.id}")
return freshly_created
class User(BonusProgramMixin, AbstractUser): class User(BonusProgramMixin, AbstractUser):
ADMIN = "admin" ADMIN = "admin"

View File

@ -20,11 +20,12 @@ class UserSerializer(serializers.ModelSerializer):
class BonusProgramTransactionSerializer(serializers.ModelSerializer): class BonusProgramTransactionSerializer(serializers.ModelSerializer):
order_id = serializers.StringRelatedField(source='order.id', allow_null=True)
type = serializers.CharField(source='get_type_display') type = serializers.CharField(source='get_type_display')
class Meta: class Meta:
model = BonusProgramTransaction 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): def non_zero_validator(value):

View File

@ -15,12 +15,9 @@ from account.serializers import SetInitialPasswordSerializer, BonusProgramTransa
UserBalanceUpdateSerializer, TelegramCallbackSerializer UserBalanceUpdateSerializer, TelegramCallbackSerializer
from tg_bot.handlers.start import request_phone_sync from tg_bot.handlers.start import request_phone_sync
from tg_bot.messages import TGCoreMessage from tg_bot.messages import TGCoreMessage
from tg_bot.bot import bot_sync
class UserViewSet(djoser_views.UserViewSet): class UserViewSet(djoser_views.UserViewSet):
""" Replacement for Djoser's UserViewSet """
def permission_denied(self, request, **kwargs): def permission_denied(self, request, **kwargs):
if ( if (
djoser_settings.HIDE_USERS djoser_settings.HIDE_USERS
@ -147,7 +144,7 @@ class TelegramLoginForm(views.APIView):
# Sign up user # Sign up user
user = User.objects.create_draft_user(tg_user_id=tg_user_id) user = User.objects.create_draft_user(tg_user_id=tg_user_id)
# Request the phone through the bot # 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) token = login_user(request, user)
return Response({"auth_token": token.key}) return Response({"auth_token": token.key})

View File

@ -52,7 +52,6 @@ TG_BOT_TOKEN = get_secret("TG_BOT_TOKEN")
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = bool(int(os.environ.get("DJANGO_DEBUG") or 0)) DEBUG = bool(int(os.environ.get("DJANGO_DEBUG") or 0))
DISABLE_PERMISSIONS = False
DISABLE_CORS = True DISABLE_CORS = True
ALLOWED_HOSTS = get_secret('ALLOWED_HOSTS').split(',') ALLOWED_HOSTS = get_secret('ALLOWED_HOSTS').split(',')
@ -175,9 +174,6 @@ REST_FRAMEWORK = {
# or allow read-only access for unauthenticated users. # or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated' 'rest_framework.permissions.IsAuthenticated'
if not DISABLE_PERMISSIONS
else
'rest_framework.permissions.AllowAny'
], ],
'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.TokenAuthentication'], 'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.TokenAuthentication'],

6
poizonstore/utils.py Normal file
View File

@ -0,0 +1,6 @@
from rest_framework.fields import DecimalField
class PriceField(DecimalField):
def __init__(self, *args, max_digits=10, decimal_places=2, min_value=0, **kwargs):
super().__init__(*args, max_digits=max_digits, decimal_places=decimal_places, min_value=min_value, **kwargs)

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 import F, Case, When, DecimalField, Prefetch, Max, Q
from django.db.models.functions import Ceil from django.db.models.functions import Ceil
from django.db.models.lookups import GreaterThan from django.db.models.lookups import GreaterThan
from django.db.transaction import atomic
from django.utils import timezone from django.utils import timezone
from django_cleanup import cleanup from django_cleanup import cleanup
from mptt.fields import TreeForeignKey from mptt.fields import TreeForeignKey
@ -251,6 +252,7 @@ class PriceSnapshot(models.Model):
class Checklist(models.Model): class Checklist(models.Model):
# Statuses # Statuses
class Status: class Status:
DELETED = "deleted"
DRAFT = "draft" DRAFT = "draft"
NEW = "neworder" NEW = "neworder"
PAYMENT = "payment" PAYMENT = "payment"
@ -267,7 +269,11 @@ class Checklist(models.Model):
PDF_AVAILABLE_STATUSES = (RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED) PDF_AVAILABLE_STATUSES = (RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED)
CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK) 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 = ( CHOICES = (
(DELETED, 'Удален'),
(DRAFT, 'Черновик'), (DRAFT, 'Черновик'),
(NEW, 'Новый заказ'), (NEW, 'Новый заказ'),
(PAYMENT, 'Проверка оплаты'), (PAYMENT, 'Проверка оплаты'),
@ -506,6 +512,7 @@ class Checklist(models.Model):
# Default commission # Default commission
commission = GlobalSettings.load().commission_rub commission = GlobalSettings.load().commission_rub
# For big orders there is an additional commission
if self.price_rub > 150_000: if self.price_rub > 150_000:
commission = max(commission, self.price_rub * Decimal(settings.COMMISSION_OVER_150K)) commission = max(commission, self.price_rub * Decimal(settings.COMMISSION_OVER_150K))
@ -516,6 +523,32 @@ class Checklist(models.Model):
return commission 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 @property
def preview_image(self): def preview_image(self):
# Prefer annotated field # Prefer annotated field
@ -589,6 +622,7 @@ class Checklist(models.Model):
if tg_message: if tg_message:
self.customer.notify_tg_bot(tg_message) self.customer.notify_tg_bot(tg_message)
@atomic
def _check_eligible_for_order_bonus(self): def _check_eligible_for_order_bonus(self):
if self.customer_id is None: if self.customer_id is None:
return return

View File

@ -1,3 +1,4 @@
from django.db.transaction import atomic
from drf_extra_fields.fields import Base64ImageField from drf_extra_fields.fields import Base64ImageField
from rest_framework import serializers from rest_framework import serializers
@ -5,6 +6,7 @@ from account.serializers import UserSerializer
from utils.exceptions import CRMException from utils.exceptions import CRMException
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift
from store.utils import get_primary_key_related_model from store.utils import get_primary_key_related_model
from poizonstore.utils import PriceField
class ImageSerializer(serializers.ModelSerializer): class ImageSerializer(serializers.ModelSerializer):
@ -35,7 +37,7 @@ class CategoryChecklistSerializer(serializers.ModelSerializer):
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
chinarush = serializers.DecimalField(source='delivery_price_CN_RU', max_digits=10, decimal_places=2) chinarush = PriceField(source='delivery_price_CN_RU')
class Meta: class Meta:
model = Category model = Category
@ -55,6 +57,7 @@ class CategoryFullSerializer(CategorySerializer):
class GiftSerializer(serializers.ModelSerializer): class GiftSerializer(serializers.ModelSerializer):
image = Base64ImageField(required=False, allow_null=True) image = Base64ImageField(required=False, allow_null=True)
min_price = PriceField()
class Meta: class Meta:
model = Gift model = Gift
@ -75,19 +78,20 @@ class ChecklistSerializer(serializers.ModelSerializer):
promocode = serializers.SlugRelatedField(slug_field='name', queryset=Promocode.objects.active(), promocode = serializers.SlugRelatedField(slug_field='name', queryset=Promocode.objects.active(),
required=False, allow_null=True) 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) 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) yuan_rate = PriceField(read_only=True)
price_yuan = serializers.DecimalField(required=False, max_digits=10, decimal_places=2) price_yuan = PriceField(required=False)
price_rub = serializers.IntegerField(read_only=True) price_rub = serializers.IntegerField(read_only=True)
delivery_price_CN = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) delivery_price_CN = PriceField(read_only=True)
delivery_price_CN_RU = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) delivery_price_CN_RU = PriceField(read_only=True)
full_price = serializers.IntegerField(read_only=True) full_price = PriceField(read_only=True)
real_price = serializers.DecimalField(required=False, allow_null=True, max_digits=10, decimal_places=2) real_price = PriceField(required=False, allow_null=True)
commission_rub = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) commission_rub = PriceField(read_only=True)
customer = get_primary_key_related_model(UserSerializer, required=False, allow_null=True) customer = get_primary_key_related_model(UserSerializer, required=False, allow_null=True)
@ -126,6 +130,7 @@ class ChecklistSerializer(serializers.ModelSerializer):
instance.images.set(img_objs) instance.images.set(img_objs)
instance.generate_preview(next(iter(img_objs), None)) instance.generate_preview(next(iter(img_objs), None))
@atomic
def create(self, validated_data): def create(self, validated_data):
images = self._collect_images_by_fields(validated_data) images = self._collect_images_by_fields(validated_data)
@ -135,8 +140,14 @@ class ChecklistSerializer(serializers.ModelSerializer):
if not user.is_manager or validated_data.get('customer') is None: if not user.is_manager or validated_data.get('customer') is None:
validated_data['customer'] = user validated_data['customer'] = user
use_bonuses = validated_data.pop('use_bonuses')
instance = super().create(validated_data) instance = super().create(validated_data)
self._create_main_images(instance, images.get('main_images')) self._create_main_images(instance, images.get('main_images'))
if use_bonuses:
instance.customer.spend_bonuses(order=instance)
return instance return instance
def update(self, instance, validated_data): def update(self, instance, validated_data):
@ -170,7 +181,7 @@ class ChecklistSerializer(serializers.ModelSerializer):
'image', 'image',
'preview_image_url', 'preview_image_url',
'yuan_rate', 'price_yuan', 'price_rub', 'delivery_price_CN', 'delivery_price_CN_RU', 'commission_rub', 'yuan_rate', 'price_yuan', 'price_rub', 'delivery_price_CN', 'delivery_price_CN_RU', 'commission_rub',
'promocode', 'gift', 'promocode', 'gift', 'use_bonuses', 'bonus_used',
'comment', 'comment',
'full_price', 'real_price', 'full_price', 'real_price',
'customer', 'customer',
@ -203,7 +214,7 @@ class ClientCreateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistS
writable_fields = { writable_fields = {
'link', 'link',
'brand', 'model', 'size', 'category', 'brand', 'model', 'size', 'category',
'price_yuan', 'price_yuan', 'use_bonuses',
'comment', 'comment',
} }
read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - writable_fields) read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - writable_fields)
@ -228,15 +239,16 @@ class ClientUpdateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistS
class GlobalSettingsSerializer(serializers.ModelSerializer): class GlobalSettingsSerializer(serializers.ModelSerializer):
currency = serializers.DecimalField(source='full_yuan_rate', read_only=True, max_digits=10, decimal_places=2) yuan_rate = PriceField(source='full_yuan_rate', read_only=True)
yuan_rate_last_updated = serializers.DateTimeField(read_only=True) yuan_rate_commission = PriceField()
chinadelivery = serializers.DecimalField(source='delivery_price_CN', max_digits=10, decimal_places=2) chinadelivery = PriceField(source='delivery_price_CN')
commission = serializers.DecimalField(source='commission_rub', max_digits=10, decimal_places=2) commission = PriceField(source='commission_rub')
pickup = serializers.CharField(source='pickup_address') pickup = serializers.CharField(source='pickup_address')
class Meta: class Meta:
model = GlobalSettings model = GlobalSettings
fields = ('currency', 'yuan_rate_last_updated', 'yuan_rate_commission', 'commission', 'chinadelivery', 'pickup', 'time_to_buy') fields = ('yuan_rate', 'yuan_rate_last_updated', 'yuan_rate_commission', 'commission', 'chinadelivery', 'pickup', 'time_to_buy')
read_only_fields = ('yuan_rate_last_updated',)
class AnonymousGlobalSettingsSerializer(GlobalSettingsSerializer): class AnonymousGlobalSettingsSerializer(GlobalSettingsSerializer):
@ -254,9 +266,6 @@ class PaymentMethodSerializer(serializers.ModelSerializer):
class PromocodeSerializer(serializers.ModelSerializer): class PromocodeSerializer(serializers.ModelSerializer):
freedelivery = serializers.BooleanField(source='free_delivery')
nocomission = serializers.BooleanField(source='no_comission')
class Meta: class Meta:
model = Promocode model = Promocode
fields = ('id', 'name', 'discount', 'freedelivery', 'nocomission', 'is_active') fields = ('id', 'name', 'discount', 'free_delivery', 'no_comission', 'is_active')

View File

@ -12,11 +12,9 @@ router.register(r'cdek', views.CDEKAPI, basename='cdek')
router.register(r'gifts', views.GiftAPI, basename='gifts') router.register(r'gifts', views.GiftAPI, basename='gifts')
router.register(r'poizon', views.PoizonAPI, basename='poizon') router.register(r'poizon', views.PoizonAPI, basename='poizon')
router.register(r'promo', views.PromoCodeAPI, basename='promo') router.register(r'promo', views.PromoCodeAPI, basename='promo')
router.register(r'category', views.CategoryAPI, basename='category')
urlpatterns = [ urlpatterns = [
path("category/", views.CategoryAPI.as_view()),
path("category/<int:id>", views.CategoryAPI.as_view()),
path("payment/", views.PaymentMethodsAPI.as_view()), path("payment/", views.PaymentMethodsAPI.as_view()),
path("settings/", views.GlobalSettingsAPI.as_view()), path("settings/", views.GlobalSettingsAPI.as_view()),

View File

@ -3,7 +3,7 @@ from datetime import timedelta
import requests import requests
from django.conf import settings from django.conf import settings
from django.db.models import F, Count, Sum, OuterRef, Subquery from django.db.models import F, Count, Sum
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, permissions, mixins, status, viewsets from rest_framework import generics, permissions, mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
@ -36,12 +36,6 @@ def prepare_external_response(r: requests.Response):
return Response(data) return Response(data)
class DisablePermissionsMixin(generics.GenericAPIView):
def get_permissions(self):
if settings.DISABLE_PERMISSIONS:
return [permissions.AllowAny()]
return super().get_permissions()
""" """
- managers can create/edit/delete orders - managers can create/edit/delete orders
@ -78,23 +72,31 @@ class ChecklistAPI(viewsets.ModelViewSet):
if self.action == "create": if self.action == "create":
return ClientCreateChecklistSerializer return ClientCreateChecklistSerializer
# Then, clients can update small set of fields # Then, clients can update small set of fields and cancel orders
elif self.action in ['update', 'partial_update']: elif self.action in ['update', 'partial_update', 'destroy']:
return ClientUpdateChecklistSerializer return ClientUpdateChecklistSerializer
# Fallback to error # Fallback to error
self.permission_denied(self.request, **self.kwargs) self.permission_denied(self.request, **self.kwargs)
def get_permissions(self): 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] self.permission_classes = [IsManager]
elif self.action == 'retrieve': elif self.action == 'retrieve':
self.permission_classes = [AllowAny] self.permission_classes = [AllowAny]
elif self.action == 'create': elif self.action in ['create', 'destroy']:
self.permission_classes = [IsAuthenticated] self.permission_classes = [IsAuthenticated]
return super().get_permissions() 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): def get_queryset(self):
return Checklist.objects.all().with_base_related() \ return Checklist.objects.all().with_base_related() \
.annotate_price_rub().annotate_commission_rub() \ .annotate_price_rub().annotate_commission_rub() \
@ -112,28 +114,23 @@ class ChecklistAPI(viewsets.ModelViewSet):
return obj return obj
class CategoryAPI(mixins.ListModelMixin, mixins.UpdateModelMixin, generics.GenericAPIView): class CategoryAPI(mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
serializer_class = CategorySerializer serializer_class = CategorySerializer
permission_classes = [IsManager | ReadOnly] permission_classes = [IsManager | ReadOnly]
lookup_field = 'id' lookup_field = 'id'
queryset = Category.objects.all()
def get_queryset(self): def list(self, request, *args, **kwargs):
return Category.objects.all()
def get(self, request, *args, **kwargs):
categories_qs = Category.objects.root_nodes() categories_qs = Category.objects.root_nodes()
return Response(CategoryFullSerializer(categories_qs, many=True).data) return Response(CategoryFullSerializer(categories_qs, many=True).data)
def patch(self, request, *args, **kwargs):
return self.partial_update(request, *args, **kwargs)
class GlobalSettingsAPI(generics.RetrieveUpdateAPIView): class GlobalSettingsAPI(generics.RetrieveUpdateAPIView):
serializer_class = GlobalSettingsSerializer serializer_class = GlobalSettingsSerializer
permission_classes = [IsManager | ReadOnly] permission_classes = [IsManager | ReadOnly]
def get_serializer_class(self): def get_serializer_class(self):
if getattr(self.request.user, 'is_manager', False) or settings.DISABLE_PERMISSIONS: if getattr(self.request.user, 'is_manager', False):
return GlobalSettingsSerializer return GlobalSettingsSerializer
# Anonymous users can view only a certain set of fields # Anonymous users can view only a certain set of fields
@ -143,12 +140,11 @@ class GlobalSettingsAPI(generics.RetrieveUpdateAPIView):
return GlobalSettings.load() return GlobalSettings.load()
# TODO: update to GenericViewSet
class PaymentMethodsAPI(generics.GenericAPIView): class PaymentMethodsAPI(generics.GenericAPIView):
serializer_class = PaymentMethodSerializer serializer_class = PaymentMethodSerializer
permission_classes = [IsManager | ReadOnly] permission_classes = [IsManager | ReadOnly]
queryset = PaymentMethod.objects.all()
def get_queryset(self):
return PaymentMethod.objects.all()
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
qs = self.get_queryset() qs = self.get_queryset()
@ -191,7 +187,7 @@ class GiftAPI(viewsets.ModelViewSet):
filterset_class = GiftFilter filterset_class = GiftFilter
def get_queryset(self): def get_queryset(self):
if getattr(self.request.user, 'is_manager', False) or settings.DISABLE_PERMISSIONS: if getattr(self.request.user, 'is_manager', False):
return Gift.objects.all() return Gift.objects.all()
# For anonymous users, show only available gifts # For anonymous users, show only available gifts

View File

@ -3,7 +3,7 @@
class TGBonusMessage: class TGBonusMessage:
SIGNUP = "Вам начислено {amount}Р баллов за привязку номера телефона 🎊" SIGNUP = "Вам начислено {amount}Р баллов за привязку номера телефона 🎊"
PURCHASE_ADDED = "Ура! Вам начислено {amount}P баллов за оформление заказа №{order_id}} 🎉" PURCHASE_ADDED = "Ура! Вам начислено {amount}P баллов за оформление заказа №{order_id} 🎉"
PURCHASE_SPENT = "Вы потратили {amount}P баллов на оформление заказа №{order_id}" PURCHASE_SPENT = "Вы потратили {amount}P баллов на оформление заказа №{order_id}"
FOR_INVITER = """ FOR_INVITER = """
Спасибо, твой друг оформил заказ и получил {amount}Р бонусов Спасибо, твой друг оформил заказ и получил {amount}Р бонусов
@ -75,7 +75,7 @@ class TGCoreMessage:
SHARE_PHONE_KEYBOARD = "Отправить номер телефона" SHARE_PHONE_KEYBOARD = "Отправить номер телефона"
SHARE_PHONE = "Поделитесь своим номером, чтобы авторизоваться:" SHARE_PHONE = "Поделитесь своим номером, чтобы авторизоваться:"
SIGN_UP_SHARE_PHONE = """ TG_AUTH_SHARE_PHONE = """
Добро пожаловать в PoizonStore! Добро пожаловать в PoizonStore!
Для завершения регистрации, поделитесь номером телефона: Для завершения регистрации, поделитесь номером телефона:
""" """

View File

@ -10,3 +10,6 @@ class CRMException(APIException):
detail = self.default_detail detail = self.default_detail
self.detail = {'error': detail} self.detail = {'error': detail}
# TODO: exceptions with a same template: ok / error_code / error_message