From 46a2e57b0c545673b6d1085c5e6e167a1942cebb Mon Sep 17 00:00:00 2001 From: erjemin Date: Mon, 7 Sep 2026 23:22:06 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0=20(19)=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D1=8B=20=D0=B2=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D1=85=20=D0=BC=D0=BE=D0=B3=D1=83=D1=82=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D1=8C=20=D1=81=D0=B2=D1=8F=D0=B7=D1=8C=20?= =?UTF-8?q?=D1=81=20SVG-=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=20"=D0=BA=D1=83=D1=81=D1=82"=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20(=D0=B8=D0=B4=D0=B8=D0=BD=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82-=D1=80=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=B8=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=D0=BA=D0=BE=D0=B2=20(=D0=B4=D0=BB=D1=8F=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=BD=D0=BE=D0=B9=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=82.=D0=BF.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hypn0/hypn0_site/admin.py | 48 ++++++- ...ost_options_tbblogpost_i_order_and_more.py | 37 ++++++ hypn0/hypn0_site/models.py | 59 ++++++++- hypn0/hypn0_site/tests.py | 123 +++++++++++++++++- 4 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 hypn0/hypn0_site/migrations/0005_alter_tbblogpost_options_tbblogpost_i_order_and_more.py diff --git a/hypn0/hypn0_site/admin.py b/hypn0/hypn0_site/admin.py index 06909df..b58d53f 100644 --- a/hypn0/hypn0_site/admin.py +++ b/hypn0/hypn0_site/admin.py @@ -347,8 +347,9 @@ class BlogPostAdminForm(CodeMirrorFormMixin): # Виртуальные поля для настройки типографа 'etp_enable', 'etp_language', 'etp_quotes', 'etp_hyphenation', 'etp_sanitize', 'etp_hanging_punctuation', 'etp_mode', - # Остальные поля модели TbArticle + # Остальные поля модели TbBlogPost 's_title', 'slug', 's_teaser', 's_content', 'f_cover_img', + 'k_item', 'k_parent_post', 'i_order', 'is_published', 'd_published_at', ) @@ -372,6 +373,17 @@ class BlogPostAdminForm(CodeMirrorFormMixin): self.setup_codemirror_field('s_content', language='html', css_class='codemirror-width-xl codemirror-min-height-10') +class ChildPostsInline(admin.TabularInline): + """Вложенное управление дочерними статьями / главами серии в родительском посте.""" + model = TbBlogPost + fk_name = "k_parent_post" + extra = 0 + fields = ("s_title", "slug", "k_item", "i_order", "is_published") + autocomplete_fields = ("k_item",) + show_change_link = True + ordering = ("i_order",) + + # Конфигурация админки @admin.register(TbBlogPost) class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin): @@ -379,16 +391,37 @@ class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin): Управление статьями блога, документацией и инфо-страницами (HTML + etpgrf). """ form = BlogPostAdminForm + inlines = (ChildPostsInline,) - list_display = ("id", "article_thumbnail", "s_title_display", "slug", "is_published", "d_published_at", "d_created_at") + list_display = ( + "id", + "article_thumbnail", + "s_title_display", + "has_svg", + "k_parent_post", + "slug", + "is_published", + "d_published_at", + "d_created_at", + ) list_display_links = ("id", "s_title_display") - list_filter = ("is_published", "d_published_at", "d_created_at") + list_filter = ( + "is_published", + ("k_item", admin.EmptyFieldListFilter), + ("k_parent_post", admin.EmptyFieldListFilter), + "d_published_at", + "d_created_at", + ) search_fields = ("s_title", "slug", "s_teaser", "s_content") + autocomplete_fields = ("k_item", "k_parent_post") prepopulated_fields = {"slug": ("s_title",)} readonly_fields = ("id", "d_created_at", "d_updated_at") fieldsets = ( - ("Атрибуты публикации", { - "fields": ("is_published", "slug", "d_published_at", ) + ("Атрибуты публикации и связи", { + "fields": ( + ("is_published", "slug", "d_published_at"), + ("k_item", "k_parent_post", "i_order"), + ), }), ("Основные поля", { "fields": ("s_title", "f_cover_img", "s_teaser", "s_content", ), @@ -408,6 +441,11 @@ class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin): }), ) + @admin.display(boolean=True, description="SVG", ordering="k_item") + def has_svg(self, obj): + """Индикатор привязки статьи к SVG-генерации.""" + return bool(obj.k_item_id) + @admin.display(description="Обложка") def article_thumbnail(self, obj): """ diff --git a/hypn0/hypn0_site/migrations/0005_alter_tbblogpost_options_tbblogpost_i_order_and_more.py b/hypn0/hypn0_site/migrations/0005_alter_tbblogpost_options_tbblogpost_i_order_and_more.py new file mode 100644 index 0000000..1ead008 --- /dev/null +++ b/hypn0/hypn0_site/migrations/0005_alter_tbblogpost_options_tbblogpost_i_order_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 6.1 on 2026-09-07 20:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hypn0_site', '0004_alter_tbhypn0item_file_svg'), + ] + + operations = [ + migrations.AlterModelOptions( + name='tbblogpost', + options={'ordering': ['-d_published_at', 'i_order', '-d_created_at'], 'verbose_name': 'Статья блога / Страница', 'verbose_name_plural': 'Статьи блога / Страницы'}, + ), + migrations.AddField( + model_name='tbblogpost', + name='i_order', + field=models.PositiveSmallIntegerField(db_index=True, default=0, help_text='Порядковый номер для сортировки внутри родительского поста (0, 1, 2...)', verbose_name='Порядок в серии'), + ), + migrations.AddField( + model_name='tbblogpost', + name='k_item', + field=models.ForeignKey(blank=True, help_text='Опциональная генерация, вокруг которой строится пост или туториал', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blog_posts', to='hypn0_site.tbhypn0item', verbose_name='Связанная SVG-картина'), + ), + migrations.AddField( + model_name='tbblogpost', + name='k_parent_post', + field=models.ForeignKey(blank=True, help_text='Если заполнен — пост является частью серии или подборки', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_posts', to='hypn0_site.tbblogpost', verbose_name='Родительский пост / Серия'), + ), + migrations.AddIndex( + model_name='tbblogpost', + index=models.Index(fields=['k_item', 'is_published', '-d_published_at'], name='idx_blog_item_pub'), + ), + ] diff --git a/hypn0/hypn0_site/models.py b/hypn0/hypn0_site/models.py index ac7d8b9..1a716af 100644 --- a/hypn0/hypn0_site/models.py +++ b/hypn0/hypn0_site/models.py @@ -1,4 +1,5 @@ import hashlib +from django.core.exceptions import ValidationError from django.db import models, transaction, IntegrityError from django.db.models import F from django.urls import reverse @@ -437,6 +438,9 @@ class TbBlogPost(models.Model): • s_teaser: тизер статьи (HTML) • s_content: основной текст статьи (HTML) • f_cover_img: картинка обложки статьи + • k_item: связанная SVG-картина (опционально) + • k_parent_post: родительский пост/серия (опционально) + • i_order: порядок в серии / подборке • is_published: флаг публикации • d_published_at: дата и время публикации • d_created_at: дата создания записи @@ -475,6 +479,30 @@ class TbBlogPost(models.Model): null=True, verbose_name="Обложка", ) + k_item = models.ForeignKey( + TbHypn0Item, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="blog_posts", + verbose_name="Связанная SVG-картина", + help_text="Опциональная генерация, вокруг которой строится пост или туториал", + ) + k_parent_post = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="child_posts", + verbose_name="Родительский пост / Серия", + help_text="Если заполнен — пост является частью серии или подборки", + ) + i_order = models.PositiveSmallIntegerField( + default=0, + db_index=True, + verbose_name="Порядок в серии", + help_text="Порядковый номер для сортировки внутри родительского поста (0, 1, 2...)", + ) is_published = models.BooleanField( default=False, @@ -497,7 +525,36 @@ class TbBlogPost(models.Model): class Meta: verbose_name = "Статья блога / Страница" verbose_name_plural = "Статьи блога / Страницы" - ordering = ["-d_published_at", "-d_created_at"] + ordering = ["-d_published_at", "i_order", "-d_created_at"] + indexes = [ + models.Index(fields=["k_item", "is_published", "-d_published_at"], name="idx_blog_item_pub"), + ] + + def clean(self): + super().clean() + if self.k_parent_post_id: + # 1. Защита от самопривязки + if self.pk and self.k_parent_post_id == self.pk: + raise ValidationError({"k_parent_post": "Статья не может быть родителем самой себя."}) + + # 2. Если у статьи уже есть дочерние посты — она сама является родителем и не может ссылаться на другую статью + if self.pk and self.child_posts.exists(): + raise ValidationError({ + "k_parent_post": "Эта статья уже содержит дочерние посты серии и не может входить в другую серию." + }) + + # 3. Выбранная родительская статья сама не должна быть дочерней (ограничение в 1 уровень вложенности) + if self.k_parent_post.k_parent_post_id is not None: + raise ValidationError({ + "k_parent_post": "Выбранная родительская статья сама является частью серии. Вложенность ограничена одним уровнем." + }) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + # Если к статье привязана генерация — гарантируем ей уровень IMMORTAL (защита от Smart Retention) + if self.k_item_id and self.k_item.i_level < TbHypn0Item.Level.IMMORTAL: + self.k_item.i_level = TbHypn0Item.Level.IMMORTAL + self.k_item.save(update_fields=["i_level"]) def __str__(self) -> str: return self.s_title diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index 42458c8..88d5445 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -1,6 +1,8 @@ import io import shutil import tempfile +from django.contrib.admin.sites import AdminSite +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command @@ -8,8 +10,9 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from PIL import Image +from .admin import TbBlogPostAdmin from .forms import HalftoneGenerateForm -from .models import TbHypn0Item, TbVote +from .models import TbBlogPost, TbHypn0Item, TbVote from .services.halftone import ( analyze_svg_structure, encode_to_base36, @@ -1153,3 +1156,121 @@ class SitemapTests(BaseMediaTestCase): self.assertIn("0.6", content) self.assertIn("daily", content) self.assertIn("weekly", content) + + +class BlogPostModelAndAdminTests(BaseMediaTestCase): + """Тестирование модели TbBlogPost, связей с генерациями, цепочек постов и админки.""" + + def setUp(self): + super().setUp() + self.vid = "123e4567-e89b-12d3-a456-426614174000" + svg_bytes = b'' + self.item = TbHypn0Item( + s_title="Тестовая картина для статьи", + file_svg=ContentFile(svg_bytes, name="post_item.svg"), + i_level=TbHypn0Item.Level.CANDIDATE, + is_public=True, + ) + self.item.save(visitor_uuid_or_fp=self.vid) + + def test_post_linked_to_generation_sets_immortal_level(self): + post = TbBlogPost.objects.create( + s_title="Пост о генерации", + slug="post-o-generatsii", + s_teaser="Тизер", + s_content="Контент", + k_item=self.item, + is_published=True, + ) + self.item.refresh_from_db() + # Проверяем, что уровень генерации повышен до IMMORTAL + self.assertEqual(self.item.i_level, TbHypn0Item.Level.IMMORTAL) + self.assertEqual(list(self.item.blog_posts.all()), [post]) + + def test_post_hierarchy_and_chains(self): + parent_post = TbBlogPost.objects.create( + s_title="Топ-10 генераций лета", + slug="top-10-generatsiy-leta", + s_teaser="Подборка", + s_content="Общий обзор", + is_published=True, + ) + child1 = TbBlogPost.objects.create( + s_title="Генерация 1: Неоновый слизень", + slug="generatsiya-1", + k_parent_post=parent_post, + i_order=1, + is_published=True, + ) + child2 = TbBlogPost.objects.create( + s_title="Генерация 2: Глубокий транс", + slug="generatsiya-2", + k_parent_post=parent_post, + i_order=2, + is_published=True, + ) + + # Проверяем связь родитель-потомки и сортировку + children = list(parent_post.child_posts.order_by("i_order")) + self.assertEqual(children, [child1, child2]) + self.assertEqual(child1.k_parent_post, parent_post) + + def test_post_self_reference_validation(self): + post = TbBlogPost.objects.create( + s_title="Автономный пост", + slug="avtonomniy-post", + ) + post.k_parent_post = post + with self.assertRaises(ValidationError) as ctx: + post.clean() + self.assertIn("k_parent_post", ctx.exception.message_dict) + + def test_parent_cannot_become_child_validation(self): + parent_post = TbBlogPost.objects.create( + s_title="Родительский сборник", + slug="roditelskiy-sbornik", + ) + TbBlogPost.objects.create( + s_title="Глава 1", + slug="glava-1", + k_parent_post=parent_post, + ) + another_parent = TbBlogPost.objects.create( + s_title="Другой сборник", + slug="drugoy-sbornik", + ) + # Родительский пост с детьми пытается сослаться на другой сборник + parent_post.k_parent_post = another_parent + with self.assertRaises(ValidationError) as ctx: + parent_post.clean() + self.assertIn("k_parent_post", ctx.exception.message_dict) + + def test_cannot_set_child_as_parent_validation(self): + grandparent = TbBlogPost.objects.create( + s_title="Главный сборник", + slug="glavniy-sbornik", + ) + parent_post = TbBlogPost.objects.create( + s_title="Подраздел", + slug="podrazdel", + k_parent_post=grandparent, + ) + child = TbBlogPost.objects.create( + s_title="Внук", + slug="vnuk", + ) + # Попытка назначить родителем статью, которая сама является ребенком + child.k_parent_post = parent_post + with self.assertRaises(ValidationError) as ctx: + child.clean() + self.assertIn("k_parent_post", ctx.exception.message_dict) + + def test_admin_has_svg_indicator(self): + post_with_svg = TbBlogPost(s_title="С SVG", k_item=self.item) + post_without_svg = TbBlogPost(s_title="Без SVG", k_item=None) + + site = AdminSite() + admin_obj = TbBlogPostAdmin(TbBlogPost, site) + + self.assertTrue(admin_obj.has_svg(post_with_svg)) + self.assertFalse(admin_obj.has_svg(post_without_svg))