loopable / api /pages.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
618de96 verified
Raw
History Blame Contribute Delete
22.3 kB
"""pages.py β€” Y1: the page-data envelope, and the ONE place its rules live (W2-2).
THE POINT OF THIS FILE IS WAVE 3. Collections and Procurement must land by adding a BUILDER, not
by inventing a shape β€” so every rule the envelope has (the closed `fmt` enum, the drill grammar,
the no-silent-caps contract, how BU scope is resolved) lives here as a constructor a builder calls,
never as a convention a builder is expected to remember. A rule that exists only in a docstring is
a rule the second page gets wrong.
WHAT A BUILDER OWES, and nothing more:
metrics(team_id, granularity, today) -> a dict of plain data, SCOPE-SHAPED ONLY
blocks(metrics, team_id, granularity) -> [block, ...] built with the constructors below
TITLE / SUBTITLE / MODULE / CONTROLS
`build_envelope` does the rest: it resolves the BU, caches the metrics on the SCOPE key, assembles
the per-request half, and returns the envelope.
β›” THE CACHE LINE, WHICH IS WHERE WAVE 1 SHIPPED A CROSS-USER LEAK. What may be cached on a
`(page, team_id, doc_mode, day)` key is the BUILDER'S METRICS β€” the answer to a question two users
with the same scope are asking identically. What may NEVER be cached there is anything with a
per-USER stratum, and on this envelope that is `controls` (its `bu.options` come from
`perms.allowed_bu_labels(user)`, i.e. from the user RECORD). Wave 1's defect was exactly this shape
one layer down: a whole payload cached on a scope key while carrying per-user fields and overlay
cells. It survived 129 green checks because every fixture user had a distinct scope pair. So the
split is structural here, not remembered: `metrics()` cannot see the session at all β€” it takes
`team_id`, and `verify_api.py` asserts the cached object holds nothing user-shaped.
⚠ THE CLIENT FORMATS. The server never sends a pre-formatted number, so one formatter change fixes
every page β€” and `fmt` is a CLOSED enum for the same reason. Two vocabularies exist and they are
not interchangeable: `FMTS` for kpi/table/validation, and viz `FieldType` for a chart block's
`fields[]` (those objects are fed straight into `chartData`). `money` vs `currency` is the trap.
"""
import time
# `deps` FIRST, deliberately: it is what puts `platform/` on `sys.path`, so importing it
# ahead of any `core.*` makes this module importable in whatever order a caller reaches it.
from deps import Session, err, perms # noqa: I001
import core.context as ctxlib # noqa: E402
# ── the two vocabularies ─────────────────────────────────────────────────────────────────────────
#: The Y1 client-format enum for kpi / table / validation values. CLOSED on purpose: a page that
#: needs a new one adds it HERE and to the client's `fmt` at the same time, which is the property
#: that keeps one formatter authoritative. A table column with NO `fmt` renders verbatim β€” that is
#: how a text column is expressed without opening the enum.
FMTS = ("money", "num", "pct", "int", "date")
#: The viz `FieldType` values a chart block's `fields[]` may use β€” a DIFFERENT vocabulary, because
#: `chartData(spec, rows, fieldByKey)` consumes those objects directly. `currency`, not `money`.
VIZ_TYPES = ("text", "status", "currency", "int", "pct", "date")
#: `chartData`'s own display cap (`MAX_BUCKETS`). A payload must fit it at the SOURCE: the engine
#: truncates past 12 and, on a date axis, sorts ASCENDING first β€” so a 13-month series loses the
#: CURRENT month, the one anybody actually looks at. Mirrored here so a builder can size its own
#: series without importing anything from the client tree.
MAX_BUCKETS = 12
#: Drill kinds the client is expected to understand. `decomp` names a WINDOW + a scope; the others
#: name an ENTITY. ⚠ THIS WAVE THEY ARE DECLARATIONS β€” there is no resolver route (Β§5 Y1 amendment
#: 4). Each descriptor is COMPLETE, so wave 3 adds the resolver with no shape change, and Y6's
#: panel must say it is thin rather than look finished.
DRILL_KINDS = ("decomp", "customer", "sku", "rep", "bu")
def _fmt_ok(fmt, allowed, where):
if fmt is not None and fmt not in allowed:
# A wrong `fmt` renders β€” wrongly β€” and nothing complains. Refusing it at construction is
# the only moment anybody finds out, so this raises rather than coercing.
raise ValueError(f"{where}: fmt {fmt!r} is not one of {allowed}")
return fmt
# ── block constructors ───────────────────────────────────────────────────────────────────────────
def kpi(key, label, value, fmt="money", delta=None, delta_fmt=None, delta_label=None,
delta_dir=None, note=None, drill=None):
"""One KPI card. `delta` is a NUMBER or None β€” never a string.
THE ZERO-REVENUE RULE, ported from `app.py:2846`: a period with no orders yet must not show an
alarming βˆ’100%. It sends NO number (`delta=None`) plus a `delta_label` and `delta_dir='off'`,
so no formatter is involved and the card states the situation instead of computing a ratio
against nothing.
THE BIG-RATIO RULE IS THE CLIENT'S: a tiny LY base makes `+14,975%` read as noise, and
Streamlit rewrites it per-card as `+150Γ— LY`. That belongs in ONE formatter (`fmt.pct` renders
|v| β‰₯ 999 as a multiple), so the server just sends the number.
"""
out = {"key": key, "label": label, "value": value,
"fmt": _fmt_ok(fmt, FMTS, f"kpi {key}")}
if delta is not None:
out["delta"] = delta
out["delta_fmt"] = _fmt_ok(delta_fmt or "pct", FMTS, f"kpi {key} delta")
if delta_label:
out["delta_label"] = delta_label
if delta_dir:
out["delta_dir"] = delta_dir # up | down | flat | off β€” a TONE hint, not the sign
if note:
out["note"] = note
if drill:
out["drill"] = drill
return out
def kpis(key, items):
return {"type": "kpis", "key": key, "items": list(items)}
def section(key, label, note=None):
out = {"type": "section", "key": key, "label": label}
if note:
out["note"] = note
return out
def column(key, label, fmt=None, align=None):
"""A table column. `fmt` omitted β‡’ render verbatim (that is how text columns work)."""
out = {"key": key, "label": label}
if fmt:
out["fmt"] = _fmt_ok(fmt, FMTS, f"column {key}")
if align:
out["align"] = align
elif fmt in ("money", "num", "pct", "int"):
out["align"] = "right" # numbers align right by default, everywhere
return out
def table(key, columns, rows, total=None, drill=None, download=None, empty=None):
"""A table block. `shown`/`total` ALWAYS ship β€” `shown == total` still sends both.
Owner rule [[no-unverifiable-aggregates]]: a silent `[:N]` cap is a defect. `total` defaults to
`len(rows)` so a builder that forgot to truncate cannot accidentally claim it did not.
"""
rows = list(rows)
out = {"type": "table", "key": key, "columns": list(columns), "rows": rows,
"shown": len(rows), "total": int(total if total is not None else len(rows))}
if drill:
out["drill"] = drill
if download:
out["download"] = download
if empty:
out["empty"] = empty
return out
def chart(key, spec, series, fields, rows, x_order=None, drill=None, total=None,
delta_key=None):
"""A chart block: ONE x, N SERIES.
β›” WHY `series[]` AND NOT `ChartSpec.splitBy`. The viz engine has no series dimension:
`chartData()` buckets on `x` only and `DashboardView` draws one flat `buckets[]`. `splitBy` is
persisted, validated and offered in the chart editor but never reaches the arithmetic or the
renderer, so a `splitBy` chart draws identically to one without it. A YoY comparison is
therefore N `chartData` calls over the SAME wide rows, one per series `y` β€” which also means
nothing about `chartData` changes and `verify_charts.py`'s verdict stays put, which W2-5's
pure-move proof requires.
β›” WHY `x_order`. The engine sorts buckets by VALUE unless the x field is date-family β€” a trend
would render as a revenue-ordered sawtooth. And date-family is not an escape: it buckets to
`YYYY-MM`, so a weekly axis would collapse into months. So the SERVER declares the chronology
and the client re-orders the buckets it got back.
"""
for f in fields:
if f.get("type") not in VIZ_TYPES:
raise ValueError(f"chart {key}: field {f.get('key')!r} type {f.get('type')!r} is not a "
f"viz FieldType {VIZ_TYPES} β€” `money` is the kpi/table vocabulary")
rows = list(rows)
out = {"type": "chart", "key": key, "spec": dict(spec), "series": list(series),
"fields": list(fields), "rows": rows,
"shown": len(rows), "total": int(total if total is not None else len(rows))}
if x_order is not None:
out["x_order"] = list(x_order)
if drill:
out["drill"] = drill
if delta_key:
# The internal-tool rule: a YoY chart PRINTS the % on the chart. The SERVER computes the
# per-bucket delta (this row key) so the client never re-derives a figure β€” a partial
# period sends None and honestly gets no label.
out["delta_key"] = str(delta_key)
return out
def validation(checks, note=None):
"""The reconciliation panel, from a module's `validate()` output.
`actual` is the module's `a` β€” the number the page shows. `expected` is its `b` β€” the
INDEPENDENT aggregate it must tie to. The signed gap rides in `note` rather than being
recomputed client-side, because the module's own tolerance decided `ok` and a second
subtraction in the client could disagree with it.
"""
out = {"type": "validation", "key": "validation",
"checks": [{"name": c.get("check") or c.get("name") or "",
"ok": bool(c.get("ok")),
"actual": c.get("a"), "expected": c.get("b"),
"fmt": "money" if isinstance(c.get("a"), float) else "num",
"note": f"gap {c.get('gap')}"} for c in (checks or [])]}
if note:
out["note"] = note
return out
# ── drill descriptors: ONE grammar, three placements ─────────────────────────────────────────────
def decomp_drill(window_row, scope_type="period", scope_value=None, label=None):
"""A WINDOW + scope descriptor. Mirrors `ui/drawers._decomp_desc` key-for-key, so the eventual
resolver can serve both front-ends from one function β€” and accepts both row shapes the sales
module produces (`date_from`/`date_to` on the scorecard, `start`/`end` on a trend row)."""
return {"kind": "decomp", "label": label,
"scope_type": scope_type, "scope_value": scope_value,
"date_from": window_row.get("date_from") or window_row.get("start"),
"date_to": window_row.get("date_to") or window_row.get("end"),
"cmp_from": window_row.get("cmp_from"), "cmp_to": window_row.get("cmp_to")}
def entity_drill(kind, id_key, label_key=None):
"""Block-level: every row of this block opens the same kind of entity panel."""
if kind not in DRILL_KINDS:
raise ValueError(f"drill kind {kind!r} is not one of {DRILL_KINDS}")
out = {"kind": kind, "id_key": id_key}
if label_key:
out["label_key"] = label_key
return out
def row_drill(row_key="_drill"):
"""Block-level: each row carries its OWN descriptor under `row_key`."""
return {"kind": "decomp", "row_key": row_key}
def truncate(rows, limit):
"""`(rows[:limit], total)` β€” the ONLY sanctioned way to cap a list. Returning the total beside
the slice is what makes "showing N of M" possible; a bare `[:N]` is the defect."""
rows = list(rows)
return rows[:limit], len(rows)
# ── BU scope: the one line that could fail open ──────────────────────────────────────────────────
def resolve_bu(session: Session, bu):
"""The effective `team_id` for this request. `bu` NARROWS; it never authorises.
β›” THE RULE, AND WHY IT IS SHAPED THIS WAY. `perms.scope_team_id` derives scope from the user
RECORD precisely because a UI selector defaults to 'All' β†’ consolidated, and mirroring that
default on an API with no selector would BE the leak. So a query parameter cannot widen:
* absent / 'all' β†’ `scope_team_id(user)`, which is None ONLY when the user may see every BU
and is the PINNED team_id otherwise. A Royal-only user asking for 'all' gets 6, and 'all'
for them honestly means "everything you may see".
* an explicit id β†’ must be in `allowed_team_ids(user)`, else **403**. Never silently coerced
to their own BU: a coerced parameter hides a client bug behind data that looks right.
* anything else β†’ 400.
"""
raw = "" if bu is None else str(bu).strip().lower()
if raw in ("", "all"):
return perms.scope_team_id(session.user)
try:
want = int(raw)
except (TypeError, ValueError):
raise err(400, "bad_bu", "bu must be 'all' or a business-unit id")
if want not in perms.allowed_team_ids(session.user):
raise err(403, "bu_forbidden",
"your account does not have access to that business unit")
return want
def bu_control(session: Session, team_id):
"""The BU picker, built from the user's OWN permitted labels β€” which is exactly why `controls`
is assembled per request and never cached on a scope key."""
labels = perms.allowed_bu_labels(session.user)
options = []
for lab in labels:
if lab == "All":
options.append({"value": "all", "label": "All"})
continue
tid = ctxlib.BRANDS.get(lab)
if tid is not None:
options.append({"value": tid, "label": lab})
return {"key": "bu", "kind": "bu", "label": "Business Unit",
"value": "all" if team_id is None else team_id, "options": options}
def choice_control(key, label, value, options):
return {"key": key, "kind": "choice", "label": label, "value": value,
"options": [{"value": v, "label": l} for v, l in options]}
def bu_label(team_id):
"""'All Business Units' / 'Fisch' / 'Royal' β€” for prose, never for a permission decision."""
if team_id is None:
return "All Business Units"
for lab, tid in ctxlib.BRANDS.items():
if tid == team_id:
return lab
return str(team_id)
# ── the registry + the assembler ─────────────────────────────────────────────────────────────────
#: page key -> builder module. Wave 3 adds `'ar'` and `'procurement'` HERE and nowhere else β€” the
#: route, the grant wall, the BU resolution, the caching and the envelope are already written.
#: ⚠ The key must be the REGISTRY key, because it is what `session.require()` gates on.
#:
#: ⭐ DEBT D-52, DECIDED 2026-08-06 (wave 25) β€” **EMPTY IN PRODUCTION, ON PURPOSE, AND THE ROUTE
#: STAYS.** The register read this as "a route that can only 404", with "delete it" as one of
#: two exits. Measured before deciding, and it is not an orphan:
#: Β· `web/src/pages/pageApi.ts` fetches `${API_V1}/pages/{key}`, `PageSurface`/`PageView`
#: render the envelope, and `shell/Shell.tsx` mounts it β€” a live client vertical;
#: Β· `verify_api` section G drives this route end-to-end with `pages_sales` as an explicit
#: fixture (~350 lines: the `fmt` enums, the drill grammar, the BU wall, the cache-key
#: discipline that closed a cross-user leak);
#: Β· CLAUDE.md's conventions and `ARCHITECTURE.md` Β§7 both name it as THE way a dashboard page
#: ships β€” *"a page is a builder, not a surface"*.
#: Deleting it would remove a documented extension seam and that coverage, in exchange for
#: retiring a 404 that is already honest (`unknown_page`, with a sentence). And registering a
#: builder would resurrect Sales, which the owner DELETED in wave 16.
#: So the third exit: the emptiness is DECLARED and GATED (`verify_api` section W25-3) rather
#: than incidental, so nobody re-reads a bare `{}` as an accident again. ⚠ The genuinely wrong
#: thing D-52 found is separate and stands: `routes_keychain.py`'s `pausedNote` describes a
#: measure path that no longer exists. That file is in no session's fence this wave.
_BUILDERS = {}
#: The reason `_BUILDERS` is empty, in one machine-readable line. Not decoration: `verify_api`
#: asserts this and the emptiness TOGETHER, so registering a builder without retiring this note
#: goes red β€” which is the only way a "deliberately empty" claim can stay true.
BUILDERS_EMPTY_BECAUSE = (
"the Sales page was deleted by owner ruling in wave 16; no dashboard page is published "
"today. The route, the envelope and the grant wall stay β€” a new page ships by registering "
"a builder here (ARCHITECTURE.md Β§7), not by rebuilding this file."
)
def register(key, builder):
_BUILDERS[str(key)] = builder
def builder_for(key):
"""The builder for `key`, or None. A missing key is a 404 at the route β€” NOT a 403: an
unported page is a fact about our roadmap, not about this user's grants.
⚠ Wave 16 (owner item 7, R3/R11): `pages_sales` is UNREGISTERED β€” the lazy self-import
that used to live here is gone, so a production process holds ZERO builders and every
/pages/{key} is a 404 until the next surface (Collections / Procurement) registers.
The registry mechanism itself is untouched: a future builder self-registers on import,
and routes_pages.py should import it at module level so deploy_web.py's import walk can
see it (the wave-9 missing-module scar). pages_sales.py stays on disk as the template
and as verify_api section G's fixture β€” the gate imports it explicitly.
"""
return _BUILDERS.get(str(key or "").strip().lower())
def known_pages():
return sorted(_BUILDERS)
#: How long a scope's metrics stay warm. Matches `routes_customers._CACHE_TTL` β€” the underlying
#: pull is the same order of cost and a second TTL would be a second thing to reason about.
_CACHE_TTL = 900
#: The metrics cache is bounded for the reason every cache in this process is: an entry per
#: (page Γ— scope Γ— day) that is never evicted is a memory leak with a tenant-shaped growth curve.
_CACHE_MAX = 24
def _cached_metrics(session: Session, page_key, team_id, granularity, builder):
"""The SCOPE-shaped half, cached on the runtime (per tenant, LRU-bounded).
The key carries the DAY: every metric here is relative to "today" (YTD, WTD, the trailing 12
months), so an entry that outlives midnight serves yesterday's question with today's label.
"""
return _metrics_for(session.runtime, page_key, team_id, granularity, builder)
def _metrics_for(rt, page_key, team_id, granularity, builder):
"""Session-free half of `_cached_metrics` (the prewarm thread calls it too).
STALE-WHILE-REFRESH (scope_cache): the first `/pages/sales` per scope is genuinely slow (it
runs `sales.validate()`); after that, expiry serves the stale copy and refreshes off-thread
instead of parking a person on a 20-second rebuild.
"""
import scope_cache
today = time.strftime("%Y-%m-%d")
key = ("page", page_key, team_id, granularity, today)
def _build():
return builder.metrics(team_id=team_id, granularity=granularity)
def _evict():
# A `while`, not a one-shot slice: evicting a fixed batch leaves the map above its own
# bound whenever it is more than that batch over, which is a bound that does not bind.
# ⚠ This dict is SHARED with `routes_customers`'s pool entries (they key on `("pool", …)`,
# these on `("page", …)`), so either side's eviction can drop the other's entry. Harmless β€”
# a miss is a rebuild β€” and deliberately one bounded map per tenant rather than two.
while len(rt.pool_cache) > _CACHE_MAX:
rt.pool_cache.pop(min(rt.pool_cache, key=lambda k: rt.pool_cache[k][0]), None)
return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, _build, _evict)
def warm_default(rt):
"""Boot prewarm: every registered page's consolidated metrics at its default granularity β€”
the envelope the first visitor after a deploy actually asks for. main.py's thread only."""
for key in known_pages():
builder = builder_for(key)
if builder is not None:
_metrics_for(rt, key, None, builder.resolve_granularity(None), builder)
def build_envelope(session: Session, page_key, bu=None, period=None):
"""The Y1 envelope for `page_key`, scoped to this session. Raises the contracted errors."""
builder = builder_for(page_key)
if builder is None:
raise err(404, "unknown_page", f"no page data is published for {page_key!r}")
# The GRANT WALL, on the builder's registry key. 403, never an empty 200 β€” an empty page reads
# as "you have no data" and is how a permission bug hides in plain sight.
session.require(builder.MODULE)
team_id = resolve_bu(session, bu)
granularity = builder.resolve_granularity(period)
metrics = _cached_metrics(session, page_key, team_id, granularity, builder)
return {
"key": page_key,
"title": builder.TITLE,
"subtitle": builder.subtitle(team_id),
# UTC with an explicit Z. A naive local timestamp from a container whose TZ nobody set is
# indistinguishable from a wrong one.
"as_of": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(metrics["pulled_at"])),
"controls": [bu_control(session, team_id)] + builder.controls(granularity),
"blocks": builder.blocks(metrics, team_id, granularity),
}