, seo атрибуты, слаг (обязательно) и т.п.)
'
'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL артиста.'
)
j_artist_metadata = models.JSONField(
default=dict,
blank=True,
null=True,
verbose_name='Метаданные JSON',
help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
' Пример: {"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}.',
)
t_artist_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
t_artist_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",
)
def __str__(self):
return f"artist {self.id:0>4}: {self.s_artist}"
def save(self, *args, **kwargs):
"""
Переопределяем save для управления синонимами исполнителей и создания связанной статьи.
При сохранении исполнителя (создание и обновление):
1. Управляем синонимами:
- Для новых исполнителей: добавляем текущий s_artist в SYN_EN
- При изменении s_artist: добавляем как старый, так и новый s_artist в SYN_EN
- При редактировании: используем j_artist_metadata из формы (приоритет админу)
2. Если статья не привязана - создаём новую статью исполнителя автоматически
3. Генерируем технический заголовок и slug для статьи
"""
# ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ =====
# Проверяем ДО работы с синонимами и метаданными!
# Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер
validate_and_raise_for_duplicates(self, 's_artist', 'j_artist_metadata')
# ===== УПРАВЛЕНИЕ СИНОНИМАМИ =====
# Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей)
update_synonyms_in_metadata(self, 's_artist', 'j_artist_metadata')
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
# Используем универсальный хелпер для создания/поиска статьи
# Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал
article = create_or_get_related_article(
self,
TbArticle.ArticleType.ARTIST,
's_artist',
'j_artist_metadata',
'k_artist_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
)
self.k_artist_to_article = article
# Вызываем оригинальный save родительского класса
super().save(*args, **kwargs)
class Meta:
verbose_name = 'Исполнитель'
verbose_name_plural = 'Исполнители'
ordering = ('s_artist',)
# index_together = ('t_artist_created', 't_artist_updated', 'k_artist_to_article')
class TbItem(models.Model):
"""
Товар в каталоге: релиз (альбом, сингл, компиляция), носитель (кассета для записи),
аксессуар (щётка для виниловых пластинок) и т.д.
Может быть представлен в виде одного или нескольких предложений от разных продавцов.
"""
s_item = models.CharField(
max_length=128,
unique=True,
verbose_name='Товар',
help_text='Техническое название товара (альбома, релиза, аксессуара) для внутреннего использования,'
'например: "Abbey Road (LP)" или "TDK CDing I (кассета для записи)".'
)
k_item_to_artist = models.ManyToManyField(
# Исполнители (ManyToMany для поддержки коллабораций)
# Например: "David Bowie & Queen", "Elton John & Tim Rice"
TbArtist,
blank=True, # Исполнителя может и не быть (например для сборников)
related_name='artist_to_item', # artist.products.all() — найти все релизы артиста
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
verbose_name='Исполнители',
help_text="Один или несколько для коллабораций",
)
k_item_to_style = models.ManyToManyField(
# Музыкальные стили (ManyToMany для альбомов с множеством жанров)
# Например: "Abbey Road" → [Rock, Progressive Rock, ...]
# Позволяет: найти все альбомы стиля / найти стили альбома / найти артистов в стиле
TbMusicStyle,
blank=True, # Стиль может быть не указан (например для аксессуаров)
related_name='style_to_item', # style.style_to_item.all() — найти все релизы в стиле
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
verbose_name='Музыкальные стили',
help_text='Один или несколько стилей, характеризующих альбом/товар. Например: Rock, Progressive Rock.',
)
k_item_to_article = models.OneToOneField(
TbArticle,
on_delete=models.SET_NULL,
related_name='article_to_item',
db_index=True,
default=None,
null=True,
blank=True,
verbose_name='Связанная статья',
help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.'
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
'
'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL альбома/релиза/товара.'
)
s_item_date = models.CharField(
max_length=10,
blank=True,
null=True,
db_index=True,
default='XXXX-XX-XX',
verbose_name='Дата релиза (str)',
help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только'
' год, или XXXX-XX-XX (если дата релиза неизвестна). Менее приоритетное поле для отображения даты'
' релиза, чем t_release_date, так как может содержать неполную дату и/или текстовую информацию.'
' Срабатывает только если t_release_date не указано.'
)
t_item_date = models.DateField(
blank=True,
null=True,
db_index=True,
verbose_name='Дата релиза',
help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.',
)
i_discogs_master_id = models.IntegerField(
blank=True,
null=True,
default=None,
verbose_name='ID на мастер-релиз Discogs',
help_text='Уникальный идентификатор мастер-релиза на Discogs, если он там есть. Например: 306323',
)
j_item_metadata = models.JSONField(
default=dict, blank=True, null=True,
verbose_name='Дополнительные данные',
help_text='Дополнительные данные и метаданные релиза (страна, жанр, количество треков и т.д.) или товавра'
' в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках',
)
t_item_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
t_item_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",)
def __str__(self):
return f"Item {self.id:0>4}: {self.s_item}"
def save(self, *args, **kwargs):
"""
Переопределяем save для управления синонимами релиза (альбома) или просто товара и создания связанной статьи.
При сохранении релиза/альбома (создание и обновление):
1. Управляем синонимами альбома/релиза/товара:
- Для новых альбомов/релиза/товара: добавляем в SYN_EN
- При изменении s_item: добавляем как старый, так и новый s_item в SYN_EN
- При редактировании: используем j_item_metadata из формы (приоритет админу)
2. Если статья не привязана - создаём новую автоматически
3. Генерируем технический заголовок и slug для статьи
"""
# ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ =====
# Проверяем ДО работы с синонимами и метаданными!
# Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер
validate_and_raise_for_duplicates(self, 's_item', 'j_item_metadata')
# ===== УПРАВЛЕНИЕ СИНОНИМАМИ =====
# Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей)
update_synonyms_in_metadata(self, 's_item', 'j_item_metadata')
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
# Используем универсальный хелпер для создания/поиска статьи
# Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал
article = create_or_get_related_article(
self,
TbArticle.ArticleType.ITEM,
's_item',
'j_item_metadata',
'k_item_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
)
self.k_item_to_article = article
# Вызываем оригинальный save родительского класса
super().save(*args, **kwargs)
class Meta:
verbose_name = 'Товар в каталоге (релиз, носитель, аксессуар)'
verbose_name_plural = 'Товары в каталоге'
ordering = ('s_item',)
# ============================================================================
# ЛЕЙБЛЫ (производители релизов)
# ============================================================================
class TbLabel(models.Model):
"""
Лейбл или издатель релиза.
Примеры: для винила, CD, Blu-Ray это: Sony, Мелодия, Atlantic, EMI ...
для кассет под запись это: TDK, AXIA, Maxell, JVC ...
для hi-fi это: Sony, Pioneer, Technics, Marantz ...
"""
# Используем SmallAutoField для оптимизации (макс ~32k)
# Лейблов обычно несколько сотен-тысяч, достаточно
id = models.SmallAutoField(primary_key=True)
s_label = models.CharField(
max_length=128,
blank=False,
unique=True,
verbose_name='Лейбл',
help_text='Название лейбла. Например: "Sony Records" или "Мелодия"... Представление на самом сайте, с версткой,'
' будет определиться через статью, связанную с лейблом (TbArticle).'
)
k_label_to_article = models.OneToOneField(
TbArticle,
on_delete=models.SET_NULL,
related_name='article_to_label',
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
default=None,
null=True,
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
verbose_name='Связанная статья',
help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи.'
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
'
'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL лейбла.'
)
j_label_metadata = models.JSONField(
default=dict,
blank=True,
null=True,
verbose_name='Метаданные',
help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга.'
' Пример: {"SYN_EN": ["Island", "Island Records", "Vertigo France"]}.'
,
)
t_label_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
t_label_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",)
def __str__(self):
return f"label: {self.id:0>5}: {self.s_label}"
def save(self, *args, **kwargs):
"""
Переопределяем save для управления синонимами лейблов и создания связанной статьи.
При сохранении лейбла (создание и обновление):
1. Управляем синонимами лейбла:
- Для новых лейблов: добавляем текущий s_label в SYN_EN
- При изменении s_label: добавляем как старый, так и новый s_label в SYN_EN
- При редактировании: используем j_label_metadata из формы (приоритет админу)
2. Если статья не привязана - создаём новую автоматически
3. Генерируем технический заголовок и slug для статьи
"""
# ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ =====
# Проверяем ДО работы с синонимами и метаданными!
# Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер
validate_and_raise_for_duplicates(self, 's_label', 'j_label_metadata')
# ===== УПРАВЛЕНИЕ СИНОНИМАМИ =====
# Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей)
update_synonyms_in_metadata(self, 's_label', 'j_label_metadata')
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
# Используем универсальный хелпер для создания/поиска статьи
# Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал
article = create_or_get_related_article(
self,
TbArticle.ArticleType.LABEL,
's_label',
'j_label_metadata',
'k_label_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
)
self.k_label_to_article = article
# Вызываем оригинальный save родительского класса
super().save(*args, **kwargs)
class Meta:
verbose_name = 'Лейбл'
verbose_name_plural = 'Лейблы'
ordering = ('s_label',)
# ============================================================================
# ПРОДАВЦЫ / МАГАЗИНЫ
# - Валюта привязана к продавцу. Если у продавца несколько валют,
# создаем несколько продавцов с разными валютами.
# ============================================================================
class TbSeller(models.Model):
"""Продавец или магазин, который продаёт товары."""
class SellerType(models.TextChoices):
SELLER = 'seller', 'Продавец'
LABEL = 'label', 'Лейбл (издатель)'
DIY = 'diy', 'Самиздат группы'
CROWD = 'crowd', 'Краудфандинг'
OTHER = '???', 'Другое'
class Currency(models.TextChoices):
RUB = 'rub', 'RUB: российский рубль'
USD = 'usd', 'USD: американский доллар'
EUR = 'eur', 'EUR: евро'
AMD = 'amd', 'AMD: армянских драм'
TRY = 'try', 'TRY: турецкая лира'
JPY = 'jpy', 'JPY: японская иена'
GBP = 'gbp', 'GBP: британский фунт'
CNY = 'cny', 'CNY: китайский юань'
BYN = 'byn', 'BYN: белорусский рубль'
TON = 'ton', 'TON: криптовалюта TON'
OTHER = '??', 'Other'
# Используем SmallAutoField для оптимизации (макс ~32k)
# Продавцов обычно до 1000-10000, поэтому достаточно
id = models.SmallAutoField(primary_key=True)
s_seller = models.CharField(
max_length=128,
blank=False,
unique=True,
verbose_name='Название продавца',
help_text='Техническое название продавца или магазина. Например: Клюква Рекодс. Может совпадать'
' с названием продавца, если лейбл сам реализует свои издания через сайт.',
)
l_seller_currency = models.CharField(
max_length=3,
choices=Currency.choices,
default=Currency.RUB,
verbose_name='Валюта источника',
help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.',
)
k_seller_to_article = models.OneToOneField(
TbArticle,
on_delete=models.SET_NULL,
related_name='article_to_seller',
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
default=None,
null=True,
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
verbose_name='Связанная статья',
help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи).'
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
'
'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL продавца.'
)
l_seller_type = models.CharField(
max_length=6,
default=SellerType.SELLER,
choices=SellerType.choices,
verbose_name='Тип продавца',
)
j_seller_metadata = models.JSONField(
default=dict, blank=True, null=True,
verbose_name='Дополнительные данные',
help_text='Дополнительные данные о продавце в виде JSON-словаря. Телефон, email, адрес, ссылка на сайт и т.д.',
)
t_seller_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
t_seller_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",)
def __str__(self):
return f"seller: {self.id:0>2}: {self.s_seller}"
def save(self, *args, **kwargs):
"""
Переопределяем save для привязки связанной статьи о продавце.
0. ВАЖНО: Продавцам не нужны синонимы (хотя технически возможно), потому синонимы не проверяем и не обновляем
1. Если статья не привязана - создаём новую статью исполнителя автоматически
2. Генерируем технический заголовок и slug для статьи
"""
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
# Используем универсальный хелпер для создания/поиска статьи
# Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал
article = create_or_get_related_article(
self,
TbArticle.ArticleType.SELLER,
's_seller',
'j_seller_metadata',
'k_seller_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
)
self.k_seller_to_article = article
# Вызываем оригинальный save родительского класса
super().save(*args, **kwargs)
class Meta:
verbose_name = 'Продавец'
verbose_name_plural = 'Продавцы'
ordering = ('s_seller',)
# ============================================================================
# ПРЕДЛОЖЕНИЯ / ОФФЕРЫ
# ============================================================================
class TbOffer(models.Model):
"""
Конкретное предложение от продавца.
Один и тот же релиз может быть несколько раз в системе от разных продавцов.
СВЯЗЬ С КАРТИНКАМИ:
Картинки к офферу управляются через TbImageMetadata.offer (M2M):
- offer.image_metadata.all() — все картинки этого офера (упорядочены по i_img_sort)
- TbImageMetadata(offer=self, image=<картинка>, i_img_sort=0, j_img_metadata={...})
В админке это выглядит как M2M с дополнительными полями (сортировка, метаданные).
"""
class Condition(models.TextChoices):
S = 's', 'Still Sealed (новое, запечатано)'
M = 'm', 'Mint (новое, распакованное)'
NM = 'nm', 'Near Mint (почти новое)'
VG = 'vg', 'Very Good (очень хорошее)'
G = 'g', 'Good (хорошее)'
F = 'f', 'Fair (удовлетворительное)'
P = 'p', 'Poor (плохое)'
OTHER = '??', 'Other'
class Format(models.TextChoices):
LP = 'lp', 'Vinyl Long-Play (12")'
EP = 'ep', 'Vinyl Extended-Play (12", 10", 7")'
V45 = '45', 'Vinyl 7" (45 rpm)'
CD = 'cd', 'Compact Disc'
LD = 'ld', 'LaserDisc'
REC_MD = 'md', 'MiniDisc Record'
USE_MD = 'ms', 'Used MiniDisc (для записи)'
REC_CS = 'cs', 'Cassette Record'
USE_CS = 'uc', 'Used Cassette (для записи)'
REC_RR = 'tp', 'Tape Reel Record'
USE_RR = 'ur', 'Used Tape Reel (для записи)'
OTHER = '??', 'Other'
s_offer = models.CharField(
max_length=128,
blank=False,
db_index=True,
verbose_name='Название оффера',
help_text='Техническое название оффера для внутреннего использования, например:'
' "Abbey Road (LP) AnTrop NM/NM (МЗГ)" или "TDK CDing I 60 (б/у) VG/VG (Janan, 198x синяя-градиент)"'
)
l_offer_to_format = models.CharField(
max_length=2,
choices=Format.choices,
default=Format.OTHER,
db_index=True,
verbose_name='Формат',
help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных),'
' то это указывать в "Дополнительных данных" в JSON-формате. Например:'
' {"OFR_MF": {"lp": 2, "cd": 1},}',
)
# Связи
k_offer_to_article = models.ForeignKey(
TbArticle,
on_delete=models.SET_NULL,
related_name='article_to_offer',
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
default=None,
null=True,
blank=True, # <-- Интерфейсное удобство. Статья НЕ БУДЕТ СОЗДАНА автоматически.
verbose_name='Связанная статья',
help_text='Связанная статья об оффере (HTML-готовые заголовок, тизер и текст статьи).'
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
'
'МОЖНО НЕ УКАЗЫВАТЬ т.к. URL оффера (для корзины) формируется через id или хеш.'
)
k_offer_to_item = models.ForeignKey(
TbItem,
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name='item_to_offer', # ← product.item_to_offer.all()
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
verbose_name='Релиз (товар)',
)
k_offer_to_label = models.ForeignKey(
TbLabel,
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name='label_to_offer', # ← label.label_to_offers.all()
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
verbose_name='Лейбл/Производитель',
help_text='Лейбл, на котором был выпущен релиз (если известен), или производитель для аудиио-кассет и MD'
' под запись, технику или аксессуар.',
)
k_offer_to_source = models.ForeignKey(
to='TbSource',
null=True,
default=None,
on_delete=models.CASCADE, # ← если удалён источник, удалены все офферы
related_name='source_to_offer',
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
verbose_name='Источник данных',
help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные '
'продавца: offer.k_offer_to_source.k_source_to_seller',
)
# Изображения (M2M к filer.Image через TbImageMetadata промежуточную таблицу)
m_offer_to_image = models.ManyToManyField(
'filer.Image',
through='TbImageMetadata',
related_name='m_image_to_offer',
blank=True,
verbose_name='Изображения товара',
help_text='Картинки этого предложения (обложка, задник, фото и т.д). '
'Управляются в админке TbImageMetadata с сортировкой (i_img_sort) и метаданными (j_img_metadata). '
'Доступ в коде: '
'offer.m_image.all() (все TbImageMetadata, отсортированные) или '
'offer.m_offer_to_image.all() (все картинки). '
'Каждая запись TbImageMetadata содержит: image, m_offer, i_img_sort, j_img_metadata.',
)
# Характеристики
s_offer_catalog_num = models.TextField(
blank=True,
default='',
verbose_name='Каталожный номер / Barcode',
help_text='Например: "SD 16023" или "5099923452355"',
)
b_offer_is_preorder = models.BooleanField(
default=False,
db_index=True,
verbose_name='Предзаказ',
help_text='Если товар доступен только для предзаказа',
)
d_offer_date_release = models.DateField(
blank=True,
null=True,
default=None,
db_index=True,
verbose_name='Дата релиза',
help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна',
)
i_offer_discogs_id = models.IntegerField(
blank=True,
default=0,
verbose_name='ID на релиз Discogs',
help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: 306323',
)
l_offer_condition_media = models.CharField(
max_length=2,
choices=Condition.choices,
default=Condition.S,
verbose_name="Состояние носителя",
help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).',
)
l_offer_condition_sleeve = models.CharField(
max_length=2,
choices=Condition.choices,
default=Condition.S,
verbose_name="Состояние обложки",
help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).',
)
# Цена и наличие
f_offer_price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0.00,
db_index=True, # <-- Чтобы можно было сортировать по цене
verbose_name='Цена',
help_text='Цена в валюте источника. Валюта определяется в TbSource: offer.k_offer_to_source.l_currency',
)
i_offer_quantity = models.IntegerField(
# Устанавливая количество в ноль, можно указать, что предложение в настоящее время не доступно.
blank=True,
default=0,
verbose_name='Количество в наличии',
)
i_offer_discount_to_daily_sale = models.IntegerField(
blank=True,
default=0,
db_index=True, # <-- Чтобы можно было сортировать по скидке и быстро выбирать то, что участвует в распродажах
verbose_name='Возможна cкидка (%)',
help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано'
' 0 то данное предложение не может участвовать в распродажах, спецпредложениях и акциях',
)
j_offer_metadata = models.JSONField(
# Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.)
default=get_offer_metadata_default,
null=True,
verbose_name='Дополнительные данные',
help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:{\n'
f' \"{KEY_OFFER_NOTE}\": \"Уценка: замят угол конверта, повреждены наклейки.\",\n'
f' \"{KEY_OFFER_ALL_MEDIA}\": '
'{\n "lp": 2,\n "cd": 1\n }\n}'
f'Ключи:'
f' - \"{KEY_OFFER_NOTE}\" — Примечание к офферу
'
f' - \"{KEY_OFFER_ALL_MEDIA}\" — Какие носители входят в коммерческое предложение.
'
' Допустимые значения: \"lp\" — Vinyl Long-Play (12"), \"ep\" — Vinyl Extended-Play (12\", 10\",'
' 7\"), \"45\" — Vinyl 7\" (45 rpm), \"cd\" — Compact Disc, \"ld\" — LaserDisc,'
' \"md\" — MiniDisc Record, \"ms\" — Used MiniDisc (для записи),'
' \"cs\" — Cassette Record, \"uc\" — Used Cassette (для записи),'
' \"tp\" — Tape Reel Record, \"ur\" — Used Tape Reel (для записи),'
' \"??\" — Other
',
)
s_offer_code = models.CharField(
max_length=12,
unique=True,
verbose_name='Код товара',
help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).'
' Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера.'
' Не редактировать вручную!',
)
i_offer_views = models.IntegerField(
default=0,
db_index=True,
verbose_name='Просмотры',
)
i_offer_favorites = models.IntegerField(
default=0,
db_index=True,
verbose_name='В избранном',
)
t_offer_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
t_offer_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",)
def __str__(self):
seller = self.k_offer_to_source.k_source_to_seller.s_seller if (self.k_offer_to_source
and self.k_offer_to_source.k_source_to_seller) else "?"
return (f"[{self.s_offer_code:0>6}]\u00A0«{self.s_offer}»"
f" ⟶\u00A0item\u00A0«{self.k_offer_to_item_id}»"
f" ⟶\u00A0seller\u00A0«{seller}»")
def increment_views(self):
"""Безопасный инкремент просмотров оффера"""
TbOffer.objects.filter(id=self.id).update(
i_offer_views=F('i_offer_views') + 1
)
def increment_favorites(self):
"""Безопасный инкремент добавлений в избранное оффера"""
TbOffer.objects.filter(id=self.id).update(
i_offer_favorites=F('i_offer_favorites') + 1
)
def save(self, *args, **kwargs):
"""
Переопределенный метод save для:
1. Автоматического формирования s_offer_code (криптографический код)
2. Записи истории изменений цены и количества в TbOfferHistory
ЛОГИКА s_offer_code:
- Для новых оферов: генерируем код после получения ID
- Для старых оферов БЕЗ кода: кодируем существующий ID (миграция)
- Для старых оферов С кодом: не трогаем
ЛОГИКА истории (TbOfferHistory):
- При первом сохранении: создаем первую запись с ценой и количеством
- При обновлении: если цена или количество изменилось → создаем новую запись
- Это позволяет отследить полную историю изменений
"""
# 1. Проверяем нужно ли генерировать s_offer_code:
# - ИЛИ это новый объект (self.pk == None)
# - ИЛИ это старый объект без s_offer_code (миграция)
if not self.pk or not self.s_offer_code:
# Если это новый оффер, сначала сохраняем, чтобы получить ID
if not self.pk:
# Сохраняем БЕЗ s_offer_code чтобы Django создал запись и присвоил pk
super().save(*args, **kwargs)
# После save() Django автоматически заполнит self.pk
# Кодируем pk в компактный, необратимый код
# Пример: pk=42 → "QBErd8"
self.s_offer_code = Hashids(salt=OFFER_HASHIDS_SALT, min_length=OFFER_HASHIDS_MIN_LENGTH).encode(self.pk)
# Сохраняем только поле s_offer_code (не перезаписываем остальное)
super().save(update_fields=['s_offer_code'])
else:
# Оффер существует И уже имеет s_offer_code: сохраняем как обычно
# Не трогаем s_offer_code, он был сформирован при создании
super().save(*args, **kwargs)
# 2. Записывает историю изменений цены и количества в TbOfferHistory.
# Пытаемся получить последнюю (самую свежую) запись в истории
latest_history = TbOfferHistory.objects.filter(
k_history_to_offer_id=self.pk
).order_by('-t_history_created').first()
if latest_history is None \
or self.f_offer_price != latest_history.f_history_price \
or self.i_offer_quantity != latest_history.i_history_quantity:
# Нет истории для этого офера, или изменилась цена/количество -> создаем новую запись в истории
TbOfferHistory.objects.create(
k_history_to_offer_id=self.pk,
f_history_price=self.f_offer_price,
i_history_quantity=self.i_offer_quantity,
# TODO: когда появится парсер, нужно будет добавить и запись поля `j_history_metadata` с информацией
# откуда "прилетели" изменения (или координаты ячеек в EXCEL, или CSS-селектор и URL, или что-то ещё)
)
class Meta:
verbose_name = 'Оффер (предложение)'
verbose_name_plural = 'Офферы (предложения)'
ordering = ('-t_offer_updated', '-t_offer_created', 's_offer')
indexes = [
# Составной индекс: найти все офферы товара, отсортировать по цене (для витрины)
models.Index(fields=['k_offer_to_item', '-f_offer_price'], name='idx_offer_by_item_price'),
## Составной индекс: для фильтра распродаж - по источнику и скидке
# models.Index(fields=['k_offer_to_source', '-i_offer_discount_to_daily_sale'], name='idx_offer_by_source_discount'),
# Составной индекс: найти актуальные офферы по товару (есть в наличии)
models.Index(fields=['k_offer_to_item', 'i_offer_quantity'], name='idx_offer_by_item_qty'),
]
# ПРИМЕЧАНИЕ: UniqueConstraint на (item, source, format) удален, т.к. k_offer_to_format теперь M2M.
# M2M не поддерживают участие в constraints. Уникальность на уровне БД не требуется.
# ============================================================================
class TbSource(models.Model):
"""
Источник данных, из которого был импортирован оффер.
Например, это может быть Excel-файл от продавца или издателя, CSV-файл, URL страницы с данными
(например, HTML-страница с каталогом товаров) и т.д.
"""
class SourceType(models.TextChoices):
EXCEL = 'excel', 'Excel-файл от продавца или издателя'
CSV = 'csv', 'CSV-файл от продавца или издателя'
URL = 'url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'
OTHER = '??', 'Другое (включая ручной ввод)'
# Используем SmallAutoField для оптимизации (макс ~32k)
# Источников обычно до 1000, достаточно
id = models.SmallAutoField(primary_key=True)
k_source_to_seller = models.ForeignKey(
TbSeller,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name='seller_to_source',
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
verbose_name='Продавец',
)
s_source_name = models.CharField(
max_length=128,
blank=True,
default='',
verbose_name='Название источника',
help_text='Название источника данных (для удобства), например: Предзаказ на RSD-2025 от Полуэкта.',
)
l_source_type = models.CharField(
max_length=5,
default=SourceType.EXCEL,
choices=SourceType.choices,
verbose_name='Тип источника',
help_text='Тип источника данных, например: Excel-файл от продавца или издателя, URL страницы'
' с данными и т.д.',
)
t_source_data = models.DateField(
blank=True, default=datetime.date.today,
verbose_name='Дата данных',
help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.',
)
source_file = FilerFileField(
null=True,
blank=True,
on_delete=models.SET_NULL,
# TODO:
# 1. Чтобы файлы автоматически привязывались к нужной виртуальной папке filer при загрузке через Django Admin.
# Для этого использовать сигналы (signals) в Admin или переопределение метода save модели (аналог
# `upload_to` в обычных FileField).
# 2. Внутри `FilerFileField` есть хеш SHA-1 (instance.doc.sha1) и размер файла в байтах (instance.doc.size).
# Они доступны в момент загрузки (сразу после, еще до записи на диск и БД. Через его проверку
# нужно предотвратить повторную запись файла-источника.
verbose_name='Файл-источник',
help_text='Файл-источник, например, Excel-файл от продавца или издателя. Если данные в источнике'
' представлены на странице в интернете, можно не указывать файл, а указать URL в поле ниже.',
)
s_source_url = models.TextField(
max_length=255, blank=True, default='',
verbose_name='URL источника',
help_text='URL страницы с данными, например, HTML-страница с каталогом товаров. Если данные в источнике'
' представлены в виде файла, можно не указывать URL, а загрузить файл в поле выше.',
)
j_source_metadata = models.JSONField(
default=dict, blank=True,
verbose_name='Дополнительные данные',
help_text='Дополнительные данные об источнике (внутреннем устройстве: вкладках и стоkбцах Excel-файла,'
' структуре HTML-страницы и т.п.) в виде JSON-словаря',
)
t_source_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
t_source_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",
)
def __str__(self):
return f"source {self.id:0>3}: {self.s_source_name}"
class Meta:
verbose_name = 'Источник данных'
verbose_name_plural = 'Источники данных'
ordering = ('-t_source_data', '-t_source_created')
# constraints = [
# # Уникальное ограничение: один продавец может иметь несколько источников,
# # но комбинация (продавец + тип источника) должна быть уникальна
# models.UniqueConstraint(fields=['k_source_to_seller', 'l_source_type'],
# name='idx_source_unique_by_seller_type'),
# ]
# ============================================================================
# ИСТОРИЯ ИЗМЕНЕНИЙ ОФФЕРОВ
# ============================================================================
class TbOfferHistory(models.Model):
"""
История изменений оффера (снапшот цены, количества, наличия).
Создаётся при каждом импорте, если что-то изменилось.
"""
k_history_to_offer = models.ForeignKey(
TbOffer,
on_delete=models.CASCADE,
related_name='offer_to_history', # ← offer.offer_to_history.all()
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
verbose_name='Оффер',
)
f_history_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
default=0.00,
verbose_name='Старая цена',
)
i_history_quantity = models.IntegerField(
# Устанавливая количество в ноль, можно указать, что предложение более не доступно. Если оффер вернется,
# то через новую запись в TbOfferHistory можно будет отследить, что он был в наличии, пропал, а потом
# снова появился (со старой или новой ценой).
default=0,
verbose_name='Старое количество',
)
j_history_metadata = models.JSONField(
default=dict,
blank=True,
verbose_name='Метаданные',
help_text='Метаданные, указывающие координаты данных внутри источника (например, внутри Excel-файла:'
' название вкладки, номер строки, номер столбца с ценой и количеством,'
' или URL + CSS-селектор для HTML-страницы и т.п.',
)
t_history_created = models.DateTimeField(
default=timezone.now,
db_index=True,
verbose_name="Дата создания",
help_text="Дата создания записи истории. Автоматически устанавливается на текущее время при создании, "
"но может быть отредактирована для загрузки исторических данных из Excel-файлов.",
)
# Нам не нужен `t_history_updated` потому что это "снимок состояния" и его не нужно менять
# после создания. И если вдруг понадобится, то правильнее будет добавить новую запись.
def __str__(self):
return f"history #{self.id} for offer {self.k_history_to_offer_id}"
class Meta:
verbose_name = 'История оффера'
verbose_name_plural = 'Истории офферов'
ordering = ('-t_history_created',)
indexes = [
# Составной индекс: найти историю оффера, отсортированную по времени (для хронологии изменений цены)
models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'),
]