""" CRUD представления для комплектов товаров (ProductKit). """ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView from django.urls import reverse_lazy from django.shortcuts import redirect from django.db import transaction, IntegrityError from user_roles.mixins import ManagerOwnerRequiredMixin from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName, ProductSalesUnit from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate from .utils import handle_photos import os class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView): model = ProductKit template_name = 'products/productkit_list.html' context_object_name = 'kits' paginate_by = 10 def get_queryset(self): queryset = super().get_queryset() queryset = queryset.prefetch_related('categories', 'photos', 'kit_items', 'tags') # Скрываем временные комплекты из общего каталога queryset = queryset.filter(is_temporary=False) # Поиск по названию 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) # Фильтр по статусу (новая система) status_filter = self.request.GET.get('status') if status_filter: queryset = queryset.filter(status=status_filter) else: # Фильтр по is_active для обратной совместимости (старая система) is_active = self.request.GET.get('is_active') if is_active == '1': queryset = queryset.filter(status='active') elif is_active == '0': queryset = queryset.filter(status__in=['archived', 'discontinued']) 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', ''), '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:products-list'), 'text': 'К товарам', 'class': 'btn-outline-primary', 'icon': 'box' }) context['action_buttons'] = action_buttons return context class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView): """ View для создания нового комплекта с добавлением компонентов на одной странице. """ model = ProductKit form_class = ProductKitForm template_name = 'products/productkit_create.html' def get_initial(self): initial = super().get_initial() copy_id = self.request.GET.get('copy_from') if copy_id: try: kit = ProductKit.objects.get(pk=copy_id) # Generate unique name base_name = f"{kit.name} (Копия)" new_name = base_name counter = 1 while ProductKit.objects.filter(name=new_name).exists(): counter += 1 new_name = f"{base_name} {counter}" initial.update({ 'name': new_name, 'description': kit.description, 'short_description': kit.short_description, 'categories': list(kit.categories.values_list('pk', flat=True)), 'tags': list(kit.tags.values_list('pk', flat=True)), 'sale_price': kit.sale_price, 'price_adjustment_type': kit.price_adjustment_type, 'price_adjustment_value': kit.price_adjustment_value, 'external_category': kit.external_category, 'status': 'active', # Default to active for new kits }) except ProductKit.DoesNotExist: pass return initial def post(self, request, *args, **kwargs): """ Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов. API возвращает ID в формате "product_123" или "kit_456", но Django ожидает числа. """ # Создаем изменяемую копию POST данных post_data = request.POST.copy() # Очищаем product ID от префиксов (product_123 -> 123) for key in post_data.keys(): if key.endswith('-product') and post_data[key]: value = post_data[key] if '_' in value: # Извлекаем числовой ID из "product_123" numeric_id = value.split('_')[1] post_data[key] = numeric_id elif key.endswith('-sales_unit') and post_data[key]: value = post_data[key] if '_' in value: # Извлекаем числовой ID из "sales_unit_123" numeric_id = value.split('_')[1] post_data[key] = numeric_id # Заменяем request.POST на очищенные данные request.POST = post_data return super().post(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if self.request.POST: context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem') # При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2 selected_products = {} selected_variants = {} selected_sales_units = {} for key, value in self.request.POST.items(): if '-product' in key and value: try: product = Product.objects.get(id=value) text = product.name if product.sku: text += f" ({product.sku})" # Получаем actual_price: приоритет sale_price > price actual_price = product.sale_price if product.sale_price else product.price selected_products[key] = { 'id': product.id, 'text': text, 'price': str(product.price) if product.price else None, 'actual_price': str(actual_price) if actual_price else '0' } except Product.DoesNotExist: pass if '-variant_group' in key and value: try: variant_group = ProductVariantGroup.objects.prefetch_related( 'items__product' ).get(id=value) variant_price = variant_group.price or 0 count = variant_group.items.count() selected_variants[key] = { 'id': variant_group.id, 'text': f"{variant_group.name} ({count} вариантов)", 'price': str(variant_price), 'actual_price': str(variant_price), 'type': 'variant', 'count': count } except ProductVariantGroup.DoesNotExist: pass if '-sales_unit' in key and value: try: sales_unit = ProductSalesUnit.objects.select_related('product').get(id=value) text = f"{sales_unit.name} ({sales_unit.product.name})" # Получаем actual_price: приоритет sale_price > price actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price selected_sales_units[key] = { 'id': sales_unit.id, 'text': text, 'price': str(sales_unit.price) if sales_unit.price else None, 'actual_price': str(actual_price) if actual_price else '0' } except ProductSalesUnit.DoesNotExist: pass context['selected_products'] = selected_products context['selected_variants'] = selected_variants context['selected_sales_units'] = selected_sales_units else: # COPY KIT LOGIC copy_id = self.request.GET.get('copy_from') initial_items = [] selected_products = {} selected_variants = {} selected_sales_units = {} if copy_id: try: source_kit = ProductKit.objects.get(pk=copy_id) for item in source_kit.kit_items.all(): item_data = { 'quantity': item.quantity, # Delete flag is false by default } form_prefix = f"kititem-{len(initial_items)}" if item.product: item_data['product'] = item.product # Select2 prefill product = item.product text = product.name if product.sku: text += f" ({product.sku})" actual_price = product.sale_price if product.sale_price else product.price selected_products[f"{form_prefix}-product"] = { 'id': product.id, 'text': text, 'price': str(product.price) if product.price else None, 'actual_price': str(actual_price) if actual_price else '0' } if item.sales_unit: item_data['sales_unit'] = item.sales_unit # Select2 prefill sales_unit = item.sales_unit text = f"{sales_unit.name} ({sales_unit.product.name})" actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price selected_sales_units[f"{form_prefix}-sales_unit"] = { 'id': sales_unit.id, 'text': text, 'price': str(sales_unit.price) if sales_unit.price else None, 'actual_price': str(actual_price) if actual_price else '0' } if item.variant_group: item_data['variant_group'] = item.variant_group # Select2 prefill variant_group = ProductVariantGroup.objects.prefetch_related( 'items__product' ).get(id=item.variant_group.id) variant_price = variant_group.price or 0 count = variant_group.items.count() selected_variants[f"{form_prefix}-variant_group"] = { 'id': variant_group.id, 'text': f"{variant_group.name} ({count} вариантов)", 'price': str(variant_price), 'actual_price': str(variant_price), 'type': 'variant', 'count': count } initial_items.append(item_data) except ProductKit.DoesNotExist: pass if initial_items: context['kititem_formset'] = KitItemFormSetCreate( prefix='kititem', initial=initial_items ) context['kititem_formset'].extra = len(initial_items) else: context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem') # Pass Select2 data to context context['selected_products'] = selected_products context['selected_variants'] = selected_variants context['selected_sales_units'] = selected_sales_units # Pass source photos if copying if copy_id: try: source_kit = ProductKit.objects.prefetch_related('photos').get(pk=copy_id) photos = source_kit.photos.all().order_by('order') print(f"DEBUG: Found {photos.count()} source photos for kit {copy_id}") context['source_photos'] = photos except ProductKit.DoesNotExist: print(f"DEBUG: Source kit {copy_id} not found") pass # Количество названий букетов в базе context['bouquet_names_count'] = BouquetName.objects.count() return context def form_valid(self, form): # Получаем формсет из POST с правильным префиксом kititem_formset = KitItemFormSetCreate(self.request.POST, prefix='kititem') # Проверяем валидность основной формы if not form.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме комплекта.') return self.form_invalid(form) if not kititem_formset.is_valid(): # Если формсет невалиден, показываем форму с ошибками messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.') return self.form_invalid(form) try: with transaction.atomic(): # Сохраняем основную форму (комплект) self.object = form.save(commit=True) # Убеждаемся что объект в БД if not self.object.pk: raise Exception("Не удалось сохранить комплект в базу данных") # Сохраняем компоненты kititem_formset.instance = self.object saved_items = kititem_formset.save() # Пересчитываем базовую цену после сохранения всех компонентов self.object.recalculate_base_price() # Обработка фотографий handle_photos(self.request, self.object, ProductKitPhoto, 'kit') # Handle copied photos copied_photo_ids = self.request.POST.getlist('copied_photos') print(f"DEBUG: copied_photo_ids in POST: {copied_photo_ids}") if copied_photo_ids: from django.core.files.base import ContentFile original_photos = ProductKitPhoto.objects.filter(id__in=copied_photo_ids) print(f"DEBUG: Found {original_photos.count()} original photos to copy") # Get max order from existing photos (uploaded via handle_photos) from django.db.models import Max max_order = self.object.photos.aggregate(Max('order'))['order__max'] next_order = 0 if max_order is None else max_order + 1 print(f"DEBUG: Starting order for copies: {next_order}") for photo in original_photos: try: # Open the original image file if photo.image: print(f"DEBUG: Processing photo {photo.id}: {photo.image.name}") with photo.image.open('rb') as f: image_content = f.read() # Create a new ContentFile new_image_name = f"copy_{self.object.id}_{os.path.basename(photo.image.name)}" print(f"DEBUG: New image name: {new_image_name}") # Create new photo instance new_photo = ProductKitPhoto(kit=self.object, order=next_order) # Save the image file (this also saves the model instance) new_photo.image.save(new_image_name, ContentFile(image_content)) print(f"DEBUG: Successfully saved copy for photo {photo.id}") next_order += 1 else: print(f"DEBUG: Photo {photo.id} has no image file") except Exception as e: print(f"Error copying photo {photo.id}: {e}") import traceback traceback.print_exc() continue messages.success( self.request, f'Комплект "{self.object.name}" успешно создан!' ) return redirect('products:products-list') except IntegrityError as e: # Обработка нарушения уникальности в БД error_msg = str(e).lower() if 'unique_active_kit_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg): messages.error( self.request, f'Ошибка: комплект с названием "{form.instance.name}" уже существует. ' 'Пожалуйста, используйте другое название.' ) elif 'slug' in error_msg or 'unique' in error_msg: messages.error( self.request, 'Ошибка: комплект с таким названием уже существует. ' 'Пожалуйста, используйте другое название.' ) else: messages.error( self.request, 'Ошибка при сохранении комплекта. Пожалуйста, проверьте введённые данные.' ) return self.form_invalid(form) except Exception as e: messages.error(self.request, f'Ошибка при сохранении: {str(e)}') import traceback traceback.print_exc() return self.form_invalid(form) def form_invalid(self, form): # Получаем формсет для отображения ошибок context = self.get_context_data(form=form) return self.render_to_response(context) class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView): """ View для редактирования существующего комплекта и добавления товаров. """ model = ProductKit form_class = ProductKitForm template_name = 'products/productkit_edit.html' def post(self, request, *args, **kwargs): """ Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов. API возвращает ID в формате "product_123" или "kit_456", но Django ожидает числа. """ # Создаем изменяемую копию POST данных post_data = request.POST.copy() # Очищаем product ID от префиксов (product_123 -> 123) for key in post_data.keys(): if key.endswith('-product') and post_data[key]: value = post_data[key] if '_' in value: # Извлекаем числовой ID из "product_123" numeric_id = value.split('_')[1] post_data[key] = numeric_id elif key.endswith('-sales_unit') and post_data[key]: value = post_data[key] if '_' in value: # Извлекаем числовой ID из "sales_unit_123" numeric_id = value.split('_')[1] post_data[key] = numeric_id # Заменяем request.POST на очищенные данные request.POST = post_data return super().post(request, *args, **kwargs) 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, prefix='kititem') # При ошибке валидации - подготавливаем данные для Select2 from ..models import Product, ProductVariantGroup, ProductSalesUnit selected_products = {} selected_variants = {} selected_sales_units = {} for key, value in self.request.POST.items(): if '-product' in key and value: try: # Очищаем ID от префикса если есть numeric_value = value.split('_')[1] if '_' in value else value product = Product.objects.get(id=numeric_value) text = product.name if product.sku: text += f" ({product.sku})" actual_price = product.sale_price if product.sale_price else product.price selected_products[key] = { 'id': product.id, 'text': text, 'price': str(product.price) if product.price else None, 'actual_price': str(actual_price) if actual_price else '0' } except Product.DoesNotExist: pass if '-variant_group' in key and value: try: variant_group = ProductVariantGroup.objects.prefetch_related( 'items__product' ).get(id=value) variant_price = variant_group.price or 0 count = variant_group.items.count() selected_variants[key] = { 'id': variant_group.id, 'text': f"{variant_group.name} ({count} вариантов)", 'price': str(variant_price), 'actual_price': str(variant_price), 'type': 'variant', 'count': count } except ProductVariantGroup.DoesNotExist: pass if '-sales_unit' in key and value: try: # Очищаем ID от префикса если есть numeric_value = value.split('_')[1] if '_' in value else value sales_unit = ProductSalesUnit.objects.select_related('product').get(id=numeric_value) text = f"{sales_unit.name} ({sales_unit.product.name})" # Получаем actual_price: приоритет sale_price > price actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price selected_sales_units[key] = { 'id': sales_unit.id, 'text': text, 'price': str(sales_unit.price) if sales_unit.price else None, 'actual_price': str(actual_price) if actual_price else '0' } except ProductSalesUnit.DoesNotExist: pass context['selected_products'] = selected_products context['selected_variants'] = selected_variants context['selected_sales_units'] = selected_sales_units else: context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem') # Подготавливаем данные для предзагрузки в Select2 from ..models import Product, ProductVariantGroup, ProductSalesUnit selected_products = {} selected_variants = {} selected_sales_units = {} for item in self.object.kit_items.all(): form_prefix = f"kititem-{item.id}" if item.product: product = item.product text = product.name if product.sku: text += f" ({product.sku})" actual_price = product.sale_price if product.sale_price else product.price selected_products[f"{form_prefix}-product"] = { 'id': product.id, 'text': text, 'price': str(product.price) if product.price else None, 'actual_price': str(actual_price) if actual_price else '0' } if item.sales_unit: sales_unit = item.sales_unit text = f"{sales_unit.name} ({sales_unit.product.name})" actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price selected_sales_units[f"{form_prefix}-sales_unit"] = { 'id': sales_unit.id, 'text': text, 'price': str(sales_unit.price) if sales_unit.price else None, 'actual_price': str(actual_price) if actual_price else '0' } if item.variant_group: variant_group = ProductVariantGroup.objects.prefetch_related( 'items__product' ).get(id=item.variant_group.id) variant_price = variant_group.price or 0 count = variant_group.items.count() selected_variants[f"{form_prefix}-variant_group"] = { 'id': variant_group.id, 'text': f"{variant_group.name} ({count} вариантов)", 'price': str(variant_price), 'actual_price': str(variant_price), 'type': 'variant', 'count': count } context['selected_products'] = selected_products context['selected_variants'] = selected_variants context['selected_sales_units'] = selected_sales_units 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, prefix='kititem') # Проверяем валидность основной формы и формсета if not form.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме комплекта.') return self.form_invalid(form) if not kititem_formset.is_valid(): # Если формсет невалиден, показываем форму с ошибками messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.') return self.form_invalid(form) try: with transaction.atomic(): # Сохраняем основную форму self.object = form.save(commit=True) # Сохраняем компоненты kititem_formset.instance = self.object kititem_formset.save() # Пересчитываем базовую цену после сохранения всех компонентов self.object.recalculate_base_price() # Обработка фотографий handle_photos(self.request, self.object, ProductKitPhoto, 'kit') 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:products-list') except IntegrityError as e: # Обработка нарушения уникальности в БД error_msg = str(e).lower() if 'unique_active_kit_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg): messages.error( self.request, f'Ошибка: комплект с названием "{form.instance.name}" уже существует. ' 'Пожалуйста, используйте другое название.' ) elif 'slug' in error_msg or 'unique' in error_msg: messages.error( self.request, 'Ошибка: комплект с таким названием уже существует. ' 'Пожалуйста, используйте другое название.' ) else: messages.error( self.request, 'Ошибка при сохранении комплекта. Пожалуйста, проверьте введённые данные.' ) return self.form_invalid(form) except Exception as e: messages.error(self.request, f'Ошибка при сохранении: {str(e)}') import traceback traceback.print_exc() 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:products-list') class ProductKitDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView): """ View для просмотра деталей комплекта. Показывает все компоненты, цены, фотографии. """ model = ProductKit template_name = 'products/productkit_detail.html' context_object_name = 'kit' 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, ManagerOwnerRequiredMixin, DeleteView): """ View для удаления комплекта. """ model = ProductKit template_name = 'products/productkit_confirm_delete.html' context_object_name = 'kit' def get_success_url(self): messages.success(self.request, f'Комплект "{self.object.name}" успешно удален!') return reverse_lazy('products:products-list') class ProductKitMakePermanentView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView): """ View для преобразования временного комплекта в постоянный. Позволяет отредактировать название, добавить категории, теги перед сохранением. """ model = ProductKit template_name = 'products/productkit_make_permanent.html' context_object_name = 'kit' fields = ['name', 'description', 'categories', 'tags', 'sale_price'] def get_queryset(self): # Только временные комплекты можно преобразовать return super().get_queryset().filter(is_temporary=True) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['kit_items'] = self.object.kit_items.all().select_related('product', 'variant_group') context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at') return context def form_valid(self, form): # Преобразуем в постоянный if self.object.make_permanent(): messages.success( self.request, f'Комплект "{self.object.name}" преобразован в постоянный и теперь доступен в каталоге!' ) else: messages.warning(self.request, f'Комплект "{self.object.name}" уже является постоянным.') return super().form_valid(form) def get_success_url(self): return reverse_lazy('products:productkit-detail', kwargs={'pk': self.object.pk})