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