"""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}