Добавить функционал массового изменения категорий товаров
- Добавлен UI для пакетного выбора товаров с чекбоксами - Реализована возможность выбора всех товаров на странице - Реализована возможность выбора всех отфильтрованных товаров - Добавлено модальное окно для массового управления категориями - Добавлены API эндпоинты: get_filtered_items_ids, bulk_update_categories - Реализованы три режима работы с категориями: добавление, замена, очистка - Добавлен селектор количества элементов на странице (20/50/100) - Улучшена информативность о количестве выбранных элементов
This commit is contained in:
336
myproject/products/static/products/js/batch-selection.js
Normal file
336
myproject/products/static/products/js/batch-selection.js
Normal file
@@ -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 = `<i class="bi bi-gear-fill"></i> Действия над выбранными (<span id="selection-count">${count}</span>)`;
|
||||
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 = '<i class="bi bi-hourglass-split"></i> Загрузка...';
|
||||
}
|
||||
|
||||
// Выполняем 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 = `
|
||||
<div id="${toastId}" class="toast align-items-center text-white ${bgClass} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
})();
|
||||
504
myproject/products/static/products/js/bulk-category-modal.js
Normal file
504
myproject/products/static/products/js/bulk-category-modal.js
Normal file
@@ -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 = `
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
<div class="mt-2 text-muted">Загрузка категорий...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Не удалось загрузить категории.
|
||||
<a href="#" onclick="location.reload()">Обновите страницу</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Нет доступных категорий.
|
||||
<a href="/products/categories/create/" target="_blank">Создать категорию</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="list-group list-group-flush">';
|
||||
|
||||
categories.forEach(category => {
|
||||
const isChecked = selectedCategoryIds.has(category.id);
|
||||
html += `
|
||||
<div class="list-group-item list-group-item-action p-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input category-checkbox"
|
||||
type="checkbox"
|
||||
value="${category.id}"
|
||||
id="category_${category.id}"
|
||||
${isChecked ? 'checked' : ''}>
|
||||
<label class="form-check-label w-100" for="category_${category.id}">
|
||||
${category.name}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
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 = '<span class="spinner-border spinner-border-sm me-2"></span>Применение...';
|
||||
|
||||
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 = '<i class="bi bi-check2-circle"></i> Применить';
|
||||
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 = '<span class="spinner-border spinner-border-sm me-2"></span>Очистка...';
|
||||
|
||||
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 = '<i class="bi bi-trash3"></i> Очистить все категории';
|
||||
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 = `
|
||||
<div id="${toastId}" class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-check-circle me-2"></i>${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -108,7 +108,17 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<!-- Показывать по -->
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="per_page" class="form-label"><i class="bi bi-list-ol"></i> Показывать по:</label>
|
||||
<select class="form-select" id="per_page" name="per_page" onchange="this.form.submit()">
|
||||
<option value="20" {% if filters.current.per_page == '20' %}selected{% endif %}>20</option>
|
||||
<option value="50" {% if filters.current.per_page == '50' %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if filters.current.per_page == '100' %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label d-none d-md-block"> </label>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@@ -125,10 +135,71 @@
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
<!-- Инструментальная панель для пакетных действий -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{% if is_paginated %}
|
||||
<span class="text-muted">
|
||||
Показано {{ page_obj.start_index }}–{{ page_obj.end_index }} из {{ page_obj.paginator.count }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Всего: {{ items|length }}</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Dropdown для выбора всех отфильтрованных -->
|
||||
<div class="btn-group" id="select-all-dropdown-group" style="display: none;">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-check2-square"></i> Выбрать
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" onclick="document.getElementById('select-all-checkbox').click(); return false;">
|
||||
<i class="bi bi-check2"></i> Все на странице
|
||||
</a>
|
||||
</li>
|
||||
{% if is_paginated %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" onclick="window.BatchSelection.selectAllFiltered(); return false;">
|
||||
<i class="bi bi-check2-all"></i> Все отфильтрованные ({{ page_obj.paginator.count }})
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" onclick="window.BatchSelection.clearSelection(); return false;">
|
||||
<i class="bi bi-x-square"></i> Снять выбор
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-secondary" id="batch-actions-btn" disabled>
|
||||
<i class="bi bi-gear-fill"></i> Действия над выбранными (<span id="selection-count">0</span>)
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
|
||||
id="batch-actions-dropdown" data-bs-toggle="dropdown" aria-expanded="false" disabled>
|
||||
<span class="visually-hidden">Открыть меню</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="batch-actions-dropdown">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" id="bulk-category-action">
|
||||
<i class="bi bi-bookmark-fill"></i> Изменить категории
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th style="width: 50px;">
|
||||
<input type="checkbox" id="select-all-checkbox" class="form-check-input"
|
||||
title="Выбрать все на странице">
|
||||
</th>
|
||||
<th style="width: 80px;">Фото</th>
|
||||
<th>Название</th>
|
||||
<th style="width: 120px;">Артикул</th>
|
||||
@@ -144,6 +215,14 @@
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" name="selected_items"
|
||||
value="{{ item.item_type }}:{{ item.pk }}"
|
||||
class="form-check-input item-checkbox"
|
||||
data-item-type="{{ item.item_type }}"
|
||||
data-item-id="{{ item.pk }}"
|
||||
data-item-name="{{ item.name }}">
|
||||
</td>
|
||||
<td>
|
||||
{% if item.photos.all %}
|
||||
{% with photo=item.photos.first %}
|
||||
@@ -318,4 +397,78 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для массового изменения категорий -->
|
||||
<div class="modal fade" id="bulkCategoryModal" tabindex="-1" aria-labelledby="bulkCategoryModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="bulkCategoryModalLabel">
|
||||
<i class="bi bi-bookmark-fill"></i> Изменить категории для выбранных товаров
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Информация о выбранных товарах -->
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle"></i> <strong>Выбрано:</strong> <span id="selectedItemsCount">0</span> товаров
|
||||
<span id="selectedItemsBreakdown" class="ms-2 text-muted"></span>
|
||||
</div>
|
||||
|
||||
<!-- Опции применения -->
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="clearExistingCategoriesToggle" role="switch">
|
||||
<label class="form-check-label" for="clearExistingCategoriesToggle">
|
||||
<i class="bi bi-trash"></i> Очистить существующие категории перед сохранением
|
||||
</label>
|
||||
<div class="form-text text-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
При включении все текущие категории товаров будут удалены, а затем применены только выбранные ниже
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Выбор категорий -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Выберите категории:</label>
|
||||
|
||||
<!-- Поиск по категориям -->
|
||||
<input type="text" class="form-control mb-2" id="categorySearchInput"
|
||||
placeholder="Поиск категорий...">
|
||||
|
||||
<!-- Список категорий -->
|
||||
<div id="categoryListContainer" style="max-height: 300px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 0.25rem; padding: 0.5rem;">
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
<div class="mt-2 text-muted">Загрузка категорий...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сообщения об ошибках -->
|
||||
<div id="bulkCategoryError" class="alert alert-danger d-none" role="alert"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger me-auto" id="clearAllCategoriesBtn">
|
||||
<i class="bi bi-trash3"></i> Очистить все категории
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="applyBulkCategoriesBtn" disabled>
|
||||
<i class="bi bi-check2-circle"></i> Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% load static %}
|
||||
<script src="{% static 'products/js/batch-selection.js' %}?v=1.1"></script>
|
||||
<script src="{% static 'products/js/bulk-category-modal.js' %}?v=1.0"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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/<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'),
|
||||
@@ -58,6 +53,8 @@ urlpatterns = [
|
||||
path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'),
|
||||
path('api/products/<int:pk>/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/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||
|
||||
@@ -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