import math import posixpath import random import string from datetime import timedelta from decimal import Decimal from io import BytesIO from typing import Optional from django.conf import settings 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 from django.db.models.functions import Ceil from django.db.models.lookups import GreaterThan from django.utils import timezone from django_cleanup import cleanup from mptt.fields import TreeForeignKey from mptt.models import MPTTModel from store.utils import create_preview 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) def __str__(self) -> str: return f'GlobalSettings <{self.id}>' @classmethod def load(cls) -> 'GlobalSettings': obj, _ = cls.objects.get_or_create(id=1) return obj @property def full_yuan_rate(self): 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 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', 'customer') \ .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().full_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) CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK) CHOICES = ( (DRAFT, 'Черновик'), (NEW, 'Новый заказ'), (PAYMENT, 'Проверка оплаты'), (BUYING, 'На закупке'), (BOUGHT, 'Закуплен'), (CHINA, 'На складе в Китае'), (CHINA_RUSSIA, 'Доставка на склад РФ'), (RUSSIA, 'На складе в РФ'), (SPLIT_WAITING, 'Сплит: ожидание оплаты 2й части'), (SPLIT_PAID, 'Сплит: полностью оплачено'), (CDEK, 'Доставляется СДЭК'), (COMPLETED, 'Завершен'), ) def get_tg_notification(self): from tg_bot.messages import TGOrderStatusMessage as msg match self.status: case Checklist.Status.NEW: return msg.NEW.format(order_id=self.id, order_link=self.order_link) case Checklist.Status.BUYING: if not self.is_split_payment: return msg.BUYING_NON_SPLIT.format(order_id=self.id) else: return msg.BUYING_SPLIT.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay()) case Checklist.Status.BOUGHT: return msg.BOUGHT.format(order_id=self.id) case Checklist.Status.CHINA: return msg.CHINA.format(order_id=self.id) case Checklist.Status.CHINA_RUSSIA: return msg.CHINA_RUSSIA.format(order_id=self.id) case Checklist.Status.RUSSIA: if self.delivery == Checklist.DeliveryType.PICKUP: return msg.RUSSIA_PICKUP.format(order_id=self.id) elif self.delivery in Checklist.DeliveryType.CDEK_TYPES: return msg.RUSSIA_CDEK.format(order_id=self.id) case Checklist.Status.SPLIT_WAITING: if self.delivery == Checklist.DeliveryType.PICKUP: return msg.SPLIT_WAITING_PICKUP.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay) elif self.delivery in Checklist.DeliveryType.CDEK_TYPES: return msg.SPLIT_WAITING_CDEK.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay) # FIXME: split_accepted ? case Checklist.Status.SPLIT_PAID: return msg.SPLIT_PAID.format(order_id=self.id) case Checklist.Status.CDEK: return msg.CDEK.format(order_id=self.id) case Checklist.Status.COMPLETED: return msg.COMPLETED.format(order_id=self.id) case _: return None @property def order_link(self): return f"https://poizonstore.com/orderpageinprogress/{self.id}" def split_amount_to_pay(self): # FIXME: it's stupid, create PaymentInfo model or something return self.full_price // 2 # Delivery class DeliveryType: PICKUP = "pickup" CDEK = "cdek" CDEK_COURIER = "cdek_courier" CHOICES = ( (PICKUP, 'Самовывоз из шоурума'), (CDEK, 'Пункт выдачи заказов CDEK'), (CDEK_COURIER, 'Курьерская доставка CDEK'), ) CDEK_TYPES = (CDEK, CDEK_COURIER) created_at = models.DateTimeField(auto_now_add=True) status_updated_at = models.DateTimeField('Дата обновления статуса заказа', auto_now_add=True, editable=False) 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('account.User', verbose_name='Менеджер', related_name='manager_orders', 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) customer = models.ForeignKey('account.User', on_delete=models.CASCADE, related_name='customer_orders', blank=True, null=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().full_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().full_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 _notify_about_status_change(self): if self.customer_id is None: return tg_message = self.get_tg_notification() if tg_message: self.customer.notify_tg_bot(tg_message) def _check_eligible_for_order_bonus(self): if self.customer_id is None: return if self.status != Checklist.Status.CHINA_RUSSIA: return # Check if any BonusProgramTransaction bound to current order exists from account.models import BonusProgramTransaction if BonusProgramTransaction.objects.filter(order_id=self.id).exists(): return # Apply either referral bonus or order bonus, not both if self.customer.inviter is not None and self.customer.customer_orders.count() == 1: self.customer.add_referral_bonus(self, for_inviter=False) self.customer.inviter.add_referral_bonus(self, for_inviter=True) else: self.customer.add_order_bonus(self) # TODO: split into sub-functions def save(self, *args, **kwargs): if self.id: old_obj = Checklist.objects.filter(id=self.id).first() self._check_eligible_for_order_bonus() # If status was updated, update status_updated_at field if old_obj is not None and self.status != old_obj.status: self.status_updated_at = timezone.now() self._notify_about_status_change() # TODO: remove bonuses if order is canceled? # 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) # Invalidate old preview_image if full_price changed price_changed = old_obj is not None and self.full_price != old_obj.full_price if price_changed: self.preview_image.delete(save=False) # Create preview image if self.preview_image is None: 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)