Добавить функционал массового изменения категорий товаров
- Добавлен UI для пакетного выбора товаров с чекбоксами - Реализована возможность выбора всех товаров на странице - Реализована возможность выбора всех отфильтрованных товаров - Добавлено модальное окно для массового управления категориями - Добавлены API эндпоинты: get_filtered_items_ids, bulk_update_categories - Реализованы три режима работы с категориями: добавление, замена, очистка - Добавлен селектор количества элементов на странице (20/50/100) - Улучшена информативность о количестве выбранных элементов
This commit is contained in:
@@ -1465,3 +1465,287 @@ def get_product_sales_units_api(request, product_id):
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
def get_filtered_items_ids(request):
|
||||
"""
|
||||
API endpoint для получения ID всех отфильтрованных товаров и комплектов.
|
||||
Используется для функции "Выбрать все отфильтрованные".
|
||||
|
||||
Параметры GET (те же, что и в CombinedProductListView):
|
||||
- search: строка поиска
|
||||
- type: 'all', 'products', 'kits'
|
||||
- category: ID категории
|
||||
- status: статус товара/комплекта
|
||||
- in_stock: '1' или '0'
|
||||
- tags: список ID тегов
|
||||
|
||||
Возвращает JSON:
|
||||
{
|
||||
"success": true,
|
||||
"items": [
|
||||
{"type": "product", "id": 1},
|
||||
{"type": "kit", "id": 5},
|
||||
...
|
||||
],
|
||||
"count": 150
|
||||
}
|
||||
"""
|
||||
from django.db.models import Q
|
||||
from ..models import Product, ProductKit
|
||||
|
||||
try:
|
||||
# Получаем фильтры из GET-параметров
|
||||
type_filter = request.GET.get('type', 'all')
|
||||
search_query = request.GET.get('search')
|
||||
category_id = request.GET.get('category')
|
||||
status_filter = request.GET.get('status')
|
||||
in_stock_filter = request.GET.get('in_stock')
|
||||
tags = request.GET.getlist('tags')
|
||||
|
||||
# Начинаем с базовых queryset
|
||||
products = Product.objects.all()
|
||||
kits = ProductKit.objects.filter(is_temporary=False)
|
||||
|
||||
# Применяем фильтры (та же логика, что в CombinedProductListView)
|
||||
if search_query:
|
||||
products = products.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(sku__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(categories__name__icontains=search_query) |
|
||||
Q(search_keywords__icontains=search_query)
|
||||
).distinct()
|
||||
|
||||
kits = kits.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(sku__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(categories__name__icontains=search_query)
|
||||
).distinct()
|
||||
|
||||
if category_id:
|
||||
products = products.filter(categories__id=category_id)
|
||||
kits = kits.filter(categories__id=category_id)
|
||||
|
||||
if status_filter:
|
||||
products = products.filter(status=status_filter)
|
||||
kits = kits.filter(status=status_filter)
|
||||
|
||||
if in_stock_filter == '1':
|
||||
products = products.filter(in_stock=True)
|
||||
elif in_stock_filter == '0':
|
||||
products = products.filter(in_stock=False)
|
||||
|
||||
if tags:
|
||||
products = products.filter(tags__id__in=tags).distinct()
|
||||
kits = kits.filter(tags__id__in=tags).distinct()
|
||||
|
||||
# Собираем ID в зависимости от фильтра типа
|
||||
items = []
|
||||
|
||||
if type_filter in ['all', 'products']:
|
||||
product_ids = products.values_list('id', flat=True)
|
||||
items.extend([{'type': 'product', 'id': pid} for pid in product_ids])
|
||||
|
||||
if type_filter in ['all', 'kits']:
|
||||
kit_ids = kits.values_list('id', flat=True)
|
||||
items.extend([{'type': 'kit', 'id': kid} for kid in kit_ids])
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'items': items,
|
||||
'count': len(items)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка получения отфильтрованных ID: {str(e)}')
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
def bulk_update_categories(request):
|
||||
"""
|
||||
API endpoint для массового обновления категорий у выбранных товаров и комплектов.
|
||||
|
||||
POST /products/api/bulk-update-categories/
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"items": [
|
||||
{"type": "product", "id": 123},
|
||||
{"type": "kit", "id": 456}
|
||||
],
|
||||
"category_ids": [5, 12, 18],
|
||||
"action_mode": "add" // "add" или "replace"
|
||||
}
|
||||
|
||||
Response JSON:
|
||||
{
|
||||
"success": true,
|
||||
"updated_count": 15,
|
||||
"skipped_count": 0,
|
||||
"message": "Категории успешно обновлены для 15 товаров"
|
||||
}
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Метод не поддерживается'
|
||||
}, status=405)
|
||||
|
||||
try:
|
||||
import json
|
||||
from django.db import transaction
|
||||
|
||||
# Парсинг JSON запроса
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Неверный формат JSON'
|
||||
}, status=400)
|
||||
|
||||
# Валидация обязательных полей
|
||||
items = data.get('items', [])
|
||||
category_ids = data.get('category_ids', [])
|
||||
action_mode = data.get('action_mode', 'add')
|
||||
|
||||
if not items:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Не выбраны товары для обновления'
|
||||
}, status=400)
|
||||
|
||||
# Для режима 'clear' категории не обязательны
|
||||
if action_mode != 'clear' and not category_ids:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Не выбраны категории'
|
||||
}, status=400)
|
||||
|
||||
if action_mode not in ['add', 'replace', 'clear']:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Неверный режим действия (должен быть add, replace или clear)'
|
||||
}, status=400)
|
||||
|
||||
# Проверка существования категорий (только если они указаны)
|
||||
categories = []
|
||||
if category_ids:
|
||||
categories = ProductCategory.objects.filter(id__in=category_ids, is_active=True)
|
||||
if categories.count() != len(category_ids):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Некоторые категории не найдены или неактивны'
|
||||
}, status=400)
|
||||
|
||||
# Обработка товаров в транзакции
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
skipped_reasons = []
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
item_type = item.get('type')
|
||||
item_id = item.get('id')
|
||||
|
||||
if not item_type or not item_id:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# Получаем объект товара или комплекта
|
||||
if item_type == 'product':
|
||||
# Проверка прав
|
||||
if not request.user.has_perm('products.change_product'):
|
||||
skipped_count += 1
|
||||
skipped_reasons.append(f'Товар ID {item_id}: нет прав')
|
||||
continue
|
||||
|
||||
obj = Product.objects.get(id=item_id)
|
||||
elif item_type == 'kit':
|
||||
# Проверка прав
|
||||
if not request.user.has_perm('products.change_productkit'):
|
||||
skipped_count += 1
|
||||
skipped_reasons.append(f'Комплект ID {item_id}: нет прав')
|
||||
continue
|
||||
|
||||
obj = ProductKit.objects.get(id=item_id)
|
||||
else:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Применяем изменения категорий
|
||||
if action_mode == 'add':
|
||||
# Добавляем категории к существующим
|
||||
obj.categories.add(*categories)
|
||||
elif action_mode == 'replace':
|
||||
# Заменяем все категории
|
||||
obj.categories.set(categories)
|
||||
elif action_mode == 'clear':
|
||||
# Очищаем все категории
|
||||
obj.categories.clear()
|
||||
|
||||
updated_count += 1
|
||||
|
||||
except (Product.DoesNotExist, ProductKit.DoesNotExist):
|
||||
skipped_count += 1
|
||||
skipped_reasons.append(f'{item_type.capitalize()} ID {item_id} не найден')
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления {item_type} ID {item_id}: {str(e)}')
|
||||
skipped_count += 1
|
||||
skipped_reasons.append(f'{item_type.capitalize()} ID {item_id}: {str(e)}')
|
||||
continue
|
||||
|
||||
# Формируем сообщение результата
|
||||
if updated_count > 0:
|
||||
if action_mode == 'clear':
|
||||
message = f'Категории удалены у {updated_count} товаров'
|
||||
else:
|
||||
category_names = ', '.join([c.name for c in categories[:3]])
|
||||
if len(categories) > 3:
|
||||
category_names += f' и ещё {len(categories) - 3}'
|
||||
|
||||
if action_mode == 'add':
|
||||
action_text = 'добавлены'
|
||||
else:
|
||||
action_text = 'установлены'
|
||||
|
||||
message = f'Категории "{category_names}" {action_text} для {updated_count} товаров'
|
||||
|
||||
if skipped_count > 0:
|
||||
message += f'. Пропущено: {skipped_count}'
|
||||
|
||||
response_data = {
|
||||
'success': True,
|
||||
'updated_count': updated_count,
|
||||
'skipped_count': skipped_count,
|
||||
'message': message
|
||||
}
|
||||
|
||||
if skipped_reasons:
|
||||
response_data['skipped_reasons'] = skipped_reasons[:10] # Ограничение первыми 10
|
||||
|
||||
return JsonResponse(response_data)
|
||||
else:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'updated_count': 0,
|
||||
'skipped_count': skipped_count,
|
||||
'message': 'Не удалось обновить ни один товар',
|
||||
'skipped_reasons': skipped_reasons[:10]
|
||||
}, status=400)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка массового обновления категорий: {str(e)}')
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': f'Произошла ошибка: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
@@ -97,13 +97,6 @@ class ProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||||
'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
|
||||
@@ -265,6 +258,22 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
|
||||
context_object_name = 'items'
|
||||
paginate_by = 20
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
"""
|
||||
Динамическое определение количества элементов на странице.
|
||||
Читает параметр per_page из GET-запроса.
|
||||
"""
|
||||
per_page = self.request.GET.get('per_page', '20')
|
||||
try:
|
||||
per_page_int = int(per_page)
|
||||
# Разрешаем только значения 20, 50, 100
|
||||
if per_page_int in [20, 50, 100]:
|
||||
return per_page_int
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# По умолчанию возвращаем 20
|
||||
return 20
|
||||
|
||||
def get_queryset(self):
|
||||
# Получаем фильтр по типу
|
||||
type_filter = self.request.GET.get('type', 'all')
|
||||
@@ -377,6 +386,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
|
||||
'type': self.request.GET.get('type', 'all'),
|
||||
'in_stock': self.request.GET.get('in_stock', ''),
|
||||
'tags': [int(tag) for tag in self.request.GET.getlist('tags') if tag.isdigit()],
|
||||
'per_page': self.request.GET.get('per_page', '20'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user