Files
octopus/myproject/products/models/base.py
Andrey Smakotin 0f19542ac9 Добавлен асинхронный импорт товаров с параллельной загрузкой фото + исправлен баг со счётчиком 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 для загрузки фото
2026-01-06 07:10:12 +03:00

306 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Базовые модели для 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