+ time_to_buy & buy_time_remaining fields

+ Category: hierarchy, removed slugs & added comission per category
* Promocode value in rubles instead of percentage
* Cleanup in GlobalSettings routes
This commit is contained in:
Phil Zhitnikov 2023-08-18 16:22:32 +04:00
parent bf3fe26dfa
commit 4ed1a74a16
10 changed files with 292 additions and 118 deletions

View File

@ -2,6 +2,7 @@
Django==4.2.2
django-cleanup==8.0.0
django-filter==23.2
django-mptt==0.14.0
djangorestframework==3.14.0
django-cors-headers==4.1.0
djoser==2.2.0

View File

@ -1,5 +1,8 @@
from django.contrib import admin
from django.contrib.admin import display
from mptt.admin import MPTTModelAdmin
from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, User, Image, Client
from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, User, Image
@ -10,8 +13,8 @@ class UserAdmin(admin.ModelAdmin):
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('slug', 'name')
class CategoryAdmin(MPTTModelAdmin):
list_display = ('name', 'delivery_price_CN_RU', 'commission')
ordering = ('id',)

View File

@ -3,19 +3,104 @@ from tqdm import tqdm
from store.models import Category, PaymentMethod
category_names = {
"shoes": "Обувь",
"outerwear": "Верхняя одежда",
"underwear": "Нижнее белье",
"bags": "Сумки",
"cosmetics": "Косметика",
"accessories": "Аксессуары",
"technics": "Техника",
"watches": "Часы",
"toys": "Игрушки",
"home": "Товары для дома",
"foodndrinks": "Еда и напитки",
"different": "Другое",
"Обувь": [
"Кроссовки",
"Кеды",
"Пляжная обувь",
"Туфли",
"Босоножки",
"Сандвли",
"Лоаферы",
"Мокасины",
"Ботинки",
"Полуботинки",
"Сапоги",
"Домашняя обувь",
"Другое",
],
"Верхняя одежда": [
"Куртка летняя",
"Куртка зимняя",
"Худи",
"Толстовка",
"Футболка",
"Майка",
"Лонгслив",
"Рубашка",
"Платье",
"Жилетка",
"Свитер",
"Топ",
"Другое",
],
"Нижнее белье": [
"Брюки",
"Штаны",
"Джинсы",
"Леггинсы",
"Шорты",
"Юбка",
"Колготки",
"Другое",
],
"Сумки": [
"Женская сумка",
"Мужская сумка",
"Рюкзак",
"Кошелек",
"Визитница",
"Клатч",
"Шоппер",
"Другое",
],
"Косметика": [
"Для лица",
"Для тела",
"Для губ",
"Для ресниц",
"Для волос",
"Для бровей",
"Для рук",
"Для ног",
"Другое",
],
"Аксессуары": [
"Шарф",
"Шапка",
"Кепка",
"Очки",
"Украшения",
"Перчатки",
"Носки",
"Нижнее белье",
"Другое",
],
"Техника": [
"Телефон",
"Планшет",
"Ноутбук",
"Приставка",
"Фен",
"Скайлер",
"Пылесос",
"Увлажнитель",
"Бытовая техника",
"Другое",
],
"Часы": ["Механические", "Электронные"],
"Игрушки": ["Lego", "Фигурка", "Мягкая", "Кукла", "Набор", "Другое"],
"Товары для дома": ["Полотенце", "Ковер", "Набор", "Косметичка", "Другое"],
"Еда и напитки": ["Еда", "Напиток"],
"Другое": [],
}
payment_methods = {
@ -41,8 +126,10 @@ class Command(BaseCommand):
help = ''' Create root categories '''
def create_categories(self):
for slug, name in tqdm(category_names.items(), desc="Creating categories"):
Category.objects.get_or_create(slug=slug, defaults={"name": name})
for cat_name, subcat_names in tqdm(category_names.items(), desc="Creating categories"):
category, _ = Category.objects.get_or_create(name=cat_name, parent=None)
for subcat_name in subcat_names:
Category.objects.get_or_create(name=subcat_name, parent_id=category.id)
def create_payment_types(self):
for slug, data in tqdm(payment_methods.items(), desc="Creating payment methods"):

View File

@ -0,0 +1,35 @@
# Generated by Django 4.2.2 on 2023-08-17 23:19
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0040_alter_paymentmethod_cardnumber_and_more'),
]
operations = [
migrations.RemoveField(
model_name='checklist',
name='category',
),
migrations.RemoveField(
model_name='checklist',
name='subcategory',
),
migrations.AddField(
model_name='globalsettings',
name='time_to_buy',
field=models.DurationField(default=datetime.timedelta(seconds=10800), help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'", verbose_name='Время на покупку'),
),
migrations.AlterField(
model_name='promocode',
name='discount',
field=models.PositiveIntegerField(verbose_name='Скидка в рублях'),
),
migrations.DeleteModel(
name='Category',
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.2 on 2023-08-17 23:20
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('store', '0041_remove_checklist_category_and_more'),
]
operations = [
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, verbose_name='Название')),
('delivery_price_CN_RU', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки Китай-РФ')),
('commission', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Дополнительная комиссия, %')),
('lft', models.PositiveIntegerField(editable=False)),
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='store.category', verbose_name='Родительская категория')),
],
options={
'verbose_name': 'Категория',
'verbose_name_plural': 'Категории',
},
),
migrations.AddField(
model_name='checklist',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.category', verbose_name='Категория'),
),
]

