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"
Выберите код для инспекции верстки и интерактивных скриптов перекалибровки:
Проверены: {', '.join(template_candidates)}
", 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: "