From edb7741ffa90e56e578831978aad570199fcbcf5 Mon Sep 17 00:00:00 2001 From: erjemin Date: Wed, 3 Jun 2026 10:57:38 +0300 Subject: [PATCH] =?UTF-8?q?add:=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20(?= =?UTF-8?q?draft=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lpon_site/frontend/models.py | 385 ++++++++++++++++++++++------------- 1 file changed, 248 insertions(+), 137 deletions(-) diff --git a/lpon_site/frontend/models.py b/lpon_site/frontend/models.py index 3d54cd7..ec1d105 100644 --- a/lpon_site/frontend/models.py +++ b/lpon_site/frontend/models.py @@ -96,54 +96,177 @@ class TbImage(models.Model): verbose_name_plural = 'Изображения' ordering = ('i_img_sort', 't_img_created') +# ============================================================================ +# СТАТЬИ (любая тексовая информация о релизе, исполнителе, продавце и т.д... +# а так же новости, блог, тексты о спец-предложениях и т.д.) +# ============================================================================ +class TbArticle(models.Model): + """ + Статья, связанная с релизом, исполнителем, продавцом и т.д. + Может быть использована для хранения любой текстовой информации, например, описания релиза из Википедии или + Discogs, биографии исполнителя, описания продавца и т.д. Сохранение типографирования и спецсимволов в HTML. + """ + class ArticleType(models.TextChoices): + ARTIST = 'artist', 'Artis: артист, группа или бренд' + ITEM = 'item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)' + OFFER = 'offer', 'Offer: конкретное предложение от продавца' + SELLER = 'seller', 'Seller: продавец или магазин' + BLOG = 'blog', 'Новость или блог' + ACTION = 'action', 'Спецпредложение, акция, распродажа и т.д.' + TO_MAIN = 'to_main', 'Текст/Блок для главной страницы' + ADV = 'adv', 'Реклама или баннер' + OTHER = '???', 'Другое' + + s_article_title = models.CharField( + max_length=255, + blank=False, + default='', + unique=True, + verbose_name='Технический заголовок', + help_text='Технический заголовок статьи для внутреннего использования, например: "Album: Abbey Road"' + ' или "Bio: The Beatles".' + ) + l_article_type = models.CharField( + max_length=7, + blank=True, + choices=ArticleType.choices, + default=ArticleType.OTHER, + db_index=True, + verbose_name='Тип статьи', + ) + b_article_published = models.BooleanField( + default=True, + db_index=True, + verbose_name='Опубликовано', + ) + t_article_started = models.DateTimeField( + auto_now_add=True, + db_index=True, + verbose_name='Дата начала публикации', + ) + t_article_ended = models.DateTimeField( + blank=True, + null=True, + default=None, + db_index=True, + verbose_name='Дата окончания публикации', + help_text='Если указано, статья будет отображаться только между датой начала и датой окончания публикации.' + ' Если не указано, статья будет отображаться всегда (или до тех пор, пока не будет удалена ' + ' или снята с публикации через `b_article_published`)', + ) + s_article_title_html = models.CharField( + max_length=255, + blank=True, + default='', + verbose_name='Заголовок', + help_text='Заголовок статьи, например: "Описание релиза Abbey Road" или "Биография группы The Beatles".' + ' Может содержать HTML-разметку для типографирования (html-мнемоники и -теги). Если не указано,' + ' будет отображаться без заголовка.' + ) + k_article_to_image = models.ForeignKey( + TbImage, + on_delete=models.SET_NULL, + related_name='image_to_article', + blank=True, + null=True, + verbose_name='Изображение для статьи', + ) + s_article_teaser_html = models.TextField( + blank=True, + null=True, + default='', + verbose_name='Тизер статьи', + help_text='Короткий анонс статьи, который будет отображаться в списках. Может содержать HTML-вёрсту (теги,' + ' мнемоники, спецсимволы) для типографирования.', + ) + s_article_content_html = models.TextField( + blank=True, + null=True, + default='', + verbose_name='Статья', + help_text='Полный текст статьи. Может содержать HTML-вёрсту (теги, мнемоники, спецсимволы) для' + ' типографирования.', + ) + 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, + verbose_name="Дата создания", + ) + t_article_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + def __str__(self): + return f"article {self.id:0>4}: {self.s_article_title}" + + class Meta: + verbose_name = 'Статья' + verbose_name_plural = 'Статьи' + ordering = ('-t_article_updated', '-t_article_created') + # ============================================================================ # ИСПОЛНИТЕЛИ # ============================================================================ class TbArtist(models.Model): """Исполнитель или музыкальная группа.""" - s_artist = models.CharField( - max_length=255, - db_index=True, + max_length=128, + unique=True, verbose_name='Исполнитель', - help_text="Исполнитель или группа.", + help_text='Техническое название исполнителя для внутреннего использования, например: "The Beatles" или' + '"David Bowie".' ) - s_artist_html = models.TextField( - blank=True, - null=True, - default='', - verbose_name='Исполнитель (типографированно в HTML)', - help_text='С сохранением типографирования и спецсимволов (всякие "метки" и иконки, типа "Иноагент",' - ' тоже можно заверстать сюда.', - ) - k_artist_to_image = models.OneToOneField( - TbImage, + k_artist_to_article = models.OneToOneField( + TbArticle, on_delete=models.SET_NULL, - related_name='image_to_artist', - blank=True, + related_name='article_to_artist', + default=None, null=True, - verbose_name='Изображение для исполнителя', - help_text='Изображение исполнителя, например, логотип группы или фотография. Если указано, будет' - ' отображаться рядом с именем исполнителя.', + blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически. + verbose_name='Связанная статья', + help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же' + ' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' + 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL артиста.' ) - s_artist_article_html = models.TextField( - blank=True, - null=True, - default='', - verbose_name='Статья', - help_text='Статья об исполнителе (типографированно в HTML)', - ) - s_slug = models.SlugField( - max_length=64, - verbose_name='Слаг', - ) - j_artist_in_source = models.JSONField( + j_artist_metadata = models.JSONField( default=list, blank=True, null=True, - verbose_name='Варианты написания в источниках', - help_text='Список вариантов: ["The Beatles", "Beatles", "Beatles, The"]', + verbose_name='Метаданные JSON', + help_text='Включая варианты написания в источниках Список вариантов: ["The Beatles", "Beatles",' + ' "Beatles, The"]', ) t_artist_created = models.DateTimeField( auto_now_add=True, @@ -169,58 +292,37 @@ class TbItem(models.Model): аксессуар (щётка для виниловых пластинок) и т.д. Может быть представлен в виде одного или нескольких предложений от разных продавцов. """ - - # Исполнители (ManyToMany для поддержки коллабораций) - # Например: "David Bowie & Queen", "Elton John & Tim Rice" + 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() — найти все релизы артиста verbose_name='Исполнители', help_text="Один или несколько для коллабораций", ) - s_item_title = models.CharField( - max_length=255, blank=False, null=False, default='', - # db_index=True, - verbose_name='Название товара (релиза)', - help_text='Название товара (релиза), как указано на обложке. Например: Abbey Road' - ' или TDK, CDing I, 90.', - ) - s_item_title_html = models.TextField( - blank=True, - null=True, - default='', - verbose_name='Название (типографированно)', - help_text='С HTML-тегами для типографирования', - ) - s_item_to_image = models.OneToOneField( - TbImage, + k_item_to_article = models.OneToOneField( + TbArticle, on_delete=models.SET_NULL, - related_name='image_to_item', - blank=True, + related_name='article_to_item', + default=None, null=True, - verbose_name='Изображение', - help_text='Изображение товара из каталога, например, обложка альбома. Если указано, будет отображаться' - ' рядом с названием товара.', + blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически. + verbose_name='Связанная статья', + help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.' + ' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' + 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL альбома/релиза/товара.' ) - s_item_article_html = models.TextField( - blank=True, - null=True, - default='', - verbose_name='Статья', - help_text='Статья о релизе, например, описание из Википедии или Discogs (а также список композиций,' - ' исполнители и т.п.). Для товара это может быть техническое описание и характеристики.' - ' Текст сверстан в HTML с сохранением типографирования и спецсимволов.', - ) - s_item_slug = models.SlugField( - blank=True, - null=True, - default='', - verbose_name='Слаг', - help_text='Слаг для товара, формируемый на основе названия товара (релиза)', - ) - # Год выпуска (важно для коллекционеров) s_item_date = models.CharField( + # Год выпуска (важно для коллекционеров). Так же в будущем можно использовать для "ЮБИЛЕЙНОГО ПРЕДЛОЖЕНИЯ" + # и фан-календаря max_length=10, blank=True, null=True, @@ -260,12 +362,12 @@ class TbItem(models.Model): ) def __str__(self): - return f"Item {self.id:0>4}: {self.s_item_title}" + return f"Item {self.id:0>4}: {self.s_item}" class Meta: verbose_name = 'Товар в каталоге (релиз, носитель, аксессуар)' verbose_name_plural = 'Товары в каталоге' - ordering = ('s_item_title',) + ordering = ('s_item',) # ============================================================================ @@ -281,24 +383,21 @@ class TbLabel(models.Model): s_label = models.CharField( max_length=128, blank=False, - null=False, - default='', - verbose_name='Название лейбла', - help_text='Например: "Sony Records" или "Мелодия"', + unique=True, + verbose_name='Лейбл', + help_text='Техническое название лейбла. Например: "Sony Records" или "Мелодия"', ) - s_label_article_html = models.TextField( - blank=True, + k_label_to_article = models.OneToOneField( + TbArticle, + on_delete=models.SET_NULL, + related_name='article_to_label', + default=None, null=True, - default='', - verbose_name='Статья', - help_text='Статья о лейбле (типографированно в HTML)', - ) - s_label_slug = models.SlugField( - max_length=64, - blank=False, - null=False, - default='', - verbose_name='Слаг', + blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически. + verbose_name='Связанная статья', + help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи.' + ' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' + 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL лейбла.' ) j_label_metadata = models.JSONField( default=dict, @@ -311,7 +410,7 @@ class TbLabel(models.Model): auto_now_add=True, verbose_name="Дата создания", ) - t_tabel_updated = models.DateTimeField( + t_label_updated = models.DateTimeField( auto_now=True, verbose_name="Дата обновления", ) @@ -337,22 +436,26 @@ class TbSeller(models.Model): DIY = 'diy', 'Самиздат группы' CROWD = 'crowdfunding', 'Краудфандинг' OTHER = '++', 'Другое' + s_seller = models.CharField( - max_length=32, blank=False, null=False, default='', + max_length=128, + blank=False, + unique=True, verbose_name='Название продавца', - help_text='Название продавца или магазина, например: Клюква Рекодс', + help_text='Техническое название продавца или магазина. Например: Клюква Рекодс. Может совпадать' + ' с названием продавца, если лейбл сам реализует свои издания через сайт.', ) - s_seller_article_html = models.TextField( - blank=True, + k_seller_to_article = models.OneToOneField( + TbArticle, + on_delete=models.SET_NULL, + related_name='article_to_seller', + default=None, null=True, - default='', - verbose_name='Статья', - help_text='Статья о продавце (типографированно в HTML)', - ) - s_seller_slug = models.SlugField( - max_length=32, blank=False, null=False, default='', - verbose_name='Слаг', - help_text='Слаг для продавца, формируемый на основе названия продавца', + blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически. + verbose_name='Связанная статья', + help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи).' + ' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)
' + 'ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ т.к. через статью получаем слаг для URL продавца.' ) l_seller_type = models.CharField( max_length=9, @@ -390,7 +493,6 @@ class TbOffer(models.Model): Конкретное предложение от продавца. Один и тот же релиз может быть несколько раз в системе от разных продавцов. """ - class Format(models.TextChoices): LP = 'lp', 'vinyl' CD = 'cd', 'CD' @@ -413,49 +515,65 @@ class TbOffer(models.Model): P = 'p', 'Poor (плохое)' OTHER = '++', 'Other' + s_offer = models.CharField( + max_length=128, + blank=False, + db_index=True, + verbose_name='Название оффера', + help_text='Техническое название оффера для внутреннего использования, например:' + ' "Abbey Road (LP) AnTrop NM/NM (МЗГ)" или "TDK CDing I 60 (б/у) VG/VG (Janan, 198x синяя-градиент)"' + ) # Связи + k_offer_to_article = models.ForeignKey( + TbArticle, + on_delete=models.SET_NULL, + related_name='article_to_offer', + 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.product_offers.all() + related_name='item_to_offer', # ← product.item_to_offer.all() verbose_name='Релиз (товар)', ) - k_offer_to_label = models.ForeignKey( TbLabel, null=True, default=None, on_delete=models.SET_NULL, - related_name='label_to_offer', # ← label.label_offers.all() + related_name='label_to_offer', # ← label.label_to_offers.all() verbose_name='Лейбл', help_text='Лейбл, на котором был выпущен релиз, если он известен. Например: Atlantic или Мелодия', ) - k_offer_to_source = models.ForeignKey( to='TbSource', null=True, default=None, - on_delete=models.CASCADE, # ← если удалён источник, удалены офферы + on_delete=models.CASCADE, # ← если удалён источник, удалены все офферы related_name='source_to_offer', verbose_name='Источник данных', help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные ' 'продавца: offer.k_offer_to_source.k_source_to_seller', ) - - # Изображения товара (одна картинка может быть у многих офферов, а офер иметь много картинок) - # M2M связь для удобства в админке (filter_horizontal) - # Порядок картинок определяется полем i_img_sort в TbImage k_offer_to_image = models.ManyToManyField( + # Изображения товара (одна картинка может быть у многих офферов, а оффер иметь много картинок) + # M2M связь для удобства в админке (filter_horizontal) + # Порядок картинок определяется полем i_img_sort в TbImage TbImage, blank=True, related_name='image_to_offer', verbose_name='Изображения', help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка��тинке.', ) - # Характеристики s_offer_catalog_num = models.TextField( blank=True, @@ -463,7 +581,6 @@ class TbOffer(models.Model): verbose_name='Каталожный номер / Barcode', help_text='Например: "SD 16023" или "5099923452355"', ) - l_offer_primary_media = models.CharField( max_length=2, choices=Format.choices, @@ -471,7 +588,6 @@ class TbOffer(models.Model): verbose_name='Формат', help_text='Основной формат носителя (пластинка, CD, кассета и т.п.)' ) - d_offer_date_release = models.DateField( blank=True, null=True, @@ -479,13 +595,12 @@ class TbOffer(models.Model): verbose_name='Дата релиза', help_text='Дата релиза, если она известна (например, дата выпуска переиздания)', ) - i_offer_discogs_id = models.IntegerField( - blank=True,default=0, + blank=True, + default=0, verbose_name='ID на релиз Discogs', help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: 306323', ) - l_offer_condition_media = models.CharField( max_length=2, choices=Condition.choices, @@ -493,7 +608,6 @@ class TbOffer(models.Model): verbose_name="Состояние носителя", help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', ) - l_offer_condition_sleeve = models.CharField( max_length=2, choices=Condition.choices, @@ -501,46 +615,43 @@ class TbOffer(models.Model): verbose_name="Состояние обложки", help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', ) - # Цена и наличие f_offer_price = models.DecimalField( max_digits=10, decimal_places=2, - blank=True, 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, - db_index=True, verbose_name='Количество в наличии', ) - i_offer_discount_to_daily_sale = models.IntegerField( blank=True, default=0, - db_index=True, + db_index=True, # <-- Чтобы можно было сортировать по скидке и быстро выбирать то, что участвует в распродажах verbose_name='Скидка', help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано' ' 0 то данное предложение не может участвовать в распродажах, спецпредложениях и акциях', ) - s_offer_comment_html = models.TextField( - blank=True, - default='', - verbose_name='Доп.инфо', - help_text='Дополнительная информация или комментарий к предложению от продавца с сохранением' - ' типографирования и спецсимволов.', - ) j_offer_metadata = models.JSONField( # Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.) default=dict, null=True, verbose_name='Дополнительные данные', help_text='Дополнительные данные о предложении в виде JSON-словаря.', ) + s_offer_skip32 = models.CharField( + max_length=12, + unique=True, + verbose_name='Код товара', + help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).' + ' Например: "4gfFCJ". Формируется автоматически связкой Skip32 (хаотичное перемешивание) и' + ' Base62 (компактная упаковка) из id оффера в методе save().', + ) t_offer_created = models.DateTimeField( auto_now_add=True, verbose_name="Дата создания", @@ -558,7 +669,7 @@ class TbOffer(models.Model): class Meta: verbose_name = 'Оффер (предложение)' verbose_name_plural = 'Офферы (предложения)' - ordering = ('-t_offer_updated', '-t_offer_created') + ordering = ('-t_offer_updated', '-t_offer_created' 's_offer') # ============================================================================ @@ -682,7 +793,7 @@ class TbOfferHistory(models.Model): k_history_to_offer = models.ForeignKey( TbOffer, on_delete=models.CASCADE, - related_name='offer_to_history', # ← offer.history_to_offer.all() + related_name='offer_to_history', # ← offer.offer_to_history.all() verbose_name='Оффер', ) f_history_price = models.DecimalField(