diff --git a/store/admin.py b/store/admin.py index 98f2a82..42153fa 100644 --- a/store/admin.py +++ b/store/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin +from django.contrib.admin import display -from .models import Category, Checklist, GlobalSettings, PaymentMethod, PromoCode, User, Image +from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, User, Image @admin.register(User) @@ -21,8 +22,12 @@ class ImageAdmin(admin.ModelAdmin): @admin.register(Checklist) class ChecklistAdmin(admin.ModelAdmin): - list_display = ('id', 'date', 'price_rub', 'commission_rub') - filter_horizontal = ('images',) + list_display = ('id', 'brand', 'model', 'price_rub', 'commission_rub', 'date', 'status_display') + ordering = ('-status_updated_at', '-created_at') + + @display(description='Статус') + def status_display(self, obj: Checklist): + return obj.get_status_display() def date(self, obj: Checklist): return obj.status_updated_at or obj.created_at @@ -41,7 +46,7 @@ class PaymentMethodAdmin(admin.ModelAdmin): list_display = ('name', 'slug') -@admin.register(PromoCode) +@admin.register(Promocode) class PromoCodeAdmin(admin.ModelAdmin): list_display = ('name', 'discount', 'free_delivery', 'no_comission') diff --git a/store/exceptions.py b/store/exceptions.py index c5f6e84..0b76681 100644 --- a/store/exceptions.py +++ b/store/exceptions.py @@ -3,6 +3,8 @@ from rest_framework.exceptions import APIException class CRMException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + def __init__(self, detail=None): if detail is None: detail = self.default_detail diff --git a/store/models.py b/store/models.py index beb402b..2286b3e 100644 --- a/store/models.py +++ b/store/models.py @@ -130,12 +130,15 @@ class User(AbstractUser): return concat_not_null_values(self.last_name, self.first_name, self.middle_name) -class PromoCode(models.Model): +class Promocode(models.Model): name = models.CharField('Название', max_length=100, unique=True) discount = models.DecimalField('Скидка', max_digits=10, decimal_places=2,) free_delivery = models.BooleanField('Бесплатная доставка', default=False) # freedelivery no_comission = models.BooleanField('Без комиссии', default=False) # nocomission + def __str__(self): + return self.name + class Meta: verbose_name = 'Промокод' verbose_name_plural = 'Промокоды' @@ -155,9 +158,11 @@ class PaymentMethod(models.Model): return self.name +@cleanup.select class Image(models.Model): image = models.ImageField(upload_to='checklist_images') is_preview = models.BooleanField(default=False) + checklist = models.ForeignKey('Checklist', on_delete=models.CASCADE, related_name='images') class Meta: verbose_name = 'Изображение' @@ -168,6 +173,8 @@ class Image(models.Model): def generate_checklist_id(): + """ Generate unique id for Checklist """ + all_ids = Checklist.objects.all().values_list('id', flat=True) allowed_chars = string.ascii_letters + string.digits @@ -226,18 +233,6 @@ class Checklist(models.Model): (COMPLETED, 'Завершен'), ) - # Payment types - class PaymentType: - ALFA = "alfa" - TINKOFF = "tink" - RAIFFEISEN = "raif" - - CHOICES = ( - (ALFA, 'Альфа-Банк'), - (TINKOFF, 'Тинькофф Банк'), - (RAIFFEISEN, 'Райффайзен Банк'), - ) - # Delivery class DeliveryType: PICKUP = "pickup" @@ -249,7 +244,7 @@ class Checklist(models.Model): ) created_at = models.DateTimeField(auto_now_add=True) - status_updated_at = models.DateTimeField('Дата обновления статуса заказа') + status_updated_at = models.DateTimeField('Дата обновления статуса заказа', auto_now_add=True) id = models.CharField(primary_key=True, max_length=settings.CHECKLIST_ID_LENGTH, default=generate_checklist_id, editable=False) status = models.CharField('Статус заказа', max_length=15, choices=Status.CHOICES, default=Status.NEW) @@ -263,14 +258,12 @@ class Checklist(models.Model): model = models.CharField('Модель', max_length=100, null=True, blank=True) size = models.CharField('Размер', max_length=30, null=True, blank=True) - images = models.ManyToManyField('Image', verbose_name='Картинки', blank=True) - # curencycurency2 price_yuan = models.DecimalField('Цена в юанях', max_digits=10, decimal_places=2, default=0) - # TODO: replace by parser + # TODO: replace real_price by parser real_price = models.DecimalField('Реальная цена', max_digits=10, decimal_places=2, null=True, blank=True) - # TODO: choose from PromoCode table + # TODO: choose from Promocode table # promo promocode = models.CharField('Промокод', max_length=100, null=True, blank=True) comment = models.CharField('Комментарий', max_length=200, null=True, blank=True) @@ -296,7 +289,8 @@ class Checklist(models.Model): delivery = models.CharField('Тип доставки', max_length=10, choices=DeliveryType.CHOICES, null=True, blank=True) # trackid - track_number = models.CharField('Трек-номер', max_length=100, null=True, blank=True) + poizon_tracking = models.CharField('Трек-номер Poizon', max_length=100, null=True, blank=True) + cdek_tracking = models.CharField('Трек-номер СДЭК', max_length=100, null=True, blank=True) objects = ChecklistQuerySet.as_manager() @@ -359,9 +353,9 @@ class Checklist(models.Model): def save(self, *args, **kwargs): if self.id: - old_obj = Checklist.objects.get(id=self.id) + old_obj = Checklist.objects.filter(id=self.id).first() # If status was updated, update status_updated_at field - if self.status != old_obj.status: + if old_obj and self.status != old_obj.status: self.status_updated_at = timezone.now() # Create preview image @@ -375,11 +369,10 @@ class Checklist(models.Model): preview.save(image_io, format='JPEG') # Create Image model and save it - image_obj = Image(is_preview=True) + image_obj = Image(is_preview=True, checklist_id=self.id) image_obj.image.save(name=f'{self.id}_preview.jpg', content=ContentFile(image_io.getvalue()), save=True) - self.images.add(image_obj) super().save(*args, **kwargs) diff --git a/store/serializers.py b/store/serializers.py index a06fc07..4af0e6d 100644 --- a/store/serializers.py +++ b/store/serializers.py @@ -1,8 +1,12 @@ +import base64 + from django.contrib.auth import authenticate +from django.core.files.base import ContentFile +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.models import User, Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image class LoginSerializer(serializers.Serializer): @@ -44,12 +48,14 @@ class UserSerializer(serializers.ModelSerializer): class ImageSerializer(serializers.ModelSerializer): + image = Base64ImageField() + class Meta: model = Image - fields = ('image', ) + fields = ('image',) -class OrderImageListSerializer(serializers.ListSerializer): +class ChecklistImageListSerializer(serializers.ListSerializer): child = ImageSerializer() def to_representation(self, data): @@ -59,44 +65,54 @@ class OrderImageListSerializer(serializers.ListSerializer): class ChecklistSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True) - managerid = serializers.CharField(source='manager.manager_id', required=False, allow_null=True) + managerid = serializers.CharField(source='manager.manager_id', read_only=True) link = serializers.URLField(source='product_link', required=False) category = serializers.SlugRelatedField(slug_field='slug', queryset=Category.objects.all()) - image = OrderImageListSerializer(source='main_images') - previewimage = serializers.ImageField(source='preview_image_url') - # TODO: choose from PromoCode table + # image = Base64ImageField(source='images', many=True, queryset=Image.objects.all()) + previewimage = serializers.ImageField(source='preview_image_url', read_only=True) + + # TODO: choose from Promocode table promo = serializers.CharField(source='promocode', required=False) currency = serializers.SerializerMethodField('get_yuan_rate') curencycurency2 = serializers.DecimalField(source='price_yuan', max_digits=10, decimal_places=2) - currency3 = serializers.DecimalField(source='price_rub', max_digits=10, decimal_places=2, read_only=True) - chinadelivery = serializers.SerializerMethodField('get_delivery_price_CN') - chinadelivery2 = serializers.DecimalField(source='delivery_price_CN_RU', max_digits=10, decimal_places=2, read_only=True) - fullprice = serializers.DecimalField(source='full_price', max_digits=10, decimal_places=2) - realprice = serializers.DecimalField(source='real_price', max_digits=10, decimal_places=2) - comission = serializers.SerializerMethodField('get_comission') + currency3 = serializers.DecimalField(source='price_rub', read_only=True, max_digits=10, decimal_places=2) + chinadelivery = serializers.SerializerMethodField('get_delivery_price_CN', read_only=True) + chinadelivery2 = serializers.DecimalField(source='delivery_price_CN_RU', read_only=True, max_digits=10, + decimal_places=2) + fullprice = serializers.DecimalField(source='full_price', read_only=True, max_digits=10, decimal_places=2) + realprice = serializers.DecimalField(source='real_price', required=False, allow_null=True, max_digits=10, + decimal_places=2) + comission = serializers.SerializerMethodField('get_comission', read_only=True) - buyername = serializers.CharField(source='buyer_name') - buyerphone = serializers.CharField(source='buyer_phone') - tg = serializers.CharField(source='buyer_telegram') + buyername = serializers.CharField(source='buyer_name', required=False, allow_null=True) + buyerphone = serializers.CharField(source='buyer_phone', required=False, allow_null=True) + tg = serializers.CharField(source='buyer_telegram', required=False, allow_null=True) - receivername = serializers.CharField(source='receiver_name') - reveiverphone = serializers.CharField(source='receiver_phone') + receivername = serializers.CharField(source='receiver_name', required=False, allow_null=True) + reveiverphone = serializers.CharField(source='receiver_phone', required=False, allow_null=True) - paymenttype = serializers.SlugRelatedField(source='payment_method', slug_field='slug', queryset=PaymentMethod.objects.all()) - paymentproovement = serializers.ImageField(source='payment_proof') - checkphoto = serializers.ImageField(source='cheque_photo') - trackid = serializers.CharField(source='track_number') - delivery = serializers.CharField(source='get_delivery_display') + paymenttype = serializers.SlugRelatedField(source='payment_method', slug_field='slug', + queryset=PaymentMethod.objects.all(), + required=False, allow_null=True) + paymentproovement = serializers.ImageField(source='payment_proof', required=False, allow_null=True) + checkphoto = serializers.ImageField(source='cheque_photo', required=False, allow_null=True) + trackid = serializers.CharField(source='poizon_tracking', required=False, allow_null=True) + cdek_tracking = serializers.CharField(required=False, allow_null=True) + delivery = serializers.CharField(source='get_delivery_display', required=False, allow_null=True) - startDate = serializers.DateTimeField(source='created_at') - currentDate = serializers.DateTimeField(source='status_updated_at') + startDate = serializers.DateTimeField(source='created_at', read_only=True) + currentDate = serializers.DateTimeField(source='status_updated_at', read_only=True) @staticmethod def get_yuan_rate(obj: Checklist): return GlobalSettings.load().yuan_rate + @staticmethod + def get_image(obj: Checklist): + return obj.images.all() + @staticmethod def get_delivery_price_CN(obj: Checklist): return GlobalSettings.load().delivery_price_CN @@ -110,7 +126,7 @@ class ChecklistSerializer(serializers.ModelSerializer): fields = ('id', 'status', 'managerid', 'link', 'category', 'subcategory', 'brand', 'model', 'size', - 'image', + # 'image', 'previewimage', 'currency', 'curencycurency2', 'currency3', 'chinadelivery', 'chinadelivery2', 'comission', 'promo', @@ -119,7 +135,7 @@ class ChecklistSerializer(serializers.ModelSerializer): 'buyername', 'buyerphone', 'tg', 'receivername', 'reveiverphone', 'paymenttype', 'paymentproovement', 'checkphoto', - 'trackid', 'delivery', + 'trackid', 'cdek_tracking', 'delivery', 'startDate', 'currentDate', ) @@ -171,5 +187,5 @@ class PromocodeSerializer(serializers.ModelSerializer): nocomission = serializers.BooleanField(source='no_comission') class Meta: - model = PromoCode + model = Promocode fields = ('name', 'discount', 'freedelivery', 'nocomission') diff --git a/store/urls.py b/store/urls.py index 630efb1..e1281b3 100644 --- a/store/urls.py +++ b/store/urls.py @@ -7,6 +7,7 @@ router = DefaultRouter() # FIXME: renamed router.register(r'statistics', views.StatisticsAPI, basename='statistics') +router.register(r'cdek', views.CDEKAPI, basename='cdek') urlpatterns = [ path("login/", views.LoginAPI.as_view()), diff --git a/store/views.py b/store/views.py index a574f85..f727add 100644 --- a/store/views.py +++ b/store/views.py @@ -1,20 +1,23 @@ import calendar import json -from collections import OrderedDict, defaultdict +from django.conf import settings from django.contrib.auth import login -from django.db.models import F, Count, Q, Sum +from django.db.models import F, Count, Q, Sum, Value, Subquery from django.utils import timezone from rest_framework import generics, permissions, mixins, status, viewsets from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from cdek.api import CDEKClient from store.exceptions import CRMException -from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, PromoCode +from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, Promocode from store.serializers import (UserSerializer, LoginSerializer, ChecklistSerializer, GlobalSettingsYuanRateSerializer, CategorySerializer, GlobalSettingsPriceSerializer, PaymentMethodSerializer, PromocodeSerializer, GlobalSettingsPickupSerializer) +from utils.permissions import ReadOnly class UserAPI(mixins.ListModelMixin, mixins.RetrieveModelMixin, generics.GenericAPIView): @@ -53,8 +56,9 @@ class LoginAPI(generics.GenericAPIView): return Response(serializer.data) -class ChecklistAPI(mixins.ListModelMixin, mixins.RetrieveModelMixin, generics.GenericAPIView): +class ChecklistAPI(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, generics.GenericAPIView): serializer_class = ChecklistSerializer + permission_classes = [IsAuthenticated | ReadOnly] lookup_field = 'id' filterset_fields = ['status', ] search_fields = ['id', 'track_id', 'buyer_phone', 'full_price'] @@ -76,6 +80,9 @@ class ChecklistAPI(mixins.ListModelMixin, mixins.RetrieveModelMixin, generics.Ge return obj + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + def get(self, request, *args, **kwargs): if 'id' in kwargs: return self.retrieve(request, *args, **kwargs) @@ -129,8 +136,7 @@ class CategoryAPI(generics.GenericAPIView): 'prices': GlobalSettingsPriceSerializer(global_settings).data, }) - # FIXME: use PATCH method for updates - def post(self, request, *args, **kwargs): + def patch(self, request, *args, **kwargs): data = json.loads(request.body) if not all(k in data for k in ("category", "chinarush")): raise CRMException('category and chinarush is required') @@ -146,8 +152,7 @@ class CategoryAPI(generics.GenericAPIView): class PricesAPI(generics.GenericAPIView): serializer_class = GlobalSettingsPriceSerializer - # FIXME: use PATCH method for updates - def post(self, request, *args, **kwargs): + def patch(self, request, *args, **kwargs): data = json.loads(request.body) instance = GlobalSettings.load() @@ -160,6 +165,7 @@ class PricesAPI(generics.GenericAPIView): class PickupAPI(generics.GenericAPIView): serializer_class = GlobalSettingsPickupSerializer + permission_classes = [IsAuthenticated | ReadOnly] def get_object(self): return GlobalSettings.load() @@ -193,8 +199,7 @@ class PaymentMethodsAPI(generics.GenericAPIView): return Response(data) - # FIXME: use PATCH method for updates - def post(self, request, *args, **kwargs): + def patch(self, request, *args, **kwargs): data = json.loads(request.body) if 'type' not in data: raise CRMException('type is required') @@ -212,7 +217,7 @@ class PromoCodeAPI(mixins.CreateModelMixin, generics.GenericAPIView): lookup_field = 'name' def get_queryset(self): - return PromoCode.objects.all() + return Promocode.objects.all() def get(self, request, *args, **kwargs): qs = self.get_queryset() @@ -245,8 +250,6 @@ class StatisticsAPI(viewsets.GenericViewSet): global_settings = GlobalSettings.load() yuan_rate = global_settings.yuan_rate - completed_filter = Q(status=Checklist.Status.COMPLETED) - # Prepare query to collect the stats qs = self.get_queryset() \ .annotate_price_rub() \ @@ -307,51 +310,72 @@ class StatisticsAPI(viewsets.GenericViewSet): return Response(result) - # TODO: implement stats_by_clients @action(url_path='clients', detail=False, methods=['get']) def stats_by_clients(self, request, *args, **kwargs): - def _create_stats(data: dict): - return { - "moreone": data.get('moreone', 0), - "moretwo": data.get('moretwo', 0), - "morethree": data.get('morethree', 0), - "morefour": data.get('morefour', 0), - "morefive": data.get('morefive', 0), - "moreten": data.get('moreten', 0), - "moretwentyfive": data.get('moretwentyfive', 0), - "morefifty": data.get('morefifty', 0), - } + options = { + "moreone": 1, + "moretwo": 2, + "morethree": 3, + "morefour": 4, + "morefive": 5, + "moreten": 10, + "moretwentyfive": 25, + "morefifty": 50, + } - def _filter_for_count(count): - return Count('buyer_phone', - filter=Q(buyer_phone__in=Checklist.objects.values('buyer_phone') - .annotate(total_orders=Count('id')) - .filter(total_orders__gt=count) - .values('buyer_phone') - )) + def _create_empty_stats(): + return {k: set() for k in options.keys()} qs = self.get_queryset() \ - .values('month') \ - .annotate( - moreone=_filter_for_count(1), - moretwo=_filter_for_count(2), - morethree=_filter_for_count(3), - morefour=_filter_for_count(4), - morefive=_filter_for_count(5), - moreten=_filter_for_count(10), - moretwentyfive=_filter_for_count(25), - morefifty=_filter_for_count(50), - ) + .filter(buyer_phone__isnull=False) \ + .values('month', 'buyer_phone') \ + .annotate(order_count=Count('id')) \ + .filter(order_count__gt=1) \ + .order_by('month') result = {} # Add empty stats for i in range(1, 13): - month = calendar.month_name[i] - result[month] = _create_stats(dict()) + month_name = calendar.month_name[i] + result[month_name] = _create_empty_stats() # Add actual stats for stat in qs: - month = calendar.month_name[stat['month']] - result[month] = _create_stats(stat) + month_name = calendar.month_name[stat['month']] + + for key, size in reversed(options.items()): + if stat['order_count'] > size: + result[month_name][key].add(stat['buyer_phone']) + break return Response(result) + + +class CDEKAPI(viewsets.GenericViewSet): + client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET) + + @action(url_path='orders', detail=False, methods=['get']) + def get_order_info(self, request, *args, **kwargs): + im_number = request.query_params.get('im_number') + if not im_number: + raise CRMException('im_number is required') + + r = self.client.get_order_info(im_number) + return Response(r.json()) + + @action(url_path='orders', detail=False, methods=['post']) + def create_order(self, request, *args, **kwargs): + order_data = request.data + if not order_data: + raise CRMException('json data is required') + + r = self.client.create_order(order_data) + return Response(r.json()) + + @action(url_path='calculator/tariff', detail=False, methods=['post']) + def calculate_tariff(self, request, *args, **kwargs): + data = request.data + if not data: + raise CRMException('json data is required') + r = self.client.calculate_tariff(data) + return Response(r.json()) diff --git a/utils/permissions.py b/utils/permissions.py index dcaebd4..af1ce47 100644 --- a/utils/permissions.py +++ b/utils/permissions.py @@ -1,8 +1,14 @@ from rest_framework import permissions from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import BasePermission, SAFE_METHODS class CsrfExemptSessionAuthentication(SessionAuthentication): def enforce_csrf(self, request): # To not perform the csrf check previously happening return + + +class ReadOnly(BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS