Реализована трёхуровневая система статусов товаров и комплектов: - active (Активный) - товар доступен для продажи - archived (Архивный) - скрыт, можно восстановить в следующем сезоне - discontinued (Снят) - морально устарел, готов к удалению Изменения: 1. Модели (BaseProductEntity, Product, ProductKit): - Заменено поле is_deleted (Boolean) на status (CharField) - Добавлены архивные метаданные (archived_at, archived_by) - Обновлены методы: archive(), restore(), discontinue(), delete() - Уникальное ограничение изменено на conditional (status='active') 2. Менеджеры (ActiveManager, SoftDeleteQuerySet): - Полиморфная поддержка обеих систем (status и is_active) - Использует hasattr() для совместимости с наследниками - Методы: archive(), restore(), discontinue(), archived_only(), active_only() 3. Формы (ProductForm, ProductKitForm): - Включены поле status в формы - Валидация уникальности по status='active' - CSS классы для статус-селектора 4. Admin панель: - DeletedFilter переименован в StatusFilter с тремя опциями - get_status_display() с цветным отображением статуса - Actions: restore_items, hard_delete_selected, delete_selected - Readonly поля для архивирования 5. Представления: - ProductListView: фильтр status вместо is_active - CombinedProductListView: поддержка фильтра status для товаров и комплектов - API views обновлены для работы со статусом 6. Шаблоны: - product_form.html: form.status вместо form.is_active - productkit_create.html: form.status вместо form.is_active - productkit_edit.html: form.status вместо form.is_active 7. Миграции: - Удалены все старые миграции (чистый перезапуск по требованию пользователя) - Создана новая миграция 0001_initial с полной структурой status-системы - Удален старый код преобразования is_deleted -> status Проведённые проверки: - Django system check passed ✓ - Полиморфные менеджеры работают с обеими системами - Уникальные ограничения корректно работают с условиями - История заказов сохраняется даже после архивирования товара (django-simple-history) 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
249 lines
9.3 KiB
Python
249 lines
9.3 KiB
Python
"""
|
||
Базовые модели для 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'),
|
||
]
|
||
|
||
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):
|
||
"""Для совместимости: вызывает archive()"""
|
||
user = kwargs.pop('user', None)
|
||
self.archive(user=user)
|
||
return 1, {self.__class__._meta.label: 1}
|
||
|
||
def hard_delete(self):
|
||
"""Физическое удаление из БД (необратимо! только для старых товаров)"""
|
||
super().delete()
|
||
|
||
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
|