File size: 22,291 Bytes
092334a c14ceee 092334a c9d432b 092334a c9d432b 092334a 618de96 092334a 618de96 092334a da5297e 092334a c9d432b 092334a c9d432b 092334a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 | """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),
}
|