From 5ccf388ef53d1025b8d2386b51a235a5d106c583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 13:46:25 +0200 Subject: [PATCH] feat(sorting): per-model maps, apply_sort, parse_find_filter (#68) --- games/sorting.py | 75 ++++++++++++++++++++++++++++++- tests/test_sorting.py | 100 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/games/sorting.py b/games/sorting.py index eaf5d44..05ea9b9 100644 --- a/games/sorting.py +++ b/games/sorting.py @@ -8,7 +8,10 @@ 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 +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") @@ -53,3 +56,73 @@ def parse_sort_terms(raw: SortString, sort_map: SortMap) -> ParsedSort: 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 diff --git a/tests/test_sorting.py b/tests/test_sorting.py index c0217a3..c95e0ab 100644 --- a/tests/test_sorting.py +++ b/tests/test_sorting.py @@ -1,6 +1,29 @@ """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. SAMPLE_MAP = {"name": SortSpec("name"), "date": SortSpec("created_at")} @@ -38,3 +61,78 @@ class TestParseSortTerms: parsed = parse_sort_terms("", SAMPLE_MAP) assert parsed.terms == [] 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