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)