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

@@ -9,58 +9,141 @@ from django.utils import timezone
class ActiveManager(models.Manager):
"""Менеджер для фильтрации только активных записей"""
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
# Работает с обоими полями: status (Product/ProductKit) и is_active (Category/Tag)
qs = super().get_queryset()
if hasattr(self.model, 'status'):
return qs.filter(status='active')
elif hasattr(self.model, 'is_active'):
return qs.filter(is_active=True)
return qs
class SoftDeleteQuerySet(models.QuerySet):
"""
QuerySet для мягкого удаления (soft delete).
Позволяет фильтровать удаленные элементы и восстанавливать их.
QuerySet для архивирования товаров.
Позволяет фильтровать архивированные/снятые элементы и восстанавливать их.
Поддерживает обе системы: status (новая) и is_deleted/is_active (старая).
"""
def _has_status_field(self):
"""Проверяет, использует ли модель поле status"""
return hasattr(self.model, 'status')
def _has_is_deleted_field(self):
"""Проверяет, использует ли модель поле is_deleted"""
return hasattr(self.model, 'is_deleted')
def delete(self):
"""Soft delete вместо hard delete"""
return self.update(
is_deleted=True,
deleted_at=timezone.now()
)
"""Архивирование вместо hard delete"""
if self._has_status_field():
return self.update(
status='archived',
archived_at=timezone.now()
)
elif self._has_is_deleted_field():
return self.update(
is_deleted=True,
deleted_at=timezone.now()
)
else:
# Fallback для моделей без мягкого удаления
return super().delete()
def hard_delete(self):
"""Явный hard delete - удаляет из БД окончательно"""
return super().delete()
def archive(self):
"""Архивирование товаров"""
if self._has_status_field():
return self.update(status='archived', archived_at=timezone.now())
elif self._has_is_deleted_field():
return self.update(is_deleted=True, deleted_at=timezone.now())
else:
return self.delete()
def restore(self):
"""Восстановление из удаленного состояния"""
return self.update(
is_deleted=False,
deleted_at=None,
deleted_by=None
)
"""Восстановление архивированных товаров"""
if self._has_status_field():
return self.update(
status='active',
archived_at=None,
archived_by=None
)
elif self._has_is_deleted_field():
return self.update(
is_deleted=False,
deleted_at=None,
deleted_by=None
)
def deleted_only(self):
"""Получить только удаленные элементы"""
return self.filter(is_deleted=True)
def discontinue(self):
"""Пометить как снятые (устарели)"""
if self._has_status_field():
return self.update(status='discontinued')
else:
# Для моделей без status просто архивируем
return self.archive()
def not_deleted(self):
"""Получить только не удаленные элементы"""
return self.filter(is_deleted=False)
def archived_only(self):
"""Получить только архивированные товары"""
if self._has_status_field():
return self.filter(status='archived')
elif self._has_is_deleted_field():
return self.filter(is_deleted=True)
return self.none()
def with_deleted(self):
"""Получить все элементы включая удаленные"""
def discontinued_only(self):
"""Получить только снятые товары"""
if self._has_status_field():
return self.filter(status='discontinued')
return self.none()
def active_only(self):
"""Получить только активные товары"""
if self._has_status_field():
return self.filter(status='active')
elif self._has_is_deleted_field():
return self.filter(is_deleted=False)
return self.all()
def with_archived(self):
"""Получить все элементы включая архивированные"""
return self.all()
class SoftDeleteManager(models.Manager):
"""
Manager для работы с мягким удалением.
По умолчанию исключает удаленные элементы из запросов.
Manager для работы с архивированием товаров.
По умолчанию показывает только активные товары/элементы.
Поддерживает обе системы: status (новая) и is_deleted/is_active (старая).
"""
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
qs = SoftDeleteQuerySet(self.model, using=self._db)
# Автоматически фильтруем активные записи в зависимости от полей
if hasattr(self.model, 'status'):
return qs.filter(status='active')
elif hasattr(self.model, 'is_deleted'):
return qs.filter(is_deleted=False)
elif hasattr(self.model, 'is_active'):
return qs.filter(is_active=True)
return qs
def deleted_only(self):
"""Получить только удаленные элементы"""
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True)
def archived_only(self):
"""Получить только архивированные товары"""
qs = SoftDeleteQuerySet(self.model, using=self._db)
if hasattr(self.model, 'status'):
return qs.filter(status='archived')
elif hasattr(self.model, 'is_deleted'):
return qs.filter(is_deleted=True)
return qs.none()
def all_with_deleted(self):
"""Получить все элементы включая удаленные"""
def discontinued_only(self):
"""Получить только снятые товары"""
qs = SoftDeleteQuerySet(self.model, using=self._db)
if hasattr(self.model, 'status'):
return qs.filter(status='discontinued')
return qs.none()
def all_with_archived(self):
"""Получить все товары включая архивированные"""
return SoftDeleteQuerySet(self.model, using=self._db).all()