Files
octopus/myproject/products/models.py
Andrey Smakotin 9a232c6813 feat: Унификация slug-идентификаторов и улучшение формы комплектов
- Добавлено поле slug в модель Product с автоматической транслитерацией кириллицы
- Обновлена логика генерации slug в Product и ProductKit с использованием unidecode
- Изменена логика обработки изображений: теперь используется slug вместо sku
- Улучшен UX формы создания комплекта: блок загрузки фото доступен сразу при создании

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 14:40:53 +03:00

1024 lines
46 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.
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from django.contrib.auth import get_user_model
from .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku
# Получаем 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'),
]
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.
"""
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 ActiveManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class SoftDeleteQuerySet(models.QuerySet):
"""
QuerySet для мягкого удаления (soft delete).
Позволяет фильтровать удаленные элементы и восстанавливать их.
"""
def delete(self):
"""Soft delete вместо hard delete"""
return self.update(
is_deleted=True,
deleted_at=timezone.now()
)
def hard_delete(self):
"""Явный hard delete - удаляет из БД окончательно"""
return super().delete()
def restore(self):
"""Восстановление из удаленного состояния"""
return self.update(
is_deleted=False,
deleted_at=None,
deleted_by=None
)
def deleted_only(self):
"""Получить только удаленные элементы"""
return self.filter(is_deleted=True)
def not_deleted(self):
"""Получить только не удаленные элементы"""
return self.filter(is_deleted=False)
def with_deleted(self):
"""Получить все элементы включая удаленные"""
return self.all()
class SoftDeleteManager(models.Manager):
"""
Manager для работы с мягким удалением.
По умолчанию исключает удаленные элементы из запросов.
"""
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
def deleted_only(self):
"""Получить только удаленные элементы"""
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True)
def all_with_deleted(self):
"""Получить все элементы включая удаленные"""
return SoftDeleteQuerySet(self.model, using=self._db).all()
class ProductCategory(models.Model):
"""
Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже).
"""
name = models.CharField(max_length=200, verbose_name="Название")
sku = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name="Артикул", db_index=True)
slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор")
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True,
related_name='children', verbose_name="Родительская категория")
is_active = models.BooleanField(default=True, verbose_name="Активна")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
# Поля для мягкого удаления
is_deleted = models.BooleanField(default=False, verbose_name="Удалена", db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
deleted_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_categories',
verbose_name="Удалена пользователем"
)
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
active = ActiveManager() # Кастомный менеджер для активных категорий
class Meta:
verbose_name = "Категория товара"
verbose_name_plural = "Категории товаров"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
]
def __str__(self):
return self.name
def clean(self):
"""Валидация категории перед сохранением"""
from django.core.exceptions import ValidationError
# 1. Защита от самоссылки
if self.parent and self.parent.pk == self.pk:
raise ValidationError({
'parent': 'Категория не может быть родителем самой себя.'
})
# 2. Защита от циклических ссылок (только для существующих категорий)
if self.parent and self.pk:
self._check_parent_chain()
# 3. Проверка активности родителя
if self.parent and not self.parent.is_active:
raise ValidationError({
'parent': 'Нельзя выбрать неактивную категорию в качестве родителя.'
})
def _check_parent_chain(self):
"""Проверяет цепочку родителей на циклы и глубину вложенности"""
from django.core.exceptions import ValidationError
from django.conf import settings
current = self.parent
depth = 0
max_depth = getattr(settings, 'MAX_CATEGORY_DEPTH', 10)
while current:
if current.pk == self.pk:
raise ValidationError({
'parent': f'Обнаружена циклическая ссылка. '
f'Категория "{self.name}" не может быть потомком самой себя.'
})
depth += 1
if depth > max_depth:
raise ValidationError({
'parent': f'Слишком глубокая вложенность категорий '
f'(максимум {max_depth} уровней).'
})
current = current.parent
def save(self, *args, **kwargs):
# Вызываем валидацию перед сохранением
self.full_clean()
# Автоматическая генерация slug из названия с транслитерацией
if not self.slug or self.slug.strip() == '':
from unidecode import unidecode
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(self.name)
self.slug = slugify(transliterated_name)
# Автоматическая генерация артикула при создании новой категории
if not self.sku and not self.pk:
from .utils.sku_generator import generate_category_sku
self.sku = generate_category_sku()
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=['is_deleted', 'deleted_at'])
# Возвращаем результат в формате Django
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
class ProductTag(models.Model):
"""
Свободные теги для фильтрации и поиска.
"""
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
# Поля для мягкого удаления
is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
deleted_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_tags',
verbose_name="Удален пользователем"
)
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
class Meta:
verbose_name = "Тег товара"
verbose_name_plural = "Теги товаров"
indexes = [
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=['is_deleted', 'deleted_at'])
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
class ProductVariantGroup(models.Model):
"""
Группа вариантов товара (взаимозаменяемые товары).
Например: "Роза красная Freedom" включает розы 50см, 60см, 70см.
"""
name = models.CharField(max_length=200, verbose_name="Название")
description = models.TextField(blank=True, verbose_name="Описание")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Группа вариантов"
verbose_name_plural = "Группы вариантов"
ordering = ['name']
def __str__(self):
return self.name
def get_products_count(self):
"""Возвращает количество товаров в группе"""
return self.products.count()
class Product(models.Model):
"""
Базовый товар (цветок, упаковка, аксессуар).
"""
UNIT_CHOICES = [
('шт', 'Штука'),
('м', 'Метр'),
('г', 'Грамм'),
('л', 'Литр'),
('кг', 'Килограмм'),
]
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-идентификатор")
variant_suffix = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name="Суффикс варианта",
help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
)
description = models.TextField(blank=True, null=True, verbose_name="Описание")
categories = models.ManyToManyField(
ProductCategory,
blank=True,
related_name='products',
verbose_name="Категории"
)
tags = models.ManyToManyField(ProductTag, blank=True, related_name='products', verbose_name="Теги")
variant_groups = models.ManyToManyField(
ProductVariantGroup,
blank=True,
related_name='products',
verbose_name="Группы вариантов"
)
unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default='шт', verbose_name="Единица измерения")
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость")
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Розничная цена")
is_active = models.BooleanField(default=True, verbose_name="Активен")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
# Поле для улучшенного поиска (задел на будущее)
search_keywords = models.TextField(
blank=True,
verbose_name="Ключевые слова для поиска",
help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
)
# Поля для мягкого удаления
is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
deleted_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_products',
verbose_name="Удален пользователем"
)
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
active = ActiveManager() # Кастомный менеджер для активных товаров
class Meta:
verbose_name = "Товар"
verbose_name_plural = "Товары"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# Автоматическое извлечение variant_suffix из названия
# (только если не задан вручную и товар еще не сохранен с суффиксом)
if not self.variant_suffix and self.name:
from .utils.sku_generator import parse_variant_suffix
parsed_suffix = parse_variant_suffix(self.name)
if parsed_suffix:
self.variant_suffix = parsed_suffix
# Генерация артикула для новых товаров
if not self.sku:
self.sku = generate_product_sku(self)
# Автоматическая генерация slug из названия с транслитерацией
if not self.slug or self.slug.strip() == '':
from unidecode import unidecode
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(self.name)
self.slug = slugify(transliterated_name)
# Убеждаемся, что slug уникален
original_slug = self.slug
counter = 1
while Product.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
# Автоматическая генерация ключевых слов для поиска
# Собираем все релевантные данные в одну строку
keywords_parts = [
self.name or '',
self.sku or '',
self.description or '',
]
# Генерируем строку для поиска (только если поле пустое)
# Это позволит администратору добавлять кастомные ключевые слова вручную
if not self.search_keywords:
self.search_keywords = ' '.join(filter(None, keywords_parts))
super().save(*args, **kwargs)
# Добавляем названия категорий в search_keywords после сохранения
# (ManyToMany требует, чтобы объект уже существовал в БД)
if self.pk and self.categories.exists():
category_names = ' '.join([cat.name for cat in self.categories.all()])
if category_names and category_names not in self.search_keywords:
self.search_keywords = f"{self.search_keywords} {category_names}".strip()
# Используем update чтобы избежать рекурсии
Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords)
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=['is_deleted', 'deleted_at'])
# Возвращаем результат в формате Django
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
def get_variant_groups(self):
"""Возвращает все группы вариантов товара"""
return self.variant_groups.all()
def get_similar_products(self):
"""Возвращает все товары из тех же групп вариантов (исключая себя)"""
return Product.objects.filter(
variant_groups__in=self.variant_groups.all()
).exclude(id=self.id).distinct()
class ProductKit(models.Model):
"""
Шаблон комплекта / букета (рецепт).
"""
PRICING_METHOD_CHOICES = [
('fixed', 'Фиксированная цена'),
('from_sale_prices', 'По ценам продажи компонентов'),
('from_cost_plus_percent', 'Себестоимость + процент наценки'),
('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
]
name = models.CharField(max_length=200, verbose_name="Название")
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул")
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-идентификатор")
description = models.TextField(blank=True, null=True, verbose_name="Описание")
categories = models.ManyToManyField(
ProductCategory,
blank=True,
related_name='kits',
verbose_name="Категории"
)
tags = models.ManyToManyField(ProductTag, blank=True, related_name='kits', verbose_name="Теги")
is_active = models.BooleanField(default=True, verbose_name="Активен")
pricing_method = models.CharField(max_length=30, choices=PRICING_METHOD_CHOICES,
default='from_sale_prices', verbose_name="Метод ценообразования")
fixed_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
verbose_name="Фиксированная цена")
markup_percent = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name="Процент наценки")
markup_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
verbose_name="Фиксированная наценка")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
# Поля для мягкого удаления
is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
deleted_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_kits',
verbose_name="Удален пользователем"
)
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
active = ActiveManager() # Кастомный менеджер для активных комплектов
class Meta:
verbose_name = "Комплект"
verbose_name_plural = "Комплекты"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['slug']),
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
from unidecode import unidecode
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(self.name)
self.slug = slugify(transliterated_name)
# Убеждаемся, что slug уникален
original_slug = self.slug
counter = 1
while ProductKit.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
if not self.sku:
self.sku = generate_kit_sku()
super().save(*args, **kwargs)
def get_total_components_count(self):
"""Возвращает количество позиций в букете"""
return self.kit_items.count()
def get_components_with_variants_count(self):
"""Возвращает количество позиций с группами вариантов"""
return self.kit_items.filter(variant_group__isnull=False).count()
def get_sale_price(self):
"""Возвращает рассчитанную цену продажи комплекта"""
try:
return self.calculate_price_with_substitutions()
except Exception:
# Если что-то пошло не так, возвращаем фиксированную цену если есть
if self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
return 0
def check_availability(self, stock_manager=None):
"""
Проверяет доступность всего букета.
Букет доступен, если для каждой позиции есть хотя бы один доступный вариант.
"""
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
for kit_item in self.kit_items.all():
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
return False
return True
def calculate_price_with_substitutions(self, stock_manager=None):
"""
Расчёт цены букета с учётом доступных замен.
Использует цены фактически доступных товаров.
"""
from decimal import Decimal
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
# Если указана фиксированная цена, используем её
if self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
total_cost = Decimal('0.00')
total_sale = Decimal('0.00')
for kit_item in self.kit_items.select_related('product', 'variant_group'):
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
# Если товар недоступен, используем цену первого в списке
available_products = kit_item.get_available_products()
best_product = available_products[0] if available_products else None
if best_product:
total_cost += best_product.cost_price * kit_item.quantity
total_sale += best_product.sale_price * kit_item.quantity
# Применяем метод ценообразования
if self.pricing_method == 'from_sale_prices':
return total_sale
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent:
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount:
return total_cost + self.markup_amount
elif self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
return total_sale
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=['is_deleted', 'deleted_at'])
# Возвращаем результат в формате Django
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
class KitItem(models.Model):
"""
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
Позиция может быть либо конкретным товаром, либо группой вариантов.
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
verbose_name="Комплект")
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items_direct',
verbose_name="Конкретный товар"
)
variant_group = models.ForeignKey(
ProductVariantGroup,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items',
verbose_name="Группа вариантов"
)
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
notes = models.CharField(
max_length=200,
blank=True,
verbose_name="Примечание"
)
class Meta:
verbose_name = "Компонент комплекта"
verbose_name_plural = "Компоненты комплектов"
def __str__(self):
return f"{self.kit.name} - {self.get_display_name()}"
def clean(self):
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
if self.product and self.variant_group:
raise ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
)
if not self.product and not self.variant_group:
raise ValidationError(
"Необходимо указать либо товар, либо группу вариантов."
)
def get_display_name(self):
"""Возвращает название для отображения (товар или группа)"""
if self.variant_group:
return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан"
def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты"""
return self.priorities.exists()
def get_available_products(self):
"""Возвращает список доступных товаров с учётом приоритетов"""
if self.product:
# Если указан конкретный товар, возвращаем только его
return [self.product]
if self.variant_group:
# Если есть настроенные приоритеты, используем их
if self.has_priorities_set():
return [
priority.product
for priority in self.priorities.select_related('product').order_by('priority', 'id')
]
# Иначе возвращаем все товары из группы
return list(self.variant_group.products.filter(is_active=True))
return []
def get_best_available_product(self, stock_manager=None):
"""Возвращает первый доступный товар по приоритету"""
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
available_products = self.get_available_products()
for product in available_products:
if stock_manager.check_stock(product, self.quantity):
return product
return None
class KitItemPriority(models.Model):
"""
Приоритеты товаров для конкретной позиции букета.
Позволяет настроить индивидуальные приоритеты замен для каждого букета.
"""
kit_item = models.ForeignKey(
KitItem,
on_delete=models.CASCADE,
related_name='priorities',
verbose_name="Позиция в букете"
)
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
verbose_name="Товар"
)
priority = models.PositiveIntegerField(
default=0,
help_text="Меньше = выше приоритет (0 - наивысший)"
)
class Meta:
verbose_name = "Приоритет варианта"
verbose_name_plural = "Приоритеты вариантов"
ordering = ['priority', 'id']
unique_together = ['kit_item', 'product']
def __str__(self):
return f"{self.product.name} (приоритет {self.priority})"
class ProductPhoto(models.Model):
"""
Модель для хранения фото товара (один товар может иметь несколько фото).
Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos',
verbose_name="Товар")
image = models.ImageField(upload_to='products/originals/', verbose_name="Оригинальное фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото товара"
verbose_name_plural = "Фото товаров"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.product.name}"
def save(self, *args, **kwargs):
"""
При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
"""
from .utils.image_processor import ImageProcessor
is_new = not self.pk
old_image_path = None
# Если это обновление существующего объекта, сохраняем старый путь для удаления
if not is_new:
try:
old_obj = ProductPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductPhoto.DoesNotExist:
pass
# Если было загружено новое изображение
if self.image and (is_new or old_image_path):
# Обрабатываем изображение с использованием slug товара как идентификатора
# slug гарантирует уникальность и читаемость имени файла
identifier = self.product.slug
processed_paths = ImageProcessor.process_image(self.image, 'products', identifier=identifier)
# Сохраняем только путь к оригиналу в поле image
self.image = processed_paths['original']
# Удаляем старые версии если это обновление
if old_image_path:
ImageProcessor.delete_all_versions('products', old_image_path)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
import logging
from .utils.image_processor import ImageProcessor
logger = logging.getLogger(__name__)
if self.image:
try:
logger.info(f"[ProductPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('products', self.image.name)
logger.info(f"[ProductPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
super().delete(*args, **kwargs)
def get_thumbnail_url(self):
"""Получить URL миниатюры (150x150)"""
from .utils.image_service import ImageService
return ImageService.get_thumbnail_url(self.image.name)
def get_medium_url(self):
"""Получить URL среднего размера (400x400)"""
from .utils.image_service import ImageService
return ImageService.get_medium_url(self.image.name)
def get_large_url(self):
"""Получить URL большого размера (800x800)"""
from .utils.image_service import ImageService
return ImageService.get_large_url(self.image.name)
def get_original_url(self):
"""Получить URL оригинального изображения"""
from .utils.image_service import ImageService
return ImageService.get_original_url(self.image.name)
class ProductKitPhoto(models.Model):
"""
Модель для хранения фото комплекта (один комплект может иметь несколько фото).
Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos',
verbose_name="Комплект")
image = models.ImageField(upload_to='kits/originals/', verbose_name="Оригинальное фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото комплекта"
verbose_name_plural = "Фото комплектов"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.kit.name}"
def save(self, *args, **kwargs):
"""
При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
"""
from .utils.image_processor import ImageProcessor
is_new = not self.pk
old_image_path = None
# Если это обновление существующего объекта, сохраняем старый путь для удаления
if not is_new:
try:
old_obj = ProductKitPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductKitPhoto.DoesNotExist:
pass
# Если было загружено новое изображение
if self.image and (is_new or old_image_path):
# Обрабатываем изображение с использованием slug комплекта как идентификатора
# slug гарантирует уникальность и читаемость имени файла
identifier = self.kit.slug
processed_paths = ImageProcessor.process_image(self.image, 'kits', identifier=identifier)
# Сохраняем только путь к оригиналу в поле image
self.image = processed_paths['original']
# Удаляем старые версии если это обновление
if old_image_path:
ImageProcessor.delete_all_versions('kits', old_image_path)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
import logging
from .utils.image_processor import ImageProcessor
logger = logging.getLogger(__name__)
if self.image:
try:
logger.info(f"[ProductKitPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('kits', self.image.name)
logger.info(f"[ProductKitPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductKitPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
super().delete(*args, **kwargs)
def get_thumbnail_url(self):
"""Получить URL миниатюры (150x150)"""
from .utils.image_service import ImageService
return ImageService.get_thumbnail_url(self.image.name)
def get_medium_url(self):
"""Получить URL среднего размера (400x400)"""
from .utils.image_service import ImageService
return ImageService.get_medium_url(self.image.name)
def get_large_url(self):
"""Получить URL большого размера (800x800)"""
from .utils.image_service import ImageService
return ImageService.get_large_url(self.image.name)
def get_original_url(self):
"""Получить URL оригинального изображения"""
from .utils.image_service import ImageService
return ImageService.get_original_url(self.image.name)
class ProductCategoryPhoto(models.Model):
"""
Модель для хранения фото категории (одна категория может иметь несколько фото).
Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
"""
category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos',
verbose_name="Категория")
image = models.ImageField(upload_to='categories/originals/', verbose_name="Оригинальное фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото категории"
verbose_name_plural = "Фото категорий"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.category.name}"
def save(self, *args, **kwargs):
"""
При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
"""
from .utils.image_processor import ImageProcessor
is_new = not self.pk
old_image_path = None
# Если это обновление существующего объекта, сохраняем старый путь для удаления
if not is_new:
try:
old_obj = ProductCategoryPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductCategoryPhoto.DoesNotExist:
pass
# Если было загружено новое изображение
if self.image and (is_new or old_image_path):
# Обрабатываем изображение с использованием slug категории как идентификатора
# slug гарантирует уникальность и читаемость имени файла
identifier = self.category.slug
processed_paths = ImageProcessor.process_image(self.image, 'categories', identifier=identifier)
# Сохраняем только путь к оригиналу в поле image
self.image = processed_paths['original']
# Удаляем старые версии если это обновление
if old_image_path:
ImageProcessor.delete_all_versions('categories', old_image_path)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
import logging
from .utils.image_processor import ImageProcessor
logger = logging.getLogger(__name__)
if self.image:
try:
logger.info(f"[ProductCategoryPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('categories', self.image.name)
logger.info(f"[ProductCategoryPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductCategoryPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
super().delete(*args, **kwargs)
def get_thumbnail_url(self):
"""Получить URL миниатюры (150x150)"""
from .utils.image_service import ImageService
return ImageService.get_thumbnail_url(self.image.name)
def get_medium_url(self):
"""Получить URL среднего размера (400x400)"""
from .utils.image_service import ImageService
return ImageService.get_medium_url(self.image.name)
def get_large_url(self):
"""Получить URL большого размера (800x800)"""
from .utils.image_service import ImageService
return ImageService.get_large_url(self.image.name)
def get_original_url(self):
"""Получить URL оригинального изображения"""
from .utils.image_service import ImageService
return ImageService.get_original_url(self.image.name)