Files
siftlode/backend/app/routes/_keyset.py
T
peter 24448e7c6f feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.

Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.

Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
2026-10-18 03:45:18 +02:00

67 lines
2.7 KiB
Python

"""Keyset paging over `(timestamp desc, id desc)`, shared by the admin history lists (server log,
audit log, background runs).
A timestamp alone is not unique (a browser log row's `ts` is clamped into its consent window; a run
and its siblings can start in the same microsecond), and an offset would skip or repeat rows while
writers add new ones at the top — so a page continues from the last row's `(timestamp, id)`.
"""
import base64
import binascii
import json
from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session
from app.models import User
PAGE_MAX = 200
def utc(value: datetime | None) -> datetime | None:
"""A bound without a zone is UTC — the columns are `timestamptz`, and a naive value would be read
in the database session's zone instead."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def encode_cursor(ts: datetime, row_id: int) -> str:
return base64.urlsafe_b64encode(json.dumps([ts.isoformat(), row_id]).encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
at = datetime.fromisoformat(ts)
return (at if at.tzinfo is not None else at.replace(tzinfo=timezone.utc)), int(row_id)
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
def older_than(ts_col, id_col, cursor: str):
"""The rows after `cursor` in `(ts desc, id desc)` order. The `ts <=` conjunct is implied by the
OR; it is there so the planner can bound the timestamp index scan instead of filtering every
newer row first."""
ts, row_id = decode_cursor(cursor)
return and_(ts_col <= ts, or_(ts_col < ts, and_(ts_col == ts, id_col < row_id)))
def page(rows: list, limit: int, ts_of) -> tuple[list, str | None]:
"""`rows` was read with `limit + 1` (to know whether there is a next page without a count query):
the page itself, and the cursor that continues after it (None on the last page)."""
more = len(rows) > limit
rows = rows[:limit]
return rows, encode_cursor(ts_of(rows[-1]), rows[-1].id) if more else None
def accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
"""The accounts a page names, by id → address. An id with no account left (erased after the row
was written) is simply absent, and the client shows the bare id."""
if not user_ids:
return {}
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
return {str(uid): email for uid, email in rows}