Добавлен асинхронный импорт товаров с параллельной загрузкой фото + исправлен баг со счётчиком SKU
- Реализован импорт Product из CSV/XLSX через Celery с прогресс-баром - Параллельная загрузка фото товаров с внешних URL (масштабируемость до 500+ товаров) - Добавлена модель ProductImportJob для отслеживания статуса импорта - Создан таск download_product_photo_async для загрузки фото в фоне - Интеграция с существующим ImageProcessor (синхронная обработка через use_async=False) - Добавлены view и template для импорта с real-time обновлением через AJAX FIX: Исправлен баг со счётчиком SKU - инкремент только после успешного сохранения - Добавлен SKUCounter.peek_next_value() - возвращает следующий номер БЕЗ инкремента - Добавлен SKUCounter.increment_counter() - инкрементирует счётчик - generate_product_sku() использует peek_next_value() вместо get_next_value() - Добавлен post_save сигнал increment_sku_counter_after_save() для инкремента после создания - Предотвращает пропуски номеров при ошибках валидации (например cost_price NULL) FIX: Исправлена ошибка с is_main в ProductPhoto - ProductPhoto не имеет поля is_main, используется только order - Первое фото (order=0) автоматически считается главным - Удалён параметр is_main из download_product_photo_async и _collect_photo_tasks Изменены файлы: - products/models/base.py - методы для управления счётчиком SKU - products/models/import_job.py - модель для отслеживания импорта - products/services/import_export.py - сервис импорта с поддержкой Celery - products/tasks.py - таски для асинхронного импорта и загрузки фото - products/signals.py - сигнал для инкремента счётчика после сохранения - products/utils/sku_generator.py - использование peek_next_value() - products/views/product_import_views.py - view для импорта - products/templates/products/product_import*.html - UI для импорта - docker/entrypoint.sh - настройка Celery worker (concurrency=4) - requirements.txt - добавлен requests для загрузки фото
This commit is contained in:
@@ -41,6 +41,14 @@ from .product_views import (
|
||||
CombinedProductListView,
|
||||
)
|
||||
|
||||
# Импорт товаров
|
||||
from .product_import_views import (
|
||||
product_import_view,
|
||||
product_import_status_view,
|
||||
product_import_status_api,
|
||||
download_import_errors,
|
||||
)
|
||||
|
||||
# CRUD представления для ProductKit
|
||||
from .productkit_views import (
|
||||
ProductKitListView,
|
||||
@@ -153,6 +161,10 @@ __all__ = [
|
||||
'ProductDeleteView',
|
||||
'CombinedProductListView',
|
||||
|
||||
# Product Import/Export
|
||||
'product_import_view',
|
||||
'download_import_errors',
|
||||
|
||||
# ProductKit CRUD
|
||||
'ProductKitListView',
|
||||
'ProductKitCreateView',
|
||||
|
||||
166
myproject/products/views/product_import_views.py
Normal file
166
myproject/products/views/product_import_views.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
View для импорта товаров из CSV/XLSX файлов.
|
||||
"""
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import connection
|
||||
import os
|
||||
|
||||
from ..models import ProductImportJob
|
||||
from ..tasks import import_products_async
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def product_import_view(request):
|
||||
"""
|
||||
Импорт товаров из CSV/XLSX файла (асинхронно через Celery).
|
||||
|
||||
GET: показывает форму загрузки
|
||||
POST: сохраняет файл и запускает асинхронную задачу
|
||||
"""
|
||||
if request.method == "POST":
|
||||
uploaded_file = request.FILES.get('file')
|
||||
update_existing = request.POST.get('update_existing') == 'on'
|
||||
|
||||
if not uploaded_file:
|
||||
messages.error(request, "Пожалуйста, выберите файл для загрузки.")
|
||||
return render(request, 'products/product_import.html')
|
||||
|
||||
try:
|
||||
# Сохраняем файл во временную директорию
|
||||
file_name = uploaded_file.name
|
||||
temp_dir = 'products/import_temp'
|
||||
file_path = os.path.join(temp_dir, f"{request.user.id}_{file_name}")
|
||||
|
||||
# Создаём директорию если не существует
|
||||
full_dir = os.path.dirname(default_storage.path(file_path))
|
||||
os.makedirs(full_dir, exist_ok=True)
|
||||
|
||||
# Сохраняем файл
|
||||
saved_path = default_storage.save(file_path, uploaded_file)
|
||||
|
||||
# Получаем schema_name для мультитенантности
|
||||
schema_name = connection.schema_name
|
||||
|
||||
# Создаём задачу импорта
|
||||
job = ProductImportJob.objects.create(
|
||||
task_id='', # Заполнится после запуска
|
||||
user=request.user,
|
||||
file_name=file_name,
|
||||
file_path=saved_path,
|
||||
update_existing=update_existing,
|
||||
status='pending'
|
||||
)
|
||||
|
||||
# Запускаем асинхронную задачу Celery
|
||||
task = import_products_async.delay(
|
||||
job_id=job.id,
|
||||
file_path=saved_path,
|
||||
update_existing=update_existing,
|
||||
schema_name=schema_name
|
||||
)
|
||||
|
||||
# Обновляем task_id
|
||||
job.task_id = task.id
|
||||
job.save(update_fields=['task_id'])
|
||||
|
||||
messages.success(request, f"Импорт запущен! Отслеживайте прогресс...")
|
||||
|
||||
# Редирект на страницу статуса
|
||||
return redirect('products:product-import-status', job_id=job.id)
|
||||
|
||||
except Exception as exc:
|
||||
messages.error(request, f"Ошибка запуска импорта: {exc}")
|
||||
return render(request, 'products/product_import.html')
|
||||
|
||||
# GET request
|
||||
return render(request, 'products/product_import.html')
|
||||
|
||||
|
||||
@login_required
|
||||
def product_import_status_view(request, job_id):
|
||||
"""
|
||||
Страница отслеживания статуса импорта.
|
||||
"""
|
||||
job = get_object_or_404(ProductImportJob, id=job_id)
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
}
|
||||
|
||||
return render(request, 'products/product_import_status.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def product_import_status_api(request, job_id):
|
||||
"""
|
||||
API endpoint для получения статуса импорта (для AJAX polling).
|
||||
"""
|
||||
job = get_object_or_404(ProductImportJob, id=job_id)
|
||||
|
||||
data = {
|
||||
'status': job.status,
|
||||
'progress_percent': job.progress_percent,
|
||||
'total_rows': job.total_rows,
|
||||
'processed_rows': job.processed_rows,
|
||||
'created_count': job.created_count,
|
||||
'updated_count': job.updated_count,
|
||||
'skipped_count': job.skipped_count,
|
||||
'errors_count': job.errors_count,
|
||||
'error_message': job.error_message,
|
||||
'is_finished': job.status in ['completed', 'failed'],
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
@login_required
|
||||
def download_import_errors(request):
|
||||
"""
|
||||
Скачивание файла с ошибками импорта.
|
||||
Генерирует файл с невалидными строками из последнего импорта.
|
||||
"""
|
||||
import_errors = request.session.get('import_errors', [])
|
||||
|
||||
if not import_errors:
|
||||
messages.error(request, "Нет ошибок для скачивания.")
|
||||
return redirect('products:product_import')
|
||||
|
||||
# Создаём временный importer для генерации файла
|
||||
# (нужны original_headers и original_rows, но их нет в сессии)
|
||||
# Упрощённый вариант: возвращаем просто список ошибок в CSV
|
||||
|
||||
import csv
|
||||
from io import StringIO
|
||||
from django.utils import timezone
|
||||
|
||||
output = StringIO()
|
||||
output.write('\ufeff') # BOM
|
||||
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Строка', 'Название', 'Артикул', 'Причина ошибки'])
|
||||
|
||||
for error in import_errors:
|
||||
writer.writerow([
|
||||
error.get('row', ''),
|
||||
error.get('name', ''),
|
||||
error.get('sku', ''),
|
||||
error.get('reason', ''),
|
||||
])
|
||||
|
||||
content = output.getvalue().encode('utf-8')
|
||||
|
||||
response = HttpResponse(content, content_type='text/csv; charset=utf-8')
|
||||
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
|
||||
response['Content-Disposition'] = f'attachment; filename="product_import_errors_{timestamp}.csv"'
|
||||
|
||||
# Очищаем сессию
|
||||
request.session.pop('import_errors', None)
|
||||
request.session.pop('import_has_error_file', None)
|
||||
|
||||
return response
|
||||
Reference in New Issue
Block a user