🚧 Add possbility to sort and filter on table headers

This commit is contained in:
Jonas Bengtsson
2026-09-05 18:44:16 +02:00
parent 6795547c47
commit 3c4b5563ea
7 changed files with 524 additions and 28 deletions
+62 -7
View File
@@ -1474,8 +1474,26 @@ def create_app(
@app.get("/messages", tags=["data"], dependencies=[Depends(authenticate)])
def list_messages(
imei: str | None = Query(None, description="Filter by IMEI (15 digits)"),
device: str | None = Query(None, description="Filter by device name"),
imei: list[str] | None = Query(
None, description="Filter by IMEI (repeatable for a multi-select)"
),
device: list[str] | None = Query(
None, description="Filter by device name (repeatable for a multi-select)"
),
imei_like: str | None = Query(
None, description="Case-insensitive contains filter on IMEI"
),
device_like: str | None = Query(
None, description="Case-insensitive contains filter on device name"
),
sort: str | None = Query(
None,
description=(
"Sort column: id, received_at, imei, device or payload_time; "
"prefix with - for descending (e.g. -received_at). Default: "
"payload_time DESC when framing on it, else id DESC (newest first)."
),
),
from_time: datetime | None = Query(
None,
alias="from",
@@ -1532,12 +1550,23 @@ def create_app(
# the query joins imei_map for the read-time settings merge.
conditions: list[sql.Composable] = []
params: list[Any] = []
# Repeatable exact-match filters: ?imei=a&imei=b -> = ANY(...).
# A single value keeps working (list of one).
if imei:
conditions.append(sql.SQL("m.imei = %s"))
conditions.append(sql.SQL("m.imei = ANY(%s)"))
params.append(imei)
if device:
conditions.append(sql.SQL("m.device = %s"))
conditions.append(sql.SQL("m.device = ANY(%s)"))
params.append(device)
# Free-text contains filters for the column filter boxes. The whole
# pattern is ONE parameter - psycopg forbids literal % inside SQL
# strings (placeholder parsing).
if imei_like:
conditions.append(sql.SQL("m.imei ILIKE %s"))
params.append(f"%{imei_like}%")
if device_like:
conditions.append(sql.SQL("m.device ILIKE %s"))
params.append(f"%{device_like}%")
if from_time is not None:
conditions.append(sql.SQL("m.received_at >= %s"))
params.append(_as_utc(from_time))
@@ -1555,9 +1584,35 @@ def create_app(
if conditions:
where = sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
# Order by device timestamp when framing on it (NULLS LAST matches the
# idx_mqtt_messages_ptime index); otherwise newest-first by insert id.
if payload_from_time is not None or payload_to_time is not None:
# Sort: explicit ?sort= wins; default stays "payload_time DESC when
# framing on it, else id DESC". The whitelist maps to pre-built sql
# fragments - user input is never interpolated into the ORDER BY.
SORTABLE = {
"id": sql.SQL("m.id"),
"received_at": sql.SQL("m.received_at"),
"imei": sql.SQL("m.imei"),
"device": sql.SQL("m.device"),
"payload_time": sql.SQL("m.payload_time"),
}
if sort is not None:
desc = sort.startswith("-")
name = sort[1:] if desc else sort
column = SORTABLE.get(name)
if column is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"unknown sort column {sort!r}; available: "
+ ", ".join(SORTABLE)
),
)
order_by = (
sql.SQL(" ORDER BY ")
+ column
+ (sql.SQL(" DESC") if desc else sql.SQL(""))
+ sql.SQL(" NULLS LAST LIMIT %s OFFSET %s")
)
elif payload_from_time is not None or payload_to_time is not None:
order_by = sql.SQL(" ORDER BY m.payload_time DESC NULLS LAST LIMIT %s OFFSET %s")
else:
order_by = sql.SQL(" ORDER BY m.id DESC LIMIT %s OFFSET %s")