Compare commits
5 Commits
12ca631d15
...
d0d637051d
| Author | SHA1 | Date | |
|---|---|---|---|
| d0d637051d | |||
| 4a987b9646 | |||
| 4a821faee9 | |||
| 81a3e15418 | |||
| 1f4d693c81 |
|
|
@ -17,5 +17,5 @@ class BonusProgramTransactionAdmin(admin.ModelAdmin):
|
|||
|
||||
def delete_queryset(self, request, queryset):
|
||||
for obj in queryset:
|
||||
obj.cancel_transaction()
|
||||
obj.cancel()
|
||||
|
||||
|
|
|
|||
|
|
@ -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='Тип транзакции'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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) \
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ TG_BOT_TOKEN = get_secret("TG_BOT_TOKEN")
|
|||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = bool(int(os.environ.get("DJANGO_DEBUG") or 0))
|
||||
DISABLE_PERMISSIONS = False
|
||||
DISABLE_CORS = True
|
||||
|
||||
ALLOWED_HOSTS = get_secret('ALLOWED_HOSTS').split(',')
|
||||
|
|
@ -175,9 +174,6 @@ REST_FRAMEWORK = {
|
|||
# or allow read-only access for unauthenticated users.
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated'
|
||||
if not DISABLE_PERMISSIONS
|
||||
else
|
||||
'rest_framework.permissions.AllowAny'
|
||||
],
|
||||
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.TokenAuthentication'],
|
||||
|
|
|
|||
6
poizonstore/utils.py
Normal file
6
poizonstore/utils.py
Normal 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)
|
||||
18
store/migrations/0004_alter_checklist_status.py
Normal file
18
store/migrations/0004_alter_checklist_status.py
Normal 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='Статус заказа'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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, 'Проверка оплаты'),
|
||||
|
|
@ -506,6 +512,7 @@ class Checklist(models.Model):
|
|||
# Default commission
|
||||
commission = GlobalSettings.load().commission_rub
|
||||
|
||||
# For big orders there is an additional commission
|
||||
if self.price_rub > 150_000:
|
||||
commission = max(commission, self.price_rub * Decimal(settings.COMMISSION_OVER_150K))
|
||||
|
||||
|
|
@ -516,6 +523,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 +622,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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from django.db.transaction import atomic
|
||||
from drf_extra_fields.fields import Base64ImageField
|
||||
from rest_framework import serializers
|
||||
|
||||
|
|
@ -5,6 +6,7 @@ from account.serializers import UserSerializer
|
|||
from utils.exceptions import CRMException
|
||||
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift
|
||||
from store.utils import get_primary_key_related_model
|
||||
from poizonstore.utils import PriceField
|
||||
|
||||
|
||||
class ImageSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -35,7 +37,7 @@ class CategoryChecklistSerializer(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:
|
||||
model = Category
|
||||
|
|
@ -55,6 +57,7 @@ class CategoryFullSerializer(CategorySerializer):
|
|||
|
||||
class GiftSerializer(serializers.ModelSerializer):
|
||||
image = Base64ImageField(required=False, allow_null=True)
|
||||
min_price = PriceField()
|
||||
|
||||
class Meta:
|
||||
model = Gift
|
||||
|
|
@ -75,19 +78,20 @@ 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)
|
||||
price_yuan = serializers.DecimalField(required=False, max_digits=10, decimal_places=2)
|
||||
yuan_rate = PriceField(read_only=True)
|
||||
price_yuan = PriceField(required=False)
|
||||
price_rub = serializers.IntegerField(read_only=True)
|
||||
|
||||
delivery_price_CN = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2)
|
||||
delivery_price_CN_RU = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2)
|
||||
delivery_price_CN = PriceField(read_only=True)
|
||||
delivery_price_CN_RU = PriceField(read_only=True)
|
||||
|
||||
full_price = serializers.IntegerField(read_only=True)
|
||||
real_price = serializers.DecimalField(required=False, allow_null=True, max_digits=10, decimal_places=2)
|
||||
full_price = PriceField(read_only=True)
|
||||
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)
|
||||
|
||||
|
|
@ -126,6 +130,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 +140,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 +181,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 +214,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)
|
||||
|
|
@ -228,15 +239,16 @@ class ClientUpdateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistS
|
|||
|
||||
|
||||
class GlobalSettingsSerializer(serializers.ModelSerializer):
|
||||
currency = serializers.DecimalField(source='full_yuan_rate', read_only=True, max_digits=10, decimal_places=2)
|
||||
yuan_rate_last_updated = serializers.DateTimeField(read_only=True)
|
||||
chinadelivery = serializers.DecimalField(source='delivery_price_CN', max_digits=10, decimal_places=2)
|
||||
commission = serializers.DecimalField(source='commission_rub', max_digits=10, decimal_places=2)
|
||||
yuan_rate = PriceField(source='full_yuan_rate', read_only=True)
|
||||
yuan_rate_commission = PriceField()
|
||||
chinadelivery = PriceField(source='delivery_price_CN')
|
||||
commission = PriceField(source='commission_rub')
|
||||
pickup = serializers.CharField(source='pickup_address')
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
|
|
@ -254,9 +266,6 @@ class PaymentMethodSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class PromocodeSerializer(serializers.ModelSerializer):
|
||||
freedelivery = serializers.BooleanField(source='free_delivery')
|
||||
nocomission = serializers.BooleanField(source='no_comission')
|
||||
|
||||
class Meta:
|
||||
model = Promocode
|
||||
fields = ('id', 'name', 'discount', 'freedelivery', 'nocomission', 'is_active')
|
||||
fields = ('id', 'name', 'discount', 'free_delivery', 'no_comission', 'is_active')
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@ router.register(r'cdek', views.CDEKAPI, basename='cdek')
|
|||
router.register(r'gifts', views.GiftAPI, basename='gifts')
|
||||
router.register(r'poizon', views.PoizonAPI, basename='poizon')
|
||||
router.register(r'promo', views.PromoCodeAPI, basename='promo')
|
||||
router.register(r'category', views.CategoryAPI, basename='category')
|
||||
|
||||
urlpatterns = [
|
||||
path("category/", views.CategoryAPI.as_view()),
|
||||
path("category/<int:id>", views.CategoryAPI.as_view()),
|
||||
|
||||
path("payment/", views.PaymentMethodsAPI.as_view()),
|
||||
path("settings/", views.GlobalSettingsAPI.as_view()),
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from datetime import timedelta
|
|||
|
||||
import requests
|
||||
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 rest_framework import generics, permissions, mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
|
|
@ -36,12 +36,6 @@ def prepare_external_response(r: requests.Response):
|
|||
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
|
||||
|
|
@ -78,23 +72,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() \
|
||||
|
|
@ -112,28 +114,23 @@ class ChecklistAPI(viewsets.ModelViewSet):
|
|||
return obj
|
||||
|
||||
|
||||
class CategoryAPI(mixins.ListModelMixin, mixins.UpdateModelMixin, generics.GenericAPIView):
|
||||
class CategoryAPI(mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
||||
serializer_class = CategorySerializer
|
||||
permission_classes = [IsManager | ReadOnly]
|
||||
lookup_field = 'id'
|
||||
queryset = Category.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
return Category.objects.all()
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
def list(self, request, *args, **kwargs):
|
||||
categories_qs = Category.objects.root_nodes()
|
||||
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):
|
||||
serializer_class = GlobalSettingsSerializer
|
||||
permission_classes = [IsManager | ReadOnly]
|
||||
|
||||
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
|
||||
|
||||
# Anonymous users can view only a certain set of fields
|
||||
|
|
@ -143,12 +140,11 @@ class GlobalSettingsAPI(generics.RetrieveUpdateAPIView):
|
|||
return GlobalSettings.load()
|
||||
|
||||
|
||||
# TODO: update to GenericViewSet
|
||||
class PaymentMethodsAPI(generics.GenericAPIView):
|
||||
serializer_class = PaymentMethodSerializer
|
||||
permission_classes = [IsManager | ReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
return PaymentMethod.objects.all()
|
||||
queryset = PaymentMethod.objects.all()
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
qs = self.get_queryset()
|
||||
|
|
@ -191,7 +187,7 @@ class GiftAPI(viewsets.ModelViewSet):
|
|||
filterset_class = GiftFilter
|
||||
|
||||
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()
|
||||
|
||||
# For anonymous users, show only available gifts
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
Для завершения регистрации, поделитесь номером телефона:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -10,3 +10,6 @@ class CRMException(APIException):
|
|||
detail = self.default_detail
|
||||
|
||||
self.detail = {'error': detail}
|
||||
|
||||
|
||||
# TODO: exceptions with a same template: ok / error_code / error_message
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user