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:
+99
-9
@@ -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:
|
||||
|
||||
+28
-6
@@ -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'<a href="{url}" class="hover:underline">{text}</a>'
|
||||
|
||||
oob_attr = ' hx-swap-oob="true"' if oob else ""
|
||||
return Safe(
|
||||
f'<li id="navbar-playtime"{oob_attr} '
|
||||
@@ -201,13 +214,20 @@ def NavbarPlaytime(
|
||||
'<span class="flex uppercase gap-1">Today'
|
||||
'<span class="dark:text-gray-400">·</span>Last 7 days</span>'
|
||||
'<span class="flex items-center gap-1">'
|
||||
f'{today_played}<span class="dark:text-gray-400">·</span>'
|
||||
f"{last_7_played}</span></li>"
|
||||
f"{total(today_played, today_url)}"
|
||||
'<span class="dark:text-gray-400">·</span>'
|
||||
f"{total(last_7_played, last_7_url)}</span></li>"
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{NavbarPlaytime(today_played, last_7_played)}
|
||||
{NavbarPlaytime(today_played, last_7_played, today_url=today_url, last_7_url=last_7_url)}
|
||||
<li>
|
||||
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
|
||||
</li>
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user