diff --git a/requirements.txt b/requirements.txt index 3b3c25c..48c7900 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/store/admin.py b/store/admin.py index 7703257..dccc5ff 100644 --- a/store/admin.py +++ b/store/admin.py @@ -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',) diff --git a/store/management/commands/create_initial_data.py b/store/management/commands/create_initial_data.py index 3903a87..da85069 100644 --- a/store/management/commands/create_initial_data.py +++ b/store/management/commands/create_initial_data.py @@ -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"): diff --git a/store/migrations/0041_remove_checklist_category_and_more.py b/store/migrations/0041_remove_checklist_category_and_more.py new file mode 100644 index 0000000..3c73c3b --- /dev/null +++ b/store/migrations/0041_remove_checklist_category_and_more.py @@ -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', + ), + ] diff --git a/store/migrations/0042_category_checklist_category.py b/store/migrations/0042_category_checklist_category.py new file mode 100644 index 0000000..40542a6 --- /dev/null +++ b/store/migrations/0042_category_checklist_category.py @@ -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='Категория'), + ), + ] diff --git a/store/models.py b/store/models.py index 5795957..ddd6468 100644 --- a/store/models.py +++ b/store/models.py @@ -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 diff --git a/store/serializers.py b/store/serializers.py index 2952cd8..4587e08 100644 --- a/store/serializers.py +++ b/store/serializers.py @@ -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): diff --git a/store/urls.py b/store/urls.py index f2b0883..7f2b265 100644 --- a/store/urls.py +++ b/store/urls.py @@ -13,11 +13,11 @@ urlpatterns = [ path("checklist/", views.ChecklistAPI.as_view()), path("checklist/", 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/", views.CategoryAPI.as_view()), + path("payment/", views.PaymentMethodsAPI.as_view()), + path("settings/", views.GlobalSettingsAPI.as_view()), path("promo/", views.PromoCodeAPI.as_view()), diff --git a/store/utils.py b/store/utils.py index 271e7fc..54ce737 100644 --- a/store/utils.py +++ b/store/utils.py @@ -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) diff --git a/store/views.py b/store/views.py index b3443cf..b4b434d 100644 --- a/store/views.py +++ b/store/views.py @@ -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):