| """core/grid_events.py β THE WRITE SEAM, lifted out of Streamlit (EXIT-1a, X1, 2026-07-30).
|
|
|
| This is the grid's event handler: the code that takes an UNTRUSTED event object from the
|
| browser component and decides what may be persisted. It was `app.py:_cl_handle_grid_event` /
|
| `_cl_handle_one` and moved here as a PURE MOVE β no behavior change inside a move (MA principle
|
| 5). Every body below is the app.py body verbatim; the ONLY mechanical rewrites are the
|
| Streamlit couplings the contract names:
|
|
|
| st.session_state['_cl_grid_seen_ids'] -> ctx.seen_ids (adapter-owned dedup state)
|
| st.session_state['_cl_doc_payload'] -> ctx.out.doc (EventResult.doc)
|
| st.session_state['_cohort_toast'] -> ctx.out.toast (EventResult.toast)
|
| st.session_state['cl_table_workspace'] -> ctx.fallback_ws (store-unavailable fallback)
|
| is_admin() (the bare call inside doc_delete, and _cl_table_workspace's admin=None default)
|
| -> ctx.admin
|
|
|
| There is NO `import streamlit` in this file and there must never be one β `verify_no_streamlit.py`
|
| enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring.
|
| (That gate was `verify_seam.py` until EXIT-6 retired it with `app.py`; the block, its negative
|
| control and the AST walk moved over intact.)
|
|
|
| ONE ADAPTER, ONE HANDLER β it was TWO until 2026-08-04:
|
| * β the Streamlit adapter is GONE. `app.py::_cl_handle_grid_event` built an EventCtx whose
|
| `seen_ids` and `fallback_ws` were session-state-backed dicts and applied the Result to session
|
| state. EXIT-6 deleted it. The host-neutrality this file was written for is what let that
|
| happen without touching a line of the handler β which is the argument for the design, recorded
|
| at the moment it paid out.
|
| * the HTTP adapter β `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None
|
| fallback with the store unavailable is a **503**, never a silent in-memory workspace: an
|
| API request has no durable session dict to fall back to, so pretending to persist would
|
| lose the write with a 200 on it.
|
|
|
| β LAYERING, stated honestly. `core/` is not supposed to import `modules/` (ARCHITECTURE Β§1's
|
| one-directional rule). This handler genuinely straddles: it is the WRITE half of the customer
|
| table and its persistence verbs live in `modules.customer_data` / `modules.cohort`. The
|
| contracted path for the strangler is `core/grid_events.py` (both adapters import it from here),
|
| so the import stays and the violation is RECORDED rather than hidden. It is the one file in
|
| `core/` that points up; when EXIT-5 ports the surfaces, the persistence verbs are what move
|
| down, and this note is the marker for that work.
|
| """
|
| import datetime as dt
|
| import json as _json_ev
|
| from dataclasses import dataclass, field
|
| from typing import MutableMapping, Optional
|
|
|
| import core.store as store
|
| import core.table_store as _tstore
|
|
|
|
|
|
|
|
|
|
|
| MAX_VIEW_ORDER = 500
|
| import core.user_tables as _ut_limits
|
| import core.users as users
|
| import harness.telemetry as _tel
|
| import modules.cohort as cohort_mod
|
| import modules.customer_data as cl_mod
|
|
|
|
|
|
|
|
|
|
|
| _DOCS_KEY = 'customer_docs'
|
| _DOC_MAX_BYTES = 5 * 1024 * 1024
|
| _DOC_MAX_PER_ROW = 50
|
|
|
|
|
|
|
|
|
| _FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions',
|
| 'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure')
|
|
|
|
|
| class StoreUnavailable(RuntimeError):
|
| """The store is down AND the caller offered no durable fallback (HTTP adapter β 503).
|
|
|
| The Streamlit adapter never sees this: it passes a session-state-backed `fallback_ws`, so
|
| its store-down path degrades to the same captioned in-memory workspace it always had. An
|
| API caller has no such dict, and the honest answer to "persist this" with nowhere to
|
| persist it is an error β not a 200 over a write that evaporates.
|
| """
|
|
|
|
|
| @dataclass
|
| class EventResult:
|
| """What the HANDLER needs to tell its adapter β the three things it cannot do itself.
|
|
|
| `rerender` is the old return value with its old meaning: "this page must re-render from
|
| server state", NOT "handled" (see `handle_one`'s docstring β the distinction is the
|
| difference between a snappy grid and a sluggish one).
|
| """
|
| rerender: bool = False
|
| doc: Optional[dict] = None
|
| toast: Optional[str] = None
|
|
|
|
|
| @dataclass
|
| class EventCtx:
|
| """Everything the handler is allowed to know, and nothing about how it is being served.
|
|
|
| β `allowed_pids` is a SECURITY parameter: it is the pool this caller may touch at all, and
|
| every row-scoped event (overlay_patch, doc_*, cohort membership, add_to_list, memberPids)
|
| is intersected with it. `admin` and `uname` are likewise the HOST's statement of identity β
|
| never anything the browser said about itself.
|
|
|
| `seen_ids` is ADAPTER-OWNED dedup state (the event log resends its recent window on every
|
| emit, so consumed ids must stay consumed): Streamlit passes a session-state dict, HTTP
|
| passes the caller's per-session mapping.
|
|
|
| `fallback_ws` is the store-unavailable workspace home, the OUTER dict keyed by username
|
| (exactly what `st.session_state.setdefault('cl_table_workspace', {})` is). HTTP passes
|
| None, and None + store down raises StoreUnavailable (β 503).
|
|
|
| `out` is the sink for the two parked answers, so `handle_one` can keep returning a bare
|
| bool exactly as it did in app.py while doc/toast still escape. (Additive amendment to X1's
|
| field list, 2026-07-30 β defaulted; recorded in the S1 mailbox.)
|
| """
|
| uname: str
|
| allowed_pids: frozenset
|
| fields: list
|
| measure_keys: frozenset = frozenset()
|
| resolved_ids: frozenset = frozenset()
|
| cohort_ids: frozenset = frozenset()
|
| measure_offer: tuple = ()
|
| visible_views: tuple = ()
|
| admin: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
| hidden_keys: frozenset = frozenset()
|
| scope_key: str = 'customer'
|
|
|
|
|
|
|
|
|
|
|
| table: Optional[object] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| st: Optional[object] = None
|
| seen_ids: Optional[MutableMapping] = None
|
| fallback_ws: Optional[MutableMapping] = None
|
| out: EventResult = field(default_factory=EventResult)
|
|
|
| def __post_init__(self):
|
|
|
|
|
|
|
| if self.seen_ids is None:
|
| self.seen_ids = {}
|
|
|
|
|
| def _store_of(ctx):
|
| """THE store handle this ctx reads and writes through (wave 25, R6b / D-16). ONE resolver,
|
| so "whose repo does this land in" has exactly one answer per event.
|
|
|
| Order, and each step is deliberate: the ctx's own tenant handle Β· else the handle the TABLE
|
| OPS object was built with (`table_store.make(..., st=β¦)` keeps it on `.st`, which is how the
|
| `ut_` path has been tenant-correct since wave 18) Β· else the module default.
|
|
|
| β THE MODULE DEFAULT IS TENANT #0, AND THAT IS THE BUG D-16 NAMES β not a safe base case.
|
| It is kept because it is byte-identical for tenant #0 (empty prefix, shared repo) and every
|
| non-HTTP caller still relies on it; what changed is that the HTTP adapters now pass a real
|
| handle, so the fallback is no longer what serves a Nurilab request. `verify_scopes` proves
|
| that with a negative control: force this to fall through and the residency legs go RED.
|
| """
|
| st = getattr(ctx, 'st', None)
|
| if st is not None:
|
| return st
|
| tbl = getattr(ctx, 'table', None)
|
| inner = getattr(tbl, 'st', None) if tbl is not None else None
|
| return inner if inner is not None else store
|
|
|
|
|
| def _blobs(ctx):
|
| """The BYTE store for this ctx β `_store_of`'s sibling, split out because it is NOT yet
|
| equivalent and pretending otherwise would hide a live residency hole.
|
|
|
| β `TenantRuntime` exposes `get/put/update/exists/available` and **no byte methods**
|
| (`harness/runtime.py`), so a tenant with its OWN repo has nowhere tenant-scoped to put a
|
| document's BYTES. The metadata half of D-16 is fixed here; the byte half needs
|
| `upload_bytes`/`download_bytes`/`delete_path` on the runtime, which is SESSION A's file β
|
| asked for in the mailbox and booked as debt rather than papered over with a fallback that
|
| reads as intentional. The moment those methods exist this resolver picks them up with no
|
| other change, which is why it is written as a capability test and not as a version check.
|
| """
|
| st = _store_of(ctx)
|
| return st if hasattr(st, 'upload_bytes') else store
|
|
|
|
|
|
|
| def _ag_overview_id():
|
| """`aios_grid.IG_OVERVIEW_ID`, resolved defensively.
|
|
|
| Function-local import, this module's convention for the layer above it. Falls back to the
|
| literal rather than raising: a store blip must not turn "refuse to delete" into "allow", and
|
| an unguarded delete here is the one that reads as success and undoes itself.
|
| """
|
| try:
|
| import aios_grid as _ago
|
| return _ago.IG_OVERVIEW_ID
|
| except Exception:
|
| return 'tpl_overview'
|
|
|
| def _tops(ctx):
|
| """The table-ops object this ctx operates on β the customer table unless the caller chose
|
| another topic. ONE resolution point so 'which bucket does this write land in' has exactly
|
| one answer per event.
|
|
|
| β WAVE 25 (R6b) β THIS FALLBACK IS STILL TENANT #0's, DELIBERATELY, AND IT IS THE ONE PIECE
|
| OF THE RESIDENCY FIX THAT DID NOT LAND. `cl_mod.TABLE_OPS` is a module-level singleton built
|
| with no store handle, so the customer and product table workspaces (views, fields, overlays,
|
| folders) resolve to tenant #0 for every caller. Scoping it here was written, measured and
|
| then REVERTED, because it would have been worse than the bug:
|
|
|
| the WRITE door (`routes_grid._ctx`) is this session's file and now carries a handle;
|
| the READ door is `routes_customers._ctx_for` / `routes_products` β files in NO session's
|
| fence this wave β and they carry none.
|
|
|
| Moving only the write side means a prefix tenant saves a view into `t/<slug>/β¦` and reads
|
| back from the unprefixed bucket forever: their own view vanishes the moment they save it.
|
| A latent split is worse than a stated residency error, and read and write must move in ONE
|
| change. Booked as an ask + debt with that exact shape. Tenant #0 is unaffected either way
|
| (empty namespace, default repo), which is why nothing today would have shown it.
|
|
|
| β Untouched by this wave regardless: the EMIT GUARD is D-40, deferred by ruling A-15.
|
| """
|
| if ctx.table is not None:
|
| return ctx.table
|
| return cl_mod.TABLE_OPS
|
|
|
|
|
| def _cohorts(ctx):
|
| """The COHORT store this ctx operates on β `_tops`'s sibling, and here for the same reason.
|
|
|
| β WAVE 19 / R9: a cohort belongs to its database. Every cohort verb below used to name
|
| `cohort_mod` directly, so a list built on the Product surface landed in `customer_cohorts`
|
| beside somebody's real customer lists β with PRODUCT pids in it (CRC32 hashes of SKU codes),
|
| which the customer page would then try to resolve against partner ids. Wave 16 answered that
|
| by REFUSING cohorts on other topics (`with_cohorts=False`); R9 says scope-parameterize
|
| instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the
|
| customer path is byte-unchanged.
|
| """
|
| return cohort_mod.scoped(ctx.scope_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
| _DOC_WIRE_KEYS = ('id', 'name', 'mime', 'size', 'by', 'ts')
|
|
|
|
|
| def docs_for(pids, scope_key='customer', uname='', admin=False, st=None):
|
| """`{"<pid>": [CustomerDoc, ...]}` for a SCOPED row set β the producer `payload.docs` lost.
|
|
|
| ββ WAVE 30 / CONTRACT C4 (D-138). The write door has been live and UNREACHABLE since EXIT-6:
|
| `doc_add`/`doc_fetch`/`doc_delete` all still work, and the comment inside that handler says
|
| exactly why nobody noticed β *"the React shell cannot reach it yet (`payload.docs` is set only
|
| by app.py)"*. `app.py` was deleted, and the producer went with it. Every client half survived:
|
| `RecordDetail`, `SwipeView` and `Documents` are complete, and all six `onDoc*` handlers in
|
| `CustomerGrid.tsx` are written as `payload?.docs ? β¦ : undefined`, so an ABSENT `docs` key is
|
| what has been switching the whole feature off. An empty dict is truthy and turns it on with an
|
| honest empty state.
|
| β THIS IS THE SIXTH INSTANCE OF THE REACHABILITY CLASS and the one `verify_reachability.py`
|
| cannot see β a payload key assembled through helpers and dict merges is not a text-matchable
|
| literal ([[reachable-is-not-the-same-as-built]], [[artifact-with-no-importer]]).
|
|
|
| β ONE READER, NOT A MATCHING PAIR. `routes_tables` (ut_*) and `routes_customers` (the customer
|
| scope) both call THIS β they do not each serialise. Two surfaces answering one question
|
| separately is how the same defect got reintroduced in the opposite direction inside a single
|
| commit ([[one-question-two-normalizers]]).
|
|
|
| β `pids` IS REQUIRED and is the row set the CALLER has already scoped β `shared_overlay.cells`'
|
| argument, for the same reason: there is no "every document in the tenant" call to reach for.
|
| A record with no documents gets `[]`, never a missing key, so a client can tell "none" from
|
| "not served".
|
| """
|
| wanted = {str(int(p)) for p in (pids or [])}
|
| if not wanted:
|
| return {}
|
| scope = str(scope_key or 'customer').strip().lower()
|
| key = _DOCS_KEY if scope in cohort_mod.LEGACY_SCOPES else f'{scope}_docs'
|
| try:
|
| stored = (st if st is not None else store).get(key) or {}
|
| except Exception:
|
| stored = {}
|
| out = {}
|
| for pid in wanted:
|
| row = stored.get(pid)
|
| docs = []
|
| for d in (row if isinstance(row, list) else []):
|
| if not isinstance(d, dict) or not d.get('id'):
|
| continue
|
| wire = {k: d.get(k) for k in _DOC_WIRE_KEYS}
|
|
|
|
|
|
|
| wire['canDelete'] = bool(admin or (d.get('by') and d.get('by') == uname))
|
| docs.append(wire)
|
| out[pid] = docs
|
| return out
|
|
|
|
|
| def _docs_key(ctx):
|
| """The DOCUMENTS bucket this ctx operates on β `_cohorts`'s sibling (wave 19, item 12's
|
| silent third case).
|
|
|
| β `_DOCS_KEY` STAYS the module constant with its shipped value: app.py re-imports it for the
|
| Streamlit docs payload and `verify_seam.py` asserts the two resolve to the same string. This
|
| resolves the CUSTOMER topic to exactly that constant and gives every other database its own
|
| bucket, so the pid keys inside one bucket all come from one id space.
|
| """
|
| scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower()
|
| if scope in cohort_mod.LEGACY_SCOPES:
|
| return _DOCS_KEY
|
| return f'{scope}_docs'
|
|
|
|
|
|
|
| def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
|
| """Durable Airtable-style view/schema/overlay state, with an honest session fallback.
|
|
|
| Wave-9 I17: the returned `views` are this user's OWN views MERGED with every view SHARED
|
| with them. Own views win a same-id collision β a personal view is the user's own object and
|
| must never be shadowed by somebody else's share.
|
|
|
| β `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries
|
| `memberPids`, which the writer's scope validated on the way IN β but the READER may have a
|
| narrower scope. Handing a Fisch-scoped user a view whose member list was built by a
|
| full-access user would leak the other BU's customers straight through the strict-isolation
|
| rule. So membership is re-filtered against the READER on the way OUT. Pass it wherever the
|
| result reaches a renderer or an event handler; omitting it drops memberPids entirely rather
|
| than trusting the stored list (fail-closed: a smaller view, never a wider one).
|
|
|
| MOVED from app.py:5323 (`_cl_table_workspace`). `admin` was a parameter defaulting to a live
|
| `is_admin()` call; it now always comes from `ctx.admin`, which is identical at the one call
|
| site because app.py computes `admin = bool(is_admin())` once per render. `allowed_pids`
|
| stays an EXPLICIT argument because the folder leg deliberately omits it.
|
| """
|
| uname = ctx.uname
|
| if _store_of(ctx).available():
|
| ws = _tops(ctx).workspace(
|
| uname, consume_corrections=bool(consume_corrections))
|
| try:
|
| shared = _tops(ctx).shared_views(uname, bool(ctx.admin))
|
| except Exception as _se:
|
| _tel.error('sharedviews:read', _se)
|
| shared = {}
|
|
|
|
|
|
|
| try:
|
| granted = _granted_views(ctx, uname)
|
| except Exception as _ge:
|
| _tel.error('sharedviews:granted', _ge)
|
| granted = {}
|
| if shared or granted:
|
| merged = dict(granted)
|
| merged.update(shared)
|
| merged.update(ws.get('views') or {})
|
| ws['views'] = {vid: scope_view(v, uname, allowed_pids)
|
| for vid, v in merged.items()}
|
| return ws
|
|
|
|
|
| if ctx.fallback_ws is None:
|
| raise StoreUnavailable('table workspace unavailable: no store and no session fallback')
|
| return ctx.fallback_ws.setdefault(
|
| uname, {'views': {}, 'fields': {}, 'overlays': {}})
|
|
|
|
|
| def _granted_views(ctx, uname):
|
| """{view_id: STAMPED view} for every view GRANTED to `uname` by name (wave 21, item 9, C1).
|
|
|
| The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's
|
| bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS
|
| bucket. An id granted on another topic simply finds nothing here and contributes nothing
|
| (fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner` so the
|
| client can synthesise the "Shared with me" group and disable edit affordances for
|
| role=view β the stamps live on the PROJECTION only, never written back to the store.
|
| `role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the
|
| registry listed only explicit entries), not the caller's rank.
|
| """
|
| import core.shares as shares
|
| tops = _tops(ctx)
|
| st = getattr(tops, 'st', None)
|
| ids = (shares.shared_with(uname, kind='view', st=st) or {}).get('view') or []
|
| out = {}
|
| for vid in ids:
|
| found = tops.find_view(vid) if hasattr(tops, 'find_view') else None
|
| if not found:
|
| continue
|
| owner, view = found
|
| if owner == uname:
|
| continue
|
| role = shares.role_for('view', vid, uname, st=st)
|
| if role not in ('view', 'edit'):
|
| continue
|
| v = dict(view)
|
| v['shared'] = True
|
| v['sharedRole'] = role
|
| v['owner'] = owner
|
| out[str(vid)] = v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| try:
|
| folder_ids = (shares.shared_with(uname, kind='folder', st=st) or {}).get('folder') or []
|
| except Exception:
|
| folder_ids = []
|
| for fid in folder_ids:
|
| found = tops.find_folder(fid) if hasattr(tops, 'find_folder') else None
|
| if not found:
|
| continue
|
| owner, folder, inside = found
|
| if owner == uname:
|
| continue
|
| role = shares.role_for('folder', fid, uname, st=st)
|
| if role not in ('view', 'edit'):
|
| continue
|
| for vid, view in inside.items():
|
| if str(vid) in out:
|
| continue
|
| v = dict(view)
|
| v['shared'] = True
|
| v['sharedRole'] = role
|
| v['owner'] = owner
|
|
|
|
|
|
|
| v['sharedFolder'] = str(folder.get('name') or '')[:80]
|
| out[str(vid)] = v
|
| return out
|
|
|
|
|
| def scope_view(view, uname, allowed_pids):
|
| """Re-scope a view to the READER. A no-op for a view the reader created.
|
|
|
| Two things travel inside a shared view that are SCOPE, not presentation:
|
| 1. `memberPids` β a customer list. Re-filtered against the reader's permitted pool.
|
| 2. cohort conditions β a cohort id is a permission handle owned by ONE user. For a reader
|
| who does not own it the leaf is UNANSWERABLE, and partial evaluation of an unanswerable
|
| leaf WIDENS `is none of` ([[cg-cohort-set-conditions]]) β i.e. it would show MORE rows,
|
| not fewer. So the whole filter tree is dropped for a foreign reader rather than
|
| evaluated with a hole in it. A visibly unfiltered shared view is recoverable; a
|
| silently widened one is a leak.
|
| """
|
| if not isinstance(view, dict) or view.get('createdBy') == uname:
|
| return view
|
| out = dict(view)
|
| cfg = dict(out.get('config') or {})
|
| mp = cfg.get('memberPids')
|
| if isinstance(mp, list):
|
| cfg['memberPids'] = ([p for p in mp if p in (allowed_pids or ())]
|
| if allowed_pids is not None else [])
|
| if tree_has_cohort(cfg.get('filters')):
|
| cfg['filters'] = []
|
| cfg['_scopedOut'] = 'cohort'
|
| out['config'] = cfg
|
| return out
|
|
|
|
|
| def tree_has_cohort(node):
|
| """True when a filter tree references the Cohorts column anywhere (any depth)."""
|
| import aios_grid as _agc
|
| col = _agc.COHORT_COLUMN
|
| if isinstance(node, list):
|
| return any(tree_has_cohort(n) for n in node)
|
| if not isinstance(node, dict):
|
| return False
|
| if node.get('colId') == col or node.get('field') == col:
|
| return True
|
| return tree_has_cohort(node.get('conditions') or node.get('children'))
|
|
|
|
|
| def measure_leaves(nodes):
|
| """Every leaf of a filter tree that carries a date WINDOW β i.e. every measure condition.
|
|
|
| Keyed off the window rather than the column name on purpose: a window is the one thing no
|
| column condition has, so this cannot mistake `revenue_ytd > 5000` for a measure.
|
| """
|
| out = []
|
| for node in nodes or []:
|
| if not isinstance(node, dict):
|
| continue
|
| if isinstance(node.get('children'), list):
|
| out.extend(measure_leaves(node['children']))
|
| elif node.get('window') is not None:
|
| out.append(node)
|
| return out
|
|
|
|
|
|
|
| def _field_echo_equal(accepted, raw):
|
| """Does the ACCEPTED def match what the client already renders? (wave-6 item 3)
|
|
|
| The no-blip law: a field_upsert whose acceptance changed NOTHING the client draws needs no
|
| re-render β the client is the ORIGIN of the change, its optimistic copy is already right,
|
| and the rerun it would buy costs a full payload re-serialisation plus an iframe remount
|
| (the "blip" the owner named). STRICT per-prop equality after collapsing the empty forms
|
| ('' / [] / {} / absent all render identically): any normalisation a validator applied is a
|
| real difference the client must be SHOWN. Fail-closed β unsure means not equal means rerun.
|
| """
|
| empty = ('', [], {})
|
| for k in _FIELD_ECHO_PROPS:
|
| a, b = accepted.get(k), raw.get(k) if isinstance(raw, dict) else None
|
| if k == 'scope':
|
|
|
|
|
| a = None if a == 'global' else a
|
| b = None if b == 'global' else b
|
| if (None if a in empty else a) != (None if b in empty else b):
|
| return False
|
| return True
|
|
|
|
|
| def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120):
|
| """Return a durable, case-insensitively unique field/view display name."""
|
| limit = max(1, int(max_len))
|
|
|
| def _clean(value):
|
| return ' '.join(str(value or '').split())
|
|
|
| base = (_clean(wanted) or _clean(fallback) or 'Untitled')[:limit].rstrip()
|
| taken = {_clean(value).casefold() for value in existing if _clean(value)}
|
| if base.casefold() not in taken:
|
| return base
|
| index = 2
|
| while True:
|
| suffix = f' {index}'
|
| stem = base[:max(0, limit - len(suffix))].rstrip()
|
| candidate = f'{stem}{suffix}' if stem else str(index)[-limit:]
|
| if candidate.casefold() not in taken:
|
| return candidate
|
| index += 1
|
|
|
|
|
| def _known_usernames():
|
| """Real account names, for C4's fail-closed `users` grant. None = "could not resolve".
|
|
|
| None means DO NOT FILTER, deliberately. Returning an empty set on a transient registry
|
| error would silently collapse a user's stored 'specific users' grant to 'personal' on the
|
| very next autosave β destroying a setting because a read blipped. The grant is inert today
|
| anyway (views are per-user; nothing reads it to allow cross-user access), so the honest
|
| trade is: never invent access, never destroy a stored intent either.
|
| """
|
| try:
|
| reg = set(users.registry() or {})
|
| except Exception as _ue:
|
| _tel.error('viewperms:users', _ue)
|
| return None
|
|
|
|
|
|
|
|
|
|
|
| return reg or None
|
|
|
|
|
|
|
| def handle_events(events, ctx):
|
| """The event LOG entry point β one EventResult. The `[-24:]` window lives here.
|
|
|
| β THE VALUE IS AN EVENT LOG `{'events': [...]}`, NOT ONE EVENT (2026-07-27). A component
|
| has exactly one value slot, and two quick emits share it: creating a field emits
|
| `field_upsert` and the insertColumn autosave emits `view_upsert` 420ms later β with the
|
| server still inside the first rerun, the second write clobbered the slot before any run
|
| read it, and the field creation VANISHED with no error anywhere (measured live; the same
|
| race sat latent under overlay-field creation, masked by localStorage). The client now
|
| sends its recent window of events; each is processed exactly once, keyed by its id. The
|
| single-event shape is still accepted β it is what an older client in a stale tab sends.
|
|
|
| Accepts everything the component value can be: the log dict, a bare list of events (what
|
| the HTTP body's `events` array unwraps to), or one legacy single-event object.
|
|
|
| `EventResult.rerender` is True when ANY processed event demands a re-render from server
|
| state β the same OR-fold `_cl_handle_grid_event` did.
|
| """
|
| if isinstance(events, dict) and isinstance(events.get('events'), list):
|
| seq = events['events'][-24:]
|
| elif isinstance(events, list):
|
| seq = events[-24:]
|
| else:
|
| seq = [events]
|
| rerun = False
|
| for one in seq:
|
| rerun = handle_one(one, ctx) or rerun
|
| ctx.out.rerender = rerun
|
| return ctx.out
|
|
|
|
|
| def handle_one(event, ctx):
|
| """Validate ONE untrusted component event.
|
|
|
| Odoo-source fields are never accepted as cell updates. View and schema objects are
|
| user-scoped; row patches additionally require the pid to be in the currently permitted
|
| Customer List pool.
|
|
|
| RETURN VALUE = "this page must re-render from server state", NOT "handled".
|
| The distinction is the difference between a snappy grid and a sluggish one.
|
| Persisting is not by itself a reason to rerun: Streamlit has ALREADY re-run the
|
| script once simply to deliver the component value, and the client is the ORIGIN
|
| of the change, so its own state is correct before the server hears about it. An
|
| extra st.rerun() therefore buys nothing and costs a second full page run.
|
| Measured on the 1,550-row book (2026-07-25): one filter tweak produced TWO
|
| page_customer_data runs ~2.8s apart, each re-serialising the whole ~576 KB row
|
| payload β and the handler on the second run is a guaranteed no-op, because the
|
| event-id dedupe two lines below has already consumed that id.
|
|
|
| THE WAVE-6 NO-BLIP LAW (item 3, 2026-07-27) β True ONLY when the next render
|
| genuinely differs from what the client already draws:
|
| field_upsert β False when the accepted def ECHOES the client's copy
|
| (_field_echo_equal); True on any validator mutation, any
|
| permissions refusal, and ALWAYS when a measure window changed
|
| (values must be re-resolved server-side).
|
| overlay_patch β False when every requested update was accepted verbatim;
|
| True the moment anything was refused or truncated.
|
| field_delete β False: the client removed the column optimistically and the
|
| store scrub is invisible to the next render.
|
| field_duplicate β True only when host-held VALUES must flow back (custom_ cell
|
| copies, measure_ recomputes); formula/created_time clones
|
| compute client-side.
|
| view_delete β False (the deleted view simply stops being served).
|
| view_upsert β unchanged: False, except an unresolved measure condition.
|
| cohort_* / add_to_list β True: the lists payload is server truth, and these are
|
| not the hot path.
|
|
|
| The id dedupe is a SET (not a last-id slot): the event log resends the whole recent
|
| window on every emit, so every id must stay consumed, not just the latest one.
|
| """
|
|
|
|
|
| uname = ctx.uname
|
| allowed_pids = ctx.allowed_pids
|
| fields = ctx.fields
|
| measure_keys = ctx.measure_keys
|
| resolved_ids = ctx.resolved_ids
|
| cohort_ids = ctx.cohort_ids
|
| measure_offer = ctx.measure_offer
|
| visible_views = ctx.visible_views
|
| admin = ctx.admin
|
| scope_key = ctx.scope_key
|
|
|
| if not isinstance(event, dict):
|
| return False
|
| event_id = str(event.get('id') or '')[:180]
|
| seen = ctx.seen_ids
|
| if not event_id or event_id in seen:
|
| return False
|
| seen[event_id] = True
|
| if len(seen) > 400:
|
| for stale in list(seen)[:200]:
|
| seen.pop(stale, None)
|
| kind = event.get('type')
|
| field_by_key = {f['key']: f for f in fields}
|
| valid_keys = set(field_by_key)
|
| overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'}
|
|
|
|
|
| ws = table_workspace(
|
| ctx, allowed_pids=allowed_pids, consume_corrections=False)
|
|
|
| def _session_ready():
|
| ws.setdefault('views', {})
|
| ws.setdefault('fields', {})
|
| ws.setdefault('overlays', {})
|
|
|
| if kind == 'view_select':
|
|
|
|
|
|
|
|
|
|
|
| vid = str(event.get('viewId') or '').strip()[:120]
|
| if vid and _store_of(ctx).available():
|
| try:
|
| _tops(ctx).save_active_view(uname, vid)
|
| except Exception as _ae:
|
| _tel.error('grid:view-select', _ae)
|
| return False
|
|
|
| if kind == 'record_layout':
|
|
|
|
|
|
|
|
|
| raw_order = event.get('order')
|
| if not isinstance(raw_order, list):
|
| return False
|
| order, seen = [], set()
|
| for k in raw_order[:200]:
|
| k = str(k or '').strip()[:80]
|
| if k and k in valid_keys and k not in seen:
|
| seen.add(k)
|
| order.append(k)
|
| if _store_of(ctx).available():
|
| _tops(ctx).save_record_layout(uname, order)
|
| else:
|
| _session_ready()
|
| if order:
|
| ws['recordLayout'] = {'order': order}
|
| else:
|
| ws.pop('recordLayout', None)
|
|
|
|
|
| return False
|
|
|
| if kind == 'view_upsert':
|
| raw = event.get('view')
|
| if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict):
|
| return False
|
| view_id = str(raw.get('id') or '').strip()[:120]
|
| name = str(raw.get('name') or '').strip()[:120]
|
| requested_name = name
|
| if not view_id or not name:
|
| return False
|
| cfg = raw['config']
|
|
|
|
|
|
|
|
|
| import aios_grid as _ag
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| filters = _ag.clean_filter_tree(cfg.get('filters'),
|
| valid_keys | set(measure_keys),
|
| cohort_ids=set(cohort_ids))
|
| sorts = []
|
| for rule in list(cfg.get('sorts') or [])[:20]:
|
| if (isinstance(rule, dict) and rule.get('colId') in valid_keys
|
| and rule.get('dir') in ('asc', 'desc')):
|
| sorts.append({'colId': rule['colId'], 'dir': rule['dir']})
|
| order = list(dict.fromkeys(
|
| key for key in list(cfg.get('order') or []) if key in valid_keys))
|
| order += [field['key'] for field in fields if field['key'] not in order]
|
| visible = list(dict.fromkeys(
|
| key for key in list(cfg.get('visible') or []) if key in valid_keys))
|
| widths = {}
|
| raw_widths = cfg.get('widths') if isinstance(cfg.get('widths'), dict) else {}
|
| for key, value in raw_widths.items():
|
| if key in valid_keys:
|
| try:
|
| widths[key] = max(60, min(600, round(float(value))))
|
| except (TypeError, ValueError):
|
| pass
|
|
|
|
|
|
|
|
|
|
|
| cohort_lock = str(cfg.get('cohortLock') or '').strip()[:120]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _is_locked_view = view_id in set(cohort_ids)
|
| if _is_locked_view:
|
| cohort_lock = view_id
|
| config = {
|
| 'filters': filters,
|
|
|
|
|
| 'filterConj': 'or' if cfg.get('filterConj') == 'or' else 'and',
|
| **({'cohortLock': cohort_lock}
|
| if cohort_lock and cohort_lock in set(cohort_ids) else {}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 'important': cfg.get('important') is True,
|
| 'sorts': sorts,
|
| 'groupBy': cfg.get('groupBy') if cfg.get('groupBy') in valid_keys else None,
|
| 'colorBy': cfg.get('colorBy') if cfg.get('colorBy') in valid_keys else None,
|
| 'rowHeightMode': (cfg.get('rowHeightMode')
|
| if cfg.get('rowHeightMode') in ('short', 'medium', 'tall')
|
| else 'short'),
|
| 'order': order,
|
| 'visible': visible,
|
| 'widths': widths,
|
| 'memberPids': [pid for pid in list(cfg.get('memberPids') or [])
|
| if isinstance(pid, int) and pid in allowed_pids],
|
| }
|
|
|
|
|
| display = _ag._clean_display(cfg.get('display'), valid_keys)
|
| if display:
|
| config['display'] = display
|
|
|
|
|
|
|
| if cfg.get('frozenCount') is not None:
|
| try:
|
| config['frozenCount'] = max(1, min(8, int(cfg['frozenCount'])))
|
| except (TypeError, ValueError):
|
| pass
|
|
|
|
|
|
|
| prior = (ws.get('views') or {}).get(view_id) or {}
|
|
|
|
|
|
|
|
|
| _raw_shared = _tops(ctx).shared_view(view_id) if _store_of(ctx).available() else None
|
| if _raw_shared is not None and not prior:
|
| return False
|
| if _raw_shared is not None and not _tstore._may_edit(_raw_shared, uname, admin):
|
| return False
|
| is_new = not prior
|
|
|
|
|
| creator = prior.get('createdBy') or uname
|
|
|
|
|
|
|
| if not is_new and not _tstore._may_administer(prior, uname, admin):
|
| raw = dict(raw)
|
| raw['permissions'] = prior.get('permissions')
|
| raw['locked'] = prior.get('locked')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _mode_frozen = (prior.get('locked') and raw.get('locked') is not False
|
| and prior.get('kind') != 'system'
|
| and view_id != 'all-customers')
|
| if _mode_frozen:
|
| if prior.get('config', {}).get('display'):
|
| config['display'] = dict(prior['config']['display'])
|
| else:
|
| config.pop('display', None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _kind = (prior.get('kind') if not is_new
|
| else (raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom')
|
| else 'custom'))
|
| if _kind == 'system' or view_id == 'all-customers':
|
| perms = {'edit': 'personal'}
|
| else:
|
| perms = _ag.clean_view_permissions(
|
| raw.get('permissions'),
|
| default='personal' if is_new else 'collaborative',
|
| known_users=_known_usernames())
|
|
|
|
|
|
|
|
|
|
|
|
|
| may_lock = admin or uname == creator
|
| locked = bool(raw.get('locked')) if may_lock else bool(prior.get('locked'))
|
|
|
|
|
|
|
| if _mode_frozen:
|
| was = (prior.get('config') or {}).get('display')
|
| now = config.get('display')
|
| if (was or {}).get('mode') != (now or {}).get('mode'):
|
| if was:
|
| config['display'] = was
|
| else:
|
| config.pop('display', None)
|
|
|
|
|
|
|
| if _is_locked_view and _store_of(ctx).available():
|
| _sets = _cohorts(ctx)
|
| _cur = (_sets.all_for(uname).get(view_id) or {}).get('name')
|
| if name and name != _cur:
|
| try:
|
| _sets.rename(uname, view_id, name)
|
| except ValueError as _e:
|
| _tel.error('cohort:rename-via-view', _e)
|
| view = {'id': view_id, 'name': name,
|
|
|
|
|
|
|
| 'kind': _ag.LOCKED_VIEW_KIND if _is_locked_view
|
| else raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom')
|
| else 'custom',
|
| 'locked': locked,
|
| 'createdBy': creator,
|
| 'permissions': perms,
|
| 'note': str(raw.get('note') or '')[:2000],
|
| 'config': config}
|
|
|
|
|
|
|
| _reserved_view_names = [
|
| value.get('name') for value in visible_views
|
| if (isinstance(value, dict) and value.get('id') != view_id
|
| and value.get('kind') in ('system', 'list'))
|
| ]
|
|
|
|
|
|
|
| _scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower()
|
| _sys_name = ('All products' if _scope == 'product'
|
| else 'All records' if _scope.startswith('ut_') else 'All customers')
|
| if view_id != 'all-customers':
|
| _reserved_view_names.append(_sys_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _save_as = uname
|
| if _store_of(ctx).available():
|
| _foreign = _tops(ctx).find_view(view_id)
|
| if _foreign and _foreign[0] != uname:
|
| _o_uname, _stored = _foreign
|
| if not admin:
|
| import core.shares as _shares
|
| if _shares.role_for('view', view_id, uname,
|
| st=getattr(_tops(ctx), 'st', None)) != 'edit':
|
| ctx.out.toast = ('That view is shared with you read-only β ask its '
|
| 'owner for edit access.')
|
| return False
|
| view['createdBy'] = _stored.get('createdBy', _o_uname)
|
| if _stored.get('permissions') is not None:
|
| view['permissions'] = _stored.get('permissions')
|
| else:
|
| view.pop('permissions', None)
|
| _save_as = _o_uname
|
| if _store_of(ctx).available():
|
| view = (_tops(ctx).save_view(
|
| _save_as, view, reserved_names=_reserved_view_names, is_admin=admin) or view)
|
| else:
|
| _session_ready()
|
| _view_names = list(_reserved_view_names)
|
| _view_names.extend(
|
| value.get('name')
|
| for candidate_id, value in ws['views'].items()
|
| if candidate_id != view_id and isinstance(value, dict)
|
| )
|
| view['name'] = _unique_name(view.get('name'), _view_names)
|
| ws['views'][view_id] = view
|
| name_changed = view.get('name') != requested_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| from harness import measure_filter as _mfc
|
| unresolved = {str(r.get('id') or '') for r in measure_leaves(filters)
|
| if _mfc.rule_complete(r)} - set(resolved_ids)
|
| return name_changed or bool(unresolved - {''})
|
|
|
| if kind == 'view_delete':
|
| view_id = str(event.get('viewId') or '')[:120]
|
|
|
|
|
|
|
|
|
|
|
|
|
| if not view_id or view_id in ('all-customers', _ag_overview_id()):
|
| return False
|
|
|
|
|
|
|
|
|
|
|
| if _store_of(ctx).available() and view_id in _cohorts(ctx).all_for(uname):
|
| _cohorts(ctx).delete(uname, view_id)
|
| _tops(ctx).delete_view(uname, view_id)
|
|
|
|
|
| return True
|
|
|
|
|
|
|
| if _store_of(ctx).available():
|
| _raw_shared = _tops(ctx).shared_view(view_id)
|
| if _raw_shared is not None and not _tstore._may_administer(
|
| _raw_shared, uname, admin):
|
| return False
|
|
|
|
|
|
|
|
|
| _foreign = _tops(ctx).find_view(view_id)
|
| if _foreign and _foreign[0] != uname:
|
| import core.shares as _shares
|
| _sst = getattr(_tops(ctx), 'st', None)
|
| if not admin and _shares.role_for('view', view_id, uname,
|
| st=_sst) != 'edit':
|
| ctx.out.toast = 'That view is shared with you read-only.'
|
| return False
|
| _tops(ctx).delete_view(_foreign[0], view_id)
|
| try:
|
| _shares.set_grants('view', view_id, [], st=_sst)
|
| except Exception:
|
| pass
|
| return False
|
| _tops(ctx).delete_view(uname, view_id)
|
| else:
|
| _session_ready()
|
| ws['views'].pop(view_id, None)
|
|
|
|
|
| return False
|
|
|
| if kind == 'field_upsert':
|
| raw = event.get('field')
|
| if not isinstance(raw, dict):
|
| return False
|
| key = str(raw.get('key') or '')[:80]
|
| import aios_grid as _ag2
|
| if key.startswith(_ag2.MEASURE_FIELD_PREFIX):
|
|
|
|
|
|
|
| field = _ag2.clean_measure_field(raw, {m['key']: m for m in measure_offer})
|
| if field is None:
|
| return False
|
| fmt = _ag2._clean_format(raw.get('format'), field.get('type'))
|
| if fmt:
|
| field['format'] = fmt
|
| elif key in field_by_key and not key.startswith('custom_'):
|
| base = field_by_key[key]
|
| field = {**base, 'note': str(raw.get('note') or '')[:2000]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _agg = raw.get('agg')
|
| if _agg in _ag2.FIELD_AGGS:
|
| field['agg'] = _agg
|
| else:
|
| field.pop('agg', None)
|
|
|
|
|
| fmt = _ag2._clean_format(raw.get('format'), base.get('type'))
|
| if fmt:
|
| field['format'] = fmt
|
| else:
|
| field.pop('format', None)
|
|
|
|
|
|
|
|
|
| if (base.get('preset') and isinstance(base.get('measure'), dict)
|
| and isinstance(raw.get('measure'), dict)):
|
| window = _ag2._clean_window(raw['measure'].get('window'))
|
| if window is not None:
|
| field['measure'] = {'key': base['measure']['key'], 'window': window}
|
| if isinstance(raw.get('label'), str) and raw['label'].strip():
|
| field['label'] = str(raw['label'])[:120]
|
| elif key.startswith('custom_') and raw.get('type') in _ag2.CUSTOM_FIELD_TYPES:
|
| ftype = raw['type']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if ftype in ('link', 'rollup'):
|
| return False
|
| readonly = ftype in _ag2.READONLY_CUSTOM_TYPES
|
|
|
|
|
|
|
| if not readonly and raw.get('source') != 'overlay':
|
| return False
|
| field = {'key': key, 'label': str(raw.get('label') or 'Untitled')[:120],
|
| 'type': ftype, 'source': 'odoo' if readonly else 'overlay',
|
| 'default': True, 'custom': True,
|
| 'note': str(raw.get('note') or '')[:2000]}
|
| if readonly:
|
| field['derived'] = True
|
|
|
|
|
|
|
| field['filterable'] = True
|
|
|
|
|
|
|
|
|
|
|
|
|
| _agg = raw.get('agg')
|
| if _agg in _ag2.FIELD_AGGS and not readonly:
|
| field['agg'] = _agg
|
|
|
|
|
|
|
| if ftype in ('select', 'multiselect'):
|
| field['options'] = _ag2._clean_options(raw.get('options'))
|
| if not field['options']:
|
| return False
|
| field.update(_ag2._choice_appearance(raw, field['options']))
|
| if ftype == 'rating':
|
| field['max'] = _ag2._clean_rating_max(raw.get('max'))
|
| if ftype == 'formula':
|
|
|
|
|
| formula = _ag2._clean_formula(raw.get('formula'), valid_keys)
|
| if formula is None:
|
| return False
|
| field['formula'] = formula
|
| if ftype == 'automation':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if isinstance(raw.get('automation'), dict):
|
| try:
|
| _flow_ids = frozenset((ctx.table.st if ctx.table is not None
|
| else _tops(ctx).st).get('automations') or {})
|
| except Exception:
|
| _flow_ids = frozenset()
|
| auto = _ag2._clean_automation(raw.get('automation'), valid_keys,
|
| flow_ids=_flow_ids)
|
| if auto is None:
|
| return False
|
| field['automation'] = auto
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if ftype == 'code' and isinstance(raw.get('code'), dict):
|
| code_bag = _ag2._clean_code(raw.get('code'))
|
| if code_bag:
|
| field['code'] = code_bag
|
| fmt = _ag2._clean_format(raw.get('format'), ftype)
|
| if fmt:
|
| field['format'] = fmt
|
| else:
|
| return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| needs_values = False
|
| refused_perms = False
|
| if key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX):
|
| prior = ws.get('fields', {}).get(key)
|
| prior = prior if isinstance(prior, dict) else None
|
| if prior is None:
|
| field['createdBy'] = uname
|
|
|
|
|
|
|
| if scope_key == 'cohort' and raw.get('scope') == 'cohort':
|
| field['scope'] = 'cohort'
|
| else:
|
| if isinstance(prior.get('createdBy'), str) and prior['createdBy'].strip():
|
| field['createdBy'] = prior['createdBy']
|
|
|
|
|
| if prior.get('scope') == 'cohort':
|
| field['scope'] = 'cohort'
|
| want = _ag2._clean_permissions(raw.get('permissions'))
|
| have = _ag2._clean_permissions((prior or {}).get('permissions'))
|
| owner = field.get('createdBy')
|
| if want is not None and (admin or (owner and owner == uname)):
|
| field['permissions'] = want
|
| else:
|
| if have is not None:
|
| field['permissions'] = have
|
|
|
|
|
| refused_perms = want is not None and want != have
|
|
|
|
|
| if key.startswith(_ag2.MEASURE_FIELD_PREFIX):
|
| needs_values = prior is None or prior.get('measure') != field.get('measure')
|
|
|
|
|
| _workspace_field_keys = set((ws.get('fields') or {}))
|
| _reserved_field_names = [
|
| value.get('label') for candidate_key, value in field_by_key.items()
|
| if (candidate_key != key and candidate_key not in _workspace_field_keys
|
| and isinstance(value, dict))
|
| ]
|
| if _store_of(ctx).available():
|
| field = (_tops(ctx).save_field(
|
| uname, field, reserved_names=_reserved_field_names,
|
| correction_id=event_id) or field)
|
| else:
|
| _session_ready()
|
| _field_names = list(_reserved_field_names)
|
| _field_names.extend(
|
| value.get('label')
|
| for candidate_key, value in ws['fields'].items()
|
| if candidate_key != key and isinstance(value, dict)
|
| )
|
| requested_label = ' '.join(
|
| str(field.get('label') or 'Untitled').split())[:120].rstrip()
|
| field.pop('labelCorrectedFrom', None)
|
| field.pop('labelCorrectionId', None)
|
| field['label'] = _unique_name(requested_label, _field_names)
|
| if field['label'] != requested_label and event_id:
|
| field['labelCorrectedFrom'] = requested_label
|
| field['labelCorrectionId'] = str(event_id)[:180]
|
|
|
|
|
|
|
| if _tstore.source_override_is_empty(field):
|
| ws['fields'].pop(key, None)
|
| else:
|
| ws['fields'][key] = field
|
| if needs_values or refused_perms:
|
| return True
|
|
|
|
|
| return not _field_echo_equal(field, raw)
|
|
|
| if kind in ('cohort_add', 'cohort_remove', 'cohort_rename', 'cohort_delete'):
|
|
|
|
|
|
|
|
|
| if not _store_of(ctx).available():
|
| return False
|
| sets = _cohorts(ctx)
|
| cohort_id = str(event.get('cohortId') or '').strip()[:120]
|
| if not cohort_id or cohort_id not in sets.all_for(uname):
|
| return False
|
| try:
|
| if kind == 'cohort_rename':
|
| name = str(event.get('name') or '').strip()[:120]
|
| if not name:
|
| return False
|
| sets.rename(uname, cohort_id, name)
|
| elif kind == 'cohort_delete':
|
| sets.delete(uname, cohort_id)
|
| else:
|
| raw_pids = event.get('pids')
|
| if not isinstance(raw_pids, list):
|
| return False
|
| pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids]
|
| if not pids:
|
| return False
|
| if kind == 'cohort_add':
|
| sets.add_members(uname, cohort_id, pids)
|
| else:
|
| sets.remove_members(uname, cohort_id, pids)
|
| except ValueError as e:
|
| _tel.error('cohort:panel-edit', e)
|
| return False
|
|
|
|
|
| return True
|
|
|
| if kind in ('doc_add', 'doc_delete', 'doc_fetch'):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import base64 as _b64c
|
| import re as _re_mod
|
| if not _store_of(ctx).available():
|
| return False
|
| docs_key = _docs_key(ctx)
|
| docs_dir = 'docs' if docs_key == _DOCS_KEY else f'docs/{docs_key[:-5]}'
|
| pid = event.get('pid')
|
| doc_id = str(event.get('docId') or '').strip()[:80]
|
|
|
|
|
|
|
| if not isinstance(pid, int) or pid not in allowed_pids or not doc_id:
|
| return False
|
|
|
|
|
|
|
|
|
| _st_docs = _store_of(ctx)
|
| meta_all = _st_docs.get(docs_key) or {}
|
| row = list((meta_all.get(str(pid)) or []))
|
|
|
| if kind == 'doc_add':
|
| raw_b64 = event.get('data_b64')
|
| name = str(event.get('name') or 'document')[:200]
|
| if not isinstance(raw_b64, str) or not raw_b64:
|
| return False
|
| try:
|
| blob = _b64c.b64decode(raw_b64, validate=True)
|
| except Exception:
|
| return False
|
|
|
|
|
|
|
| if not blob or len(blob) > _DOC_MAX_BYTES:
|
| return False
|
| if len(row) >= _DOC_MAX_PER_ROW or any(d.get('id') == doc_id for d in row):
|
| return False
|
| safe = _re_mod.sub(r'[^A-Za-z0-9._-]', '_', name)[:80] or 'file'
|
| path = f'{docs_dir}/{pid}/{doc_id}_{safe}'
|
| try:
|
| _blobs(ctx).upload_bytes(
|
| path, blob, message=f'doc {doc_id} for {scope_key} {pid}')
|
| except Exception as _ue:
|
| _tel.error('docs:upload', _ue)
|
| return False
|
| row.append({'id': doc_id, 'name': name,
|
| 'mime': str(event.get('mime') or 'application/octet-stream')[:120],
|
| 'size': len(blob), 'by': uname,
|
| 'ts': dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
| 'path': path})
|
| elif kind == 'doc_delete':
|
| hit = next((d for d in row if d.get('id') == doc_id), None)
|
| if not hit:
|
| return False
|
|
|
|
|
| if hit.get('by') != uname and not admin:
|
| return False
|
| _blobs(ctx).delete_path(hit.get('path') or '')
|
| row = [d for d in row if d.get('id') != doc_id]
|
| else:
|
| hit = next((d for d in row if d.get('id') == doc_id), None)
|
| if not hit:
|
| return False
|
| blob = _blobs(ctx).download_bytes(hit.get('path') or '')
|
| if blob is None:
|
| return False
|
|
|
|
|
| ctx.out.doc = {
|
| 'pid': pid, 'docId': doc_id, 'name': hit.get('name') or 'document',
|
| 'mime': hit.get('mime') or 'application/octet-stream',
|
| 'data_b64': _b64c.b64encode(blob).decode('ascii')}
|
| return True
|
|
|
| def _write(cur):
|
| cur[str(pid)] = row
|
| return cur
|
|
|
|
|
|
|
|
|
|
|
|
|
| _st_docs.update(docs_key, _write, flush='async')
|
| return True
|
|
|
| if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate',
|
| 'item_move', 'folder_reorder'):
|
|
|
|
|
|
|
| import aios_grid as _agf
|
| if not _store_of(ctx).available():
|
| return False
|
| surface = str(event.get('surface') or '')
|
| if surface not in _agf.FOLDER_SURFACES:
|
| return False
|
|
|
|
|
|
|
| def _own_ids(sfc, cur_ws):
|
| if sfc == 'views':
|
| return set((cur_ws.get('views') or {}))
|
| return set(_cohorts(ctx).all_for(uname))
|
|
|
| def _mutate(cur_ws):
|
| folders = _agf.clean_folders(cur_ws.get('folders'))
|
| placed = dict(cur_ws.get('itemFolders') or {})
|
| lst = list(folders.get(surface) or [])
|
|
|
|
|
|
|
| fid = str(event.get('folderId') or '').strip()[:80]
|
| if kind == 'folder_create':
|
| name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
|
| if not fid or not name or len(lst) >= _agf.MAX_FOLDERS:
|
| return None
|
| if any(f['id'] == fid for f in lst):
|
| return None
|
| row = {'id': fid, 'name': name, 'order': len(lst)}
|
| icon = _agf.clean_folder_icon(event.get('icon'))
|
| if icon:
|
| row['icon'] = icon
|
| lst.append(row)
|
| elif kind == 'folder_rename':
|
| name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
|
| hit = next((f for f in lst if f['id'] == fid), None)
|
| if not hit or not name:
|
| return None
|
| hit['name'] = name
|
|
|
|
|
|
|
| if 'icon' in event:
|
| icon = _agf.clean_folder_icon(event.get('icon'))
|
| if icon:
|
| hit['icon'] = icon
|
| else:
|
| hit.pop('icon', None)
|
| elif kind == 'folder_delete':
|
| if not any(f['id'] == fid for f in lst):
|
| return None
|
| lst = [f for f in lst if f['id'] != fid]
|
|
|
|
|
| placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items()
|
| if v != fid}
|
| elif kind == 'folder_reorder':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| order = [str(i).strip()[:80] for i in (event.get('order') or [])]
|
| if not order:
|
| return None
|
| known = {f['id']: f for f in lst}
|
| seen, ranked = set(), []
|
| for fid_ in order:
|
| if fid_ in known and fid_ not in seen:
|
| seen.add(fid_)
|
| ranked.append(known[fid_])
|
| ranked.extend(f for f in lst if f['id'] not in seen)
|
| if len(ranked) != len(lst):
|
| return None
|
| for i, f in enumerate(ranked):
|
| f['order'] = i
|
| lst = ranked
|
| elif kind == 'folder_duplicate':
|
| new_id = str(event.get('newId') or '').strip()[:80]
|
| src = next((f for f in lst if f['id'] == fid), None)
|
| if not src or not new_id or len(lst) >= _agf.MAX_FOLDERS:
|
| return None
|
| if any(f['id'] == new_id for f in lst):
|
| return None
|
| lst.append({'id': new_id, 'name': f"{src['name']} copy"[:_agf.MAX_FOLDER_NAME],
|
| 'order': len(lst)})
|
|
|
|
|
|
|
|
|
|
|
| else:
|
| item_id = str(event.get('itemId') or '').strip()[:120]
|
| target = event.get('folderId')
|
| if not item_id or item_id not in _own_ids(surface, cur_ws):
|
| return None
|
| cur = dict(placed.get(surface) or {})
|
| if target is None:
|
|
|
|
|
|
|
| cur.pop(item_id, None)
|
| elif str(target) == _agf.ROOT_PLACEMENT:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| cur[item_id] = _agf.ROOT_PLACEMENT
|
| else:
|
| tid = str(target)[:80]
|
| if not any(f['id'] == tid for f in lst):
|
| return None
|
| cur[item_id] = tid
|
| placed[surface] = cur
|
| folders[surface] = lst
|
| clean = _agf.clean_folders(folders)
|
| return clean, _agf.clean_item_folders(
|
| placed, clean,
|
| {sfc: _own_ids(sfc, cur_ws) for sfc in _agf.FOLDER_SURFACES})
|
|
|
|
|
|
|
|
|
|
|
|
|
| _res = _mutate(table_workspace(ctx, consume_corrections=False))
|
| if _res is None:
|
| return False
|
| _tops(ctx).save_folders(uname, _res[0], _res[1])
|
|
|
|
|
| return True
|
|
|
| if kind == 'view_reorder':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| raw_order = event.get('order')
|
| if not isinstance(raw_order, list):
|
| return False
|
| order, seen_v = [], set()
|
| for vid in raw_order[:MAX_VIEW_ORDER]:
|
| vid = str(vid or '').strip()[:120]
|
| if vid and vid not in seen_v:
|
| seen_v.add(vid)
|
| order.append(vid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if _store_of(ctx).available():
|
| _tops(ctx).save_view_order(uname, order)
|
| else:
|
| _session_ready()
|
| if order:
|
| ws['viewOrder'] = order
|
| else:
|
| ws.pop('viewOrder', None)
|
|
|
| return True
|
|
|
| if kind == 'field_delete':
|
|
|
|
|
|
|
|
|
|
|
|
|
| key = str(event.get('key') or '')[:80]
|
| import aios_grid as _ag3
|
| if not (key.startswith('custom_') or key.startswith(_ag3.MEASURE_FIELD_PREFIX)):
|
| return False
|
| if _store_of(ctx).available():
|
| _tops(ctx).delete_field(uname, key)
|
| else:
|
| _session_ready()
|
| ws['fields'].pop(key, None)
|
| for _row in ws['overlays'].values():
|
| if isinstance(_row, dict):
|
| _row.pop(key, None)
|
|
|
|
|
| return False
|
|
|
| if kind == 'choice_rename':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| key = str(event.get('key') or '')[:80]
|
| pairs = []
|
| for r in (event.get('renames') or ()):
|
| if not isinstance(r, dict):
|
| continue
|
| a, b = str(r.get('from') or '')[:200], str(r.get('to') or '')[:200]
|
| if a and b and a != b:
|
| pairs.append((a, b))
|
| field = field_by_key.get(key)
|
| if not key or not pairs or not isinstance(field, dict):
|
| return False
|
| if str(field.get('type') or '') not in ('select', 'multiselect', 'status'):
|
| return False
|
|
|
|
|
| if key in (ctx.hidden_keys or frozenset()):
|
| return False
|
| mapping = dict(pairs)
|
|
|
| def _rename_in(ws_):
|
| fields_ = ws_.setdefault('fields', {})
|
| fld = fields_.get(key)
|
| if isinstance(fld, dict) and isinstance(fld.get('choices'), list):
|
| seen, out = set(), []
|
| for c in fld['choices']:
|
| if isinstance(c, dict):
|
| nm = mapping.get(str(c.get('name') or ''), None)
|
| if nm is not None:
|
| c = {**c, 'name': nm}
|
| ident = str(c.get('name') or '')
|
| else:
|
| c = mapping.get(str(c), str(c))
|
| ident = str(c)
|
|
|
|
|
|
|
|
|
| if ident in seen:
|
| continue
|
| seen.add(ident)
|
| out.append(c)
|
| fld['choices'] = out
|
| fields_[key] = fld
|
| for row in (ws_.get('overlays') or {}).values():
|
| if not isinstance(row, dict) or key not in row:
|
| continue
|
| val = row[key]
|
| if isinstance(val, list):
|
| merged, out2 = set(), []
|
| for v in val:
|
| nv = mapping.get(str(v), str(v))
|
| if nv not in merged:
|
| merged.add(nv)
|
| out2.append(nv)
|
| row[key] = out2
|
| elif isinstance(val, str) and val in mapping:
|
| row[key] = mapping[val]
|
|
|
| for view in (ws_.get('views') or {}).values():
|
| if not isinstance(view, dict):
|
| continue
|
| cfg = view.get('config') if isinstance(view.get('config'), dict) else view
|
|
|
| def _walk(nodes):
|
| for n in nodes or ():
|
| if not isinstance(n, dict):
|
| continue
|
| if isinstance(n.get('children'), list):
|
| _walk(n['children'])
|
| continue
|
| if str(n.get('colId') or '') != key:
|
| continue
|
| v = n.get('value')
|
| if isinstance(v, str) and v in mapping:
|
| n['value'] = mapping[v]
|
| elif isinstance(v, list):
|
| n['value'] = [mapping.get(str(x), x) for x in v]
|
|
|
| _walk(cfg.get('filters') or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| return ws_
|
|
|
| if _store_of(ctx).available():
|
| _tops(ctx).rename_choice_values(uname, _rename_in)
|
| else:
|
| _session_ready()
|
| _rename_in(ws)
|
|
|
|
|
| return True
|
|
|
| if kind == 'field_duplicate':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| src_key = str(event.get('sourceKey') or '')[:80]
|
| new_key = str(event.get('key') or '')[:80]
|
| import aios_grid as _ag5
|
| prefix = ('custom_' if src_key.startswith('custom_')
|
| else _ag5.MEASURE_FIELD_PREFIX
|
| if src_key.startswith(_ag5.MEASURE_FIELD_PREFIX) else None)
|
| if (prefix is None or not new_key.startswith(prefix) or new_key == src_key
|
| or new_key in field_by_key):
|
| return False
|
| src = ws.get('fields', {}).get(src_key)
|
| if not isinstance(src, dict):
|
| return False
|
| clone = dict(src)
|
| clone['key'] = new_key
|
| label = str(event.get('label') or '').strip()[:120]
|
| requested_label = label or (str(src.get('label') or 'Untitled')[:110] + ' copy')
|
| _workspace_field_keys = set((ws.get('fields') or {}))
|
| _reserved_field_names = [
|
| value.get('label') for candidate_key, value in field_by_key.items()
|
| if (candidate_key != new_key and candidate_key not in _workspace_field_keys
|
| and isinstance(value, dict))
|
| ]
|
| clone['label'] = requested_label
|
| clone['createdBy'] = uname
|
|
|
|
|
|
|
| if scope_key == 'cohort' and event.get('scope') in ('cohort', 'global'):
|
| if event['scope'] == 'cohort':
|
| clone['scope'] = 'cohort'
|
| else:
|
| clone.pop('scope', None)
|
| if _store_of(ctx).available():
|
| clone = (_tops(ctx).duplicate_field(
|
| uname, src_key, new_key, clone,
|
| reserved_names=_reserved_field_names,
|
| correction_id=event_id) or clone)
|
| else:
|
| _session_ready()
|
| _field_names = list(_reserved_field_names)
|
| _field_names.extend(
|
| value.get('label')
|
| for candidate_key, value in ws['fields'].items()
|
| if candidate_key != new_key and isinstance(value, dict)
|
| )
|
| clone.pop('labelCorrectedFrom', None)
|
| clone.pop('labelCorrectionId', None)
|
| clone['label'] = _unique_name(requested_label, _field_names)
|
| if clone['label'] != requested_label and event_id:
|
| clone['labelCorrectedFrom'] = requested_label
|
| clone['labelCorrectionId'] = str(event_id)[:180]
|
| ws['fields'][new_key] = clone
|
| if src_key.startswith('custom_'):
|
| for _row in ws['overlays'].values():
|
| if isinstance(_row, dict) and src_key in _row:
|
| _row[new_key] = _row[src_key]
|
|
|
|
|
|
|
| label_changed = clone['label'] != requested_label
|
| return label_changed or src.get('type') not in ('formula', 'created_time')
|
|
|
| if kind == 'add_to_list':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| raw_pids = event.get('pids')
|
| if not isinstance(raw_pids, list):
|
| return False
|
| pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids]
|
| if not pids:
|
| return False
|
| cohort_id = str(event.get('cohortId') or '').strip()[:120]
|
| name = str(event.get('name') or '').strip()[:120]
|
| if not _store_of(ctx).available():
|
| return False
|
|
|
|
|
|
|
|
|
|
|
| sets = _cohorts(ctx)
|
| try:
|
| if not cohort_id:
|
| if not name:
|
| return False
|
| cohort_id = sets.create(uname, name, pids)
|
| else:
|
| if cohort_id not in sets.all_for(uname):
|
| return False
|
| sets.add_members(uname, cohort_id, pids)
|
| except ValueError as e:
|
| _tel.error('cohort:add_to_list', e)
|
| return False
|
|
|
|
|
|
|
|
|
|
|
|
|
| _noun = sets.noun
|
| ctx.out.toast = (
|
| f"Added {len(pids):,} {_noun}{'' if len(pids) == 1 else 's'} to "
|
| f"{name or 'the list'}.")
|
| return True
|
|
|
| if kind == 'overlay_patch':
|
| pid = event.get('pid')
|
| if not isinstance(pid, int) or pid not in allowed_pids:
|
| return False
|
| updates = {}
|
| refused = False
|
| stage_moved = False
|
| row_events = []
|
| raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {}
|
| for key, value in raw_updates.items():
|
| if key not in overlay_keys or not isinstance(value, (str, int, float)):
|
| refused = True
|
| continue
|
|
|
|
|
|
|
|
|
|
|
| if key in (ctx.hidden_keys or frozenset()):
|
| refused = True
|
| continue
|
|
|
|
|
|
|
| perms = (field_by_key.get(key) or {}).get('permissions') or {}
|
| edit = perms.get('edit')
|
| if edit == 'admins' and not admin:
|
| refused = True
|
| continue
|
| if edit == 'creator' and not (
|
| admin or (field_by_key.get(key) or {}).get('createdBy') == uname):
|
| refused = True
|
| continue
|
|
|
|
|
|
|
|
|
| if (field_by_key.get(key) or {}).get('type') == 'automation':
|
| refused = True
|
| continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if str(ctx.scope_key or '').startswith('ut_'):
|
| import core.user_tables as _ut_mod
|
| _tdef = _ut_mod.get(ctx.scope_key, st=ctx.table.st) if ctx.table is not None \
|
| else None
|
| _fdef = next((f for f in ((_tdef or {}).get('fields') or [])
|
| if f.get('key') == key), None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if _ut_mod.is_computed_cell(_fdef):
|
| refused = True
|
| continue
|
| _auto = (_fdef or {}).get('automation')
|
|
|
|
|
|
|
| if (_fdef or {}).get('type') == 'link':
|
| _linked = _ut_mod.patch_link_cell(
|
| ctx.scope_key, pid, key, value, st=ctx.table.st)
|
| if _linked is None:
|
| refused = True
|
| continue
|
| if _linked != str(value):
|
| refused = True
|
| row_events.append((key, _linked))
|
| continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if isinstance((_fdef or {}).get('profile'), dict):
|
| _pw = _ut_mod.patch_profile_cell(
|
| ctx.scope_key, pid, key, value, st=ctx.table.st)
|
| if _pw is None:
|
|
|
|
|
| refused = True
|
| continue
|
|
|
|
|
|
|
| if _pw['handle'] != str(value):
|
| refused = True
|
| if _pw['cleared']:
|
| stage_moved = True
|
| row_events.append((key, _pw['handle']))
|
| continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if isinstance(_auto, dict) and (_auto.get('flowId') or _auto.get('preset')):
|
| refused = True
|
| continue
|
|
|
|
|
|
|
|
|
|
|
| if (field_by_key.get(key) or {}).get('type') == 'json':
|
| raw_j = value if isinstance(value, str) else _json_ev.dumps(value)
|
| if not str(raw_j).strip():
|
| updates[key] = ''
|
| continue
|
| if len(str(raw_j)) > _ut_limits.MAX_JSON_CELL:
|
| refused = True
|
| continue
|
| try:
|
| _json_ev.loads(raw_j)
|
| except (ValueError, TypeError):
|
| refused = True
|
| continue
|
| updates[key] = str(raw_j)
|
| continue
|
| clean = str(value)[:10000]
|
| if isinstance(value, str) and clean != value:
|
|
|
|
|
| refused = True
|
| definition = field_by_key.get(key) or {}
|
| if definition.get('type') in ('select', 'multiselect') and clean:
|
| options = [str(v) for v in definition.get('options') or []]
|
| canonical = {v.strip().lower(): v for v in options}
|
| if definition.get('type') == 'select':
|
| normalized = canonical.get(clean.strip().lower())
|
| if normalized is None:
|
| refused = True
|
| continue
|
| else:
|
| normalized_parts = []
|
| seen = set()
|
| invalid = False
|
| for part in clean.split(','):
|
| match = canonical.get(part.strip().lower())
|
| if match is None:
|
| invalid = True
|
| break
|
| if match.lower() not in seen:
|
| seen.add(match.lower())
|
| normalized_parts.append(match)
|
| if invalid:
|
| refused = True
|
| continue
|
| normalized = ','.join(normalized_parts)
|
| if normalized != clean:
|
| refused = True
|
| clean = normalized
|
| updates[key] = clean
|
| if updates:
|
| if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
|
|
|
|
|
|
|
| import core.user_tables as _ut_values
|
| _ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st)
|
| elif _store_of(ctx).available():
|
| _tops(ctx).patch_overlay(uname, pid, updates)
|
| else:
|
| _session_ready()
|
| ws['overlays'].setdefault(str(pid), {}).update(updates)
|
|
|
|
|
|
|
| if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
|
| import core.user_tables as _ut_ev
|
| for _k, _v in list(updates.items()) + row_events:
|
| _ut_ev.emit_row_event({'type': 'event_field', 'table': str(ctx.scope_key),
|
| 'rowId': str(pid), 'field': _k, 'after': _v,
|
| 'st': ctx.table.st, 'user': uname})
|
|
|
|
|
|
|
|
|
| return refused or stage_moved
|
|
|
| return False
|
|
|