feat: Замена is_active на status для архивирования товаров

Реализована трёхуровневая система статусов товаров и комплектов:
- 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>
This commit is contained in:
2025-11-15 15:30:23 +03:00
parent 079bd23829
commit 7132d2c910
26 changed files with 529 additions and 354 deletions

View File

@@ -3,6 +3,7 @@
Содержит 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
@@ -98,10 +99,19 @@ class BaseProductEntity(models.Model):
help_text="Используется для карточек товаров, превью и площадок"
)
# Статус
is_active = models.BooleanField(
default=True,
verbose_name="Активен"
# Статусы товаров
STATUS_CHOICES = [
('active', 'Активный'), # На продажу
('archived', 'Архивный'), # Скрыт (можно вернуть в сезон)
('discontinued', 'Снят'), # Морально устарел, на удаление
]
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='active',
db_index=True,
verbose_name="Статус"
)
# Временные метки
@@ -114,54 +124,73 @@ class BaseProductEntity(models.Model):
verbose_name="Дата обновления"
)
# Soft delete
is_deleted = models.BooleanField(
default=False,
verbose_name="Удален",
db_index=True
)
deleted_at = models.DateTimeField(
# История архивирования
archived_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Время удаления"
verbose_name="Время архивирования"
)
deleted_by = models.ForeignKey(
archived_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_%(class)s_set',
verbose_name="Удален пользователем"
related_name='archived_%(class)s_set',
verbose_name="Архивировано пользователем"
)
# Managers
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
all_objects = models.Manager()
active = ActiveManager()
objects = models.Manager() # Все товары
active_objects = models.Manager() # Будет переопределен ниже
class Meta:
abstract = True
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
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 delete(self, *args, **kwargs):
"""Мягкое удаление (soft delete)"""
user = kwargs.pop('user', None)
self.is_deleted = True
self.deleted_at = timezone.now()
def archive(self, user=None):
"""Архивирование товара (скрыть, но можно восстановить)"""
self.status = 'archived'
self.archived_at = timezone.now()
if user:
self.deleted_by = user
self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by'])
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):