| """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 |
|
|
| |
| |
| from deps import Session, err, perms |
|
|
| import core.context as ctxlib |
|
|
| |
| |
| |
| |
| |
| FMTS = ("money", "num", "pct", "int", "date") |
|
|
| |
| |
| VIZ_TYPES = ("text", "status", "currency", "int", "pct", "date") |
|
|
| |
| |
| |
| |
| MAX_BUCKETS = 12 |
|
|
| |
| |
| |
| |
| DRILL_KINDS = ("decomp", "customer", "sku", "rep", "bu") |
|
|
|
|
| def _fmt_ok(fmt, allowed, where): |
| if fmt is not None and fmt not in allowed: |
| |
| |
| raise ValueError(f"{where}: fmt {fmt!r} is not one of {allowed}") |
| return fmt |
|
|
|
|
| |
| 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 |
| 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" |
| 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: |
| |
| |
| |
| 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 |
|
|
|
|
| |
| 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) |
|
|
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _BUILDERS = {} |
|
|
| |
| |
| |
| 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) |
|
|
|
|
| |
| |
| _CACHE_TTL = 900 |
| |
| |
| _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(): |
| |
| |
| |
| |
| |
| 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}") |
| |
| |
| 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), |
| |
| |
| "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), |
| } |
|
|