app/sorting.py
Rama main · réplica de lectura del repositorio original.
"""Click a column header to sort. Shared by every table that wants it.
Ordering lives here rather than in `columns.py`, which is about which columns
exist; the FY report has three tables of three shapes and wants only this.
Sorting is server-side, blanks sort last in both directions, and the sort lives
in the URL rather than the user record — decisions.md #81. Parameters carry a
table-name prefix because the FY report puts three sortable tables on a page.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Mapping, Sequence
from urllib.parse import urlencode
UP = "▲"
DOWN = "▼"
def _blank(value: Any) -> bool:
"""Missing, as opposed to merely falsy.
`None` and the empty string are absences. **Zero and False are not** —
treating falsiness as absence would drop every position that has not moved
today to the bottom of a sort on "Today", which is precisely the set of rows
somebody sorting that column is looking at.
"""
return value is None or value == ""
def apply(rows: Sequence, getter: Callable, *, descending: bool) -> list:
"""`rows` ordered by `getter`, with the blanks kept at the end.
Partition rather than a clever key function: a key that encodes "blank" as
a tuple has to invent a value to pair it with, and there isn't one that
compares against both Decimals and strings. Two lists is less code and
cannot be wrong.
Both halves stay stable, so equal values keep the order the page already
had — which makes the natural order the secondary sort for free.
"""
present = [r for r in rows if not _blank(getter(r))]
absent = [r for r in rows if _blank(getter(r))]
try:
present.sort(key=getter, reverse=descending)
except TypeError:
# A column whose values are not comparable to one another. Rendering the
# table unsorted is a worse page; a 500 is no page at all.
return list(rows)
return present + absent
@dataclass(frozen=True)
class Sort:
"""One table's sort state, and the links its headers point at."""
table: str
key: str | None = None
descending: bool = False
# Every other query parameter on the current URL, so a header link keeps
# them. `?fy=2026` is the one that matters: losing it sorts a different
# financial year, silently, with entirely plausible numbers.
others: tuple[tuple[str, str], ...] = field(default=())
def on(self, key: str) -> bool:
return self.key == key
def arrow(self, key: str) -> str:
"""The glyph for this header — empty for every column but the sorted
one. An arrow on all of them is decoration that stops saying which is
active, which is the whole job."""
if not self.on(key):
return ""
return DOWN if self.descending else UP
def aria(self, key: str) -> str:
"""`aria-sort`, which is what a screen reader reads. The arrow is a
glyph and says nothing to one."""
if not self.on(key):
return "none"
return "descending" if self.descending else "ascending"
def href(self, key: str) -> str:
"""Where this header links: the same page, sorted by `key`.
Ascending first; clicking the column that is already sorted flips it.
"""
params = list(self.others)
params.append((f"{self.table}_sort", key))
if self.on(key) and not self.descending:
params.append((f"{self.table}_dir", "desc"))
return "?" + urlencode(params)
# Headers that cycle through a pair of keys: value ↑, % ↑, value ↓, % ↓.
# Why four states rather than a second control, and why `unit()` has to
# render which one you are in — decisions.md #25.
def _states(self, keys: Sequence[str]) -> list[tuple[str, bool]]:
"""The cycle, in order: every key ascending, then every key descending."""
return [(key, descending) for descending in (False, True) for key in keys]
def cycle(self, keys: Sequence[str]) -> str:
"""Where a paired header links: one step around the cycle."""
states = self._states(keys)
try:
index = states.index((self.key, self.descending))
except ValueError:
index = -1 # not sorted by this pair: start at the first
key, descending = states[(index + 1) % len(states)]
params = list(self.others)
params.append((f"{self.table}_sort", key))
if descending:
params.append((f"{self.table}_dir", "desc"))
return "?" + urlencode(params)
def on_any(self, keys: Sequence[str]) -> bool:
return self.key in set(keys)
def unit(self, keys: Sequence[str], marks: Sequence[str]) -> str:
"""Which of the pair is active, as the header shows it — `$` or `%`.
Empty when this pair is not the sorted one, so exactly one header on the
table is wearing a state.
"""
if not self.on_any(keys):
return ""
return marks[list(keys).index(self.key)]
def pair_arrow(self, keys: Sequence[str]) -> str:
if not self.on_any(keys):
return ""
return DOWN if self.descending else UP
def pair_aria(self, keys: Sequence[str]) -> str:
if not self.on_any(keys):
return "none"
return "descending" if self.descending else "ascending"
def read(query: Mapping[str, str], table: str, allowed: Iterable[str]) -> Sort:
"""This table's sort state, from the request's query parameters.
`allowed` is an allow-list and not a suggestion: the parameter names an
attribute to order by, so it is caller-supplied input arriving at a lookup.
Anything not on the list is dropped and the table renders in its natural
order.
"""
sort_param = f"{table}_sort"
dir_param = f"{table}_dir"
key = query.get(sort_param)
if key not in set(allowed):
key = None
return Sort(
table=table,
key=key,
descending=query.get(dir_param) == "desc",
others=tuple((k, v) for k, v in query.items()
if k not in (sort_param, dir_param)),
)