Files

367 lines
14 KiB
Python
Raw Permalink 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.
"""
Базовые модели для products приложения.
Содержит SKUCounter и BaseProductEntity (абстрактный базовый класс).
TODO: Унификация системы soft delete
- Перевести ProductCategory с is_deleted на status (как в BaseProductEntity)
- Упростить managers.py, убрав поддержку старой системы is_deleted
- Унифицировать ProductTag (добавить status или оставить is_active)
"""
from django.db import models, transaction, IntegrityError
from django.db.models import Q
from django.utils import timezone
from django.utils.text import slugify
from unidecode import unidecode
class SKUCounter(models.Model):
"""
Глобальные счетчики для генерации уникальных номеров артикулов.
Используется для товаров (product), комплектов (kit) и категорий (category).
"""
COUNTER_TYPE_CHOICES = [
('product', 'Product Counter'),
('kit', 'Kit Counter'),
('category', 'Category Counter'),
('configurable', 'Configurable Product Counter'),
]
counter_type = models.CharField(
max_length=20,
unique=True,
choices=COUNTER_TYPE_CHOICES,
verbose_name="Тип счетчика"
)
current_value = models.IntegerField(
default=0,
verbose_name="Текущее значение"
)
class Meta:
verbose_name = "Счетчик артикулов"
verbose_name_plural = "Счетчики артикулов"
def __str__(self):
return f"{self.get_counter_type_display()}: {self.current_value}"
@classmethod
def get_next_value(cls, counter_type):
"""
Получить следующее значение счетчика (thread-safe).
Использует select_for_update для предотвращения race conditions.
DEPRECATED: Используйте peek_next_value() + increment_counter() вместо этого метода.
Этот метод инкрементирует счётчик немедленно, что может привести к пропускам номеров
при ошибках сохранения объекта.
"""
with transaction.atomic():
counter, created = cls.objects.select_for_update().get_or_create(
counter_type=counter_type,
defaults={'current_value': 0}
)
counter.current_value += 1
counter.save()
return counter.current_value
@classmethod
def peek_next_value(cls, counter_type):
"""
FIX: SKU counter bug - increment only after successful save
Получить следующее значение счетчика БЕЗ инкремента (thread-safe).
Используется для генерации SKU перед сохранением объекта.
Фактический инкремент выполняется в post_save сигнале после успешного создания.
Args:
counter_type: Тип счётчика ('product', 'kit', 'category', 'configurable')
Returns:
int: Следующее значение счётчика (current_value + 1)
"""
with transaction.atomic():
counter, created = cls.objects.select_for_update().get_or_create(
counter_type=counter_type,
defaults={'current_value': 0}
)
return counter.current_value + 1
@classmethod
def increment_counter(cls, counter_type):
"""
FIX: SKU counter bug - increment only after successful save
Инкрементировать счётчик (thread-safe).
Вызывается в post_save сигнале после успешного создания объекта с автогенерированным SKU.
Args:
counter_type: Тип счётчика ('product', 'kit', 'category', 'configurable')
Returns:
int: Новое значение счётчика после инкремента
"""
with transaction.atomic():
counter, created = cls.objects.select_for_update().get_or_create(
counter_type=counter_type,
defaults={'current_value': 0}
)
counter.current_value += 1
counter.save()
return counter.current_value
class BaseProductEntity(models.Model):
"""
Абстрактный базовый класс для Product и ProductKit.
Объединяет общие поля идентификации, описания, статуса и soft delete.
Используется как основа для:
- Product (простой товар)
- ProductKit (комплект товаров)
"""
# Идентификация
name = models.CharField(
max_length=200,
verbose_name="Название"
)
sku = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name="Артикул",
db_index=True
)
slug = models.SlugField(
max_length=200,
unique=True,
blank=True,
verbose_name="URL-идентификатор"
)
# Описания
description = models.TextField(
blank=True,
null=True,
verbose_name="Описание"
)
short_description = models.TextField(
blank=True,
null=True,
verbose_name="Краткое описание",
help_text="Используется для карточек товаров, превью и площадок"
)
# Статусы товаров
STATUS_CHOICES = [
('active', 'Активный'), # На продажу
('archived', 'Архивный'), # Скрыт (можно вернуть в сезон)
('discontinued', 'Снят'), # Морально устарел, на удаление
]
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='active',
db_index=True,
verbose_name="Статус"
)
# Маркетинговые флаги для внешних площадок (Recommerce и др.)
is_new = models.BooleanField(
default=False,
verbose_name="Новинка",
help_text="Отображать как новый товар"
)
is_popular = models.BooleanField(
default=False,
verbose_name="Популярный",
help_text="Отображать как популярный товар"
)
is_special = models.BooleanField(
default=False,
verbose_name="Спецпредложение",
help_text="Отображать как спецпредложение (акция)"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления"
)
# История архивирования
archived_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Время архивирования"
)
archived_by = models.ForeignKey(
'accounts.CustomUser',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='archived_%(class)s_set',
verbose_name="Архивировано пользователем"
)
# Внешняя категория для интеграций
external_category = models.ForeignKey(
'ProductCategory',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='external_%(class)ss',
verbose_name="Внешняя категория",
help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)"
)
# Manager
objects = models.Manager()
class Meta:
abstract = True
indexes = [
models.Index(fields=['status']),
models.Index(fields=['status', 'created_at']),
models.Index(fields=['created_at']),
]
constraints = [
models.UniqueConstraint(
fields=['name'],
condition=Q(status='active'),
name='unique_active_%(class)s_name'
),
]
def __str__(self):
return self.name
def archive(self, user=None):
"""Архивирование товара (скрыть, но можно восстановить)"""
self.status = 'archived'
self.archived_at = timezone.now()
if user:
self.archived_by = user
self.save(update_fields=['status', 'archived_at', 'archived_by'])
def restore(self):
"""Восстановление архивированного товара"""
self.status = 'active'
self.archived_at = None
self.archived_by = None
self.save(update_fields=['status', 'archived_at', 'archived_by'])
def discontinue(self, user=None):
"""Пометить товар как снятый (устарел, готов к удалению)"""
self.status = 'discontinued'
if user:
self.archived_by = user
self.save(update_fields=['status', 'archived_by'])
def delete(self, *args, **kwargs):
"""Для совместимости: вызывает discontinue() - статус СНЯТ"""
user = kwargs.pop('user', None)
self.discontinue(user=user)
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Физическое удаление из БД (необратимо! только для старых товаров)"""
super().delete()
@property
def is_active(self):
"""Возвращает True если товар активен"""
return self.status == 'active'
def get_category_for_integration(self, integration_type: str = 'recommerce'):
"""
Получить категорию товара для конкретной интеграции.
Приоритет:
1. external_category (если указана и есть маппинг)
2. Первая категория из categories с существующим маппингом (если есть M2M)
Args:
integration_type: Тип интеграции ('recommerce', 'woocommerce')
Returns:
tuple: (ProductCategory, external_sku) или (None, None)
"""
from integrations.models import IntegrationCategoryMapping
# Приоритет 1: external_category
if self.external_category:
mapping = IntegrationCategoryMapping.objects.filter(
category=self.external_category,
integration_type=integration_type
).first()
if mapping:
return self.external_category, mapping.external_category_sku
# Приоритет 2: первая категория из M2M (если есть)
if hasattr(self, 'categories'):
for category in self.categories.all():
mapping = IntegrationCategoryMapping.objects.filter(
category=category,
integration_type=integration_type
).first()
if mapping:
return category, mapping.external_category_sku
return None, None
def save(self, *args, **kwargs):
"""
Автогенерация slug из name если не задан.
Использует transaction.atomic() и retry логику для обработки race condition.
"""
from ..services.slug_service import SlugService
# Генерируем базовый slug
if not self.slug or self.slug.strip() == '':
base_slug = slugify(unidecode(self.name))
else:
base_slug = self.slug
# Пытаемся сохранить с retry при IntegrityError
max_retries = 5
for attempt in range(max_retries):
try:
with transaction.atomic():
# Попытка 1: используем обычный способ генерации
if not self.slug or self.slug.strip() == '':
if attempt == 0:
# Первая попытка - используем обычный generate_unique_slug
self.slug = SlugService.generate_unique_slug(
self.name,
self.__class__,
self.pk
)
else:
# Retry попытки - используем get_next_available_slug с суффиксом
try:
self.slug = SlugService.get_next_available_slug(
base_slug,
self.__class__,
self.pk,
start_counter=attempt
)
except ValueError:
# Если все попытки с суффиксом исчерпаны, добавляем timestamp
import time
self.slug = f"{base_slug}-{int(time.time() % 10000)}"
# Основное сохранение
super().save(*args, **kwargs)
return # Успешно сохранили, выходим
except IntegrityError as e:
# Если это последняя попытка, выбрасываем исключение
if attempt == max_retries - 1:
raise
# Иначе пытаемся снова со следующим slug'ом
continue