From 3fd02bbcf99f2d5ab808d0a0c16b782e64d68f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 08:53:06 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- common/criteria.py | 108 +++++++++++++++++++++++++++--- common/layout.py | 34 ++++++++-- games/filters.py | 45 +++++++++++-- games/views/general.py | 23 ++++++- games/views/session.py | 8 ++- tests/test_filter_url.py | 38 +++++++++++ tests/test_filter_where.py | 79 ++++++++++++++++++++++ tests/test_navbar_playtime.py | 35 ++++++++++ tests/test_session_date_filter.py | 54 +++++++++++++++ 9 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 tests/test_filter_url.py create mode 100644 tests/test_filter_where.py create mode 100644 tests/test_session_date_filter.py diff --git a/common/criteria.py b/common/criteria.py index 9df7081..b0d17ac 100644 --- a/common/criteria.py +++ b/common/criteria.py @@ -379,6 +379,57 @@ class ChoiceCriterion(_SetCriterion): F = TypeVar("F", bound="OperatorFilter") +# Maps criterion class names (as they appear in dataclass annotations) to the +# concrete class. Shared by from_json() and where() so the two construction +# paths resolve field types identically and cannot drift. +_CRITERION_TYPES: dict[str, type[_Criterion]] = { + "StringCriterion": StringCriterion, + "IntCriterion": IntCriterion, + "FloatCriterion": FloatCriterion, + "DateCriterion": DateCriterion, + "BoolCriterion": BoolCriterion, + "MultiCriterion": MultiCriterion, + "ChoiceCriterion": ChoiceCriterion, +} + + +def _criterion_class_for( + cls: type["OperatorFilter"], field_name: str +) -> type[_Criterion] | None: + """Resolve the criterion class declared for ``field_name`` on a filter, or + None if the field is absent or isn't a criterion field.""" + for dataclass_field in dc_fields(cls): + if dataclass_field.name != field_name: + continue + field_type = dataclass_field.type + if isinstance(field_type, str): + # e.g. "StringCriterion | None" → "StringCriterion" + field_type = field_type.split("|")[0].strip() + return _CRITERION_TYPES.get(field_type) + if isinstance(field_type, type) and issubclass(field_type, _Criterion): + return field_type + return None + return None + + +# Lookup suffix → Modifier. A missing suffix defaults per criterion type +# (EQUALS for scalars, INCLUDES for set criteria) and is handled in where(). +_SUFFIX_MODIFIER: dict[str, Modifier] = { + "gt": Modifier.GREATER_THAN, + "lt": Modifier.LESS_THAN, + "ne": Modifier.NOT_EQUALS, + "between": Modifier.BETWEEN, + "not_between": Modifier.NOT_BETWEEN, + "in": Modifier.INCLUDES, + "exclude": Modifier.EXCLUDES, + "all": Modifier.INCLUDES_ALL, + "contains": Modifier.INCLUDES, + "regex": Modifier.MATCHES_REGEX, + "isnull": Modifier.IS_NULL, + "notnull": Modifier.NOT_NULL, +} + + @dataclass class OperatorFilter: """Mixin providing AND/OR/NOT composition for entity filter types. @@ -394,6 +445,53 @@ class OperatorFilter: ... """ + @classmethod + def where(cls: type[F], **lookups: Any) -> F: + """Build a filter from Django-``QuerySet.filter()``-style lookups. + + Each keyword is ``field__suffix=value`` (or ``field=value`` for the + default modifier). The criterion class is resolved from the field's + annotation, so the same value can target an int / string / date / set + field without naming the criterion type:: + + GameFilter.where(year_released__gt=2010, status=["f", "p"]) + + Suffix → modifier follows ``_SUFFIX_MODIFIER``; a missing suffix means + EQUALS for scalars and INCLUDES for set criteria. ``between`` / + ``not_between`` consume a 2-tuple; ``isnull`` / ``notnull`` ignore the + value. Unknown fields or suffixes raise ``TypeError``. + """ + field_criteria: dict[str, Any] = {} + for lookup, value in lookups.items(): + field_name, _, suffix = lookup.rpartition("__") + if not field_name: + field_name, suffix = lookup, "" + + criterion_class = _criterion_class_for(cls, field_name) + if criterion_class is None: + raise TypeError(f"{cls.__name__} has no filter field {field_name!r}") + + is_set_criterion = issubclass(criterion_class, _SetCriterion) + if suffix == "": + modifier = Modifier.INCLUDES if is_set_criterion else Modifier.EQUALS + elif suffix in _SUFFIX_MODIFIER: + modifier = _SUFFIX_MODIFIER[suffix] + else: + raise TypeError(f"Unknown lookup suffix {suffix!r} on {field_name!r}") + + criterion_arguments: dict[str, Any] = {"modifier": modifier} + if suffix in ("isnull", "notnull"): + pass # presence test ignores the value + elif modifier in (Modifier.BETWEEN, Modifier.NOT_BETWEEN): + lower_bound, upper_bound = value + criterion_arguments["value"] = lower_bound + criterion_arguments["value2"] = upper_bound + else: + criterion_arguments["value"] = value + + field_criteria[field_name] = criterion_class(**criterion_arguments) + return cls(**field_criteria) + def sub_filter(self) -> OperatorFilter | None: """Return the first non-None of AND / OR / NOT.""" for attr in ("AND", "OR", "NOT"): @@ -436,15 +534,7 @@ class OperatorFilter: if data is None or not isinstance(data, dict): return None # Resolve criterion class names to actual types - criterion_types: dict[str, type[_Criterion]] = { - "StringCriterion": StringCriterion, - "IntCriterion": IntCriterion, - "FloatCriterion": FloatCriterion, - "DateCriterion": DateCriterion, - "BoolCriterion": BoolCriterion, - "MultiCriterion": MultiCriterion, - "ChoiceCriterion": ChoiceCriterion, - } + criterion_types = _CRITERION_TYPES kwargs: dict[str, Any] = {} for f in dc_fields(cls): if f.name not in data: diff --git a/common/layout.py b/common/layout.py index 8a0f255..191f298 100644 --- a/common/layout.py +++ b/common/layout.py @@ -188,12 +188,25 @@ def _main_script(mastered: bool) -> str: def NavbarPlaytime( - today_played: str, last_7_played: str, *, oob: bool = False + today_played: str, + last_7_played: str, + *, + today_url: str | None = None, + last_7_url: str | None = None, + oob: bool = False, ) -> "Node": """The navbar 'Today · Last 7 days' totals. Carries a stable id so - htmx endpoints can refresh it out-of-band after a session change.""" + htmx endpoints can refresh it out-of-band after a session change. + + When ``today_url`` / ``last_7_url`` are given, each total links to the + matching filtered session list.""" from common.components import Safe + def total(text: str, url: str | None) -> str: + if not url: + return text + return f'{text}' + oob_attr = ' hx-swap-oob="true"' if oob else "" return Safe( f'" + f"{total(today_played, today_url)}" + '·' + f"{total(last_7_played, last_7_url)}" ) def Navbar( - *, today_played: str, last_7_played: str, current_year: int, csrf_token: str + *, + today_played: str, + last_7_played: str, + today_url: str | None = None, + last_7_url: str | None = None, + current_year: int, + csrf_token: str, ) -> "Node": """Top navigation bar. @@ -244,7 +264,7 @@ def Navbar( - {NavbarPlaytime(today_played, last_7_played)} + {NavbarPlaytime(today_played, last_7_played, today_url=today_url, last_7_url=last_7_url)}
  • Home
  • @@ -330,6 +350,8 @@ def Page( navbar = Navbar( today_played=counts["today_played"], last_7_played=counts["last_7_played"], + today_url=counts["today_url"], + last_7_url=counts["last_7_url"], current_year=year, csrf_token=get_token(request), ) diff --git a/games/filters.py b/games/filters.py index 8b6d25e..8bd22e9 100644 --- a/games/filters.py +++ b/games/filters.py @@ -14,6 +14,8 @@ from __future__ import annotations from dataclasses import dataclass from django.db.models import Q +from django.urls import reverse +from django.utils.http import urlencode from common.criteria import ( BoolCriterion, @@ -26,6 +28,7 @@ from common.criteria import ( OperatorFilter, StringCriterion, filter_from_json, + filter_to_json, ) # ── FindFilter (sort / pagination) ───────────────────────────────────────── @@ -438,8 +441,8 @@ class SessionFilter(OperatorFilter): duration_manual_hours: IntCriterion | None = None duration_calculated_hours: IntCriterion | None = None is_active: BoolCriterion | None = None # timestamp_end IS NULL - timestamp_start: StringCriterion | None = None # date string - timestamp_end: StringCriterion | None = None # date string + timestamp_start: DateCriterion | None = None # date, compared via __date + timestamp_end: DateCriterion | None = None # date, compared via __date is_manual: BoolCriterion | None = None # duration_manual > 0 created_at: StringCriterion | None = None @@ -519,9 +522,10 @@ class SessionFilter(OperatorFilter): else: q &= Q(timestamp_end__isnull=False) if self.timestamp_start is not None: - q &= self.timestamp_start.to_q("timestamp_start") + # Compare the date portion so a date matches the datetime column. + q &= self.timestamp_start.to_q("timestamp_start__date") if self.timestamp_end is not None: - q &= self.timestamp_end.to_q("timestamp_end") + q &= self.timestamp_end.to_q("timestamp_end__date") if self.is_manual is not None: if self.is_manual.value: q &= ~Q(duration_manual=timedelta(0)) @@ -977,3 +981,36 @@ def parse_platform_filter(json_str: str) -> PlatformFilter | None: def parse_playevent_filter(json_str: str) -> PlayEventFilter | None: return filter_from_json(PlayEventFilter, json_str) + + +# ── URL building (the "reverse() for filters") ───────────────────────────── + + +_FILTER_LIST_URL: dict[type[OperatorFilter], str] = { + GameFilter: "games:list_games", + SessionFilter: "games:list_sessions", + PurchaseFilter: "games:list_purchases", + PlayEventFilter: "games:list_playevents", + DeviceFilter: "games:list_devices", + PlatformFilter: "games:list_platforms", +} + + +def filter_url(filter_obj: OperatorFilter, **extra_params: str) -> str: + """Build a URL to the filtered list view for ``filter_obj``. + + The target view is inferred from the filter's type, so a filter can never be + paired with a mismatched list URL. ``extra_params`` are merged into the + query string (e.g. ``sort``, ``page``). + + Usage: + filter_url(GameFilter.where(purchase_count__gt=1)) + """ + try: + url_name = _FILTER_LIST_URL[type(filter_obj)] + except KeyError: + raise TypeError( + f"No list view registered for {type(filter_obj).__name__}" + ) from None + params = {"filter": filter_to_json(filter_obj), **extra_params} + return f"{reverse(url_name)}?{urlencode(params)}" diff --git a/games/views/general.py b/games/views/general.py index 000fc0c..8fba285 100644 --- a/games/views/general.py +++ b/games/views/general.py @@ -15,6 +15,7 @@ from django.utils.timezone import now as timezone_now from common.layout import render_page from common.time import format_duration +from games.filters import SessionFilter, filter_url from games.models import Game, Platform, Purchase, Session from games.views.stats_content import stats_content from games.views.stats_data import compute_stats @@ -23,21 +24,37 @@ from games.views.stats_data import compute_stats # component, so Page() loads it automatically on the stats pages. -def model_counts(request: HttpRequest) -> dict[str, bool]: +def model_counts(request: HttpRequest) -> dict[str, Any]: now = timezone_now() # Use a contiguous [midnight, next midnight) range in the active timezone # instead of day/month/year extracts: a range filter can use an index on # timestamp_start, whereas the extracts force a per-row datetime function. + today = localtime(now).date() start_of_today = localtime(now).replace(hour=0, minute=0, second=0, microsecond=0) start_of_tomorrow = start_of_today + timedelta(days=1) + # "Last 7 days" is a calendar-day window (today plus the previous six) so the + # displayed total matches the list its navbar link points to. + start_of_window = start_of_today - timedelta(days=6) today_played = Session.objects.filter( timestamp_start__gte=start_of_today, timestamp_start__lt=start_of_tomorrow, ).aggregate(time=Sum(F("duration_total")))["time"] last_7_played = Session.objects.filter( - timestamp_start__gte=(now - timedelta(days=7)) + timestamp_start__gte=start_of_window, + timestamp_start__lt=start_of_tomorrow, ).aggregate(time=Sum(F("duration_total")))["time"] + today_iso = today.isoformat() + today_url = filter_url(SessionFilter.where(timestamp_start=today_iso)) + last_7_url = filter_url( + SessionFilter.where( + timestamp_start__between=( + (today - timedelta(days=6)).isoformat(), + today_iso, + ) + ) + ) + return { "game_available": Game.objects.exists(), "platform_available": Platform.objects.exists(), @@ -45,6 +62,8 @@ def model_counts(request: HttpRequest) -> dict[str, bool]: "session_count": Session.objects.exists(), "today_played": format_duration(today_played, "%H h %m m"), "last_7_played": format_duration(last_7_played, "%H h %m m"), + "today_url": today_url, + "last_7_url": last_7_url, } diff --git a/games/views/session.py b/games/views/session.py index de5bef0..a52c537 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -308,7 +308,13 @@ def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse: counts = model_counts(request) fragment = Fragment( session_row(session, device_list, get_token(request)), - NavbarPlaytime(counts["today_played"], counts["last_7_played"], oob=True), + NavbarPlaytime( + counts["today_played"], + counts["last_7_played"], + today_url=counts["today_url"], + last_7_url=counts["last_7_url"], + oob=True, + ), ) return HttpResponse(str(fragment)) diff --git a/tests/test_filter_url.py b/tests/test_filter_url.py new file mode 100644 index 0000000..0c6a509 --- /dev/null +++ b/tests/test_filter_url.py @@ -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" diff --git a/tests/test_filter_where.py b/tests/test_filter_where.py new file mode 100644 index 0000000..fa6b7ce --- /dev/null +++ b/tests/test_filter_where.py @@ -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) diff --git a/tests/test_navbar_playtime.py b/tests/test_navbar_playtime.py index 6c26efd..0cafd7e 100644 --- a/tests/test_navbar_playtime.py +++ b/tests/test_navbar_playtime.py @@ -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 diff --git a/tests/test_session_date_filter.py b/tests/test_session_date_filter.py new file mode 100644 index 0000000..34189c0 --- /dev/null +++ b/tests/test_session_date_filter.py @@ -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, + }