feat(sorting): per-model maps, apply_sort, parse_find_filter (#68)
This commit is contained in:
+74
-1
@@ -8,7 +8,10 @@ docs/superpowers/specs/2026-06-21-list-view-sort-param-design.md.
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from django.db.models import Aggregate
|
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 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 SortString = str # comma-list of signed SortKeys: the URL ?sort= value and *_DEFAULT_SORT ("-date,created")
|
||||||
@@ -53,3 +56,73 @@ def parse_sort_terms(raw: SortString, sort_map: SortMap) -> ParsedSort:
|
|||||||
else:
|
else:
|
||||||
unknown.append(key)
|
unknown.append(key)
|
||||||
return ParsedSort(terms, unknown)
|
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
|
||||||
|
|||||||
+99
-1
@@ -1,6 +1,29 @@
|
|||||||
"""Tests for the list-view sorting system (games/sorting.py)."""
|
"""Tests for the list-view sorting system (games/sorting.py)."""
|
||||||
|
|
||||||
from games.sorting import SortSpec, SortTerm, parse_sort_terms
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import RequestFactory
|
||||||
|
|
||||||
|
from games.filters import FindFilter
|
||||||
|
from games.models import Game, Platform, Purchase, Session
|
||||||
|
from games.sorting import (
|
||||||
|
GAME_DEFAULT_SORT,
|
||||||
|
GAME_SORTS,
|
||||||
|
PURCHASE_DEFAULT_SORT,
|
||||||
|
PURCHASE_SORTS,
|
||||||
|
SESSION_DEFAULT_SORT,
|
||||||
|
SESSION_SORTS,
|
||||||
|
SortSpec,
|
||||||
|
SortTerm,
|
||||||
|
apply_sort,
|
||||||
|
parse_find_filter,
|
||||||
|
parse_sort_terms,
|
||||||
|
)
|
||||||
|
|
||||||
|
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||||
|
|
||||||
# A minimal map; parse_sort_terms only checks key membership, not spec internals.
|
# A minimal map; parse_sort_terms only checks key membership, not spec internals.
|
||||||
SAMPLE_MAP = {"name": SortSpec("name"), "date": SortSpec("created_at")}
|
SAMPLE_MAP = {"name": SortSpec("name"), "date": SortSpec("created_at")}
|
||||||
@@ -38,3 +61,78 @@ class TestParseSortTerms:
|
|||||||
parsed = parse_sort_terms("", SAMPLE_MAP)
|
parsed = parse_sort_terms("", SAMPLE_MAP)
|
||||||
assert parsed.terms == []
|
assert parsed.terms == []
|
||||||
assert parsed.unknown == []
|
assert parsed.unknown == []
|
||||||
|
|
||||||
|
|
||||||
|
def _find(sort=None):
|
||||||
|
return FindFilter(sort=sort)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_games(db):
|
||||||
|
platform = Platform.objects.create(name="P", icon="p")
|
||||||
|
alpha = Game.objects.create(name="Alpha", sort_name="Alpha", platform=platform)
|
||||||
|
beta = Game.objects.create(name="Beta", sort_name="Beta", platform=platform)
|
||||||
|
return alpha, beta
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplySortGames:
|
||||||
|
def test_name_ascending(self, two_games):
|
||||||
|
alpha, beta = two_games
|
||||||
|
result = apply_sort(Game.objects.all(), _find("name"), GAME_SORTS, GAME_DEFAULT_SORT)
|
||||||
|
assert list(result.queryset) == [alpha, beta]
|
||||||
|
assert result.terms[0].key == "name"
|
||||||
|
assert result.unknown == []
|
||||||
|
|
||||||
|
def test_name_descending(self, two_games):
|
||||||
|
alpha, beta = two_games
|
||||||
|
result = apply_sort(Game.objects.all(), _find("-name"), GAME_SORTS, GAME_DEFAULT_SORT)
|
||||||
|
assert list(result.queryset) == [beta, alpha]
|
||||||
|
|
||||||
|
def test_default_sort_when_absent_is_created_desc(self, two_games):
|
||||||
|
alpha, beta = two_games # beta created after alpha
|
||||||
|
result = apply_sort(Game.objects.all(), _find(None), GAME_SORTS, GAME_DEFAULT_SORT)
|
||||||
|
assert list(result.queryset) == [beta, alpha]
|
||||||
|
|
||||||
|
def test_unknown_key_reported_and_falls_back(self, two_games):
|
||||||
|
result = apply_sort(Game.objects.all(), _find("bogus"), GAME_SORTS, GAME_DEFAULT_SORT)
|
||||||
|
assert result.unknown == ["bogus"]
|
||||||
|
assert result.queryset.count() == 2 # still returns rows (default order)
|
||||||
|
|
||||||
|
def test_playtime_annotation_no_duplicate_rows(self, two_games):
|
||||||
|
alpha, _ = two_games
|
||||||
|
Session.objects.create(
|
||||||
|
game=alpha,
|
||||||
|
timestamp_start=datetime(2022, 1, 1, 10, tzinfo=ZONEINFO),
|
||||||
|
timestamp_end=datetime(2022, 1, 1, 12, tzinfo=ZONEINFO),
|
||||||
|
)
|
||||||
|
Session.objects.create(
|
||||||
|
game=alpha,
|
||||||
|
timestamp_start=datetime(2022, 1, 2, 10, tzinfo=ZONEINFO),
|
||||||
|
timestamp_end=datetime(2022, 1, 2, 11, tzinfo=ZONEINFO),
|
||||||
|
)
|
||||||
|
result = apply_sort(Game.objects.all(), _find("-playtime"), GAME_SORTS, GAME_DEFAULT_SORT)
|
||||||
|
# two sessions on alpha must not duplicate the alpha row
|
||||||
|
assert result.queryset.count() == 2
|
||||||
|
assert list(result.queryset)[0] == alpha # most playtime first
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseFindFilter:
|
||||||
|
def test_reads_sort_param(self):
|
||||||
|
request = RequestFactory().get("/x", {"sort": "-playtime,name"})
|
||||||
|
assert parse_find_filter(request).sort == "-playtime,name"
|
||||||
|
|
||||||
|
def test_absent_sort_is_none(self):
|
||||||
|
request = RequestFactory().get("/x")
|
||||||
|
assert parse_find_filter(request).sort is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSortMapShapes:
|
||||||
|
def test_default_sort_keys_exist_in_maps(self):
|
||||||
|
# every key referenced by a default sort string must be defined in its map
|
||||||
|
for default, sort_map in [
|
||||||
|
(GAME_DEFAULT_SORT, GAME_SORTS),
|
||||||
|
(SESSION_DEFAULT_SORT, SESSION_SORTS),
|
||||||
|
(PURCHASE_DEFAULT_SORT, PURCHASE_SORTS),
|
||||||
|
]:
|
||||||
|
for token in default.split(","):
|
||||||
|
assert token.lstrip("-") in sort_map
|
||||||
|
|||||||
Reference in New Issue
Block a user