mod: блоги (2) драфт
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
from django.contrib.sitemaps import Sitemap
|
from django.contrib.sitemaps import Sitemap
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .models import TbHypn0Item
|
from .models import TbBlogPost, TbHypn0Item
|
||||||
|
|
||||||
|
|
||||||
class StaticViewSitemap(Sitemap):
|
class StaticViewSitemap(Sitemap):
|
||||||
@@ -17,6 +17,7 @@ class StaticViewSitemap(Sitemap):
|
|||||||
return [
|
return [
|
||||||
("hypn0_site:index", 1.0, "daily"),
|
("hypn0_site:index", 1.0, "daily"),
|
||||||
("hypn0_site:gallery_archive", 0.9, "daily"),
|
("hypn0_site:gallery_archive", 0.9, "daily"),
|
||||||
|
("hypn0_site:blog_feed", 0.8, "daily"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def location(self, item):
|
def location(self, item):
|
||||||
@@ -29,6 +30,23 @@ class StaticViewSitemap(Sitemap):
|
|||||||
return item[2]
|
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):
|
class GalleryItemSitemap(Sitemap):
|
||||||
"""
|
"""
|
||||||
Карта сайта для публичных гипно-картин галереи сообщества.
|
Карта сайта для публичных гипно-картин галереи сообщества.
|
||||||
@@ -63,4 +81,5 @@ class GalleryItemSitemap(Sitemap):
|
|||||||
sitemaps = {
|
sitemaps = {
|
||||||
"static": StaticViewSitemap,
|
"static": StaticViewSitemap,
|
||||||
"gallery": GalleryItemSitemap,
|
"gallery": GalleryItemSitemap,
|
||||||
|
"blog": BlogPostSitemap,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1317,3 +1317,96 @@ class BlogPostModelAndAdminTests(BaseMediaTestCase):
|
|||||||
self.assertContains(response, "Секретные формулы векторного воздействия")
|
self.assertContains(response, "Секретные формулы векторного воздействия")
|
||||||
self.assertContains(response, "/blog/khroniki-hypno-transa-urok-1")
|
self.assertContains(response, "/blog/khroniki-hypno-transa-urok-1")
|
||||||
self.assertNotContains(response, "Секретный черновик")
|
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>", views.gallery_detail, name="gallery_detail"),
|
||||||
path("gallery/<str:hash_id>/download", views.gallery_download, name="gallery_download"),
|
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("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:
|
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 django.views.decorators.http import require_POST
|
||||||
|
|
||||||
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,
|
||||||
generate_halftone_svg,
|
generate_halftone_svg,
|
||||||
@@ -573,3 +573,144 @@ def gallery_vote(request: HttpRequest, hash_id: str) -> HttpResponse:
|
|||||||
"voted_now": voted,
|
"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)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>{# /ЛОГОТИП #}
|
</div>{# /ЛОГОТИП #}
|
||||||
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-start text-xl font-semibold text-slate-900 dark:text-slate-100">
|
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-start text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||||
<a href="{% url 'hypn0_site:gallery_archive' %}" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg border-0 text-slate-900 dark:text-slate-100">галерея</a>
|
<a href="{% url 'hypn0_site:gallery_archive' %}" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg border-0 text-slate-900 dark:text-slate-100">галерея</a>
|
||||||
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden md:block hyphens-none border-0 text-slate-900 dark:text-slate-100">блог</a>
|
<a href="{% url 'hypn0_site:blog_feed' %}" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden md:block hyphens-none border-0 text-slate-900 dark:text-slate-100">блог</a>
|
||||||
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden lg:block border-0 text-slate-900 dark:text-slate-100" @click.prevent="darkMode = !darkMode; localStorage.setItem('darkMode', darkMode)" title="Переключить тему"><span x-text="darkMode?'●':'○'"></span></a>
|
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden lg:block border-0 text-slate-900 dark:text-slate-100" @click.prevent="darkMode = !darkMode; localStorage.setItem('darkMode', darkMode)" title="Переключить тему"><span x-text="darkMode?'●':'○'"></span></a>
|
||||||
</menu>{# /ПРАВОЕ МЕНЮ #}
|
</menu>{# /ПРАВОЕ МЕНЮ #}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user