View File

@ -1,10 +1,11 @@
import math
import posixpath
from datetime import timedelta
from decimal import Decimal
import random
import string
from io import BytesIO
from typing import Optional
from django.conf import settings
from django.contrib.admin import display
@ -18,6 +19,8 @@ from django.db.models.lookups import GreaterThan
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_cleanup import cleanup
from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel
from store.utils import create_preview, concat_not_null_values
from utils.cache import InMemoryCache
@ -30,6 +33,9 @@ class GlobalSettings(models.Model):
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
pickup_address = models.CharField('Адрес пункта самовывоза', max_length=200, blank=True, null=True)
time_to_buy = models.DurationField('Время на покупку',
help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'",
default=timedelta(hours=3))
class Meta:
verbose_name = 'Глобальные настройки'
@ -57,14 +63,21 @@ class GlobalSettings(models.Model):
return obj
class Category(models.Model):
class Category(MPTTModel):
name = models.CharField('Название', max_length=20)
slug = models.SlugField('Идентификатор', unique=True)
parent = TreeForeignKey('self', verbose_name='Родительская категория', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', db_index=True)
delivery_price_CN_RU = models.DecimalField('Цена доставки Китай-РФ', max_digits=10, decimal_places=2, default=0) # Chinadelivery2
commission = models.DecimalField('Дополнительная комиссия, %',
max_digits=10, decimal_places=2, default=0,
validators=[MinValueValidator(0), MaxValueValidator(100)])
def __str__(self):
return self.name
class MPTTMeta:
order_insertion_by = ['id']
parent_attr = 'parent'
class Meta:
verbose_name = 'Категория'
verbose_name_plural = 'Категории'
@ -138,8 +151,7 @@ class PromocodeQuerySet(models.QuerySet):
class Promocode(models.Model):
name = models.CharField('Название', max_length=100, unique=True)
discount = models.DecimalField('Скидка', max_digits=10, decimal_places=2,
validators=[MinValueValidator(0), MaxValueValidator(100)])
discount = models.PositiveIntegerField('Скидка в рублях')
free_delivery = models.BooleanField('Бесплатная доставка', default=False) # freedelivery
no_comission = models.BooleanField('Без комиссии', default=False) # nocomission
is_active = models.BooleanField('Активен', default=True)
@ -291,7 +303,6 @@ class Checklist(models.Model):
manager = models.ForeignKey('User', verbose_name='Менеджер', on_delete=models.SET_NULL, blank=True, null=True)
product_link = models.URLField('Ссылка на товар', null=True, blank=True)
category = models.ForeignKey('Category', verbose_name="Категория", blank=True, null=True, on_delete=models.SET_NULL)
subcategory = models.CharField('Подкатегория', max_length=20, blank=True, null=True)
brand = models.CharField('Бренд', max_length=100, null=True, blank=True)
model = models.CharField('Модель', max_length=100, null=True, blank=True)
@ -341,6 +352,16 @@ class Checklist(models.Model):
verbose_name = 'Заказ'
verbose_name_plural = 'Заказы'
@property
def buy_time_remaining(self) -> Optional[timedelta]:
if self.status != Checklist.Status.NEW:
return None
time_to_buy = GlobalSettings.load().time_to_buy
diff = max(timedelta(), timezone.now() - self.status_updated_at)
result = max(timedelta(), time_to_buy - diff)
return result
@property
def price_rub(self) -> int:
# Prefer annotated field
@ -361,7 +382,7 @@ class Checklist(models.Model):
# and intentionally don't check if promocode is active here.
# It's also good for archive orders.
promocode = self.promocode
price -= promocode.discount * self.price_rub / 100
price -= min(self.price_rub, promocode.discount)
free_delivery = promocode.free_delivery
no_comission = promocode.no_comission
@ -372,6 +393,11 @@ class Checklist(models.Model):
if not no_comission:
price += self.commission_rub
# Add commission of bottom-most category
category = self.category.get_ancestors(ascending=True, include_self=True).first()
category_commission = getattr(category, 'commission', 0)
price += category_commission * self.price_rub / 100
return max(0, math.ceil(price))
@property

View File

@ -1,9 +1,8 @@
from django.contrib.auth import authenticate
from drf_extra_fields.fields import Base64ImageField
from rest_framework import serializers
from store.exceptions import CRMException, InvalidCredentialsException
from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image
from store.utils import get_primary_key_related_model
class UserSerializer(serializers.ModelSerializer):
@ -39,12 +38,36 @@ class ImageListSerializer(serializers.ListSerializer):
return [image['image'] for image in images]
class CategoryChecklistSerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ('id', 'name')
class CategorySerializer(serializers.ModelSerializer):
chinarush = serializers.DecimalField(source='delivery_price_CN_RU', max_digits=10, decimal_places=2)
class Meta:
model = Category
fields = ('id', 'name', 'chinarush', 'commission')
class CategoryFullSerializer(CategorySerializer):
children = serializers.SerializerMethodField()
def get_children(self, obj):
return CategoryFullSerializer(obj.get_children(), many=True).data
class Meta:
model = CategorySerializer.Meta.model
fields = CategorySerializer.Meta.fields + ('children',)
class ChecklistSerializer(serializers.ModelSerializer):
id = serializers.CharField(read_only=True)
managerid = serializers.PrimaryKeyRelatedField(source='manager_id', read_only=True, allow_null=True)
link = serializers.URLField(source='product_link', required=False)
category = serializers.SlugRelatedField(slug_field='slug', queryset=Category.objects.all(),
required=False, allow_null=True)
category = get_primary_key_related_model(CategoryChecklistSerializer, required=False, allow_null=True)
image = ImageListSerializer(source='main_images', required=False)
previewimage = serializers.ImageField(source='preview_image_url', read_only=True)
@ -156,7 +179,7 @@ class ChecklistSerializer(serializers.ModelSerializer):
class Meta:
model = Checklist
fields = ('id', 'status', 'managerid', 'link',
'category', 'subcategory',
'category',
'brand', 'model', 'size',
'image',
'previewimage',
@ -168,7 +191,7 @@ class ChecklistSerializer(serializers.ModelSerializer):
'receivername', 'reveiverphone',
'split', 'paymenttype', 'paymentprovement', 'checkphoto',
'trackid', 'cdek_tracking', 'cdek_barcode_pdf', 'delivery', 'delivery_display',
'startDate', 'currentDate',
'startDate', 'currentDate', 'buy_time_remaining'
)
@ -183,38 +206,15 @@ class AnonymousUserChecklistSerializer(ChecklistSerializer):
'recievername', 'recieverphone', 'tg'})
class GlobalSettingsYuanRateSerializer(serializers.ModelSerializer):
class GlobalSettingsSerializer(serializers.ModelSerializer):
currency = serializers.DecimalField(source='yuan_rate', max_digits=10, decimal_places=2)
class Meta:
model = GlobalSettings
fields = ('currency',)
class GlobalSettingsPickupSerializer(serializers.ModelSerializer):
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)
pickup = serializers.CharField(source='pickup_address')
class Meta:
model = GlobalSettings
fields = ('pickup',)
class GlobalSettingsPriceSerializer(serializers.ModelSerializer):
commission = serializers.DecimalField(source='commission_rub', max_digits=10, decimal_places=2)
chinadelivery = serializers.DecimalField(source='delivery_price_CN', max_digits=10, decimal_places=2)
class Meta:
model = GlobalSettings
fields = ('commission', 'chinadelivery')
class CategorySerializer(serializers.ModelSerializer):
category = serializers.CharField(source='slug')
chinarush = serializers.DecimalField(source='delivery_price_CN_RU', max_digits=10, decimal_places=2)
class Meta:
model = Category
fields = ('category', 'chinarush')
fields = ('currency', 'commission', 'chinadelivery', 'pickup', 'time_to_buy')
class PaymentMethodSerializer(serializers.ModelSerializer):

