Add bulk photo deletion feature for products.
Allows selecting and deleting multiple photos at once via checkboxes and an AJAX endpoint. Key features: - Checkboxes next to each photo in edit form - Delete button that shows only when photos are selected - AJAX request with JSON payload and success confirmation - DOM removal and counter update after deletion - Uses existing ImageProcessor cleanup logic Files changed: - product_form.html: Added checkboxes and delete button with JS handler - photo_management.py: Added product_photos_delete_bulk AJAX view - urls.py: Added /product/photos/delete-bulk/ endpoint - views/__init__.py: Exported new function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -182,11 +182,23 @@
|
||||
<!-- Существующие фотографии (только при редактировании) -->
|
||||
{% if object and product_photos %}
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-3">Текущие фотографии ({{ photos_count }})</h6>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Текущие фотографии (<span id="photos-count">{{ photos_count }}</span>)</h6>
|
||||
<button type="button" id="delete-selected-btn" class="btn btn-danger btn-sm" style="display: none;">
|
||||
🗑️ Удалить отмеченные (<span id="selected-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-2 mb-3" id="photos-grid">
|
||||
{% for photo in product_photos %}
|
||||
<div class="col-md-3 col-sm-4 col-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="col-md-3 col-sm-4 col-6 photo-card" data-photo-id="{{ photo.pk }}">
|
||||
<div class="card shadow-sm h-100 position-relative">
|
||||
<!-- Чекбокс для выбора -->
|
||||
<div class="position-absolute" style="top: 5px; left: 5px; z-index: 10;">
|
||||
<input type="checkbox" class="form-check-input photo-checkbox"
|
||||
data-photo-id="{{ photo.pk }}"
|
||||
style="width: 20px; height: 20px; cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<!-- Кликабельное фото для открытия модального окна -->
|
||||
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
|
||||
data-bs-toggle="modal"
|
||||
@@ -262,6 +274,99 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript для массового удаления фотографий -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteBtn = document.getElementById('delete-selected-btn');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const photosCount = document.getElementById('photos-count');
|
||||
const checkboxes = document.querySelectorAll('.photo-checkbox');
|
||||
|
||||
// Обновляем счётчик выбранных и видимость кнопки
|
||||
function updateUI() {
|
||||
const checked = document.querySelectorAll('.photo-checkbox:checked').length;
|
||||
selectedCount.textContent = checked;
|
||||
deleteBtn.style.display = checked > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Обработчик для каждого чекбокса
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updateUI);
|
||||
});
|
||||
|
||||
// Обработчик кнопки удаления
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
const checked = document.querySelectorAll('.photo-checkbox:checked');
|
||||
if (checked.length === 0) return;
|
||||
|
||||
const photoIds = Array.from(checked).map(cb => cb.dataset.photoId);
|
||||
const count = photoIds.length;
|
||||
|
||||
if (!confirm(`Вы уверены что хотите удалить ${count} фото? Это действие необратимо.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.innerHTML = '⏳ Удаляю...';
|
||||
|
||||
// AJAX запрос на удаление
|
||||
fetch('{% url "products:product-photos-delete-bulk" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
photo_ids: photoIds
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Удаляем карточки фотографий из DOM
|
||||
photoIds.forEach(photoId => {
|
||||
const card = document.querySelector(`[data-photo-id="${photoId}"]`);
|
||||
if (card) card.remove();
|
||||
});
|
||||
|
||||
// Обновляем счётчик фотографий
|
||||
const newCount = parseInt(photosCount.textContent) - count;
|
||||
photosCount.textContent = newCount;
|
||||
|
||||
// Скрываем блок если фотографий больше нет
|
||||
if (newCount === 0) {
|
||||
document.querySelector('[id="photos-count"]').closest('.mb-3').parentElement.style.display = 'none';
|
||||
}
|
||||
|
||||
// Показываем сообщение об успехе
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show';
|
||||
alert.innerHTML = `✓ ${data.deleted} фото успешно удалено!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
document.querySelector('.mb-4.p-3.bg-light.rounded').insertAdjacentElement('beforebegin', alert);
|
||||
|
||||
// Скрываем кнопку
|
||||
deleteBtn.style.display = 'none';
|
||||
selectedCount.textContent = '0';
|
||||
} else {
|
||||
throw new Error(data.error || 'Неизвестная ошибка');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Ошибка при удалении: ' + error.message);
|
||||
console.error(error);
|
||||
})
|
||||
.finally(() => {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.innerHTML = '🗑️ Удалить отмеченные (<span id="selected-count">' + selectedCount.textContent + '</span>)';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Поле для загрузки новых фотографий -->
|
||||
|
||||
@@ -24,6 +24,7 @@ urlpatterns = [
|
||||
path('product/photo/<int:pk>/set-main/', views.product_photo_set_main, name='product-photo-set-main'),
|
||||
path('product/photo/<int:pk>/move-up/', views.product_photo_move_up, name='product-photo-move-up'),
|
||||
path('product/photo/<int:pk>/move-down/', views.product_photo_move_down, name='product-photo-move-down'),
|
||||
path('product/photos/delete-bulk/', views.product_photos_delete_bulk, name='product-photos-delete-bulk'),
|
||||
|
||||
# CRUD URLs for ProductKit (комплекты/букеты)
|
||||
path('kit/create/', views.ProductKitCreateView.as_view(), name='productkit-create'),
|
||||
|
||||
@@ -12,6 +12,7 @@ from .photo_management import (
|
||||
product_photo_set_main,
|
||||
product_photo_move_up,
|
||||
product_photo_move_down,
|
||||
product_photos_delete_bulk,
|
||||
)
|
||||
|
||||
# Управление фотографиями (ProductKit)
|
||||
@@ -105,6 +106,7 @@ __all__ = [
|
||||
'product_photo_set_main',
|
||||
'product_photo_move_up',
|
||||
'product_photo_move_down',
|
||||
'product_photos_delete_bulk',
|
||||
|
||||
# Управление фотографиями ProductKit
|
||||
'productkit_photo_delete',
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
Универсальные функции для управления фотографиями товаров, комплектов и категорий.
|
||||
Устраняет дублирование кода для операций: delete, set_main, move_up, move_down.
|
||||
"""
|
||||
import json
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
|
||||
from ..models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||
|
||||
@@ -308,3 +313,71 @@ def category_photo_move_down(request, pk):
|
||||
parent_attr='category',
|
||||
permission='products.change_productcategory'
|
||||
)
|
||||
|
||||
|
||||
# ====================================
|
||||
# AJAX Endpoints для массового удаления
|
||||
# ====================================
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@login_required
|
||||
def product_photos_delete_bulk(request):
|
||||
"""
|
||||
AJAX endpoint для массового удаления фотографий товара.
|
||||
|
||||
Ожидает JSON: {photo_ids: [1, 2, 3]}
|
||||
Возвращает JSON: {success: true, deleted: 3} или {success: false, error: "..."}
|
||||
"""
|
||||
# Проверка прав доступа
|
||||
if not request.user.has_perm('products.change_product'):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'У вас нет прав для удаления фотографий'
|
||||
}, status=403)
|
||||
|
||||
try:
|
||||
# Получаем список photo_ids из JSON тела запроса
|
||||
data = json.loads(request.body)
|
||||
photo_ids = data.get('photo_ids', [])
|
||||
|
||||
if not photo_ids or not isinstance(photo_ids, list):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Неверный формат: требуется список photo_ids'
|
||||
}, status=400)
|
||||
|
||||
# Удаляем фотографии
|
||||
deleted_count = 0
|
||||
for photo_id in photo_ids:
|
||||
try:
|
||||
photo = ProductPhoto.objects.get(pk=photo_id)
|
||||
photo.delete() # Это вызовет ImageProcessor.delete_all_versions()
|
||||
deleted_count += 1
|
||||
except ProductPhoto.DoesNotExist:
|
||||
# Если фото не найдена, просто пропускаем
|
||||
continue
|
||||
except Exception as e:
|
||||
# Логируем ошибку но продолжаем удаление остальных
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error deleting photo {photo_id}: {str(e)}", exc_info=True)
|
||||
continue
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'deleted': deleted_count
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Неверный JSON формат'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Bulk photo deletion error: {str(e)}", exc_info=True)
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Ошибка сервера: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
Reference in New Issue
Block a user