Files
2026-hypn0-site/hypn0/hypn0_site/views.py
T

831 lines
32 KiB
Python

import hashlib
import random
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.paginator import Paginator
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
from .forms import HalftoneGenerateForm
from .models import TbBlogPost, TbHypn0Item, TbVote
from .services.halftone import (
analyze_svg_structure,
generate_halftone_svg,
prepare_active_svg,
prepare_gallery_svg,
)
from .services.naming import generate_hypno_title
def get_floor_fresh(limit: int | None = 8, offset: int = 0):
"""
1-й ЭТАЖ: «Плеск бессознательного» (Инкубатор открытий).
Выборка: свежие кандидаты и первичный поток (Level.CANDIDATE, Level.LEVEL_1).
Сортировка: по наименьшему числу просмотров i_views_count (чтобы дать шанс всем) и свежести -d_created_at.
"""
qs = TbHypn0Item.objects.filter(
i_level__in=[TbHypn0Item.Level.CANDIDATE, TbHypn0Item.Level.LEVEL_1],
is_public=True,
).order_by("i_views_count", "-d_created_at")
if limit is not None:
return qs[offset : offset + limit]
return qs
def get_floor_curated(limit: int | None = 8, offset: int = 0):
"""
2-й ЭТАЖ: «Одобрено Мозговым Слизнем» (Кураторский отбор / Тренды).
Выборка: прошедшие первичный отбор (Level.LEVEL_2).
Сортировка: по гравитационному рейтингу -f_score (актуальные тренды) и свежести -d_created_at.
"""
qs = TbHypn0Item.objects.filter(
i_level=TbHypn0Item.Level.LEVEL_2,
is_public=True,
).order_by("-f_score", "-d_created_at")
if limit is not None:
return qs[offset : offset + limit]
return qs
def get_floor_top(limit: int | None = 8, offset: int = 0):
"""
3-й ЭТАЖ: «Глубокий транс» (Золотой фонд / Высшая лига).
Выборка: бессмертные шедевры сообщества (Level.IMMORTAL).
Сортировка: по числу признания -i_likes_count, -f_score и свежести -d_created_at.
"""
qs = TbHypn0Item.objects.filter(
i_level=TbHypn0Item.Level.IMMORTAL,
is_public=True,
).order_by("-i_likes_count", "-f_score", "-d_created_at")
if limit is not None:
return qs[offset : offset + limit]
return qs
def gallery_archive(request: HttpRequest) -> HttpResponse:
"""
Общий архив галереи транса с фильтрацией по этажам и сортировкой.
Пагинация по 16 карточек на страницу.
"""
floor = request.GET.get("floor", "all")
sort = request.GET.get("sort", "gravity")
# Базовый QuerySet
qs = TbHypn0Item.objects.filter(is_public=True)
# Фильтрация по этажам
if floor == "fresh":
qs = qs.filter(i_level__in=[TbHypn0Item.Level.CANDIDATE, TbHypn0Item.Level.LEVEL_1])
elif floor == "curated":
qs = qs.filter(i_level=TbHypn0Item.Level.LEVEL_2)
elif floor == "top":
qs = qs.filter(i_level=TbHypn0Item.Level.IMMORTAL)
else:
floor = "all"
# Применение сортировки
if sort == "new":
qs = qs.order_by("-d_created_at")
elif sort == "likes":
qs = qs.order_by("-i_likes_count", "-f_score", "-d_created_at")
elif sort == "views":
qs = qs.order_by("i_views_count", "-d_created_at")
elif sort == "popular":
qs = qs.order_by("-i_views_count", "-d_created_at")
else:
sort = "gravity"
qs = qs.order_by("-f_score", "-d_created_at")
# Подсчет количества работ для бейджей на табах
counts = {
"all": TbHypn0Item.objects.filter(is_public=True).count(),
"fresh": TbHypn0Item.objects.filter(
is_public=True,
i_level__in=[TbHypn0Item.Level.CANDIDATE, TbHypn0Item.Level.LEVEL_1]
).count(),
"curated": TbHypn0Item.objects.filter(
is_public=True,
i_level=TbHypn0Item.Level.LEVEL_2
).count(),
"top": TbHypn0Item.objects.filter(
is_public=True,
i_level=TbHypn0Item.Level.IMMORTAL
).count(),
}
paginator = Paginator(qs, 16)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
sort_options = [
{"id": "gravity", "title": "По гравитации (f_score)", "icon": "🌀"},
{"id": "new", "title": "Свежие (по дате)", "icon": "✨"},
{"id": "likes", "title": "По числу лайков", "icon": "♥"},
{"id": "views", "title": "Редкие (мало показов)", "icon": "👁"},
{"id": "popular", "title": "Популярные по показам", "icon": "🔥"},
]
context = {
"page_obj": page_obj,
"current_floor": floor,
"current_sort": sort,
"counts": counts,
"sort_options": sort_options,
}
return render(request, "gallery/archive.html", context)
@ensure_csrf_cookie
def index(request: HttpRequest | None) -> HttpResponse:
fresh_items = get_floor_fresh(limit=8)
curated_items = get_floor_curated(limit=8)
top_items = get_floor_top(limit=8)
return render(request, "index.html", {
"fresh_items": fresh_items,
"curated_items": curated_items,
"top_items": top_items,
})
def tmp(request: HttpRequest | None) -> HttpResponse:
return render(request, "tmp.html", {})
def debug_error_preview(request: HttpRequest, code: str = "") -> HttpResponse:
"""
Dev-представление для отладки и предпросмотра страниц ошибок в режиме DEBUG.
Поддерживает пути вида /_error/404, /_error/404.html, /_error/500, /_error/, /_error/under_reconstruction.
"""
from django.template.exceptions import TemplateDoesNotExist
from django.template.loader import get_template
# Очистка имени шаблона от расширения .html и слэшей
raw_name = code.strip("/").removesuffix(".html") if code else ""
available_codes = [
"400",
"401",
"403",
"404",
"413",
"429",
"500",
"502",
"503",
"504",
"under_reconstruction",
]
if not raw_name:
# Индексная страница со списком всех кодов ошибок для dev-отладки
links = []
for c in available_codes:
exists = False
for tpl in [f"_error/{c}.html", f"{c}.html"]:
try:
get_template(tpl)
exists = True
break
except TemplateDoesNotExist:
pass
status_mark = "готов к просмотру" if exists else "шаблон не создан"
links.append(f"<li><a href='/_error/{c}'><strong>/{c}</strong></a> ({status_mark})</li>")
html = f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Dev: Предпросмотр страниц ошибок // HypnoSVG</title>
<style>
body {{ font-family: ui-monospace, monospace; background: #09090b; color: #d9f99d; padding: 2rem; line-height: 1.6; }}
a {{ color: #a3e635; text-decoration: none; border-bottom: 1px dotted #a3e635; }}
a:hover {{ border-bottom-style: solid; }}
ul {{ list-style-type: square; margin-top: 1rem; }}
li {{ margin-bottom: 0.5rem; }}
.card {{ background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 1.5rem; max-width: 600px; }}
</style>
</head>
<body>
<div class="card">
<h2>Центр отладки страниц ошибок (DEBUG=True)</h2>
<p>Выберите код для инспекции верстки и интерактивных скриптов перекалибровки:</p>
<ul>
{''.join(links)}
</ul>
</div>
</body>
</html>"""
return HttpResponse(html, content_type="text/html; charset=utf-8")
# Поиск соответствующего шаблона
template_candidates = [
f"_error/{raw_name}.html",
f"{raw_name}.html",
f"_error/{raw_name}",
raw_name,
]
found_template = None
for candidate in template_candidates:
try:
get_template(candidate)
found_template = candidate
break
except TemplateDoesNotExist:
continue
if not found_template:
return HttpResponse(
f"<h1>Шаблон для ошибки '{raw_name}' не найден</h1><p>Проверены: {', '.join(template_candidates)}</p><p><a href='/_error/'>← Вернуться к списку</a></p>",
status=404,
content_type="text/html; charset=utf-8",
)
status_code = 200
if raw_name.isdigit() and 400 <= int(raw_name) <= 599:
status_code = int(raw_name)
return render(request, found_template, {}, status=status_code)
def error_400(request: HttpRequest, exception: Exception | None = None) -> HttpResponse:
return render(request, "_error/400.html", {}, status=400)
def error_403(request: HttpRequest, exception: Exception | None = None) -> HttpResponse:
return render(request, "_error/403.html", {}, status=403)
def error_404(request: HttpRequest, exception: Exception | None = None) -> HttpResponse:
return render(request, "_error/404.html", {}, status=404)
def error_500(request: HttpRequest) -> HttpResponse:
return render(request, "_error/500.html", {}, status=500)
@require_POST
def generate(request: HttpRequest) -> HttpResponse:
"""
HTMX-эндпоинт для генерации гипнотического полутонового SVG на лету.
Не сохраняет данные в БД (Zero-Disk / Zero-PII).
"""
form = HalftoneGenerateForm(request.POST, request.FILES)
if not form.is_valid():
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации"
return render(request, "block/preview.html", {"error": error_msg})
image_file = form.cleaned_data["image"]
shape = form.cleaned_data["shape"]
cols = form.cleaned_data["cols"]
max_radius = form.cleaned_data["max_radius"]
blink = form.cleaned_data["blink"]
rotation = form.cleaned_data["rotation"]
scale = form.cleaned_data["scale"]
angle = form.cleaned_data["angle"]
# Извлечение основного цвета (поддержка монотонной схемы)
colors = request.POST.getlist("colors")
primary_color = colors[0] if colors and colors[0] else "#a855ff"
try:
svg_content = generate_halftone_svg(
image=image_file,
cols=cols,
max_radius=max_radius,
shape=shape,
color=primary_color,
blink=blink,
rotation=rotation,
scale=scale,
angle=angle,
)
except Exception as e:
return render(
request,
"block/preview.html",
{"error": f"Сбой в матрице гипноза: {str(e)}"},
)
return render(
request,
"block/preview.html",
{
"svg_content": svg_content,
"form_data": form.cleaned_data,
"primary_color": primary_color,
},
)
@require_POST
def publish(request: HttpRequest) -> HttpResponse:
"""
HTMX-эндпоинт для сохранения текущей генерации как кандидата в галерею транса.
Требует согласия на отслеживание (Zero-PII cookie hypn0_vid).
"""
svg_content = request.POST.get("svg_content", "").strip()
if not svg_content:
return render(
request,
"block/publish_status.html",
{"error": "Нет данных SVG для публикации. Попробуйте сгенерировать заново."},
)
visitor_uuid = request.COOKIES.get("hypn0_vid")
if not visitor_uuid:
# Мозговые слизняки протестуют: пользователь не дал согласия и не подчинился Гипножабе
return render(
request,
"block/publish_status.html",
{
"not_agreed": True,
"svg_content": svg_content,
"shape": request.POST.get("shape", "circle"),
"cols": request.POST.get("cols", "35"),
"max_radius": request.POST.get("max_radius", "8"),
"blink": request.POST.get("blink", "6"),
"rotation": request.POST.get("rotation", "0"),
"scale": request.POST.get("scale", "980"),
"angle": request.POST.get("angle", "0"),
"color": request.POST.get("color", "#a855ff"),
},
)
# 1. Генерируем гипнотическое название
title = generate_hypno_title()
# 2. Подготавливаем SVG для галереи (пауза по умолчанию + hover)
gallery_svg = prepare_gallery_svg(svg_content)
svg_bytes = gallery_svg.encode("utf-8")
# 3. Собираем параметры генерации в метаданные
metadata = {
"shape": request.POST.get("shape", "circle"),
"cols": int(request.POST.get("cols", 35)) if request.POST.get("cols", "").isdigit() else 35,
"max_radius": int(request.POST.get("max_radius", 8)) if request.POST.get("max_radius", "").isdigit() else 8,
"blink": int(request.POST.get("blink", 6)) if request.POST.get("blink", "").isdigit() else 6,
"rotation": int(request.POST.get("rotation", 0)) if request.POST.get("rotation", "").lstrip("-").isdigit() else 0,
"scale": int(request.POST.get("scale", 980)) if request.POST.get("scale", "").isdigit() else 980,
"angle": int(request.POST.get("angle", 0)) if request.POST.get("angle", "").lstrip("-").isdigit() else 0,
"color": request.POST.get("color", "#a855ff"),
}
try:
svg_file = ContentFile(svg_bytes, name="hypn0.svg")
item = TbHypn0Item(
s_title=title,
file_svg=svg_file,
j_metadata=metadata,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
item.save(visitor_uuid_or_fp=visitor_uuid)
except PermissionError:
return render(
request,
"block/publish_status.html",
{
"not_agreed": True,
"svg_content": svg_content,
"shape": request.POST.get("shape", "circle"),
"cols": request.POST.get("cols", "35"),
"max_radius": request.POST.get("max_radius", "8"),
"blink": request.POST.get("blink", "6"),
"rotation": request.POST.get("rotation", "0"),
"scale": request.POST.get("scale", "980"),
"angle": request.POST.get("angle", "0"),
"color": request.POST.get("color", "#a855ff"),
},
)
except Exception as e:
return render(
request,
"block/publish_status.html",
{"error": f"Сбой фиксации в трансе: {str(e)}"},
)
return render(
request,
"block/publish_status.html",
{
"success": True,
"item": item,
},
)
def gallery_random(request: HttpRequest) -> HttpResponse:
"""
Быстрый эндпоинт для случайной навигации по галерее ("в транс") и предзагрузки пула хэшей.
Принимает список уже просмотренных хэшей в параметре `exclude` (через запятую) и текущий хэш `current`.
Архитектура выборки:
- Запрашивает только плоский список индексированных строк `s_hash_id` (Index Only Scan).
- Перемешивание и срез происходят в памяти Python за доли миллисекунды без тяжелого `ORDER BY RANDOM()`.
- Если все исключенные работы покрыли базу, фильтр сбрасывается для замыкания кольца.
========================================================================================
МАСШТАБИРОВАНИЕ НА 1 000 000+ ЗАПИСЕЙ (FUTURE SCALING NOTE):
При достижении миллионов строк в базе операция exclude(s_hash_id__in=...) может нагружать СУБД.
Для ультра-высоких нагрузок переключить на алгоритм:
1. count = TbHypn0Item.objects.filter(is_public=True).count()
2. random_offset = random.randint(0, max(0, count - slice_size))
3. pool_slice = list(TbHypn0Item.objects.filter(is_public=True)
.values_list('s_hash_id', flat=True)[random_offset : random_offset + slice_size])
4. candidates = [h for h in pool_slice if h not in exclude_set and h != current]
5. return random.sample(candidates, min(len(candidates), limit))
========================================================================================
"""
exclude_raw = request.GET.get("exclude", "")
exclude_hashes = set(h.strip() for h in exclude_raw.split(",") if h.strip())
current_hash = request.GET.get("current", "").strip()
if current_hash:
exclude_hashes.add(current_hash)
try:
limit = min(max(int(request.GET.get("limit", 20)), 1), 50)
except (ValueError, TypeError):
limit = 20
base_qs = TbHypn0Item.objects.filter(is_public=True)
# 1. Попытка выбрать кандидатов среди еще не просмотренных
candidate_hashes = list(
base_qs.exclude(s_hash_id__in=exclude_hashes).values_list("s_hash_id", flat=True)[:300]
)
# 2. Если все просмотрены (или база меньше истории) — замыкаем кольцо, исключая только текущий
if not candidate_hashes and current_hash:
candidate_hashes = list(
base_qs.exclude(s_hash_id=current_hash).values_list("s_hash_id", flat=True)[:300]
)
# 3. Крайний случай — берем любые доступные
if not candidate_hashes:
candidate_hashes = list(base_qs.values_list("s_hash_id", flat=True)[:300])
if candidate_hashes:
random.shuffle(candidate_hashes)
pool = candidate_hashes[:limit]
next_hash = pool[0]
else:
pool = []
next_hash = None
# Определение формата ответа
is_json = (
request.GET.get("format") == "json"
or request.headers.get("Accept") == "application/json"
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
)
if is_json:
return JsonResponse({"next_hash_id": next_hash, "pool": pool})
if next_hash:
return redirect("hypn0_site:gallery_detail", hash_id=next_hash)
return redirect("hypn0_site:index")
def build_unconscious_matrix(current_hash: str, seed_param: str | None) -> tuple[list[dict], str, str, str, str, str]:
"""
Формирует "Матрицу бессознательного" (до 200 псевдослучайных узлов) вокруг корневой картины.
Формат seed: "<NUM>_<ORIGIN_HASH>", например "74291_a1b2c3".
Алгоритм:
- При первом заходе без seed генерируется seed_num и origin_hash = current_hash.
- Выбираются -99 элементов слева и +100 элементов справа от origin_hash (всего до 200).
- Для N <= 200 перемешиваются все доступные картины, а origin_hash помещается в центр.
- Для текущего элемента current_hash определяются prev_hash и next_hash по кольцу.
"""
seed_num = None
origin_hash = current_hash
if seed_param:
parts = str(seed_param).split("_", 1)
try:
seed_num = int(parts[0])
if len(parts) > 1 and parts[1].strip():
origin_hash = parts[1].strip()
except (ValueError, TypeError):
seed_num = None
if seed_num is None:
seed_num = random.randint(10000, 99999)
origin_hash = current_hash
effective_seed = f"{seed_num}_{origin_hash}"
rng = random.Random(seed_num)
# 1. Запрашиваем все публичные хэши
all_hashes = list(TbHypn0Item.objects.filter(is_public=True).values_list("s_hash_id", flat=True))
if not all_hashes:
all_hashes = [current_hash]
if origin_hash not in all_hashes:
origin_hash = current_hash
other_hashes = [h for h in all_hashes if h != origin_hash]
if len(other_hashes) <= 199:
rng.shuffle(other_hashes)
mid_idx = len(other_hashes) // 2
trail = other_hashes[:mid_idx] + [origin_hash] + other_hashes[mid_idx:]
else:
chosen_others = rng.sample(other_hashes, 199)
left_part = chosen_others[:99]
right_part = chosen_others[99:]
trail = left_part + [origin_hash] + right_part
# Гарантия наличия current_hash в trail
if current_hash not in trail:
trail.append(current_hash)
idx = trail.index(current_hash)
total = len(trail)
prev_hash = trail[(idx - 1) % total]
next_hash = trail[(idx + 1) % total]
prev_url = f"/gallery/{prev_hash}?seed={effective_seed}"
next_url = f"/gallery/{next_hash}?seed={effective_seed}"
matrix_items = [
{
"s_hash_id": h,
"is_current": (h == current_hash),
"url": f"/gallery/{h}?seed={effective_seed}",
}
for h in trail
]
return matrix_items, prev_hash, next_hash, prev_url, next_url, effective_seed
def gallery_detail(request: HttpRequest, hash_id: str) -> HttpResponse:
"""
Страница детального просмотра и шеринга картины из галереи транса.
Выводит активный живой SVG, кнопки скачивания/копирования, наукообразную телеметрию
и интерактивную Матрицу бессознательного (персональный поток хэшей по seed).
"""
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
# Инкремент просмотров
item.increment_views()
# Считывание содержимого SVG
svg_content = ""
if item.file_svg:
try:
with item.file_svg.open("r") as f:
svg_content = f.read()
if isinstance(svg_content, bytes):
svg_content = svg_content.decode("utf-8")
except Exception:
svg_content = ""
active_svg = prepare_active_svg(svg_content)
svg_stats = analyze_svg_structure(svg_content)
# Авторский отпечаток
author_vote = item.votes.filter(i_direction=TbVote.Direction.AUTHOR).first()
author_fp = author_vote.s_fingerprint if author_vote else None
# Проверка, голосовал ли текущий посетитель
user_voted = False
visitor_uuid = request.COOKIES.get("hypn0_vid")
if visitor_uuid and item.pk:
fp = hashlib.sha256(f"{visitor_uuid}:{settings.SECRET_KEY}".encode()).hexdigest()
user_voted = item.votes.filter(s_fingerprint=fp).exists()
# Построение матрицы бессознательного
seed_param = request.GET.get("seed")
matrix_items, prev_hash, next_hash, prev_url, next_url, seed = build_unconscious_matrix(
current_hash=item.s_hash_id, seed_param=seed_param
)
context = {
"item": item,
"active_svg": active_svg,
"raw_svg": svg_content,
"svg_stats": svg_stats,
"author_fp": author_fp,
"user_voted": user_voted,
"metadata": item.j_metadata or {},
"matrix_items": matrix_items,
"prev_hash": prev_hash,
"next_hash": next_hash,
"prev_url": prev_url,
"next_url": next_url,
"seed": seed,
}
return render(request, "gallery/detail.html", context)
def gallery_download(request: HttpRequest, hash_id: str) -> HttpResponse:
"""
Эндпоинт для скачивания чистого SVG-файла картины.
"""
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
if not item.file_svg:
raise Http404("SVG файл отсутствует")
try:
with item.file_svg.open("r") as f:
svg_content = f.read()
if isinstance(svg_content, bytes):
svg_content = svg_content.decode("utf-8")
except Exception:
raise Http404("SVG файл не может быть прочитан")
clean_svg = prepare_active_svg(svg_content)
response = HttpResponse(clean_svg, content_type="image/svg+xml")
filename = f"hypn0-{item.s_hash_id}.svg"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@require_POST
def gallery_vote(request: HttpRequest, hash_id: str) -> HttpResponse:
"""
HTMX-эндпоинт для голосования (лайк) за картину в галерее.
"""
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
visitor_uuid = request.COOKIES.get("hypn0_vid")
if not visitor_uuid:
return render(
request,
"block/vote_button.html",
{
"item": item,
"user_voted": False,
"not_agreed": True,
},
)
voted = item.increment_likes(visitor_uuid)
item.refresh_from_db(fields=["i_likes_count"])
return render(
request,
"block/vote_button.html",
{
"item": item,
"user_voted": True,
"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,
"series_total_count": len(series_posts) if series_posts else 0,
"prev_post": prev_post,
"next_post": next_post,
}
return render(request, "blog/detail.html", context)