from django.db import models
from filer.fields.image import FilerImageField
from filer.fields.file import FilerFileField
import datetime
# ============================================================================
# ИЗОБРАЖЕНИЯ
# ============================================================================
class TbImage(models.Model):
"""
Изображение, связанное с релизом, оффером, исполнителем и т.д.
"""
# Источник изображения
class ImageSource(models.TextChoices):
PARSER_UPLOAD = 'parser', 'Загружено парсером (из Discogs, Meshok или другого сайта)'
MANUAL_UPLOAD = 'manual', 'Ручная загрузка пользователем'
VENDOR = 'vendor', 'От продавца'
OTHER = 'other', 'Другое'
# Тип изображения (реальное или абстрактное)
class ImageReality(models.TextChoices):
REAL_PHOTO = 'real', 'Реальная фотография товара'
ABSTRACT = 'abstract', 'Абстрактное (из внешнего источника)'
image = FilerImageField(
# Файл через django_filer
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name='Файл изображения',
help_text='Файл изображения, загруженный через django_filer.',
)
l_img_source = models.CharField(
max_length=10,
choices=ImageSource.choices,
default=ImageSource.MANUAL_UPLOAD,
verbose_name='Источник',
help_text='Как был получен этот снимок: загружен вручную, получен парсером из внешнего источника (например,'
' Discogs), предоставлен продавцом и т.д.',
)
l_img_reality = models.CharField(
max_length=10,
choices=ImageReality.choices,
default=ImageReality.ABSTRACT,
verbose_name='Тип снимка',
help_text='Реальная фотография товара или картинка из внешнего источника?',
)
s_img_src_url = models.URLField(
blank=True,
null=True,
verbose_name='URL источника',
help_text='Если изображение взято из внешнего источника (например, Discogs)',
)
# Порядок вывода
i_img_sort = models.IntegerField(
default=0,
db_index=True,
verbose_name='Cортировка',
help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать'
' для указания обложки (0), задника (1) и т.д.',
)
# Доверие данным (для парсеров и API)
f_img_confidence_score = models.FloatField(
null=True,
blank=True,
default=None,
verbose_name='Уверенность (для автоматических данных)',
help_text='0.0 - 1.0, насколько уверены, что это правильное изображение',
)
# Авторские права
s_img_copyright = models.CharField(
max_length=255,
blank=True,
default='',
verbose_name='Авторские права / Лицензия',
help_text='Например: "© 2024 User" или "CC-BY"',
)
# Timestamps
t_img_created = models.DateTimeField(
auto_now_add=True,
verbose_name='Дата добавления',
)
t_img_updated = models.DateTimeField(
auto_now=True,
verbose_name='Дата обновления',
)
class Meta:
verbose_name = 'Изображение'
verbose_name_plural = 'Изображения'
ordering = ('i_img_sort', 't_img_created')
# ============================================================================
# ИСПОЛНИТЕЛИ
# ============================================================================
class TbArtist(models.Model):
"""Исполнитель или музыкальная группа."""
s_artist = models.CharField(
max_length=255,
db_index=True,
verbose_name='Исполнитель',
help_text="Исполнитель или группа.",
)
s_artist_html = models.TextField(
blank=True,
null=True,
default='',
verbose_name='Исполнитель (типографированно в HTML)',
help_text='С сохранением типографирования и спецсимволов (всякие "метки" и иконки, типа "Иноагент",'
' тоже можно заверстать сюда.',
)
k_artist_to_image = models.OneToOneField(
TbImage,
on_delete=models.SET_NULL,
related_name='image_to_artist',
blank=True,
null=True,
verbose_name='Изображение для исполнителя',
help_text='Изображение исполнителя, например, логотип группы или фотография. Если указано, будет'
' отображаться рядом с именем исполнителя.',
)
s_artist_article_html = models.TextField(
blank=True,
null=True,
default='',
verbose_name='Статья',
help_text='Статья об исполнителе (типографированно в HTML)',
)
s_slug = models.SlugField(
max_length=64,
verbose_name='Слаг',
)
j_artist_in_source = models.JSONField(
default=list,
blank=True,
null=True,
verbose_name='Варианты написания в источниках',
help_text='Список вариантов: ["The Beatles", "Beatles", "Beatles, The"]',
)
t_artist_created = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания",
)
t_artist_updated = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления",
)
def __str__(self):
return f"artist {self.id:0>4}: {self.s_artist}"
class Meta:
verbose_name = 'Исполнитель'
verbose_name_plural = 'Исполнители'
ordering = ('s_artist',)
class TbItem(models.Model):
"""
Товар в каталоге: релиз (альбом, сингл, компиляция), носитель (кассета для записи),
аксессуар (щётка для виниловых пластинок) и т.д.
Может быть представлен в виде одного или нескольких предложений от разных продавцов.
"""
# Исполнители (ManyToMany для поддержки коллабораций)
# Например: "David Bowie & Queen", "Elton John & Tim Rice"
k_item_to_artist = models.ManyToManyField(
TbArtist,
blank=True,
related_name='artist_to_item', # artist.products.all() — найти все релизы артиста
verbose_name='Исполнители',
help_text="Один или несколько для коллабораций",
)
s_item_title = models.CharField(
max_length=255, blank=False, null=False, default='',
# db_index=True,
verbose_name='Название товара (релиза)',
help_text='Название товара (релиза), как указано на обложке. Например: Abbey Road'
' или TDK, CDing I, 90.',
)
s_item_title_html = models.TextField(
blank=True,
null=True,
default='',
verbose_name='Название (типографированно)',
help_text='С HTML-тегами для типографирования',
)
s_item_to_image = models.OneToOneField(
TbImage,
on_delete=models.SET_NULL,
related_name='image_to_item',
blank=True,
null=True,
verbose_name='Изображение',
help_text='Изображение товара из каталога, например, обложка альбома. Если указано, будет отображаться'
' рядом с названием товара.',
)
s_item_article_html = models.TextField(
blank=True,
null=True,
default='',
verbose_name='Статья',
help_text='Статья о релизе, например, описание из Википедии или Discogs (а также список композиций,'
' исполнители и т.п.). Для товара это может быть техническое описание и характеристики.'
' Текст сверстан в HTML с сохранением типографирования и спецсимволов.',
)
s_item_slug = models.SlugField(
blank=True,
null=True,
default='',
verbose_name='Слаг',
help_text='Слаг для товара, формируемый на основе названия товара (релиза)',
)
# Год выпуска (важно для коллекционеров)
s_item_date = models.CharField(
max_length=10,
blank=True,
null=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,
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,
verbose_name="Дата создания",
)
t_item_updated = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления",
)
def __str__(self):
return f"Item {self.id:0>4}: {self.s_item_title}"
class Meta:
verbose_name = 'Товар в каталоге (релиз, носитель, аксессуар)'
verbose_name_plural = 'Товары в каталоге'
ordering = ('s_item_title',)
# ============================================================================
# ЛЕЙБЛЫ (производители релизов)
# ============================================================================
class TbLabel(models.Model):
"""
Лейбл или издатель релиза.
Примеры: для винила, CD, Blu-Ray это: Sony, Мелодия, Atlantic, EMI ...
для кассет под запись это: TDK, AXIA, Maxell, JVC ...
для hi-fi это: Sony, Pioneer, Technics, Marantz ...
"""
s_label = models.CharField(
max_length=128,
blank=False,
null=False,
default='',
verbose_name='Название лейбла',
help_text='Например: "Sony Records" или "Мелодия"',
)
s_label_article_html = models.TextField(
blank=True,
null=True,
default='',
verbose_name='Статья',
help_text='Статья о лейбле (типографированно в HTML)',
)
s_label_slug = models.SlugField(
max_length=64,
blank=False,
null=False,
default='',
verbose_name='Слаг',
)
j_label_metadata = models.JSONField(
default=dict,
blank=True,
null=True,
verbose_name='Метаданные',
help_text='JSON: страна лейбла, официальный сайт и т.д.',
)
t_label_created = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания",
)
t_tabel_updated = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления",
)
def __str__(self):
return f"label: {self.id:0>5}: {self.s_label}"
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 = 'crowdfunding', 'Краудфандинг'
OTHER = '++', 'Другое'
s_seller = models.CharField(
max_length=32, blank=False, null=False, default='',
verbose_name='Название продавца',
help_text='Название продавца или магазина, например: Клюква Рекодс',
)
s_seller_article_html = models.TextField(
blank=True,
null=True,
default='',
verbose_name='Статья',
help_text='Статья о продавце (типографированно в HTML)',
)
s_seller_slug = models.SlugField(
max_length=32, blank=False, null=False, default='',
verbose_name='Слаг',
help_text='Слаг для продавца, формируемый на основе названия продавца',
)
l_seller_type = models.CharField(
max_length=9,
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,
verbose_name="Дата создания",
)
t_seller_updated = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления",
)
def __str__(self):
return f"seller: {self.id:0>2}: {self.s_seller}"
class Meta:
verbose_name = 'Продавец'
verbose_name_plural = 'Продавцы'
ordering = ('s_seller',)
# ============================================================================
# ПРЕДЛОЖЕНИЯ / ОФФЕРЫ
# ============================================================================
class TbOffer(models.Model):
"""
Конкретное предложение от продавца.
Один и тот же релиз может быть несколько раз в системе от разных продавцов.
"""
class Format(models.TextChoices):
LP = 'lp', 'vinyl'
CD = 'cd', 'CD'
BD = 'bd', 'Blu-ray'
CR = 'cr', 'Кассета с записью (фирменная)'
CS = 'cs', 'Кассета под запись (новая или б/у)'
MD = 'md', 'MiniDisc'
BX = 'bx', 'Box Set'
HI = 'hi', 'Hi-Fi (аппаратура)'
AC = 'ac', 'Accessory (Аксессуар)'
OTHER = '++', 'Other'
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'
# Связи
k_offer_to_item = models.ForeignKey(
TbItem,
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name='item_to_offer', # ← product.product_offers.all()
verbose_name='Релиз (товар)',
)
k_offer_to_label = models.ForeignKey(
TbLabel,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name='label_to_offer', # ← label.label_offers.all()
verbose_name='Лейбл',
help_text='Лейбл, на котором был выпущен релиз, если он известен. Например: Atlantic или Мелодия',
)
k_offer_to_source = models.ForeignKey(
to='TbSource',
null=True,
default=None,
on_delete=models.CASCADE, # ← если удалён источник, удалены офферы
related_name='source_to_offer',
verbose_name='Источник данных',
help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные '
'продавца: offer.k_offer_to_source.k_source_to_seller',
)
# Изображения товара (одна картинка может быть у многих офферов, а офер иметь много картинок)
# M2M связь для удобства в админке (filter_horizontal)
# Порядок картинок определяется полем i_img_sort в TbImage
k_offer_to_image = models.ManyToManyField(
TbImage,
blank=True,
related_name='image_to_offer',
verbose_name='Изображения',
help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка��тинке.',
)
# Характеристики
s_offer_catalog_num = models.TextField(
blank=True,
default='',
verbose_name='Каталожный номер / Barcode',
help_text='Например: "SD 16023" или "5099923452355"',
)
l_offer_primary_media = models.CharField(
max_length=2,
choices=Format.choices,
default=Format.LP,
verbose_name='Формат',
help_text='Основной формат носителя (пластинка, CD, кассета и т.п.)'
)
d_offer_date_release = models.DateField(
blank=True,
null=True,
default=None,
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,
blank=True,
default=0.00,
verbose_name='Цена',
help_text='Цена в валюте источника. Валюта определяется в TbSource: offer.k_offer_to_source.l_currency',
)
i_offer_quantity = models.IntegerField(
# Устанавливая количество в ноль, можно указать, что предложение в настоящее время не доступно.
blank=True,
default=0,
db_index=True,
verbose_name='Количество в наличии',
)
i_offer_discount_to_daily_sale = models.IntegerField(
blank=True,
default=0,
db_index=True,
verbose_name='Скидка',
help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано'
' 0 то данное предложение не может участвовать в распродажах, спецпредложениях и акциях',
)
s_offer_comment_html = models.TextField(
blank=True,
default='',
verbose_name='Доп.инфо',
help_text='Дополнительная информация или комментарий к предложению от продавца с сохранением'
' типографирования и спецсимволов.',
)
j_offer_metadata = models.JSONField(
# Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.)
default=dict, null=True,
verbose_name='Дополнительные данные',
help_text='Дополнительные данные о предложении в виде JSON-словаря.',
)
t_offer_created = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания",
)
t_offer_updated = models.DateTimeField(
auto_now=True,
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"offer {self.id:0>4} for item {self.k_offer_to_item_id} from seller {seller}"
class Meta:
verbose_name = 'Оффер (предложение)'
verbose_name_plural = 'Офферы (предложения)'
ordering = ('-t_offer_updated', '-t_offer_created')
# ============================================================================
# ИСТОЧНИКИ ДАННЫХ
# ============================================================================
class TbSource(models.Model):
"""
Источник данных, из которого был импортирован оффер.
Например, это может быть Excel-файл от продавца или издателя, CSV-файл, URL страницы с данными
(например, HTML-страница с каталогом товаров) и т.д.
"""
class SourceType(models.TextChoices):
EXCEL = 'excel', 'Excel-файл от продавца или издателя'
CSV = 'csv', 'CSV-файл от продавца или издателя'
URL = 'url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'
OTHER = '++', 'Другое'
class Currency(models.TextChoices):
RUB = 'rub', 'RUB: российский рубль'
USD = 'usd', 'USD: американский доллар'
EUR = 'eur', 'EUR: евро'
TRY = 'try', 'TRY: турецкая лира'
AMD = 'amd', 'AMD: армянский драм'
JPY = 'jpy', 'JPY: японская иена'
GBP = 'gbp', 'GBP: британский фунт'
CNY = 'cny', 'CNY: китайский юань'
BYN = 'byn', 'BYN: белорусский рубль'
TON = 'ton', 'TON: криптовалюта TON'
OTHER = '++', 'Other'
k_source_to_seller = models.ForeignKey(
TbSeller,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name='seller_to_source',
verbose_name='Продавец',
)
l_source_currency = models.CharField(
max_length=3,
choices=Currency.choices,
default=Currency.RUB,
verbose_name='Валюта источника',
help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.',
)
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,
verbose_name="Дата Создания записи в БД",
)
t_source_updated = models.DateTimeField(
auto_now=True,
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')
# ============================================================================
# ИСТОРИЯ ИЗМЕНЕНИЙ ОФФЕРОВ
# ============================================================================
class TbOfferHistory(models.Model):
"""
История изменений оффера (снапшот цены, количества, наличия).
Создаётся при каждом импорте, если что-то изменилось.
"""
k_history_to_offer = models.ForeignKey(
TbOffer,
on_delete=models.CASCADE,
related_name='offer_to_history', # ← offer.history_to_offer.all()
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(
auto_now_add=True,
db_index=True,
verbose_name="Дата создания",
)
# Нам не нужен `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',)
#
#
# ┌────────────────────┐
# │ ProductEnrichment │
# ├────────────────────┤
# │ id │
# │ product_id │ FK
# │ source │ ← discogs/api/scraper
# │ confidence_score │
# │ data_json │
# │ fetched_at │
# └────────────────────┘
#
#