add: галерея (7) Общая галерея с фильтрами

This commit is contained in:
2026-09-06 19:17:22 +03:00
parent 12474b9097
commit 60fda39bb3
6 changed files with 239 additions and 10 deletions
+135
View File
@@ -923,3 +923,138 @@ class CardBgStyleTests(BaseMediaTestCase):
self.assertContains(response, 'template shadowrootmode="open"')
self.assertContains(response, "hypn0-card-svg")
self.assertContains(response, "--hypn0-play: running !important")
class GalleryArchiveTests(BaseMediaTestCase):
"""Тестирование общего архива галереи /gallery (фильтры, сортировка, пагинация)."""
def setUp(self):
super().setUp()
self.vid = "123e4567-e89b-12d3-a456-426614174000"
svg_bytes = b'<svg><circle/></svg>'
# Создаем картины разных уровней
self.item_fresh = TbHypn0Item(
s_title="Свежая картина",
file_svg=ContentFile(svg_bytes, name="fresh.svg"),
i_level=TbHypn0Item.Level.CANDIDATE,
i_likes_count=5,
i_views_count=10,
f_score=1.5,
is_public=True,
)
self.item_fresh.save(visitor_uuid_or_fp=self.vid)
self.item_curated = TbHypn0Item(
s_title="Кураторская картина",
file_svg=ContentFile(svg_bytes, name="curated.svg"),
i_level=TbHypn0Item.Level.LEVEL_2,
i_likes_count=50,
i_views_count=200,
f_score=80.0,
is_public=True,
)
self.item_curated.save(visitor_uuid_or_fp=self.vid)
self.item_top = TbHypn0Item(
s_title="Бессмертная картина",
file_svg=ContentFile(svg_bytes, name="top.svg"),
i_level=TbHypn0Item.Level.IMMORTAL,
i_likes_count=500,
i_views_count=2000,
f_score=150.0,
is_public=True,
)
self.item_top.save(visitor_uuid_or_fp=self.vid)
def test_gallery_archive_default_renders_all_and_gravity(self):
url = reverse("hypn0_site:gallery_archive")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Галерея транса")
self.assertContains(response, "ARCHIVE")
self.assertContains(response, "Все волны")
self.assertContains(response, "Инкубатор")
self.assertContains(response, "Одобрено")
self.assertContains(response, "Золотой фонд")
# Все 3 картины присутствуют в выдаче
self.assertContains(response, f"gallery-card-{self.item_fresh.s_hash_id}")
self.assertContains(response, f"gallery-card-{self.item_curated.s_hash_id}")
self.assertContains(response, f"gallery-card-{self.item_top.s_hash_id}")
# Проверяем правильный порядок (по f_score DESC: top, curated, fresh)
page_items = list(response.context["page_obj"])
self.assertEqual(page_items[0].pk, self.item_top.pk)
self.assertEqual(page_items[1].pk, self.item_curated.pk)
self.assertEqual(page_items[2].pk, self.item_fresh.pk)
def test_gallery_archive_filter_by_floor(self):
url = reverse("hypn0_site:gallery_archive")
# Фильтр: Инкубатор (fresh)
resp_fresh = self.client.get(url, data={"floor": "fresh"})
self.assertEqual(resp_fresh.status_code, 200)
fresh_ids = [it.pk for it in resp_fresh.context["page_obj"]]
self.assertIn(self.item_fresh.pk, fresh_ids)
self.assertNotIn(self.item_curated.pk, fresh_ids)
self.assertNotIn(self.item_top.pk, fresh_ids)
# Фильтр: Одобрено (curated)
resp_curated = self.client.get(url, data={"floor": "curated"})
self.assertEqual(resp_curated.status_code, 200)
curated_ids = [it.pk for it in resp_curated.context["page_obj"]]
self.assertNotIn(self.item_fresh.pk, curated_ids)
self.assertIn(self.item_curated.pk, curated_ids)
self.assertNotIn(self.item_top.pk, curated_ids)
# Фильтр: Золотой фонд (top)
resp_top = self.client.get(url, data={"floor": "top"})
self.assertEqual(resp_top.status_code, 200)
top_ids = [it.pk for it in resp_top.context["page_obj"]]
self.assertNotIn(self.item_fresh.pk, top_ids)
self.assertNotIn(self.item_curated.pk, top_ids)
self.assertIn(self.item_top.pk, top_ids)
def test_gallery_archive_sorting(self):
url = reverse("hypn0_site:gallery_archive")
# Сортировка: по числу лайков
resp_likes = self.client.get(url, data={"sort": "likes"})
self.assertEqual(resp_likes.status_code, 200)
likes_items = list(resp_likes.context["page_obj"])
self.assertEqual(likes_items[0].pk, self.item_top.pk)
self.assertEqual(likes_items[-1].pk, self.item_fresh.pk)
# Сортировка: по числу просмотров (наименее просмотренные первые)
resp_views = self.client.get(url, data={"sort": "views"})
self.assertEqual(resp_views.status_code, 200)
views_items = list(resp_views.context["page_obj"])
self.assertEqual(views_items[0].pk, self.item_fresh.pk)
self.assertEqual(views_items[-1].pk, self.item_top.pk)
def test_gallery_archive_pagination_and_link_preservation(self):
svg_bytes = b'<svg><circle/></svg>'
# Создаем еще 15 свежих картин (всего 18 в базе)
for i in range(15):
item = TbHypn0Item(
s_title=f"Доп #{i}",
file_svg=ContentFile(svg_bytes, name=f"extra_{i}.svg"),
i_level=TbHypn0Item.Level.CANDIDATE,
f_score=float(i),
is_public=True,
)
item.save(visitor_uuid_or_fp=self.vid)
url = reverse("hypn0_site:gallery_archive")
resp_p1 = self.client.get(url, data={"floor": "all", "sort": "likes"})
self.assertEqual(resp_p1.status_code, 200)
self.assertEqual(len(resp_p1.context["page_obj"]), 16)
self.assertContains(resp_p1, "Фаза 1 из 2")
# Проверяем сохранение query-параметров в ссылках пагинатора
self.assertContains(resp_p1, "floor=all&sort=likes&page=2")
resp_p2 = self.client.get(url, data={"floor": "all", "sort": "likes", "page": 2})
self.assertEqual(resp_p2.status_code, 200)
self.assertEqual(len(resp_p2.context["page_obj"]), 2)
self.assertContains(resp_p2, "Фаза 2 из 2")
self.assertContains(resp_p2, "floor=all&sort=likes&page=1")
+1
View File
@@ -9,6 +9,7 @@ urlpatterns = [
path("", views.index, name="index"),
path("generate", views.generate, name="generate"),
path("publish", views.publish, name="publish"),
path("gallery", views.gallery_archive, name="gallery_archive"),
path("gallery/random", views.gallery_random, name="gallery_random"),
path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"),
path("gallery/floor/<slug:floor_slug>", views.gallery_floor, name="gallery_floor"),
+73
View File
@@ -68,6 +68,79 @@ def get_floor_top(limit: int | None = 8, offset: int = 0):
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)
def gallery_floor(request: HttpRequest, floor_slug: str) -> HttpResponse:
"""
Страница полного просмотра конкретного этажа галереи с пагинацией (по 16 карточек).
+28 -8
View File
@@ -36,19 +36,39 @@
</div>
</a>
<!-- Верхний бейдж: Хэш -->
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-stone-900/70 text-stone-200 backdrop-blur-md border border-white/10 opacity-75 group-hover:opacity-100 transition-opacity">
¤{{ item.s_hash_id }}
</span>
<!-- Верхний бейдж: Хэш и уровень -->
<div class="absolute top-2 left-2 z-20 pointer-events-none flex items-center gap-1">
{% if item.i_level >= 1000 %}
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-emerald-950/80 text-emerald-300 backdrop-blur-md border border-emerald-500/30 opacity-80 group-hover:opacity-100 transition-opacity">
★ ¤{{ item.s_hash_id }}
</span>
{% elif item.i_level >= 30 %}
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-cyan-950/80 text-cyan-300 backdrop-blur-md border border-cyan-500/30 opacity-80 group-hover:opacity-100 transition-opacity">
◉ ¤{{ item.s_hash_id }}
</span>
{% else %}
<span class="text-[10px] font-mono font-bold tracking-tight px-1.5 py-0.5 rounded bg-stone-900/70 text-stone-200 backdrop-blur-md border border-white/10 opacity-75 group-hover:opacity-100 transition-opacity">
¤{{ item.s_hash_id }}
</span>
{% endif %}
</div>
<!-- Нижняя панель статистики (Лайки и Просмотры) -->
<div class="absolute bottom-2 left-2 right-2 z-20 flex items-center justify-between pointer-events-none">
<!-- Бейдж лайков -->
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-amber-400 backdrop-blur-md border border-amber-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
{% if item.i_level >= 1000 %}
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-emerald-400 backdrop-blur-md border border-emerald-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
{% elif item.i_level >= 30 %}
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-cyan-400 backdrop-blur-md border border-cyan-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
{% else %}
<span class="inline-flex items-center gap-1 text-[11px] font-bold font-mono px-2 py-0.5 rounded-full bg-stone-900/80 text-amber-400 backdrop-blur-md border border-amber-500/20 shadow-sm opacity-90 group-hover:opacity-100 transition-opacity">
♥ {{ item.i_likes_count }}
</span>
{% endif %}
<!-- Бейдж просмотров (проявляется при наведении) -->
<span class="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-stone-900/80 text-stone-400 backdrop-blur-md opacity-0 group-hover:opacity-100 transition-opacity">
+1 -1
View File
@@ -9,7 +9,7 @@
<a href="/" class="border-0"><img src="{% static 'img/logo-hypn0.svg' %}" alt="Логотип hypn0"></a>
</div>{# /ЛОГОТИП #}
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-start text-xl font-semibold text-slate-900 dark:text-slate-100">
<a href="#" 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="#" 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>{# /ПРАВОЕ МЕНЮ #}
+1 -1
View File
@@ -501,7 +501,7 @@
<h2 class="text-3xl font-black uppercase tracking-tighter">Галерея транса</h2>
<p class="text-sm text-stone-500 dark:text-zinc-400 mt-1">Многоуровневое хранилище зрительного подчинения</p>
</div>
<a href="#" class="text-emerald-600 dark:text-emerald-400 font-bold hover:underline self-start sm:self-auto">Смотреть весь архив →</a>
<a href="{% url 'hypn0_site:gallery_archive' %}" class="text-emerald-600 dark:text-emerald-400 font-bold hover:underline self-start sm:self-auto">Смотреть весь архив →</a>
</div>
<!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадает только что созданный шедевр -->