# LPON Store — Django E-Commerce Database Schema (SQLite optimized) # # ER-ДИАГРАММА СХЕМЫ БД (v2.0 - переделана правильно!) # # Легенда: # PK = Primary Key FK = Foreign Key M2M = Many-to-Many # 1:1 = OneToOne связь 1:M = One-to-Many M:M = Many-to-Many # # ВАЖНО: Диаграмма разделена по логическим секциям, см. ниже! # # ════════════════════════════════════════════════════════════════════════════════ # ЦЕНТР СХЕМЫ: СТАТЬИ (TbArticle) — ИНДЕКСНАЯ ТАБЛИЦА ДЛЯ ВСЕХ СПРАВОЧНИКОВ # ════════════════════════════════════════════════════════════════════════════════ # # TbArticle — главная таблица для всех текстовых описаний, SEO, контента: # - Соединитель для Artist, Item, Label, Seller, Style (все 1:1 в статьи) # - Прямая ссылка на filer.Image для обложки # - M2M к TbMusicStyle (теги стилей) # # ════════════════════════════════════════════════════════════════════════════════ # СПРАВОЧНИКИ (все связаны с TbArticle через OneToOne) # ════════════════════════════════════════════════════════════════════════════════ # # TbArtist (1:1→TbArticle) + M2M←→TbItem через k_item_to_artist (коллаборации) # TbItem (1:1→TbArticle) + M2M←→TbMusicStyle через k_item_to_style (жанры) # TbLabel (1:1→TbArticle) # TbSeller (1:1→TbArticle) + 1:M→TbSource (источники данных от продавца) # TbMusicStyle (1:1→TbArticle) + M2M←→TbArticle через k_article_to_styles (теги статей) # # ════════════════════════════════════════════════════════════════════════════════ # КОММЕРЧЕСКИЕ ТАБЛИЦЫ: Управление предложениями и их историей # ════════════════════════════════════════════════════════════════════════════════ # # TbSource (1:M←TbSeller) # └─→ TbOffer (1:M) # ├─ 1:1→TbItem (FK что именно продавать) # ├─ 1:1→TbLabel (FK от какого издателя) # ├─ 1:1→TbArticle (опцион, для уникальной статьи предложения) # ├─ M2M←→TbImageMetadata (картинки оффера) # │ └─ TbImageMetadata (промежуточная таблица M2M offer←→Image) # │ └─ 1:1→filer.Image (FK на файл из django_filer) # │ └─ m_offer: FK→TbOffer (часть M2M) # │ └─ i_img_sort: порядок сортировки # │ └─ j_img_metadata: JSON метаданные (тип, источник и т.д.) # │ # └─ 1:M→TbOfferHistory (история цены/кол-ва) # └─ t_history_created: Timestamp (редактируется для импорта исторических данных) # # ════════════════════════════════════════════════════════════════════════════════ # СПРАВОЧНИК ПОЛЕЙ ВСЕХ МОДЕЛЕЙ # ════════════════════════════════════════════════════════════════════════════════ # # TbArticle (текстовый контент и SEO): # id, s_article_title (уникальный), l_article_type, b_article_published # s_article_title_html, k_article_to_image (→filer.Image) # k_article_to_styles (M2M→TbMusicStyle), i_article_views, i_article_favorites # slug (уникальный, для URL), seo_title, seo_description, s_article_teaser_html # s_article_content_html, t_article_started, t_article_ended, t_article_created, t_article_updated # # TbArtist (1:1→TbArticle, M2M←TbItem.k_item_to_artist): # id, s_artist, k_artist_to_article (1:1→TbArticle) # t_artist_created, t_artist_updated # # TbItem (1:1→TbArticle, M2M: →TbArtist, →TbMusicStyle): # id, s_item, k_item_to_artist (M2M), k_item_to_style (M2M) # k_item_to_article (1:1→TbArticle), s_item_date (текстовая дата) # t_item_date (базовая дата), i_discogs_master_id # t_item_created, t_item_updated # # TbLabel (1:1→TbArticle, ←TbOffer.k_offer_to_label): # id, s_label, k_label_to_article (1:1→TbArticle) # t_label_created, t_label_updated # # TbSeller (1:1→TbArticle, 1:M→TbSource): # id, s_seller (уникальный), l_seller_currency, l_seller_type # k_seller_to_article (1:1→TbArticle), j_seller_metadata (JSON) # t_seller_created, t_seller_updated # # TbMusicStyle (1:1→TbArticle, M2M: ←TbArticle, ←TbItem): # id, s_style_name, k_style_to_article (1:1→TbArticle) # j_style_metadata (JSON синонимы Discogs), t_style_created, t_style_updated # # TbSource (1:M←TbSeller, 1:M→TbOffer): # id, k_source_to_seller (1:M←), l_source_type, s_source_name # source_file (FilerFileField), s_source_url, t_source_data # j_source_metadata (JSON структура источника), t_source_created, t_source_updated # # TbOffer (1:M←TbSource, FK: TbItem, TbLabel, TbArticle, M2M: ←TbImageMetadata): # id, s_offer (название оффера) # k_offer_to_item (1:1→TbItem), k_offer_to_label (1:1→TbLabel) # k_offer_to_source (1:M←TbSource), k_offer_to_article (1:1→TbArticle, опцион) # m_image (M2M←TbImageMetadata) # l_offer_to_format (формат: LP, CD, Vinyl и т.д.) # b_offer_is_preorder, d_offer_date_release, l_offer_condition_media, l_offer_condition_sleeve # f_offer_price, i_offer_quantity, i_offer_discount_to_daily_sale # i_offer_discogs_id, s_offer_code (hashids, уникальный, генерируется автоматически) # j_offer_metadata (JSON), i_offer_views, i_offer_favorites # t_offer_created, t_offer_updated # # TbOfferHistory (1:M←TbOffer, только история цены/кол-ва): # id, k_history_to_offer (1:M←TbOffer) # f_history_price (старая цена), i_history_quantity (старое кол-во) # j_history_metadata (JSON координаты в источнике) # t_history_created (Timestamp, РЕДАКТИРУЕТСЯ ДЛЯ ИМПОРТА исторических данных!) # # TbImageMetadata (промежуточная M2M offer←→Image): # id, k_image_to_image (FK→filer.Image), m_offer (FK→TbOffer) # i_img_sort (порядок сортировки) # j_img_metadata (JSON: тип, источник, ссылка, заметка) # t_img_metadata_created, t_img_metadata_updated # # ════════════════════════════════════════════════════════════════════════════════ # МЕТОДЫ И АВТОМАТИКА МОДЕЛЕЙ # ════════════════════════════════════════════════════════════════════════════════ # # TbArticle: # • save(): генерирует slug автоматически (если не установлен) # • increment_views(): безопасный инкремент просмотров # • increment_favorites(): безопасный инкремент добавлений в избранное # • Если привязана картинка без метаданных → создает TbImageMetadata # # TbArtist, TbItem, TbLabel, TbSeller, TbMusicStyle: # • save(): создает/обновляет связанную TbArticle автоматически # • create_or_get_related_article(): хелпер для создания статьи # # TbOffer: # • save(): генерирует s_offer_code (hashids) при создании нового оффера # • save(): отслеживает изменения цены/кол-ва → создает TbOfferHistory # • Двухэтапное сохранение: super().save() → кодирование → update(s_offer_code=...) # • Не обновляет код при изменении (только при создании) # # TbOfferHistory: # • Создается автоматически в save() TbOffer # • Сравнивает последнюю запись истории с текущей ценой/кол-вом # • Новая запись создается ТОЛЬКО если произошли изменения # • t_history_created редактируется для импорта исторических данных # # TbImageMetadata: # • Промежуточная таблица M2M (не имеет собственной автоматики) # • Заполняется вручную через админку или парсерами # # ════════════════════════════════════════════════════════════════════════════════ # # Справочники (5): # TbArticle - индексная таблица для всех текстовых описаний # TbArtist, TbItem, TbLabel, TbSeller - справочники (все 1:1→article) # TbMusicStyle - музыкальные стили (1:1→article, M2M←article, M2M←item) # # Коммерческие (5): # TbSource - источники данных (1:M←seller) # TbOffer - предложения товаров (1:M←source, FK→item/label/article, M2M←imagemetadata) # TbOfferHistory - история изменений цены/кол-ва (1:M←offer) # TbImageMetadata - метаданные изображений (M2M offer←→Image) # # # ОПТИМИЗАЦИЯ ДЛЯ SQLite: # - db_index=True на все FK поля (SQLite не создает их автоматически) # - Составные индексы на часто используемые комбинации # - PRAGMA auto_vacuum=2 для невручного сокращения файла БД # - PRAGMA journal_mode=WAL для лучшей concurrency # - M2M использует числовые FK (INT) вместо строк # - Slug'и как UNIQUE indexed fields (не primary_key) для экономии места import base64 import datetime import logging from hashids import Hashids from django.db import models from django.db.models import F from django.utils import timezone from filer.fields.image import FilerImageField from filer.fields.file import FilerFileField from frontend.utils import make_slug, update_synonyms_in_metadata, create_or_get_related_article from frontend.utils_validators import validate_and_raise_for_duplicates from lpon_site.settings import ( KEY_IMAGE_TYPE, # Ключ, о том, какой тип изображения (реальное или абстрактное) VALUE_IMAGE_ABSTRACT, # Реальная картинка (актуально, для фото товаров в TbOffer) KEY_IMAGE_FROM, # Ключ, о том, как получено изображение VALUE_IMAGE_FROM_USER, # Картинка загружена пользователем через админку KEY_IMAGE_URL, # Ключ, о том, откуда получена картинка (URL источника) KEY_IMAGE_NOTE, # Ключ, для заметок о картинке (например, "обложка", "задник", "вкладка" и т.д.) KEY_OFFER_NOTE, # Ключ, для заметок о коммерческом предложении (например, "замят угол конверта" и т.д.) KEY_OFFER_ALL_MEDIA, # Ключ, для хранения информации о том, какие носители входят в оффер (например, CD+2LP) OFFER_HASHIDS_SALT, # Соль для криптографического кодирования ID оферов OFFER_HASHIDS_MIN_LENGTH, # Минимальная длина кода ) logger = logging.getLogger(__name__) # ============================================================================ # ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ DEFAULTS В МОДЕЛЯХ # ============================================================================ def get_image_metadata_default(): """ Возвращает значение по умолчанию для j_img_metadata. Используется как callable default в Django моделях (Django не может сериализовать lambda). """ return { KEY_IMAGE_TYPE: VALUE_IMAGE_ABSTRACT, KEY_IMAGE_FROM: VALUE_IMAGE_FROM_USER, KEY_IMAGE_URL: None, KEY_IMAGE_NOTE: '???', } def get_offer_metadata_default(): """ Возвращает значение по умолчанию для j_offer_metadata. Используется как callable default в Django моделях (Django не может сериализовать lambda). """ return { KEY_OFFER_NOTE: '', KEY_OFFER_ALL_MEDIA: [], } # ============================================================================ # МЕТАДАННЫЕ ИЗОБРАЖЕНИЙ # ============================================================================ class TbImageMetadata(models.Model): """ Метаданные изображений для офферов (промежуточная таблица M2M). Поля: • image (FK→filer.Image): файл изображения из django_filer [indexed] • m_offer (FK→TbOffer): оффер, к которому привязана картинка [nullable, indexed] • i_img_sort (int): порядок сортировки (0=обложка, 1=задник и т.д.) [indexed] • j_img_metadata (JSON): метаданные (тип, источник, ссылка, заметка) • t_img_metadata_created, t_img_metadata_updated (Timestamps) Использование: • offer.m_image.all() — все картинки оффера • offer.m_image.all().order_by('i_img_sort') — отсортированные картинки • image.metadata (через related_name) — доступ к метаданным """ image = FilerImageField( # Связь в M2M с filer.Image (часть промежуточной таблицы) # CASCADE: если удалится картинка → удалить метаданные (метаданные без картинки бессмысленны) null=False, blank=False, on_delete=models.CASCADE, related_name='m_offer', # Встречный доступ: filer_image.m_offer.all() verbose_name='Файл изображения', help_text='Файл изображения из django_filer.', ) i_img_sort = models.IntegerField( # Порядок (сортировка) вывода изображений default=0, db_index=True, verbose_name='Сортировка', help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. ' 'Можно использовать для указания обложки (0), задника (1) и т.д.', ) j_img_metadata = models.JSONField( # Гибкие дополнительные данные об изображении с предзаполненной структурой # Пример: {"IMG_IS": "abstract", "IMG_FROM": "ivan", "IMG_URL": None, "IMG_NOTE": "обложка"} default=get_image_metadata_default, blank=True, null=False, # Изменено с True на False т.к. теперь всегда есть структура default verbose_name='Метаданные', help_text='JSON с дополнительными данными: тип (реальное/абстрактное), источник (парсер/админка), ' 'URL источника и заметки. Предзаполнено структурой для удобства.', ) # Связь с TbOffer (часть M2M через эту промежуточную таблицу) m_offer = models.ForeignKey( 'TbOffer', on_delete=models.SET_NULL, # SET_NULL: если удалится оффер → метаданные остаются (картинка же еще есть!) null=True, blank=True, related_name='m_image', # Встречный доступ: offer.m_image.all() verbose_name='Оффер', help_text='Оффер, к которому привязана эта картинка (может быть пусто, если оффер удален)', ) class Meta: verbose_name = 'Метаданные изображения' verbose_name_plural = 'Метаданные изображений' ordering = ('i_img_sort',) # ============================================================================ # СТАТЬИ (любая текстовая информация о релизе, исполнителе, продавце и т.д...) # а так же новости, блог, тексты о спец-предложениях и т.д.) # ============================================================================ class TbArticle(models.Model): """ Центральная таблица текстового контента, SEO и описаний. Поля: • id (PK), s_article_title (str, уникальный): технический заголовок • l_article_type (choice): тип статьи (artist, item, offer, seller, style, blog и т.д.) • b_article_published (bool): опубликовано ли [indexed] • s_article_title_html (str): HTML-заголовок для отображения • k_article_to_image (FK→filer.Image): обложка статьи [nullable, indexed] • k_article_to_styles (M2M→TbMusicStyle): теги стилей статьи • slug (str, уникальный): URL-идентификатор [indexed] • seo_title, seo_description (str): SEO метаданные • s_article_teaser_html, s_article_content_html (text): тизер и полный текст статьи • i_article_views, i_article_favorites (int): счётчики [indexed] • t_article_started, t_article_ended, t_article_created, t_article_updated (datetime) Методы: • save(): генерирует slug автоматически из s_article_title (если не установлен) • increment_views(): безопасный инкремент просмотров (F-выражение) • increment_favorites(): безопасный инкремент добавлений в избранное Использование: • Соединитель для Artist, Item, Label, Seller, Style (все 1:1→article) • Хранит текстовый контент, SEO, изображения справочников """ class ArticleType(models.TextChoices): ARTIST = 'artist', 'Artis: артист, группа или бренд' STYLE = 'style', 'Slyle: музыкальный стиль' ITEM = 'item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)' LABEL = 'label', 'Label: Лейбл, издатель или компания' 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 = FilerImageField( # Прямая ссылка на файл изображения из django_filer. # Метаданные об этом изображении (сортировка, источник, тип и т.д.) хранятся в TbImageMetadata.k_image_to_image on_delete=models.SET_NULL, related_name='image_to_article', blank=True, null=True, db_index=True, verbose_name='Изображение для статьи', help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer).' ' Для получения метаданных: TbImageMetadata.objects.filter(k_image_to_image=article.k_article_to_image).
' 'ВАЖНО: Так как статья привязывается к исполнителям, лейблам, продавцам, музыкальным стилям,' ' её изображение можно использовать как логотип, пиктограмму или баннер этих сущностей. ' 'Рекомендуется использовать прозрачный фон (движок поддерживает SVG, WebP и PNG).' ) 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-вёрсту (теги, мнемоники, спецсимволы) для' ' типографирования.', ) i_article_views = models.IntegerField( # Счетчик просмотров (включая просмотры артиста, итема/релиза/товара, лейбла и продавца) default=0, db_index=True, # для сортировки "самые просматриваемые" verbose_name='Число просмотров', ) i_article_favorites = models.IntegerField( # Счетчик добавлений в избранное (включая избранный артист, item/релиз/товар/лейбл/продавец) 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 ) def save(self, *args, **kwargs): """ Автоматически генерируем slug на основе заголовка статьи. Если привязана картинка и метаданных нет — создаем TbImageMetadata с тегами IMG_IS и IMG_FROM. Вызывается при каждом сохранении записи (создание или обновление). """ # Если slug не установлен (новая запись) — генерируем его из названия if not self.slug: # Генерируем базовый slug на основе заголовка статьи base_slug = make_slug(self.s_article_title) # Проверяем на уникальность и добавляем счетчик если нужно # Это гарантирует, что slug будет уникален даже для похожих названий slug = base_slug counter = 1 while TbArticle.objects.filter(slug=slug).exclude(pk=self.pk).exists(): slug = f"{base_slug}-{counter}" counter += 1 self.slug = slug # Сохраняем статью в БД (нужно чтобы потом привязать метаданные картинки) super().save(*args, **kwargs) # Если картинка привязана и метаданных для неё нет — создаем их if self.k_article_to_image: # Получаем существующие метаданные (если есть) metadata = TbImageMetadata.objects.filter(image=self.k_article_to_image).first() adding_metadata = {} if not metadata or not KEY_IMAGE_TYPE in (metadata.j_img_metadata or {}): adding_metadata[KEY_IMAGE_TYPE] = VALUE_IMAGE_ABSTRACT if not metadata or not KEY_IMAGE_FROM in (metadata.j_img_metadata or {}): # Получаем username из контекста админки (установлен в ArticleAdmin.save_model()) # Fallback на 'unknown' если username недоступен (например, при программном создании) username = getattr(self, '_admin_username', 'unknown') adding_metadata[KEY_IMAGE_FROM] = username if not metadata or not KEY_IMAGE_URL in (metadata.j_img_metadata or {}): adding_metadata[KEY_IMAGE_URL] = None if not metadata or not KEY_IMAGE_NOTE in (metadata.j_img_metadata or {}): adding_metadata[KEY_IMAGE_NOTE] = None if not metadata: metadata = TbImageMetadata.objects.create( image=self.k_article_to_image, i_img_sort=0, j_img_metadata=adding_metadata ) else: # Обновляем только недостающие ключи в j_img_metadata if adding_metadata: metadata.j_img_metadata.update(adding_metadata) metadata.save(update_fields=['j_img_metadata']) # Заполняем поля filer.Image если они пусты # Это улучшает SEO и accessibility (для скринридеров, поисковиков) image_needs_update = False # default_alt_text (alt) — компактное описание для скринридеров и SEO if not self.k_article_to_image.default_alt_text: # Приоритет: KEY_IMAGE_NOTE → seo_title (оптимизирован для SEO) → fallback alt_text = ( metadata.j_img_metadata.get(KEY_IMAGE_NOTE) or self.seo_title or self.s_article_title[:60] ) self.k_article_to_image.default_alt_text = alt_text image_needs_update = True # default_caption (title) — подробное описание с контекстом if not self.k_article_to_image.default_caption: # Используем seo_description (оптимизирован для SEO) с добавлением типа и источника caption = self.seo_description or self.s_article_title[:80] # Добавляем метаинформацию для полноты caption = ( f"{caption}. " f"Type: {metadata.j_img_metadata[KEY_IMAGE_TYPE]}. " f"Source: {metadata.j_img_metadata[KEY_IMAGE_FROM]}" ) self.k_article_to_image.default_caption = caption image_needs_update = True # Сохраняем картинку, если были изменения if image_needs_update: self.k_article_to_image.save() # Примечание: m_offer остаётся NULL т.к. это статья, а не оффер 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 TbMusicStyle(models.Model): """ Музыкальные стили и жанры. Поля: • id (SmallPK): оптимизировано (до ~32k стилей) • s_style_name (str, уникальный): название стиля (Rock, Jazz и т.д.) • k_style_to_article (1:1→TbArticle): связанная статья (SEO, слаг, картинка) • j_style_metadata (JSON): синонимы из Discogs для матчинга при импорте • t_style_created, t_style_updated (datetime) Связи: • M2M←TbArticle.k_article_to_styles: теги стилей к статьям • M2M←TbItem.k_item_to_style: жанры альбомов """ # Используем 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"', ) k_style_to_article = models.OneToOneField( TbArticle, on_delete=models.SET_NULL, related_name='article_to_style', db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. default=None, null=True, blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически. verbose_name='Связанная статья', help_text='Связанная статья о музыкальном стиле (Типографированные заголовок, тизер и текст статьи. Так же' ' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL музыкального стиля.' ) j_style_metadata = models.JSONField( default=dict, blank=True, verbose_name='Метаданные', help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.' ' Пример: {"SYN_EN": ["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 __str__(self): return self.s_style_name def save(self, *args, **kwargs): """ Переопределяем save для управления синонимами музыкального стиля и создания связанной статьи. При сохранении музыкального стиля (создание и обновление): 1. Управляем синонимами: - Для новых музыкальных стилей: добавляем текущий s_style_name в SYN_EN - При изменении s_style_name: добавляем как старый, так и новый s_style_name в SYN_EN - При редактировании: используем j_style_metadata из формы (приоритет админу) 2. Если статья не привязана - создаём новую статью музыкального стиля автоматически 3. Генерируем технический заголовок и slug для статьи """ # ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ ===== # Проверяем ДО работы с синонимами и метаданными! # Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер validate_and_raise_for_duplicates(self, 's_style_name', 'j_style_metadata') # ===== УПРАВЛЕНИЕ СИНОНИМАМИ ===== # Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей) update_synonyms_in_metadata(self, 's_style_name', 'j_style_metadata') # ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ ===== # Используем универсальный хелпер для создания/поиска статьи # Хелпер сам проверит через обратный FK. Не дублирует статьи, даже если админ переименовал article = create_or_get_related_article( self, TbArticle.ArticleType.STYLE, 's_style_name', 'j_style_metadata', 'k_style_to_article' # ← Явно передаем имя FK поля (избегаем "магии") ) self.k_style_to_article = article # Вызываем оригинальный save родительского класса super().save(*args, **kwargs) class Meta: verbose_name = 'Музыкальный стиль' verbose_name_plural = 'Музыкальные стили' ordering = ('s_style_name',) # ============================================================================ # ИСПОЛНИТЕЛИ # ============================================================================ class TbArtist(models.Model): """ Исполнители и музыкальные группы. Поля: • id (SmallPK): оптимизировано • s_artist (str, уникальный): название исполнителя • k_artist_to_article (1:1→TbArticle): связанная статья (SEO, слаг, картинка) • t_artist_created, t_artist_updated (datetime) Связи: • M2M←TbItem.k_item_to_artist: коллаборации (артист может быть на нескольких альбомах) """ # Используем 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 атрибуты, слаг (обязательно) и т.п.)
' 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL артиста.' ) j_artist_metadata = models.JSONField( default=dict, blank=True, null=True, verbose_name='Метаданные JSON', help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.' ' Пример: {"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}.', ) t_artist_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",) t_artist_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления", ) def __str__(self): return f"artist {self.id:0>4}: {self.s_artist}" def save(self, *args, **kwargs): """ Переопределяем save для управления синонимами исполнителей и создания связанной статьи. При сохранении исполнителя (создание и обновление): 1. Управляем синонимами: - Для новых исполнителей: добавляем текущий s_artist в SYN_EN - При изменении s_artist: добавляем как старый, так и новый s_artist в SYN_EN - При редактировании: используем j_artist_metadata из формы (приоритет админу) 2. Если статья не привязана - создаём новую статью исполнителя автоматически 3. Генерируем технический заголовок и slug для статьи """ # ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ ===== # Проверяем ДО работы с синонимами и метаданными! # Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер validate_and_raise_for_duplicates(self, 's_artist', 'j_artist_metadata') # ===== УПРАВЛЕНИЕ СИНОНИМАМИ ===== # Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей) update_synonyms_in_metadata(self, 's_artist', 'j_artist_metadata') # ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ ===== # Используем универсальный хелпер для создания/поиска статьи # Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал article = create_or_get_related_article( self, TbArticle.ArticleType.ARTIST, 's_artist', 'j_artist_metadata', 'k_artist_to_article' # ← Явно передаем имя FK поля (избегаем "магии") ) self.k_artist_to_article = article # Вызываем оригинальный save родительского класса super().save(*args, **kwargs) class Meta: verbose_name = 'Исполнитель' verbose_name_plural = 'Исполнители' ordering = ('s_artist',) # index_together = ('t_artist_created', 't_artist_updated', 'k_artist_to_article') class TbItem(models.Model): """ Товары в каталоге: релизы (альбомы, синглы), носители, аксессуары. Поля: • id (PK), s_item (str, уникальный): название товара • k_item_to_artist (M2M→TbArtist): исполнители (коллаборации) • k_item_to_style (M2M→TbMusicStyle): жанры альбома • k_item_to_article (1:1→TbArticle): связанная статья (SEO, слаг, картинка) • s_item_date (str): дата релиза (текстовая, неполная) [indexed] • t_item_date (date): нормализованная дата релиза [indexed] • i_discogs_master_id (int): ID мастер-релиза на Discogs • t_item_created, t_item_updated (datetime) Использование: • Один товар может быть в нескольких TbOffer от разных продавцов • M2M с TbArtist для поддержки коллабораций • M2M с TbMusicStyle для жанров """ s_item = models.CharField( max_length=128, unique=True, verbose_name='Товар', help_text='Техническое название товара (альбома, релиза, аксессуара) для внутреннего использования,' 'например: "Abbey Road (LP)" или "TDK CDing I (кассета для записи)".' ) k_item_to_artist = models.ManyToManyField( # Исполнители (ManyToMany для поддержки коллабораций) # Например: "David Bowie & Queen", "Elton John & Tim Rice" TbArtist, blank=True, # Исполнителя может и не быть (например для сборников) related_name='artist_to_item', # artist.products.all() — найти все релизы артиста db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. verbose_name='Исполнители', help_text="Один или несколько для коллабораций", ) k_item_to_style = models.ManyToManyField( # Музыкальные стили (ManyToMany для альбомов с множеством жанров) # Например: "Abbey Road" → [Rock, Progressive Rock, ...] # Позволяет: найти все альбомы стиля / найти стили альбома / найти артистов в стиле TbMusicStyle, blank=True, # Стиль может быть не указан (например для аксессуаров) related_name='style_to_item', # style.style_to_item.all() — найти все релизы в стиле db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. verbose_name='Музыкальные стили', help_text='Один или несколько стилей, характеризующих альбом/товар. Например: Rock, Progressive Rock.', ) k_item_to_article = models.OneToOneField( TbArticle, on_delete=models.SET_NULL, related_name='article_to_item', db_index=True, default=None, null=True, blank=True, verbose_name='Связанная статья', help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.' ' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL альбома/релиза/товара.' ) s_item_date = models.CharField( max_length=10, blank=True, null=True, db_index=True, default='XXXX-XX-XX', verbose_name='Дата релиза (str)', help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только' ' год, или XXXX-XX-XX (если дата релиза неизвестна). Менее приоритетное поле для отображения даты' ' релиза, чем t_release_date, так как может содержать неполную дату и/или текстовую информацию.' ' Срабатывает только если t_release_date не указано.' ) t_item_date = models.DateField( blank=True, null=True, db_index=True, verbose_name='Дата релиза', help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.', ) i_discogs_master_id = models.IntegerField( blank=True, null=True, default=None, verbose_name='ID на мастер-релиз Discogs', help_text='Уникальный идентификатор мастер-релиза на Discogs, если он там есть. Например: 306323', ) j_item_metadata = models.JSONField( default=dict, blank=True, null=True, verbose_name='Дополнительные данные', help_text='Дополнительные данные и метаданные релиза (страна, жанр, количество треков и т.д.) или товавра' ' в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках', ) t_item_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",) t_item_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",) def __str__(self): return f"Item {self.id:0>4}: {self.s_item}" def save(self, *args, **kwargs): """ Переопределяем save для управления синонимами релиза (альбома) или просто товара и создания связанной статьи. При сохранении релиза/альбома (создание и обновление): 1. Управляем синонимами альбома/релиза/товара: - Для новых альбомов/релиза/товара: добавляем в SYN_EN - При изменении s_item: добавляем как старый, так и новый s_item в SYN_EN - При редактировании: используем j_item_metadata из формы (приоритет админу) 2. Если статья не привязана - создаём новую автоматически 3. Генерируем технический заголовок и slug для статьи """ # ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ ===== # Проверяем ДО работы с синонимами и метаданными! # Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер validate_and_raise_for_duplicates(self, 's_item', 'j_item_metadata') # ===== УПРАВЛЕНИЕ СИНОНИМАМИ ===== # Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей) update_synonyms_in_metadata(self, 's_item', 'j_item_metadata') # ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ ===== # Используем универсальный хелпер для создания/поиска статьи # Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал article = create_or_get_related_article( self, TbArticle.ArticleType.ITEM, 's_item', 'j_item_metadata', 'k_item_to_article' # ← Явно передаем имя FK поля (избегаем "магии") ) self.k_item_to_article = article # Вызываем оригинальный save родительского класса super().save(*args, **kwargs) class Meta: verbose_name = 'Товар в каталоге (релиз, носитель, аксессуар)' verbose_name_plural = 'Товары в каталоге' ordering = ('s_item',) # ============================================================================ # ЛЕЙБЛЫ (производители релизов) # ============================================================================ class TbLabel(models.Model): """ Лейблы и издатели релизов. Поля: • id (SmallPK): оптимизировано • s_label (str, уникальный): название лейбла (Sony, Мелодия, TDK, Pioneer и т.д.) • k_label_to_article (1:1→TbArticle): связанная статья (SEO, слаг, картинка) • t_label_created, t_label_updated (datetime) Связи: • 1:M←TbOffer.k_offer_to_label: предложения от этого лейбла """ # Используем SmallAutoField для оптимизации (макс ~32k) # Лейблов обычно несколько сотен-тысяч, достаточно id = models.SmallAutoField(primary_key=True) s_label = models.CharField( max_length=128, blank=False, unique=True, verbose_name='Лейбл', help_text='Название лейбла. Например: "Sony Records" или "Мелодия"... Представление на самом сайте, с версткой,' ' будет определиться через статью, связанную с лейблом (TbArticle).' ) k_label_to_article = models.OneToOneField( TbArticle, on_delete=models.SET_NULL, related_name='article_to_label', db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. default=None, null=True, blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически. verbose_name='Связанная статья', help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи.' ' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL лейбла.' ) j_label_metadata = models.JSONField( default=dict, blank=True, null=True, verbose_name='Метаданные', help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга.' ' Пример: {"SYN_EN": ["Island", "Island Records", "Vertigo France"]}.' , ) t_label_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",) t_label_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",) def __str__(self): return f"label: {self.id:0>5}: {self.s_label}" def save(self, *args, **kwargs): """ Переопределяем save для управления синонимами лейблов и создания связанной статьи. При сохранении лейбла (создание и обновление): 1. Управляем синонимами лейбла: - Для новых лейблов: добавляем текущий s_label в SYN_EN - При изменении s_label: добавляем как старый, так и новый s_label в SYN_EN - При редактировании: используем j_label_metadata из формы (приоритет админу) 2. Если статья не привязана - создаём новую автоматически 3. Генерируем технический заголовок и slug для статьи """ # ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ ===== # Проверяем ДО работы с синонимами и метаданными! # Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер validate_and_raise_for_duplicates(self, 's_label', 'j_label_metadata') # ===== УПРАВЛЕНИЕ СИНОНИМАМИ ===== # Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей) update_synonyms_in_metadata(self, 's_label', 'j_label_metadata') # ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ ===== # Используем универсальный хелпер для создания/поиска статьи # Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал article = create_or_get_related_article( self, TbArticle.ArticleType.LABEL, 's_label', 'j_label_metadata', 'k_label_to_article' # ← Явно передаем имя FK поля (избегаем "магии") ) self.k_label_to_article = article # Вызываем оригинальный save родительского класса super().save(*args, **kwargs) class Meta: verbose_name = 'Лейбл' verbose_name_plural = 'Лейблы' ordering = ('s_label',) # ============================================================================ # ПРОДАВЦЫ / МАГАЗИНЫ # - Валюта привязана к продавцу. Если у продавца несколько валют, # создаем несколько продавцов с разными валютами. # ============================================================================ class TbSeller(models.Model): """ Продавцы и магазины. Поля: • id (SmallPK): оптимизировано • s_seller (str, уникальный): название продавца [indexed] • l_seller_type (choice): тип (seller, label, diy, crowdfunding, other) • l_seller_currency (choice): валюта (RUB, USD, EUR, JPY и т.д.) • k_seller_to_article (1:1→TbArticle): связанная статья (SEO, слаг, картинка, контакты) • j_seller_metadata (JSON): доп. данные (ссылки, контакты, соцсети) • t_seller_created, t_seller_updated (datetime) Связи: • 1:M→TbSource.k_source_to_seller: источники данных от этого продавца • 1:M→TbOffer (через TbSource): предложения товаров """ class SellerType(models.TextChoices): SELLER = 'seller', 'Продавец' LABEL = 'label', 'Лейбл (издатель)' DIY = 'diy', 'Самиздат группы' CROWD = 'crowd', 'Краудфандинг' OTHER = '???', 'Другое' class Currency(models.TextChoices): RUB = 'rub', 'RUB: российский рубль' USD = 'usd', 'USD: американский доллар' EUR = 'eur', 'EUR: евро' AMD = 'amd', 'AMD: армянских драм' TRY = 'try', 'TRY: турецкая лира' JPY = 'jpy', 'JPY: японская иена' GBP = 'gbp', 'GBP: британский фунт' CNY = 'cny', 'CNY: китайский юань' BYN = 'byn', 'BYN: белорусский рубль' TON = 'ton', 'TON: криптовалюта TON' OTHER = '??', 'Other' # Используем SmallAutoField для оптимизации (макс ~32k) # Продавцов обычно до 1000-10000, поэтому достаточно id = models.SmallAutoField(primary_key=True) s_seller = models.CharField( max_length=128, blank=False, unique=True, verbose_name='Название продавца', help_text='Техническое название продавца или магазина. Например: Клюква Рекодс. Может совпадать' ' с названием продавца, если лейбл сам реализует свои издания через сайт.', ) l_seller_currency = models.CharField( max_length=3, choices=Currency.choices, default=Currency.RUB, verbose_name='Валюта источника', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', ) k_seller_to_article = models.OneToOneField( TbArticle, on_delete=models.SET_NULL, related_name='article_to_seller', db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. default=None, null=True, blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически. verbose_name='Связанная статья', help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи).' ' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL продавца.' ) l_seller_type = models.CharField( max_length=6, default=SellerType.SELLER, choices=SellerType.choices, verbose_name='Тип продавца', ) j_seller_metadata = models.JSONField( default=dict, blank=True, null=True, verbose_name='Дополнительные данные', help_text='Дополнительные данные о продавце в виде JSON-словаря. Телефон, email, адрес, ссылка на сайт и т.д.', ) t_seller_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",) t_seller_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",) def __str__(self): return f"seller: {self.id:0>2}: {self.s_seller}" def save(self, *args, **kwargs): """ Переопределяем save для привязки связанной статьи о продавце. 0. ВАЖНО: Продавцам не нужны синонимы (хотя технически возможно), потому синонимы не проверяем и не обновляем 1. Если статья не привязана - создаём новую статью исполнителя автоматически 2. Генерируем технический заголовок и slug для статьи """ # ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ ===== # Используем универсальный хелпер для создания/поиска статьи # Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал article = create_or_get_related_article( self, TbArticle.ArticleType.SELLER, 's_seller', 'j_seller_metadata', 'k_seller_to_article' # ← Явно передаем имя FK поля (избегаем "магии") ) self.k_seller_to_article = article # Вызываем оригинальный save родительского класса super().save(*args, **kwargs) class Meta: verbose_name = 'Продавец' verbose_name_plural = 'Продавцы' ordering = ('s_seller',) # ============================================================================ # ПРЕДЛОЖЕНИЯ / ОФФЕРЫ # ============================================================================ class TbOffer(models.Model): """ Конкретное предложение от продавца. Один и тот же релиз может быть несколько раз в системе от разных продавцов. УНИКАЛЬНЫЙ КОД (s_offer_code): Генерируется автоматически при создании нового оффера с помощью hashids-кодирования: - При первом сохранении: рассчитывается из id через Skip32 + Base62/Base64 обфускацию - При обновлении: код НЕ изменяется (используется для отслеживания в корзине и заказах) - Уникален в пределах системы (UNIQUE constraint) - Используется в QR-кодах и ссылках (вместо прямого id для конфиденциальности) ИСТОРИЯ ЦЕНЫ И КОЛИЧЕСТВА (TbOfferHistory): При каждом сохранении оффера система автоматически отслеживает изменения: - Если это новый оффер: создаёт первую запись истории с текущей ценой/количеством - Если цена или количество изменились: создаёт новую запись истории - История помогает отслеживать динамику наличия и ценообразования - Поле t_history_created можно редактировать для импорта исторических данных СВЯЗЬ С КАРТИНКАМИ: Картинки к офферу управляются через TbImageMetadata (промежуточная таблица M2M): - offer.m_image.all() — все картинки этого офера (упорядочены по i_img_sort) - Каждая запись TbImageMetadata содержит: k_image_to_image (FK на Image), i_img_sort, j_img_metadata - Метаданные: порядок сортировки, тип изображения, источник, заметки и т.д. МЕТОДЫ: • save(): двухэтапное сохранение: 1. Вызывает super().save() чтобы получить id для кодирования 2. Кодирует id в s_offer_code через hashids (только для новых офферов!) 3. Вызывает update(s_offer_code=...) чтобы обновить БД с кодом • save(): отслеживает изменения цены/кол-ва → создает TbOfferHistory В админке M2M выглядит как список картинок с сортировкой и метаданными. """ class Condition(models.TextChoices): S = 's', 'Still Sealed (новое, запечатано)' M = 'm', 'Mint (новое, распакованное)' NM = 'nm', 'Near Mint (почти новое)' VG = 'vg', 'Very Good (очень хорошее)' G = 'g', 'Good (хорошее)' F = 'f', 'Fair (удовлетворительное)' P = 'p', 'Poor (плохое)' OTHER = '??', 'Other' class Format(models.TextChoices): LP = 'lp', 'Vinyl Long-Play (12")' EP = 'ep', 'Vinyl Extended-Play (12", 10", 7")' V45 = '45', 'Vinyl 7" (45 rpm)' CD = 'cd', 'Compact Disc' LD = 'ld', 'LaserDisc' REC_MD = 'md', 'MiniDisc Record' USE_MD = 'ms', 'Used MiniDisc (для записи)' REC_CS = 'cs', 'Cassette Record' USE_CS = 'uc', 'Used Cassette (для записи)' REC_RR = 'tp', 'Tape Reel Record' USE_RR = 'ur', 'Used Tape Reel (для записи)' OTHER = '??', 'Other' s_offer = models.CharField( max_length=128, blank=False, db_index=True, verbose_name='Название оффера', help_text='Техническое название оффера для внутреннего использования, например:' ' "Abbey Road (LP) AnTrop NM/NM (МЗГ)" или "TDK CDing I 60 (б/у) VG/VG (Janan, 198x синяя-градиент)"' ) l_offer_to_format = models.CharField( max_length=2, choices=Format.choices, default=Format.OTHER, db_index=True, verbose_name='Формат', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных),' ' то это указывать в "Дополнительных данных" в JSON-формате. Например:' ' {"OFR_MF": {"lp": 2, "cd": 1},}', ) # Связи k_offer_to_article = models.ForeignKey( TbArticle, on_delete=models.SET_NULL, related_name='article_to_offer', db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. default=None, null=True, blank=True, # <-- Интерфейсное удобство. Статья НЕ БУДЕТ СОЗДАНА автоматически. verbose_name='Связанная статья', help_text='Связанная статья об оффере (HTML-готовые заголовок, тизер и текст статьи).' ' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' 'МОЖНО НЕ УКАЗЫВАТЬ т.к. URL оффера (для корзины) формируется через id или хеш.' ) k_offer_to_item = models.ForeignKey( TbItem, blank=True, null=True, default=None, on_delete=models.SET_NULL, related_name='item_to_offer', # ← product.item_to_offer.all() db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. verbose_name='Релиз (товар)', ) k_offer_to_label = models.ForeignKey( TbLabel, blank=True, null=True, default=None, on_delete=models.SET_NULL, related_name='label_to_offer', # ← label.label_to_offers.all() db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. verbose_name='Лейбл/Производитель', help_text='Лейбл, на котором был выпущен релиз (если известен), или производитель для аудиио-кассет и MD' ' под запись, технику или аксессуар.', ) k_offer_to_source = models.ForeignKey( to='TbSource', null=True, default=None, on_delete=models.CASCADE, # ← если удалён источник, удалены все офферы related_name='source_to_offer', db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. verbose_name='Источник данных', help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные ' 'продавца: offer.k_offer_to_source.k_source_to_seller', ) # Изображения (M2M к filer.Image через TbImageMetadata промежуточную таблицу) m_offer_to_image = models.ManyToManyField( 'filer.Image', through='TbImageMetadata', related_name='m_image_to_offer', blank=True, verbose_name='Изображения товара', help_text='Картинки этого предложения (обложка, задник, фото и т.д). ' 'Управляются в админке TbImageMetadata с сортировкой (i_img_sort) и метаданными (j_img_metadata). ' 'Доступ в коде: ' 'offer.m_image.all() (все TbImageMetadata, отсортированные) или ' 'offer.m_offer_to_image.all() (все картинки). ' 'Каждая запись TbImageMetadata содержит: image, m_offer, i_img_sort, j_img_metadata.', ) # Характеристики s_offer_catalog_num = models.TextField( blank=True, default='', verbose_name='Каталожный номер / Barcode', help_text='Например: "SD 16023" или "5099923452355"', ) b_offer_is_preorder = models.BooleanField( default=False, db_index=True, verbose_name='Предзаказ', help_text='Если товар доступен только для предзаказа', ) d_offer_date_release = models.DateField( blank=True, null=True, default=None, db_index=True, verbose_name='Дата релиза', help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна', ) i_offer_discogs_id = models.IntegerField( blank=True, default=0, verbose_name='ID на релиз Discogs', help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: 306323', ) l_offer_condition_media = models.CharField( max_length=2, choices=Condition.choices, default=Condition.S, verbose_name="Состояние носителя", help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', ) l_offer_condition_sleeve = models.CharField( max_length=2, choices=Condition.choices, default=Condition.S, verbose_name="Состояние обложки", help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', ) # Цена и наличие f_offer_price = models.DecimalField( max_digits=10, decimal_places=2, default=0.00, db_index=True, # <-- Чтобы можно было сортировать по цене verbose_name='Цена', help_text='Цена в валюте источника. Валюта определяется в TbSource: offer.k_offer_to_source.l_currency', ) i_offer_quantity = models.IntegerField( # Устанавливая количество в ноль, можно указать, что предложение в настоящее время не доступно. blank=True, default=0, verbose_name='Количество в наличии', ) i_offer_discount_to_daily_sale = models.IntegerField( blank=True, default=0, db_index=True, # <-- Чтобы можно было сортировать по скидке и быстро выбирать то, что участвует в распродажах verbose_name='Возможна cкидка (%)', help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано' ' 0 то данное предложение не может участвовать в распродажах, спецпредложениях и акциях', ) j_offer_metadata = models.JSONField( # Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.) default=get_offer_metadata_default, null=True, verbose_name='Дополнительные данные', help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:
{\n'
                  f'  \"{KEY_OFFER_NOTE}\": \"Уценка: замят угол конверта, повреждены наклейки.\",\n'
                  f'  \"{KEY_OFFER_ALL_MEDIA}\": '
                  '{\n    "lp": 2,\n    "cd": 1\n  }\n}
' f'Ключи:', ) s_offer_code = models.CharField( max_length=12, unique=True, verbose_name='Код товара', help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).' ' Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера.' ' Не редактировать вручную!', ) i_offer_views = models.IntegerField( default=0, db_index=True, verbose_name='Просмотры', ) i_offer_favorites = models.IntegerField( default=0, db_index=True, verbose_name='В избранном', ) t_offer_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",) t_offer_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",) def __str__(self): seller = self.k_offer_to_source.k_source_to_seller.s_seller if (self.k_offer_to_source and self.k_offer_to_source.k_source_to_seller) else "?" return (f"[{self.s_offer_code:0>6}]\u00A0«{self.s_offer}»" f" ⟶\u00A0item\u00A0«{self.k_offer_to_item_id}»" f" ⟶\u00A0seller\u00A0«{seller}»") def increment_views(self): """Безопасный инкремент просмотров оффера""" TbOffer.objects.filter(id=self.id).update( i_offer_views=F('i_offer_views') + 1 ) def increment_favorites(self): """Безопасный инкремент добавлений в избранное оффера""" TbOffer.objects.filter(id=self.id).update( i_offer_favorites=F('i_offer_favorites') + 1 ) def save(self, *args, **kwargs): """ Переопределенный метод save для: 1. Автоматического формирования s_offer_code (криптографический код) 2. Записи истории изменений цены и количества в TbOfferHistory ЛОГИКА s_offer_code: - Для новых оферов: генерируем код после получения ID - Для старых оферов БЕЗ кода: кодируем существующий ID (миграция) - Для старых оферов С кодом: не трогаем ЛОГИКА истории (TbOfferHistory): - При первом сохранении: создаем первую запись с ценой и количеством - При обновлении: если цена или количество изменилось → создаем новую запись - Это позволяет отследить полную историю изменений """ # 1. Проверяем нужно ли генерировать s_offer_code: # - ИЛИ это новый объект (self.pk == None) # - ИЛИ это старый объект без s_offer_code (миграция) if not self.pk or not self.s_offer_code: # Если это новый оффер, сначала сохраняем, чтобы получить ID if not self.pk: # Сохраняем БЕЗ s_offer_code чтобы Django создал запись и присвоил pk super().save(*args, **kwargs) # После save() Django автоматически заполнит self.pk # Кодируем pk в компактный, необратимый код # Пример: pk=42 → "QBErd8" self.s_offer_code = Hashids(salt=OFFER_HASHIDS_SALT, min_length=OFFER_HASHIDS_MIN_LENGTH).encode(self.pk) # Сохраняем только поле s_offer_code (не перезаписываем остальное) super().save(update_fields=['s_offer_code']) else: # Оффер существует И уже имеет s_offer_code: сохраняем как обычно # Не трогаем s_offer_code, он был сформирован при создании super().save(*args, **kwargs) # 2. Записывает историю изменений цены и количества в TbOfferHistory. # Пытаемся получить последнюю (самую свежую) запись в истории latest_history = TbOfferHistory.objects.filter( k_history_to_offer_id=self.pk ).order_by('-t_history_created').first() if latest_history is None \ or self.f_offer_price != latest_history.f_history_price \ or self.i_offer_quantity != latest_history.i_history_quantity: # Нет истории для этого офера, или изменилась цена/количество -> создаем новую запись в истории TbOfferHistory.objects.create( k_history_to_offer_id=self.pk, f_history_price=self.f_offer_price, i_history_quantity=self.i_offer_quantity, # TODO: когда появится парсер, нужно будет добавить и запись поля `j_history_metadata` с информацией # откуда "прилетели" изменения (или координаты ячеек в EXCEL, или CSS-селектор и URL, или что-то ещё) ) class Meta: verbose_name = 'Оффер (предложение)' verbose_name_plural = 'Офферы (предложения)' ordering = ('-t_offer_updated', '-t_offer_created', 's_offer') indexes = [ # Составной индекс: найти все офферы товара, отсортировать по цене (для витрины) models.Index(fields=['k_offer_to_item', '-f_offer_price'], name='idx_offer_by_item_price'), ## Составной индекс: для фильтра распродаж - по источнику и скидке # models.Index(fields=['k_offer_to_source', '-i_offer_discount_to_daily_sale'], name='idx_offer_by_source_discount'), # Составной индекс: найти актуальные офферы по товару (есть в наличии) models.Index(fields=['k_offer_to_item', 'i_offer_quantity'], name='idx_offer_by_item_qty'), ] # ПРИМЕЧАНИЕ: UniqueConstraint на (item, source, format) удален, т.к. k_offer_to_format теперь M2M. # M2M не поддерживают участие в constraints. Уникальность на уровне БД не требуется. # ============================================================================ class TbSource(models.Model): """ Источники данных для импорта офферов (Excel, CSV, URL, ручной ввод). Поля: • id (SmallPK): оптимизировано • k_source_to_seller (FK→TbSeller): от какого продавца это данные [indexed] • l_source_type (choice): тип источника (excel, csv, url, other) • s_source_name (str): название источника для удобства • source_file (FilerFileField): загруженный файл (если Excel/CSV) • s_source_url (str): URL источника (если URL) • t_source_data (date): дата данных (когда их получили) • j_source_metadata (JSON): структура источника (вкладки, столбцы, CSS-селекторы и т.д.) • t_source_created, t_source_updated (datetime) Связи: • 1:M→TbOffer.k_offer_to_source: офферы из этого источника """ class SourceType(models.TextChoices): EXCEL = 'excel', 'Excel-файл от продавца или издателя' CSV = 'csv', 'CSV-файл от продавца или издателя' URL = 'url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)' OTHER = '??', 'Другое (включая ручной ввод)' # Используем SmallAutoField для оптимизации (макс ~32k) # Источников обычно до 1000, достаточно id = models.SmallAutoField(primary_key=True) k_source_to_seller = models.ForeignKey( TbSeller, null=True, default=None, on_delete=models.SET_NULL, related_name='seller_to_source', db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. verbose_name='Продавец', ) s_source_name = models.CharField( max_length=128, blank=True, default='', verbose_name='Название источника', help_text='Название источника данных (для удобства), например: Предзаказ на RSD-2025 от Полуэкта.', ) l_source_type = models.CharField( max_length=5, default=SourceType.EXCEL, choices=SourceType.choices, verbose_name='Тип источника', help_text='Тип источника данных, например: Excel-файл от продавца или издателя, URL страницы' ' с данными и т.д.', ) t_source_data = models.DateField( blank=True, default=datetime.date.today, verbose_name='Дата данных', help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.', ) source_file = FilerFileField( null=True, blank=True, on_delete=models.SET_NULL, # TODO: # 1. Чтобы файлы автоматически привязывались к нужной виртуальной папке filer при загрузке через Django Admin. # Для этого использовать сигналы (signals) в Admin или переопределение метода save модели (аналог # `upload_to` в обычных FileField). # 2. Внутри `FilerFileField` есть хеш SHA-1 (instance.doc.sha1) и размер файла в байтах (instance.doc.size). # Они доступны в момент загрузки (сразу после, еще до записи на диск и БД. Через его проверку # нужно предотвратить повторную запись файла-источника. verbose_name='Файл-источник', help_text='Файл-источник, например, Excel-файл от продавца или издателя. Если данные в источнике' ' представлены на странице в интернете, можно не указывать файл, а указать URL в поле ниже.', ) s_source_url = models.TextField( max_length=255, blank=True, default='', verbose_name='URL источника', help_text='URL страницы с данными, например, HTML-страница с каталогом товаров. Если данные в источнике' ' представлены в виде файла, можно не указывать URL, а загрузить файл в поле выше.', ) j_source_metadata = models.JSONField( default=dict, blank=True, verbose_name='Дополнительные данные', help_text='Дополнительные данные об источнике (внутреннем устройстве: вкладках и стоkбцах Excel-файла,' ' структуре HTML-страницы и т.п.) в виде JSON-словаря', ) t_source_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",) t_source_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления", ) def __str__(self): return f"source {self.id:0>3}: {self.s_source_name}" class Meta: verbose_name = 'Источник данных' verbose_name_plural = 'Источники данных' ordering = ('-t_source_data', '-t_source_created') # constraints = [ # # Уникальное ограничение: один продавец может иметь несколько источников, # # но комбинация (продавец + тип источника) должна быть уникальна # models.UniqueConstraint(fields=['k_source_to_seller', 'l_source_type'], # name='idx_source_unique_by_seller_type'), # ] # ============================================================================ # ИСТОРИЯ ИЗМЕНЕНИЙ ОФФЕРОВ # ============================================================================ class TbOfferHistory(models.Model): """ История изменений оффера (снапшот цены, количества, наличия). Создаётся автоматически при каждом сохранении оффера, если цена или количество изменились. АВТОМАТИЧЕСКОЕ СОЗДАНИЕ: - При создании нового оффера: создаёт первую запись с текущей ценой/количеством - При обновлении оффера: сравнивает последнюю запись истории с текущими значениями - Новая запись создаётся только если произошли изменения в цене ИЛИ количестве РЕДАКТИРОВАНИЕ ИСТОРИЧЕСКИХ ДАННЫХ: - Поле t_history_created можно редактировать (editable=True) - Используется для импорта исторических данных из Excel-файлов и других источников - Позволяет восстановить временную линию цены/наличия для аналитики """ k_history_to_offer = models.ForeignKey( TbOffer, on_delete=models.CASCADE, related_name='offer_to_history', # ← offer.offer_to_history.all() db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. verbose_name='Оффер', ) f_history_price = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, default=0.00, verbose_name='Старая цена', ) i_history_quantity = models.IntegerField( # Устанавливая количество в ноль, можно указать, что предложение более не доступно. Если оффер вернется, # то через новую запись в TbOfferHistory можно будет отследить, что он был в наличии, пропал, а потом # снова появился (со старой или новой ценой). default=0, verbose_name='Старое количество', ) j_history_metadata = models.JSONField( default=dict, blank=True, verbose_name='Метаданные', help_text='Метаданные, указывающие координаты данных внутри источника (например, внутри Excel-файла:' ' название вкладки, номер строки, номер столбца с ценой и количеством,' ' или URL + CSS-селектор для HTML-страницы и т.п.', ) t_history_created = models.DateTimeField( default=timezone.now, db_index=True, verbose_name="Дата создания", help_text="Дата создания записи истории. Автоматически устанавливается на текущее время при создании, " "но может быть отредактирована для загрузки исторических данных из Excel-файлов.", ) # Нам не нужен `t_history_updated` потому что это "снимок состояния" и его не нужно менять # после создания. И если вдруг понадобится, то правильнее будет добавить новую запись. def __str__(self): return f"history #{self.id} for offer {self.k_history_to_offer_id}" class Meta: verbose_name = 'История оффера' verbose_name_plural = 'Истории офферов' ordering = ('-t_history_created',) indexes = [ # Составной индекс: найти историю оффера, отсортированную по времени (для хронологии изменений цены) models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'), ]