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:
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user