Files
octopus/myproject/products/views/product_views.py
Andrey Smakotin 7132d2c910 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>
2025-11-15 15:30:23 +03:00

335 lines
14 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.
"""
CRUD представления для товаров (Product).
"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.db.models import Q
from itertools import chain
from ..models import Product, ProductCategory, ProductTag, ProductKit
from ..forms import ProductForm
from .utils import handle_photos
from ..models import ProductPhoto
class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
model = Product
template_name = 'products/product_list.html'
context_object_name = 'products'
permission_required = 'products.view_product'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
# Добавляем prefetch_related для оптимизации запросов к категориям
queryset = queryset.prefetch_related('categories', 'photos', 'tags')
# Улучшенный поиск по нескольким полям
search_query = self.request.GET.get('search')
if search_query:
# Ищем по названию, артикулу, описанию, категориям и ключевым словам
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query) |
Q(search_keywords__icontains=search_query)
).distinct()
# Фильтр по категории
category_id = self.request.GET.get('category')
if category_id:
queryset = queryset.filter(categories__id=category_id)
# Фильтр по статусу
status_filter = self.request.GET.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter)
# Фильтр по тегам
tags = self.request.GET.getlist('tags')
if tags:
queryset = queryset.filter(tags__id__in=tags).distinct()
return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
'tags': ProductTag.objects.filter(is_active=True),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),
'status': self.request.GET.get('status', ''),
'tags': self.request.GET.getlist('tags'),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_product'):
action_buttons.append({
'url': reverse_lazy('products:product-create'),
'text': 'Создать товар',
'class': 'btn-primary',
'icon': 'plus-circle'
})
if self.request.user.has_perm('products.add_productkit'):
action_buttons.append({
'url': reverse_lazy('products:productkit-create'),
'text': 'Создать комплект',
'class': 'btn-outline-primary',
'icon': 'box-seam'
})
action_buttons.append({
'url': reverse_lazy('products:productkit-list'),
'text': 'К списку комплектов',
'class': 'btn-outline-secondary',
'icon': 'list'
})
context['action_buttons'] = action_buttons
return context
class ProductCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
model = Product
form_class = ProductForm
template_name = 'products/product_form.html'
permission_required = 'products.add_product'
def get_success_url(self):
return reverse_lazy('products:product-list')
def form_valid(self, form):
from django.db import IntegrityError
try:
response = super().form_valid(form)
# Handle photo uploads
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
if photo_errors:
for error in photo_errors:
# Если это предупреждение о лимите фото - warning, иначе - error
if 'Загружено' in error and 'обработано только' in error:
messages.warning(self.request, error)
else:
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
return response
except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints
error_msg = str(e).lower()
if 'slug' in error_msg or 'duplicate key' in error_msg:
messages.error(
self.request,
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.'
)
# Перенаправляем обратно на форму с сохранённой информацией
return self.form_invalid(form)
class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
model = Product
template_name = 'products/product_detail.html'
context_object_name = 'product'
permission_required = 'products.view_product'
def get_queryset(self):
# Prefetch photos to avoid N+1 queries
return super().get_queryset().prefetch_related('photos')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии товара в контекст
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
return context
class ProductUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
model = Product
form_class = ProductForm
template_name = 'products/product_form.html'
permission_required = 'products.change_product'
def get_success_url(self):
return reverse_lazy('products:product-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии товара в контекст
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
return context
def form_valid(self, form):
from django.db import IntegrityError
try:
response = super().form_valid(form)
# Handle photo uploads
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
if photo_errors:
for error in photo_errors:
# Если это предупреждение о лимите фото - warning, иначе - error
if 'Загружено' in error and 'обработано только' in error:
messages.warning(self.request, error)
else:
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
return response
except IntegrityError as e:
# Обработка ошибки дублирования slug'а или других unique constraints
error_msg = str(e).lower()
if 'slug' in error_msg or 'duplicate key' in error_msg:
messages.error(
self.request,
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.'
)
# Перенаправляем обратно на форму с сохранённой информацией
return self.form_invalid(form)
class ProductDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
model = Product
template_name = 'products/product_confirm_delete.html'
context_object_name = 'product'
permission_required = 'products.delete_product'
def get_success_url(self):
messages.success(self.request, f'Товар "{self.object.name}" успешно удален!')
return reverse_lazy('products:product-list')
class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
"""
Объединенное представление для товаров и комплектов.
Показывает оба типа продуктов в одном списке.
"""
template_name = 'products/all_products_list.html'
context_object_name = 'items'
permission_required = 'products.view_product'
paginate_by = 20
def get_queryset(self):
# Получаем товары и комплекты (только постоянные комплекты)
products = Product.objects.prefetch_related('categories', 'photos', 'tags')
kits = ProductKit.objects.filter(is_temporary=False).prefetch_related('categories', 'photos')
# Применяем фильтры
search_query = self.request.GET.get('search')
category_id = self.request.GET.get('category')
status_filter = self.request.GET.get('status')
# Фильтрация по поиску
if search_query:
products = products.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query) |
Q(search_keywords__icontains=search_query)
).distinct()
kits = kits.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query)
).distinct()
# Фильтрация по категории
if category_id:
products = products.filter(categories__id=category_id)
kits = kits.filter(categories__id=category_id)
# Фильтрация по статусу
if status_filter:
products = products.filter(status=status_filter)
kits = kits.filter(status=status_filter)
# Добавляем type для различения в шаблоне
products_list = list(products.order_by('-created_at'))
for p in products_list:
p.item_type = 'product'
kits_list = list(kits.order_by('-created_at'))
for k in kits_list:
k.item_type = 'kit'
# Объединяем и сортируем по дате создания
combined = sorted(
chain(products_list, kits_list),
key=lambda x: x.created_at,
reverse=True
)
return combined
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
'tags': ProductTag.objects.all(),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),
'status': self.request.GET.get('status', ''),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_product'):
action_buttons.append({
'url': reverse_lazy('products:product-create'),
'text': 'Создать товар',
'class': 'btn-primary',
'icon': 'plus-circle'
})
if self.request.user.has_perm('products.add_productkit'):
action_buttons.append({
'url': reverse_lazy('products:productkit-create'),
'text': 'Создать комплект',
'class': 'btn-outline-primary',
'icon': 'box-seam'
})
context['action_buttons'] = action_buttons
return context