add: галерея (1) одиночная картинка

This commit is contained in:
2026-08-29 23:32:43 +03:00
parent f9042b0e59
commit 6756b301b5
8 changed files with 1090 additions and 8 deletions
+99
View File
@@ -1,6 +1,7 @@
import io
import math
import random
import re
from collections import defaultdict
from typing import BinaryIO, Union
@@ -281,3 +282,101 @@ def prepare_gallery_svg(svg_content: str) -> str:
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
return svg_content
def prepare_active_svg(svg_content: str) -> str:
"""
Возвращает полностью активный SVG без паузы анимации вне ховера
для страницы детального просмотра и скачивания пользователем.
"""
if not svg_content:
return svg_content
hover_css = (
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
)
return svg_content.replace(hover_css, "")
def analyze_svg_structure(svg_content: str) -> dict:
"""
Анализирует разметку SVG и возвращает наукообразную детальную статистику
по геометрическим примитивам, классам, анимациям и энтропии данных.
"""
if not svg_content:
return {
"total_bytes": 0,
"total_kb": 0.0,
"total_oscillators": 0,
"use_count": 0,
"circle_count": 0,
"rect_count": 0,
"polygon_count": 0,
"path_count": 0,
"defs_count": 0,
"groups_count": 0,
"keyframes_count": 0,
"classes_count": 0,
"viewbox": "0 0 1000 1000",
"width": 1000,
"height": 1000,
"aspect_ratio": "1:1",
"density": 0.0,
"bytes_per_quantum": 0.0,
}
total_bytes = len(svg_content.encode("utf-8"))
# Подсчет векторных сущностей
use_count = len(re.findall(r"<use\b", svg_content))
circle_count = len(re.findall(r"<circle\b", svg_content))
rect_count = len(re.findall(r"<rect\b", svg_content))
polygon_count = len(re.findall(r"<polygon\b", svg_content))
path_count = len(re.findall(r"<path\b", svg_content))
defs_count = circle_count + rect_count + polygon_count + path_count
# Группы и классы
groups_count = len(re.findall(r"<g\b", svg_content))
keyframes_count = len(re.findall(r"@keyframes\b", svg_content))
classes = set(re.findall(r"\.([a-zA-Z0-9_-]+)\s*\{", svg_content))
classes_count = len(classes)
# ViewBox и геометрия
vb_match = re.search(r'viewBox=["\']([^"\']+)["\']', svg_content)
viewbox = vb_match.group(1) if vb_match else "0 0 1000 1000"
vb_parts = viewbox.split()
try:
width = int(float(vb_parts[2])) if len(vb_parts) >= 3 else 1000
height = int(float(vb_parts[3])) if len(vb_parts) >= 4 else 1000
except (ValueError, IndexError):
width, height = 1000, 1000
gcd_val = math.gcd(width, height)
aspect_ratio = f"{width // gcd_val}:{height // gcd_val}" if gcd_val > 0 else "1:1"
# Наукообразные показатели
total_oscillators = use_count if use_count > 0 else defs_count
bytes_per_quantum = round(total_bytes / max(total_oscillators, 1), 1)
density = round(total_oscillators / max((width * height) / 100000.0, 1.0), 2)
return {
"total_bytes": total_bytes,
"total_kb": round(total_bytes / 1024, 2),
"total_oscillators": total_oscillators,
"use_count": use_count,
"circle_count": circle_count,
"rect_count": rect_count,
"polygon_count": polygon_count,
"path_count": path_count,
"defs_count": defs_count,
"groups_count": groups_count,
"keyframes_count": keyframes_count,
"classes_count": classes_count,
"viewbox": viewbox,
"width": width,
"height": height,
"aspect_ratio": aspect_ratio,
"density": density,
"bytes_per_quantum": bytes_per_quantum,
}
+265 -1
View File
@@ -1,4 +1,5 @@
import io
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
@@ -6,7 +7,13 @@ from PIL import Image
from .forms import HalftoneGenerateForm
from .models import TbHypn0Item, TbVote
from .services.halftone import encode_to_base36, generate_halftone_svg, prepare_gallery_svg
from .services.halftone import (
analyze_svg_structure,
encode_to_base36,
generate_halftone_svg,
prepare_active_svg,
prepare_gallery_svg,
)
from .services.naming import generate_hypno_title, generate_title_openrouter
@@ -304,3 +311,260 @@ class PublishViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Нет данных SVG для публикации")
self.assertEqual(TbHypn0Item.objects.count(), 0)
class SvgAnalysisAndActiveSvgTests(TestCase):
"""Тестирование анализа структуры SVG и очистки от паузы."""
def test_prepare_active_svg(self):
gallery_svg = '<svg><style>svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}</style><g></g></svg>'
active = prepare_active_svg(gallery_svg)
self.assertNotIn("animation-play-state:paused!important", active)
def test_analyze_svg_structure(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">'
'<style>.a0{--d:0s}.a1{--d:1s}@keyframes noise{0%{opacity:1}100%{opacity:0.5}}</style>'
'<defs><circle id="s0" r="5"/><rect id="s1" width="10" height="10"/></defs>'
'<g id="hypn0-canvas">'
'<g class="a0"><use href="#s0" x="10" y="10"/><use href="#s0" x="20" y="20"/></g>'
'<g class="a1"><use href="#s1" x="30" y="30"/></g>'
'</g>'
'</svg>'
)
stats = analyze_svg_structure(svg)
self.assertEqual(stats["use_count"], 3)
self.assertEqual(stats["total_oscillators"], 3)
self.assertEqual(stats["defs_count"], 2)
self.assertEqual(stats["circle_count"], 1)
self.assertEqual(stats["rect_count"], 1)
self.assertEqual(stats["groups_count"], 3)
self.assertEqual(stats["keyframes_count"], 1)
self.assertEqual(stats["viewbox"], "0 0 800 600")
self.assertEqual(stats["width"], 800)
self.assertEqual(stats["height"], 600)
self.assertEqual(stats["aspect_ratio"], "4:3")
self.assertTrue(stats["total_bytes"] > 0)
class GalleryDetailAndDownloadTests(TestCase):
"""Тестирование страниц детального просмотра картины, скачивания и голосования."""
def setUp(self):
self.client = Client()
self.vid = "123e4567-e89b-12d3-a456-426614174000"
svg_code = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">'
'<style>.a0{--d:0s}svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}</style>'
'<defs><circle id="s0" r="8"/></defs>'
'<g id="hypn0-canvas"><g class="a0"><use href="#s0" x="50" y="50"/></g></g>'
'</svg>'
)
self.item = TbHypn0Item(
s_title="Астральный Транс Сознания #42",
file_svg=ContentFile(svg_code.encode("utf-8"), name="test_item.svg"),
i_file_size=len(svg_code),
j_metadata={
"shape": "circle",
"cols": 35,
"max_radius": 8,
"blink": 6,
"rotation": 0,
"scale": 980,
"angle": 0,
"color": "#a855ff",
},
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
self.item.save(visitor_uuid_or_fp=self.vid)
def test_gallery_detail_view_success(self):
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": self.item.s_hash_id}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Астральный Транс Сознания #42")
self.assertContains(response, f"#{self.item.s_hash_id}")
self.assertContains(response, "Векторная анатомия")
self.assertContains(response, "Синтез психо-сетки")
self.assertContains(response, "Астральный паспорт")
self.assertContains(response, "Копировать SVG-код")
self.assertContains(response, "Скачать .svg")
# Проверка инкремента просмотров
self.item.refresh_from_db()
self.assertGreaterEqual(self.item.i_views_count, 2)
def test_gallery_detail_404_on_invalid_hash(self):
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": "nonexistent999"}))
self.assertEqual(response.status_code, 404)
def test_gallery_download_view_success(self):
response = self.client.get(reverse("hypn0_site:gallery_download", kwargs={"hash_id": self.item.s_hash_id}))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "image/svg+xml")
self.assertIn(f'filename="hypn0-{self.item.s_hash_id}.svg"', response["Content-Disposition"])
# Должен быть чистый активный SVG (без паузы анимации)
self.assertNotIn("animation-play-state:paused!important", response.content.decode("utf-8"))
def test_gallery_vote_with_cookie(self):
# Новый посетитель
voter_vid = "987e6543-e21b-12d3-a456-426614174999"
self.client.cookies["hypn0_vid"] = voter_vid
response = self.client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
self.assertEqual(response.status_code, 200)
self.item.refresh_from_db()
self.assertEqual(self.item.i_likes_count, 2)
def test_gallery_vote_without_cookie_returns_notice(self):
client = Client()
response = client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Подчинитесь воле Гипножабы!")
class GalleryRandomAndNavigationTests(TestCase):
"""Тестирование случайной навигации и пула хэшей (/gallery/random)."""
def setUp(self):
self.client = Client()
self.vid = "123e4567-e89b-12d3-a456-426614174000"
# Создаем 3 картины в галерее
self.items = []
for i in range(3):
svg_code = f'<svg><g id="item_{i}"></g></svg>'
item = TbHypn0Item(
s_title=f"Гипно Картина #{i}",
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_item_{i}.svg"),
i_file_size=len(svg_code),
j_metadata={"cols": 30 + i},
is_public=True,
)
item.save(visitor_uuid_or_fp=self.vid)
self.items.append(item)
def test_gallery_random_direct_redirect(self):
response = self.client.get(reverse("hypn0_site:gallery_random"))
self.assertEqual(response.status_code, 302)
all_hashes = [it.s_hash_id for it in self.items]
self.assertTrue(any(h in response.url for h in all_hashes))
def test_gallery_random_json_pool(self):
response = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json", "limit": 2})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("next_hash_id", data)
self.assertIn("pool", data)
self.assertEqual(len(data["pool"]), 2)
all_hashes = {it.s_hash_id for it in self.items}
self.assertIn(data["next_hash_id"], all_hashes)
def test_gallery_random_exclude_filters_seen(self):
exclude_hashes = [self.items[0].s_hash_id, self.items[1].s_hash_id]
response = self.client.get(
reverse("hypn0_site:gallery_random"),
data={"format": "json", "exclude": ",".join(exclude_hashes)},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["next_hash_id"], self.items[2].s_hash_id)
def test_gallery_random_exclude_all_loops_circle(self):
# Если исключены все 3 картины, кольцо замыкается
exclude_hashes = [it.s_hash_id for it in self.items]
response = self.client.get(
reverse("hypn0_site:gallery_random"),
data={
"format": "json",
"exclude": ",".join(exclude_hashes),
"current": self.items[0].s_hash_id,
},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsNotNone(data["next_hash_id"])
# Текущий элемент исключен, выбран один из оставшихся
self.assertIn(data["next_hash_id"], [self.items[1].s_hash_id, self.items[2].s_hash_id])
def test_gallery_random_empty_database(self):
TbHypn0Item.objects.all().delete()
# Direct visit redirects to index
response = self.client.get(reverse("hypn0_site:gallery_random"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("hypn0_site:index"))
# JSON returns null next_hash_id
json_resp = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json"})
self.assertEqual(json_resp.status_code, 200)
self.assertIsNone(json_resp.json()["next_hash_id"])
self.assertEqual(json_resp.json()["pool"], [])
class UnconsciousMatrixTests(TestCase):
"""Тестирование Матрицы бессознательного (build_unconscious_matrix) и навигации."""
def setUp(self):
self.client = Client()
self.vid = "123e4567-e89b-12d3-a456-426614174000"
# Создаем 5 картин
self.items = []
for i in range(5):
svg_code = f'<svg><circle id="dot_{i}"/></svg>'
item = TbHypn0Item(
s_title=f"Тестовый транс #{i}",
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_matrix_{i}.svg"),
i_file_size=len(svg_code),
j_metadata={"cols": 20 + i},
is_public=True,
)
item.save(visitor_uuid_or_fp=self.vid)
self.items.append(item)
def test_build_unconscious_matrix_basic(self):
from hypn0_site.views import build_unconscious_matrix
current = self.items[0].s_hash_id
matrix_items, prev_h, next_h, prev_url, next_url, seed = build_unconscious_matrix(current, None)
self.assertEqual(len(matrix_items), 5)
# Проверяем, что current отмечен как is_current
current_node = [n for n in matrix_items if n["s_hash_id"] == current][0]
self.assertTrue(current_node["is_current"])
# Другие узлы не current
other_nodes = [n for n in matrix_items if n["s_hash_id"] != current]
self.assertTrue(all(not n["is_current"] for n in other_nodes))
# Ссылки содержат seed
self.assertIn(f"seed={seed}", prev_url)
self.assertIn(f"seed={seed}", next_url)
def test_matrix_stability_with_fixed_seed(self):
from hypn0_site.views import build_unconscious_matrix
root_hash = self.items[2].s_hash_id
fixed_seed = f"42109_{root_hash}"
# Первый запрос
m1, prev1, next1, _, _, seed1 = build_unconscious_matrix(self.items[0].s_hash_id, fixed_seed)
# Второй запрос с тем же seed на другую картину
m2, prev2, next2, _, _, seed2 = build_unconscious_matrix(self.items[1].s_hash_id, fixed_seed)
self.assertEqual(seed1, fixed_seed)
self.assertEqual(seed2, fixed_seed)
# Состав и порядок хэшей в матрице должны быть идентичны
hashes1 = [n["s_hash_id"] for n in m1]
hashes2 = [n["s_hash_id"] for n in m2]
self.assertEqual(hashes1, hashes2)
def test_gallery_detail_renders_matrix(self):
item = self.items[0]
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": item.s_hash_id}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Матрица бессознательного")
self.assertContains(response, "5 узлов")
self.assertContains(response, f"#{item.s_hash_id}")
self.assertContains(response, "btn-nav-prev")
self.assertContains(response, "btn-nav-next")
+5
View File
@@ -9,6 +9,11 @@ urlpatterns = [
path("", views.index, name="index"),
path("generate", views.generate, name="generate"),
path("publish", views.publish, name="publish"),
path("gallery/random", views.gallery_random, name="gallery_random"),
path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"),
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"),
]
if settings.DEBUG:
+279 -4
View File
@@ -1,12 +1,21 @@
import hashlib
import random
from django.conf import settings
from django.core.files.base import ContentFile
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
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
from .services.halftone import generate_halftone_svg, prepare_gallery_svg
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
@@ -170,3 +179,269 @@ def publish(request: HttpRequest) -> HttpResponse:
"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,
},
)