| """aios_grid β embed the AIOS React/glide Airtable-style grid inside Streamlit.
|
|
|
| This is REUSABLE MODULE INFRASTRUCTURE: the same self-contained grid that runs as the
|
| standalone aios-web app is inlined into a single HTML file and hosted inside a Streamlit
|
| component. The React bundle reads its data from `window.__AIOS_GRID__` (an object
|
| `{fields, rows}`) that we inject into the page <head> before the app's module script runs
|
| β so there is NO backend and NO /api call in the Streamlit container; the browser only ever
|
| sees the derived JSON we hand it.
|
|
|
| Usage (from any page):
|
| import aios_grid
|
| aios_grid.render(aios_grid.rows_from_pool(pool_rows), aios_grid.FIELDS)
|
|
|
| Design notes:
|
| * ZERO app-internal imports (no `modules.*` / `core.*`). FIELDS is a literal and
|
| `rows_from_pool` takes already-built rows β so this helper is tenant/module-agnostic and
|
| sidesteps deploy_hf.py's import guard entirely.
|
| * The built HTML ships as `aios_grid_embed.html` in THIS directory (added to
|
| deploy_hf.py INCLUDE). Build it with `npm run build:embed` in aios-web/web, then copy
|
| dist-embed/index.html -> platform/aios_grid_embed.html (see build_embed.py).
|
| For LOCAL dev before that copy, we fall back to reading dist-embed/index.html directly.
|
| * The preferred host is a Streamlit Components v1 bridge. It sends data/view/schema args
|
| into the React app and returns guarded events for persistence in the tenant store. The old
|
| injected-HTML path remains a read/local-write fallback when component assets are absent.
|
| """
|
| import json
|
| import re
|
| from pathlib import Path
|
|
|
| _HERE = Path(__file__).resolve().parent
|
|
|
|
|
|
|
|
|
| _EMBED_CANDIDATES = [
|
| _HERE / "aios_grid_embed.html",
|
| _HERE.parent / "aios-web" / "web" / "dist-embed" / "index.html",
|
| ]
|
| _COMPONENT_CANDIDATES = [
|
| _HERE / "aios_grid_component",
|
| _HERE.parent / "aios-web" / "web" / "dist-embed",
|
| ]
|
| _DECLARED_COMPONENTS = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _FIELDS_PATH = _HERE / "aios_grid_fields.json"
|
|
|
|
|
| def _load_fields():
|
| if not _FIELDS_PATH.is_file():
|
| raise FileNotFoundError(
|
| f"aios_grid: canonical field contract missing at {_FIELDS_PATH}. It is the single "
|
| "source of truth for the grid schema and MUST ship (deploy_hf.py INCLUDE lists it)."
|
| )
|
| doc = json.loads(_FIELDS_PATH.read_text(encoding="utf-8"))
|
| fields = doc.get("fields") if isinstance(doc, dict) else doc
|
| if not isinstance(fields, list) or not fields:
|
| raise ValueError(f"aios_grid: {_FIELDS_PATH} has no 'fields' list.")
|
| return fields
|
|
|
|
|
| FIELDS = _load_fields()
|
|
|
|
|
| def product_fields():
|
| """The PRODUCT table's canonical contract (wave 15 C-TOPIC, `product_data` key in the same
|
| JSON). A separate accessor rather than a second module constant so the one file-read and the
|
| one failure mode stay shared with `FIELDS`."""
|
| doc = json.loads(_FIELDS_PATH.read_text(encoding="utf-8"))
|
| fields = (doc.get("product_data") or {}).get("fields")
|
| if not isinstance(fields, list) or not fields:
|
| raise ValueError(f"aios_grid: {_FIELDS_PATH} has no product_data.fields list.")
|
| return fields
|
|
|
|
|
|
|
|
|
|
|
| def _round(v):
|
| return round(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
|
| "checkbox", "phone", "email", "url", "rating", "created_time", "formula",
|
| "automation", "image", "json", "link", "rollup", "code"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| CODE_LANGUAGES = {"plain", "json", "sql", "python", "javascript", "typescript",
|
| "html", "css", "markdown", "yaml", "xml", "shell"}
|
|
|
|
|
| def _clean_code(raw):
|
| """The `code` field's config bag -> `{'language': ...}`, or None.
|
|
|
| Deliberately OPTIONAL rather than required (the `automation` posture, not `link`'s): a code
|
| column with no declared language is a legitimate state β it stores and highlights as plain
|
| text β so refusing the FIELD over a missing bag would block the ordinary create path. An
|
| unknown language falls back to `plain` rather than refusing, because the value is a rendering
|
| hint: dropping the user's column to punish a typo in a highlighter name would be the
|
| disproportionate half of the fail-closed rule.
|
|
|
| β `plain` RETURNS NONE, and that is what makes the control reversible rather than one-way.
|
| Absent already means plain, so storing `{'language': 'plain'}` would be the default wearing a
|
| second name (the `kanbanClamp` law). But the patch path resolves an OMITTED key to the
|
| previous value β so if plain were merely omitted by the client, switching a column back from
|
| SQL to Plain text would keep storing SQL and read as a control that does not save. Sending
|
| the bag explicitly and having it evaluate to None here means: omit = keep, plain = clear.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| lang = str(raw.get('language') or 'plain').strip().lower()
|
| if lang not in CODE_LANGUAGES or lang == 'plain':
|
| return None
|
| return {'language': lang}
|
|
|
|
|
|
|
|
|
| READONLY_CUSTOM_TYPES = {"created_time", "formula"}
|
| MAX_FIELD_OPTIONS = 50
|
| MAX_FORMULA_LEN = 500
|
|
|
| RATING_MAX_DEFAULT, RATING_MAX_MIN, RATING_MAX_MAX = 5, 2, 10
|
|
|
|
|
|
|
|
|
|
|
|
|
| MEASURE_FIELD_PREFIX = "measure_"
|
|
|
| MEASURE_FIELD_TYPES = {"currency", "int", "pct"}
|
|
|
|
|
| def _clean_options(raw):
|
| """Choices for a `select`: strings, trimmed, de-duplicated case-insensitively, capped.
|
| Mirrors types.ts parseOptions β a choice list that means one thing in the picker and
|
| another in the filter dropdown is a column with two vocabularies."""
|
| out, seen = [], set()
|
| for v in list(raw or [])[:MAX_FIELD_OPTIONS * 2]:
|
| if not isinstance(v, (str, int, float)) or isinstance(v, bool):
|
| continue
|
| s = str(v).strip()[:120]
|
| if not s or s.lower() in seen:
|
| continue
|
| seen.add(s.lower())
|
| out.append(s)
|
| return out[:MAX_FIELD_OPTIONS]
|
|
|
|
|
| def _clean_option_colors(raw, options):
|
| """Choice-label -> #RRGGBB, limited to the field's canonical option vocabulary."""
|
| if not isinstance(raw, dict):
|
| return {}
|
| supplied = {}
|
| for label, color in raw.items():
|
| if not isinstance(label, str) or not isinstance(color, str):
|
| continue
|
| clean = color.strip().upper()
|
| if re.fullmatch(r"#[0-9A-F]{6}", clean):
|
| supplied[label.strip().lower()] = clean
|
| out = {}
|
| for option in options or []:
|
| color = supplied.get(str(option).strip().lower())
|
| if color:
|
| out[str(option)] = color
|
| return out
|
|
|
|
|
| def _choice_appearance(raw, options):
|
| """Validated select-family appearance. Absent colour toggle means legacy-on."""
|
| if not isinstance(raw, dict):
|
| return {}
|
| out = {}
|
| if isinstance(raw.get("colorCodeOptions"), bool):
|
| out["colorCodeOptions"] = raw["colorCodeOptions"]
|
| colors = _clean_option_colors(raw.get("optionColors"), options)
|
| if colors:
|
| out["optionColors"] = colors
|
| return out
|
|
|
|
|
| def _clean_rating_max(raw):
|
| """A rating's star count, bounded. Anything unusable is the default, not a refusal β the
|
| field still holds its 1..max integers either way."""
|
| try:
|
| return max(RATING_MAX_MIN, min(int(raw), RATING_MAX_MAX))
|
| except (TypeError, ValueError):
|
| return RATING_MAX_DEFAULT
|
|
|
|
|
|
|
|
|
| _NUMBER_FORMAT_TYPES = {"int", "currency", "pct", "formula"}
|
|
|
|
|
| def _clean_format(raw, ftype):
|
| """Per-type DISPLAY format (wave-5 item 10), fail-closed: unknown keys are DROPPED, wrong
|
| types return None (the property is simply absent). Rendering-only β a format can change how
|
| a value reads, never what it is, which is why this needs no parity gate of its own."""
|
| if not isinstance(raw, dict):
|
| return None
|
| out = {}
|
| if ftype in _NUMBER_FORMAT_TYPES:
|
| if isinstance(raw.get("thousands"), bool):
|
| out["thousands"] = raw["thousands"]
|
| if raw.get("decimals") is not None:
|
| try:
|
| d = int(raw["decimals"])
|
| except (TypeError, ValueError):
|
| d = None
|
| if d is not None and 0 <= d <= 4:
|
| out["decimals"] = d
|
| if isinstance(raw.get("abbrev"), bool):
|
| out["abbrev"] = raw["abbrev"]
|
| elif ftype in ("date", "created_time"):
|
| if isinstance(raw.get("time"), bool):
|
| out["time"] = raw["time"]
|
| if raw.get("tz") in ("local", "utc"):
|
| out["tz"] = raw["tz"]
|
| return out or None
|
|
|
|
|
| def _clean_permissions(raw):
|
| """`{edit: 'everyone' | 'creator' | 'admins'}` or None (wave-5 item 1). WHO may set it is
|
| the host handler's business (creator/admin, enforced fail-closed there); this validates
|
| only the shape, like every other property here."""
|
| if isinstance(raw, dict) and raw.get("edit") in ("everyone", "creator", "admins"):
|
| return {"edit": raw["edit"]}
|
| return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _FORMULA_CHARS = re.compile(r"^[\w\s{}()+\-*/.,<>=!^&\"]*$")
|
| _FORMULA_REF = re.compile(r"\{([^{}]*)\}")
|
|
|
|
|
| def _quotes_balanced(s):
|
| """An even number of `"` β the structural half of string support.
|
|
|
| Sound because the engine's own escape is Excel's: `""` inside a string is one quote, and it
|
| contributes TWO characters. So a well-formed expression always has an even count and an
|
| unterminated string always has an odd one. What a balanced pair MEANS is the engine's
|
| business, exactly as with parentheses.
|
| """
|
| return s.count('"') % 2 == 0
|
|
|
|
|
|
|
|
|
| AUTOMATION_KINDS = {"instagram_profile"}
|
| AUTOMATION_SOURCES = {"record_url_field", "self"}
|
| MAX_AUTOMATION_SETTINGS = 12
|
|
|
|
|
| def _clean_automation(raw, valid_keys=None, flow_ids=None):
|
| """Validate an `automation` config bag. Returns the clean dict, or None when there is
|
| nothing valid to store (the column then renders as unconfigured β never invented).
|
|
|
| β WAVE 22 (contract C8, owner item 5) β NO FIELD WITHOUT A FLOW. With `flow_ids` given
|
| (the WRITE path: grid_events / user_tables pass the tenant's automation-definition ids),
|
| the bag MUST carry a `flowId` naming one of them β absent or naming a deleted flow is
|
| refused, the same fail-closed direction as a formula ref that names no field. With
|
| `flow_ids=None` (the READ path, `fields_from_workspace`) the law is NOT applied: a column
|
| stored before the law must keep projecting β enforcement at read time would vaporise it,
|
| which is the `_clean_formula` write/read split exactly.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| kind = str(raw.get('kind') or '').strip()
|
| if kind not in AUTOMATION_KINDS:
|
| return None
|
| source = str(raw.get('source') or 'record_url_field').strip()
|
| if source not in AUTOMATION_SOURCES:
|
| source = 'record_url_field'
|
| out = {'kind': kind, 'source': source}
|
| flow = str(raw.get('flowId') or '').strip()[:40]
|
| if flow_ids is not None and (not flow or flow not in flow_ids):
|
| return None
|
| if flow:
|
| out['flowId'] = flow
|
| url_field = str(raw.get('urlField') or '').strip()[:80]
|
|
|
| if url_field and (valid_keys is None or url_field in valid_keys):
|
| out['urlField'] = url_field
|
| settings = {}
|
| for k, v in list((raw.get('settings') or {}).items())[:MAX_AUTOMATION_SETTINGS]:
|
| if isinstance(v, bool) or isinstance(v, (int, float)):
|
| settings[str(k)[:40]] = v
|
| elif isinstance(v, str):
|
| settings[str(k)[:40]] = v[:200]
|
| if settings:
|
| out['settings'] = settings
|
| return out
|
|
|
|
|
| def _clean_formula(raw, valid_keys=None):
|
| """STRUCTURAL passthrough for a formula field's expression (wave-5 item 9).
|
|
|
| Meaning is NOT checked here: the CLIENT engine owns the grammar (arithmetic over `{field}`
|
| refs, ABS/ROUND/MIN/MAX/IF), and any evaluation error degrades to a BLANK cell β never a
|
| wrong number. That is the `_clean_window` split one stratum up, and deliberately NOT a
|
| Python mirror of the grammar: a second engine is the filter_sql-class drift risk.
|
| Structure IS checked β charset, length, balanced parens, BALANCED QUOTES, well-formed
|
| non-empty `{refs}` β and at WRITE time (`valid_keys` given) every ref must name a field this
|
| table has, fail closed. At READ time refs are left alone: a referenced field deleted later
|
| must blank the CELLS, not vaporise the column.
|
|
|
| β A `{ref}` INSIDE A STRING LITERAL is still checked against `valid_keys` at write time, so
|
| `IF(x, "see {notafield}", "")` is refused. That is a false rejection and it is the
|
| fail-closed direction: the alternative is teaching this function where strings begin and
|
| end, which is the second grammar the paragraph above refuses to write.
|
| """
|
| if not isinstance(raw, str):
|
| return None
|
| s = raw.strip()
|
| if not s or len(s) > MAX_FORMULA_LEN or not _FORMULA_CHARS.match(s):
|
| return None
|
| depth = 0
|
| for ch in s:
|
| if ch == "(":
|
| depth += 1
|
| elif ch == ")":
|
| depth -= 1
|
| if depth < 0:
|
| return None
|
| if depth:
|
| return None
|
| if not _quotes_balanced(s):
|
| return None
|
| refs = _FORMULA_REF.findall(s)
|
| leftover = _FORMULA_REF.sub("", s)
|
| if "{" in leftover or "}" in leftover:
|
| return None
|
| if any(not r.strip() for r in refs):
|
| return None
|
| if valid_keys is not None and any(r not in valid_keys for r in refs):
|
| return None
|
| return s
|
|
|
|
|
| def _field_extras(saved, ftype):
|
| """createdBy / permissions / format / scope β the validated passthrough the created strata
|
| share (wave 5). `createdBy` is only ever WRITTEN host-side (the handler stamps it); here it
|
| is carried so the client can gate its menus and the handler can enforce against it.
|
| `scope` (wave-6 item 9): 'cohort' marks a field created as cohort-specific β the handler
|
| stamps it at create (cohort page only) and preserves it like createdBy; carried here so the
|
| client can label the field, filtered OUT of other pages by fields_from_workspace."""
|
| out = {}
|
| who = saved.get("createdBy")
|
| if isinstance(who, str) and who.strip():
|
| out["createdBy"] = who.strip()[:80]
|
| perms = _clean_permissions(saved.get("permissions"))
|
| if perms:
|
| out["permissions"] = perms
|
| fmt = _clean_format(saved.get("format"), ftype)
|
| if fmt:
|
| out["format"] = fmt
|
| if saved.get("scope") == "cohort":
|
| out["scope"] = "cohort"
|
| corrected_from = saved.get("labelCorrectedFrom")
|
| correction_id = saved.get("labelCorrectionId")
|
| if (isinstance(corrected_from, str) and corrected_from.strip()
|
| and isinstance(correction_id, str) and correction_id.strip()):
|
| out["labelCorrectedFrom"] = corrected_from.strip()[:120]
|
| out["labelCorrectionId"] = correction_id.strip()[:180]
|
| return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| COHORT_COLUMN = 'cohorts'
|
|
|
|
|
| def cohort_field(label='Locked views'):
|
| """The derived membership column's descriptor.
|
|
|
| β WAVE 17 item 14 / C-STR β THE LABEL AND THE NOTE SPEAK THE NEW VOCABULARY; THE KEY DOES
|
| NOT. `COHORT_COLUMN` is still `'cohorts'` and the function is still `cohort_field`, because
|
| every stored view that shows or groups by this column names it by KEY, and every gate in
|
| two runtimes names the function. The owner renamed a CONCEPT ("we should stop calling it
|
| Cohort, but locked instead"), which is a change to what a reader sees β renaming the
|
| identifiers would break saved views to change a word nobody reads.
|
|
|
| `source: 'odoo'` is doing ONE job here and it is not provenance: the client keys editability
|
| off `source == 'overlay'`, and `_cl_handle_grid_event` accepts cell writes only for overlay
|
| keys. So 'odoo' is what makes this column READ-ONLY at both ends. `derived: True` is what
|
| stops the column menu calling it a "source field" on that basis.
|
|
|
| β `filterable: False`, and the replacement is the `Where [Cohort] [β¦]` CONDITION, not another
|
| column. Text ops over a joined string would ALMOST work and disagree at the edges β `contains
|
| "VIP"` also matches a cohort called "VIP club" β and a filter that is nearly right is worse
|
| than one that is absent.
|
| """
|
| return {
|
| 'key': COHORT_COLUMN, 'label': label, 'type': 'text', 'source': 'odoo',
|
| 'default': False, 'filterable': False, 'derived': True, 'multi': True,
|
| 'note': 'The locked views this customer is in, newest first. A locked view holds a SET, '
|
| 'so grouping by this column puts a customer under EVERY view they are in β the '
|
| 'group counts therefore add up to more than the record count, which stays the '
|
| 'number of distinct customers. Read-only: membership changes only by adding or '
|
| 'removing customers on the locked view itself.',
|
| }
|
|
|
|
|
| def cohort_cells(cohorts, allowed_pids=None):
|
| """`{pid: {'cohorts': 'Q3 calls, Lost'}}` from `[{id,name,pids}, ...]`.
|
|
|
| Built per render from the caller's OWN cohorts and handed to `rows_from_pool` as `derived`,
|
| never merged into the cached pool rows β those are shared across users, and stamping one
|
| user's cohorts onto them would leak the membership to everybody else on the next render.
|
| """
|
| cells = {}
|
| for c in cohorts or []:
|
|
|
|
|
|
|
|
|
|
|
|
|
| name = str(c.get('name') or c.get('id') or '').replace(',', ' ').strip()
|
| for pid in c.get('pids') or ():
|
| if allowed_pids is not None and pid not in allowed_pids:
|
| continue
|
| cells.setdefault(pid, []).append(name)
|
| return {pid: {COHORT_COLUMN: ', '.join(names)} for pid, names in cells.items()}
|
|
|
|
|
| def clean_measure_field(raw, offered):
|
| """Validate one UNTRUSTED formula-measure field (owner item 7) against the caller's OFFER.
|
|
|
| `offered` is `{measure key: {label, type}}` from `measure_filter.measure_fields(team_id)` β
|
| the same admission the condition builder uses, so a field can only name a measure this
|
| caller could also filter by. Returns the canonical stored shape, or None (fail closed).
|
|
|
| `source:'odoo'` + `derived:True` is the cohort column's read-only mechanism, reused:
|
| the client keys editability off `source == 'overlay'` and the host accepts cell writes only
|
| for overlay keys, so a measure column cannot be typed into at either end. `filterable:False`
|
| because the REPLACEMENT is the measure CONDITION with the same measure and window β the
|
| governed, gate-proved path (CG-8/CG-12) β not text ops over a derived cell.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| key = str(raw.get("key") or "")
|
| if not key.startswith(MEASURE_FIELD_PREFIX) or len(key) > 80:
|
| return None
|
| spec = raw.get("measure")
|
| if not isinstance(spec, dict):
|
| return None
|
| m = (offered or {}).get(spec.get("key"))
|
| if not m:
|
| return None
|
| window = _clean_window(spec.get("window"))
|
| if window is None:
|
| return None
|
| mtype = m.get("type") if m.get("type") in MEASURE_FIELD_TYPES else "currency"
|
| return {
|
| "key": key,
|
| "label": str(raw.get("label") or m.get("label") or "Measure")[:120],
|
| "type": mtype,
|
| "source": "odoo",
|
| "default": True,
|
| "custom": True,
|
| "derived": True,
|
| "filterable": False,
|
| "agg": "sum" if mtype in ("currency", "int") else None,
|
| "note": str(raw.get("note") or "")[:2000],
|
| "measure": {"key": str(spec.get("key"))[:80], "window": window},
|
| }
|
|
|
|
|
| def measure_fields_of(fields):
|
| """The formula-measure columns among `fields` β the ones whose values the host must compute
|
| per render (see `rows_from_pool`'s `derived`)."""
|
| return [f for f in fields or [] if isinstance(f.get("measure"), dict)]
|
|
|
|
|
| def fields_from_workspace(workspace=None, cohorts=False, scope_key=None, fields_base=None):
|
| """Overlay persisted notes/custom fields onto the immutable source-field contract.
|
|
|
| `cohorts=True` appends the derived cohort column β see `cohort_field`. Appended LAST and
|
| `default: False`, so it never displaces a column somebody already reads; the Fields menu is
|
| where you turn it on.
|
|
|
| Three saved strata pass through: notes on base fields, `custom_` overlay fields (editable),
|
| and `measure_` formula-measure fields (read-only, host-computed β owner item 7). A measure
|
| field was validated against the caller's measure OFFER when it was written
|
| (`clean_measure_field`); here only its SHAPE is re-checked, because this module has zero
|
| app-internal imports and cannot know the offer. A measure that has since become
|
| unanswerable (a BU scope on a company-level measure) degrades to a BLANK column at value
|
| time, never to an error.
|
|
|
| `scope_key` (wave-6 item 9) names the PAGE doing the asking ('customer' / 'cohort'). A
|
| saved def carrying `scope` is emitted only when it matches β so a cohort-specific field
|
| never appears on the Customer table. FAIL-CLOSED: a caller that passes no scope_key sees
|
| only unscoped (global) fields; base contract fields are never scoped.
|
|
|
| `fields_base` (wave 16 C-TOPIC) β the canonical contract to overlay onto. Absent = the
|
| CUSTOMER contract (`FIELDS`), byte-identical to before the parameter existed; the product
|
| surface passes `product_fields()`. The workspace dict a caller hands in must already be the
|
| matching table object's bucket β this function cannot tell a customer overlay from a
|
| product one, which is exactly why the buckets are separate stores.
|
| """
|
| saved = dict((workspace or {}).get("fields") or {})
|
| out = []
|
| base_fields = fields_base if fields_base is not None else FIELDS
|
| base_keys = {field["key"] for field in base_fields}
|
| for base in base_fields:
|
| meta = saved.get(base["key"]) or {}
|
| field = dict(base)
|
| if isinstance(meta.get("note"), str):
|
| field["note"] = meta["note"][:2000]
|
|
|
|
|
| fmt = _clean_format(meta.get("format"), base.get("type"))
|
| if fmt:
|
| field["format"] = fmt
|
|
|
|
|
|
|
|
|
| if meta.get("agg") in FIELD_AGGS:
|
| field["agg"] = meta["agg"]
|
|
|
|
|
|
|
|
|
|
|
| if base.get("preset") and isinstance(base.get("measure"), dict):
|
| saved_measure = meta.get("measure") if isinstance(meta.get("measure"), dict) else {}
|
| window = _clean_window(saved_measure.get("window"))
|
| if window is not None:
|
| field["measure"] = {"key": base["measure"]["key"], "window": window}
|
| if isinstance(meta.get("label"), str) and meta["label"].strip():
|
| field["label"] = meta["label"][:120]
|
| out.append(field)
|
| for key, field in saved.items():
|
| if key in base_keys or not isinstance(field, dict):
|
| continue
|
| if field.get("scope") and field.get("scope") != scope_key:
|
| continue
|
| if (str(key).startswith(MEASURE_FIELD_PREFIX)
|
| and isinstance(field.get("measure"), dict)):
|
| window = _clean_window(field["measure"].get("window"))
|
| mkey = str(field["measure"].get("key") or "")
|
| if window is None or not mkey:
|
| continue
|
| mtype = field.get("type") if field.get("type") in MEASURE_FIELD_TYPES else "currency"
|
| out.append({
|
| "key": str(key)[:80],
|
| "label": str(field.get("label") or "Measure")[:120],
|
| "type": mtype,
|
| "source": "odoo",
|
| "default": bool(field.get("default", True)),
|
| "custom": True,
|
| "derived": True,
|
| "filterable": False,
|
| "agg": "sum" if mtype in ("currency", "int") else None,
|
| "note": str(field.get("note") or "")[:2000],
|
| "measure": {"key": mkey[:80], "window": window},
|
| **_field_extras(field, mtype),
|
| })
|
| continue
|
| if not str(key).startswith("custom_"):
|
| continue
|
| ftype = field.get("type")
|
| if ftype not in CUSTOM_FIELD_TYPES:
|
| continue
|
| if ftype in READONLY_CUSTOM_TYPES:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| entry = {
|
| "key": str(key)[:80],
|
| "label": str(field.get("label") or "Untitled")[:120],
|
| "type": ftype,
|
| "source": "odoo",
|
| "derived": True,
|
| "filterable": True,
|
| "default": bool(field.get("default", True)),
|
| "custom": True,
|
| "note": str(field.get("note") or "")[:2000],
|
| **_field_extras(field, ftype),
|
| }
|
| if ftype == "formula":
|
| formula = _clean_formula(field.get("formula"))
|
| if formula is None:
|
| continue
|
| entry["formula"] = formula
|
| out.append(entry)
|
| continue
|
| if field.get("source") != "overlay":
|
| continue
|
| options = (_clean_options(field.get("options"))
|
| if ftype in ("select", "multiselect") else [])
|
| if ftype in ("select", "multiselect") and not options:
|
|
|
|
|
|
|
| ftype = "text"
|
| out.append({
|
| "key": str(key)[:80],
|
| "label": str(field.get("label") or "Untitled")[:120],
|
| "type": ftype,
|
| "source": "overlay",
|
| "default": bool(field.get("default", True)),
|
| "custom": True,
|
|
|
|
|
|
|
| "agg": field.get("agg") if field.get("agg") in FIELD_AGGS else None,
|
| "note": str(field.get("note") or "")[:2000],
|
| **({"options": options} if ftype in ("select", "multiselect") else {}),
|
| **(_choice_appearance(field, options)
|
| if ftype in ("select", "multiselect") else {}),
|
|
|
|
|
| **({"multi": True} if ftype == "multiselect" else {}),
|
| **({"max": _clean_rating_max(field.get("max"))} if ftype == "rating" else {}),
|
| **({"automation": _clean_automation(field.get("automation"))}
|
| if ftype == "automation" and _clean_automation(field.get("automation")) else {}),
|
|
|
|
|
|
|
| **({"code": _clean_code(field.get("code"))}
|
| if ftype == "code" and _clean_code(field.get("code")) else {}),
|
| **_field_extras(field, ftype),
|
| })
|
| if cohorts and not any(f.get('key') == COHORT_COLUMN for f in out):
|
|
|
|
|
|
|
|
|
|
|
|
|
| out.append(cohort_field())
|
| return out
|
|
|
|
|
| def rows_from_pool(pool_rows, fields=None, overlays=None, derived=None):
|
| """Map customer_data.pool() dicts -> the API row shape the grid expects:
|
| pid + each Odoo field (numeric fields rounded, text/date passed through) + the
|
| persisted external overlay. Mirrors the standalone API payload contract.
|
|
|
| `derived` is `{pid: {key: value}}` for columns the HOST computes per render rather than
|
| reads off the pool row β today just the cohort column. A separate argument from `overlays`
|
| on purpose: `overlays` is the PERSISTED user stratum, and putting a value there that is
|
| never written back would make the dict mean two things.
|
| """
|
| fields = fields or FIELDS
|
| odoo_fields = [field for field in fields if field["source"] == "odoo"]
|
| overlay_fields = [field for field in fields if field["source"] == "overlay"]
|
| derived_keys = [field["key"] for field in fields if field.get("derived")]
|
| overlays = overlays or {}
|
| derived = derived or {}
|
| out = []
|
| for r in pool_rows:
|
| pid = r.get("pid")
|
|
|
|
|
|
|
|
|
| row = {"pid": pid, "_created": r.get("_created") or "",
|
| "lat": r.get("lat"), "lon": r.get("lon")}
|
| for field in odoo_fields:
|
| k = field["key"]
|
| if field.get("derived"):
|
| continue
|
| v = r.get(k)
|
| row[k] = v if field["type"] in {"text", "status", "date"} else _round(v)
|
| saved = overlays.get(str(pid), {}) or {}
|
| for field in overlay_fields:
|
| row[field["key"]] = saved.get(field["key"], "")
|
| got = derived.get(pid) or {}
|
| for k in derived_keys:
|
|
|
|
|
| row[k] = got.get(k, "")
|
| out.append(row)
|
| return out
|
|
|
|
|
|
|
|
|
|
|
| FILTER_OPS = {'contains', 'doesNotContain', 'eq', 'neq', 'isEmpty', 'isNotEmpty',
|
| 'gt', 'gte', 'lt', 'lte', 'between', 'within',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 'topN', 'bottomN', 'inTopPct', 'inBottomPct',
|
| 'aboveAvg', 'belowAvg', 'inQuartile', 'inDecile'}
|
|
|
|
|
|
|
| COHORT_FIELD = '__cohort__'
|
|
|
|
|
|
|
|
|
| COHORT_OPS = {'anyOf', 'allOf', 'noneOf'}
|
|
|
|
|
|
|
| COHORT_OP_ALIASES = {'eq': 'anyOf', 'neq': 'noneOf'}
|
|
|
| MAX_COHORT_IDS = 20
|
|
|
|
|
| def parse_cohort_ids(value):
|
| """The cohorts a leaf names, parsed out of `value`. Mirrors types.ts `cohortIds()`.
|
|
|
| Comma-separated in one string because `FilterRule.value` is what all four layers persist and
|
| round-trip, and a one-element list is byte-identical to what the single-cohort leaf already
|
| stored β so every shipped view parses with no migration. Safe because a cohort id is built
|
| from `[a-z0-9_]` only (modules/cohort.new_id), so a comma cannot occur inside one.
|
| """
|
| out = []
|
| for raw in ('' if value is None else str(value)).split(','):
|
| cid = raw.strip()[:120]
|
| if not cid or cid in out:
|
| continue
|
| out.append(cid)
|
| if len(out) >= MAX_COHORT_IDS:
|
| break
|
| return out
|
|
|
|
|
| MAX_FILTER_DEPTH = 3
|
| MAX_FILTER_NODES = 100
|
| MAX_FILTER_SIBLINGS = 50
|
|
|
|
|
|
|
|
|
|
|
|
|
| WINDOW_MAX_N = 3650
|
|
|
|
|
| def _clean_window(raw):
|
| """Structural passthrough for a measure condition's `{kind, n?, from?, to?}` window.
|
|
|
| Meaning is NOT checked here: an unrecognised `kind` survives this function and is REFUSED by
|
| `harness.measure_filter.resolve_rule`, the layer that owns the vocabulary. Splitting it this
|
| way keeps the grid module reusable and keeps one definition of what "last quarter" means.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| kind = raw.get('kind')
|
| if not isinstance(kind, str) or not kind or len(kind) > 40:
|
| return None
|
| out = {'kind': kind}
|
| if raw.get('n') is not None:
|
| try:
|
| out['n'] = max(1, min(int(raw['n']), WINDOW_MAX_N))
|
| except (TypeError, ValueError):
|
| return None
|
| for side in ('from', 'to'):
|
| if raw.get(side) not in (None, ''):
|
| out[side] = str(raw[side])[:32]
|
| return out
|
|
|
|
|
| def _clean_rhs(raw, valid_keys):
|
| """CG-9 β validate `{kind, colId, window?}`, the "compare against another attribute" side.
|
|
|
| SHAPE and KEY only: `colId` must be something this table has (the caller widens `valid_keys`
|
| with the measure keys, exactly as it does for the left side), and a measure rhs must carry a
|
| window. What the window MEANS is `harness/windows.py`'s business, same split as `_clean_window`.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| kind = raw.get('kind')
|
| if kind not in ('field', 'measure', 'stat'):
|
| return None
|
| if kind == 'stat':
|
|
|
|
|
|
|
| stat = raw.get('stat')
|
| if not isinstance(stat, str) or not stat or len(stat) > 24:
|
| return None
|
| return {'kind': 'stat', 'stat': stat}
|
| col = raw.get('colId')
|
| if col not in valid_keys:
|
| return None
|
| out = {'kind': kind, 'colId': col}
|
| if kind == 'measure':
|
| window = _clean_window(raw.get('window'))
|
| if window is None:
|
| return None
|
| out['window'] = window
|
| return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
|
| 'timeseries', 'catalog', 'swipe', 'form'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| FORM_ACCESS = ('public', 'emails')
|
| MAX_FORM_FIELDS = 60
|
| MAX_FORM_EMAILS = 200
|
| MAX_FORM_TITLE, MAX_FORM_DESC, MAX_FORM_SUBMIT = 120, 1000, 60
|
| MAX_FORM_EMAIL = 254
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| FORM_FIELD_TYPES = ('text', 'select', 'multiselect', 'int', 'currency', 'pct', 'date',
|
| 'checkbox', 'phone', 'email', 'url', 'rating')
|
|
|
|
|
|
|
|
|
| _FORM_EMAIL = re.compile(r'^[^@\s,;]+@[^@\s,;]+\.[^@\s,;]+$')
|
|
|
|
|
| def _clean_form(raw, valid_keys):
|
| """One form spec, fail-closed. Returns None when nothing is configured.
|
|
|
| β FIELD ORDER IS THE FORM'S OWN and is preserved here, not re-derived from the schema: the
|
| builder let somebody arrange these questions, and sorting them by column order would silently
|
| rearrange a live form every time a column was added (`_public_form` states the same rule from
|
| the serving end).
|
|
|
| β PARTIAL-DROP, not whole-key drop, and the asymmetry against `swipe` above is deliberate. A
|
| swipe binding is one three-part machine: two of its parts is not a degraded deck, it is a deck
|
| that can never write. A form is a LIST of questions β losing the column behind question three
|
| costs the asker question three, and taking the whole form away because one field was deleted
|
| would be a far larger loss than the one that happened.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| fields, seen = [], set()
|
| for k in (raw.get('fields') or [])[:MAX_FORM_FIELDS]:
|
| if k in valid_keys and k not in seen:
|
| seen.add(k)
|
| fields.append(k)
|
| out = {}
|
| if fields:
|
| out['fields'] = fields
|
|
|
|
|
| req = [k for k in dict.fromkeys(raw.get('required') or []) if k in seen]
|
| if req:
|
| out['required'] = req
|
| for key, cap in (('title', MAX_FORM_TITLE), ('desc', MAX_FORM_DESC),
|
| ('submitLabel', MAX_FORM_SUBMIT)):
|
| text = str(raw.get(key) or '').strip()[:cap]
|
| if text:
|
| out[key] = text
|
|
|
|
|
|
|
| if raw.get('access') == 'emails':
|
| out['access'] = 'emails'
|
| emails = []
|
| for e in (raw.get('emails') or [])[:MAX_FORM_EMAILS]:
|
| e = str(e or '').strip().lower()[:MAX_FORM_EMAIL]
|
| if _FORM_EMAIL.match(e) and e not in emails:
|
| emails.append(e)
|
|
|
|
|
| if emails:
|
| out['emails'] = emails
|
| return out or None
|
|
|
|
|
|
|
|
|
|
|
|
|
| MAX_SWIPE_OPTION = 120
|
|
|
|
|
|
|
|
|
|
|
| TS_BUCKETS = {'week', 'month', 'quarter', 'year'}
|
| TS_MAX_LAST_N = 120
|
| TS_MAX_FIELDS = 12
|
| MAX_CALENDAR_METRICS = 4
|
| _ISO_DAY = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
|
|
|
|
|
|
|
|
|
|
| MAX_CATALOGS = 12
|
| MAX_CATALOG_PAGES = 40
|
| MAX_CATALOG_CODES = 500
|
| CATALOG_PAPERS = {'letter', 'a4', 'tabloid'}
|
| CATALOG_ORIENTATIONS = {'portrait', 'landscape'}
|
| CATALOG_QUALITIES = {'web', 'print'}
|
| CATALOG_PAGE_KINDS = {'cover', 'intro', 'section', 'gallery'}
|
| CATALOG_COLS = (2, 3, 4)
|
| CATALOG_ID_MAX, CATALOG_NAME_MAX = 40, 80
|
| CATALOG_TITLE_MAX, CATALOG_BODY_MAX, CATALOG_CODE_MAX = 120, 2000, 60
|
| _HEX6 = re.compile(r'^#[0-9A-Fa-f]{6}$')
|
|
|
|
|
|
|
| TS_DELTA_KINDS = ('abs', 'pct', 'yoy', 'ytd')
|
| TS_MAX_CUSTOM_ROWS = 12
|
| TS_MAX_STYLES = 200
|
|
|
|
|
|
|
| _TS_EXPR_OK = re.compile(r'^[A-Za-z0-9_ .+\-*/()\[\]]+$')
|
|
|
|
|
|
|
| _LEGACY_MODES = {'dashboard': 'chart'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| CHART_KINDS = {'bar', 'line', 'area', 'donut', 'kpi', 'table'}
|
| CHART_AGGS = {'sum', 'avg', 'count', 'min', 'max'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| FIELD_AGGS = ('sum', 'average', 'median', 'min', 'max', 'count')
|
| MAX_CHARTS = 12
|
| MAX_CHART_TITLE = 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| CHART_PALETTES = {'brand', 'categorical', 'sequential', 'diverging'}
|
| CHART_FORMATS = {'auto', 'number', 'currency', 'percent', 'compact'}
|
| MAX_AXIS_LABEL = 40
|
|
|
| CHART_W_RANGE = (1, 12)
|
| CHART_H_RANGE = (120, 800)
|
|
|
|
|
| def _clean_chart(raw, valid_keys):
|
| """One dashboard chart, fail-closed. Returns None if the chart cannot be drawn.
|
|
|
| A chart's `y` is optional (absent = count of rows, which is what "how many customers per
|
| state" means). `x` is NOT: a chart with no category axis has nothing to plot against, and
|
| silently keeping it would put an empty card on the dashboard with no way to tell why.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| kind = raw.get('kind')
|
| if kind not in CHART_KINDS:
|
| return None
|
| x = raw.get('x')
|
| if x not in valid_keys:
|
| return None
|
| cid = raw.get('id')
|
| if not isinstance(cid, str) or not cid.strip():
|
| return None
|
|
|
| out = {'id': cid.strip()[:64], 'kind': kind, 'x': x,
|
| 'agg': raw.get('agg') if raw.get('agg') in CHART_AGGS else 'sum'}
|
| if raw.get('y') in valid_keys:
|
| out['y'] = raw['y']
|
| else:
|
|
|
| out['agg'] = 'count'
|
| title = raw.get('title')
|
| if isinstance(title, str) and title.strip():
|
| out['title'] = title.strip()[:MAX_CHART_TITLE]
|
|
|
|
|
|
|
|
|
|
|
| if raw.get('splitBy') in valid_keys and raw['splitBy'] != x:
|
| out['splitBy'] = raw['splitBy']
|
|
|
|
|
|
|
| if out.get('splitBy') and kind in ('bar', 'area') and raw.get('stacked') is True:
|
| out['stacked'] = True
|
| if raw.get('palette') in CHART_PALETTES:
|
| out['palette'] = raw['palette']
|
|
|
| axis = raw.get('axis')
|
| if isinstance(axis, dict):
|
|
|
|
|
|
|
|
|
| clean_axis = {}
|
| for side in ('x', 'y'):
|
| spec = axis.get(side)
|
| if not isinstance(spec, dict):
|
| continue
|
| one = {}
|
| lab = spec.get('label')
|
| if isinstance(lab, str) and lab.strip():
|
| one['label'] = lab.strip()[:MAX_AXIS_LABEL]
|
| if spec.get('format') in CHART_FORMATS:
|
| one['format'] = spec['format']
|
| if one:
|
| clean_axis[side] = one
|
| if clean_axis:
|
| out['axis'] = clean_axis
|
|
|
| size = raw.get('size')
|
| if isinstance(size, dict):
|
| one = {}
|
| for key, (lo, hi) in (('w', CHART_W_RANGE), ('h', CHART_H_RANGE)):
|
| try:
|
| one[key] = max(lo, min(hi, int(size[key])))
|
| except (KeyError, TypeError, ValueError):
|
| pass
|
| if one:
|
| out['size'] = one
|
|
|
|
|
|
|
|
|
|
|
| if isinstance(out.get('y'), str) and out['y'].startswith('measure_'):
|
| if raw.get('bucket') in TS_BUCKETS:
|
| out['bucket'] = raw['bucket']
|
| sp = raw.get('span')
|
| if isinstance(sp, dict):
|
| n = sp.get('lastN')
|
| if (isinstance(n, int) and not isinstance(n, bool)
|
| and 1 <= n <= TS_MAX_LAST_N):
|
| out['span'] = {'lastN': n}
|
|
|
|
|
|
|
|
|
| if raw.get('compare') == 'prior_year' and (
|
| out.get('bucket') or (kind == 'kpi' and out.get('agg') == 'sum')):
|
| out['compare'] = 'prior_year'
|
| return out
|
|
|
|
|
| def _clean_catalog_page(raw, budget):
|
| """C6-CATALOG β one page of a catalog. Returns `(page | None, codes_spent)`.
|
|
|
| `budget` is what is LEFT of the catalog's 500-code allowance. Codes are deduped WITHIN a
|
| page and not across the catalog: a product legitimately appears on a gallery page and again
|
| in its section listing, and de-duplicating globally would silently delete the second
|
| appearance. The budget is spent in page order, so a catalog that runs out loses the TAIL of
|
| its last pages β never a random scatter, and never a page (a page with no products is a
|
| heading the user can still see and fix).
|
| """
|
| if not isinstance(raw, dict):
|
| return None, 0
|
| page_id = str(raw.get('id') or '')[:CATALOG_ID_MAX]
|
| kind = raw.get('kind')
|
| if not page_id or kind not in CATALOG_PAGE_KINDS:
|
| return None, 0
|
| out = {'id': page_id, 'kind': kind}
|
| for key, cap in (('title', CATALOG_TITLE_MAX), ('body', CATALOG_BODY_MAX),
|
| ('imageCode', CATALOG_CODE_MAX)):
|
| v = raw.get(key)
|
| if isinstance(v, str) and v:
|
| out[key] = v[:cap]
|
| products = raw.get('products')
|
| if isinstance(products, list) and budget > 0:
|
| clean_p, seen_p = [], set()
|
| for c in products:
|
| if not isinstance(c, str) or not c:
|
| continue
|
| c = c[:CATALOG_CODE_MAX]
|
| if c in seen_p:
|
| continue
|
| seen_p.add(c)
|
| clean_p.append(c)
|
| if len(clean_p) >= budget:
|
| break
|
| if clean_p:
|
| out['products'] = clean_p
|
| layout = raw.get('layout')
|
| if isinstance(layout, dict):
|
| clean_l = {}
|
| cols = layout.get('cols')
|
| if isinstance(cols, int) and not isinstance(cols, bool) and cols in CATALOG_COLS:
|
| clean_l['cols'] = cols
|
|
|
|
|
|
|
|
|
| if layout.get('showPack') is False:
|
| clean_l['showPack'] = False
|
| if layout.get('showColor') is False:
|
| clean_l['showColor'] = False
|
| if layout.get('showPrice') is True:
|
| clean_l['showPrice'] = True
|
| if clean_l:
|
| out['layout'] = clean_l
|
| return out, len(out.get('products') or ())
|
|
|
|
|
| def _clean_catalogs(raw, valid_keys):
|
| """C6-CATALOG (wave 18) β `display.catalogs`, fail-closed. Returns a list or None.
|
|
|
| A catalog is a PRINT artifact, so the two structural keys that decide how it paginates
|
| (`paper`, `orientation`) are NORMALISED WITH A DEFAULT rather than dropped: a page box with
|
| no size is not a smaller catalog, it is an unrenderable one. Everything else follows the
|
| house rules β unknown keys dropped, per-entry drops never cost the neighbours, empty
|
| sub-objects omitted entirely (`brand`, `fields`, `layout`, `products`) so an absent key and
|
| an empty one are not two spellings of the same nothing.
|
|
|
| `fields` binds the listing lines to real columns (the 2027 listing prints description / SKU /
|
| pack / colour, and `product_data` carries no pack or colour of its own β the user binds
|
| custom fields). Refs are checked against `valid_keys` HERE and not on the client, the same
|
| split `dateField`/`stackField` already run.
|
| """
|
| if not isinstance(raw, list):
|
| return None
|
| out = []
|
| for c in raw:
|
| if len(out) >= MAX_CATALOGS:
|
| break
|
| if not isinstance(c, dict):
|
| continue
|
| cat_id = str(c.get('id') or '')[:CATALOG_ID_MAX]
|
| name = c.get('name')
|
|
|
|
|
| if not cat_id or not isinstance(name, str):
|
| continue
|
| cat = {'id': cat_id, 'name': name[:CATALOG_NAME_MAX]}
|
| cat['paper'] = c['paper'] if c.get('paper') in CATALOG_PAPERS else 'letter'
|
| cat['orientation'] = (c['orientation']
|
| if c.get('orientation') in CATALOG_ORIENTATIONS else 'portrait')
|
| if c.get('quality') in CATALOG_QUALITIES:
|
| cat['quality'] = c['quality']
|
| brand = c.get('brand')
|
| if isinstance(brand, dict):
|
| clean_b = {}
|
| for k in ('primary', 'accent'):
|
| v = brand.get(k)
|
| if isinstance(v, str) and _HEX6.match(v):
|
| clean_b[k] = v
|
| company = brand.get('company')
|
| if isinstance(company, str) and company:
|
| clean_b['company'] = company[:CATALOG_NAME_MAX]
|
|
|
|
|
| logo = brand.get('logo')
|
| if isinstance(logo, str) and logo:
|
| clean_b['logo'] = logo[:CATALOG_CODE_MAX]
|
| if clean_b:
|
| cat['brand'] = clean_b
|
| binds = c.get('fields')
|
| if isinstance(binds, dict):
|
| clean_bind = {k: binds[k] for k in ('name', 'pack', 'color', 'price')
|
| if binds.get(k) in valid_keys}
|
| if clean_bind:
|
| cat['fields'] = clean_bind
|
| pages, budget = [], MAX_CATALOG_CODES
|
| raw_pages = c.get('pages')
|
| if isinstance(raw_pages, list):
|
| for p in raw_pages:
|
| if len(pages) >= MAX_CATALOG_PAGES:
|
| break
|
| page, spent = _clean_catalog_page(p, budget)
|
| if page is None:
|
| continue
|
| budget -= spent
|
| pages.append(page)
|
|
|
|
|
| cat['pages'] = pages
|
| out.append(cat)
|
| return out or None
|
|
|
|
|
| def _clean_display(raw, valid_keys):
|
| """Structural passthrough for a view's `config.display` (wave-6 item 10), fail-closed.
|
|
|
| `{mode, dateField?, stackField?, titleField?, colorField?, sizeField?, charts?}` β mode
|
| must be a known non-grid mode (grid is the absent default, so storing it would be a second
|
| way to say nothing); every field ref must name a field this table has (a ref to a deleted
|
| field is DROPPED and the client falls back to its per-mode default); unknown keys are
|
| dropped. What each mode MEANS β calendar wants a date-family field, kanban a select-family
|
| stack, map a single-select to colour by and a numeric to size by β is the client's
|
| business: it is the only layer that renders them, and a wrong-typed ref degrades to that
|
| surface's default rather than to an error (the `_clean_window` split).
|
|
|
| Wave-8 (contract C2) adds the map encodings (`colorField` I3, `sizeField` I5) and
|
| dashboard `charts` (I19). A chart whose x/y names a deleted field is dropped INDIVIDUALLY β
|
| never the whole array, because losing one column should not cost the user a dashboard they
|
| spent time building.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| mode = raw.get('mode')
|
| if mode not in DISPLAY_MODES or mode == 'grid':
|
| return None
|
|
|
|
|
|
|
| mode = _LEGACY_MODES.get(mode, mode)
|
| out = {'mode': mode}
|
| for ref in ('dateField', 'stackField', 'titleField', 'colorField', 'sizeField'):
|
| if raw.get(ref) in valid_keys:
|
| out[ref] = raw[ref]
|
|
|
|
|
|
|
|
|
| if raw.get('kanbanClamp') is False:
|
| out['kanbanClamp'] = False
|
| if raw.get('calendarMode') in ('records', 'summary'):
|
| out['calendarMode'] = raw['calendarMode']
|
| metrics = raw.get('calendarMetrics')
|
| if isinstance(metrics, list):
|
| clean_m, seen_m = [], set()
|
| for m in metrics[:MAX_CALENDAR_METRICS]:
|
|
|
|
|
| if not isinstance(m, dict):
|
| continue
|
| mid = str(m.get('id') or '')[:40]
|
| if (not mid or mid in seen_m or m.get('field') not in valid_keys
|
| or m.get('agg') not in CHART_AGGS):
|
| continue
|
| seen_m.add(mid)
|
| clean_m.append({'id': mid, 'field': m['field'], 'agg': m['agg']})
|
| if clean_m:
|
| out['calendarMetrics'] = clean_m
|
| if raw.get('tsBucket') in TS_BUCKETS:
|
| out['tsBucket'] = raw['tsBucket']
|
| span = raw.get('tsSpan')
|
| if isinstance(span, dict):
|
| clean_span = {}
|
| n = span.get('lastN')
|
| if isinstance(n, int) and not isinstance(n, bool) and 1 <= n <= TS_MAX_LAST_N:
|
| clean_span['lastN'] = n
|
| else:
|
| f, t = span.get('from'), span.get('to')
|
| f = f if isinstance(f, str) and _ISO_DAY.match(f) else None
|
| t = t if isinstance(t, str) and _ISO_DAY.match(t) else None
|
| if f and t and f > t:
|
| f, t = t, f
|
| if f:
|
| clean_span['from'] = f
|
| if t:
|
| clean_span['to'] = t
|
| if clean_span:
|
| out['tsSpan'] = clean_span
|
| ts_fields = raw.get('tsFields')
|
| if isinstance(ts_fields, list):
|
| clean_f, seen_f = [], set()
|
| for k in ts_fields[:TS_MAX_FIELDS]:
|
| if k in valid_keys and k not in seen_f:
|
| seen_f.add(k)
|
| clean_f.append(k)
|
| if clean_f:
|
| out['tsFields'] = clean_f
|
|
|
| deltas = raw.get('tsDeltas')
|
| if isinstance(deltas, list):
|
| clean_d, seen_d = [], set()
|
| for d in deltas:
|
| if d in TS_DELTA_KINDS and d not in seen_d:
|
| seen_d.add(d)
|
| clean_d.append(d)
|
| if clean_d:
|
| out['tsDeltas'] = clean_d
|
|
|
|
|
| if raw.get('tsGridlines') is False:
|
| out['tsGridlines'] = False
|
| if raw.get('tsSparkline') is True:
|
| out['tsSparkline'] = True
|
| rows = raw.get('tsRows')
|
| if isinstance(rows, list):
|
| clean_r, seen_r = [], set()
|
| for r in rows[:TS_MAX_CUSTOM_ROWS]:
|
| if not isinstance(r, dict):
|
| continue
|
| rid = str(r.get('id') or '')[:40]
|
| r_kind = r.get('kind')
|
| if not rid or rid in seen_r or r_kind not in ('note', 'formula'):
|
| continue
|
| label = r.get('label')
|
| if not isinstance(label, str):
|
| continue
|
|
|
| one = {'id': rid, 'kind': r_kind, 'label': label.strip()[:120]}
|
| if r_kind == 'formula':
|
| expr = r.get('expr')
|
| if (isinstance(expr, str) and expr.strip()
|
| and len(expr) <= 200 and _TS_EXPR_OK.match(expr)):
|
| one['expr'] = expr
|
|
|
|
|
|
|
| seen_r.add(rid)
|
| clean_r.append(one)
|
| if clean_r:
|
| out['tsRows'] = clean_r
|
| styles = raw.get('tsStyles')
|
| if isinstance(styles, dict):
|
| clean_s = {}
|
| for s_key, s_val in styles.items():
|
| if len(clean_s) >= TS_MAX_STYLES:
|
| break
|
| if not isinstance(s_key, str) or not s_key or len(s_key) > 96:
|
| continue
|
| if not isinstance(s_val, dict):
|
| continue
|
| one = {}
|
| if s_val.get('bold') is True:
|
| one['bold'] = True
|
| if s_val.get('line') is True:
|
| one['line'] = True
|
| if one:
|
| clean_s[s_key] = one
|
| if clean_s:
|
| out['tsStyles'] = clean_s
|
| charts = raw.get('charts')
|
| if isinstance(charts, list):
|
| clean = [c for c in (_clean_chart(x, valid_keys) for x in charts[:MAX_CHARTS]) if c]
|
|
|
|
|
| seen, uniq = set(), []
|
| for c in clean:
|
| if c['id'] in seen:
|
| continue
|
| seen.add(c['id'])
|
| uniq.append(c)
|
| if uniq:
|
| out['charts'] = uniq
|
|
|
| catalogs = _clean_catalogs(raw.get('catalogs'), valid_keys)
|
| if catalogs:
|
| out['catalogs'] = catalogs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| swipe = raw.get('swipe')
|
| if isinstance(swipe, dict):
|
| f_key = swipe.get('fieldKey')
|
| left, right = swipe.get('leftOption'), swipe.get('rightOption')
|
| ok = (f_key in valid_keys
|
| and isinstance(left, str) and isinstance(right, str))
|
| if ok:
|
| left, right = left.strip()[:MAX_SWIPE_OPTION], right.strip()[:MAX_SWIPE_OPTION]
|
|
|
|
|
|
|
| if left and right and left.casefold() != right.casefold():
|
| out['swipe'] = {'fieldKey': f_key, 'leftOption': left, 'rightOption': right}
|
|
|
| form = _clean_form(raw.get('form'), valid_keys)
|
| if form:
|
| out['form'] = form
|
| return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| FOLDER_SURFACES = {"views", "cohorts"}
|
| MAX_FOLDERS = 60
|
| MAX_FOLDER_NAME = 80
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| FOLDER_ICON_SHAPES = {"folder", "star", "flag", "tag", "bookmark", "box", "circle", "square"}
|
|
|
|
|
| FOLDER_ICON_TONES = {"blue", "green", "yellow", "red", "grey"}
|
| FOLDER_ICON_DEFAULT_TONE = "grey"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| VIEW_EDIT_MODES = {'personal', 'collaborative', 'users'}
|
| MAX_VIEW_USERS = 50
|
|
|
|
|
| def clean_view_permissions(raw, default, known_users=None):
|
| """{edit, users?} β fail-closed on both halves.
|
|
|
| `default` is supplied by the CALLER because it splits by path, and that split is a
|
| permission rule rather than a formatting one: absent on a view that already exists means a
|
| pre-wave-9 view and must stay 'collaborative' (retro-restricting somebody's saved view is a
|
| silent takeaway), while absent on CREATE must be 'personal' (a new view must never be
|
| anyone-can-edit purely by omission).
|
|
|
| `known_users` (when given) is the real account list: an unknown name is DROPPED, and an
|
| 'users' grant left with nobody in it collapses to 'personal' rather than to everyone.
|
| """
|
| mode = (raw or {}).get('edit') if isinstance(raw, dict) else None
|
| if mode not in VIEW_EDIT_MODES:
|
| mode = default if default in VIEW_EDIT_MODES else 'personal'
|
| if mode != 'users':
|
| return {'edit': mode}
|
| names, seen = [], set()
|
| for u in list((raw or {}).get('users') or [])[:MAX_VIEW_USERS]:
|
| u = str(u or '').strip()
|
| if not u or u.lower() in seen:
|
| continue
|
| if known_users is not None and u not in known_users:
|
| continue
|
| seen.add(u.lower())
|
| names.append(u)
|
| if not names:
|
| return {'edit': 'personal'}
|
| return {'edit': 'users', 'users': names}
|
|
|
|
|
| def clean_folder_icon(raw):
|
| """{shape, tone} or None. Fail-closed on both halves, independently.
|
|
|
| A folder with a valid shape but a junk tone keeps the shape and defaults the tone rather
|
| than losing the icon entirely β losing a user's pick because one half was wrong is the kind
|
| of silent data loss the folder events already avoid elsewhere.
|
| """
|
| if not isinstance(raw, dict):
|
| return None
|
| shape = raw.get("shape")
|
| if shape not in FOLDER_ICON_SHAPES:
|
| return None
|
| tone = raw.get("tone")
|
| return {"shape": shape,
|
| "tone": tone if tone in FOLDER_ICON_TONES else FOLDER_ICON_DEFAULT_TONE}
|
|
|
|
|
| def clean_folders(raw):
|
| """Validate the per-surface folder lists, fail-closed. {surface: [{id, name, order}]}."""
|
| out = {}
|
| for surface in FOLDER_SURFACES:
|
| items, seen = [], set()
|
| for f in list((raw or {}).get(surface) or [])[:MAX_FOLDERS]:
|
| if not isinstance(f, dict):
|
| continue
|
| fid = str(f.get("id") or "").strip()[:80]
|
| name = str(f.get("name") or "").strip()[:MAX_FOLDER_NAME]
|
| if not fid or not name or fid in seen:
|
| continue
|
| seen.add(fid)
|
| try:
|
| order = int(f.get("order", len(items)))
|
| except (TypeError, ValueError):
|
| order = len(items)
|
| row = {"id": fid, "name": name, "order": order}
|
| icon = clean_folder_icon(f.get("icon"))
|
| if icon:
|
| row["icon"] = icon
|
| items.append(row)
|
| items.sort(key=lambda x: x["order"])
|
| for i, f in enumerate(items):
|
| f["order"] = i
|
| if items:
|
| out[surface] = items
|
| return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| ROOT_PLACEMENT = "__root__"
|
|
|
|
|
| def clean_item_folders(raw, folders, valid_ids):
|
| """{surface: {itemId: folderId}} β dropping any placement whose ITEM or FOLDER is gone.
|
|
|
| This is what makes a deleted folder's contents fall back to the root rather than vanish:
|
| nothing stores "this item is in no folder", so an unresolvable placement simply disappears
|
| and the item renders at the top level. Same for an item that was deleted elsewhere β its
|
| stale placement can never resurrect it, because the sidebars render ITEMS and consult this
|
| map, never the other way round.
|
|
|
| ββ WAVE 32 β THE PARAGRAPH ABOVE STATES THE FEATURE AND THE BUG IN ONE SENTENCE, and it took
|
| owner item 20 to notice they were the same mechanism. *"Nothing stores 'this item is in no
|
| folder', so an unresolvable placement simply disappears"* is exactly right for a DELETED FOLDER
|
| (its contents should fall to the root) and exactly wrong for a SHARED VIEW (falling back means
|
| falling back INTO the Shared group, which is where it started). `ROOT_PLACEMENT` is the value
|
| that survives this function so the second case can be said out loud.
|
| """
|
| out = {}
|
| for surface in FOLDER_SURFACES:
|
| fids = {f["id"] for f in (folders or {}).get(surface, [])}
|
| ok = {}
|
| for item_id, fid in ((raw or {}).get(surface) or {}).items():
|
| if not isinstance(item_id, str) or not isinstance(fid, str):
|
| continue
|
|
|
|
|
|
|
|
|
|
|
| if item_id in (valid_ids or {}).get(surface, ()) and (fid == ROOT_PLACEMENT
|
| or fid in fids):
|
| ok[item_id[:120]] = fid
|
| if ok:
|
| out[surface] = ok
|
| return out
|
|
|
|
|
| def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
|
| """Recursively validate an UNTRUSTED filter tree (conditions + nested groups).
|
|
|
| Returns a clean tree of leaf conditions ({colId, op, value, value2}) and
|
| groups ({conj, children}). Module-agnostic on purpose: any module embedding
|
| the grid validates its own view state through this one function.
|
|
|
| Fail-closed PER NODE: anything unrecognised is DROPPED rather than raised β
|
| the same contract the rest of the view sanitiser follows, so one bad rule can
|
| never cost a user their whole saved view. Depth, per-level width and total
|
| node count are all capped: the tree is re-evaluated for every row on every
|
| render, so an unbounded structure would be a persistent client-side DoS.
|
| Empty groups are dropped (they carry no meaning once persisted).
|
|
|
| `cohort_ids` is the set of cohorts the CALLER may see. A cohort leaf naming anything else is
|
| DROPPED here rather than left for the engine β a deleted cohort would otherwise leave a
|
| condition that can only match nothing, so `List is not [deleted]` would show an empty table
|
| forever with no way to tell why. `None` means this host has no cohorts, and then every
|
| cohort leaf is dropped: fail-closed, like every other unknown key.
|
| """
|
| if budget is None:
|
| budget = [MAX_FILTER_NODES]
|
| out = []
|
| for node in list(raw or [])[:MAX_FILTER_SIBLINGS]:
|
| if budget[0] <= 0:
|
| break
|
| if not isinstance(node, dict):
|
| continue
|
| if isinstance(node.get('children'), list):
|
| if depth >= MAX_FILTER_DEPTH:
|
| continue
|
| budget[0] -= 1
|
| children = clean_filter_tree(node['children'], valid_keys,
|
| depth + 1, budget, cohort_ids)
|
| if children:
|
| out.append({'conj': 'or' if node.get('conj') == 'or' else 'and',
|
| 'children': children})
|
| continue
|
| if node.get('colId') == COHORT_FIELD:
|
| op = COHORT_OP_ALIASES.get(node.get('op'), node.get('op'))
|
| named = parse_cohort_ids(node.get('value'))
|
|
|
|
|
|
|
|
|
| if op in COHORT_OPS and named and all(c in (cohort_ids or ()) for c in named):
|
| budget[0] -= 1
|
| out.append({'colId': COHORT_FIELD, 'op': op,
|
| 'value': ','.join(named), 'value2': ''})
|
| continue
|
| if node.get('colId') in valid_keys and node.get('op') in FILTER_OPS:
|
| budget[0] -= 1
|
|
|
|
|
|
|
|
|
| val, val2 = node.get('value'), node.get('value2')
|
| leaf = {'colId': node['colId'], 'op': node['op'],
|
| 'value': ('' if val is None else str(val))[:500],
|
| 'value2': ('' if val2 is None else str(val2))[:500]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| rid = node.get('id')
|
| if rid not in (None, ''):
|
| leaf['id'] = str(rid)[:64]
|
| window = _clean_window(node.get('window'))
|
| if window is not None:
|
| leaf['window'] = window
|
|
|
|
|
|
|
|
|
|
|
|
|
| date_window = _clean_window(node.get('dateWindow'))
|
| if date_window is not None:
|
| leaf['dateWindow'] = date_window
|
| mode = node.get('dateMode')
|
|
|
|
|
|
|
|
|
| if isinstance(mode, str) and 0 < len(mode) <= 40:
|
| leaf['dateMode'] = mode
|
| rhs = _clean_rhs(node.get('rhs'), valid_keys)
|
| if rhs is not None:
|
| leaf['rhs'] = rhs
|
| out.append(leaf)
|
| return out
|
|
|
|
|
| def _default_view_config(fields):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| shown = [field["key"] for field in fields if field.get("default") is not False]
|
| hidden = [field["key"] for field in fields if field["key"] not in shown]
|
| return {
|
|
|
|
|
| "filters": [], "filterConj": "and",
|
| "sorts": [], "groupBy": None, "colorBy": None,
|
| "rowHeightMode": "short", "order": shown + hidden, "visible": shown,
|
| "widths": {}, "memberPids": [],
|
| }
|
|
|
|
|
|
|
|
|
| LOCKED_VIEW_KIND = 'locked'
|
|
|
|
|
| def locked_view_projection(entry, base_config):
|
| """One cohort -> the saved-view row that IS it (wave 17 R1, contract C-LOCKV).
|
|
|
| β THE LOCK IS THE VIEW'S IDENTITY, NOT ITS CONFIGURATION. `config.cohortLock` names the
|
| view's OWN id, which is what makes the shipped engine law (`useVisibleRows`: intersect the
|
| named set FIRST, unconditionally, and match NOTHING when the membership is unresolvable) do
|
| all the work with no second mechanism. Membership itself is NEVER copied in here β it stays
|
| in `customer_cohorts` and travels as `workspace.lists`, because a per-reader-scoped
|
| collection inside a client-writable `config` is deleted by the next autosave (see the
|
| contract's reason 2).
|
|
|
| β `locked: True` is the LEGACY "undeletable/mode-frozen" flag and is deliberately NOT set:
|
| these views are ordinary in every respect the owner asked for β reorder, folder, sort,
|
| filter, change display mode. The lock mark in the rail is driven by `kind`.
|
| """
|
| return {
|
| 'id': entry['id'],
|
| 'name': entry.get('name') or entry['id'],
|
| 'kind': LOCKED_VIEW_KIND,
|
| 'config': {**base_config, 'cohortLock': entry['id']},
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| IG_OVERVIEW_COLUMNS = ('handle', 'full_name', 'followers', 'avg_engagement',
|
| 'location_guess', 'enriched_at', 'profile_url')
|
|
|
|
|
|
|
|
|
| IG_OVERVIEW_ID = 'tpl_overview'
|
|
|
|
|
|
|
|
|
|
|
|
|
| _IG_SIGNATURE = ('handle', 'followers', 'avg_engagement')
|
|
|
|
|
| def _ig_overview_view(fields, base):
|
| """R8's curated Overview, or None when this table is not an Instagram one."""
|
| keys = {f['key'] for f in fields}
|
| if not all(k in keys for k in _IG_SIGNATURE):
|
| return None
|
| visible = [k for k in IG_OVERVIEW_COLUMNS if k in keys]
|
| return {
|
| 'id': IG_OVERVIEW_ID,
|
| 'name': 'Overview',
|
| 'kind': 'system',
|
|
|
|
|
|
|
| 'note': 'The five things worth seeing first on a creator. Sorted by reach.',
|
| 'config': {
|
| **dict(base),
|
| 'visible': visible,
|
| 'order': visible + [k for k in (f['key'] for f in fields) if k not in visible],
|
| 'sorts': ([{'colId': 'followers', 'dir': 'desc'}]
|
| if 'followers' in keys else []),
|
| },
|
| }
|
|
|
|
|
| def views_from_defs(defs, saved_views, fields, system_name="All customers", locked_lists=None,
|
| view_order=None):
|
| """Convert legacy list formulas into the shared serializable SavedView contract.
|
|
|
| `system_name` (wave 16 C-TOPIC) labels the system view per TOPIC ("All products" on the
|
| product surface). The ID stays "all-customers" on every topic β the client pins it
|
| (UNDELETABLE_VIEW_IDS, the landing default), and an id that varies by surface would fork
|
| that contract for a label's sake.
|
|
|
| `locked_lists` (wave 17 R1) are the caller's cohorts, each PROJECTED as a saved view whose
|
| id IS the cohort id β so every stored reference to that id (a `cohortLock` on another view,
|
| an `is part of` condition, a folder placement) keeps pointing at the same thing and no
|
| rewrite map is needed. Saved config OVERLAYS the projection through the same mechanism the
|
| `list:` views have always used, which is what gives a locked view its own sort, filter,
|
| columns and display mode with no new storage."""
|
| base = _default_view_config(fields)
|
| views = [{
|
| "id": "all-customers", "name": system_name, "kind": "system",
|
| "locked": True, "config": dict(base),
|
| }]
|
|
|
|
|
|
|
|
|
|
|
|
|
| _overview = _ig_overview_view(fields, base)
|
| if _overview:
|
| views.insert(0, _overview)
|
| op_map = {">=": "gte", ">": "gt", "<=": "lte", "<": "lt", "=": "eq",
|
| "contains": "contains"}
|
| for name, definition in (defs or {}).items():
|
| filters = []
|
| for rule in definition.get("rules") or []:
|
| if rule.get("field") not in {field["key"] for field in fields}:
|
| continue
|
| filters.append({
|
| "colId": rule["field"],
|
| "op": op_map.get(rule.get("op"), "eq"),
|
| "value": str(rule.get("value") if rule.get("value") is not None else ""),
|
| })
|
| sort = str(definition.get("sort") or "")
|
| sorts = ([{"colId": sort.lstrip("-"),
|
| "dir": "desc" if sort.startswith("-") else "asc"}]
|
| if sort.lstrip("-") in {field["key"] for field in fields} else [])
|
| views.append({
|
| "id": "list:" + str(name),
|
| "name": str(name),
|
| "kind": "list",
|
| "note": str(definition.get("note") or ""),
|
| "config": {
|
| **base, "filters": filters, "sorts": sorts,
|
| "memberPids": [int(pid) for pid in definition.get("members") or []
|
| if isinstance(pid, int) or str(pid).isdigit()],
|
| },
|
| })
|
|
|
|
|
|
|
| for _entry in (locked_lists or []):
|
| if isinstance(_entry, dict) and _entry.get('id'):
|
| views.append(locked_view_projection(_entry, base))
|
| index = {view["id"]: i for i, view in enumerate(views)}
|
| for view_id, saved in (saved_views or {}).items():
|
| if not isinstance(saved, dict) or not isinstance(saved.get("config"), dict):
|
| continue
|
| clean = dict(saved)
|
| clean["id"] = str(view_id)
|
| if view_id in index:
|
| views[index[view_id]] = clean
|
| else:
|
| views.append(clean)
|
|
|
|
|
|
|
|
|
|
|
|
|
| _locked_ids = {e['id']: e for e in (locked_lists or [])
|
| if isinstance(e, dict) and e.get('id')}
|
| if _locked_ids:
|
| for _v in views:
|
| _entry = _locked_ids.get(_v.get('id'))
|
| if not _entry:
|
| continue
|
| _v['kind'] = LOCKED_VIEW_KIND
|
| _v['config'] = {**(_v.get('config') or {}), 'cohortLock': _v['id']}
|
|
|
|
|
| _v['name'] = _entry.get('name') or _v['id']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if view_order:
|
| _rank = {vid: i for i, vid in enumerate(view_order) if isinstance(vid, str)}
|
| _pinned = [v for v in views if v.get('id') in (IG_OVERVIEW_ID, 'all-customers')]
|
| _rest = [v for v in views if v.get('id') not in (IG_OVERVIEW_ID, 'all-customers')]
|
|
|
|
|
| _rest.sort(key=lambda v: _rank.get(v.get('id'), len(_rank) + 1))
|
| views = _pinned + _rest
|
| return views
|
|
|
|
|
| def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storage_key=None,
|
| fields_base=None, with_cohorts=True):
|
| """The client's `GridWorkspace` WIRE SHAPE from the stored table workspace β the ONE
|
| projection, shared by both servers (app.py's `_table_grid` and the API's `/workspace`).
|
|
|
| β WHY THIS EXISTS (2026-07-30). The API route used to return the STORE shape with no
|
| `storageKey` β and the client validator (`fetchWorkspace`) requires one, so the standalone
|
| shell silently discarded the whole workspace: saved views never rendered and `cohortMode`
|
| never arrived (the Cohort route drew the Customer surface). Duplicating the host's inline
|
| projection into the route would have re-created the same drift one wave later; extracting it
|
| means the wire can only be one thing.
|
|
|
| Returns `(workspace, fields, views, cohort_lists)` β the extra three because the host
|
| interleaves further work (docs, derived cells, measure sets) that consumes them.
|
|
|
| HOST-ONLY extras stay with the host: `docs`/`docPayload`, `pool`, `hideViews`,
|
| `cohortMode`/`scopeChoice` (the API stamps its own from `?scope=`).
|
|
|
| Wave 16 C-TOPIC: `fields_base` selects the canonical contract (absent = customer,
|
| byte-identical).
|
|
|
| β WAVE 19 / R9 β `with_cohorts` NO LONGER MEANS "customer only". Wave 16 set it False on the
|
| product surface because cohorts were a single customer-keyed bucket, so resolving them against
|
| product pids would have intersected two unrelated id spaces and printed a plausible,
|
| meaningless member count. `modules.cohort` is scope-parameterized now: the lists come from
|
| THIS topic's bucket (`cohort_mod.scoped(scope_key)`), so their ids are this topic's ids and
|
| the intersection with `pool_pids` is the ordinary one. The flag survives as an honest OFF
|
| switch for a surface that wants no membership channel at all β it is not a topic wall.
|
| """
|
| import modules.cohort as cohort_mod
|
|
|
| cohort_lists = []
|
| if with_cohorts:
|
| for cid, c in sorted(cohort_mod.scoped(scope_key).visible(uname, pool_pids).items(),
|
| key=lambda kv: (kv[1].get('name') or '').lower()):
|
| members = [p for p in (c.get('members') or []) if p in pool_pids]
|
| entry = {'id': cid, 'name': c.get('name') or cid, 'pids': members}
|
|
|
|
|
|
|
| missing = len(c.get('members') or []) - len(members)
|
| if missing:
|
| entry['missing'] = missing
|
| cohort_lists.append(entry)
|
|
|
| fields = fields_from_workspace(ws, cohorts=bool(cohort_lists), scope_key=scope_key,
|
| fields_base=fields_base)
|
| views = views_from_defs(defs or {}, ws.get('views'), fields,
|
|
|
|
|
|
|
|
|
|
|
|
|
| system_name=("All products" if scope_key == 'product'
|
| else "All records"
|
| if str(scope_key or '').startswith('ut_')
|
| else "All customers"),
|
| locked_lists=cohort_lists,
|
|
|
|
|
|
|
|
|
|
|
| view_order=ws.get('viewOrder'))
|
| workspace = {'storageKey': storage_key, 'views': views, 'lists': cohort_lists}
|
|
|
|
|
|
|
| if ws.get('activeViewId'):
|
| workspace['activeViewId'] = str(ws['activeViewId'])
|
|
|
|
|
|
|
|
|
| _folders = clean_folders(ws.get('folders'))
|
| _view_ids = {v['id'] for v in (views or []) if isinstance(v, dict) and v.get('id')}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _cf = list(_folders.get('cohorts') or [])
|
| if _cf:
|
| _vf = list(_folders.get('views') or [])
|
| _taken = {f['id'] for f in _vf}
|
| _order = len(_vf)
|
| for _f in _cf:
|
| if _f['id'] in _taken:
|
| continue
|
| _vf.append({**_f, 'order': _order})
|
| _order += 1
|
| _folders['views'] = _vf
|
| _raw_item_folders = dict(ws.get('itemFolders') or {})
|
| if _raw_item_folders.get('cohorts'):
|
|
|
|
|
| _raw_item_folders['views'] = {**dict(_raw_item_folders.get('cohorts') or {}),
|
| **dict(_raw_item_folders.get('views') or {})}
|
| _placed = clean_item_folders(
|
| _raw_item_folders, _folders,
|
| {'views': _view_ids, 'cohorts': {c['id'] for c in cohort_lists}})
|
| if _folders.get('views'):
|
| workspace['folders'] = _folders['views']
|
|
|
|
|
| _vplaced = _placed.get('views') or {}
|
| for _v in (views or []):
|
| if isinstance(_v, dict) and _v.get('id') in _vplaced:
|
| _v['folderId'] = _vplaced[_v['id']]
|
| _cplaced = _placed.get('cohorts') or {}
|
| for _c in cohort_lists:
|
| if _c['id'] in _cplaced:
|
| _c['folderId'] = _cplaced[_c['id']]
|
|
|
|
|
|
|
|
|
| _rl = ws.get('recordLayout')
|
| if isinstance(_rl, dict) and isinstance(_rl.get('order'), list):
|
| _fkeys = {f['key'] for f in fields if isinstance(f, dict) and f.get('key')}
|
| _order, _seen = [], set()
|
| for _k in _rl['order'][:200]:
|
| _k = str(_k or '')
|
| if _k and _k in _fkeys and _k not in _seen:
|
| _seen.add(_k)
|
| _order.append(_k)
|
| if _order:
|
| workspace['recordLayout'] = {'order': _order}
|
|
|
| return workspace, fields, views, cohort_lists
|
|
|
|
|
| def embed_html_path():
|
| """The first existing candidate path for the inlined single-file build, or None."""
|
| for p in _EMBED_CANDIDATES:
|
| if p.is_file():
|
| return p
|
| return None
|
|
|
|
|
| def scope_counts(shown, matched, total):
|
| """The honest 'N of M' a SERVER-WINDOWED table must carry (CG-2).
|
|
|
| `matched` and `total` MUST come from their own queries over the whole scope. Never pass
|
| `len(rows)` as `matched` β that is the silent [:N] this exists to prevent: the page would
|
| report the window size as though it were the result size.
|
|
|
| Refuses the shapes that could only be a mistake, because a wrong count here is invisible on
|
| screen (it looks like a smaller dataset, not like an error).
|
| """
|
| shown, matched, total = int(shown), int(matched), int(total)
|
| if matched > total:
|
| raise ValueError(f"matched ({matched}) exceeds total ({total}) β a filter cannot match "
|
| f"more rows than the scope holds")
|
| if shown > matched:
|
| raise ValueError(f"shown ({shown}) exceeds matched ({matched}) β the window cannot hold "
|
| f"more rows than the filter matched")
|
| return {"shown": shown, "matched": matched, "total": total, "windowed": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|