From 3828bfb9ef19c955893a932cf81f5fce961e2dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 18:24:40 +0200 Subject: [PATCH] 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 --- games/templates/icons/arrowright.html | 3 + games/views/game.py | 57 ++++++++++++++++-- tests/test_game_detail_links.py | 87 +++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 games/templates/icons/arrowright.html create mode 100644 tests/test_game_detail_links.py diff --git a/games/templates/icons/arrowright.html b/games/templates/icons/arrowright.html new file mode 100644 index 0000000..2841e72 --- /dev/null +++ b/games/templates/icons/arrowright.html @@ -0,0 +1,3 @@ + + + diff --git a/games/views/game.py b/games/views/game.py index 1386fde..51ec3b8 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -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])), ) diff --git a/tests/test_game_detail_links.py b/tests/test_game_detail_links.py new file mode 100644 index 0000000..7a8572b --- /dev/null +++ b/tests/test_game_detail_links.py @@ -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