View File

@ -13,11 +13,11 @@ urlpatterns = [
path("checklist/", views.ChecklistAPI.as_view()),
path("checklist/<str:id>", views.ChecklistAPI.as_view()),
path("currency/", views.YuanRateAPI.as_view()),
path("category/", views.CategoryAPI.as_view()),
path("pickup/", views.PickupAPI.as_view()),
path("category/price/", views.PricesAPI.as_view()),
path("category/<int:id>", views.CategoryAPI.as_view()),
path("payment/", views.PaymentMethodsAPI.as_view()),
path("settings/", views.GlobalSettingsAPI.as_view()),
path("promo/", views.PromoCodeAPI.as_view()),

View File

@ -2,6 +2,7 @@ import os
import textwrap
from typing import Tuple
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
from poizonstore.settings import BASE_DIR
@ -125,3 +126,30 @@ def create_preview(source_image: str, size=None, price_rub=None, title_lines=Non
def concat_not_null_values(*values, separator=' '):
return separator.join([v for v in values if v is not None])
def get_primary_key_related_model(model_class, **kwargs):
"""
Info: https://stackoverflow.com/a/43742949
Nested serializers are a mess.
This lets us accept ids when saving / updating instead of nested objects.
Representation would be into an object (depending on model_class).
"""
class PrimaryKeyNestedMixin(model_class):
default_error_messages = {
'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'),
'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'),
}
def to_internal_value(self, data):
try:
return model_class.Meta.model.objects.get(pk=data)
except model_class.Meta.model.DoesNotExist:
self.fail('does_not_exist', pk_value=data)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(data).__name__)
def to_representation(self, data):
return model_class.to_representation(self, data)
return PrimaryKeyNestedMixin(**kwargs)

