129 lines
4.9 KiB
Python
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
|