From 161f65e6c3b27f02e3e2ce0a6bcdd3a48ee62cb8 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 7 Jan 2026 09:15:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=BC=D0=B0=D1=81=D1=81=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B9=20=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен UI для пакетного выбора товаров с чекбоксами - Реализована возможность выбора всех товаров на странице - Реализована возможность выбора всех отфильтрованных товаров - Добавлено модальное окно для массового управления категориями - Добавлены API эндпоинты: get_filtered_items_ids, bulk_update_categories - Реализованы три режима работы с категориями: добавление, замена, очистка - Добавлен селектор количества элементов на странице (20/50/100) - Улучшена информативность о количестве выбранных элементов --- .../static/products/js/batch-selection.js | 336 ++++++++++++ .../static/products/js/bulk-category-modal.js | 504 ++++++++++++++++++ .../templates/products/products_list.html | 155 +++++- myproject/products/urls.py | 7 +- myproject/products/views/api_views.py | 284 ++++++++++ myproject/products/views/product_views.py | 24 +- 6 files changed, 1297 insertions(+), 13 deletions(-) create mode 100644 myproject/products/static/products/js/batch-selection.js create mode 100644 myproject/products/static/products/js/bulk-category-modal.js diff --git a/myproject/products/static/products/js/batch-selection.js b/myproject/products/static/products/js/batch-selection.js new file mode 100644 index 0000000..1ebf047 --- /dev/null +++ b/myproject/products/static/products/js/batch-selection.js @@ -0,0 +1,336 @@ +/** + * Batch Selection Manager for Product List + * Handles checkbox selection, row highlighting, and counter updates + */ + +(function() { + 'use strict'; + + // Selection state + let selectedItems = new Set(); + + // DOM elements + const selectAllCheckbox = document.getElementById('select-all-checkbox'); + const batchActionsBtn = document.getElementById('batch-actions-btn'); + const batchActionsDropdown = document.getElementById('batch-actions-dropdown'); + let selectionCountSpan = document.getElementById('selection-count'); + + /** + * Initialize the batch selection functionality + */ + function init() { + if (!selectAllCheckbox) { + console.warn('Batch selection: select-all checkbox not found'); + return; + } + + // Attach event listeners + selectAllCheckbox.addEventListener('change', handleSelectAllChange); + + // Attach listeners to all item checkboxes + const itemCheckboxes = document.querySelectorAll('.item-checkbox'); + itemCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', handleItemCheckboxChange); + }); + + // Initialize state + updateUI(); + } + + /** + * Handle "Select All" checkbox change + */ + function handleSelectAllChange(event) { + const isChecked = event.target.checked; + const itemCheckboxes = document.querySelectorAll('.item-checkbox'); + + itemCheckboxes.forEach(checkbox => { + checkbox.checked = isChecked; + const itemValue = checkbox.value; + + if (isChecked) { + selectedItems.add(itemValue); + highlightRow(checkbox, true); + } else { + selectedItems.delete(itemValue); + highlightRow(checkbox, false); + } + }); + + updateUI(); + } + + /** + * Handle individual item checkbox change + */ + function handleItemCheckboxChange(event) { + const checkbox = event.target; + const itemValue = checkbox.value; + const isChecked = checkbox.checked; + + if (isChecked) { + selectedItems.add(itemValue); + highlightRow(checkbox, true); + } else { + selectedItems.delete(itemValue); + highlightRow(checkbox, false); + } + + // Update "Select All" checkbox state + updateSelectAllCheckbox(); + + // Update UI + updateUI(); + } + + /** + * Highlight or unhighlight the row containing the checkbox + */ + function highlightRow(checkbox, highlight) { + const row = checkbox.closest('tr'); + if (row) { + if (highlight) { + row.classList.add('table-active'); + } else { + row.classList.remove('table-active'); + } + } + } + + /** + * Update the "Select All" checkbox state based on individual checkboxes + */ + function updateSelectAllCheckbox() { + const itemCheckboxes = document.querySelectorAll('.item-checkbox'); + const totalCheckboxes = itemCheckboxes.length; + const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked').length; + + if (checkedCheckboxes === 0) { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = false; + } else if (checkedCheckboxes === totalCheckboxes) { + selectAllCheckbox.checked = true; + selectAllCheckbox.indeterminate = false; + } else { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = true; + } + } + + /** + * Update UI elements based on selection state + */ + function updateUI() { + const count = selectedItems.size; + + // Обновляем счётчик (ищем заново на случай, если он был пересоздан) + selectionCountSpan = document.getElementById('selection-count'); + if (selectionCountSpan) { + selectionCountSpan.textContent = count; + } + + // Enable/disable batch actions button and restore text if needed + const shouldEnable = count > 0; + if (batchActionsBtn) { + batchActionsBtn.disabled = !shouldEnable; + + // Если в кнопке нет span#selection-count, значит текст был изменён - восстанавливаем + if (!batchActionsBtn.querySelector('#selection-count')) { + batchActionsBtn.innerHTML = ` Действия над выбранными (${count})`; + selectionCountSpan = document.getElementById('selection-count'); + } + } + if (batchActionsDropdown) { + batchActionsDropdown.disabled = !shouldEnable; + } + + // Показываем/скрываем dropdown для выбора всех + const selectAllDropdown = document.getElementById('select-all-dropdown-group'); + if (selectAllDropdown) { + // Показываем всегда, когда есть элементы на странице + const itemCheckboxes = document.querySelectorAll('.item-checkbox'); + if (itemCheckboxes.length > 0) { + selectAllDropdown.style.display = 'block'; + } + } + } + + /** + * Get currently selected items + * @returns {Array} Array of selected item identifiers + */ + function getSelectedItems() { + return Array.from(selectedItems); + } + + /** + * Clear all selections + */ + function clearSelection() { + selectedItems.clear(); + + // Uncheck all checkboxes + const itemCheckboxes = document.querySelectorAll('.item-checkbox'); + itemCheckboxes.forEach(checkbox => { + checkbox.checked = false; + highlightRow(checkbox, false); + }); + + if (selectAllCheckbox) { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = false; + } + + updateUI(); + } + + /** + * Select all filtered items via AJAX request + */ + function selectAllFiltered() { + // Получаем текущие параметры фильтрации из URL + const urlParams = new URLSearchParams(window.location.search); + + // Строим URL для API запроса + const apiUrl = '/products/api/filtered-items-ids/?' + urlParams.toString(); + + // Показываем индикатор загрузки + if (batchActionsBtn) { + batchActionsBtn.disabled = true; + batchActionsBtn.innerHTML = ' Загрузка...'; + } + + // Выполняем AJAX запрос + fetch(apiUrl, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'same-origin' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Очищаем текущий выбор + selectedItems.clear(); + + // Добавляем все отфильтрованные элементы + data.items.forEach(item => { + const itemValue = `${item.type}:${item.id}`; + selectedItems.add(itemValue); + }); + + // Отмечаем чекбоксы на текущей странице + const itemCheckboxes = document.querySelectorAll('.item-checkbox'); + itemCheckboxes.forEach(checkbox => { + const itemValue = checkbox.value; + if (selectedItems.has(itemValue)) { + checkbox.checked = true; + highlightRow(checkbox, true); + } + }); + + // Обновляем состояние "Select All" checkbox + updateSelectAllCheckbox(); + + // Обновляем UI + updateUI(); + + // Показываем уведомление + showNotification(`Выбрано ${data.count} элементов (включая элементы на других страницах)`, 'success'); + } else { + showNotification('Ошибка при загрузке списка элементов: ' + data.error, 'error'); + } + }) + .catch(error => { + console.error('Error fetching filtered items:', error); + showNotification('Ошибка при загрузке списка элементов', 'error'); + }) + .finally(() => { + // Восстанавливаем кнопку + updateUI(); + }); + } + + /** + * Show notification to user + */ + function showNotification(message, type = 'info') { + // Создаём toast уведомление (Bootstrap 5) + const toastContainer = document.querySelector('.toast-container') || createToastContainer(); + + const toastId = 'toast-' + Date.now(); + const bgClass = type === 'success' ? 'bg-success' : type === 'error' ? 'bg-danger' : 'bg-info'; + + const toastHTML = ` + + `; + + toastContainer.insertAdjacentHTML('beforeend', toastHTML); + + const toastElement = document.getElementById(toastId); + const toast = new bootstrap.Toast(toastElement, { delay: 3000 }); + toast.show(); + + // Удаляем элемент после скрытия + toastElement.addEventListener('hidden.bs.toast', () => { + toastElement.remove(); + }); + } + + /** + * Create toast container if it doesn't exist + */ + function createToastContainer() { + const container = document.createElement('div'); + container.className = 'toast-container position-fixed top-0 end-0 p-3'; + container.style.zIndex = '9999'; + document.body.appendChild(container); + return container; + } + + /** + * Update checkboxes on current page based on selection state + * Called when navigating between pages + */ + function syncCheckboxesWithSelection() { + const itemCheckboxes = document.querySelectorAll('.item-checkbox'); + itemCheckboxes.forEach(checkbox => { + const itemValue = checkbox.value; + if (selectedItems.has(itemValue)) { + checkbox.checked = true; + highlightRow(checkbox, true); + } + }); + + updateSelectAllCheckbox(); + updateUI(); + } + + // Initialize on DOM ready and sync selection state + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + init(); + syncCheckboxesWithSelection(); + }); + } else { + init(); + syncCheckboxesWithSelection(); + } + + // Export public API + window.BatchSelection = { + getSelectedItems: getSelectedItems, + clearSelection: clearSelection, + selectAllFiltered: selectAllFiltered, + syncCheckboxesWithSelection: syncCheckboxesWithSelection + }; + +})(); diff --git a/myproject/products/static/products/js/bulk-category-modal.js b/myproject/products/static/products/js/bulk-category-modal.js new file mode 100644 index 0000000..cddda19 --- /dev/null +++ b/myproject/products/static/products/js/bulk-category-modal.js @@ -0,0 +1,504 @@ +/** + * Bulk Category Assignment Modal + * Handles modal for assigning categories to multiple products/kits + */ + +(function() { + 'use strict'; + + // State + let allCategories = []; + let selectedCategoryIds = new Set(); + let modalInstance = null; + + // DOM elements + const modal = document.getElementById('bulkCategoryModal'); + const bulkCategoryAction = document.getElementById('bulk-category-action'); + const applyBtn = document.getElementById('applyBulkCategoriesBtn'); + const clearAllBtn = document.getElementById('clearAllCategoriesBtn'); + const categoryListContainer = document.getElementById('categoryListContainer'); + const categorySearchInput = document.getElementById('categorySearchInput'); + const clearExistingToggle = document.getElementById('clearExistingCategoriesToggle'); + const errorAlert = document.getElementById('bulkCategoryError'); + const selectedItemsCountSpan = document.getElementById('selectedItemsCount'); + const selectedItemsBreakdownSpan = document.getElementById('selectedItemsBreakdown'); + + /** + * Initialize the bulk category modal functionality + */ + function init() { + if (!modal || !bulkCategoryAction) { + console.warn('Bulk category modal elements not found'); + return; + } + + // Initialize Bootstrap modal instance + modalInstance = new bootstrap.Modal(modal); + + // Event listeners + bulkCategoryAction.addEventListener('click', handleOpenModal); + applyBtn.addEventListener('click', handleApply); + clearAllBtn.addEventListener('click', handleClearAll); + categorySearchInput.addEventListener('input', handleCategorySearch); + + // Listen for modal close to reset state + modal.addEventListener('hidden.bs.modal', resetModalState); + + console.log('Bulk category modal initialized'); + } + + /** + * Handle opening the modal + */ + function handleOpenModal(e) { + e.preventDefault(); + + // Get selected items from batch selection + const selectedItems = getSelectedItems(); + + if (selectedItems.length === 0) { + alert('Выберите товары для изменения категорий'); + return; + } + + // Update modal header with selection info + updateSelectionInfo(selectedItems); + + // Reset state + selectedCategoryIds.clear(); + hideError(); + + // Open modal and load categories + modalInstance.show(); + loadCategories(); + } + + /** + * Get selected items from batch selection system + */ + function getSelectedItems() { + const items = []; + + // Access the global batch selection state + if (window.BatchSelection && window.BatchSelection.getSelectedItems) { + const selectedItemsArray = window.BatchSelection.getSelectedItems(); + selectedItemsArray.forEach(itemKey => { + const [type, id] = itemKey.split(':'); + items.push({ type, id: parseInt(id) }); + }); + } else { + // Fallback: read from checkboxes directly + document.querySelectorAll('.item-checkbox:checked').forEach(checkbox => { + const type = checkbox.dataset.itemType; + const id = parseInt(checkbox.dataset.itemId); + items.push({ type, id }); + }); + } + + return items; + } + + /** + * Update selection info in modal + */ + function updateSelectionInfo(items) { + selectedItemsCountSpan.textContent = items.length; + + // Calculate breakdown + const products = items.filter(i => i.type === 'product').length; + const kits = items.filter(i => i.type === 'kit').length; + + let breakdown = ''; + if (products > 0 && kits > 0) { + breakdown = `(${products} товаров, ${kits} комплектов)`; + } else if (products > 0) { + breakdown = `(товары)`; + } else if (kits > 0) { + breakdown = `(комплекты)`; + } + + selectedItemsBreakdownSpan.textContent = breakdown; + } + + /** + * Load categories from server + */ + async function loadCategories() { + categoryListContainer.innerHTML = ` +
+
+ Загрузка... +
+
Загрузка категорий...
+
+ `; + + try { + // Get active categories (we can reuse the filter endpoint or create a dedicated one) + const response = await fetch('/products/api/search-products-variants/?type=category', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + throw new Error('Не удалось загрузить категории'); + } + + // For now, let's use a simpler approach - get categories from the page context + // In production, you might want a dedicated API endpoint + allCategories = await fetchCategoriesFromDOM(); + + renderCategoryList(allCategories); + } catch (error) { + console.error('Error loading categories:', error); + categoryListContainer.innerHTML = ` +
+ + Не удалось загрузить категории. + Обновите страницу +
+ `; + } + } + + /** + * Fetch categories from the page's filter dropdown + */ + function fetchCategoriesFromDOM() { + const categories = []; + const categorySelect = document.getElementById('category'); + + if (categorySelect) { + categorySelect.querySelectorAll('option').forEach(option => { + if (option.value) { // Skip empty option + categories.push({ + id: parseInt(option.value), + name: option.textContent.trim() + }); + } + }); + } + + return categories; + } + + /** + * Render category list with checkboxes + */ + function renderCategoryList(categories) { + if (categories.length === 0) { + categoryListContainer.innerHTML = ` +
+ + Нет доступных категорий. + Создать категорию +
+ `; + return; + } + + let html = '
'; + + categories.forEach(category => { + const isChecked = selectedCategoryIds.has(category.id); + html += ` +
+
+ + +
+
+ `; + }); + + html += '
'; + categoryListContainer.innerHTML = html; + + // Add event listeners to checkboxes + categoryListContainer.querySelectorAll('.category-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', handleCategoryToggle); + }); + + updateApplyButtonState(); + } + + /** + * Handle category checkbox toggle + */ + function handleCategoryToggle(e) { + const categoryId = parseInt(e.target.value); + + if (e.target.checked) { + selectedCategoryIds.add(categoryId); + } else { + selectedCategoryIds.delete(categoryId); + } + + updateApplyButtonState(); + } + + /** + * Handle category search + */ + function handleCategorySearch(e) { + const searchTerm = e.target.value.toLowerCase().trim(); + + if (searchTerm === '') { + renderCategoryList(allCategories); + } else { + const filtered = allCategories.filter(cat => + cat.name.toLowerCase().includes(searchTerm) + ); + renderCategoryList(filtered); + } + } + + /** + * Update apply button state + */ + function updateApplyButtonState() { + // Кнопка Применить активна только если выбрана хотя бы одна категория + applyBtn.disabled = selectedCategoryIds.size === 0; + } + + /** + * Handle apply button click + */ + async function handleApply() { + if (selectedCategoryIds.size === 0) { + showError('Выберите хотя бы одну категорию'); + return; + } + + const selectedItems = getSelectedItems(); + if (selectedItems.length === 0) { + showError('Нет выбранных товаров'); + return; + } + + // Определяем режим: если чекбокс включен - replace, иначе - add + const actionMode = clearExistingToggle.checked ? 'replace' : 'add'; + + // Prepare request data + const requestData = { + items: selectedItems, + category_ids: Array.from(selectedCategoryIds), + action_mode: actionMode + }; + + // Disable button and show loading + applyBtn.disabled = true; + applyBtn.innerHTML = 'Применение...'; + + try { + const response = await fetch('/products/api/bulk-update-categories/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify(requestData) + }); + + const result = await response.json(); + + if (result.success) { + // Show success message + showSuccessToast(result.message); + + // Close modal + modalInstance.hide(); + + // Optionally reload page to show updated categories + // For v1, we'll just show message suggesting refresh + if (result.updated_count > 0) { + setTimeout(() => { + if (confirm('Категории обновлены. Обновить страницу для отображения изменений?')) { + location.reload(); + } + }, 500); + } + } else { + showError(result.message || 'Не удалось обновить категории'); + + // Show skipped reasons if any + if (result.skipped_reasons && result.skipped_reasons.length > 0) { + console.warn('Skipped items:', result.skipped_reasons); + } + } + } catch (error) { + console.error('Error applying bulk categories:', error); + showError('Произошла ошибка при обновлении категорий. Попробуйте снова.'); + } finally { + // Reset button + applyBtn.innerHTML = ' Применить'; + applyBtn.disabled = false; + updateApplyButtonState(); + } + } + + /** + * Handle clear all categories button click + */ + async function handleClearAll() { + const selectedItems = getSelectedItems(); + if (selectedItems.length === 0) { + showError('Нет выбранных товаров'); + return; + } + + // Подтверждение опасной операции + if (!confirm(`Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!`)) { + return; + } + + // Отправляем запрос с пустым списком категорий и режимом replace + const requestData = { + items: selectedItems, + category_ids: [], // Пустой список = очистить все + action_mode: 'clear' // Специальный режим + }; + + // Disable button and show loading + clearAllBtn.disabled = true; + clearAllBtn.innerHTML = 'Очистка...'; + + try { + const response = await fetch('/products/api/bulk-update-categories/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify(requestData) + }); + + const result = await response.json(); + + if (result.success) { + showSuccessToast(result.message || `Категории удалены у ${result.updated_count} товаров`); + modalInstance.hide(); + + setTimeout(() => { + if (confirm('Категории очищены. Обновить страницу?')) { + location.reload(); + } + }, 500); + } else { + showError(result.message || 'Не удалось очистить категории'); + } + } catch (error) { + console.error('Error clearing categories:', error); + showError('Произошла ошибка. Попробуйте снова.'); + } finally { + clearAllBtn.innerHTML = ' Очистить все категории'; + clearAllBtn.disabled = false; + } + } + + /** + * Show error message in modal + */ + function showError(message) { + errorAlert.textContent = message; + errorAlert.classList.remove('d-none'); + } + + /** + * Hide error message + */ + function hideError() { + errorAlert.classList.add('d-none'); + } + + /** + * Show success toast notification + */ + function showSuccessToast(message) { + // Create toast container if it doesn't exist + let toastContainer = document.querySelector('.toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'; + toastContainer.style.zIndex = '11000'; + document.body.appendChild(toastContainer); + } + + // Create toast + const toastId = 'toast_' + Date.now(); + const toastHtml = ` + + `; + + toastContainer.insertAdjacentHTML('beforeend', toastHtml); + + const toastElement = document.getElementById(toastId); + const toast = new bootstrap.Toast(toastElement, { + autohide: true, + delay: 5000 + }); + + toast.show(); + + // Remove toast element after it's hidden + toastElement.addEventListener('hidden.bs.toast', () => { + toastElement.remove(); + }); + } + + /** + * Reset modal state + */ + function resetModalState() { + selectedCategoryIds.clear(); + categorySearchInput.value = ''; + hideError(); + + // Сбрасываем чекбокс очистки + clearExistingToggle.checked = false; + + updateApplyButtonState(); + } + + /** + * Get CSRF token from cookie + */ + function getCsrfToken() { + const name = 'csrftoken'; + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } + + // Initialize on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/myproject/products/templates/products/products_list.html b/myproject/products/templates/products/products_list.html index a123bd1..e1d38f6 100644 --- a/myproject/products/templates/products/products_list.html +++ b/myproject/products/templates/products/products_list.html @@ -108,7 +108,17 @@ {% endif %} -
+ +
+ + +
+ +
{% if items %} + +
+
+ {% if is_paginated %} + + Показано {{ page_obj.start_index }}–{{ page_obj.end_index }} из {{ page_obj.paginator.count }} + + {% else %} + Всего: {{ items|length }} + {% endif %} + + + +
+
+ + + +
+
+
+ @@ -144,6 +215,14 @@ {% for item in items %} +
+ + Фото Название Артикул
+ + {% if item.photos.all %} {% with photo=item.photos.first %} @@ -318,4 +397,78 @@ {% endif %} + + + +{% endblock %} + +{% block extra_js %} +{% load static %} + + {% endblock %} diff --git a/myproject/products/urls.py b/myproject/products/urls.py index b0960b6..7bb8119 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -11,11 +11,6 @@ urlpatterns = [ # Каталог с drag-n-drop path('catalog/', views.CatalogView.as_view(), name='catalog'), - - # Legacy URLs for backward compatibility - path('all/', views.CombinedProductListView.as_view(), name='all-products'), - path('products/', views.ProductListView.as_view(), name='product-list-legacy'), - path('kits/', views.ProductKitListView.as_view(), name='productkit-list'), # CRUD URLs for Product path('product/create/', views.ProductCreateView.as_view(), name='product-create'), path('product//', views.ProductDetailView.as_view(), name='product-detail'), @@ -58,6 +53,8 @@ urlpatterns = [ path('api/categories//rename/', api_views.rename_category_api, name='api-category-rename'), path('api/products//update-price/', api_views.update_product_price_api, name='api-update-product-price'), path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'), + path('api/filtered-items-ids/', api_views.get_filtered_items_ids, name='api-filtered-items-ids'), + path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'), # Photo processing status API (for AJAX polling) path('api/photos/status//', photo_status_api.photo_processing_status, name='api-photo-status'), diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 0111215..099d6eb 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -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) diff --git a/myproject/products/views/product_views.py b/myproject/products/views/product_views.py index ab3ae01..021a042 100644 --- a/myproject/products/views/product_views.py +++ b/myproject/products/views/product_views.py @@ -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'), } }