619 lines
27 KiB
Python
619 lines
27 KiB
Python
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 .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku
|
||
|
||
|
||
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 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="Активна")
|
||
|
||
objects = models.Manager() # Менеджер по умолчанию
|
||
active = ActiveManager() # Кастомный менеджер для активных категорий
|
||
|
||
class Meta:
|
||
verbose_name = "Категория товара"
|
||
verbose_name_plural = "Категории товаров"
|
||
indexes = [
|
||
models.Index(fields=['is_active']),
|
||
]
|
||
|
||
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)
|
||
|
||
|
||
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-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Тег товара"
|
||
verbose_name_plural = "Теги товаров"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def save(self, *args, **kwargs):
|
||
if not self.slug:
|
||
self.slug = slugify(self.name)
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
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)
|
||
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="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
|
||
)
|
||
|
||
objects = models.Manager() # Менеджер по умолчанию
|
||
active = ActiveManager() # Кастомный менеджер для активных товаров
|
||
|
||
class Meta:
|
||
verbose_name = "Товар"
|
||
verbose_name_plural = "Товары"
|
||
indexes = [
|
||
models.Index(fields=['is_active']),
|
||
]
|
||
|
||
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)
|
||
|
||
# Автоматическая генерация ключевых слов для поиска
|
||
# Собираем все релевантные данные в одну строку
|
||
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 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="Дата обновления")
|
||
|
||
objects = models.Manager() # Менеджер по умолчанию
|
||
active = ActiveManager() # Кастомный менеджер для активных комплектов
|
||
|
||
class Meta:
|
||
verbose_name = "Комплект"
|
||
verbose_name_plural = "Комплекты"
|
||
indexes = [
|
||
models.Index(fields=['is_active']),
|
||
models.Index(fields=['slug']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def save(self, *args, **kwargs):
|
||
if not self.slug:
|
||
self.slug = slugify(self.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
|
||
|
||
|
||
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):
|
||
"""
|
||
Модель для хранения фото товара (один товар может иметь несколько фото).
|
||
"""
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos',
|
||
verbose_name="Товар")
|
||
image = models.ImageField(upload_to='products/', 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}"
|
||
|
||
|
||
class ProductKitPhoto(models.Model):
|
||
"""
|
||
Модель для хранения фото комплекта (один комплект может иметь несколько фото).
|
||
"""
|
||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos',
|
||
verbose_name="Комплект")
|
||
image = models.ImageField(upload_to='kits/', 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}"
|
||
|
||
|
||
class ProductCategoryPhoto(models.Model):
|
||
"""
|
||
Модель для хранения фото категории (одна категория может иметь несколько фото).
|
||
"""
|
||
category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos',
|
||
verbose_name="Категория")
|
||
image = models.ImageField(upload_to='categories/', 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}"
|