kwork-poizonstore/store/models.py
2023-11-23 03:39:06 +04:00

664 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import math
import posixpath
from datetime import timedelta
from decimal import Decimal
import random
import string
from io import BytesIO
from typing import Optional
from django.conf import settings
from django.contrib.admin import display
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.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from django.db.models import F, Case, When, DecimalField, Prefetch, Max, Q, ExpressionWrapper
from django.db.models.functions import Ceil
from django.db.models.lookups import GreaterThan
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_cleanup import cleanup
from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel
from external_api.currency import client as CurrencyAPIClient
from store.utils import create_preview, concat_not_null_values
from utils.cache import InMemoryCache
class GlobalSettings(models.Model):
# currency
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
yuan_rate_last_updated = models.DateTimeField('Дата обновления курса CNY/RUB', null=True, default=None)
yuan_rate_commission = models.DecimalField('Наценка на курс юаня, руб', 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)
time_to_buy = models.DurationField('Время на покупку',
help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'",
default=timedelta(hours=3))
class Meta:
verbose_name = 'Глобальные настройки'
verbose_name_plural = 'Глобальные настройки'
def save(self, *args, **kwargs):
# Store only one instance of GlobalSettings
self.id = 1
self.__class__.objects.exclude(id=self.id).delete()
super().save(*args, **kwargs)
InMemoryCache.set('GlobalSettings', self)
def __str__(self) -> str:
return f'GlobalSettings <{self.id}>'
@classmethod
def load(cls) -> 'GlobalSettings':
cached = InMemoryCache.get('GlobalSettings')
if cached:
return cached
obj, _ = cls.objects.get_or_create(id=1)
InMemoryCache.set('GlobalSettings', obj)
return obj
def get_yuan_rate(self):
""" Get rate either from CurrencyAPI or from DB (if fresh enough) """
if self.yuan_rate_last_updated is not None:
diff_minutes = (timezone.now() - self.yuan_rate_last_updated).total_seconds() / 60
else:
diff_minutes = None
if diff_minutes is None or diff_minutes > settings.YUAN_RATE_UPDATE_PERIOD_MINUTES:
# Get fresh rate from API
rate = CurrencyAPIClient.get_cny_rate()
if rate:
# Save rate in DB for future usage
self.yuan_rate = rate
self.yuan_rate_last_updated = timezone.now()
self.save()
# Default
return self.yuan_rate + self.yuan_rate_commission
class Category(MPTTModel):
name = models.CharField('Название', max_length=20)
parent = TreeForeignKey('self', verbose_name='Родительская категория', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', db_index=True)
delivery_price_CN_RU = models.DecimalField('Цена доставки Китай-РФ', max_digits=10, decimal_places=2, default=0) # Chinadelivery2
commission = models.DecimalField('Дополнительная комиссия, %',
max_digits=10, decimal_places=2, default=0,
validators=[MinValueValidator(0), MaxValueValidator(100)])
def __str__(self):
return self.name
class MPTTMeta:
order_insertion_by = ['id']
parent_attr = 'parent'
class Meta:
verbose_name = 'Категория'
verbose_name_plural = 'Категории'
@property
def delivery_price(self):
if not self.delivery_price_CN_RU and self.parent_id:
return self.parent.delivery_price
else:
return self.delivery_price_CN_RU
@property
def commission_price(self):
""" Get commission from object or from its parent """
if not self.commission and self.parent_id:
return self.parent.commission_price
else:
return self.commission
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)
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 PromocodeQuerySet(models.QuerySet):
def active(self):
return self.filter(is_active=True)
class Promocode(models.Model):
name = models.CharField('Название', max_length=100, unique=True)
discount = models.PositiveIntegerField('Скидка в рублях')
free_delivery = models.BooleanField('Бесплатная доставка', default=False) # freedelivery
no_comission = models.BooleanField('Без комиссии', default=False) # nocomission
is_active = models.BooleanField('Активен', default=True)
objects = PromocodeQuerySet.as_manager()
def __str__(self):
return self.name
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, blank=True, null=True)
requisites = models.CharField('Реквизиты', max_length=200, blank=True, null=True)
class Meta:
verbose_name = 'Метод оплаты'
verbose_name_plural = 'Методы оплаты'
def __str__(self):
return self.name
def image_upload_path(instance, filename):
dirname = Image.TYPE_TO_UPLOAD_PATH[instance.type]
return posixpath.join(dirname, filename)
@cleanup.select
class Image(models.Model):
DEFAULT = 0
PREVIEW = 1
DOC = 2
GIFT = 3
TYPE_CHOICES = (
(DEFAULT, 'Изображение'),
(PREVIEW, 'Превью'),
(DOC, 'Документ'),
(GIFT, 'Подарок'),
)
TYPE_TO_UPLOAD_PATH = {
DEFAULT: 'checklist_images/',
PREVIEW: 'checklist_images/',
DOC: 'docs/',
GIFT: 'gifts/',
}
image = models.ImageField('Файл изображения', upload_to=image_upload_path)
type = models.PositiveSmallIntegerField('Тип', choices=TYPE_CHOICES, default=DEFAULT)
class Meta:
verbose_name = 'Изображение'
verbose_name_plural = 'Изображения'
def __str__(self):
return f"{self.get_type_display()}: {getattr(self.image, 'name', '')}"
class Gift(models.Model):
name = models.CharField('Название', max_length=100)
image = models.ImageField('Фото', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.GIFT], null=True, blank=True)
min_price = models.DecimalField('Минимальная цена в юанях', help_text='от какой суммы доступен подарок', max_digits=10, decimal_places=2, default=0)
available_count = models.PositiveSmallIntegerField('Доступное количество', default=0)
def __str__(self):
return self.name
class Meta:
verbose_name = 'Подарок'
verbose_name_plural = 'Подарки'
def generate_checklist_id():
""" Generate unique id for Checklist """
all_ids = Checklist.objects.all().values_list('id', flat=True)
allowed_chars = string.ascii_letters + string.digits
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',
'promocode', 'price_snapshot', 'gift') \
.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):
return self.annotate(
_yuan_rate=Case(
When(price_snapshot_id__isnull=False, then=F('price_snapshot__yuan_rate')),
default=GlobalSettings.load().get_yuan_rate()
),
_price_rub=Ceil(F('_yuan_rate') * F('price_yuan'))
)
def annotate_commission_rub(self):
default_commission = GlobalSettings.load().commission_rub
over_150k_commission = F('_price_rub') * settings.COMMISSION_OVER_150K
category_commission_is_zero_and_parent_present = (
(Q(category__commission__isnull=True) | Q(category__commission=0)) & Q(category__parent__isnull=False)
)
return self.annotate(
_category_commission_percent=Case(
When(category_commission_is_zero_and_parent_present, then=F('category__parent__commission')),
default=F('category__commission')
),
_category_commission=F('_category_commission_percent') * F('_price_rub') / 100,
_over_150k_commission=Case(
When(GreaterThan(F("_price_rub"), 150_000), then=over_150k_commission),
default=0,
output_field=DecimalField()
),
_commission_rub=Case(
When(price_snapshot_id__isnull=False, then=F('price_snapshot__commission_rub')),
default=Max(default_commission, F('_over_150k_commission'), F('_category_commission')),
output_field=DecimalField()
),
)
class PriceSnapshot(models.Model):
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
delivery_price_CN_RU = models.DecimalField('Цена доставки Китай-РФ', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
@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"
SPLIT_WAITING = "split_waiting"
SPLIT_PAID = "split_paid"
CDEK = "cdek"
COMPLETED = "completed"
PDF_AVAILABLE_STATUSES = (RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED)
CHOICES = (
(DRAFT, 'Черновик'),
(NEW, 'Новый заказ'),
(PAYMENT, 'Проверка оплаты'),
(BUYING, 'На закупке'),
(BOUGHT, 'Закуплен'),
(CHINA, 'На складе в Китае'),
(CHINA_RUSSIA, 'Доставка на склад РФ'),
(RUSSIA, 'На складе в РФ'),
(SPLIT_WAITING, 'Сплит: ожидание оплаты 2й части'),
(SPLIT_PAID, 'Сплит: полностью оплачено'),
(CDEK, 'Доставляется СДЭК'),
(COMPLETED, 'Завершен'),
)
# Delivery
class DeliveryType:
PICKUP = "pickup"
CDEK = "cdek"
CDEK_COURIER = "cdek_courier"
CHOICES = (
(PICKUP, 'Самовывоз из шоурума'),
(CDEK, 'Пункт выдачи заказов CDEK'),
(CDEK_COURIER, 'Курьерская доставка CDEK'),
)
created_at = models.DateTimeField(auto_now_add=True)
status_updated_at = models.DateTimeField('Дата обновления статуса заказа', auto_now_add=True)
id = models.CharField(primary_key=True, max_length=settings.CHECKLIST_ID_LENGTH,
default=generate_checklist_id, editable=False)
status = models.CharField('Статус заказа', max_length=15, choices=Status.CHOICES, default=Status.NEW)
# 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)
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)
# curencycurency2
price_yuan = models.DecimalField('Цена в юанях', max_digits=10, decimal_places=2, default=0)
# TODO: replace real_price by parser
real_price = models.DecimalField('Реальная цена', max_digits=10, decimal_places=2, null=True, blank=True)
# promo
promocode = models.ForeignKey('Promocode', verbose_name='Промокод', on_delete=models.PROTECT, null=True, blank=True)
gift = models.ForeignKey('Gift', verbose_name='Подарок', on_delete=models.SET_NULL, 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)
is_split_payment = models.BooleanField('Оплата частями', default=False)
# paymenttype
payment_method = models.ForeignKey('PaymentMethod', verbose_name='Метод оплаты',
null=True, blank=True,
on_delete=models.SET_NULL)
images = models.ManyToManyField('Image', verbose_name='Изображения', related_name='+', blank=True)
payment_proof = models.ImageField('Подтверждение оплаты', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC],
null=True, blank=True) # paymentprovement
split_payment_proof = models.ImageField('Подтверждение оплаты сплита',
upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC],
null=True, blank=True)
split_accepted = models.BooleanField('Сплит принят', default=False)
receipt = models.ImageField('Фото чека', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC], null=True, blank=True) # checkphoto
delivery = models.CharField('Тип доставки', max_length=15, choices=DeliveryType.CHOICES, null=True, blank=True)
# trackid
poizon_tracking = models.CharField('Трек-номер Poizon', max_length=100, null=True, blank=True)
cdek_tracking = models.CharField('Трек-номер СДЭК', max_length=100, null=True, blank=True)
cdek_barcode_pdf = models.FileField('Штрих-код СДЭК в PDF', upload_to='docs', null=True, blank=True)
price_snapshot = models.ForeignKey('PriceSnapshot', verbose_name='Сохраненные цены',
related_name='checklist',
on_delete=models.SET_NULL, null=True, blank=True)
objects = ChecklistQuerySet.as_manager()
class Meta:
verbose_name = 'Заказ'
verbose_name_plural = 'Заказы'
@property
def buy_time_remaining(self) -> Optional[timedelta]:
if self.status != Checklist.Status.NEW:
return None
time_to_buy = GlobalSettings.load().time_to_buy
diff = max(timedelta(), timezone.now() - self.status_updated_at)
result = max(timedelta(), time_to_buy - diff)
return result
@property
def price_rub(self) -> int:
# Prefer annotated field for calculation
if hasattr(self, '_price_rub'):
return self._price_rub
# Get saved prices
if self.price_snapshot_id:
yuan_rate = self.price_snapshot.yuan_rate
else:
yuan_rate = GlobalSettings.load().get_yuan_rate()
return math.ceil(yuan_rate * self.price_yuan)
@property
def full_price(self) -> int:
price = self.price_rub
free_delivery = False
no_comission = False
if self.promocode_id:
# We assume that working promocode was bound correctly through serializer
# and intentionally don't check if promocode is active here.
# It's also good for archive orders.
promocode = self.promocode
price -= min(self.price_rub, promocode.discount)
free_delivery = promocode.free_delivery
no_comission = promocode.no_comission
if not free_delivery:
price += self.delivery_price_CN + self.delivery_price_CN_RU
if not no_comission:
price += self.commission_rub
return max(0, math.ceil(price))
@property
def yuan_rate(self) -> Decimal:
# Get saved value if exists
if self.price_snapshot_id:
return self.price_snapshot.yuan_rate
else:
return GlobalSettings.load().get_yuan_rate()
@property
def delivery_price_CN(self) -> Decimal:
# Get saved value if exists
if self.price_snapshot_id:
return self.price_snapshot.delivery_price_CN
else:
return GlobalSettings.load().delivery_price_CN
@property
def delivery_price_CN_RU(self) -> Decimal:
# Get saved value if exists
if self.price_snapshot_id:
return self.price_snapshot.delivery_price_CN_RU
else:
return getattr(self.category, 'delivery_price', Decimal(0))
@property
def commission_rub(self) -> Decimal:
# Prefer annotated field
if hasattr(self, '_commission_rub'):
return self._commission_rub
# Prefer saved value
if self.price_snapshot_id:
return self.price_snapshot.commission_rub
# Default commission
commission = GlobalSettings.load().commission_rub
if self.price_rub > 150_000:
commission = max(commission, self.price_rub * Decimal(settings.COMMISSION_OVER_150K))
if self.category_id:
# Add commission of bottom-most category
category_commission = getattr(self.category, 'commission_price', 0)
commission = max(commission, category_commission * self.price_rub / 100)
return commission
@property
def preview_image(self):
# Prefer annotated field
if hasattr(self, '_images'):
# Get first preview image from all images
return next(filter(lambda x: x.type == Image.PREVIEW, self._images), None)
return self.images.filter(type=Image.PREVIEW).first()
@property
def preview_image_url(self):
return getattr(self.preview_image, 'image', None)
@property
def main_images(self) -> list:
# Prefer prefetched field
if hasattr(self, '_images'):
return [img for img in self._images if img.type == Image.DEFAULT]
return self.images.filter(type=Image.DEFAULT)
def __str__(self):
return f'{self.id}'
def generate_preview(self, source_img: Image = None):
source_img = source_img or next(iter(self.main_images), None)
if not source_img:
return
title_lines = [v for v in (self.brand, self.model) if v is not None]
# Render preview image
preview = create_preview(source_img.image.path,
size=self.size,
price_rub=self.full_price,
title_lines=title_lines)
# Prepare bytes
image_io = BytesIO()
preview.save(image_io, format='JPEG')
# Create Image model and save it
image_obj = Image(type=Image.PREVIEW)
image_obj.image.save(name=f'{self.id}_preview.jpg',
content=ContentFile(image_io.getvalue()),
save=True)
self.images.add(image_obj)
def save_prices(self):
# Temporarily remove snapshot from object
self.price_snapshot = None
snapshot, _ = PriceSnapshot.objects.get_or_create(
checklist__id=self.id,
defaults={
'yuan_rate': self.yuan_rate,
'delivery_price_CN': self.delivery_price_CN,
'delivery_price_CN_RU': self.delivery_price_CN_RU,
'commission_rub': self.commission_rub,
}
)
# Restore snapshot
self.price_snapshot = snapshot
def save(self, *args, **kwargs):
if self.id:
old_obj = Checklist.objects.filter(id=self.id).first()
# If status was updated, update status_updated_at field
if old_obj and self.status != old_obj.status:
self.status_updated_at = timezone.now()
# Invalidate old CDEK barcode PDF
if not self.cdek_barcode_pdf or self.cdek_tracking != old_obj.cdek_tracking:
self.cdek_barcode_pdf.delete(save=False)
self.cdek_barcode_pdf = None
# Try to get CDEK barcode PDF
if not self.cdek_barcode_pdf and self.cdek_tracking and self.status in Checklist.Status.PDF_AVAILABLE_STATUSES:
from store.views import CDEKAPI
pdf_file = CDEKAPI.client.get_barcode_file(self.cdek_tracking)
if pdf_file:
self.cdek_barcode_pdf.save(f'{self.id}_barcode.pdf', pdf_file, save=False)
# Create preview image
if not self.preview_image:
self.generate_preview()
# Update available gifts count
old_gift = getattr(old_obj, 'gift', None)
if self.gift != old_gift:
# Decrement new gift
if self.gift:
self.gift.available_count = max(0, self.gift.available_count - 1)
self.gift.save()
# Increment new gift
if old_gift:
old_gift.available_count = max(0, old_gift.available_count + 1)
old_gift.save()
# Save price details to snapshot
if self.price_snapshot_id:
# Status updated from other statuses back to DRAFT
if self.status == Checklist.Status.DRAFT:
self.price_snapshot.delete()
self.price_snapshot = None
elif self.status != Checklist.Status.DRAFT:
self.save_prices()
super().save(*args, **kwargs)