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'
Today'
'·Last 7 days'
''
- f'{today_played}·'
- f"{last_7_played}"
+ 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,
+ }