Initial commit
This commit is contained in:
commit
fcfc33a435
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# Static stuff
|
||||||
|
!assets
|
||||||
|
!media
|
||||||
|
media/**/*
|
||||||
|
assets/**/*
|
||||||
|
|
||||||
|
env
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
db.sqlite3
|
||||||
BIN
_docs/cdekapi.pdf
Normal file
BIN
_docs/cdekapi.pdf
Normal file
Binary file not shown.
BIN
_docs/Доработки.pdf
Normal file
BIN
_docs/Доработки.pdf
Normal file
Binary file not shown.
BIN
_docs/ТЗ Rest Api.pdf
Normal file
BIN
_docs/ТЗ Rest Api.pdf
Normal file
Binary file not shown.
BIN
_docs/обновления исправления.pdf
Normal file
BIN
_docs/обновления исправления.pdf
Normal file
Binary file not shown.
0
cdek/__init__.py
Normal file
0
cdek/__init__.py
Normal file
89
cdek/api.py
Normal file
89
cdek/api.py
Normal file
|
|
@ -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)
|
||||||
22
manage.py
Executable file
22
manage.py
Executable file
|
|
@ -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()
|
||||||
0
poizonstore/__init__.py
Normal file
0
poizonstore/__init__.py
Normal file
16
poizonstore/asgi.py
Normal file
16
poizonstore/asgi.py
Normal file
|
|
@ -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()
|
||||||
163
poizonstore/settings.py
Normal file
163
poizonstore/settings.py
Normal file
|
|
@ -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
|
||||||
29
poizonstore/urls.py
Normal file
29
poizonstore/urls.py
Normal file
|
|
@ -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)
|
||||||
|
|
||||||
16
poizonstore/wsgi.py
Normal file
16
poizonstore/wsgi.py
Normal file
|
|
@ -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()
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
|
|
@ -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
|
||||||
BIN
static/preview_image_font.ttf
Normal file
BIN
static/preview_image_font.ttf
Normal file
Binary file not shown.
0
store/__init__.py
Normal file
0
store/__init__.py
Normal file
50
store/admin.py
Normal file
50
store/admin.py
Normal file
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
store/apps.py
Normal file
6
store/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StoreConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'store'
|
||||||
19
store/exceptions.py
Normal file
19
store/exceptions.py
Normal file
|
|
@ -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'
|
||||||
0
store/management/__init__.py
Normal file
0
store/management/__init__.py
Normal file
0
store/management/commands/__init__.py
Normal file
0
store/management/commands/__init__.py
Normal file
54
store/management/commands/create_initial_data.py
Normal file
54
store/management/commands/create_initial_data.py
Normal file
|
|
@ -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()
|
||||||
|
|
||||||
107
store/migrations/0001_initial.py
Normal file
107
store/migrations/0001_initial.py
Normal file
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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='Цена в юанях'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
store/migrations/0006_alter_checklist_price_yuan.py
Normal file
18
store/migrations/0006_alter_checklist_price_yuan.py
Normal file
|
|
@ -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='Цена в юанях'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
store/migrations/0007_alter_category_slug.py
Normal file
18
store/migrations/0007_alter_category_slug.py
Normal file
|
|
@ -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='Идентификатор'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
store/migrations/0008_alter_checklist_id.py
Normal file
19
store/migrations/0008_alter_checklist_id.py
Normal file
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
store/migrations/__init__.py
Normal file
0
store/migrations/__init__.py
Normal file
385
store/models.py
Normal file
385
store/models.py
Normal file
|
|
@ -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)
|
||||||
|
|
||||||
175
store/serializers.py
Normal file
175
store/serializers.py
Normal file
|
|
@ -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')
|
||||||
3
store/tests.py
Normal file
3
store/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
27
store/urls.py
Normal file
27
store/urls.py
Normal file
|
|
@ -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/<int:pk>", views.UserAPI.as_view()),
|
||||||
|
|
||||||
|
path("checklist/", views.ChecklistAPI.as_view()),
|
||||||
|
path("checklist/<str:id>", views.ChecklistAPI.as_view()),
|
||||||
|
|
||||||
|
path("currency/", views.YuanRateAPI.as_view()),
|
||||||
|
path("category/", views.CategoryAPI.as_view()),
|
||||||
|
path("pickup/", views.PickupAPI.as_view()),
|
||||||
|
path("category/price/", views.PricesAPI.as_view()),
|
||||||
|
path("payment/", views.PaymentMethodsAPI.as_view()),
|
||||||
|
|
||||||
|
path("promo/", views.PromoCodeAPI.as_view()),
|
||||||
|
|
||||||
|
] + router.urls
|
||||||
84
store/utils.py
Normal file
84
store/utils.py
Normal file
|
|
@ -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])
|
||||||
0
store/validators.py
Normal file
0
store/validators.py
Normal file
357
store/views.py
Normal file
357
store/views.py
Normal file
|
|
@ -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)
|
||||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
17
utils/drf.py
Normal file
17
utils/drf.py
Normal file
|
|
@ -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),
|
||||||
|
]))
|
||||||
8
utils/permissions.py
Normal file
8
utils/permissions.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user