741 lines
31 KiB
Python
741 lines
31 KiB
Python
from django.db import models
|
||
from filer.fields.image import FilerImageField
|
||
from filer.fields.file import FilerFileField
|
||
import datetime
|
||
|
||
# ============================================================================
|
||
# ИЗОБРАЖЕНИЯ
|
||
# ============================================================================
|
||
class TbImage(models.Model):
|
||
# Типы изображений
|
||
class ImageType(models.TextChoices):
|
||
COVER = 'cover', 'Обложка (лицевая сторона)'
|
||
BACK = 'back', 'Задняя сторона'
|
||
OBI = 'obi', 'OBI полоска (японская)'
|
||
MATRIX = 'matrix', 'Матричный номер (на виниле)'
|
||
SPINE = 'spine', 'Корешок'
|
||
CONDITION = 'condition', 'Фото состояния товара'
|
||
BOOKLET = 'booklet', 'Буклет/Постер'
|
||
OTHER = 'other', 'Другое'
|
||
|
||
# Источник изображения
|
||
class ImageSource(models.TextChoices):
|
||
DISCOGS = 'discogs', 'Discogs (внешний)'
|
||
MANUAL_UPLOAD = 'manual', 'Ручная загрузка пользователем'
|
||
VENDOR = 'vendor', 'От продавца'
|
||
API = 'api', 'API (автоматически)'
|
||
OTHER = 'other', 'Другое'
|
||
|
||
# Тип изображения (реальное или абстрактное)
|
||
class ImageReality(models.TextChoices):
|
||
REAL_PHOTO = 'real', 'Реальная фотография товара'
|
||
ABSTRACT = 'abstract', 'Абстрактное (из внешнего источника)'
|
||
|
||
# Файл через django_filer
|
||
file = FilerImageField(
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name='images',
|
||
)
|
||
|
||
# Связи (через GenericForeignKey?)
|
||
# или отдельные FK к Product/Offer/Artist?
|
||
|
||
# Основные поля
|
||
l_image_type = models.CharField(
|
||
max_length=10,
|
||
choices=ImageType.choices,
|
||
default=ImageType.OTHER,
|
||
verbose_name='Тип изображения',
|
||
)
|
||
|
||
l_source = models.CharField(
|
||
max_length=10,
|
||
choices=ImageSource.choices,
|
||
default=ImageSource.MANUAL_UPLOAD,
|
||
verbose_name='Источник',
|
||
)
|
||
|
||
# КЛЮЧЕВОЕ: реальная или абстрактная?
|
||
l_reality = models.CharField(
|
||
max_length=10,
|
||
choices=ImageReality.choices,
|
||
default=ImageReality.ABSTRACT,
|
||
verbose_name='Тип снимка',
|
||
help_text='Реальная фотография товара или картинка из внешнего источника?',
|
||
)
|
||
|
||
# URL источника (для внешних изображений)
|
||
s_source_url = models.URLField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name='URL источника',
|
||
help_text='Если изображение взято из внешнего источника (например, Discogs)',
|
||
)
|
||
|
||
# Порядок вывода
|
||
i_sort_order = models.IntegerField(
|
||
default=0,
|
||
db_index=True,
|
||
verbose_name='Порядок сортировки',
|
||
)
|
||
|
||
# Основная картинка?
|
||
b_is_primary = models.BooleanField(
|
||
default=False,
|
||
db_index=True,
|
||
verbose_name='Основное изображение',
|
||
help_text='Показывать как обложку в списке товаров',
|
||
)
|
||
|
||
# Доверие данным (для парсеров и API)
|
||
f_confidence_score = models.FloatField(
|
||
null=True,
|
||
blank=True,
|
||
default=None,
|
||
verbose_name='Уверенность (для автоматических данных)',
|
||
help_text='0.0 - 1.0, насколько уверены, что это правильное изображение',
|
||
)
|
||
|
||
# Описание
|
||
s_description = models.TextField(
|
||
blank=True,
|
||
default='',
|
||
verbose_name='Описание',
|
||
help_text='Например: "Состояние пластинки", "Царапина на обложке"',
|
||
)
|
||
|
||
# Авторские права
|
||
s_copyright = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
default='',
|
||
verbose_name='Авторские права / Лицензия',
|
||
help_text='Например: "© 2024 User" или "CC-BY"',
|
||
)
|
||
|
||
# Метаданные (размеры, EXIF и т.д.)
|
||
j_metadata = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
verbose_name='Метаданные',
|
||
help_text='Разрешение, размер файла, EXIF и т.д.',
|
||
)
|
||
|
||
# Timestamps
|
||
t_created = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата добавления',
|
||
)
|
||
t_updated = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления',
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# ИСПОЛНИТЕЛИ
|
||
# ============================================================================
|
||
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='С сохранением типографирования и спецсимволов.',
|
||
)
|
||
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_created = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания",
|
||
)
|
||
t_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 TbProduct(models.Model):
|
||
"""
|
||
Абстрактный релиз (альбом, сингл, компиляция).
|
||
Может быть представлен в виде одного или нескольких предложений от разных продавцов.
|
||
"""
|
||
|
||
# Исполнители (ManyToMany для поддержки коллабораций)
|
||
# Например: "David Bowie & Queen", "Elton John & Tim Rice"
|
||
k_artists = models.ManyToManyField(
|
||
TbArtist,
|
||
blank=True,
|
||
related_name='artist_to_products', # artist.products.all() — найти все релизы артиста
|
||
verbose_name='Исполнители',
|
||
help_text="Один или несколько для коллабораций",
|
||
)
|
||
|
||
s_title = models.CharField(
|
||
max_length=255, blank=False, null=False, default='',
|
||
# db_index=True,
|
||
verbose_name='Название товара (релиза)',
|
||
help_text='Название товара (релиза), как указано на обложке. Например: <tt>Abbey Road</tt>'
|
||
' или <tt>TDK, CDing I, 90</tt>.',
|
||
)
|
||
|
||
s_title_html = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
default='',
|
||
verbose_name='Название (типографированно)',
|
||
help_text='С HTML-тегами для типографирования',
|
||
)
|
||
|
||
s_title_slug = models.SlugField(
|
||
blank=True,
|
||
null=True,
|
||
default='',
|
||
verbose_name='Слаг',
|
||
help_text='Слаг для товара, формируемый на основе названия товара (релиза)',
|
||
)
|
||
|
||
# Год выпуска (важно для коллекционеров)
|
||
s_release_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 = 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, если он там есть. Например: <tt>306323</tt>',
|
||
)
|
||
|
||
# Метаданные релиза (страна, жанр, количество треков и т.д.)
|
||
j_product_metadata = models.JSONField(
|
||
default=dict, blank=True, null=True,
|
||
verbose_name='Дополнительные данные',
|
||
help_text='Дополнительные данные о релизе в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках',
|
||
)
|
||
|
||
t_created = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания",
|
||
)
|
||
t_updated = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name="Дата обновления",
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"Product {self.id:0>4}: {self.s_title}"
|
||
|
||
class Meta:
|
||
verbose_name = 'Релиз (товар)'
|
||
verbose_name_plural = 'Релизы (товары)'
|
||
ordering = ('s_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_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_created = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания",
|
||
)
|
||
t_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='Название продавца или магазина, например: <tt>Клюква Рекодс</tt>',
|
||
)
|
||
|
||
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_created = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания",
|
||
)
|
||
t_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'
|
||
|
||
class Currency(models.TextChoices):
|
||
RUB = 'rub', 'RUB: российский рубль'
|
||
TRY = 'try', 'TRY: турецкая лира'
|
||
AMD = 'amd', 'AMD: армянский драм'
|
||
USD = 'usd', 'USD: американский доллар'
|
||
EUR = 'eur', 'EUR: евро'
|
||
JPY = 'jpy', 'JPY: японская иена'
|
||
GBP = 'gbp', 'GBP: британский фунт'
|
||
CNY = 'cny', 'CNY: китайский юань'
|
||
BYN = 'byn', 'BYN: белорусский рубль'
|
||
TON = 'ton', 'TON: криптовалюта TON'
|
||
OTHER = '++', 'Other'
|
||
|
||
# Связи
|
||
k_product = models.ForeignKey(
|
||
TbProduct,
|
||
blank=True,
|
||
null=True,
|
||
default=None,
|
||
on_delete=models.SET_NULL,
|
||
related_name='product_to_offer', # ← product.product_offers.all()
|
||
verbose_name='Релиз (товар)',
|
||
)
|
||
|
||
k_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='Лейбл, на котором был выпущен релиз, если он известен. Например: <tt>Atlantic</tt> или <tt>Мелодия</tt>',
|
||
)
|
||
|
||
k_source = models.ForeignKey(
|
||
to='TbSource',
|
||
null=True,
|
||
default=None,
|
||
on_delete=models.CASCADE, # ← если удалён источник, удалены офферы
|
||
related_name='source_to_offer',
|
||
verbose_name='Источник данных',
|
||
help_text='Обязательно - каждый оффер должен иметь источник. Через источник, так же получаем данные'
|
||
' продавца `offer.k_source.k_seller`.',
|
||
)
|
||
|
||
# Характеристики
|
||
s_catalog_num = models.TextField(
|
||
blank=True,
|
||
default='',
|
||
verbose_name='Каталожный номер / Barcode',
|
||
help_text='Например: "SD 16023" или "5099923452355"',
|
||
)
|
||
|
||
l_primary_media = models.CharField(
|
||
max_length=2,
|
||
choices=Format.choices,
|
||
default=Format.LP,
|
||
verbose_name='Формат',
|
||
help_text='Основной формат носителя (пластинка, CD, кассета и т.п.)'
|
||
)
|
||
|
||
i_discogs_id = models.IntegerField(
|
||
blank=True,default=0,
|
||
verbose_name='ID на релиз Discogs',
|
||
help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: <tt>306323</tt>',
|
||
)
|
||
|
||
l_condition_media = models.CharField(
|
||
max_length=2,
|
||
choices=Condition.choices,
|
||
default=Condition.S,
|
||
verbose_name="Состояние носителя",
|
||
help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).',
|
||
)
|
||
|
||
l_condition_sleeve = models.CharField(
|
||
max_length=2,
|
||
choices=Condition.choices,
|
||
default=Condition.S,
|
||
verbose_name="Состояние обложки",
|
||
help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).',
|
||
)
|
||
|
||
# Цена и наличие
|
||
f_price = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
blank=True,
|
||
default=0.00,
|
||
verbose_name='Цена',
|
||
)
|
||
|
||
l_currency = models.CharField(
|
||
max_length=3,
|
||
choices=Currency.choices,
|
||
default=Currency.RUB,
|
||
verbose_name="Валюта",
|
||
)
|
||
|
||
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,
|
||
verbose_name='Скидка',
|
||
help_text='Процент скидки, для ежедневной распродажи',
|
||
)
|
||
|
||
# Комментарии
|
||
s_offer_comment = models.TextField(
|
||
blank=True,
|
||
default='',
|
||
verbose_name='Доп.инфо',
|
||
help_text='Дополнительная информация или комментарий к предложению от продавца, например:'
|
||
' <tt>"Пластинка запаяна в целлофан, угол обложки замят."</tt>.',
|
||
)
|
||
|
||
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):
|
||
return f"offer {self.id:0>4} for product {self.k_product_id} from seller {self.k_source.k_seller_id}"
|
||
|
||
|
||
# ============================================================================
|
||
# ИСТОЧНИКИ ДАННЫХ
|
||
# ============================================================================
|
||
class TbSource(models.Model):
|
||
"""
|
||
Источник данных, из которого был импортирован оффер.
|
||
Например, это может быть Excel-файл от продавца или издателя, CSV-файл, URL страницы с данными
|
||
(например, HTML-страница с каталогом товаров) и т.д.
|
||
"""
|
||
class SourceType(models.TextChoices):
|
||
EXCEL = 'excel', 'Excel-файл от продавца или издателя'
|
||
CSV = 'csv', 'CSV-файл от продавца или издателя'
|
||
URL = 'url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'
|
||
OTHER = '++', 'Другое'
|
||
|
||
k_seller = models.ForeignKey(
|
||
TbSeller,
|
||
null=True,
|
||
default=None,
|
||
on_delete=models.SET_NULL,
|
||
verbose_name='Продавец',
|
||
)
|
||
s_source_name = models.CharField(
|
||
max_length=128,
|
||
blank=True,
|
||
default='',
|
||
verbose_name='Название источника',
|
||
help_text='Название источника данных (для удобства), например: <tt>Предзаказ на RSD-2025 от Полуэкта.</tt>',
|
||
)
|
||
l_source_type = models.CharField(
|
||
max_length=5, default=SourceType.EXCEL, choices=SourceType.choices,
|
||
verbose_name='Тип источника',
|
||
help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы'
|
||
' с данными</tt> и т.д.',
|
||
)
|
||
t_source_data = models.DateField(
|
||
blank=True, default=datetime.date.today,
|
||
verbose_name='Дата данных',
|
||
help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.',
|
||
)
|
||
file_source = FilerFileField(
|
||
null=True,
|
||
blank=True,
|
||
related_name="source_file",
|
||
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_offer = models.ForeignKey(
|
||
TbOffer,
|
||
on_delete=models.CASCADE,
|
||
related_name='history_to_offer', # ← 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='Старое количество',
|
||
)
|
||
# Откуда приехало изменение (какой источник данных) можно получить через k_offer.k_source, так что отдельного
|
||
# поля для источника в истории не нужно. Но если вдруг понадобится, то можно будет
|
||
# k_source = models.ForeignKey(
|
||
# TbSource,
|
||
# null=True,
|
||
# default=None,
|
||
# on_delete=models.SET_NULL,
|
||
# related_name='source_to_offer_history', # ← source.source_to_offer_history.all()
|
||
# 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_offer_id}"
|
||
|
||
class Meta:
|
||
verbose_name = 'История оффера'
|
||
verbose_name_plural = 'Истории офферов'
|
||
ordering = ('-t_history_created',)
|
||
|
||
|
||
|
||
# │ 1
|
||
# │
|
||
# │ N
|
||
# ┌───────▼────────────┐
|
||
# │ ProductImage │
|
||
# ├────────────────────┤
|
||
# │ id │
|
||
# │ product_id │ FK
|
||
# │ image_type │ ← cover/back/obi/matrix/etc
|
||
# │ source │ ← discogs/manual/vendor
|
||
# │ url │
|
||
# │ local_path │
|
||
# │ is_primary │
|
||
# │ sort_order │
|
||
# │ metadata_json │
|
||
# └────────────────────┘
|
||
#
|
||
#
|
||
# ┌────────────────────┐
|
||
# │ ProductEnrichment │
|
||
# ├────────────────────┤
|
||
# │ id │
|
||
# │ product_id │ FK
|
||
# │ source │ ← discogs/api/scraper
|
||
# │ confidence_score │
|
||
# │ data_json │
|
||
# │ fetched_at │
|
||
# └────────────────────┘
|
||
#
|
||
#
|