""" Базовые модели для products приложения. Содержит SKUCounter и BaseProductEntity (абстрактный базовый класс). """ from django.db import models, transaction from django.db.models import Q from django.utils import timezone from django.contrib.auth import get_user_model from .managers import ActiveManager, SoftDeleteManager, SoftDeleteQuerySet # Получаем User модель один раз для использования в ForeignKey User = get_user_model() 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. """ 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="Статус" ) # Временные метки 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( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='archived_%(class)s_set', verbose_name="Архивировано пользователем" ) # Managers objects = models.Manager() # Все товары active_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 save(self, *args, **kwargs): """ Автогенерация slug из name если не задан. Использует transaction.atomic() и retry логику для обработки race condition. """ from django.db import transaction, IntegrityError from ..services.slug_service import SlugService # Генерируем базовый slug if not self.slug or self.slug.strip() == '': transliterated_name = __import__('unidecode', fromlist=['unidecode']).unidecode(self.name) base_slug = __import__('django.utils.text', fromlist=['slugify']).slugify(transliterated_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