- Реализован импорт 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 для загрузки фото
306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""
|
||
Базовые модели для products приложения.
|
||
Содержит SKUCounter и BaseProductEntity (абстрактный базовый класс).
|
||
|
||
TODO: Унификация системы soft delete
|
||
- Перевести ProductCategory с is_deleted на status (как в BaseProductEntity)
|
||
- Упростить managers.py, убрав поддержку старой системы is_deleted
|
||
- Унифицировать ProductTag (добавить status или оставить is_active)
|
||
"""
|
||
from django.db import models, transaction, IntegrityError
|
||
from django.db.models import Q
|
||
from django.utils import timezone
|
||
from django.contrib.auth import get_user_model
|
||
from django.utils.text import slugify
|
||
from unidecode import unidecode
|
||
|
||
# Получаем User модель один раз для использования в ForeignKey
|
||
User = get_user_model()
|
||
|
||
|
||
class SKUCounter(models.Model):
|
||
"""
|
||
Глобальные счетчики для генерации уникальных номеров артикулов.
|
||
Используется для товаров (product), комплектов (kit) и категорий (category).
|
||
"""
|
||
COUNTER_TYPE_CHOICES = [
|
||
('product', 'Product Counter'),
|
||
('kit', 'Kit Counter'),
|
||
('category', 'Category Counter'),
|
||
('configurable', 'Configurable Product Counter'),
|
||
]
|
||
|
||
counter_type = models.CharField(
|
||
max_length=20,
|
||
unique=True,
|
||
choices=COUNTER_TYPE_CHOICES,
|
||
verbose_name="Тип счетчика"
|
||
)
|
||
current_value = models.IntegerField(
|
||
default=0,
|
||
verbose_name="Текущее значение"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Счетчик артикулов"
|
||
verbose_name_plural = "Счетчики артикулов"
|
||
|
||
def __str__(self):
|
||
return f"{self.get_counter_type_display()}: {self.current_value}"
|
||
|
||
@classmethod
|
||
def get_next_value(cls, counter_type):
|
||
"""
|
||
Получить следующее значение счетчика (thread-safe).
|
||
Использует select_for_update для предотвращения race conditions.
|
||
|
||
DEPRECATED: Используйте peek_next_value() + increment_counter() вместо этого метода.
|
||
Этот метод инкрементирует счётчик немедленно, что может привести к пропускам номеров
|
||
при ошибках сохранения объекта.
|
||
"""
|
||
with transaction.atomic():
|
||
counter, created = cls.objects.select_for_update().get_or_create(
|
||
counter_type=counter_type,
|
||
defaults={'current_value': 0}
|
||
)
|
||
counter.current_value += 1
|
||
counter.save()
|
||
return counter.current_value
|
||
|
||
@classmethod
|
||
def peek_next_value(cls, counter_type):
|
||
"""
|
||
FIX: SKU counter bug - increment only after successful save
|
||
|
||
Получить следующее значение счетчика БЕЗ инкремента (thread-safe).
|
||
Используется для генерации SKU перед сохранением объекта.
|
||
Фактический инкремент выполняется в post_save сигнале после успешного создания.
|
||
|
||
Args:
|
||
counter_type: Тип счётчика ('product', 'kit', 'category', 'configurable')
|
||
|
||
Returns:
|
||
int: Следующее значение счётчика (current_value + 1)
|
||
"""
|
||
with transaction.atomic():
|
||
counter, created = cls.objects.select_for_update().get_or_create(
|
||
counter_type=counter_type,
|
||
defaults={'current_value': 0}
|
||
)
|
||
return counter.current_value + 1
|
||
|
||
@classmethod
|
||
def increment_counter(cls, counter_type):
|
||
"""
|
||
FIX: SKU counter bug - increment only after successful save
|
||
|
||
Инкрементировать счётчик (thread-safe).
|
||
Вызывается в post_save сигнале после успешного создания объекта с автогенерированным SKU.
|
||
|
||
Args:
|
||
counter_type: Тип счётчика ('product', 'kit', 'category', 'configurable')
|
||
|
||
Returns:
|
||
int: Новое значение счётчика после инкремента
|
||
"""
|
||
with transaction.atomic():
|
||
counter, created = cls.objects.select_for_update().get_or_create(
|
||
counter_type=counter_type,
|
||
defaults={'current_value': 0}
|
||
)
|
||
counter.current_value += 1
|
||
counter.save()
|
||
return counter.current_value
|
||
|
||
|
||
class BaseProductEntity(models.Model):
|
||
"""
|
||
Абстрактный базовый класс для Product и ProductKit.
|
||
Объединяет общие поля идентификации, описания, статуса и soft delete.
|
||
|
||
Используется как основа для:
|
||
- Product (простой товар)
|
||
- ProductKit (комплект товаров)
|
||
"""
|
||
# Идентификация
|
||
name = models.CharField(
|
||
max_length=200,
|
||
verbose_name="Название"
|
||
)
|
||
sku = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Артикул",
|
||
db_index=True
|
||
)
|
||
slug = models.SlugField(
|
||
max_length=200,
|
||
unique=True,
|
||
blank=True,
|
||
verbose_name="URL-идентификатор"
|
||
)
|
||
|
||
# Описания
|
||
description = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Описание"
|
||
)
|
||
short_description = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Краткое описание",
|
||
help_text="Используется для карточек товаров, превью и площадок"
|
||
)
|
||
|
||
# Статусы товаров
|
||
STATUS_CHOICES = [
|
||
('active', 'Активный'), # На продажу
|
||
('archived', 'Архивный'), # Скрыт (можно вернуть в сезон)
|
||
('discontinued', 'Снят'), # Морально устарел, на удаление
|
||
]
|
||
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='active',
|
||
db_index=True,
|
||
verbose_name="Статус"
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания"
|
||
)
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name="Дата обновления"
|
||
)
|
||
|
||
# История архивирования
|
||
archived_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Время архивирования"
|
||
)
|
||
archived_by = models.ForeignKey(
|
||
User,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='archived_%(class)s_set',
|
||
verbose_name="Архивировано пользователем"
|
||
)
|
||
|
||
# Manager
|
||
objects = models.Manager()
|
||
|
||
class Meta:
|
||
abstract = True
|
||
indexes = [
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['status', 'created_at']),
|
||
models.Index(fields=['created_at']),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=['name'],
|
||
condition=Q(status='active'),
|
||
name='unique_active_%(class)s_name'
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def archive(self, user=None):
|
||
"""Архивирование товара (скрыть, но можно восстановить)"""
|
||
self.status = 'archived'
|
||
self.archived_at = timezone.now()
|
||
if user:
|
||
self.archived_by = user
|
||
self.save(update_fields=['status', 'archived_at', 'archived_by'])
|
||
|
||
def restore(self):
|
||
"""Восстановление архивированного товара"""
|
||
self.status = 'active'
|
||
self.archived_at = None
|
||
self.archived_by = None
|
||
self.save(update_fields=['status', 'archived_at', 'archived_by'])
|
||
|
||
def discontinue(self, user=None):
|
||
"""Пометить товар как снятый (устарел, готов к удалению)"""
|
||
self.status = 'discontinued'
|
||
if user:
|
||
self.archived_by = user
|
||
self.save(update_fields=['status', 'archived_by'])
|
||
|
||
def delete(self, *args, **kwargs):
|
||
"""Для совместимости: вызывает discontinue() - статус СНЯТ"""
|
||
user = kwargs.pop('user', None)
|
||
self.discontinue(user=user)
|
||
return 1, {self.__class__._meta.label: 1}
|
||
|
||
def hard_delete(self):
|
||
"""Физическое удаление из БД (необратимо! только для старых товаров)"""
|
||
super().delete()
|
||
|
||
@property
|
||
def is_active(self):
|
||
"""Возвращает True если товар активен"""
|
||
return self.status == 'active'
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""
|
||
Автогенерация slug из name если не задан.
|
||
Использует transaction.atomic() и retry логику для обработки race condition.
|
||
"""
|
||
from ..services.slug_service import SlugService
|
||
|
||
# Генерируем базовый slug
|
||
if not self.slug or self.slug.strip() == '':
|
||
base_slug = slugify(unidecode(self.name))
|
||
else:
|
||
base_slug = self.slug
|
||
|
||
# Пытаемся сохранить с retry при IntegrityError
|
||
max_retries = 5
|
||
for attempt in range(max_retries):
|
||
try:
|
||
with transaction.atomic():
|
||
# Попытка 1: используем обычный способ генерации
|
||
if not self.slug or self.slug.strip() == '':
|
||
if attempt == 0:
|
||
# Первая попытка - используем обычный generate_unique_slug
|
||
self.slug = SlugService.generate_unique_slug(
|
||
self.name,
|
||
self.__class__,
|
||
self.pk
|
||
)
|
||
else:
|
||
# Retry попытки - используем get_next_available_slug с суффиксом
|
||
try:
|
||
self.slug = SlugService.get_next_available_slug(
|
||
base_slug,
|
||
self.__class__,
|
||
self.pk,
|
||
start_counter=attempt
|
||
)
|
||
except ValueError:
|
||
# Если все попытки с суффиксом исчерпаны, добавляем timestamp
|
||
import time
|
||
self.slug = f"{base_slug}-{int(time.time() % 10000)}"
|
||
|
||
# Основное сохранение
|
||
super().save(*args, **kwargs)
|
||
return # Успешно сохранили, выходим
|
||
|
||
except IntegrityError as e:
|
||
# Если это последняя попытка, выбрасываем исключение
|
||
if attempt == max_retries - 1:
|
||
raise
|
||
# Иначе пытаемся снова со следующим slug'ом
|
||
continue
|