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

553 lines
22 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 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_floor(request: HttpRequest, floor_slug: str) -> HttpResponse:
"""
Страница полного просмотра конкретного этажа галереи с пагинацией (по 16 карточек).
"""
floors_config = {
"fresh": {
"title": "Плеск бессознательного",
"badge": "FRESH STREAM",
"badge_color": "amber",
"subtitle": "Свежие галлюцинации из инкубатора • Первичная оценка сообщества",
"getter": get_floor_fresh,
},
"curated": {
"title": "Одобрено Мозговым Слизнем",
"badge": "CURATED",
"badge_color": "cyan",
"subtitle": "Проверено контролем качества • Высокий психоделический резонанс",
"getter": get_floor_curated,
},
"top": {
"title": "Глубокий транс",
"badge": "TOP TIER",
"badge_color": "emerald",
"subtitle": "Золотой фонд гипноза • Абсолютное подчинение воли",
"getter": get_floor_top,
},
}
if floor_slug not in floors_config:
raise Http404("Этаж транса не обнаружен в матрице")
cfg = floors_config[floor_slug]
items_qs = cfg["getter"](limit=None)
paginator = Paginator(items_qs, 16)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
context = {
"floor_slug": floor_slug,
"floor_title": cfg["title"],
"floor_badge": cfg["badge"],
"floor_badge_color": cfg["badge_color"],
"floor_subtitle": cfg["subtitle"],
"page_obj": page_obj,
}
return render(request, "gallery/floor.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", {})
@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,
i_file_size=len(svg_bytes),
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,
},
)