mod: блоги (2) драфт
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user