mod: модели и админка (19) посты в блогах могут иметь связь с SVG-генерацией и возможен "куст" постов (идин пост-родитель и много потомков (для формирования многостраничной документации и т.п.)
This commit is contained in:
@@ -347,8 +347,9 @@ class BlogPostAdminForm(CodeMirrorFormMixin):
|
|||||||
# Виртуальные поля для настройки типографа
|
# Виртуальные поля для настройки типографа
|
||||||
'etp_enable', 'etp_language', 'etp_quotes', 'etp_hyphenation', 'etp_sanitize',
|
'etp_enable', 'etp_language', 'etp_quotes', 'etp_hyphenation', 'etp_sanitize',
|
||||||
'etp_hanging_punctuation', 'etp_mode',
|
'etp_hanging_punctuation', 'etp_mode',
|
||||||
# Остальные поля модели TbArticle
|
# Остальные поля модели TbBlogPost
|
||||||
's_title', 'slug', 's_teaser', 's_content', 'f_cover_img',
|
's_title', 'slug', 's_teaser', 's_content', 'f_cover_img',
|
||||||
|
'k_item', 'k_parent_post', 'i_order',
|
||||||
'is_published', 'd_published_at',
|
'is_published', 'd_published_at',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -372,6 +373,17 @@ class BlogPostAdminForm(CodeMirrorFormMixin):
|
|||||||
self.setup_codemirror_field('s_content', language='html',
|
self.setup_codemirror_field('s_content', language='html',
|
||||||
css_class='codemirror-width-xl codemirror-min-height-10')
|
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)
|
@admin.register(TbBlogPost)
|
||||||
class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin):
|
class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin):
|
||||||
@@ -379,16 +391,37 @@ class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin):
|
|||||||
Управление статьями блога, документацией и инфо-страницами (HTML + etpgrf).
|
Управление статьями блога, документацией и инфо-страницами (HTML + etpgrf).
|
||||||
"""
|
"""
|
||||||
form = BlogPostAdminForm
|
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_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")
|
search_fields = ("s_title", "slug", "s_teaser", "s_content")
|
||||||
|
autocomplete_fields = ("k_item", "k_parent_post")
|
||||||
prepopulated_fields = {"slug": ("s_title",)}
|
prepopulated_fields = {"slug": ("s_title",)}
|
||||||
readonly_fields = ("id", "d_created_at", "d_updated_at")
|
readonly_fields = ("id", "d_created_at", "d_updated_at")
|
||||||
fieldsets = (
|
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", ),
|
"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="Обложка")
|
@admin.display(description="Обложка")
|
||||||
def article_thumbnail(self, obj):
|
def article_thumbnail(self, obj):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+37
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models, transaction, IntegrityError
|
from django.db import models, transaction, IntegrityError
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -437,6 +438,9 @@ class TbBlogPost(models.Model):
|
|||||||
• s_teaser: тизер статьи (HTML)
|
• s_teaser: тизер статьи (HTML)
|
||||||
• s_content: основной текст статьи (HTML)
|
• s_content: основной текст статьи (HTML)
|
||||||
• f_cover_img: картинка обложки статьи
|
• f_cover_img: картинка обложки статьи
|
||||||
|
• k_item: связанная SVG-картина (опционально)
|
||||||
|
• k_parent_post: родительский пост/серия (опционально)
|
||||||
|
• i_order: порядок в серии / подборке
|
||||||
• is_published: флаг публикации
|
• is_published: флаг публикации
|
||||||
• d_published_at: дата и время публикации
|
• d_published_at: дата и время публикации
|
||||||
• d_created_at: дата создания записи
|
• d_created_at: дата создания записи
|
||||||
@@ -475,6 +479,30 @@ class TbBlogPost(models.Model):
|
|||||||
null=True,
|
null=True,
|
||||||
verbose_name="Обложка",
|
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(
|
is_published = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -497,7 +525,36 @@ class TbBlogPost(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Статья блога / Страница"
|
verbose_name = "Статья блога / Страница"
|
||||||
verbose_name_plural = "Статьи блога / Страницы"
|
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:
|
def __str__(self) -> str:
|
||||||
return self.s_title
|
return self.s_title
|
||||||
|
|||||||
+122
-1
@@ -1,6 +1,8 @@
|
|||||||
import io
|
import io
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
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.base import ContentFile
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.core.management import call_command
|
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 django.urls import reverse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from .admin import TbBlogPostAdmin
|
||||||
from .forms import HalftoneGenerateForm
|
from .forms import HalftoneGenerateForm
|
||||||
from .models import TbHypn0Item, TbVote
|
from .models import TbBlogPost, TbHypn0Item, TbVote
|
||||||
from .services.halftone import (
|
from .services.halftone import (
|
||||||
analyze_svg_structure,
|
analyze_svg_structure,
|
||||||
encode_to_base36,
|
encode_to_base36,
|
||||||
@@ -1153,3 +1156,121 @@ class SitemapTests(BaseMediaTestCase):
|
|||||||
self.assertIn("<priority>0.6</priority>", content)
|
self.assertIn("<priority>0.6</priority>", content)
|
||||||
self.assertIn("<changefreq>daily</changefreq>", content)
|
self.assertIn("<changefreq>daily</changefreq>", content)
|
||||||
self.assertIn("<changefreq>weekly</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))
|
||||||
|
|||||||
Reference in New Issue
Block a user