feat(game-detail): link sections to filtered lists (#66)

Add "View all" links to the Purchases / Sessions / Play Events sections
on the game-detail page, each pointing at the matching filtered list view
via filter_url(). Pure consumer of the #56 filter_url()/where() helpers;
no new filter machinery.

- _game_section() gains an optional view_all_url, rendered as a gray xs
  button beside the heading (shown only when the section is non-empty).
- New arrowright icon for the link.
- Tests: each section renders the expected escaped href; a parity test
  asserts each link's filter scopes to the game and excludes others.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 18:24:40 +02:00
parent 9107db1448
commit 3828bfb9ef
3 changed files with 141 additions and 6 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg class="dark:text-white w-3" viewBox="5 5 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 226 B

+51 -6
View File
@@ -48,7 +48,13 @@ from common.time import (
timeformat,
)
from common.utils import build_dynamic_filter, paginate, safe_division, truncate
from games.filters import parse_game_filter
from games.filters import (
PlayEventFilter,
PurchaseFilter,
SessionFilter,
filter_url,
parse_game_filter,
)
from games.sorting import GAME_DEFAULT_SORT, GAME_SORTS, apply_sort, parse_find_filter
from games.forms import GameForm
from games.models import Game
@@ -534,12 +540,35 @@ def _game_history(statuschanges) -> SafeText:
def _game_section(
title: str, count: int, table: SafeText, empty_message: str
title: str,
count: int,
table: SafeText,
empty_message: str,
view_all_url: str | None = None,
) -> SafeText:
if view_all_url and count:
view_all_link = A(
href=view_all_url,
children=[
StyledButton(
icon=True,
color="gray",
size="xs",
title=f"View all {title.lower()} for this game",
children=[Icon("arrowright"), "View all"],
)
],
)
header = Div(
[("class", "flex items-center justify-between")],
[H1(children=[title], badge=count), view_all_link],
)
else:
header = H1(children=[title], badge=count)
return Div(
[("class", "mb-6")],
[
H1(children=[title], badge=count),
header,
table if count else empty_message,
],
)
@@ -687,7 +716,13 @@ def _purchases_section(game: Game) -> SafeText:
for purchase in purchases
]
table = SimpleTable(columns=["Name", "Type", "Date", "Price", "Actions"], rows=rows)
return _game_section("Purchases", purchases.count(), table, "No purchases yet.")
return _game_section(
"Purchases",
purchases.count(),
table,
"No purchases yet.",
view_all_url=filter_url(PurchaseFilter.where(games=[game.id])),
)
def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
@@ -772,7 +807,13 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
elided_page_range=elided_page_range,
request=request,
)
return _game_section("Sessions", session_count, table, "No sessions yet.")
return _game_section(
"Sessions",
session_count,
table,
"No sessions yet.",
view_all_url=filter_url(SessionFilter.where(game=[game.id])),
)
def _playevents_section(game: Game) -> SafeText:
@@ -780,7 +821,11 @@ def _playevents_section(game: Game) -> SafeText:
data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
table = SimpleTable(columns=data["columns"], rows=data["rows"])
return _game_section(
"Play Events", playevents.count(), table, "No play events yet."
"Play Events",
playevents.count(),
table,
"No play events yet.",
view_all_url=filter_url(PlayEventFilter.where(game=[game.id])),
)
+87
View File
@@ -0,0 +1,87 @@
"""Rendering tests: game-detail sections wire "View all" links to filtered lists (#66)."""
from datetime import datetime, timezone
import pytest
from django.utils.html import escape
from games.filters import (
PlayEventFilter,
PurchaseFilter,
SessionFilter,
filter_url,
)
from games.models import Game, Platform, PlayEvent, Purchase, Session
from games.views.game import view_game
def _dt(day, hour=12):
return datetime(2024, 6, day, hour, 0, tzinfo=timezone.utc)
@pytest.fixture
def game(db):
platform = Platform.objects.create(name="PC")
game = Game.objects.create(
name="Test Game", platform=platform, status=Game.Status.PLAYED
)
Session.objects.create(game=game, timestamp_start=_dt(1), timestamp_end=_dt(1, 13))
Purchase.objects.create(date_purchased=_dt(1), type=Purchase.GAME).games.set([game])
PlayEvent.objects.create(game=game, ended=_dt(2))
return game
@pytest.fixture
def rendered(game, rf, django_user_model):
user = django_user_model.objects.create_user(username="u", password="p")
request = rf.get(f"/game/{game.id}/")
request.user = user
request.session = {}
return view_game(request, game.id).content.decode()
def test_sessions_section_links_to_filtered_sessions(game, rendered):
href = escape(filter_url(SessionFilter.where(game=[game.id])))
assert href in rendered
def test_purchases_section_links_to_filtered_purchases(game, rendered):
href = escape(filter_url(PurchaseFilter.where(games=[game.id])))
assert href in rendered
def test_playevents_section_links_to_filtered_playevents(game, rendered):
href = escape(filter_url(PlayEventFilter.where(game=[game.id])))
assert href in rendered
def test_link_filters_scope_to_game(game):
"""Each link's filter selects exactly the game's own records, not another
game's (parity between the section and the filtered list it links to)."""
other = Game.objects.create(name="Other", platform=game.platform)
Session.objects.create(game=other, timestamp_start=_dt(3), timestamp_end=_dt(3, 13))
Purchase.objects.create(date_purchased=_dt(3), type=Purchase.GAME).games.set(
[other]
)
PlayEvent.objects.create(game=other, ended=_dt(4))
sessions = Session.objects.filter(SessionFilter.where(game=[game.id]).to_q())
assert list(sessions) == list(game.sessions.all())
purchases = Purchase.objects.filter(PurchaseFilter.where(games=[game.id]).to_q())
assert list(purchases) == list(game.purchases.all())
playevents = PlayEvent.objects.filter(PlayEventFilter.where(game=[game.id]).to_q())
assert list(playevents) == list(game.playevents.all())
def test_no_view_all_for_empty_section(db, django_user_model, rf):
"""A game with no sessions/purchases/playevents shows no 'View all' link."""
platform = Platform.objects.create(name="PC")
empty_game = Game.objects.create(name="Empty", platform=platform)
user = django_user_model.objects.create_user(username="u2", password="p")
request = rf.get(f"/game/{empty_game.id}/")
request.user = user
request.session = {}
html = view_game(request, empty_game.id).content.decode()
assert "View all" not in html