1658 lines
102 KiB
Python
1658 lines
102 KiB
Python
# 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) — доступ к метаданным
|
||
|
||
МЕТОДЫ:
|
||
• Нет переопределённых методов save(). Используется стандартное поведение Django.
|
||
"""
|
||
|
||
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).<br/>'
|
||
'<b>ВАЖНО</b>: Так как статья привязывается к исполнителям, лейблам, продавцам, музыкальным стилям,'
|
||
' её изображение можно использовать как логотип, пиктограмму или баннер этих сущностей. '
|
||
'Рекомендуется использовать прозрачный фон (движок поддерживает 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: жанры альбомов
|
||
|
||
МЕТОДЫ:
|
||
• save(): управляет синонимами и создаёт связанную статью автоматически
|
||
- При создании/изменении: добавляет название стиля в SYN_EN метаданные
|
||
- Если статья не привязана: создаёт новую через create_or_get_related_article()
|
||
"""
|
||
# Используем 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 атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.'
|
||
)
|
||
j_style_metadata = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
verbose_name='Метаданные',
|
||
help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||
' Пример: <tt>{"SYN_EN": ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]}</tt>.',
|
||
)
|
||
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: коллаборации (артист может быть на нескольких альбомах)
|
||
|
||
МЕТОДЫ:
|
||
• save(): управляет синонимами и создаёт связанную статью автоматически
|
||
- При создании/изменении: добавляет названия артиста в SYN_EN метаданные
|
||
- Если статья не привязана: создаёт новую через create_or_get_related_article()
|
||
"""
|
||
# Используем 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='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же'
|
||
' через статью может быть получена <b>картинка<b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.'
|
||
)
|
||
j_artist_metadata = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name='Метаданные JSON',
|
||
help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||
' Пример: <tt>{"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.',
|
||
)
|
||
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 для жанров
|
||
|
||
МЕТОДЫ:
|
||
• save(): управляет синонимами и создаёт связанную статью автоматически
|
||
- При создании/изменении: добавляет названия товара в SYN_EN метаданные
|
||
- Если статья не привязана: создаёт новую через create_or_get_related_article()
|
||
"""
|
||
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='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.'
|
||
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для 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, если он там есть. Например: <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}"
|
||
|
||
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: предложения от этого лейбла
|
||
|
||
МЕТОДЫ:
|
||
• save(): управляет синонимами и создаёт связанную статью автоматически
|
||
- При создании/изменении: добавляет названия лейбла в SYN_EN метаданные
|
||
- Если статья не привязана: создаёт новую через create_or_get_related_article()
|
||
"""
|
||
# Используем 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='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи.'
|
||
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.'
|
||
)
|
||
j_label_metadata = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name='Метаданные',
|
||
help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга.'
|
||
' Пример: <tt>{"SYN_EN": ["Island", "Island Records", "Vertigo France"]}</tt>.'
|
||
,
|
||
)
|
||
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): предложения товаров
|
||
|
||
МЕТОДЫ:
|
||
• save(): создаёт связанную статью автоматически (синонимы не используются)
|
||
- Если статья не привязана: создаёт новую через create_or_get_related_article()
|
||
"""
|
||
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='Техническое название продавца или магазина. Например: <tt>Клюква Рекодс</tt>. Может совпадать'
|
||
' с названием продавца, если лейбл сам реализует свои издания через сайт.',
|
||
)
|
||
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-готовые заголовок, тизер и текст статьи).'
|
||
' Так же через статью может быть получена <b>картинка</b>, 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}"
|
||
|
||
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-формате. Например:'
|
||
' <tt>{"OFR_MF": {"lp": 2, "cd": 1},}</tt>',
|
||
)
|
||
# Связи
|
||
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,
|
||
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, если он там есть. Например: <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='Возможна cкидка (%)',
|
||
help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано'
|
||
' <tt>0</tt> то данное предложение не может участвовать в распродажах, спецпредложениях и акциях',
|
||
)
|
||
j_offer_metadata = models.JSONField(
|
||
# Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.)
|
||
default=get_offer_metadata_default,
|
||
null=True,
|
||
verbose_name='Дополнительные данные',
|
||
help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:<pre style=\"'
|
||
'background-color:#80808080;border:1px solid #ccc; padding:1ex;max-width: calc(75% - 13em);\">{\n'
|
||
f' \"{KEY_OFFER_NOTE}\": \"Уценка: замят угол конверта, повреждены наклейки.\",\n'
|
||
f' \"{KEY_OFFER_ALL_MEDIA}\": '
|
||
'{\n "lp": 2,\n "cd": 1\n }\n}</pre>'
|
||
f'Ключи:<ul>'
|
||
f' <li>\"{KEY_OFFER_NOTE}\" — Примечание к офферу</li>'
|
||
f' <li>\"{KEY_OFFER_ALL_MEDIA}\" — Какие носители входят в коммерческое предложение.<br/>'
|
||
' Допустимые значения: \"lp\" — Vinyl Long-Play (12"), \"ep\" — Vinyl Extended-Play (12\", 10\",'
|
||
' 7\"), \"45\" — Vinyl 7\" (45 rpm), \"cd\" — Compact Disc, \"ld\" — LaserDisc,'
|
||
' \"md\" — MiniDisc Record, \"ms\" — Used MiniDisc (для записи),'
|
||
' \"cs\" — Cassette Record, \"uc\" — Used Cassette (для записи),'
|
||
' \"tp\" — Tape Reel Record, \"ur\" — Used Tape Reel (для записи),'
|
||
' \"??\" — Other</li></ul>',
|
||
)
|
||
s_offer_code = models.CharField(
|
||
max_length=12,
|
||
unique=True,
|
||
verbose_name='Код товара',
|
||
help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).'
|
||
' Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера.'
|
||
' <b>Не редактировать вручную!</b>',
|
||
)
|
||
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: офферы из этого источника
|
||
|
||
МЕТОДЫ:
|
||
• Нет переопределённых методов save(). Используется стандартное поведение Django.
|
||
"""
|
||
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='Название источника данных (для удобства), например: <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):
|
||
"""
|
||
История изменений оффера (снапшот цены, количества, наличия).
|
||
Создаётся автоматически при каждом сохранении оффера, если цена или количество изменились.
|
||
|
||
АВТОМАТИЧЕСКОЕ СОЗДАНИЕ:
|
||
- При создании нового оффера: создаёт первую запись с текущей ценой/количеством
|
||
- При обновлении оффера: сравнивает последнюю запись истории с текущими значениями
|
||
- Новая запись создаётся только если произошли изменения в цене ИЛИ количестве
|
||
|
||
РЕДАКТИРОВАНИЕ ИСТОРИЧЕСКИХ ДАННЫХ:
|
||
- Поле t_history_created можно редактировать (editable=True)
|
||
- Используется для импорта исторических данных из Excel-файлов и других источников
|
||
- Позволяет восстановить временную линию цены/наличия для аналитики
|
||
|
||
МЕТОДЫ:
|
||
• save(): вызывается автоматически из TbOffer.save() с параметрами цены/количества
|
||
"""
|
||
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'),
|
||
]
|