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