diff --git a/hypn0/hypn0_site/sitemaps.py b/hypn0/hypn0_site/sitemaps.py index 41fa512..1a2e433 100644 --- a/hypn0/hypn0_site/sitemaps.py +++ b/hypn0/hypn0_site/sitemaps.py @@ -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, } diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index ae72a61..5eeb67f 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -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"

Полное содержание {i}

", + is_published=True, + ) + TbBlogPost.objects.create( + s_title="Черновик ноосферы", + slug="chernovik-noosfery", + s_teaser="Скрытый черновик", + s_content="

Скрыто

", + 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="

Введение в серию...

", + is_published=True, + ) + ch1 = TbBlogPost.objects.create( + s_title="Глава 1: Первичная модуляция", + slug="glava-1-modulyatsiya", + s_teaser="Лид первой главы", + s_content="

Контент главы 1

", + 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="

Контент главы 2

", + 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) diff --git a/hypn0/hypn0_site/urls.py b/hypn0/hypn0_site/urls.py index 5ea8b41..314bfad 100644 --- a/hypn0/hypn0_site/urls.py +++ b/hypn0/hypn0_site/urls.py @@ -14,6 +14,8 @@ urlpatterns = [ path("gallery/", views.gallery_detail, name="gallery_detail"), path("gallery//download", views.gallery_download, name="gallery_download"), path("gallery//vote", views.gallery_vote, name="gallery_vote"), + path("blog", views.blog_feed, name="blog_feed"), + path("blog/", views.blog_detail, name="blog_detail"), ] if settings.DEBUG: diff --git a/hypn0/hypn0_site/views.py b/hypn0/hypn0_site/views.py index 54ee28f..d9b8c5b 100644 --- a/hypn0/hypn0_site/views.py +++ b/hypn0/hypn0_site/views.py @@ -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) diff --git a/hypn0/templates/block/header.html b/hypn0/templates/block/header.html index c741787..c75928b 100644 --- a/hypn0/templates/block/header.html +++ b/hypn0/templates/block/header.html @@ -10,7 +10,7 @@ {# /ЛОГОТИП #} {# ПРАВОЕ МЕНЮ #} галерея - + {# /ПРАВОЕ МЕНЮ #}