feat(filters): programmatic filter links + navbar playtime links (#56)

Add filter_url(), a reverse()-style helper that builds a URL to a filtered
list view from a filter object (target inferred from the filter type).

Add OperatorFilter.where(**lookups), a Django-.filter()-style ergonomic
constructor that resolves each field's criterion class from its annotation
(shared with from_json via _criterion_class_for, removing duplication).

Make SessionFilter.timestamp_start/timestamp_end DateCriterion applied via
the __date lookup, so date ranges over the timestamp columns are expressible.

Wire the navbar 'today' / 'last 7 days' totals as links to the matching
filtered session lists, and align the 'last 7 days' total to the same
calendar-day window so the number matches the list it links to.

Stats-table and game-detail links remain a follow-up (see spec).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
This commit is contained in:
2026-06-21 08:53:06 +02:00
parent b9545a780b
commit 3fd02bbcf9
9 changed files with 402 additions and 22 deletions
+38
View File
@@ -0,0 +1,38 @@
"""Tests for filter_url() — the reverse()-style helper that builds a URL to a
filtered list view from a filter object (issue #56)."""
from urllib.parse import parse_qs, urlparse
from django.urls import reverse
from common.criteria import IntCriterion, Modifier, filter_to_json
from games.filters import (
GameFilter,
PurchaseFilter,
SessionFilter,
filter_url,
parse_game_filter,
)
def test_filter_url_path_inferred_from_filter_type():
assert urlparse(filter_url(GameFilter())).path == reverse("games:list_games")
assert urlparse(filter_url(SessionFilter())).path == reverse("games:list_sessions")
assert urlparse(filter_url(PurchaseFilter())).path == reverse(
"games:list_purchases"
)
def test_filter_url_encodes_filter_json_that_round_trips():
game_filter = GameFilter(
year_released=IntCriterion(value=2010, modifier=Modifier.GREATER_THAN)
)
url = filter_url(game_filter)
query = parse_qs(urlparse(url).query)
assert query["filter"][0] == filter_to_json(game_filter)
assert parse_game_filter(query["filter"][0]).to_q() == game_filter.to_q()
def test_filter_url_merges_extra_params():
query = parse_qs(urlparse(filter_url(GameFilter(), sort="name")).query)
assert query["sort"][0] == "name"
+79
View File
@@ -0,0 +1,79 @@
"""Tests for OperatorFilter.where() — Django-.filter()-style ergonomic
construction of filters (issue #56, Component 1b)."""
import pytest
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
IntCriterion,
Modifier,
MultiCriterion,
StringCriterion,
)
from games.filters import GameFilter
def test_no_suffix_defaults_to_equals_for_scalar():
assert GameFilter.where(year_released=2010) == GameFilter(
year_released=IntCriterion(value=2010, modifier=Modifier.EQUALS)
)
def test_no_suffix_defaults_to_includes_for_set_criterion():
assert GameFilter.where(status=["f", "p"]) == GameFilter(
status=ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES)
)
def test_gt_suffix_maps_to_greater_than():
assert GameFilter.where(year_released__gt=2010) == GameFilter(
year_released=IntCriterion(value=2010, modifier=Modifier.GREATER_THAN)
)
def test_contains_suffix_maps_to_includes_for_string():
assert GameFilter.where(name__contains="Zelda") == GameFilter(
name=StringCriterion(value="Zelda", modifier=Modifier.INCLUDES)
)
def test_between_suffix_consumes_tuple_into_value_and_value2():
assert GameFilter.where(year_released__between=(2010, 2020)) == GameFilter(
year_released=IntCriterion(value=2010, value2=2020, modifier=Modifier.BETWEEN)
)
def test_isnull_suffix_ignores_value():
assert GameFilter.where(playtime_hours__isnull=True) == GameFilter(
playtime_hours=IntCriterion(modifier=Modifier.IS_NULL)
)
def test_bool_field_resolves_bool_criterion():
assert GameFilter.where(mastered=True) == GameFilter(
mastered=BoolCriterion(value=True, modifier=Modifier.EQUALS)
)
def test_multi_field_resolves_multi_criterion():
assert GameFilter.where(device=[1, 2]) == GameFilter(
device=MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES)
)
def test_multiple_lookups_are_combined_on_one_filter():
assert GameFilter.where(year_released__gt=2010, mastered=True) == GameFilter(
year_released=IntCriterion(value=2010, modifier=Modifier.GREATER_THAN),
mastered=BoolCriterion(value=True, modifier=Modifier.EQUALS),
)
def test_unknown_field_raises():
with pytest.raises((TypeError, ValueError)):
GameFilter.where(does_not_exist=1)
def test_unknown_suffix_raises():
with pytest.raises((TypeError, ValueError)):
GameFilter.where(year_released__nope=1)
+35
View File
@@ -1,4 +1,10 @@
from urllib.parse import parse_qs, urlparse
import pytest
from django.test import RequestFactory
from common.layout import NavbarPlaytime
from games.filters import parse_session_filter
def test_navbar_playtime_has_stable_id_and_values():
@@ -13,3 +19,32 @@ def test_navbar_playtime_oob_flag():
html = str(NavbarPlaytime("1 h 00 m", "7 h 00 m", oob=True))
assert 'id="navbar-playtime"' in html
assert 'hx-swap-oob="true"' in html
def test_navbar_playtime_wraps_totals_in_links():
html = str(
NavbarPlaytime(
"1 h 00 m",
"5 h 00 m",
today_url="/sessions/?filter=today",
last_7_url="/sessions/?filter=week",
)
)
assert 'href="/sessions/?filter=today"' in html
assert 'href="/sessions/?filter=week"' in html
assert "1 h 00 m" in html
assert "5 h 00 m" in html
@pytest.mark.django_db
def test_model_counts_exposes_session_filter_urls():
from games.views.general import model_counts
request = RequestFactory().get("/")
counts = model_counts(request)
today_filter_json = parse_qs(urlparse(counts["today_url"]).query)["filter"][0]
last_7_filter_json = parse_qs(urlparse(counts["last_7_url"]).query)["filter"][0]
assert parse_session_filter(today_filter_json).timestamp_start is not None
assert parse_session_filter(last_7_filter_json).timestamp_start is not None
+54
View File
@@ -0,0 +1,54 @@
"""Date-range filtering on session timestamps (issue #56, Component 2).
The navbar 'today' / 'last 7 days' links need SessionFilter to express date
ranges over the timestamp_start datetime column."""
from datetime import timedelta
import pytest
from django.utils.timezone import localtime
from django.utils.timezone import now as timezone_now
from games.filters import SessionFilter
from games.models import Game, Platform, Session
@pytest.fixture
def sessions_across_days(db):
platform = Platform.objects.create(name="PC")
game = Game.objects.create(name="Zelda", platform=platform)
today = localtime(timezone_now())
return {
"today": Session.objects.create(game=game, timestamp_start=today),
"three_days_ago": Session.objects.create(
game=game, timestamp_start=today - timedelta(days=3)
),
"ten_days_ago": Session.objects.create(
game=game, timestamp_start=today - timedelta(days=10)
),
"today_date": today.date(),
}
def test_today_filter_matches_only_todays_sessions(sessions_across_days):
today_iso = sessions_across_days["today_date"].isoformat()
session_filter = SessionFilter.where(timestamp_start=today_iso)
matched = list(Session.objects.filter(session_filter.to_q()))
assert matched == [sessions_across_days["today"]]
def test_last_7_days_filter_matches_calendar_window(sessions_across_days):
today_date = sessions_across_days["today_date"]
session_filter = SessionFilter.where(
timestamp_start__between=(
(today_date - timedelta(days=6)).isoformat(),
today_date.isoformat(),
)
)
matched = set(
Session.objects.filter(session_filter.to_q()).values_list("id", flat=True)
)
assert matched == {
sessions_across_days["today"].id,
sessions_across_days["three_days_ago"].id,
}