Добавить функционал массового изменения категорий товаров

- Добавлен UI для пакетного выбора товаров с чекбоксами
- Реализована возможность выбора всех товаров на странице
- Реализована возможность выбора всех отфильтрованных товаров
- Добавлено модальное окно для массового управления категориями
- Добавлены API эндпоинты: get_filtered_items_ids, bulk_update_categories
- Реализованы три режима работы с категориями: добавление, замена, очистка
- Добавлен селектор количества элементов на странице (20/50/100)
- Улучшена информативность о количестве выбранных элементов
This commit is contained in:
2026-01-07 09:15:53 +03:00
parent d5c1ed1e4b
commit 161f65e6c3
6 changed files with 1297 additions and 13 deletions

View File

@@ -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)

View File

@@ -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'),
}
}