Files
octopus/myproject/products/views.py

1202 lines
47 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.
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, reverse
from django.shortcuts import redirect, get_object_or_404
from django.http import JsonResponse
from django.db import models, transaction
from .models import Product, ProductPhoto, ProductKit, ProductKitPhoto, ProductCategory, ProductCategoryPhoto, ProductTag, ProductVariantGroup
from .forms import ProductForm, ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate, ProductCategoryForm
import os
import json
def validate_photo(photo):
"""
Валидация загружаемого фото.
Возвращает (True, None) если валидно, или (False, error_message) если ошибка.
"""
max_size = 5 * 1024 * 1024 # 5MB
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
if photo.size > max_size:
return False, f'Размер файла {photo.name} превышает 5MB.'
ext = os.path.splitext(photo.name)[1].lower()
if ext not in allowed_extensions:
return False, f'Формат файла {ext} не поддерживается. Разрешены: {", ".join(allowed_extensions)}'
return True, None
def handle_product_photos(request, product):
"""
Обработка загружаемых фото для товара.
Возвращает список сообщений об ошибках (пустой список если все ок).
"""
errors = []
photos = request.FILES.getlist('photos')
if not photos:
return errors
if len(photos) > 10:
errors.append('Можно загрузить не более 10 фотографий.')
return errors
# Получаем максимальный order для этого товара
max_order = ProductPhoto.objects.filter(product=product).aggregate(
models.Max('order')
)['order__max']
# Если фото нет, начинаем с 0, иначе с max_order + 1
next_order = 0 if max_order is None else max_order + 1
# Валидация и сохранение фото
for photo in photos:
is_valid, error_msg = validate_photo(photo)
if not is_valid:
errors.append(error_msg)
else:
ProductPhoto.objects.create(product=product, image=photo, order=next_order)
next_order += 1
return errors
def handle_productkit_photos(request, productkit):
"""
Обработка загружаемых фото для комплекта.
Возвращает список сообщений об ошибках (пустой список если все ок).
"""
errors = []
photos = request.FILES.getlist('photos')
if not photos:
return errors
if len(photos) > 10:
errors.append('Можно загрузить не более 10 фотографий.')
return errors
# Получаем максимальный order для этого комплекта
max_order = ProductKitPhoto.objects.filter(kit=productkit).aggregate(
models.Max('order')
)['order__max']
# Если фото нет, начинаем с 0, иначе с max_order + 1
next_order = 0 if max_order is None else max_order + 1
# Валидация и сохранение фото
for photo in photos:
is_valid, error_msg = validate_photo(photo)
if not is_valid:
errors.append(error_msg)
else:
ProductKitPhoto.objects.create(kit=productkit, image=photo, order=next_order)
next_order += 1
return errors
# CRUD Views for Product
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):
from django.db.models import Q
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)
# Фильтр по статусу
is_active = self.request.GET.get('is_active')
if is_active == '1':
queryset = queryset.filter(is_active=True)
elif is_active == '0':
queryset = queryset.filter(is_active=False)
# Фильтр по тегам
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)
from .models import ProductCategory, ProductTag
# Данные для фильтров
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', ''),
'is_active': self.request.GET.get('is_active', ''),
'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):
response = super().form_valid(form)
# Handle photo uploads
photo_errors = handle_product_photos(self.request, self.object)
if photo_errors:
for error in photo_errors:
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
return response
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):
response = super().form_valid(form)
# Handle photo uploads
photo_errors = handle_product_photos(self.request, self.object)
if photo_errors:
for error in photo_errors:
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
return response
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')
def product_photo_delete(request, pk):
"""
Удаление фотографии товара
"""
photo = get_object_or_404(ProductPhoto, pk=pk)
product_id = photo.product.id
# Проверка прав доступа
if not request.user.has_perm('products.change_product'):
messages.error(request, 'У вас нет прав для удаления фотографий.')
return redirect('products:product-update', pk=product_id)
photo.delete()
messages.success(request, 'Фото успешно удалено!')
return redirect('products:product-update', pk=product_id)
def product_photo_set_main(request, pk):
"""
Установка фото как главного (order = 0)
"""
photo = get_object_or_404(ProductPhoto, pk=pk)
product_id = photo.product.id
# Проверка прав доступа
if not request.user.has_perm('products.change_product'):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect('products:product-update', pk=product_id)
# Получаем все фото этого товара
photos = ProductPhoto.objects.filter(product_id=product_id).order_by('order')
# Если это уже главное фото, ничего не делаем
if photo.order == 0:
messages.info(request, 'Это фото уже установлено как главное.')
return redirect('products:product-update', pk=product_id)
# Меняем порядок: текущее главное фото становится вторым
old_order = photo.order
for p in photos:
if p.pk == photo.pk:
p.order = 0
p.save()
elif p.order == 0:
p.order = old_order
p.save()
messages.success(request, 'Фото установлено как главное!')
return redirect('products:product-update', pk=product_id)
def product_photo_move_up(request, pk):
"""
Переместить фото вверх (уменьшить order)
"""
photo = get_object_or_404(ProductPhoto, pk=pk)
product_id = photo.product.id
# Проверка прав доступа
if not request.user.has_perm('products.change_product'):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect('products:product-update', pk=product_id)
# Если это уже первое фото
if photo.order == 0:
messages.info(request, 'Это фото уже первое в списке.')
return redirect('products:product-update', pk=product_id)
# Находим предыдущее фото
prev_photo = ProductPhoto.objects.filter(
product_id=product_id,
order__lt=photo.order
).order_by('-order').first()
if prev_photo:
# Меняем местами
photo.order, prev_photo.order = prev_photo.order, photo.order
photo.save()
prev_photo.save()
messages.success(request, 'Фото перемещено вверх!')
return redirect('products:product-update', pk=product_id)
def product_photo_move_down(request, pk):
"""
Переместить фото вниз (увеличить order)
"""
photo = get_object_or_404(ProductPhoto, pk=pk)
product_id = photo.product.id
# Проверка прав доступа
if not request.user.has_perm('products.change_product'):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect('products:product-update', pk=product_id)
# Находим следующее фото
next_photo = ProductPhoto.objects.filter(
product_id=product_id,
order__gt=photo.order
).order_by('order').first()
if next_photo:
# Меняем местами
photo.order, next_photo.order = next_photo.order, photo.order
photo.save()
next_photo.save()
messages.success(request, 'Фото перемещено вниз!')
else:
messages.info(request, 'Это фото уже последнее в списке.')
return redirect('products:product-update', pk=product_id)
# CRUD Views for ProductKit
# Временный файл для добавления в views.py
class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
model = ProductKit
template_name = 'products/productkit_list.html'
context_object_name = 'kits'
permission_required = 'products.view_productkit'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.prefetch_related('categories', 'photos', 'kit_items', 'tags')
# Поиск по названию
search_query = self.request.GET.get('search')
if search_query:
queryset = queryset.filter(name__icontains=search_query)
# Фильтр по категории
category_id = self.request.GET.get('category')
if category_id:
queryset = queryset.filter(categories__id=category_id)
# Фильтр по статусу
is_active = self.request.GET.get('is_active')
if is_active == '1':
queryset = queryset.filter(is_active=True)
elif is_active == '0':
queryset = queryset.filter(is_active=False)
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.all(),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),
'is_active': self.request.GET.get('is_active', ''),
'tags': self.request.GET.getlist('tags'),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_productkit'):
action_buttons.append({
'url': reverse_lazy('products:productkit-create'),
'text': 'Создать комплект',
'class': 'btn-primary',
'icon': 'plus-circle'
})
action_buttons.append({
'url': reverse_lazy('products:product-list'),
'text': 'К товарам',
'class': 'btn-outline-primary',
'icon': 'box'
})
context['action_buttons'] = action_buttons
return context
class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
"""
View для создания нового комплекта с компонентами.
"""
model = ProductKit
form_class = ProductKitForm
template_name = 'products/productkit_form.html'
permission_required = 'products.add_productkit'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, instance=self.object)
else:
context['kititem_formset'] = KitItemFormSetCreate(instance=self.object)
return context
def form_valid(self, form):
# Получаем формсет из POST
kititem_formset = KitItemFormSetCreate(self.request.POST, instance=self.object)
# Проверяем валидность формсета
if kititem_formset.is_valid():
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем компоненты
kititem_formset.instance = self.object
kititem_formset.save()
# Обработка фотографий
handle_productkit_photos(self.request, self.object)
messages.success(self.request, f'Комплект "{self.object.name}" успешно создан!')
# Проверяем, какую кнопку нажали
if self.request.POST.get('action') == 'continue':
return redirect('products:productkit-update', pk=self.object.pk)
else:
return redirect('products:productkit-list')
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
else:
# Если формсет невалиден, показываем форму с ошибками
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
return self.form_invalid(form)
def form_invalid(self, form):
# Получаем формсет для отображения ошибок
context = self.get_context_data(form=form)
return self.render_to_response(context)
def get_success_url(self):
return reverse_lazy('products:productkit-list')
class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
"""
View для редактирования существующего комплекта.
"""
model = ProductKit
form_class = ProductKitForm
template_name = 'products/productkit_form.html'
permission_required = 'products.change_productkit'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object)
else:
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object)
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
return context
def form_valid(self, form):
# Получаем формсет из POST
kititem_formset = KitItemFormSetUpdate(self.request.POST, instance=self.object)
# Проверяем валидность формсета
if kititem_formset.is_valid():
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем компоненты
kititem_formset.instance = self.object
kititem_formset.save()
# Обработка фотографий
handle_productkit_photos(self.request, self.object)
messages.success(self.request, f'Комплект "{self.object.name}" успешно обновлен!')
# Проверяем, какую кнопку нажали
if self.request.POST.get('action') == 'continue':
return redirect('products:productkit-update', pk=self.object.pk)
else:
return redirect('products:productkit-list')
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
else:
# Если формсет невалиден, показываем форму с ошибками
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
return self.form_invalid(form)
def form_invalid(self, form):
# Получаем формсет для отображения ошибок
context = self.get_context_data(form=form)
return self.render_to_response(context)
def get_success_url(self):
return reverse_lazy('products:productkit-list')
class ProductKitDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
"""
View для просмотра деталей комплекта.
Показывает все компоненты, цены, фотографии.
"""
model = ProductKit
template_name = 'products/productkit_detail.html'
context_object_name = 'kit'
permission_required = 'products.view_productkit'
def get_queryset(self):
# Prefetch для оптимизации запросов
return super().get_queryset().prefetch_related(
'photos',
'kit_items__product',
'kit_items__variant_group',
'tags'
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии комплекта в контекст
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
# Добавляем компоненты
context['kit_items'] = self.object.kit_items.all().select_related('product', 'variant_group')
return context
class ProductKitDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
"""
View для удаления комплекта.
"""
model = ProductKit
template_name = 'products/productkit_confirm_delete.html'
context_object_name = 'kit'
permission_required = 'products.delete_productkit'
def get_success_url(self):
messages.success(self.request, f'Комплект "{self.object.name}" успешно удален!')
return reverse_lazy('products:productkit-list')
# Функции для управления фотографиями комплектов
def productkit_photo_delete(request, pk):
"""
Удаление фотографии комплекта
"""
photo = get_object_or_404(ProductKitPhoto, pk=pk)
kit_id = photo.kit.id
# Проверка прав доступа
if not request.user.has_perm('products.change_productkit'):
messages.error(request, 'У вас нет прав для удаления фотографий.')
return redirect('products:productkit-update', pk=kit_id)
photo.delete()
messages.success(request, 'Фото успешно удалено!')
return redirect('products:productkit-update', pk=kit_id)
def productkit_photo_set_main(request, pk):
"""
Установка фото как главного (order = 0)
"""
photo = get_object_or_404(ProductKitPhoto, pk=pk)
kit_id = photo.kit.id
# Проверка прав доступа
if not request.user.has_perm('products.change_productkit'):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect('products:productkit-update', pk=kit_id)
# Получаем все фото этого комплекта
photos = ProductKitPhoto.objects.filter(kit_id=kit_id).order_by('order')
# Если это уже главное фото, ничего не делаем
if photo.order == 0:
messages.info(request, 'Это фото уже установлено как главное.')
return redirect('products:productkit-update', pk=kit_id)
# Меняем порядок: текущее главное фото становится вторым
old_order = photo.order
for p in photos:
if p.pk == photo.pk:
p.order = 0
p.save()
elif p.order == 0:
p.order = old_order
p.save()
messages.success(request, 'Фото установлено как главное!')
return redirect('products:productkit-update', pk=kit_id)
def productkit_photo_move_up(request, pk):
"""
Переместить фото вверх (уменьшить order)
"""
photo = get_object_or_404(ProductKitPhoto, pk=pk)
kit_id = photo.kit.id
# Проверка прав доступа
if not request.user.has_perm('products.change_productkit'):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect('products:productkit-update', pk=kit_id)
# Если это уже первое фото
if photo.order == 0:
messages.info(request, 'Это фото уже первое в списке.')
return redirect('products:productkit-update', pk=kit_id)
# Находим предыдущее фото
prev_photo = ProductKitPhoto.objects.filter(
kit_id=kit_id,
order__lt=photo.order
).order_by('-order').first()
if prev_photo:
# Меняем местами
photo.order, prev_photo.order = prev_photo.order, photo.order
photo.save()
prev_photo.save()
messages.success(request, 'Фото перемещено вверх!')
return redirect('products:productkit-update', pk=kit_id)
def productkit_photo_move_down(request, pk):
"""
Переместить фото вниз (увеличить order)
"""
photo = get_object_or_404(ProductKitPhoto, pk=pk)
kit_id = photo.kit.id
# Проверка прав доступа
if not request.user.has_perm('products.change_productkit'):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect('products:productkit-update', pk=kit_id)
# Находим следующее фото
next_photo = ProductKitPhoto.objects.filter(
kit_id=kit_id,
order__gt=photo.order
).order_by('order').first()
if next_photo:
# Меняем местами
photo.order, next_photo.order = next_photo.order, photo.order
photo.save()
next_photo.save()
messages.success(request, 'Фото перемещено вниз!')
else:
messages.info(request, 'Это фото уже последнее в списке.')
return redirect('products:productkit-update', pk=kit_id)
# API view для поиска товаров и групп вариантов
def search_products_and_variants(request):
"""
API endpoint для поиска товаров и групп вариантов.
Используется для автокомплита при добавлении компонентов в комплект.
Параметры GET:
- q: строка поиска
- type: 'product' или 'variant' (опционально, если не указано - поиск по обоим)
Возвращает JSON список:
[
{
"id": 1,
"name": "Роза красная Freedom 50см",
"sku": "PROD-000001",
"type": "product",
"price": "150.00"
},
{
"id": 1,
"name": "Роза красная Freedom",
"type": "variant",
"count": 3
}
]
"""
query = request.GET.get('q', '').strip()
search_type = request.GET.get('type', 'all')
if not query or len(query) < 2:
return JsonResponse({'results': []})
results = []
# Поиск товаров
if search_type in ['all', 'product']:
products = Product.objects.filter(
models.Q(name__icontains=query) |
models.Q(sku__icontains=query) |
models.Q(description__icontains=query),
is_active=True
).values('id', 'name', 'sku', 'sale_price')[:10]
for product in products:
results.append({
'id': product['id'],
'name': f"{product['name']} ({product['sku']})",
'sku': product['sku'],
'type': 'product',
'price': str(product['sale_price'])
})
# Поиск групп вариантов
if search_type in ['all', 'variant']:
variants = ProductVariantGroup.objects.filter(
models.Q(name__icontains=query) |
models.Q(description__icontains=query)
).prefetch_related('products')[:10]
for variant in variants:
count = variant.products.filter(is_active=True).count()
results.append({
'id': variant.id,
'name': f"{variant.name} ({count} вариантов)",
'type': 'variant',
'count': count
})
return JsonResponse({'results': results})
# ========================================
# Category Photo Management Functions
# ========================================
def handle_category_photos(request, category):
"""
Обработка загружаемых фото для категории.
Возвращает список сообщений об ошибках (пустой список если все ок).
"""
errors = []
photos = request.FILES.getlist('photos')
if not photos:
return errors
if len(photos) > 10:
errors.append('Можно загрузить не более 10 фотографий.')
return errors
# Получаем максимальный order для этой категории
max_order = ProductCategoryPhoto.objects.filter(category=category).aggregate(
models.Max('order')
)['order__max']
# Если фото нет, начинаем с 0, иначе с max_order + 1
next_order = 0 if max_order is None else max_order + 1
# Валидация и сохранение фото
for photo in photos:
is_valid, error_msg = validate_photo(photo)
if not is_valid:
errors.append(error_msg)
else:
ProductCategoryPhoto.objects.create(category=category, image=photo, order=next_order)
next_order += 1
return errors
def category_photo_delete(request, pk):
"""Удаление фото категории"""
photo = get_object_or_404(ProductCategoryPhoto, pk=pk)
category = photo.category
photo.delete()
messages.success(request, 'Фото удалено.')
return redirect('products:category-update', pk=category.pk)
def category_photo_set_main(request, pk):
"""Установка фото категории как главного (order=0)"""
photo = get_object_or_404(ProductCategoryPhoto, pk=pk)
category = photo.category
# Находим текущее главное фото (order=0)
current_main = ProductCategoryPhoto.objects.filter(category=category, order=0).first()
if current_main and current_main.pk != photo.pk:
# Меняем местами order
current_main.order = photo.order
photo.order = 0
current_main.save()
photo.save()
messages.success(request, 'Главное фото обновлено.')
else:
messages.info(request, 'Это фото уже является главным.')
return redirect('products:category-update', pk=category.pk)
def category_photo_move_up(request, pk):
"""Переместить фото категории вверх (уменьшить order)"""
photo = get_object_or_404(ProductCategoryPhoto, pk=pk)
category = photo.category
# Находим фото с меньшим order
prev_photo = ProductCategoryPhoto.objects.filter(
category=category,
order__lt=photo.order
).order_by('-order').first()
if prev_photo:
# Меняем местами order
photo.order, prev_photo.order = prev_photo.order, photo.order
photo.save()
prev_photo.save()
messages.success(request, 'Фото перемещено вверх.')
else:
messages.info(request, 'Фото уже первое в списке.')
return redirect('products:category-update', pk=category.pk)
def category_photo_move_down(request, pk):
"""Переместить фото категории вниз (увеличить order)"""
photo = get_object_or_404(ProductCategoryPhoto, pk=pk)
category = photo.category
# Находим фото с большим order
next_photo = ProductCategoryPhoto.objects.filter(
category=category,
order__gt=photo.order
).order_by('order').first()
if next_photo:
# Меняем местами order
photo.order, next_photo.order = next_photo.order, photo.order
photo.save()
next_photo.save()
messages.success(request, 'Фото перемещено вниз.')
else:
messages.info(request, 'Фото уже последнее в списке.')
return redirect('products:category-update', pk=category.pk)
# ========================================
# CRUD Views for ProductCategory
# ========================================
class TreeItem:
"""
Универсальный элемент дерева категорий.
Может представлять категорию, товар или набор.
"""
def __init__(self, obj, item_type, depth, parent_category_id=None):
self.obj = obj
self.item_type = item_type # 'category', 'product', 'kit'
self.depth = depth
self.pk = obj.pk
self.name = obj.name
self.sku = getattr(obj, 'sku', None)
if item_type == 'category':
self.has_children = obj.children.exists()
self.parent_id = obj.parent.pk if obj.parent else None
else:
# Товары и наборы не имеют детей
self.has_children = False
# Виртуальный parent_id = ID категории (для JavaScript)
self.parent_id = parent_category_id
# Получение цены
if item_type == 'product':
self.price = obj.sale_price
elif item_type == 'kit':
self.price = obj.get_sale_price()
else:
self.price = None
class ProductCategoryListView(LoginRequiredMixin, ListView):
model = ProductCategory
template_name = 'products/category_list.html'
context_object_name = 'categories'
paginate_by = None # Отключаем пагинацию для иерархического отображения
def get_queryset(self):
from django.db.models import Q
queryset = super().get_queryset()
# Добавляем prefetch_related для оптимизации запросов к фото
queryset = queryset.prefetch_related('photos', 'children')
# Поиск по названию и артикулу
search_query = self.request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(slug__icontains=search_query)
)
# Фильтр по статусу
is_active = self.request.GET.get('is_active')
if is_active == '1':
queryset = queryset.filter(is_active=True)
elif is_active == '0':
queryset = queryset.filter(is_active=False)
return queryset
def build_category_tree(self, queryset):
"""
Строит иерархическое дерево категорий с товарами и наборами.
Возвращает плоский список TreeItem объектов.
"""
# Получаем все категории из queryset с prefetch для товаров и наборов
all_categories = list(queryset.select_related('parent')
.prefetch_related('photos', 'children',
'products', 'kits'))
# Создаем словарь для быстрого доступа по ID
categories_dict = {cat.pk: cat for cat in all_categories}
# Добавляем служебные поля
for cat in all_categories:
cat.depth = 0
cat.has_children = cat.children.exists()
# Находим корневые категории (parent=None или parent не в нашем queryset)
queryset_ids = set(categories_dict.keys())
root_categories = [
cat for cat in all_categories
if cat.parent is None or cat.parent.pk not in queryset_ids
]
# Сортируем корневые по имени
root_categories.sort(key=lambda x: x.name.lower())
# Рекурсивно строим дерево
result = []
def add_category_and_children(category, depth=0):
# 1. Добавляем категорию как TreeItem
tree_item = TreeItem(category, 'category', depth)
result.append(tree_item)
# 2. Добавляем активные товары этой категории (отсортированные по имени)
products = category.products.filter(is_active=True).order_by('name')
for product in products:
product_item = TreeItem(product, 'product', depth + 1, category.pk)
result.append(product_item)
# 3. Добавляем активные наборы этой категории (отсортированные по имени)
kits = category.kits.filter(is_active=True).order_by('name')
for kit in kits:
kit_item = TreeItem(kit, 'kit', depth + 1, category.pk)
result.append(kit_item)
# 4. Рекурсивно обрабатываем дочерние категории
children = [
cat for cat in all_categories
if cat.parent and cat.parent.pk == category.pk
]
# Сортируем детей по имени
children.sort(key=lambda x: x.name.lower())
# Рекурсивно добавляем детей
for child in children:
add_category_and_children(child, depth + 1)
# Обходим все корневые категории
for root in root_categories:
add_category_and_children(root, 0)
return result
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Строим иерархическое дерево
queryset = self.get_queryset()
category_tree = self.build_category_tree(queryset)
context['category_tree'] = category_tree
# Флаг для автоматического раскрытия при поиске
context['has_search'] = bool(self.request.GET.get('search'))
# Фильтры для панели фильтрации
context['filters'] = {
'search': self.request.GET.get('search', ''),
'is_active': self.request.GET.get('is_active', ''),
'current': {
'search': self.request.GET.get('search', ''),
'is_active': self.request.GET.get('is_active', ''),
}
}
# Кнопки действий
context['action_buttons'] = [
{'url': reverse('products:category-create'), 'text': 'Создать категорию', 'icon': 'plus-circle'}
]
return context
class ProductCategoryCreateView(LoginRequiredMixin, CreateView):
model = ProductCategory
form_class = ProductCategoryForm
template_name = 'products/category_form.html'
success_url = reverse_lazy('products:category-list')
def form_valid(self, form):
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.')
# Обработка загрузки фотографий
errors = handle_category_photos(self.request, self.object)
if errors:
for error in errors:
messages.warning(self.request, error)
return redirect(self.get_success_url())
def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
return super().form_invalid(form)
class ProductCategoryDetailView(LoginRequiredMixin, DetailView):
model = ProductCategory
template_name = 'products/category_detail.html'
context_object_name = 'category'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Получаем фотографии категории
context['category_photos'] = self.object.photos.all().order_by('order')
context['photos_count'] = context['category_photos'].count()
# Получаем дочерние категории
context['children_categories'] = self.object.children.filter(is_active=True)
# Получаем товары в категории
context['products'] = self.object.products.filter(is_active=True)[:20]
context['products_count'] = self.object.products.filter(is_active=True).count()
return context
class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView):
model = ProductCategory
form_class = ProductCategoryForm
template_name = 'products/category_form.html'
success_url = reverse_lazy('products:category-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии для отображения в форме
context['category_photos'] = self.object.photos.all().order_by('order')
context['photos_count'] = context['category_photos'].count()
return context
def form_valid(self, form):
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.')
# Обработка загрузки новых фотографий
errors = handle_category_photos(self.request, self.object)
if errors:
for error in errors:
messages.warning(self.request, error)
return redirect(self.get_success_url())
def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
return super().form_invalid(form)
class ProductCategoryDeleteView(LoginRequiredMixin, DeleteView):
model = ProductCategory
template_name = 'products/category_confirm_delete.html'
success_url = reverse_lazy('products:category-list')
context_object_name = 'category'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Проверяем, есть ли товары в категории
context['products_count'] = self.object.products.count()
context['children_count'] = self.object.children.count()
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
# Проверяем, есть ли товары в категории
if self.object.products.exists():
messages.error(
request,
f'Невозможно удалить категорию "{self.object.name}", '
f'так как в ней есть товары ({self.object.products.count()} шт.). '
f'Сначала удалите или переместите товары.'
)
return redirect('products:category-detail', pk=self.object.pk)
# Проверяем, есть ли дочерние категории
if self.object.children.exists():
messages.error(
request,
f'Невозможно удалить категорию "{self.object.name}", '
f'так как у неё есть подкатегории ({self.object.children.count()} шт.). '
f'Сначала удалите или переместите подкатегории.'
)
return redirect('products:category-detail', pk=self.object.pk)
messages.success(request, f'Категория "{self.object.name}" удалена успешно.')
return super().post(request, *args, **kwargs)