Files
timetracker/games/sorting.py
T

129 lines
4.9 KiB
Python

"""Structured sorting for list views (Stash-inspired, paired with games/filters.py).
A list view maps a public sort key to a SortSpec; the URL ?sort= param is a
signed comma-list of those keys (e.g. "-playtime,name"). See
docs/superpowers/specs/2026-06-21-list-view-sort-param-design.md.
"""
from dataclasses import dataclass
from typing import NamedTuple
from django.db.models import Aggregate, Max, Min, QuerySet, Sum
from django.http import HttpRequest
from games.filters import FindFilter
type SortKey = str # public column key in a *_SORTS map and in a URL term ("playtime", "name")
type SortString = str # comma-list of signed SortKeys: the URL ?sort= value and *_DEFAULT_SORT ("-date,created")
type AnnotationName = str # an alias added via .annotate(), then referenced by SortSpec.expression
type OrderField = str # SortSpec.expression: a real model field path OR an AnnotationName
# alias name -> the ORM aggregate that computes it, applied via queryset.annotate()
# e.g. {"total_playtime": Sum("sessions__duration_total")}
type Annotations = dict[AnnotationName, Aggregate]
@dataclass(frozen=True)
class SortSpec:
expression: OrderField # unsigned; a real column path or an AnnotationName
annotate: Annotations | None = None
class SortTerm(NamedTuple):
key: SortKey
descending: bool # True = "-key" (desc), False = bare key (asc)
type SortMap = dict[SortKey, SortSpec]
class ParsedSort(NamedTuple):
terms: list[SortTerm]
unknown: list[SortKey] # keys not in the map — the view turns these into warnings
def parse_sort_terms(raw: SortString, sort_map: SortMap) -> ParsedSort:
terms: list[SortTerm] = []
unknown: list[SortKey] = []
for token in raw.split(","):
token = token.strip()
if not token:
continue
descending = token.startswith("-")
key = token.lstrip("-")
if key in sort_map:
terms.append(SortTerm(key, descending))
else:
unknown.append(key)
return ParsedSort(terms, unknown)
# ── Per-model sort maps ─────────────────────────────────────────────────────
# Cross-relation sorts use annotated aggregates (group by PK → no row dup).
# To-one relations (game__sort_name, device__name) are ordered directly.
GAME_SORTS: SortMap = {
"name": SortSpec("name"),
"sort_name": SortSpec("sort_name"),
"year": SortSpec("year_released"),
"status": SortSpec("status"),
"wikidata": SortSpec("wikidata"),
"created": SortSpec("created_at"),
"playtime": SortSpec("total_playtime", {"total_playtime": Sum("sessions__duration_total")}),
"finished": SortSpec("last_finished", {"last_finished": Max("playevents__ended")}),
}
GAME_DEFAULT_SORT: SortString = "-created"
SESSION_SORTS: SortMap = {
"name": SortSpec("game__sort_name"),
"date": SortSpec("timestamp_start"),
"duration": SortSpec("duration_total"),
"device": SortSpec("device__name"),
"created": SortSpec("created_at"),
}
SESSION_DEFAULT_SORT: SortString = "-date,created"
PURCHASE_SORTS: SortMap = {
"name": SortSpec("first_game_name", {"first_game_name": Min("games__name")}),
"type": SortSpec("type"),
"price": SortSpec("converted_price"),
"infinite": SortSpec("infinite"),
"purchased": SortSpec("date_purchased"),
"refunded": SortSpec("date_refunded"),
"created": SortSpec("created_at"),
"finished": SortSpec("last_finished", {"last_finished": Max("games__playevents__ended")}),
}
PURCHASE_DEFAULT_SORT: SortString = "-purchased,-created"
# ── Apply ───────────────────────────────────────────────────────────────────
class SortResult(NamedTuple):
queryset: QuerySet
terms: list[SortTerm] # the order actually applied — #73's header UI consumes this
unknown: list[SortKey] # rejected keys — the view turns these into warning toasts
def apply_sort(
queryset: QuerySet, find: FindFilter, sort_map: SortMap, default_sort: SortString
) -> SortResult:
terms, unknown = parse_sort_terms(find.sort or "", sort_map)
if not terms:
# default_sort is trusted developer config — ignore any "unknown" from it
terms, _ = parse_sort_terms(default_sort, sort_map)
annotations: Annotations = {}
order_by: list[OrderField] = []
for term in terms:
spec = sort_map[term.key]
if spec.annotate:
annotations.update(spec.annotate)
order_by.append(("-" if term.descending else "") + spec.expression)
if annotations:
queryset = queryset.annotate(**annotations)
return SortResult(queryset.order_by(*order_by), terms, unknown)
def parse_find_filter(request: HttpRequest) -> FindFilter:
return FindFilter(sort=request.GET.get("sort") or None) # FindFilter.sort holds a SortString