commit fcfc33a4351e2fd9890344b51a1e47bbd3c78daa Author: phzhik Date: Mon Jul 3 06:38:55 2023 +0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd23078 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ + +# Static stuff +!assets +!media +media/**/* +assets/**/* + +env +.idea +.DS_Store +db.sqlite3 \ No newline at end of file diff --git a/_docs/cdekapi.pdf b/_docs/cdekapi.pdf new file mode 100644 index 0000000..675d3d1 Binary files /dev/null and b/_docs/cdekapi.pdf differ diff --git a/_docs/Доработки.pdf b/_docs/Доработки.pdf new file mode 100644 index 0000000..d0a65a2 Binary files /dev/null and b/_docs/Доработки.pdf differ diff --git a/_docs/ТЗ Rest Api.pdf b/_docs/ТЗ Rest Api.pdf new file mode 100644 index 0000000..fa1318a Binary files /dev/null and b/_docs/ТЗ Rest Api.pdf differ diff --git a/_docs/обновления исправления.pdf b/_docs/обновления исправления.pdf new file mode 100644 index 0000000..98df052 Binary files /dev/null and b/_docs/обновления исправления.pdf differ diff --git a/cdek/__init__.py b/cdek/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cdek/api.py b/cdek/api.py new file mode 100644 index 0000000..666dbe2 --- /dev/null +++ b/cdek/api.py @@ -0,0 +1,89 @@ +import http +import os +from urllib.parse import urljoin + +import requests +from django.conf import settings +from requests import Request + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') + + +class CDEKClient: + AUTH_ENDPOINT = 'oauth/token' + ORDER_INFO_ENDPOINT = 'orders' + CALCULATOR_TARIFF_ENDPOINT = 'calculator/tariff' + + MAX_RETRIES = 2 + + def __init__(self, client_id, client_secret, grant_type='client_credentials'): + self.api_url = 'https://api.cdek.ru/v2/' + self.client_id = client_id + self.client_secret = client_secret + self.grant_type = grant_type + + self.session = requests.Session() + + def request(self, method, url, *args, **kwargs): + joined_url = urljoin(self.api_url, url) + request = Request(method, joined_url, *args, **kwargs) + prepared = self.session.prepare_request(request) + + retries = 0 + while retries < self.MAX_RETRIES: + r = self.session.send(prepared) + + # TODO: handle/log errors + if r.status_code == http.HTTPStatus.UNAUTHORIZED: + self.authorize() + continue + return r + + def authorize(self): + params = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'grant_type': self.grant_type + } + r = self.request('POST', self.AUTH_ENDPOINT, params=params) + if r: + data = r.json() + token = data['access_token'] + self.session.headers.update({'Authorization': f'Bearer {token}'}) + + # FIXME: not working? + def get_order_info(self, cdek_number): + params = { + 'cdek_number': str(cdek_number) + } + return self.request('GET', self.ORDER_INFO_ENDPOINT, params=params) + + def calculate_tariff(self, order_data): + return self.request('POST', self.CALCULATOR_TARIFF_ENDPOINT, json=order_data) + + +client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET) +client.authorize() + +order_data = { + "type": "1", + "currency": "1", + "lang": "rus", + "tariff_code": "137", + "from_location": { + "address": "Санкт-Петербург, Невский пр. 30" + }, + "to_location": { + "address": "Тверь, ул 15 лет октября, дом 50, кВ 2" + }, + "packages": [ + { + "weight": 1200, + "length": 35, + "width": 26, + "height": 14 + } + ] +} +r = client.calculate_tariff(order_data) +print(r) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..5ea160b --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/poizonstore/__init__.py b/poizonstore/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/poizonstore/asgi.py b/poizonstore/asgi.py new file mode 100644 index 0000000..1a32c30 --- /dev/null +++ b/poizonstore/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for poizonstore project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') + +application = get_asgi_application() diff --git a/poizonstore/settings.py b/poizonstore/settings.py new file mode 100644 index 0000000..40e0a82 --- /dev/null +++ b/poizonstore/settings.py @@ -0,0 +1,163 @@ +""" +Django settings for poizonstore project. + +Generated by 'django-admin startproject' using Django 4.2.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-e&9j(^9z7p7qs-@d)vftjz4%xqu0#3mmn@+$wzwh!%-dwjecm-' + +CDEK_CLIENT_ID = 'wZWtjnWtkX7Fin2tvDdUE6eqYz1t1GND' +CDEK_CLIENT_SECRET = 'lc2gmrmK5s1Kk6FhZbNqpQCaATQRlsOy' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] +INTERNAL_IPS = ["127.0.0.1"] + +AUTH_USER_MODEL = 'store.User' + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'django_cleanup.apps.CleanupSelectedConfig', + 'rest_framework', + 'debug_toolbar', + 'django_filters', + + 'store' +] + +MIDDLEWARE = [ + 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'poizonstore.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'poizonstore.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +REST_FRAMEWORK = { + 'COERCE_DECIMAL_TO_STRING': False, + 'DATETIME_FORMAT': '%d.%m.%Y %H:%M:%S', + + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated' + ], + + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'utils.permissions.CsrfExemptSessionAuthentication', + ), + + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_PAGINATION_CLASS': 'utils.drf.StandardResultsSetPagination' +} + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-RU' + +TIME_ZONE = 'Europe/Moscow' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' +STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] +STATIC_ROOT = os.path.join(BASE_DIR, 'assets') + +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +CHECKLIST_ID_LENGTH = 10 +COMMISSION_OVER_150K = 1.1 diff --git a/poizonstore/urls.py b/poizonstore/urls.py new file mode 100644 index 0000000..5124927 --- /dev/null +++ b/poizonstore/urls.py @@ -0,0 +1,29 @@ +""" +URL configuration for poizonstore project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path, include + + +urlpatterns = [ + path('admin/', admin.site.urls), + path('__debug__/', include('debug_toolbar.urls')), + path('', include('store.urls')), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ + + static(settings.STATIC_URL) + diff --git a/poizonstore/wsgi.py b/poizonstore/wsgi.py new file mode 100644 index 0000000..4e0b4e4 --- /dev/null +++ b/poizonstore/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for poizonstore project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') + +application = get_wsgi_application() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..343ea5c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Core +Django==4.2.2 +django-filter==23.2 +djangorestframework==3.14.0 +Pillow==9.5.0 + +# Misc +tqdm==4.65.0 diff --git a/static/preview_image_font.ttf b/static/preview_image_font.ttf new file mode 100644 index 0000000..9bb70f0 Binary files /dev/null and b/static/preview_image_font.ttf differ diff --git a/store/__init__.py b/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/admin.py b/store/admin.py new file mode 100644 index 0000000..98f2a82 --- /dev/null +++ b/store/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin + +from .models import Category, Checklist, GlobalSettings, PaymentMethod, PromoCode, User, Image + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ('email', 'job_title', 'full_name',) + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ('slug', 'name') + ordering = ('id',) + + +@admin.register(Image) +class ImageAdmin(admin.ModelAdmin): + pass + + +@admin.register(Checklist) +class ChecklistAdmin(admin.ModelAdmin): + list_display = ('id', 'date', 'price_rub', 'commission_rub') + filter_horizontal = ('images',) + + def date(self, obj: Checklist): + return obj.status_updated_at or obj.created_at + + def get_queryset(self, request): + return Checklist.objects.with_base_related().annotate_price_rub().annotate_commission_rub() + + +@admin.register(GlobalSettings) +class GlobalSettingsAdmin(admin.ModelAdmin): + pass + + +@admin.register(PaymentMethod) +class PaymentMethodAdmin(admin.ModelAdmin): + list_display = ('name', 'slug') + + +@admin.register(PromoCode) +class PromoCodeAdmin(admin.ModelAdmin): + list_display = ('name', 'discount', 'free_delivery', 'no_comission') + + + + diff --git a/store/apps.py b/store/apps.py new file mode 100644 index 0000000..41658c1 --- /dev/null +++ b/store/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'store' diff --git a/store/exceptions.py b/store/exceptions.py new file mode 100644 index 0000000..c5f6e84 --- /dev/null +++ b/store/exceptions.py @@ -0,0 +1,19 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class CRMException(APIException): + def __init__(self, detail=None): + if detail is None: + detail = self.default_detail + + self.detail = {'error': detail} + + +class AuthErrorMixin(CRMException): + """Authentication exception error mixin.""" + status_code = status.HTTP_401_UNAUTHORIZED + + +class InvalidCredentialsException(AuthErrorMixin): + default_detail = 'cannot find the worker' diff --git a/store/management/__init__.py b/store/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/management/commands/__init__.py b/store/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/management/commands/create_initial_data.py b/store/management/commands/create_initial_data.py new file mode 100644 index 0000000..3903a87 --- /dev/null +++ b/store/management/commands/create_initial_data.py @@ -0,0 +1,54 @@ +from django.core.management import BaseCommand +from tqdm import tqdm + +from store.models import Category, PaymentMethod + +category_names = { + "shoes": "Обувь", + "outerwear": "Верхняя одежда", + "underwear": "Нижнее белье", + "bags": "Сумки", + "cosmetics": "Косметика", + "accessories": "Аксессуары", + "technics": "Техника", + "watches": "Часы", + "toys": "Игрушки", + "home": "Товары для дома", + "foodndrinks": "Еда и напитки", + "different": "Другое", +} + +payment_methods = { + "alfa": { + "name": "Альфабанк", + "cardnumber": "", + "requisites": "" + }, + "ralf": { + "name": "Райффайзен Банк", + "cardnumber": "", + "requisites": "" + }, + "tink": { + "name": "Тинькофф", + "cardnumber": "", + "requisites": "" + } +} + + +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}) + + def create_payment_types(self): + for slug, data in tqdm(payment_methods.items(), desc="Creating payment methods"): + PaymentMethod.objects.get_or_create(slug=slug, defaults=data) + + def handle(self, *args, **kwargs): + self.create_categories() + self.create_payment_types() + diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py new file mode 100644 index 0000000..bf3a2f7 --- /dev/null +++ b/store/migrations/0001_initial.py @@ -0,0 +1,107 @@ +# Generated by Django 4.2.2 on 2023-06-30 22:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import store.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='Эл. почта')), + ('first_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='last name')), + ('middle_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='Отчество')), + ('job_title', models.CharField(choices=[('admin', 'Администратор'), ('ordermanager', 'Менеджер по заказам'), ('productmanager', 'Менеджер по закупкам')], max_length=30, verbose_name='Должность')), + ('manager_id', models.CharField(blank=True, max_length=5, null=True, verbose_name='ID менеджера')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', store.models.UserManager()), + ], + ), + 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='Название')), + ('slug', models.SlugField(verbose_name='Идентификатор')), + ], + ), + migrations.CreateModel( + name='GlobalSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('yuan_rate', models.DecimalField(decimal_places=2, max_digits=10)), + ('delivery_price_CN', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('delivery_price_CN_RU', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ], + ), + migrations.CreateModel( + name='PromoCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Название')), + ('discount', models.PositiveSmallIntegerField(verbose_name='Скидка')), + ('free_delivery', models.BooleanField(default=False, verbose_name='Бесплатная доставка')), + ('no_comission', models.BooleanField(default=False, verbose_name='Без комиссии')), + ], + ), + migrations.CreateModel( + name='Checklist', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status_updated_at', models.DateTimeField()), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('completed', 'Завершен')], max_length=15, verbose_name='Статус заказа')), + ('product_link', models.URLField(blank=True, null=True)), + ('subcategory', models.CharField(blank=True, max_length=20, null=True, verbose_name='Подкатегория')), + ('brand', models.CharField(blank=True, max_length=100, null=True, verbose_name='Бренд')), + ('model', models.CharField(blank=True, max_length=100, null=True, verbose_name='Модель')), + ('size', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Размер')), + ('image', models.ImageField(blank=True, null=True, upload_to='')), + ('preview_image', models.ImageField(blank=True, null=True, upload_to='')), + ('price_yuan', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('comission', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('real_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('promocode', models.CharField(blank=True, max_length=100, null=True, verbose_name='Промокод')), + ('comment', models.CharField(blank=True, max_length=200, null=True, verbose_name='Комментарий')), + ('buyer_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Имя покупателя')), + ('buyer_phone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Телефон покупателя')), + ('buyer_telegram', models.CharField(blank=True, max_length=100, null=True, verbose_name='Telegram покупателя')), + ('receiver_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Имя получателя')), + ('receiver_phone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Телефон получателя')), + ('payment_type', models.CharField(blank=True, choices=[('alfa', 'Альфа-Банк'), ('tink', 'Тинькофф Банк'), ('raif', 'Райффайзен Банк')], max_length=10, null=True, verbose_name='Метод оплаты')), + ('payment_proof', models.ImageField(blank=True, null=True, upload_to='', verbose_name='Подтверждение оплаты')), + ('cheque_photo', models.ImageField(blank=True, null=True, upload_to='', verbose_name='Фото чека')), + ('delivery', models.CharField(blank=True, choices=[('pickup', 'Самовывоз из шоурума'), ('cdek', 'Пункт выдачи заказов CDEK')], max_length=10, null=True, verbose_name='Тип доставки')), + ('track_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.category', verbose_name='Категория')), + ('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/store/migrations/0002_alter_category_options_remove_checklist_comission_and_more.py b/store/migrations/0002_alter_category_options_remove_checklist_comission_and_more.py new file mode 100644 index 0000000..8e3fcfc --- /dev/null +++ b/store/migrations/0002_alter_category_options_remove_checklist_comission_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.2 on 2023-06-30 22:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='category', + options={'verbose_name': 'Category', 'verbose_name_plural': 'Categories'}, + ), + migrations.RemoveField( + model_name='checklist', + name='comission', + ), + migrations.AlterField( + model_name='checklist', + name='price_yuan', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена в юанях'), + ), + ] diff --git a/store/migrations/0003_alter_checklist_id_alter_checklist_manager_and_more.py b/store/migrations/0003_alter_checklist_id_alter_checklist_manager_and_more.py new file mode 100644 index 0000000..e7d14c7 --- /dev/null +++ b/store/migrations/0003_alter_checklist_id_alter_checklist_manager_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.2 on 2023-07-01 16:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0002_alter_category_options_remove_checklist_comission_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='checklist', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='checklist', + name='manager', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Менеджер'), + ), + migrations.AlterField( + model_name='checklist', + name='product_link', + field=models.URLField(blank=True, null=True, verbose_name='Ссылка на товар'), + ), + migrations.AlterField( + model_name='checklist', + name='real_price', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Реальная цена'), + ), + migrations.AlterField( + model_name='checklist', + name='status_updated_at', + field=models.DateTimeField(verbose_name='Дата обновления статуса заказа'), + ), + migrations.AlterField( + model_name='globalsettings', + name='delivery_price_CN', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='globalsettings', + name='delivery_price_CN_RU', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/store/migrations/0004_alter_globalsettings_options_and_more.py b/store/migrations/0004_alter_globalsettings_options_and_more.py new file mode 100644 index 0000000..2a50e6d --- /dev/null +++ b/store/migrations/0004_alter_globalsettings_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.2 on 2023-07-01 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0003_alter_checklist_id_alter_checklist_manager_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='globalsettings', + options={'verbose_name': 'GlobalSettings', 'verbose_name_plural': 'GlobalSettings'}, + ), + migrations.AlterField( + model_name='globalsettings', + name='yuan_rate', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + ] diff --git a/store/migrations/0005_alter_globalsettings_delivery_price_cn.py b/store/migrations/0005_alter_globalsettings_delivery_price_cn.py new file mode 100644 index 0000000..4232b73 --- /dev/null +++ b/store/migrations/0005_alter_globalsettings_delivery_price_cn.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2023-07-01 17:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0004_alter_globalsettings_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='globalsettings', + name='delivery_price_CN', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + ] diff --git a/store/migrations/0006_alter_checklist_price_yuan.py b/store/migrations/0006_alter_checklist_price_yuan.py new file mode 100644 index 0000000..c4cf67b --- /dev/null +++ b/store/migrations/0006_alter_checklist_price_yuan.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2023-07-01 17:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0005_alter_globalsettings_delivery_price_cn'), + ] + + operations = [ + migrations.AlterField( + model_name='checklist', + name='price_yuan', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена в юанях'), + ), + ] diff --git a/store/migrations/0007_alter_category_slug.py b/store/migrations/0007_alter_category_slug.py new file mode 100644 index 0000000..c4fa133 --- /dev/null +++ b/store/migrations/0007_alter_category_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2023-07-01 17:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0006_alter_checklist_price_yuan'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='slug', + field=models.SlugField(unique=True, verbose_name='Идентификатор'), + ), + ] diff --git a/store/migrations/0008_alter_checklist_id.py b/store/migrations/0008_alter_checklist_id.py new file mode 100644 index 0000000..852880d --- /dev/null +++ b/store/migrations/0008_alter_checklist_id.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.2 on 2023-07-01 17:33 + +from django.db import migrations, models +import store.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0007_alter_category_slug'), + ] + + operations = [ + migrations.AlterField( + model_name='checklist', + name='id', + field=models.CharField(default=store.models.generate_checklist_id, editable=False, max_length=10, primary_key=True, serialize=False), + ), + ] diff --git a/store/migrations/__init__.py b/store/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/models.py b/store/models.py new file mode 100644 index 0000000..beb402b --- /dev/null +++ b/store/models.py @@ -0,0 +1,385 @@ +import time +from decimal import Decimal +from datetime import datetime +import random +import string +import uuid +from io import BytesIO + +from django.conf import settings +from django.contrib.admin import display +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import AbstractUser, UserManager as _UserManager +from django.core.files.base import ContentFile +from django.db import models +from django.db.models import F, Case, When, DecimalField, Prefetch +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 store.utils import create_preview, concat_not_null_values + + +class GlobalSettings(models.Model): + cached = None + + # currency + yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0) + # Chinadelivery + 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) + + class Meta: + verbose_name = 'Глобальные настройки' + verbose_name_plural = 'Глобальные настройки' + + def save(self, *args, **kwargs): + # Store only one instance of GlobalSettings + self.__class__.objects.exclude(id=self.id).delete() + super().save(*args, **kwargs) + GlobalSettings.cached = self + + def __str__(self) -> str: + return f'GlobalSettings for {self.id}' + + @classmethod + def load(cls) -> 'GlobalSettings': + if cls.cached is not None: + return cls.cached + + obj, _ = cls.objects.get_or_create(id=1) + cls.cached = obj + return obj + + +class Category(models.Model): + name = models.CharField('Название', max_length=20) + slug = models.SlugField('Идентификатор', unique=True) + delivery_price_CN_RU = models.DecimalField('Цена доставки Китай-РФ', max_digits=10, decimal_places=2, default=0) # Chinadelivery2 + + def __str__(self): + return self.name + + class Meta: + verbose_name = 'Категория' + verbose_name_plural = 'Категории' + + +class UserQuerySet(models.QuerySet): + pass + + +class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager): + def _create_user(self, email, password, **extra_fields): + if not email: + raise ValueError("The given email must be set") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.password = make_password(password) + user.save(using=self._db) + return user + + def create_user(self, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("job_title", User.ADMIN) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + + return self._create_user(email, password, **extra_fields) + + +class User(AbstractUser): + ADMIN = "admin" + ORDER_MANAGER = "ordermanager" + PRODUCT_MANAGER = "productmanager" + + JOB_CHOICES = ( + (ADMIN, 'Администратор'), + (ORDER_MANAGER, 'Менеджер по заказам'), + (PRODUCT_MANAGER, 'Менеджер по закупкам'), + ) + + # Login by email + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] + username = None + email = models.EmailField("Эл. почта", unique=True) + + first_name = models.CharField(_("first name"), max_length=150, blank=True, null=True) + last_name = models.CharField(_("last name"), max_length=150, blank=True, null=True) + middle_name = models.CharField("Отчество", max_length=150, blank=True, null=True) + job_title = models.CharField("Должность", max_length=30, choices=JOB_CHOICES) + + manager_id = models.CharField("ID менеджера", max_length=5, blank=True, null=True) + + objects = UserManager() + + @property + def is_superuser(self): + return self.job_title == self.ADMIN + + @display(description='ФИО') + def full_name(self): + return concat_not_null_values(self.last_name, self.first_name, self.middle_name) + + +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 + + class Meta: + verbose_name = 'Промокод' + verbose_name_plural = 'Промокоды' + + +class PaymentMethod(models.Model): + name = models.CharField('Название', max_length=30) + slug = models.SlugField('Идентификатор', unique=True) + cardnumber = models.CharField('Номер карты', max_length=30) + requisites = models.CharField('Реквизиты', max_length=200) + + class Meta: + verbose_name = 'Метод оплаты' + verbose_name_plural = 'Методы оплаты' + + def __str__(self): + return self.name + + +class Image(models.Model): + image = models.ImageField(upload_to='checklist_images') + is_preview = models.BooleanField(default=False) + + class Meta: + verbose_name = 'Изображение' + verbose_name_plural = 'Изображения' + + def __str__(self): + return getattr(self.image, 'name') + + +def generate_checklist_id(): + all_ids = Checklist.objects.all().values_list('id', flat=True) + allowed_chars = string.ascii_letters + string.digits + + while True: + generated_id = ''.join(random.choice(allowed_chars) for _ in range(settings.CHECKLIST_ID_LENGTH)) + if generated_id not in all_ids: + return generated_id + + +class ChecklistQuerySet(models.QuerySet): + def with_base_related(self): + return self.select_related('manager', 'category', 'payment_method')\ + .prefetch_related(Prefetch('images', to_attr='_images')) + + def default_ordering(self): + return self.order_by(F('status_updated_at').desc(nulls_last=True)) + + def annotate_price_rub(self): + yuan_rate = GlobalSettings.load().yuan_rate + return self.annotate(_price_rub=F('price_yuan') * yuan_rate) + + def annotate_commission_rub(self): + commission = GlobalSettings.load().commission_rub + return self.annotate(_commission_rub=Case( + When(GreaterThan(F("_price_rub"), 150_000), then=F("_price_rub") * settings.COMMISSION_OVER_150K), + default=commission, + output_field=DecimalField() + )) + + +@cleanup.select +class Checklist(models.Model): + # Statuses + class Status: + DRAFT = "draft" + NEW = "neworder" + PAYMENT = "payment" + BUYING = "buying" + BOUGHT = "bought" + CHINA = "china" + CHINA_RUSSIA = "chinarush" + RUSSIA = "rush" + CDEK = "cdek" + COMPLETED = "completed" + + CHOICES = ( + (DRAFT, 'Черновик'), + (NEW, 'Новый заказ'), + (PAYMENT, 'Проверка оплаты'), + (BUYING, 'На закупке'), + (BOUGHT, 'Закуплен'), + (CHINA, 'На складе в Китае'), + (CHINA_RUSSIA, 'Доставка на склад РФ'), + (RUSSIA, 'На складе в РФ'), + (CDEK, 'Доставляется СДЭК'), + (COMPLETED, 'Завершен'), + ) + + # Payment types + class PaymentType: + ALFA = "alfa" + TINKOFF = "tink" + RAIFFEISEN = "raif" + + CHOICES = ( + (ALFA, 'Альфа-Банк'), + (TINKOFF, 'Тинькофф Банк'), + (RAIFFEISEN, 'Райффайзен Банк'), + ) + + # Delivery + class DeliveryType: + PICKUP = "pickup" + CDEK = "cdek" + + CHOICES = ( + (PICKUP, 'Самовывоз из шоурума'), + (CDEK, 'Пункт выдачи заказов CDEK'), + ) + + created_at = models.DateTimeField(auto_now_add=True) + status_updated_at = models.DateTimeField('Дата обновления статуса заказа') + 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) + # managerid + 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) + 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 + real_price = models.DecimalField('Реальная цена', max_digits=10, decimal_places=2, null=True, blank=True) + + # 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) + + # buyername + buyer_name = models.CharField('Имя покупателя', max_length=100, null=True, blank=True) + # buyerphone + buyer_phone = models.CharField('Телефон покупателя', max_length=100, null=True, blank=True) + # tg + buyer_telegram = models.CharField('Telegram покупателя', max_length=100, null=True, blank=True) + + # receivername + receiver_name = models.CharField('Имя получателя', max_length=100, null=True, blank=True) + # reveiverphone + receiver_phone = models.CharField('Телефон получателя', max_length=100, null=True, blank=True) + + # paymenttype + payment_method = models.ForeignKey('PaymentMethod', verbose_name='Метод оплаты', + null=True, blank=True, + on_delete=models.SET_NULL) + payment_proof = models.ImageField('Подтверждение оплаты', null=True, blank=True) # paymentproovement + cheque_photo = models.ImageField('Фото чека', null=True, blank=True) # checkphoto + + 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) + + objects = ChecklistQuerySet.as_manager() + + class Meta: + verbose_name = 'Заказ' + verbose_name_plural = 'Заказы' + + @property + def price_rub(self) -> Decimal: + # Prefer annotated field + if hasattr(self, '_price_rub'): + return self._price_rub + + return GlobalSettings.load().yuan_rate * self.price_yuan + + @property + def full_price(self) -> Decimal: + return self.price_rub + GlobalSettings.load().delivery_price_CN + self.delivery_price_CN_RU + + @property + def delivery_price_CN_RU(self) -> Decimal: + return getattr(self.category, 'delivery_price_CN_RU', 0.0) + + @property + def commission_rub(self) -> Decimal: + # Prefer annotated field + if hasattr(self, '_commission_rub'): + return self._commission_rub + + return (self.price_rub * Decimal(settings.COMMISSION_OVER_150K) + if self.price_rub > 150_000 + else GlobalSettings.load().commission_rub) + + @property + def preview_image(self): + # Prefer annotated field + if hasattr(self, '_images'): + return next(filter(lambda x: x.is_preview, self._images), None) + + return self.images.filter(is_preview=True).first() + + @property + def preview_image_url(self): + return getattr(self.preview_image, 'image', None) + + @property + def main_images(self): + # Prefer prefetched field + if hasattr(self, '_images'): + return [img for img in self._images if not img.is_preview] + + return self.images.filter(is_preview=False) + + @property + def title(self): + return concat_not_null_values(self.brand, self.model) + + def __str__(self): + return f'{self.id}' + + def save(self, *args, **kwargs): + if self.id: + old_obj = Checklist.objects.get(id=self.id) + # If status was updated, update status_updated_at field + if self.status != old_obj.status: + self.status_updated_at = timezone.now() + + # Create preview image + if self.images.exists() and not self.preview_image: + # Render preview image + original = self.images.first().image + preview = create_preview(original.path, size=self.size, price_rub=self.price_rub, title=self.title) + + # Prepare bytes + image_io = BytesIO() + preview.save(image_io, format='JPEG') + + # Create Image model and save it + image_obj = Image(is_preview=True) + 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 new file mode 100644 index 0000000..a06fc07 --- /dev/null +++ b/store/serializers.py @@ -0,0 +1,175 @@ +from django.contrib.auth import authenticate +from rest_framework import serializers + +from store.exceptions import CRMException, InvalidCredentialsException +from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, PromoCode, Image + + +class LoginSerializer(serializers.Serializer): + login = serializers.CharField(write_only=True, required=False) + password = serializers.CharField(trim_whitespace=False, write_only=True, required=False) + + def validate(self, attrs): + email = attrs.get('login') + password = attrs.get('password') + + if not email or not password: + raise CRMException('login and password is required') + + user = authenticate(request=self.context.get('request'), + email=email, + password=password) + + # The authenticate call simply returns None for is_active=False + # users. (Assuming the default ModelBackend authentication + # backend.) + if not user: + raise InvalidCredentialsException() + + attrs['user'] = user + return attrs + + +class UserSerializer(serializers.ModelSerializer): + login = serializers.CharField(source='email', required=False) + job = serializers.CharField(source='job_title', required=False) + name = serializers.CharField(source='first_name', required=False) + lastname = serializers.CharField(source='middle_name', required=False) + surname = serializers.CharField(source='last_name', required=False) + managerid = serializers.CharField(source='manager_id', required=False) + + class Meta: + model = User + fields = ('id', 'login', 'job', 'name', 'lastname', 'surname', 'managerid') + + +class ImageSerializer(serializers.ModelSerializer): + class Meta: + model = Image + fields = ('image', ) + + +class OrderImageListSerializer(serializers.ListSerializer): + child = ImageSerializer() + + def to_representation(self, data): + images = super().to_representation(data) + return [image['image'] for image in images] + + +class ChecklistSerializer(serializers.ModelSerializer): + id = serializers.CharField(read_only=True) + managerid = serializers.CharField(source='manager.manager_id', required=False, allow_null=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 + 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') + + buyername = serializers.CharField(source='buyer_name') + buyerphone = serializers.CharField(source='buyer_phone') + tg = serializers.CharField(source='buyer_telegram') + + receivername = serializers.CharField(source='receiver_name') + reveiverphone = serializers.CharField(source='receiver_phone') + + 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') + + startDate = serializers.DateTimeField(source='created_at') + currentDate = serializers.DateTimeField(source='status_updated_at') + + @staticmethod + def get_yuan_rate(obj: Checklist): + return GlobalSettings.load().yuan_rate + + @staticmethod + def get_delivery_price_CN(obj: Checklist): + return GlobalSettings.load().delivery_price_CN + + @staticmethod + def get_comission(obj: Checklist): + return GlobalSettings.load().commission_rub + + class Meta: + model = Checklist + fields = ('id', 'status', 'managerid', 'link', + 'category', 'subcategory', + 'brand', 'model', 'size', + 'image', + 'previewimage', + 'currency', 'curencycurency2', 'currency3', 'chinadelivery', 'chinadelivery2', 'comission', + 'promo', + 'comment', + 'fullprice', 'realprice', + 'buyername', 'buyerphone', 'tg', + 'receivername', 'reveiverphone', + 'paymenttype', 'paymentproovement', 'checkphoto', + 'trackid', 'delivery', + 'startDate', 'currentDate', + ) + + +class GlobalSettingsYuanRateSerializer(serializers.ModelSerializer): + currency = serializers.DecimalField(source='yuan_rate', max_digits=10, decimal_places=2) + + class Meta: + model = GlobalSettings + fields = ('currency',) + + +class GlobalSettingsPickupSerializer(serializers.ModelSerializer): + pickup = serializers.CharField(source='pickup_address') + + class Meta: + model = GlobalSettings + fields = ('pickup',) + + +class GlobalSettingsPriceSerializer(serializers.ModelSerializer): + comission = 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 = ('comission', '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') + + +class PaymentMethodSerializer(serializers.ModelSerializer): + type = serializers.CharField(source='slug') + + class Meta: + model = PaymentMethod + fields = ('type', 'name', 'requisites', 'cardnumber') + + +class PromocodeSerializer(serializers.ModelSerializer): + freedelivery = serializers.BooleanField(source='free_delivery') + nocomission = serializers.BooleanField(source='no_comission') + + class Meta: + model = PromoCode + fields = ('name', 'discount', 'freedelivery', 'nocomission') diff --git a/store/tests.py b/store/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/store/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/store/urls.py b/store/urls.py new file mode 100644 index 0000000..630efb1 --- /dev/null +++ b/store/urls.py @@ -0,0 +1,27 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from store import views + +router = DefaultRouter() + +# FIXME: renamed +router.register(r'statistics', views.StatisticsAPI, basename='statistics') + +urlpatterns = [ + path("login/", views.LoginAPI.as_view()), + path("users/", views.UserAPI.as_view()), + path("users/", views.UserAPI.as_view()), + + 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("payment/", views.PaymentMethodsAPI.as_view()), + + path("promo/", views.PromoCodeAPI.as_view()), + +] + router.urls diff --git a/store/utils.py b/store/utils.py new file mode 100644 index 0000000..b2884e4 --- /dev/null +++ b/store/utils.py @@ -0,0 +1,84 @@ +import os +import textwrap + +from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError + +from poizonstore.settings import BASE_DIR + + +def create_preview(source_image: str, size=None, price_rub=None, title=None): + def get_font(font_size): + font_path = os.path.join(BASE_DIR, 'static', 'preview_image_font.ttf') + return ImageFont.truetype(font_path, size=font_size) + + def resize_with_ar(image, size): + max_w, max_h = size + + if image.height > image.width: + factor = max_h / image.height + else: + factor = max_w / image.width + + return image.resize((int(image.width * factor), int(image.height * factor))) + + if not os.path.isfile(source_image): + return None + + # Create image + preview_width, preview_height = 800, 600 + hor_padding = 15 + vert_padding = 50 + + canvas_img = Image.new('RGBA', (preview_width, preview_height), color='white') + draw = ImageDraw.Draw(canvas_img) + + # Draw top text + top_font = get_font(20) + text = 'Заказ в Poizon Store' + draw.text((hor_padding, vert_padding), text, font=top_font, fill='black') + + # Draw title + if title: + title_font = get_font(40) + text = title + wrapped_text = textwrap.wrap(text, width=10) + + x, start_y = hor_padding, vert_padding + 30 + for line in wrapped_text: + draw.text((x, start_y), line, font=title_font, fill='black') + start_y += title_font.getbbox(line)[3] + + # Draw size + if size: + size_text = str(size) + size_font = get_font(20) + x1, y1 = hor_padding, vert_padding + 30 + 100 + x2, y2 = x1 + 40, y1 + 40 + draw.rectangle((x1, y1, x2, y2), fill='black', width=2) + draw.text((x1 + 7, y1 + 7), size_text, font=size_font, fill='white') + + # Draw price + if price_rub: + price_text = f"{str(price_rub)} ₽" + price_font = get_font(50) + draw.text((hor_padding + 15, preview_height - 100), price_text, font=price_font, fill='black') + + # Draw goods image + img2_box_w = preview_width - 270 - hor_padding * 2 + img2_box_y = preview_height - vert_padding * 2 + + try: + with Image.open(source_image).convert("RGBA") as img2: + img2 = resize_with_ar(img2, (img2_box_w, img2_box_y)) + + img2_x = 270 + int(img2_box_w / 2) - int(img2.width / 2) + img2_y = 50 + int(img2_box_y / 2) - int(img2.height / 2) + + canvas_img.paste(img2, (img2_x, img2_y), mask=img2) + return canvas_img.convert('RGB') + except UnidentifiedImageError: + return None + + +def concat_not_null_values(*values, separator=' '): + return separator.join([v for v in values if v is not None]) diff --git a/store/validators.py b/store/validators.py new file mode 100644 index 0000000..e69de29 diff --git a/store/views.py b/store/views.py new file mode 100644 index 0000000..a574f85 --- /dev/null +++ b/store/views.py @@ -0,0 +1,357 @@ +import calendar +import json +from collections import OrderedDict, defaultdict + +from django.contrib.auth import login +from django.db.models import F, Count, Q, Sum +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.response import Response + +from store.exceptions import CRMException +from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, PromoCode +from store.serializers import (UserSerializer, LoginSerializer, ChecklistSerializer, GlobalSettingsYuanRateSerializer, + CategorySerializer, GlobalSettingsPriceSerializer, PaymentMethodSerializer, + PromocodeSerializer, GlobalSettingsPickupSerializer) + + +class UserAPI(mixins.ListModelMixin, mixins.RetrieveModelMixin, generics.GenericAPIView): + serializer_class = UserSerializer + + def get_queryset(self): + return User.objects.all() + + def get(self, request, *args, **kwargs): + if 'pk' in kwargs: + return self.retrieve(request, *args, **kwargs) + return self.list(request, *args, **kwargs) + + # Update some data on current user + def patch(self, request, *args, **kwargs): + data = json.loads(request.body) + instance = self.request.user + serializer = self.get_serializer(instance, data=data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + +class LoginAPI(generics.GenericAPIView): + serializer_class = LoginSerializer + permission_classes = (permissions.AllowAny,) + + def post(self, request, *args, **kwargs): + data = json.loads(request.body) + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + login(request, user) + return Response(serializer.data) + + +class ChecklistAPI(mixins.ListModelMixin, mixins.RetrieveModelMixin, generics.GenericAPIView): + serializer_class = ChecklistSerializer + lookup_field = 'id' + filterset_fields = ['status', ] + search_fields = ['id', 'track_id', 'buyer_phone', 'full_price'] + + def get_queryset(self): + return Checklist.objects.all().with_base_related() \ + .annotate_price_rub().annotate_commission_rub() \ + .default_ordering() + + 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: + obj.status = Checklist.Status.DRAFT + obj.save() + + return obj + + def get(self, request, *args, **kwargs): + if 'id' in kwargs: + return self.retrieve(request, *args, **kwargs) + return self.list(request, *args, **kwargs) + + # Update some data on current user + def patch(self, request, *args, **kwargs): + data = json.loads(request.body) + instance = get_object_or_404(self.get_queryset(), id=self.kwargs['id']) + serializer = self.get_serializer(instance, data=data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + +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}) + + # FIXME: use PATCH method for updates + def post(self, request, *args, **kwargs): + data = json.loads(request.body) + instance = self.get_object() + + serializer = self.get_serializer(instance, data=data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + +class CategoryAPI(generics.GenericAPIView): + serializer_class = CategorySerializer + + def get_queryset(self): + return Category.objects.all() + + 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, + }) + + # FIXME: use PATCH method for updates + def post(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') + + 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) + + +class PricesAPI(generics.GenericAPIView): + serializer_class = GlobalSettingsPriceSerializer + + # FIXME: use PATCH method for updates + def post(self, request, *args, **kwargs): + data = json.loads(request.body) + + instance = GlobalSettings.load() + serializer = self.get_serializer(instance, data=data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + +class PickupAPI(generics.GenericAPIView): + serializer_class = GlobalSettingsPickupSerializer + + def get_object(self): + return GlobalSettings.load() + + def get(self, request, *args, **kwargs): + instance = self.get_object() + return Response(self.get_serializer(instance).data) + + def patch(self, request, *args, **kwargs): + data = json.loads(request.body) + + instance = GlobalSettings.load() + serializer = self.get_serializer(instance, data=data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + +class PaymentMethodsAPI(generics.GenericAPIView): + serializer_class = PaymentMethodSerializer + + def get_queryset(self): + return PaymentMethod.objects.all() + + def get(self, request, *args, **kwargs): + qs = self.get_queryset() + data = {} + for obj in qs: + data[obj.slug] = self.get_serializer(obj).data + + return Response(data) + + # FIXME: use PATCH method for updates + def post(self, request, *args, **kwargs): + data = json.loads(request.body) + if 'type' not in data: + raise CRMException('type is required') + + instance = get_object_or_404(self.get_queryset(), slug=data['type']) + serializer = self.get_serializer(instance, data=data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + +class PromoCodeAPI(mixins.CreateModelMixin, generics.GenericAPIView): + serializer_class = PromocodeSerializer + lookup_field = 'name' + + def get_queryset(self): + return PromoCode.objects.all() + + def get(self, request, *args, **kwargs): + qs = self.get_queryset() + return Response( + {'promo': self.get_serializer(qs, many=True).data} + ) + + def post(self, request, *args, **kwargs): + self.create(request, *args, **kwargs) + return self.get(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + data = json.loads(request.body) + if 'name' not in data: + raise CRMException('name is required') + + instance = get_object_or_404(self.get_queryset(), name=data['name']) + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class StatisticsAPI(viewsets.GenericViewSet): + def get_queryset(self): + return Checklist.objects.all() \ + .filter(status=Checklist.Status.COMPLETED) \ + .annotate(month=F('status_updated_at__month')) + + @action(url_path='orders', detail=False, methods=['get']) + def stats_by_orders(self, request, *args, **kwargs): + 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() \ + .annotate_commission_rub() \ + .values('month') \ + .annotate(total=Count('id'), + total_completed=Count('id'), + total_bought_yuan=Sum('real_price', default=0), + total_bought_rub=F('total_bought_yuan') * yuan_rate, + total_commission_rub=Sum('_commission_rub', default=0) + ) + + def _create_stats(data: dict): + return { + "CountOrders": data.get('total', 0), + "CountComplete": data.get('total_completed', 0), + "SumCommission": data.get('total_commission_rub', 0), + "SumOrders1": data.get('total_bought_yuan', 0), + "SumOrders2": data.get('total_bought_rub', 0), + } + + result = {} + # Add empty stats + for i in range(1, 13): + month_name = calendar.month_name[i] + result[month_name] = _create_stats(dict()) + + # Add actual stats + for stat in qs: + month_name = calendar.month_name[stat['month']] + result[month_name] = _create_stats(stat) + + return Response(result) + + @action(url_path='categories', detail=False, methods=['get']) + def stats_by_categories(self, request, *args, **kwargs): + all_categories = Category.objects.values_list('slug', flat=True) + categories_dict = {c: 0 for c in all_categories} + + qs = self.get_queryset() \ + .select_related('category') \ + .values('month', 'category__slug') \ + .annotate(total_orders=Count('id')) + + result = {} + # Add empty stats + for i in range(1, 13): + month = calendar.month_name[i] + result[month] = categories_dict.copy() + + # Add actual stats + for stat in qs: + month = calendar.month_name[stat['month']] + category_slug = stat['category__slug'] + total_orders = stat['total_orders'] + + result[month][category_slug] = total_orders + + 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), + } + + 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') + )) + + 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), + ) + + result = {} + # Add empty stats + for i in range(1, 13): + month = calendar.month_name[i] + result[month] = _create_stats(dict()) + + # Add actual stats + for stat in qs: + month = calendar.month_name[stat['month']] + result[month] = _create_stats(stat) + + return Response(result) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/drf.py b/utils/drf.py new file mode 100644 index 0000000..b3f9320 --- /dev/null +++ b/utils/drf.py @@ -0,0 +1,17 @@ +from collections import OrderedDict + +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + + +class StandardResultsSetPagination(PageNumberPagination): + page_size_query_param = 'limit' + max_page_size = 1000 + + def get_paginated_response(self, data): + return Response(OrderedDict([ + ('total_pages', self.page.paginator.count), + ('limit', self.page.paginator.per_page), + ('page', self.page.number), + ('data', data), + ])) diff --git a/utils/permissions.py b/utils/permissions.py new file mode 100644 index 0000000..dcaebd4 --- /dev/null +++ b/utils/permissions.py @@ -0,0 +1,8 @@ +from rest_framework import permissions +from rest_framework.authentication import SessionAuthentication + + +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + # To not perform the csrf check previously happening + return