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