View File

@ -1,9 +1,9 @@
import calendar
from datetime import timedelta
from django.conf import settings
from django.contrib.auth import login
from django.db.models import F, Count, Sum
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, permissions, mixins, status, viewsets
from rest_framework.decorators import action
@ -15,9 +15,9 @@ from rest_framework.response import Response
from cdek.api import CDEKClient
from store.exceptions import CRMException
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode
from store.serializers import (ChecklistSerializer, GlobalSettingsYuanRateSerializer,
CategorySerializer, GlobalSettingsPriceSerializer, PaymentMethodSerializer,
PromocodeSerializer, GlobalSettingsPickupSerializer, AnonymousUserChecklistSerializer)
from store.serializers import (ChecklistSerializer, CategorySerializer, CategoryFullSerializer,
PaymentMethodSerializer, GlobalSettingsSerializer,
PromocodeSerializer, AnonymousUserChecklistSerializer)
from utils.permissions import ReadOnly
@ -63,10 +63,9 @@ class ChecklistAPI(mixins.ListModelMixin,
def get_object(self):
obj: Checklist = super().get_object()
# 3 hours maximum in 'neworder' status -> move to drafts
if obj.status == Checklist.Status.NEW:
diff_hours = (timezone.now() - obj.status_updated_at).seconds / 3600
if diff_hours > 3:
# N time maximum in 'neworder' status -> move to drafts
if obj.status == Checklist.Status.NEW and obj.buy_time_remaining is not None:
if obj.buy_time_remaining <= timedelta():
obj.status = Checklist.Status.DRAFT
obj.save()
@ -95,67 +94,23 @@ class ChecklistAPI(mixins.ListModelMixin,
return self.destroy(request, *args, **kwargs)
class YuanRateAPI(generics.GenericAPIView):
serializer_class = GlobalSettingsYuanRateSerializer
def get_object(self):
return GlobalSettings.load()
def get(self, request, *args, **kwargs):
yuan_rate = GlobalSettings.load().yuan_rate
return Response(data={'currency': yuan_rate})
def patch(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class CategoryAPI(generics.GenericAPIView):
class CategoryAPI(mixins.ListModelMixin, mixins.UpdateModelMixin, generics.GenericAPIView):
serializer_class = CategorySerializer
lookup_field = 'id'
def get_queryset(self):
return Category.objects.all()
return Category.objects.root_nodes()
def get(self, request, *args, **kwargs):
categories_qs = self.get_queryset()
global_settings = GlobalSettings.load()
return Response({
'categories': CategorySerializer(categories_qs, many=True).data,
'prices': GlobalSettingsPriceSerializer(global_settings).data,
})
return Response(CategoryFullSerializer(categories_qs, many=True).data)
def patch(self, request, *args, **kwargs):
data = request.data
if not all(k in data for k in ("category", "chinarush")):
raise CRMException('category and chinarush is required')
instance = get_object_or_404(self.get_queryset(), slug=data['category'])
serializer = self.get_serializer(instance, data=data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
return self.partial_update(request, *args, **kwargs)
class PricesAPI(generics.GenericAPIView):
serializer_class = GlobalSettingsPriceSerializer
def patch(self, request, *args, **kwargs):
instance = GlobalSettings.load()
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class PickupAPI(DisablePermissionsMixin):
serializer_class = GlobalSettingsPickupSerializer
class GlobalSettingsAPI(generics.GenericAPIView):
serializer_class = GlobalSettingsSerializer
permission_classes = [IsAuthenticated | ReadOnly]
def get_object(self):