Merge pull request #62 from KucharczykL/feat/issue-56-filter-links
feat(filters): programmatic filter links + navbar playtime links (#56)
This commit is contained in:
+99
-9
@@ -379,6 +379,57 @@ class ChoiceCriterion(_SetCriterion):
|
|||||||
F = TypeVar("F", bound="OperatorFilter")
|
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
|
@dataclass
|
||||||
class OperatorFilter:
|
class OperatorFilter:
|
||||||
"""Mixin providing AND/OR/NOT composition for entity filter types.
|
"""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:
|
def sub_filter(self) -> OperatorFilter | None:
|
||||||
"""Return the first non-None of AND / OR / NOT."""
|
"""Return the first non-None of AND / OR / NOT."""
|
||||||
for attr in ("AND", "OR", "NOT"):
|
for attr in ("AND", "OR", "NOT"):
|
||||||
@@ -436,15 +534,7 @@ class OperatorFilter:
|
|||||||
if data is None or not isinstance(data, dict):
|
if data is None or not isinstance(data, dict):
|
||||||
return None
|
return None
|
||||||
# Resolve criterion class names to actual types
|
# Resolve criterion class names to actual types
|
||||||
criterion_types: dict[str, type[_Criterion]] = {
|
criterion_types = _CRITERION_TYPES
|
||||||
"StringCriterion": StringCriterion,
|
|
||||||
"IntCriterion": IntCriterion,
|
|
||||||
"FloatCriterion": FloatCriterion,
|
|
||||||
"DateCriterion": DateCriterion,
|
|
||||||
"BoolCriterion": BoolCriterion,
|
|
||||||
"MultiCriterion": MultiCriterion,
|
|
||||||
"ChoiceCriterion": ChoiceCriterion,
|
|
||||||
}
|
|
||||||
kwargs: dict[str, Any] = {}
|
kwargs: dict[str, Any] = {}
|
||||||
for f in dc_fields(cls):
|
for f in dc_fields(cls):
|
||||||
if f.name not in data:
|
if f.name not in data:
|
||||||
|
|||||||
+28
-6
@@ -188,12 +188,25 @@ def _main_script(mastered: bool) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def NavbarPlaytime(
|
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":
|
) -> "Node":
|
||||||
"""The navbar 'Today · Last 7 days' totals. Carries a stable id so
|
"""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
|
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 ""
|
oob_attr = ' hx-swap-oob="true"' if oob else ""
|
||||||
return Safe(
|
return Safe(
|
||||||
f'<li id="navbar-playtime"{oob_attr} '
|
f'<li id="navbar-playtime"{oob_attr} '
|
||||||
@@ -201,13 +214,20 @@ def NavbarPlaytime(
|
|||||||
'<span class="flex uppercase gap-1">Today'
|
'<span class="flex uppercase gap-1">Today'
|
||||||
'<span class="dark:text-gray-400">·</span>Last 7 days</span>'
|
'<span class="dark:text-gray-400">·</span>Last 7 days</span>'
|
||||||
'<span class="flex items-center gap-1">'
|
'<span class="flex items-center gap-1">'
|
||||||
f'{today_played}<span class="dark:text-gray-400">·</span>'
|
f"{total(today_played, today_url)}"
|
||||||
f"{last_7_played}</span></li>"
|
'<span class="dark:text-gray-400">·</span>'
|
||||||
|
f"{total(last_7_played, last_7_url)}</span></li>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def Navbar(
|
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":
|
) -> "Node":
|
||||||
"""Top navigation bar.
|
"""Top navigation bar.
|
||||||
|
|
||||||
@@ -244,7 +264,7 @@ def Navbar(
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{NavbarPlaytime(today_played, last_7_played)}
|
{NavbarPlaytime(today_played, last_7_played, today_url=today_url, last_7_url=last_7_url)}
|
||||||
<li>
|
<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>
|
<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>
|
</li>
|
||||||
@@ -330,6 +350,8 @@ def Page(
|
|||||||
navbar = Navbar(
|
navbar = Navbar(
|
||||||
today_played=counts["today_played"],
|
today_played=counts["today_played"],
|
||||||
last_7_played=counts["last_7_played"],
|
last_7_played=counts["last_7_played"],
|
||||||
|
today_url=counts["today_url"],
|
||||||
|
last_7_url=counts["last_7_url"],
|
||||||
current_year=year,
|
current_year=year,
|
||||||
csrf_token=get_token(request),
|
csrf_token=get_token(request),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Issue #56 — Programmatic way of defining filters (filter links)
|
||||||
|
|
||||||
|
**Date:** 2026-06-21
|
||||||
|
**Issue:** https://github.com/KucharczykL/timetracker/issues/56
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Filters can currently only be built through the UI. There is no Python-level way
|
||||||
|
to construct a link to a filtered list view, so places that *should* link to a
|
||||||
|
filtered list cannot. The issue cites three call sites:
|
||||||
|
|
||||||
|
1. Navbar playtime totals ("today" / "last 7 days") — should link to the matching
|
||||||
|
filtered session list.
|
||||||
|
2. Stats-page tables — rows should link to filtered results.
|
||||||
|
3. `view_game` tables — should link to filtered list views.
|
||||||
|
|
||||||
|
The acceptance criteria are:
|
||||||
|
|
||||||
|
- A programmatic way in Python to create a link to a combination of filters
|
||||||
|
(analogous to Django's `reverse()`).
|
||||||
|
- Clickable links for the navbar playtime statistics.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope (this work):**
|
||||||
|
|
||||||
|
- The core `filter_url()` helper.
|
||||||
|
- The `OperatorFilter.where()` ergonomic constructor.
|
||||||
|
- The date-range filtering capability the navbar links require.
|
||||||
|
- Wiring the navbar "today" / "last 7 days" totals as links.
|
||||||
|
|
||||||
|
**Out of scope — filed as a follow-up issue:**
|
||||||
|
|
||||||
|
- Stats-page table links.
|
||||||
|
- `view_game` table links.
|
||||||
|
|
||||||
|
These are deferred deliberately to keep this change small. They both build
|
||||||
|
directly on `filter_url()`, so they are pure consumers of this work. See
|
||||||
|
"Follow-up issue" below for the drafted text.
|
||||||
|
|
||||||
|
## Current state (as investigated)
|
||||||
|
|
||||||
|
- Filters are `@dataclass` subclasses of `OperatorFilter` in `games/filters.py`
|
||||||
|
(`GameFilter`, `SessionFilter`, `PurchaseFilter`, `PlayEventFilter`,
|
||||||
|
`DeviceFilter`, `PlatformFilter`). Each is built from typed criterion objects
|
||||||
|
defined in `common/criteria.py`.
|
||||||
|
- Serialization helpers in `common/criteria.py`: `filter_to_json(f)` →
|
||||||
|
`json.dumps(f.to_json())`; `filter_from_json(cls, json_str)` → filter instance.
|
||||||
|
- List views read `request.GET.get("filter")` (a URL-encoded JSON string),
|
||||||
|
deserialize via `parse_*_filter()`, and apply `.to_q()` to the queryset.
|
||||||
|
- List view URL names: `games:list_games`, `games:list_sessions`,
|
||||||
|
`games:list_purchases`, `games:list_playevents`, `games:list_devices`,
|
||||||
|
`games:list_platforms`.
|
||||||
|
- `games/views/filter_presets.py` already hand-rolls the URL-building pattern:
|
||||||
|
`f"{reverse(f'games:list_{mode}')}?filter={quote(filter_json)}"`. There is **no
|
||||||
|
shared helper** — this work introduces one.
|
||||||
|
- The navbar playtime totals are produced by the `model_counts` context processor
|
||||||
|
(`games/views/general.py`) as formatted strings (`today_played`,
|
||||||
|
`last_7_played`) and rendered by `NavbarPlaytime()` (`common/layout.py`). The
|
||||||
|
component is also refreshed out-of-band after session changes
|
||||||
|
(`games/views/session.py:311`), so any new data must flow through both paths.
|
||||||
|
- `SessionFilter.timestamp_start` / `timestamp_end` are typed as
|
||||||
|
`StringCriterion`, which supports only EQUALS / NOT_EQUALS / INCLUDES /
|
||||||
|
EXCLUDES / regex / null — **no `GREATER_THAN` / `LESS_THAN` / `BETWEEN`**. So
|
||||||
|
date-range filtering on session timestamps is not currently expressible. These
|
||||||
|
fields are **not** exposed in the filter-bar UI
|
||||||
|
(`common/components/filters.py` has no reference to them), so their criterion
|
||||||
|
type can be changed safely.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Component 1 — `filter_url()` (the "`reverse()` for filters")
|
||||||
|
|
||||||
|
A single helper in `games/filters.py`, symmetric with the existing
|
||||||
|
`parse_*_filter()` functions. It infers the target list view from the filter
|
||||||
|
object's *type*, so a filter can never be paired with a mismatched URL.
|
||||||
|
|
||||||
|
```python
|
||||||
|
_FILTER_LIST_URL = {
|
||||||
|
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. ``extra_params`` are
|
||||||
|
merged into the query string (e.g. ``sort``, ``page``)."""
|
||||||
|
url_name = _FILTER_LIST_URL[type(filter_obj)]
|
||||||
|
params = {"filter": filter_to_json(filter_obj), **extra_params}
|
||||||
|
return f"{reverse(url_name)}?{urlencode(params)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses `django.utils.http.urlencode` (already imported in `common/utils.py`),
|
||||||
|
which URL-encodes the JSON value correctly.
|
||||||
|
- `**extra_params` leaves room for `sort` / `page` later without being required
|
||||||
|
now.
|
||||||
|
- `filter_presets.py` can adopt this helper later; not required for this change.
|
||||||
|
|
||||||
|
### Component 1b — `OperatorFilter.where()` (ergonomic construction)
|
||||||
|
|
||||||
|
Building filters via the explicit constructor is verbose, because each criterion
|
||||||
|
must be wrapped and a `Modifier` imported:
|
||||||
|
|
||||||
|
```python
|
||||||
|
GameFilter(
|
||||||
|
purchase_count=IntCriterion(value=1, modifier=Modifier.GREATER_THAN),
|
||||||
|
playtime_hours=IntCriterion(modifier=Modifier.IS_NULL),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `where(**lookups)` classmethod on `OperatorFilter` (so every filter type
|
||||||
|
inherits it) accepting Django-`QuerySet.filter()`-style `field__modifier=value`
|
||||||
|
lookups:
|
||||||
|
|
||||||
|
```python
|
||||||
|
GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True)
|
||||||
|
|
||||||
|
# combined with filter_url():
|
||||||
|
filter_url(GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True))
|
||||||
|
|
||||||
|
# the navbar filters become:
|
||||||
|
filter_url(SessionFilter.where(timestamp_start=today_iso))
|
||||||
|
filter_url(SessionFilter.where(timestamp_start__between=(week_ago_iso, today_iso)))
|
||||||
|
```
|
||||||
|
|
||||||
|
How it works (no new architecture — it builds the same dataclass instances):
|
||||||
|
|
||||||
|
1. Split each kwarg into `(field_name, suffix)` on the last `__`.
|
||||||
|
2. Resolve the field's criterion class from its dataclass annotation, reusing the
|
||||||
|
logic `from_json` already has (`common/criteria.py:439-473`). Factor that
|
||||||
|
resolution into a shared helper (e.g. `_criterion_class_for(cls, field)`) so
|
||||||
|
`from_json` and `where()` cannot drift — a small de-duplication bonus.
|
||||||
|
3. Map the suffix to a `Modifier` (see table); no suffix → the criterion type's
|
||||||
|
natural default (`EQUALS` for scalar/string/bool, `INCLUDES` for the set
|
||||||
|
criteria `MultiCriterion` / `ChoiceCriterion`).
|
||||||
|
4. Build the concrete criterion: scalar → `value`; a 2-tuple → `value` / `value2`
|
||||||
|
(for `between` / `not_between`); a list → the set criterion's `value`.
|
||||||
|
5. Return a normal filter instance.
|
||||||
|
|
||||||
|
Suffix → `Modifier` map:
|
||||||
|
|
||||||
|
| suffix | Modifier |
|
||||||
|
|---------------|-----------------|
|
||||||
|
| *(none)* | `EQUALS` (scalar/string/bool) / `INCLUDES` (set) |
|
||||||
|
| `gt` | `GREATER_THAN` |
|
||||||
|
| `lt` | `LESS_THAN` |
|
||||||
|
| `ne` | `NOT_EQUALS` |
|
||||||
|
| `between` | `BETWEEN` (value is a 2-tuple) |
|
||||||
|
| `not_between` | `NOT_BETWEEN` (value is a 2-tuple) |
|
||||||
|
| `in` | `INCLUDES` (set) |
|
||||||
|
| `exclude` | `EXCLUDES` (set) |
|
||||||
|
| `all` | `INCLUDES_ALL` (set) |
|
||||||
|
| `contains` | `INCLUDES` (string `icontains`) |
|
||||||
|
| `regex` | `MATCHES_REGEX` |
|
||||||
|
| `isnull` | `IS_NULL` (value ignored) |
|
||||||
|
| `notnull` | `NOT_NULL` (value ignored) |
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- **Purely additive.** The explicit constructor, `to_q()`, and serialization are
|
||||||
|
unchanged. `where()` is chosen over a custom `__init__` precisely to keep the
|
||||||
|
explicit `GameFilter(name=StringCriterion(...))` form **fully statically typed**
|
||||||
|
(a `@dataclass(init=False)` + `**kwargs` constructor would have erased that).
|
||||||
|
- **Real field names, no aliasing.** Lookups use the actual dataclass field names
|
||||||
|
(e.g. `playtime_hours`, not a prettier `playtime`); no alias layer to maintain.
|
||||||
|
- **Fail loud.** An unknown field name or an unknown/!type-incompatible suffix
|
||||||
|
raises a clear `ValueError`/`TypeError` rather than silently producing an empty
|
||||||
|
filter. The lookup form is dynamic (like Django's `.filter()`), so this runtime
|
||||||
|
validation replaces static checking for that form only.
|
||||||
|
- **Scope.** `where()` covers the common flat-AND case (the verbose pain point).
|
||||||
|
`AND` / `OR` / `NOT` nesting continues to use the explicit constructor, which
|
||||||
|
reads fine and is rare.
|
||||||
|
|
||||||
|
### Component 2 — date-range filtering on session timestamps
|
||||||
|
|
||||||
|
**Decision: switch `SessionFilter.timestamp_start` / `timestamp_end` to
|
||||||
|
`DateCriterion` and apply them via Django's `__date` lookup.**
|
||||||
|
|
||||||
|
Rationale (the alternative was a new "relative date" criterion type):
|
||||||
|
|
||||||
|
- `DateCriterion` already exists and supports `GREATER_THAN` / `LESS_THAN` /
|
||||||
|
`BETWEEN` — no new criterion semantics to invent.
|
||||||
|
- The navbar link is server-rendered and regenerated on every request, so
|
||||||
|
encoding concrete dates is correct, reproducible, and shareable. A rolling
|
||||||
|
"relative date" concept is unnecessary machinery for this scope (YAGNI).
|
||||||
|
- The serialized JSON shape (`value`, `modifier`, optional `value2`) is
|
||||||
|
backward-compatible with any existing `StringCriterion`-shaped data for these
|
||||||
|
fields, and the fields are not in the UI, so the type change is low-risk.
|
||||||
|
|
||||||
|
Changes in `SessionFilter`:
|
||||||
|
|
||||||
|
- Field annotations: `timestamp_start: DateCriterion | None`,
|
||||||
|
`timestamp_end: DateCriterion | None`.
|
||||||
|
- In `to_q()`, target the date lookup so a date compares correctly against the
|
||||||
|
datetime column:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.timestamp_start is not None:
|
||||||
|
q &= self.timestamp_start.to_q("timestamp_start__date")
|
||||||
|
if self.timestamp_end is not None:
|
||||||
|
q &= self.timestamp_end.to_q("timestamp_end__date")
|
||||||
|
```
|
||||||
|
|
||||||
|
`DateCriterion.to_q("timestamp_start__date")` produces
|
||||||
|
`timestamp_start__date__gte=…` etc., which is valid.
|
||||||
|
|
||||||
|
The two navbar filters expressed with this:
|
||||||
|
|
||||||
|
- **Today:** `SessionFilter(timestamp_start=DateCriterion(value=today_iso,
|
||||||
|
modifier=Modifier.EQUALS))` → `timestamp_start__date = today`.
|
||||||
|
- **Last 7 days:** `SessionFilter(timestamp_start=DateCriterion(
|
||||||
|
value=(today−6)_iso, value2=today_iso, modifier=Modifier.BETWEEN))` →
|
||||||
|
7 calendar days inclusive (today and the previous six).
|
||||||
|
|
||||||
|
### Component 3 — navbar wiring (and a consistency fix)
|
||||||
|
|
||||||
|
- `model_counts` (`games/views/general.py`) computes `today_url` and
|
||||||
|
`last_7_url` with `filter_url(SessionFilter.where(...))` (see Component 1b) and
|
||||||
|
adds them to its returned dict alongside the existing formatted totals.
|
||||||
|
- `NavbarPlaytime()` (`common/layout.py`) gains `today_url` / `last_7_url`
|
||||||
|
parameters and wraps each total string in an `<a href>`. The out-of-band
|
||||||
|
refresh call in `games/views/session.py:311` passes the new URLs too.
|
||||||
|
|
||||||
|
**Deliberate behavior change — align the "last 7 days" total with its link.**
|
||||||
|
The displayed "last 7 days" total currently uses a *rolling 168-hour* window
|
||||||
|
(`timestamp_start__gte = now − timedelta(days=7)`), which would not match a
|
||||||
|
calendar-day link. To keep the number and the list it links to consistent, the
|
||||||
|
total is changed to the same **calendar-day boundary** used by the link
|
||||||
|
(`timestamp_start__date >= today − 6 days`, i.e. 7 calendar days inclusive). The
|
||||||
|
"today" total already matches (`[midnight, next midnight)` ≡ `__date = today`),
|
||||||
|
so only the 7-day computation changes.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **`filter_url()`** (unit): returns the correct path for each filter type; the
|
||||||
|
`filter` query param is the URL-encoded `filter_to_json(filter_obj)`; extra
|
||||||
|
params are merged; the produced URL round-trips through `parse_session_filter`
|
||||||
|
to an equivalent filter.
|
||||||
|
- **`where()`** (unit): each suffix maps to the right `Modifier`; the resolved
|
||||||
|
criterion class matches the field annotation across criterion types
|
||||||
|
(`Int`/`String`/`Bool`/`Date`/`Multi`/`Choice`); `between` consumes a 2-tuple
|
||||||
|
into `value`/`value2`; no-suffix defaults to `EQUALS` for scalars and
|
||||||
|
`INCLUDES` for set criteria; `isnull`/`notnull` ignore the value;
|
||||||
|
`GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True)` produces a
|
||||||
|
filter equal to the explicit construction and yields the same `to_q()`; an
|
||||||
|
unknown field or suffix raises.
|
||||||
|
- **Date filtering** (unit/db): sessions started today / 3 days ago / 10 days ago
|
||||||
|
fall into the correct buckets for the "today" and "last 7 days" filters via
|
||||||
|
`SessionFilter.to_q()`.
|
||||||
|
- **Navbar** (render): `NavbarPlaytime` renders anchors with the expected
|
||||||
|
`href`s; a smoke test confirms the linked URLs return 200 and apply the filter.
|
||||||
|
|
||||||
|
## Follow-up issue (to be filed)
|
||||||
|
|
||||||
|
**Title:** Wire programmatic filter links into stats tables and the game-detail page
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
> Issue #56 introduced `filter_url()` (a `reverse()`-style helper that builds a
|
||||||
|
> URL to a filtered list view from a filter object) and used it for the navbar
|
||||||
|
> playtime links. Two of the call sites named in #56 were deferred and should now
|
||||||
|
> be wired up using that helper:
|
||||||
|
>
|
||||||
|
> - **Stats-page tables** (`games/views/stats_content.py`): make table rows link
|
||||||
|
> to the corresponding filtered list (e.g. a game's playtime row → sessions for
|
||||||
|
> that game; a finished-games row → that game).
|
||||||
|
> - **`view_game` tables** (`games/views/game.py`): the sessions / purchases /
|
||||||
|
> playevents sections should offer "view all … for this game" links to the
|
||||||
|
> filtered list views.
|
||||||
|
>
|
||||||
|
> All of these are pure consumers of `filter_url()`; no new filter machinery is
|
||||||
|
> required. Decide per table which filter each link should encode.
|
||||||
|
|
||||||
|
`gh` is not installed in the working environment, so this is provided as
|
||||||
|
ready-to-paste text; it can be filed manually or via `gh` once available.
|
||||||
|
|
||||||
|
## Notes / risks
|
||||||
|
|
||||||
|
- Changing the criterion type of `timestamp_start` / `timestamp_end` affects how
|
||||||
|
any persisted `FilterPreset` containing those fields deserializes (it will now
|
||||||
|
resolve to `DateCriterion`). The JSON shape is compatible and these fields are
|
||||||
|
not surfaced in the UI, so the risk is minimal, but it is worth a grep for any
|
||||||
|
saved presets referencing them before merge.
|
||||||
+41
-4
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
from common.criteria import (
|
from common.criteria import (
|
||||||
BoolCriterion,
|
BoolCriterion,
|
||||||
@@ -26,6 +28,7 @@ from common.criteria import (
|
|||||||
OperatorFilter,
|
OperatorFilter,
|
||||||
StringCriterion,
|
StringCriterion,
|
||||||
filter_from_json,
|
filter_from_json,
|
||||||
|
filter_to_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
|
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
|
||||||
@@ -438,8 +441,8 @@ class SessionFilter(OperatorFilter):
|
|||||||
duration_manual_hours: IntCriterion | None = None
|
duration_manual_hours: IntCriterion | None = None
|
||||||
duration_calculated_hours: IntCriterion | None = None
|
duration_calculated_hours: IntCriterion | None = None
|
||||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||||
timestamp_start: StringCriterion | None = None # date string
|
timestamp_start: DateCriterion | None = None # date, compared via __date
|
||||||
timestamp_end: StringCriterion | None = None # date string
|
timestamp_end: DateCriterion | None = None # date, compared via __date
|
||||||
is_manual: BoolCriterion | None = None # duration_manual > 0
|
is_manual: BoolCriterion | None = None # duration_manual > 0
|
||||||
created_at: StringCriterion | None = None
|
created_at: StringCriterion | None = None
|
||||||
|
|
||||||
@@ -519,9 +522,10 @@ class SessionFilter(OperatorFilter):
|
|||||||
else:
|
else:
|
||||||
q &= Q(timestamp_end__isnull=False)
|
q &= Q(timestamp_end__isnull=False)
|
||||||
if self.timestamp_start is not None:
|
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:
|
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 is not None:
|
||||||
if self.is_manual.value:
|
if self.is_manual.value:
|
||||||
q &= ~Q(duration_manual=timedelta(0))
|
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:
|
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||||
return filter_from_json(PlayEventFilter, json_str)
|
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)}"
|
||||||
|
|||||||
+21
-2
@@ -15,6 +15,7 @@ from django.utils.timezone import now as timezone_now
|
|||||||
|
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import format_duration
|
from common.time import format_duration
|
||||||
|
from games.filters import SessionFilter, filter_url
|
||||||
from games.models import Game, Platform, Purchase, Session
|
from games.models import Game, Platform, Purchase, Session
|
||||||
from games.views.stats_content import stats_content
|
from games.views.stats_content import stats_content
|
||||||
from games.views.stats_data import compute_stats
|
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.
|
# 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()
|
now = timezone_now()
|
||||||
# Use a contiguous [midnight, next midnight) range in the active timezone
|
# 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
|
# 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.
|
# 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_today = localtime(now).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
start_of_tomorrow = start_of_today + timedelta(days=1)
|
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(
|
today_played = Session.objects.filter(
|
||||||
timestamp_start__gte=start_of_today,
|
timestamp_start__gte=start_of_today,
|
||||||
timestamp_start__lt=start_of_tomorrow,
|
timestamp_start__lt=start_of_tomorrow,
|
||||||
).aggregate(time=Sum(F("duration_total")))["time"]
|
).aggregate(time=Sum(F("duration_total")))["time"]
|
||||||
last_7_played = Session.objects.filter(
|
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"]
|
).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 {
|
return {
|
||||||
"game_available": Game.objects.exists(),
|
"game_available": Game.objects.exists(),
|
||||||
"platform_available": Platform.objects.exists(),
|
"platform_available": Platform.objects.exists(),
|
||||||
@@ -45,6 +62,8 @@ def model_counts(request: HttpRequest) -> dict[str, bool]:
|
|||||||
"session_count": Session.objects.exists(),
|
"session_count": Session.objects.exists(),
|
||||||
"today_played": format_duration(today_played, "%H h %m m"),
|
"today_played": format_duration(today_played, "%H h %m m"),
|
||||||
"last_7_played": format_duration(last_7_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -308,7 +308,13 @@ def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse:
|
|||||||
counts = model_counts(request)
|
counts = model_counts(request)
|
||||||
fragment = Fragment(
|
fragment = Fragment(
|
||||||
session_row(session, device_list, get_token(request)),
|
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))
|
return HttpResponse(str(fragment))
|
||||||
|
|
||||||
|
|||||||
@@ -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 common.layout import NavbarPlaytime
|
||||||
|
from games.filters import parse_session_filter
|
||||||
|
|
||||||
|
|
||||||
def test_navbar_playtime_has_stable_id_and_values():
|
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))
|
html = str(NavbarPlaytime("1 h 00 m", "7 h 00 m", oob=True))
|
||||||
assert 'id="navbar-playtime"' in html
|
assert 'id="navbar-playtime"' in html
|
||||||
assert 'hx-swap-oob="true"' 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