Добавлен асинхронный импорт товаров с параллельной загрузкой фото + исправлен баг со счётчиком 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:
616
myproject/products/services/import_export.py
Normal file
616
myproject/products/services/import_export.py
Normal file
@@ -0,0 +1,616 @@
|
||||
"""
|
||||
Сервис для импорта и экспорта товаров.
|
||||
|
||||
Этот модуль содержит логику импорта/экспорта товаров в различных форматах (CSV, Excel).
|
||||
Разделение на отдельный модуль улучшает организацию кода и следует принципам SRP.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from django.utils import timezone
|
||||
from ..models import Product
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
load_workbook = None
|
||||
|
||||
import re
|
||||
|
||||
|
||||
class ProductImporter:
|
||||
"""
|
||||
Универсальный импорт товаров из CSV/XLSX.
|
||||
|
||||
Поддерживаемые форматы:
|
||||
- CSV (UTF-8, заголовок в первой строке)
|
||||
- XLSX (первая строка — заголовки)
|
||||
|
||||
Алгоритм:
|
||||
- Читаем файл → получаем headers и list[dict] строк
|
||||
- По headers строим маппинг на поля Product
|
||||
- Для каждой строки:
|
||||
- пропускаем полностью пустые строки
|
||||
- ищем товар по sku, потом по name
|
||||
- если update_existing=False и товар найден → пропуск
|
||||
- если update_existing=True → обновляем найденного
|
||||
- если не найден → создаём нового
|
||||
- Вся валидация делается через Product.full_clean()
|
||||
"""
|
||||
|
||||
FIELD_ALIASES = {
|
||||
"name": ["name", "название", "наименование", "товар", "продукт", "имя"],
|
||||
"sku": ["sku", "артикул", "код", "code", "article"],
|
||||
"price": ["price", "цена", "ценапродажи", "стоимость", "pricesale"],
|
||||
"description": ["description", "описание", "desc"],
|
||||
"short_description": ["shortdescription", "краткоеописание", "короткоеописание", "краткое"],
|
||||
"unit": ["unit", "единица", "ед", "едизм", "единицаизмерения"],
|
||||
"cost_price": ["costprice", "себестоимость", "закупочнаяцена", "cost"],
|
||||
"sale_price": ["saleprice", "ценасоскидкой", "скидка", "discount", "discountprice"],
|
||||
"images": ["images", "изображения", "фото", "картинки", "photos", "pictures", "image"],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.errors = []
|
||||
self.success_count = 0
|
||||
self.update_count = 0
|
||||
self.skip_count = 0
|
||||
# Сохраняем исходные данные для генерации error-файла
|
||||
self.original_headers = []
|
||||
self.original_rows = []
|
||||
self.file_format = None
|
||||
self.real_errors = []
|
||||
# Для Celery: собираем задачи загрузки фото
|
||||
self.photo_tasks = []
|
||||
|
||||
def import_from_file(self, file, update_existing: bool = False, progress_callback=None, skip_images: bool = False, schema_name: str = None) -> dict:
|
||||
"""
|
||||
Импорт товаров из загруженного файла.
|
||||
|
||||
Args:
|
||||
file: UploadedFile
|
||||
update_existing: обновлять ли существующих товаров (по sku/name)
|
||||
progress_callback: callback для обновления прогресса (current, total, created, updated, skipped, errors)
|
||||
skip_images: пропустить загрузку фото (для Celery)
|
||||
schema_name: схема тенанта (для Celery задач фото)
|
||||
|
||||
Returns:
|
||||
dict: результат импорта
|
||||
"""
|
||||
file_format = self._detect_format(file)
|
||||
|
||||
if file_format is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Неподдерживаемый формат файла. Ожидается CSV или XLSX.",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [{"row": None, "reason": "Unsupported file type"}],
|
||||
}
|
||||
|
||||
if file_format == "xlsx" and load_workbook is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Для импорта XLSX необходим пакет openpyxl. Установите его и повторите попытку.",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [{"row": None, "reason": "openpyxl is not installed"}],
|
||||
}
|
||||
|
||||
# Сохраняем формат файла
|
||||
self.file_format = file_format
|
||||
|
||||
try:
|
||||
if file_format == "csv":
|
||||
headers, rows = self._read_csv(file)
|
||||
else:
|
||||
headers, rows = self._read_xlsx(file)
|
||||
|
||||
# Сохраняем исходные данные
|
||||
self.original_headers = headers
|
||||
self.original_rows = rows
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Ошибка чтения файла: {exc}",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [{"row": None, "reason": str(exc)}],
|
||||
}
|
||||
|
||||
if not headers:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "В файле не найдены заголовки.",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [{"row": None, "reason": "Empty header row"}],
|
||||
}
|
||||
|
||||
mapping = self._build_mapping(headers)
|
||||
|
||||
# Минимальное требование: name ИЛИ sku
|
||||
if not any(field in mapping for field in ("name", "sku")):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Не удалось сопоставить обязательные поля (название или артикул).",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": len(rows),
|
||||
"errors": [
|
||||
{
|
||||
"row": None,
|
||||
"reason": "No required fields (name/sku) mapped from headers",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
for index, row in enumerate(rows, start=2): # первая строка — заголовки
|
||||
self._process_row(index, row, mapping, update_existing, skip_images, schema_name)
|
||||
|
||||
# Обновляем прогресс (если есть callback)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
current=index - 1, # текущая строка
|
||||
total=len(rows),
|
||||
created=self.success_count,
|
||||
updated=self.update_count,
|
||||
skipped=self.skip_count,
|
||||
errors=self.errors
|
||||
)
|
||||
|
||||
total_errors = len(self.errors)
|
||||
real_error_count = len(self.real_errors)
|
||||
success = (self.success_count + self.update_count) > 0
|
||||
|
||||
if success and total_errors == 0:
|
||||
message = "Импорт завершён успешно."
|
||||
elif success and total_errors > 0:
|
||||
message = "Импорт завершён с ошибками."
|
||||
else:
|
||||
message = "Не удалось импортировать данные."
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": message,
|
||||
"created": self.success_count,
|
||||
"updated": self.update_count,
|
||||
"skipped": self.skip_count,
|
||||
"errors": self.errors,
|
||||
"real_errors": self.real_errors,
|
||||
"real_error_count": real_error_count,
|
||||
"photo_tasks": self.photo_tasks, # Для Celery
|
||||
}
|
||||
|
||||
def _detect_format(self, file) -> str | None:
|
||||
name = (getattr(file, "name", None) or "").lower()
|
||||
if name.endswith(".csv"):
|
||||
return "csv"
|
||||
if name.endswith(".xlsx") or name.endswith(".xls"):
|
||||
return "xlsx"
|
||||
return None
|
||||
|
||||
def _read_csv(self, file):
|
||||
file.seek(0)
|
||||
raw = file.read()
|
||||
if isinstance(raw, bytes):
|
||||
text = raw.decode("utf-8-sig")
|
||||
else:
|
||||
text = raw
|
||||
f = io.StringIO(text)
|
||||
reader = csv.DictReader(f)
|
||||
headers = reader.fieldnames or []
|
||||
rows = list(reader)
|
||||
return headers, rows
|
||||
|
||||
def _read_xlsx(self, file):
|
||||
file.seek(0)
|
||||
wb = load_workbook(file, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
|
||||
headers = []
|
||||
rows = []
|
||||
first_row = True
|
||||
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
if first_row:
|
||||
headers = [str(v).strip() if v is not None else "" for v in row]
|
||||
first_row = False
|
||||
continue
|
||||
|
||||
if not any(row):
|
||||
continue
|
||||
|
||||
row_dict = {}
|
||||
for idx, value in enumerate(row):
|
||||
if idx < len(headers):
|
||||
header = headers[idx] or f"col_{idx}"
|
||||
row_dict[header] = value
|
||||
rows.append(row_dict)
|
||||
|
||||
return headers, rows
|
||||
|
||||
def _normalize_header(self, header: str) -> str:
|
||||
if header is None:
|
||||
return ""
|
||||
cleaned = "".join(ch for ch in str(header).strip().lower() if ch.isalnum())
|
||||
return cleaned
|
||||
|
||||
def _build_mapping(self, headers):
|
||||
mapping = {}
|
||||
normalized_aliases = {
|
||||
field: {self._normalize_header(a) for a in aliases}
|
||||
for field, aliases in self.FIELD_ALIASES.items()
|
||||
}
|
||||
|
||||
for header in headers:
|
||||
norm = self._normalize_header(header)
|
||||
if not norm:
|
||||
continue
|
||||
for field, alias_set in normalized_aliases.items():
|
||||
if norm in alias_set and field not in mapping:
|
||||
mapping[field] = header
|
||||
break
|
||||
|
||||
return mapping
|
||||
|
||||
def _clean_value(self, value):
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
def _parse_decimal(self, value: str) -> Decimal | None:
|
||||
"""
|
||||
Парсинг decimal значения (цена, себестоимость).
|
||||
Обрабатывает разные форматы: запятая/точка, пробелы.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
# Убираем пробелы
|
||||
value = value.replace(' ', '')
|
||||
|
||||
# Заменяем запятую на точку
|
||||
value = value.replace(',', '.')
|
||||
|
||||
try:
|
||||
return Decimal(value)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
def _process_row(self, row_number: int, row: dict, mapping: dict, update_existing: bool, skip_images: bool = False, schema_name: str = None):
|
||||
name = self._clean_value(row.get(mapping.get("name", ""), ""))
|
||||
sku = self._clean_value(row.get(mapping.get("sku", ""), ""))
|
||||
price_raw = self._clean_value(row.get(mapping.get("price", ""), ""))
|
||||
description = self._clean_value(row.get(mapping.get("description", ""), ""))
|
||||
short_description = self._clean_value(row.get(mapping.get("short_description", ""), ""))
|
||||
unit = self._clean_value(row.get(mapping.get("unit", ""), ""))
|
||||
cost_price_raw = self._clean_value(row.get(mapping.get("cost_price", ""), ""))
|
||||
sale_price_raw = self._clean_value(row.get(mapping.get("sale_price", ""), ""))
|
||||
images_raw = self._clean_value(row.get(mapping.get("images", ""), ""))
|
||||
|
||||
# Пропускаем полностью пустые строки
|
||||
if not any([name, sku, price_raw, description]):
|
||||
self.skip_count += 1
|
||||
return
|
||||
|
||||
# Минимальное требование: name ИЛИ sku
|
||||
if not name and not sku:
|
||||
self.skip_count += 1
|
||||
error_record = {
|
||||
"row": row_number,
|
||||
"name": name or None,
|
||||
"sku": sku or None,
|
||||
"reason": "Требуется хотя бы одно: название или артикул",
|
||||
}
|
||||
self.errors.append(error_record)
|
||||
self.real_errors.append(error_record)
|
||||
return
|
||||
|
||||
# Парсим цены
|
||||
price = self._parse_decimal(price_raw)
|
||||
cost_price = self._parse_decimal(cost_price_raw)
|
||||
sale_price = self._parse_decimal(sale_price_raw)
|
||||
|
||||
# Цена обязательна
|
||||
if price is None:
|
||||
self.skip_count += 1
|
||||
error_record = {
|
||||
"row": row_number,
|
||||
"name": name or None,
|
||||
"sku": sku or None,
|
||||
"reason": "Требуется корректная цена (price)",
|
||||
}
|
||||
self.errors.append(error_record)
|
||||
self.real_errors.append(error_record)
|
||||
return
|
||||
|
||||
# Единица измерения по умолчанию
|
||||
if not unit:
|
||||
unit = 'шт'
|
||||
|
||||
# Валидация единицы измерения
|
||||
valid_units = [choice[0] for choice in Product.UNIT_CHOICES]
|
||||
if unit not in valid_units:
|
||||
unit = 'шт' # fallback
|
||||
|
||||
# Пытаемся найти существующего товара
|
||||
existing = None
|
||||
if sku:
|
||||
existing = Product.objects.filter(sku=sku, status='active').first()
|
||||
if existing is None and name:
|
||||
existing = Product.objects.filter(name=name, status='active').first()
|
||||
|
||||
if existing and not update_existing:
|
||||
self.skip_count += 1
|
||||
self.errors.append({
|
||||
"row": row_number,
|
||||
"name": name or None,
|
||||
"sku": sku or None,
|
||||
"reason": "Товар с таким артикулом/названием уже существует, обновление отключено.",
|
||||
})
|
||||
return
|
||||
|
||||
if existing and update_existing:
|
||||
# Обновление существующего товара
|
||||
if name:
|
||||
existing.name = name
|
||||
if sku:
|
||||
existing.sku = sku
|
||||
if description:
|
||||
existing.description = description
|
||||
if short_description:
|
||||
existing.short_description = short_description
|
||||
if unit:
|
||||
existing.unit = unit
|
||||
|
||||
existing.price = price
|
||||
if cost_price is not None:
|
||||
existing.cost_price = cost_price
|
||||
if sale_price is not None:
|
||||
existing.sale_price = sale_price
|
||||
|
||||
try:
|
||||
existing.full_clean()
|
||||
existing.save()
|
||||
self.update_count += 1
|
||||
|
||||
# Обрабатываем изображения (если есть)
|
||||
if images_raw:
|
||||
if skip_images:
|
||||
# Для Celery: собираем задачи загрузки фото
|
||||
self._collect_photo_tasks(existing, images_raw)
|
||||
else:
|
||||
# Синхронно загружаем фото
|
||||
self._process_product_images(existing, images_raw)
|
||||
|
||||
except Exception as exc:
|
||||
self.skip_count += 1
|
||||
error_record = {
|
||||
"row": row_number,
|
||||
"name": name or None,
|
||||
"sku": sku or None,
|
||||
"reason": str(exc),
|
||||
}
|
||||
self.errors.append(error_record)
|
||||
self.real_errors.append(error_record)
|
||||
return
|
||||
|
||||
# Создание нового товара
|
||||
product = Product(
|
||||
name=name or f"Товар {sku}", # fallback если нет имени
|
||||
sku=sku or None,
|
||||
description=description or "",
|
||||
short_description=short_description or "",
|
||||
unit=unit,
|
||||
price=price,
|
||||
cost_price=cost_price or 0, # Устанавливаем 0 вместо None (для CostPriceHistory)
|
||||
sale_price=sale_price,
|
||||
status='active',
|
||||
)
|
||||
|
||||
try:
|
||||
product.full_clean()
|
||||
product.save()
|
||||
self.success_count += 1
|
||||
|
||||
# Обрабатываем изображения (если есть)
|
||||
if images_raw:
|
||||
if skip_images:
|
||||
# Для Celery: собираем задачи загрузки фото
|
||||
self._collect_photo_tasks(product, images_raw)
|
||||
else:
|
||||
# Синхронно загружаем фото
|
||||
self._process_product_images(product, images_raw)
|
||||
|
||||
except Exception as exc:
|
||||
self.skip_count += 1
|
||||
error_record = {
|
||||
"row": row_number,
|
||||
"name": name or None,
|
||||
"sku": sku or None,
|
||||
"reason": str(exc),
|
||||
}
|
||||
self.errors.append(error_record)
|
||||
self.real_errors.append(error_record)
|
||||
|
||||
def _collect_photo_tasks(self, product, images_raw: str):
|
||||
"""
|
||||
Собираем задачи загрузки фото для параллельной обработки через Celery.
|
||||
|
||||
Args:
|
||||
product: Объект Product
|
||||
images_raw: Строка с URL изображений
|
||||
"""
|
||||
# Разбиваем на отдельные URL
|
||||
urls = []
|
||||
for line in images_raw.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Если в строке несколько URL через запятую
|
||||
for url in line.split(','):
|
||||
url = url.strip()
|
||||
if url.startswith('http'):
|
||||
urls.append(url)
|
||||
|
||||
if not urls:
|
||||
return
|
||||
|
||||
# Создаём задачи для каждого URL
|
||||
for idx, url in enumerate(urls):
|
||||
# FIX: ProductPhoto не имеет is_main, используется только order
|
||||
task = {
|
||||
'product_id': product.id,
|
||||
'url': url,
|
||||
'order': idx, # Первое фото (order=0) автоматически главное
|
||||
}
|
||||
self.photo_tasks.append(task)
|
||||
|
||||
def _process_product_images(self, product, images_raw: str):
|
||||
"""
|
||||
Обработка изображений товара из URL.
|
||||
|
||||
Поддерживаемые форматы:
|
||||
- Один URL: https://example.com/image.jpg
|
||||
- Несколько URL через перенос строки или запятую
|
||||
|
||||
Args:
|
||||
product: Объект Product
|
||||
images_raw: Строка с URL изображений
|
||||
"""
|
||||
import requests
|
||||
from django.core.files.base import ContentFile
|
||||
from ..models import ProductPhoto
|
||||
import urllib.parse
|
||||
|
||||
# Разбиваем на отдельные URL
|
||||
urls = []
|
||||
for line in images_raw.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Если в строке несколько URL через запятую
|
||||
for url in line.split(','):
|
||||
url = url.strip()
|
||||
if url.startswith('http'):
|
||||
urls.append(url)
|
||||
|
||||
if not urls:
|
||||
return
|
||||
|
||||
# Скачиваем и сохраняем каждое изображение
|
||||
for idx, url in enumerate(urls):
|
||||
try:
|
||||
# Скачиваем изображение
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Получаем имя файла из URL
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
filename = parsed_url.path.split('/')[-1]
|
||||
|
||||
# Создаём ProductPhoto
|
||||
# FIX: ProductPhoto не имеет is_main, используется только order
|
||||
photo = ProductPhoto(
|
||||
product=product,
|
||||
order=idx # Первое фото (order=0) автоматически главное
|
||||
)
|
||||
|
||||
# Сохраняем файл
|
||||
photo.image.save(
|
||||
filename,
|
||||
ContentFile(response.content),
|
||||
save=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Игнорируем ошибки загрузки изображений
|
||||
# чтобы не прерывать импорт товара
|
||||
continue
|
||||
|
||||
def generate_error_file(self) -> tuple[bytes, str] | None:
|
||||
"""
|
||||
Генерирует файл с ошибочными строками.
|
||||
|
||||
Возвращает тот же формат, что был загружен (CSV или XLSX).
|
||||
Добавляет колонку "Ошибка" с описанием проблемы.
|
||||
|
||||
Returns:
|
||||
tuple[bytes, str]: (file_content, filename) или None если нет ошибок
|
||||
"""
|
||||
if not self.real_errors or not self.original_headers:
|
||||
return None
|
||||
|
||||
# Создаём mapping row_number -> error
|
||||
error_map = {err['row']: err for err in self.real_errors if err.get('row')}
|
||||
|
||||
if not error_map:
|
||||
return None
|
||||
|
||||
# Собираем ошибочные строки
|
||||
error_rows = []
|
||||
for index, row in enumerate(self.original_rows, start=2):
|
||||
if index in error_map:
|
||||
error_info = error_map[index]
|
||||
row_with_error = dict(row)
|
||||
row_with_error['Ошибка'] = error_info['reason']
|
||||
error_rows.append(row_with_error)
|
||||
|
||||
if not error_rows:
|
||||
return None
|
||||
|
||||
headers_with_error = list(self.original_headers) + ['Ошибка']
|
||||
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
if self.file_format == 'csv':
|
||||
return self._generate_csv_error_file(headers_with_error, error_rows, timestamp)
|
||||
else:
|
||||
return self._generate_xlsx_error_file(headers_with_error, error_rows, timestamp)
|
||||
|
||||
def _generate_csv_error_file(self, headers: list, rows: list[dict], timestamp: str) -> tuple[bytes, str]:
|
||||
"""Генерирует CSV файл с ошибками."""
|
||||
output = io.StringIO()
|
||||
output.write('\ufeff') # BOM для Excel
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=headers, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
content = output.getvalue().encode('utf-8')
|
||||
filename = f'product_import_errors_{timestamp}.csv'
|
||||
|
||||
return content, filename
|
||||
|
||||
def _generate_xlsx_error_file(self, headers: list, rows: list[dict], timestamp: str) -> tuple[bytes, str] | None:
|
||||
"""Генерирует XLSX файл с ошибками."""
|
||||
if load_workbook is None:
|
||||
return self._generate_csv_error_file(headers, rows, timestamp)
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Ошибки импорта"
|
||||
|
||||
ws.append(headers)
|
||||
|
||||
for row_dict in rows:
|
||||
row_data = [row_dict.get(h, '') for h in headers]
|
||||
ws.append(row_data)
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
content = output.read()
|
||||
filename = f'product_import_errors_{timestamp}.xlsx'
|
||||
|
||||
return content, filename
|
||||
except Exception:
|
||||
return self._generate_csv_error_file(headers, rows, timestamp)
|
||||
Reference in New Issue
Block a user