1202 lines
47 KiB
Python
1202 lines
47 KiB
Python
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)
|