mod: блоги (2) драфт

This commit is contained in:
2026-09-08 00:40:48 +03:00
parent 4a4d009b04
commit 6da0e63a48
5 changed files with 258 additions and 3 deletions
+20 -1
View File
@@ -4,7 +4,7 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from .models import TbHypn0Item
from .models import TbBlogPost, TbHypn0Item
class StaticViewSitemap(Sitemap):
@@ -17,6 +17,7 @@ class StaticViewSitemap(Sitemap):
return [
("hypn0_site:index", 1.0, "daily"),
("hypn0_site:gallery_archive", 0.9, "daily"),
("hypn0_site:blog_feed", 0.8, "daily"),
]
def location(self, item):
@@ -29,6 +30,23 @@ class StaticViewSitemap(Sitemap):
return item[2]
class BlogPostSitemap(Sitemap):
"""
Карта сайта для опубликованных статей блога и хроник гипноза.
"""
changefreq = "weekly"
priority = 0.8
def items(self):
return TbBlogPost.objects.filter(is_published=True).order_by("-d_published_at")
def lastmod(self, obj: TbBlogPost):
return obj.d_updated_at
def location(self, obj: TbBlogPost):
return obj.get_absolute_url()
class GalleryItemSitemap(Sitemap):
"""
Карта сайта для публичных гипно-картин галереи сообщества.
@@ -63,4 +81,5 @@ class GalleryItemSitemap(Sitemap):
sitemaps = {
"static": StaticViewSitemap,
"gallery": GalleryItemSitemap,
"blog": BlogPostSitemap,
}
+93
View File
@@ -1317,3 +1317,96 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
self.assertContains(response, "Секретные формулы векторного воздействия")
self.assertContains(response, "/blog/khroniki-hypno-transa-urok-1")
self.assertNotContains(response, "Секретный черновик")
def test_blog_feed_view(self):
# Создаем несколько опубликованных постов и один черновик
for i in range(1, 12):
TbBlogPost.objects.create(
s_title=f"Тестовая депеша {i}",
slug=f"testovaya-depesha-{i}",
s_teaser=f"Краткий лид депеши {i}",
s_content=f"<p>Полное содержание {i}</p>",
is_published=True,
)
TbBlogPost.objects.create(
s_title="Черновик ноосферы",
slug="chernovik-noosfery",
s_teaser="Скрытый черновик",
s_content="<p>Скрыто</p>",
is_published=False,
)
# Проверка 1-й страницы ленты блога
response = self.client.get("/blog")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Хроники гипноза")
self.assertContains(response, "Тестовая депеша 11")
self.assertNotContains(response, "Черновик ноосферы")
self.assertContains(response, "page=2")
# Проверка 2-й страницы ленты блога
response_p2 = self.client.get("/blog?page=2")
self.assertEqual(response_p2.status_code, 200)
self.assertContains(response_p2, "Тестовая депеша 1")
def test_blog_detail_view_and_navigation(self):
# Создаем серию постов
series_parent = TbBlogPost.objects.create(
s_title="Цикл: Основы векторного гипноза",
slug="tsikl-osnovy-gipnoza",
s_teaser="Вводный обзор всего цикла",
s_content="<p>Введение в серию...</p>",
is_published=True,
)
ch1 = TbBlogPost.objects.create(
s_title="Глава 1: Первичная модуляция",
slug="glava-1-modulyatsiya",
s_teaser="Лид первой главы",
s_content="<p>Контент главы 1</p>",
k_parent_post=series_parent,
i_order=1,
k_item=self.item,
is_published=True,
)
ch2 = TbBlogPost.objects.create(
s_title="Глава 2: Фазовый сдвиг матрицы",
slug="glava-2-sdvig",
s_teaser="Лид второй главы",
s_content="<p>Контент главы 2</p>",
k_parent_post=series_parent,
i_order=2,
is_published=True,
)
# 1. Проверка родительского хаб-поста
resp_parent = self.client.get(f"/blog/{series_parent.slug}")
self.assertEqual(resp_parent.status_code, 200)
self.assertContains(resp_parent, "Цикл: Основы векторного гипноза")
self.assertContains(resp_parent, "Материалы и главы этого цикла")
self.assertContains(resp_parent, "Глава 1: Первичная модуляция")
self.assertContains(resp_parent, "Глава 2: Фазовый сдвиг матрицы")
# 2. Проверка первой главы (связанный SVG, ссылка на родителя, следующая глава)
resp_ch1 = self.client.get(f"/blog/{ch1.slug}")
self.assertEqual(resp_ch1.status_code, 200)
self.assertContains(resp_ch1, "Глава 1: Первичная модуляция")
self.assertContains(resp_ch1, "Материал входит в серию:")
self.assertContains(resp_ch1, "Связанная SVG-матрица")
self.assertContains(resp_ch1, f"#{self.item.s_hash_id}")
self.assertContains(resp_ch1, "Следующая глава цикла")
self.assertContains(resp_ch1, "Глава 2: Фазовый сдвиг матрицы")
# 3. Проверка второй главы (ссылка назад на главу 1)
resp_ch2 = self.client.get(f"/blog/{ch2.slug}")
self.assertEqual(resp_ch2.status_code, 200)
self.assertContains(resp_ch2, "Предыдущая глава цикла")
self.assertContains(resp_ch2, "Глава 1: Первичная модуляция")
# 4. Проверка доступа по числовому ID
resp_by_id = self.client.get(f"/blog/{ch1.pk}")
self.assertEqual(resp_by_id.status_code, 200)
self.assertContains(resp_by_id, "Глава 1: Первичная модуляция")
# 5. Проверка 404 для несуществующего слага
resp_404 = self.client.get("/blog/non-existent-slug-xyz")
self.assertEqual(resp_404.status_code, 404)
+2
View File
@@ -14,6 +14,8 @@ urlpatterns = [
path("gallery/<str:hash_id>", views.gallery_detail, name="gallery_detail"),
path("gallery/<str:hash_id>/download", views.gallery_download, name="gallery_download"),
path("gallery/<str:hash_id>/vote", views.gallery_vote, name="gallery_vote"),
path("blog", views.blog_feed, name="blog_feed"),
path("blog/<str:slug>", views.blog_detail, name="blog_detail"),
]
if settings.DEBUG:
+142 -1
View File
@@ -10,7 +10,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
from .forms import HalftoneGenerateForm
from .models import TbHypn0Item, TbVote
from .models import TbBlogPost, TbHypn0Item, TbVote
from .services.halftone import (
analyze_svg_structure,
generate_halftone_svg,
@@ -573,3 +573,144 @@ def gallery_vote(request: HttpRequest, hash_id: str) -> HttpResponse:
"voted_now": voted,
},
)
def blog_feed(request: HttpRequest) -> HttpResponse:
"""
Лента статей блога / хроник гипноза с пагинацией.
Выводит только опубликованные статьи (is_published=True).
"""
page_number = request.GET.get("page", 1)
posts_qs = (
TbBlogPost.objects.filter(is_published=True)
.select_related("k_item", "k_parent_post")
.prefetch_related("child_posts")
.order_by("-d_published_at", "i_order", "-d_created_at")
)
paginator = Paginator(posts_qs, 9)
page_obj = paginator.get_page(page_number)
context = {
"page_obj": page_obj,
"paginator": paginator,
"total_posts_count": paginator.count,
}
return render(request, "blog/feed.html", context)
def blog_detail(request: HttpRequest, slug: str) -> HttpResponse:
"""
Детальная страница статьи блога / хроник гипноза.
Поддерживает поиск по слагу или первичному ключу.
Отображает связанный SVG, навигацию вперед-назад и дерево серии (если статья часть цикла/сборника).
"""
post = (
TbBlogPost.objects.filter(is_published=True)
.select_related("k_item", "k_parent_post")
.filter(slug=slug)
.first()
)
if not post and slug.isdigit():
post = (
TbBlogPost.objects.filter(is_published=True)
.select_related("k_item", "k_parent_post")
.filter(pk=int(slug))
.first()
)
if not post:
raise Http404("Статья блога не найдена или еще не материализована в ноосфере")
# Обработка связанного SVG-шедевра
active_svg = ""
svg_stats = {}
if post.k_item and post.k_item.file_svg:
try:
with post.k_item.file_svg.open("r") as f:
svg_content = f.read()
if isinstance(svg_content, bytes):
svg_content = svg_content.decode("utf-8")
active_svg = prepare_active_svg(svg_content)
svg_stats = analyze_svg_structure(svg_content)
except Exception:
active_svg = ""
svg_stats = {}
# Логика серий и навигации вперед/назад
series_parent = None
series_posts = []
prev_post = None
next_post = None
if post.k_parent_post:
# Статья является частью серии
series_parent = post.k_parent_post
series_posts = list(
series_parent.child_posts.filter(is_published=True)
.select_related("k_item")
.order_by("i_order", "-d_published_at")
)
current_idx = next((i for i, p in enumerate(series_posts) if p.pk == post.pk), None)
if current_idx is not None:
if current_idx > 0:
prev_post = series_posts[current_idx - 1]
if current_idx < len(series_posts) - 1:
next_post = series_posts[current_idx + 1]
elif post.child_posts.filter(is_published=True).exists():
# Статья сама является родительской серией/хабом
series_posts = list(
post.child_posts.filter(is_published=True)
.select_related("k_item")
.order_by("i_order", "-d_published_at")
)
# Для родительского поста вперед/назад идет по хронологии других родительских/одиночных постов
prev_post = (
TbBlogPost.objects.filter(
is_published=True,
d_published_at__lt=post.d_published_at,
k_parent_post__isnull=True,
)
.order_by("-d_published_at")
.first()
)
next_post = (
TbBlogPost.objects.filter(
is_published=True,
d_published_at__gt=post.d_published_at,
k_parent_post__isnull=True,
)
.order_by("d_published_at")
.first()
)
else:
# Одиночный пост без серий
prev_post = (
TbBlogPost.objects.filter(
is_published=True,
d_published_at__lt=post.d_published_at,
k_parent_post__isnull=True,
)
.order_by("-d_published_at")
.first()
)
next_post = (
TbBlogPost.objects.filter(
is_published=True,
d_published_at__gt=post.d_published_at,
k_parent_post__isnull=True,
)
.order_by("d_published_at")
.first()
)
context = {
"post": post,
"active_svg": active_svg,
"svg_stats": svg_stats,
"series_parent": series_parent,
"series_posts": series_posts,
"prev_post": prev_post,
"next_post": next_post,
}
return render(request, "blog/detail.html", context)