Files
octopus/myproject/products/models/base.py
Andrey Smakotin 5ba38f39f5 Упрощение base.py: удаление неиспользуемого кода
- Удалён импорт неиспользуемых менеджеров (ActiveManager, SoftDeleteManager, SoftDeleteQuerySet)
- Удалён неиспользуемый active_objects manager
- Заменены хаки __import__ на нормальные импорты (slugify, unidecode)
- Перенесён IntegrityError в импорты модуля
- Добавлен TODO для унификации системы soft delete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 23:18:44 +03:00

257 lines
9.5 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.
"""
Базовые модели для 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.contrib.auth import get_user_model
from django.utils.text import slugify
from unidecode import unidecode
# Получаем 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="Архивировано пользователем"
)
# 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 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