add: галерея (7) Общая галерея с фильтрами
This commit is contained in:
@@ -923,3 +923,138 @@ class CardBgStyleTests(BaseMediaTestCase):
|
|||||||
self.assertContains(response, 'template shadowrootmode="open"')
|
self.assertContains(response, 'template shadowrootmode="open"')
|
||||||
self.assertContains(response, "hypn0-card-svg")
|
self.assertContains(response, "hypn0-card-svg")
|
||||||
self.assertContains(response, "--hypn0-play: running !important")
|
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")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
|||||||
path("", views.index, name="index"),
|
path("", views.index, name="index"),
|
||||||
path("generate", views.generate, name="generate"),
|
path("generate", views.generate, name="generate"),
|
||||||
path("publish", views.publish, name="publish"),
|
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", views.gallery_random, name="gallery_random"),
|
||||||
path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"),
|
path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"),
|
||||||
path("gallery/floor/<slug:floor_slug>", views.gallery_floor, name="gallery_floor"),
|
path("gallery/floor/<slug:floor_slug>", views.gallery_floor, name="gallery_floor"),
|
||||||
|
|||||||
@@ -68,6 +68,79 @@ def get_floor_top(limit: int | None = 8, offset: int = 0):
|
|||||||
return qs
|
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:
|
def gallery_floor(request: HttpRequest, floor_slug: str) -> HttpResponse:
|
||||||
"""
|
"""
|
||||||
Страница полного просмотра конкретного этажа галереи с пагинацией (по 16 карточек).
|
Страница полного просмотра конкретного этажа галереи с пагинацией (по 16 карточек).
|
||||||
|
|||||||
@@ -36,19 +36,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Верхний бейдж: Хэш -->
|
<!-- Верхний бейдж: Хэш и уровень -->
|
||||||
<div class="absolute top-2 left-2 z-20 pointer-events-none">
|
<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">
|
<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 }}
|
¤{{ item.s_hash_id }}
|
||||||
</span>
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Нижняя панель статистики (Лайки и Просмотры) -->
|
<!-- Нижняя панель статистики (Лайки и Просмотры) -->
|
||||||
<div class="absolute bottom-2 left-2 right-2 z-20 flex items-center justify-between pointer-events-none">
|
<div class="absolute bottom-2 left-2 right-2 z-20 flex items-center justify-between pointer-events-none">
|
||||||
<!-- Бейдж лайков -->
|
<!-- Бейдж лайков -->
|
||||||
|
{% 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">
|
<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 }}
|
♥ {{ item.i_likes_count }}
|
||||||
</span>
|
</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">
|
<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">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<a href="/" class="border-0"><img src="{% static 'img/logo-hypn0.svg' %}" alt="Логотип hypn0"></a>
|
<a href="/" class="border-0"><img src="{% static 'img/logo-hypn0.svg' %}" alt="Логотип hypn0"></a>
|
||||||
</div>{# /ЛОГОТИП #}
|
</div>{# /ЛОГОТИП #}
|
||||||
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-start text-xl font-semibold text-slate-900 dark:text-slate-100">
|
{# ПРАВОЕ МЕНЮ #}<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 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>
|
<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>{# /ПРАВОЕ МЕНЮ #}
|
</menu>{# /ПРАВОЕ МЕНЮ #}
|
||||||
|
|||||||
@@ -501,7 +501,7 @@
|
|||||||
<h2 class="text-3xl font-black uppercase tracking-tighter">Галерея транса</h2>
|
<h2 class="text-3xl font-black uppercase tracking-tighter">Галерея транса</h2>
|
||||||
<p class="text-sm text-stone-500 dark:text-zinc-400 mt-1">Многоуровневое хранилище зрительного подчинения</p>
|
<p class="text-sm text-stone-500 dark:text-zinc-400 mt-1">Многоуровневое хранилище зрительного подчинения</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадает только что созданный шедевр -->
|
<!-- 1-й ЭТАЖ: ПЛЕСК БЕССОЗНАТЕЛЬНОГО (Свежий поток)... Сюда попадает только что созданный шедевр -->
|
||||||
|
|||||||
Reference in New Issue
Block a user