1243 lines
74 KiB
Python
1243 lines
74 KiB
Python
# LPON Store — Django E-Commerce Database Schema (SQLite optimized)
|
||
#
|
||
# ╔════════════════════════════════════════════════════════════════════════════╗
|
||
# ║ ER-ДИАГРАММА СХЕМЫ БД (v1.0) ║
|
||
# ╚════════════════════════════════════════════════════════════════════════════╝
|
||
#
|
||
# Легенда:
|
||
# Ключи:
|
||
# PK = Primary Key (первичный ключ)
|
||
# FK = Foreign Key (внешний ключ)
|
||
# M2M = Many-to-Many (ключ многие-ко-многим)
|
||
# Связи:
|
||
# 1:1 = OneToOne связь
|
||
# 1:M = One-to-Many связь
|
||
# M:M = Many-to-Many связь
|
||
#
|
||
#
|
||
# ##════════════════════════════════════════════════════════════════════════════##
|
||
# МЕДИА И СПРАВОЧНИКИ
|
||
# ##════════════════════════════════════════════════════════════════════════════##
|
||
#
|
||
# ┌─────────────────────┐
|
||
# │ TbImage │ Базовые изображения (обложки, фото и т.д.)
|
||
# ├─────────────────────┼─────────────────────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ image │ FilerImageField
|
||
# │ l_img_source │ Источник (parser, manual, vendor, other)
|
||
# │ l_img_reality │ Тип (real, abstract)
|
||
# │ i_img_sort │ Порядок отображения
|
||
# │ f_img_confidence_ │ Доверие данным (0-1)
|
||
# │ s_img_copyright │ Авторские права
|
||
# │ t_img_created │ Timestamp
|
||
# │ t_img_updated │ Timestamp
|
||
# │ │ ⬆ Индекс на: id (+), i_img_sort
|
||
# └─────────────────────┘
|
||
# △
|
||
# │ M:M TbOffer.k_offer_to_image
|
||
# │ [промежуточная таблица: offer_id, image_id]
|
||
# │
|
||
# ├────┬───────────────────────────────────────────────────────────────────┐
|
||
# │ │
|
||
# │ ▼
|
||
# │ ┌──────────────────────┐
|
||
# │ │ TbArticle │ Текстовый контент (статьи, SEO, теги)
|
||
# │ ├──────────────────────┼──────────────────────────────────────────────────
|
||
# │ │ PK: id │ AutoField
|
||
# │ │ s_article_title │ Технический заголовок
|
||
# │ │ l_article_type │ Тип (artist, item, offer, seller, blog...)
|
||
# │ │ b_article_published │ Опубликовано (bool)
|
||
# │ │ s_article_title_html │ HTML-заголовок
|
||
# │ │ k_article_to_image │ FK → TbImage (обложка статьи)
|
||
# │ │ k_article_to_styles │ M2M → TbMusicStyle (теги стилей)
|
||
# │ │ i_article_views │ Счетчик просмотров
|
||
# │ │ i_article_favorites │ Счетчик в избранном
|
||
# │ │ slug │ URL-идентификатор (уникальный)
|
||
# │ │ seo_title, │ SEO метаданные
|
||
# │ │ seo_description │
|
||
# │ │ t_article_created │ Timestamps
|
||
# │ │ t_article_updated │ Timestamps
|
||
# │ │ │ ⬆ Индексы: id (+), l_article_type, b_article_published, slug,
|
||
# │ │ │ k_article_to_image, (type, published, created)
|
||
# │ └──────────────────────┘
|
||
# │ △
|
||
# │ │ 1:1 (OneToOne обратные связи)
|
||
# │ │
|
||
# │ ┌───┴─────────┬────────────┬──────────────┐
|
||
# │ │ │ │ │
|
||
# │ ▼ ▼ ▼ ▼
|
||
# │ ┌─────────┐ ┌───────┐ ┌─────────┐ ┌──────────┐
|
||
# │ │TbArtist │ │TbItem │ │TbLabel │ │TbSeller │
|
||
# │ ├─────────┤ ├───────┤ ├─────────┤ ├──────────┤
|
||
# │ │PK: id │ │PK: id │ │ PK: id │ │ PK: id │
|
||
# │ │s_artist │ │s_item │ │ s_label │ │ s_seller │
|
||
# │ └─────────┘ └───────┘ └─────────┘ └──────────┘
|
||
# │ ▲
|
||
# │ │ M:M TbItem.k_item_to_artist
|
||
# │ │ (для поддержки коллабораций)
|
||
# │ │
|
||
# └──────┘
|
||
#
|
||
#
|
||
# ┌──────────────────┐
|
||
# │ TbMusicStyle │ Музыкальные стили (теги для категоризации)
|
||
# ├──────────────────┼────────────────────────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ s_style_name │ Название (Rock, Jazz, Classical...)
|
||
# │ s_style_slug │ SlugField(50) — уникальный, indexed
|
||
# │ j_style_synonyms │ JSON синонимы из Discogs для матчинга
|
||
# │ t_style_created │ Timestamp
|
||
# │ t_style_updated │ Timestamp
|
||
# │ │ ⬆ Индексы: id (+), s_style_slug
|
||
# └──────────────────┘
|
||
# △
|
||
# │ M2M TbArticle.k_article_to_styles
|
||
# │ [промежуточная таблица: article_id, musicstyle_id]
|
||
#
|
||
#
|
||
# ┌──────────────────┐
|
||
# │ TbFormat │ Форматы носителей (LP, CD, Cassette...)
|
||
# ├──────────────────┼────────────────────────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ s_format │ Название (LP, CD, Blu-ray, Cassette...)
|
||
# │ s_format_slug │ SlugField(16) — уникальный
|
||
# └──────────────────┘
|
||
# △
|
||
# │ M2M TbOffer.k_offer_to_format
|
||
# │ [промежуточная таблица: offer_id, format_id]
|
||
#
|
||
#
|
||
# ##════════════════════════════════════════════════════════════════════════════##
|
||
# ПРЕДЛОЖЕНИЯ И ЦЕНЫ
|
||
# ##════════════════════════════════════════════════════════════════════════════##
|
||
#
|
||
# ┌──────────────────────┐
|
||
# │ TbSeller │ Продавцы / магазины
|
||
# ├──────────────────────┼──────────────────────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ s_seller │ Название (уникальный)
|
||
# │ l_seller_type │ Тип (seller, label, diy, crowdfunding, other)
|
||
# │ k_seller_to_article │ 1:1 FK → TbArticle (content, SEO, slug)
|
||
# │ t_seller_created │ Timestamp
|
||
# │ t_seller_updated │ Timestamp
|
||
# │ │ ⬆ Индекс: id
|
||
# └──────────────────┬───┘
|
||
# │
|
||
# │ 1:M TbSource.k_source_to_seller
|
||
# ▼
|
||
# ┌──────────────────────┐
|
||
# │ TbSource │ Источники данных (Excel, URL, CSV...)
|
||
# ├──────────────────────┼───────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ k_source_to_seller │ FK → TbSeller [indexed]
|
||
# │ l_source_type │ (excel, csv, url, other)
|
||
# │ l_source_currency │ (rub, usd, eur, ...)
|
||
# │ s_source_name │ Название источника
|
||
# │ source_file │ FilerFileField
|
||
# │ s_source_url │ URL источника
|
||
# │ t_source_data │ Дата данных
|
||
# │ t_source_created │ Timestamp
|
||
# │ t_source_updated │ Timestamp
|
||
# │ ⬆ Индекс: k_source_to_seller
|
||
# └──────────────────┬───┘
|
||
# │
|
||
# │ 1:M TbOffer.k_offer_to_source
|
||
# ▼
|
||
# ┌────────────────────────────────┐
|
||
# │ TbOffer │ Конкретное предложение товара
|
||
# ├────────────────────────────────┼───────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ s_offer │ Название (indexed)
|
||
# │ k_offer_to_item │ FK → TbItem [indexed]
|
||
# │ k_offer_to_label │ FK → TbLabel [indexed]
|
||
# │ k_offer_to_source │ FK → TbSource [indexed]
|
||
# │ k_offer_to_article │ FK → TbArticle (опционально)
|
||
# │ k_offer_to_format │ M2M → TbFormat (может быть несколько)
|
||
# │ k_offer_to_image │ M2M → TbImage (несколько фото)
|
||
# │ l_offer_condition_media │ Состояние (s, m, nm, vg, g, f, p)
|
||
# │ l_offer_condition_sleeve │ Состояние (s, m, nm, vg, g, f, p)
|
||
# │ f_offer_price │ Цена [indexed для сортировки]
|
||
# │ i_offer_quantity │ Количество в наличии [indexed]
|
||
# │ i_offer_discount_to_daily_sale │ % скидка [indexed для фильтров]
|
||
# │ s_offer_skip32 │ Хеш для корзины (unique)
|
||
# │ i_offer_views │ Счетчик просмотров
|
||
# │ i_offer_favorites │ Счетчик в избранном
|
||
# │ t_offer_created │ Timestamp
|
||
# │ t_offer_updated │ Timestamp
|
||
# │ │ ⬆ Индексы: (item, price↓), (item, quantity), (source, discount)
|
||
# │ │ ⬆ Constraint UNIQUE: (item, source, format)
|
||
# └────────────────────┬───────────┘
|
||
# │
|
||
# │ 1:M TbOfferHistory.k_history_to_offer
|
||
# ▼
|
||
# ┌────────────────────┐
|
||
# │ TbOfferHistory │ История изменений цены/кол-ва
|
||
# ├────────────────────┼──────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ k_history_to_offer │ FK → TbOffer [indexed]
|
||
# │ f_history_price │ Старая цена
|
||
# │ i_history_quantity │ Старое количество
|
||
# │ t_history_created │ Timestamp [indexed]
|
||
# │ │ ⬆ Индекс: (offer, created↓)
|
||
# └────────────────────┘
|
||
#
|
||
#
|
||
# ##════════════════════════════════════════════════════════════════════════════##
|
||
# КАТАЛОГ ТОВАРОВ
|
||
# ##════════════════════════════════════════════════════════════════════════════##
|
||
#
|
||
# ┌────────────────────┐
|
||
# │ TbLabel │ Издатели / лейблы
|
||
# ├────────────────────┼────────────────────────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ s_label │ Название (Sony, Atlantic, Мелодия...)
|
||
# │ k_label_to_article │ 1:1 FK → TbArticle (content, SEO)
|
||
# └────────────────────┘
|
||
# △
|
||
# │ 1:M TbOffer.k_offer_to_label
|
||
# │
|
||
# ┌─────────────────────┐
|
||
# │ TbItem │ Товары в каталоге (релизы, носители, аксессуары)
|
||
# ├─────────────────────┼────────────────────────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ s_item │ Название (Abbey Road (LP), TDK CDing I...)
|
||
# │ k_item_to_artist │ M2M → TbArtist (для коллабораций)
|
||
# │ k_item_to_article │ 1:1 FK → TbArticle (content, SEO, slug)
|
||
# │ t_item_date │ Дата релиза
|
||
# │ i_discogs_master_id │ ID мастер-релиза на Discogs
|
||
# │ t_item_created │ Timestamp
|
||
# │ t_item_updated │ Timestamp
|
||
# └─────────────────────┘
|
||
# △
|
||
# │ 1:M TbOffer.k_offer_to_item
|
||
# │
|
||
# ├──────────────────── M2M → TbArtist.k_item_to_artist
|
||
# │ [промежуточная таблица: item_id, artist_id]
|
||
# │
|
||
# ┌─────────────────────┐
|
||
# │ TbArtist │ Исполнители / группы
|
||
# ├─────────────────────┼─────────────────────────────────────────────────────
|
||
# │ PK: id │ AutoField
|
||
# │ s_artist │ Название (The Beatles, David Bowie...)
|
||
# │ k_artist_to_article │ 1:1 FK → TbArticle (content, SEO, slug)
|
||
# │ t_artist_created │ Timestamp
|
||
# │ t_artist_updated │ Timestamp
|
||
# │ │
|
||
# │ │ ⬆ Индекс: id
|
||
# └─────────────────────┘
|
||
#
|
||
#
|
||
# ╔════════════════════════════════════════════════════════════════════════════╗
|
||
# ║ ИТОГО ТАБЛИЦ: 11 ║
|
||
# ║ Базовые: TbImage, TbArticle, TbMusicStyle, TbFormat ║
|
||
# ║ Справочники: TbSeller, TbLabel, TbArtist, TbItem ║
|
||
# ║ Коммерческие: TbSource, TbOffer (M2M форматы, фото), TbOfferHistory ║
|
||
# ║ M2M промежуточные: article←→styles ║
|
||
# ║ offer←→formats ║
|
||
# ║ offer←→images ║
|
||
# ║ item←→artists ║
|
||
# ╚════════════════════════════════════════════════════════════════════════════╝
|
||
#
|
||
# ОПТИМИЗАЦИЯ ДЛЯ SQLite:
|
||
# - db_index=True на все FK поля (SQLite не создает их автоматически)
|
||
# - Составные индексы на часто используемые комбинации
|
||
# - PRAGMA auto_vacuum=2 для невручного сокращения файла БД
|
||
# - PRAGMA journal_mode=WAL для лучшей concurrency
|
||
# - M2M использует числовые FK (INT) вместо строк
|
||
# - Slug'и как UNIQUE indexed fields (не primary_key) для экономии места
|
||
|
||
from django.db import models
|
||
from django.db.models import F
|
||
from django.utils.text import slugify
|
||
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=False,
|
||
blank=False,
|
||
on_delete=models.DO_NOTHING,
|
||
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='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)',
|
||
)
|
||
i_img_sort = models.IntegerField(
|
||
# Порядок (сортировка) вывода
|
||
default=0,
|
||
db_index=True,
|
||
verbose_name='Сортировка',
|
||
help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать'
|
||
' для указания обложки (0), задника (1) и т.д.',
|
||
)
|
||
f_img_confidence_score = models.FloatField(
|
||
# Доверие данным (для парсеров и API)
|
||
null=True,
|
||
blank=True,
|
||
default=10.0,
|
||
verbose_name='Достоверность',
|
||
help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение',
|
||
)
|
||
s_img_copyright = models.CharField(
|
||
# Авторские права и лицензия (по идее -- ненужное поле. Можно в filer использовать `obj.image.author`.
|
||
max_length=255,
|
||
blank=True,
|
||
default='',
|
||
editable=False, # Поле не редактируется. Кандидат на удаление.
|
||
verbose_name='Авторские права / Лицензия',
|
||
help_text='Например: "© 2024 User" или "CC-BY"',
|
||
)
|
||
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 = ('-t_img_created', 'i_img_sort',)
|
||
|
||
|
||
# ============================================================================
|
||
# МУЗЫКАЛЬНЫЕ СТИЛИ
|
||
# ============================================================================
|
||
class TbMusicStyle(models.Model):
|
||
"""
|
||
Музыкальный стиль (канонический / опорный).
|
||
|
||
Один главный стиль может иметь несколько синонимов (из Discogs).
|
||
Пример:
|
||
- Главный: "Rock"
|
||
- Синонимы: ["rock", "Rock Music", "Rock & Roll", "Hard Rock", ...]
|
||
|
||
Примечание:
|
||
- Slug автоматически генерируется из s_style_name в методе save()
|
||
"""
|
||
# Используем SmallAutoField для оптимизации (макс ~32k)
|
||
# Стилей обычно 100-1000, поэтому 2 байта достаточно
|
||
id = models.SmallAutoField(primary_key=True)
|
||
s_style_name = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name='Стиль (канонический)',
|
||
help_text='Основное название стиля. Например: "Rock", "Jazz", "Classical"',
|
||
)
|
||
s_style_slug = models.SlugField(
|
||
max_length=50,
|
||
unique=True,
|
||
db_index=True, # Индекс для быстрого поиска по слагу (но НЕ primary_key!)
|
||
editable=False, # Не дать админу редактировать вручную (автогенерируется)
|
||
verbose_name='Слаг (уникальный идентификатор)',
|
||
help_text='Автоматически генерируется из названия. Используется в URL и API.',
|
||
)
|
||
j_style_synonyms = models.JSONField(
|
||
default=list,
|
||
blank=True,
|
||
verbose_name='Синонимы из источников',
|
||
help_text='Список вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||
' Пример: ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]',
|
||
)
|
||
t_style_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания")
|
||
t_style_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления")
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""
|
||
Автоматически генерируем slug из названия стиля.
|
||
Вызывается при каждом сохранении записи (создание или обновление).
|
||
"""
|
||
# Если slug не установлен (новая запись) — генерируем его из названия
|
||
if not self.s_style_slug:
|
||
# Генерируем базовый slug (Rock → rock, Rock Music → rock-music)
|
||
base_slug = slugify(self.s_style_name, allow_unicode=True)
|
||
|
||
# Проверяем на уникальность и добавляем счетчик если нужно
|
||
# Это гарантирует, что slug будет уникален даже для похожих названий
|
||
slug = base_slug
|
||
counter = 1
|
||
while TbMusicStyle.objects.filter(s_style_slug=slug).exclude(pk=self.pk).exists():
|
||
slug = f"{base_slug}-{counter}"
|
||
counter += 1
|
||
|
||
self.s_style_slug = slug
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return self.s_style_name
|
||
|
||
class Meta:
|
||
verbose_name = 'Музыкальный стиль'
|
||
verbose_name_plural = 'Музыкальные стили'
|
||
ordering = ('s_style_name',)
|
||
|
||
|
||
# ============================================================================
|
||
# СТАТЬИ (любая текстовая информация о релизе, исполнителе, продавце и т.д...)
|
||
# а так же новости, блог, тексты о спец-предложениях и т.д.)
|
||
# ============================================================================
|
||
class TbArticle(models.Model):
|
||
"""
|
||
Статья, связанная с релизом, исполнителем, продавцом и т.д.
|
||
Может быть использована для хранения любой текстовой информации, например, описания релиза из Википедии или
|
||
Discogs, биографии исполнителя, описания продавца и т.д. Сохранение типографирования и спецсимволов в HTML.
|
||
"""
|
||
class ArticleType(models.TextChoices):
|
||
ARTIST = 'artist', 'Artis: артист, группа или бренд'
|
||
ITEM = 'item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'
|
||
OFFER = 'offer', 'Offer: конкретное предложение от продавца'
|
||
SELLER = 'seller', 'Seller: продавец или магазин'
|
||
BLOG = 'blog', 'Новость или блог'
|
||
ACTION = 'action', 'Спецпредложение, акция, распродажа и т.д.'
|
||
TO_MAIN = 'to_main', 'Текст/Блок для главной страницы'
|
||
ADV = 'adv', 'Реклама или баннер'
|
||
OTHER = '???', 'Другое'
|
||
|
||
s_article_title = models.CharField(
|
||
max_length=255,
|
||
blank=False,
|
||
default='',
|
||
unique=True,
|
||
verbose_name='Технический заголовок',
|
||
help_text='Технический заголовок статьи для внутреннего использования, например: "Album: Abbey Road"'
|
||
' или "Bio: The Beatles".'
|
||
)
|
||
l_article_type = models.CharField(
|
||
max_length=7,
|
||
blank=True,
|
||
choices=ArticleType.choices,
|
||
default=ArticleType.OTHER,
|
||
db_index=True,
|
||
verbose_name='Тип статьи',
|
||
)
|
||
b_article_published = models.BooleanField(
|
||
default=True,
|
||
db_index=True,
|
||
verbose_name='Опубликовано',
|
||
)
|
||
t_article_started = models.DateTimeField(
|
||
auto_now_add=True,
|
||
db_index=True,
|
||
verbose_name='Дата начала публикации',
|
||
)
|
||
t_article_ended = models.DateTimeField(
|
||
blank=True,
|
||
null=True,
|
||
default=None,
|
||
db_index=True,
|
||
verbose_name='Дата окончания публикации',
|
||
help_text='Если указано, статья будет отображаться только между датой начала и датой окончания публикации.'
|
||
' Если не указано, статья будет отображаться всегда (или до тех пор, пока не будет удалена '
|
||
' или снята с публикации через `b_article_published`)',
|
||
)
|
||
s_article_title_html = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
default='',
|
||
verbose_name='Заголовок',
|
||
help_text='Заголовок статьи, например: "Описание релиза Abbey Road" или "Биография группы The Beatles".'
|
||
' Может содержать HTML-разметку для типографирования (html-мнемоники и -теги). Если не указано,'
|
||
' будет отображаться без заголовка.'
|
||
)
|
||
k_article_to_image = models.ForeignKey(
|
||
TbImage,
|
||
on_delete=models.SET_NULL,
|
||
related_name='image_to_article',
|
||
blank=True,
|
||
null=True,
|
||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||
verbose_name='Изображение для статьи',
|
||
)
|
||
s_article_teaser_html = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
default='',
|
||
verbose_name='Тизер статьи',
|
||
help_text='Короткий анонс статьи, который будет отображаться в списках. Может содержать HTML-вёрсту (теги,'
|
||
' мнемоники, спецсимволы) для типографирования.',
|
||
)
|
||
s_article_content_html = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
default='',
|
||
verbose_name='Статья',
|
||
help_text='Полный текст статьи. Может содержать HTML-вёрсту (теги, мнемоники, спецсимволы) для'
|
||
' типографирования.',
|
||
)
|
||
k_article_to_styles = models.ManyToManyField(
|
||
TbMusicStyle,
|
||
blank=True,
|
||
related_name='style_to_article',
|
||
db_index=True,
|
||
verbose_name='Музыкальные стили',
|
||
help_text='Стили этой статьи/артиста/релиза (Rock, Jazz, Classical, ...)',
|
||
)
|
||
i_article_views = models.IntegerField(
|
||
# Счетчик просмотров (включая просмотры артиста, итема/релиза/товара, лейбла и продавца)
|
||
default=0,
|
||
db_index=True, # для сортировки "самые просматриваемые"
|
||
verbose_name='Число просмотров',
|
||
)
|
||
i_article_favorites = models.IntegerField(
|
||
# Счетчик добавлений в избранное (включая избранное артиста, итема/релиза/товара, лейбла и продавца)
|
||
default=0,
|
||
db_index=True, # для сортировки "самые добавляемые в избранное"
|
||
verbose_name='Число в избранном',
|
||
)
|
||
slug = models.SlugField(
|
||
max_length=255,
|
||
blank=False,
|
||
default='',
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name='Слаг статьи',
|
||
)
|
||
seo_title = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
default='',
|
||
verbose_name='SEO Title',
|
||
help_text='SEO Title для статьи. Если не указано, будет использоваться заголовок статьи'
|
||
' (s_article_title_html) без HTML-тегов.',
|
||
)
|
||
seo_description = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
default='',
|
||
verbose_name='SEO Description',
|
||
help_text='SEO Description для статьи. Если не указано, будет использоваться обрезанный тизер статьи'
|
||
' (s_article_teaser_html) без HTML-тегов.',
|
||
)
|
||
seo_keywords = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
default='',
|
||
verbose_name='SEO Keywords',
|
||
help_text='SEO Keywords для статьи, через запятую. Например: "The Beatles, Abbey Road, Vinyl, 1969"',
|
||
)
|
||
t_article_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
|
||
t_article_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"article {self.id:0>4}: {self.s_article_title}"
|
||
|
||
def increment_views(self):
|
||
"""Безопасный инкремент просмотров (статьи, артиста, лейбла, продавца, товара/релиза/альбома...)"""
|
||
TbArticle.objects.filter(id=self.id).update(
|
||
i_article_views=F('i_article_views') + 1
|
||
)
|
||
|
||
def increment_favorites(self):
|
||
"""Безопасный инкремент добавлений в избранное (статьи, артиста, лейбла, продавца, товара/релиза/альбома...)"""
|
||
TbArticle.objects.filter(id=self.id).update(
|
||
i_article_favorites=F('i_article_favorites') + 1
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = 'Статья'
|
||
verbose_name_plural = 'Статьи'
|
||
ordering = ('-t_article_updated', '-t_article_created', 's_article_title')
|
||
indexes = [
|
||
# Составной индекс: найти опубликованные статьи по типу, отсортированные по свежести (для витрины)
|
||
models.Index(fields=['l_article_type', 'b_article_published', '-t_article_created'],
|
||
name='idx_articles_by_type_published'),
|
||
]
|
||
|
||
|
||
# ============================================================================
|
||
# ИСПОЛНИТЕЛИ
|
||
# ============================================================================
|
||
class TbArtist(models.Model):
|
||
"""Исполнитель или музыкальная группа."""
|
||
# Используем SmallAutoField для оптимизации (макс ~32k)
|
||
# Артистов в базе может быть несколько тысяч, достаточно
|
||
id = models.SmallAutoField(primary_key=True)
|
||
s_artist = models.CharField(
|
||
max_length=128,
|
||
unique=True,
|
||
verbose_name='Исполнитель',
|
||
help_text='Техническое название исполнителя для внутреннего использования, например: "The Beatles" или'
|
||
'"David Bowie".'
|
||
)
|
||
k_artist_to_article = models.OneToOneField(
|
||
TbArticle,
|
||
on_delete=models.SET_NULL,
|
||
related_name='article_to_artist',
|
||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||
default=None,
|
||
null=True,
|
||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||
verbose_name='Связанная статья',
|
||
help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же'
|
||
' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.'
|
||
)
|
||
j_artist_metadata = models.JSONField(
|
||
default=list,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name='Метаданные JSON',
|
||
help_text='Включая варианты написания в источниках Список вариантов: ["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}"
|
||
|
||
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_article = models.OneToOneField(
|
||
TbArticle,
|
||
on_delete=models.SET_NULL,
|
||
related_name='article_to_item',
|
||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||
default=None,
|
||
null=True,
|
||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||
verbose_name='Связанная статья',
|
||
help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.'
|
||
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.'
|
||
)
|
||
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, если он там есть. Например: <tt>306323</tt>',
|
||
)
|
||
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}"
|
||
|
||
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" или "Мелодия"',
|
||
)
|
||
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 атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.'
|
||
)
|
||
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, 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}"
|
||
|
||
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 = '???', 'Другое'
|
||
|
||
# Используем 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='Техническое название продавца или магазина. Например: <tt>Клюква Рекодс</tt>. Может совпадать'
|
||
' с названием продавца, если лейбл сам реализует свои издания через сайт.',
|
||
)
|
||
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 атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для 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}"
|
||
|
||
class Meta:
|
||
verbose_name = 'Продавец'
|
||
verbose_name_plural = 'Продавцы'
|
||
ordering = ('s_seller',)
|
||
|
||
|
||
# ============================================================================
|
||
# ФОРМАТЫ НОСИТЕЛЕЙ
|
||
# ============================================================================
|
||
class TbFormat(models.Model):
|
||
"""
|
||
Формат носителя (LP, CD, Cassette и т.д.).
|
||
|
||
Используем SmallAutoField для оптимизации (макс ~32k).
|
||
Форматов обычно 50-100, поэтому 2 байта более чем достаточно.
|
||
"""
|
||
# Используем SmallAutoField для оптимизации
|
||
id = models.SmallAutoField(primary_key=True)
|
||
s_format = models.CharField(
|
||
max_length=16,
|
||
blank=False,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name='Формат носителя',
|
||
help_text='Название формата носителя, например: "LP", "CD", "Blu-ray", "Compact Cassette", "MiniDisc",'
|
||
' "Hi-Fi", "Accessory" и т.д.',
|
||
)
|
||
s_format_slug = models.SlugField(
|
||
max_length=16,
|
||
blank=False,
|
||
unique=True,
|
||
verbose_name='Слаг',
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"format: {self.s_format}"
|
||
|
||
class Meta:
|
||
verbose_name = 'Формат носителя'
|
||
verbose_name_plural = 'Форматы носителей'
|
||
ordering = ('s_format',)
|
||
|
||
|
||
# ============================================================================
|
||
# ПРЕДЛОЖЕНИЯ / ОФФЕРЫ
|
||
# ============================================================================
|
||
class TbOffer(models.Model):
|
||
"""
|
||
Конкретное предложение от продавца.
|
||
Один и тот же релиз может быть несколько раз в системе от разных продавцов.
|
||
"""
|
||
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'
|
||
|
||
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 синяя-градиент)"'
|
||
)
|
||
# Связи
|
||
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 атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>МОЖНО НЕ УКАЗЫВАТЬ</b> т.к. 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,
|
||
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='Лейбл, на котором был выпущен релиз, если он известен. Например: <tt>Atlantic</tt> или <tt>Мелодия</tt>',
|
||
)
|
||
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',
|
||
)
|
||
k_offer_to_image = models.ManyToManyField(
|
||
# Изображения товара (одна картинка может быть у многих офферов, а оффер иметь много картинок)
|
||
# M2M связь для удобства в админке (filter_horizontal)
|
||
# Порядок картинок определяется полем i_img_sort в TbImage
|
||
TbImage,
|
||
blank=True,
|
||
related_name='image_to_offer',
|
||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||
verbose_name='Изображения',
|
||
help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка<D0BA><D0B0>тинке.',
|
||
)
|
||
# Характеристики
|
||
s_offer_catalog_num = models.TextField(
|
||
blank=True,
|
||
default='',
|
||
verbose_name='Каталожный номер / Barcode',
|
||
help_text='Например: "SD 16023" или "5099923452355"',
|
||
)
|
||
k_offer_to_format = models.ManyToManyField(
|
||
TbFormat,
|
||
blank=True,
|
||
related_name='format_to_offer', # ← format.format_to_offers.all()
|
||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||
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, если он там есть. Например: <tt>306323</tt>',
|
||
)
|
||
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='Скидка',
|
||
help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано'
|
||
' <tt>0</tt> то данное предложение не может участвовать в распродажах, спецпредложениях и акциях',
|
||
)
|
||
j_offer_metadata = models.JSONField(
|
||
# Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.)
|
||
default=dict, null=True,
|
||
verbose_name='Дополнительные данные',
|
||
help_text='Дополнительные данные о предложении в виде JSON-словаря.',
|
||
)
|
||
s_offer_skip32 = models.CharField(
|
||
max_length=12,
|
||
unique=True,
|
||
verbose_name='Код товара',
|
||
help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).'
|
||
' Например: "4gfFCJ". Формируется автоматически связкой Skip32 (хаотичное перемешивание) и'
|
||
' Base62 (компактная упаковка) из id оффера в методе save().',
|
||
)
|
||
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"offer {self.id:0>4} for item {self.k_offer_to_item_id} from seller {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
|
||
)
|
||
|
||
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 = '??', 'Другое'
|
||
|
||
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'
|
||
|
||
# Используем 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='Продавец',
|
||
)
|
||
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='Название источника данных (для удобства), например: <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-файл.',
|
||
)
|
||
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(
|
||
auto_now_add=True,
|
||
db_index=True,
|
||
editable=False,
|
||
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',)
|
||
indexes = [
|
||
# Составной индекс: найти историю оффера, отсортированную по времени (для хронологии изменений цены)
|
||
models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'),
|
||
]
|
||
|