mod: модели и админка (19) посты в блогах могут иметь связь с SVG-генерацией и возможен "куст" постов (идин пост-родитель и много потомков (для формирования многостраничной документации и т.п.)

This commit is contained in:
2026-09-07 23:22:06 +03:00
parent b868fc4359
commit 46a2e57b0c
4 changed files with 260 additions and 7 deletions
+43 -5
View File
@@ -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):
"""
@@ -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'),
),
]
+58 -1
View File
@@ -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
+122 -1
View File
@@ -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("<priority>0.6</priority>", content)
self.assertIn("<changefreq>daily</changefreq>", content)
self.assertIn("<changefreq>weekly</changefreq>", content)
class BlogPostModelAndAdminTests(BaseMediaTestCase):
"""Тестирование модели TbBlogPost, связей с генерациями, цепочек постов и админки."""
def setUp(self):
super().setUp()
self.vid = "123e4567-e89b-12d3-a456-426614174000"
svg_bytes = b'<svg><circle/></svg>'
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))