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))