Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/routes_customers.py +2 -1
- api/routes_products.py +3 -3
- api/routes_shares.py +4 -3
- api/routes_tables.py +2 -2
- platform/aios_grid.py +2 -2
- platform/core/grid_events.py +1 -1
- platform/modules/cohort.py +58 -55
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "db43911",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
db43911
|
api/routes_customers.py
CHANGED
|
@@ -320,7 +320,8 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
|
|
| 320 |
_ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
|
| 321 |
ws["overlays"] = _ov
|
| 322 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 323 |
-
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key
|
|
|
|
| 324 |
# ββ W38-T20 β AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
|
| 325 |
# position is load-bearing twice, once for the transitive closure and once because it is the
|
| 326 |
# only order in which the per-field grant marker is ever presented to `hidden_keys`.
|
|
|
|
| 320 |
_ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
|
| 321 |
ws["overlays"] = _ov
|
| 322 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 323 |
+
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
|
| 324 |
+
st=rt)
|
| 325 |
# ββ W38-T20 β AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
|
| 326 |
# position is load-bearing twice, once for the transitive closure and once because it is the
|
| 327 |
# only order in which the per-field grant marker is ever presented to `hidden_keys`.
|
api/routes_products.py
CHANGED
|
@@ -363,7 +363,7 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
|
|
| 363 |
ctx = grid_events.EventCtx(
|
| 364 |
uname=session.uname, allowed_pids=pids, fields=[],
|
| 365 |
hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base, st=session.runtime),
|
| 366 |
-
admin=session.admin, fallback_ws=None, seen_ids={},
|
| 367 |
scope_key="product", table=pd.table_ops(session.runtime))
|
| 368 |
ws = grid_events.table_workspace(ctx, allowed_pids=pids,
|
| 369 |
consume_corrections=consume_corrections)
|
|
@@ -385,7 +385,7 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
|
|
| 385 |
ws["overlays"] = _ov
|
| 386 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 387 |
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
|
| 388 |
-
fields_base=fields_base)
|
| 389 |
|
| 390 |
# W43: source-backed Product choices learn values in the shared overlay schema. Apply that
|
| 391 |
# durable vocabulary before the payload is served, so "Add option: et" is still available
|
|
@@ -517,7 +517,7 @@ def patch_product(pid: int, body: dict = Body(default=None),
|
|
| 517 |
admin=session.admin, fallback_ws=None, seen_ids={},
|
| 518 |
hidden_keys=perm_scope.hidden_keys(
|
| 519 |
session.user, MODULE, g["fields"], st=session.runtime),
|
| 520 |
-
scope_key="product", table=pd.table_ops(session.runtime))
|
| 521 |
try:
|
| 522 |
grid_events.handle_one(
|
| 523 |
{"id": f"patch:product:{pid}:{time.time_ns()}", "type": "overlay_patch",
|
|
|
|
| 363 |
ctx = grid_events.EventCtx(
|
| 364 |
uname=session.uname, allowed_pids=pids, fields=[],
|
| 365 |
hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base, st=session.runtime),
|
| 366 |
+
admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
|
| 367 |
scope_key="product", table=pd.table_ops(session.runtime))
|
| 368 |
ws = grid_events.table_workspace(ctx, allowed_pids=pids,
|
| 369 |
consume_corrections=consume_corrections)
|
|
|
|
| 385 |
ws["overlays"] = _ov
|
| 386 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 387 |
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
|
| 388 |
+
fields_base=fields_base, st=session.runtime)
|
| 389 |
|
| 390 |
# W43: source-backed Product choices learn values in the shared overlay schema. Apply that
|
| 391 |
# durable vocabulary before the payload is served, so "Add option: et" is still available
|
|
|
|
| 517 |
admin=session.admin, fallback_ws=None, seen_ids={},
|
| 518 |
hidden_keys=perm_scope.hidden_keys(
|
| 519 |
session.user, MODULE, g["fields"], st=session.runtime),
|
| 520 |
+
scope_key="product", table=pd.table_ops(session.runtime), st=session.runtime)
|
| 521 |
try:
|
| 522 |
grid_events.handle_one(
|
| 523 |
{"id": f"patch:product:{pid}:{time.time_ns()}", "type": "overlay_patch",
|
api/routes_shares.py
CHANGED
|
@@ -973,7 +973,7 @@ def _object_ref(session, kind, oid):
|
|
| 973 |
"field": "A column", "cohort": "A cohort"}.get(kind, "An item"), None, "")
|
| 974 |
|
| 975 |
|
| 976 |
-
def _cohort_visible(scope, username, allowed_pids=None):
|
| 977 |
"""Adapt the Cohort module's existing list projection to the one registry predicate.
|
| 978 |
|
| 979 |
``workspace_wire`` is the live Cohort list/open path. It intentionally receives only a
|
|
@@ -984,7 +984,7 @@ def _cohort_visible(scope, username, allowed_pids=None):
|
|
| 984 |
"""
|
| 985 |
import modules.cohort as cohort_mod
|
| 986 |
|
| 987 |
-
records = shares.visible_cohorts(scope, username)
|
| 988 |
return {cid: {**record,
|
| 989 |
"members": cohort_mod._clean_members(record.get("members"), allowed_pids)}
|
| 990 |
for cid, record in records.items()}
|
|
@@ -999,7 +999,8 @@ def _install_cohort_visibility():
|
|
| 999 |
return _cohort_visible("customer", username, allowed_pids)
|
| 1000 |
|
| 1001 |
def _scoped_visible(self, username, allowed_pids=None):
|
| 1002 |
-
return _cohort_visible(self.scope, username, allowed_pids
|
|
|
|
| 1003 |
|
| 1004 |
cohort_mod.visible = _customer_visible
|
| 1005 |
cohort_mod.CohortStore.visible = _scoped_visible
|
|
|
|
| 973 |
"field": "A column", "cohort": "A cohort"}.get(kind, "An item"), None, "")
|
| 974 |
|
| 975 |
|
| 976 |
+
def _cohort_visible(scope, username, allowed_pids=None, st=None):
|
| 977 |
"""Adapt the Cohort module's existing list projection to the one registry predicate.
|
| 978 |
|
| 979 |
``workspace_wire`` is the live Cohort list/open path. It intentionally receives only a
|
|
|
|
| 984 |
"""
|
| 985 |
import modules.cohort as cohort_mod
|
| 986 |
|
| 987 |
+
records = shares.visible_cohorts(scope, username, st=st)
|
| 988 |
return {cid: {**record,
|
| 989 |
"members": cohort_mod._clean_members(record.get("members"), allowed_pids)}
|
| 990 |
for cid, record in records.items()}
|
|
|
|
| 999 |
return _cohort_visible("customer", username, allowed_pids)
|
| 1000 |
|
| 1001 |
def _scoped_visible(self, username, allowed_pids=None):
|
| 1002 |
+
return _cohort_visible(self.scope, username, allowed_pids,
|
| 1003 |
+
st=getattr(self, 'st', None))
|
| 1004 |
|
| 1005 |
cohort_mod.visible = _customer_visible
|
| 1006 |
cohort_mod.CohortStore.visible = _scoped_visible
|
api/routes_tables.py
CHANGED
|
@@ -747,7 +747,7 @@ def ut_write_ctx(session: Session, table_key: str):
|
|
| 747 |
ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
|
| 748 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 749 |
ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
|
| 750 |
-
fields_base=fields_base)
|
| 751 |
# ββ W38-T16 β THE SHARED COLUMNS JOIN THE **WRITE** CTX'S CONTRACT TOO, and for the reason
|
| 752 |
# W36-T21 gave one wall over: `grid_events` refuses a key by asking `ctx.hidden_keys`, so a
|
| 753 |
# column missing from this list is a column the events transport does not know it must
|
|
@@ -1257,7 +1257,7 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
|
|
| 1257 |
consume_corrections=consume_corrections)
|
| 1258 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 1259 |
ws, session.uname, set(pids), defs={}, scope_key=table_key,
|
| 1260 |
-
storage_key=storage_key, fields_base=fields_base)
|
| 1261 |
# ββ W38-T16 β THE TENANT-WIDE STRATUM, ON A MATERIALISED GRID. Merged HERE, between
|
| 1262 |
# `workspace_wire` and the wall, which is `routes_odoo_tables`' own position for the same two
|
| 1263 |
# lines and for the same reason: the closure must be recomputed on the MERGED contract or a
|
|
|
|
| 747 |
ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
|
| 748 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 749 |
ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
|
| 750 |
+
fields_base=fields_base, st=session.runtime)
|
| 751 |
# ββ W38-T16 β THE SHARED COLUMNS JOIN THE **WRITE** CTX'S CONTRACT TOO, and for the reason
|
| 752 |
# W36-T21 gave one wall over: `grid_events` refuses a key by asking `ctx.hidden_keys`, so a
|
| 753 |
# column missing from this list is a column the events transport does not know it must
|
|
|
|
| 1257 |
consume_corrections=consume_corrections)
|
| 1258 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 1259 |
ws, session.uname, set(pids), defs={}, scope_key=table_key,
|
| 1260 |
+
storage_key=storage_key, fields_base=fields_base, st=session.runtime)
|
| 1261 |
# ββ W38-T16 β THE TENANT-WIDE STRATUM, ON A MATERIALISED GRID. Merged HERE, between
|
| 1262 |
# `workspace_wire` and the wall, which is `routes_odoo_tables`' own position for the same two
|
| 1263 |
# lines and for the same reason: the closure must be recomputed on the MERGED contract or a
|
platform/aios_grid.py
CHANGED
|
@@ -2962,7 +2962,7 @@ def views_from_defs(defs, saved_views, fields, system_name="All customers", lock
|
|
| 2962 |
|
| 2963 |
|
| 2964 |
def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storage_key=None,
|
| 2965 |
-
fields_base=None, with_cohorts=True):
|
| 2966 |
"""The client's `GridWorkspace` WIRE SHAPE from the stored table workspace β the ONE
|
| 2967 |
projection, shared by both servers (app.py's `_table_grid` and the API's `/workspace`).
|
| 2968 |
|
|
@@ -2994,7 +2994,7 @@ def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storag
|
|
| 2994 |
|
| 2995 |
cohort_lists = []
|
| 2996 |
if with_cohorts:
|
| 2997 |
-
for cid, c in sorted(cohort_mod.scoped(scope_key).visible(uname, pool_pids).items(),
|
| 2998 |
key=lambda kv: (kv[1].get('name') or '').lower()):
|
| 2999 |
members = [p for p in (c.get('members') or []) if p in pool_pids]
|
| 3000 |
entry = {'id': cid, 'name': c.get('name') or cid, 'pids': members}
|
|
|
|
| 2962 |
|
| 2963 |
|
| 2964 |
def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storage_key=None,
|
| 2965 |
+
fields_base=None, with_cohorts=True, st=None):
|
| 2966 |
"""The client's `GridWorkspace` WIRE SHAPE from the stored table workspace β the ONE
|
| 2967 |
projection, shared by both servers (app.py's `_table_grid` and the API's `/workspace`).
|
| 2968 |
|
|
|
|
| 2994 |
|
| 2995 |
cohort_lists = []
|
| 2996 |
if with_cohorts:
|
| 2997 |
+
for cid, c in sorted(cohort_mod.scoped(scope_key, st=st).visible(uname, pool_pids).items(),
|
| 2998 |
key=lambda kv: (kv[1].get('name') or '').lower()):
|
| 2999 |
members = [p for p in (c.get('members') or []) if p in pool_pids]
|
| 3000 |
entry = {'id': cid, 'name': c.get('name') or cid, 'pids': members}
|
platform/core/grid_events.py
CHANGED
|
@@ -344,7 +344,7 @@ def _cohorts(ctx):
|
|
| 344 |
instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the
|
| 345 |
customer path is byte-unchanged.
|
| 346 |
"""
|
| 347 |
-
return cohort_mod.scoped(ctx.scope_key)
|
| 348 |
|
| 349 |
|
| 350 |
#: The keys a stored document entry may put on the wire. β `path` IS NOT ONE OF THEM β it is the
|
|
|
|
| 344 |
instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the
|
| 345 |
customer path is byte-unchanged.
|
| 346 |
"""
|
| 347 |
+
return cohort_mod.scoped(ctx.scope_key, st=_store_of(ctx))
|
| 348 |
|
| 349 |
|
| 350 |
#: The keys a stored document entry may put on the wire. β `path` IS NOT ONE OF THEM β it is the
|
platform/modules/cohort.py
CHANGED
|
@@ -40,13 +40,10 @@ bucket per topic (`<scope>_cohorts`), reached through `scoped(scope)`.
|
|
| 40 |
`modules.cohort.visible` (the gates do) keep working through the facade, and every existing
|
| 41 |
positional call site is byte-unaffected.
|
| 42 |
|
| 43 |
-
TENANCY
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
one tenant and no cross-tenant READ is reachable. What is wrong is RESIDENCY (tenant B's lists
|
| 48 |
-
sit in tenant #0's repo). Fixing it means threading the runtime handle through `EventCtx`, which
|
| 49 |
-
needs `routes_grid._ctx` β another session's file this wave.
|
| 50 |
|
| 51 |
No validate(): there is no Odoo aggregate to reconcile a hand-picked list against. Its counts
|
| 52 |
derive from the rows it renders (rule 8b), and the metrics themselves are already validated by
|
|
@@ -104,21 +101,26 @@ def _now():
|
|
| 104 |
return _dt.datetime.now().strftime('%Y-%m-%d %H:%M')
|
| 105 |
|
| 106 |
|
| 107 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
"""{cohort_id: {name, members, note, created, updated}} for one user, from ONE bucket.
|
| 109 |
|
| 110 |
Returns {} when the store is unavailable rather than raising: a cohort list that cannot load
|
| 111 |
should degrade to "no cohorts", never to a broken page.
|
| 112 |
"""
|
| 113 |
try:
|
| 114 |
-
return dict((
|
| 115 |
except Exception:
|
| 116 |
return {}
|
| 117 |
|
| 118 |
|
| 119 |
-
def all_for(username):
|
| 120 |
"""{cohort_id: {...}} for one user on the CUSTOMER book β see `_all_for`."""
|
| 121 |
-
return _all_for(KEY, username)
|
| 122 |
|
| 123 |
|
| 124 |
def _clean_members(raw, allowed_pids=None):
|
|
@@ -144,15 +146,15 @@ def _clean_members(raw, allowed_pids=None):
|
|
| 144 |
return out[:MAX_MEMBERS]
|
| 145 |
|
| 146 |
|
| 147 |
-
def _visible(key, username, allowed_pids=None):
|
| 148 |
"""Every cohort in ONE bucket, with membership already scoped to what this user may open."""
|
| 149 |
return {cid: {**c, 'members': _clean_members(c.get('members'), allowed_pids)}
|
| 150 |
-
for cid, c in _all_for(key, username).items()}
|
| 151 |
|
| 152 |
|
| 153 |
-
def visible(username, allowed_pids=None):
|
| 154 |
"""Every CUSTOMER cohort, membership scoped to what this user may open."""
|
| 155 |
-
return _visible(KEY, username, allowed_pids)
|
| 156 |
|
| 157 |
|
| 158 |
def new_id(name):
|
|
@@ -167,7 +169,7 @@ def new_id(name):
|
|
| 167 |
return f"cohort_{slug or 'list'}_{_dt.datetime.now().strftime('%H%M%S')}"
|
| 168 |
|
| 169 |
|
| 170 |
-
def _create(key, username, name, members=(), note=''):
|
| 171 |
"""Create a cohort in ONE bucket and return its id. Raises ValueError on a blank name or when
|
| 172 |
the per-user cap is reached β refusing loudly beats silently dropping somebody's list.
|
| 173 |
|
|
@@ -177,7 +179,7 @@ def _create(key, username, name, members=(), note=''):
|
|
| 177 |
name = str(name or '').strip()[:120]
|
| 178 |
if not name:
|
| 179 |
raise ValueError('a cohort needs a name')
|
| 180 |
-
existing = _all_for(key, username)
|
| 181 |
if len(existing) >= MAX_COHORTS:
|
| 182 |
raise ValueError(f'you already have {MAX_COHORTS} cohorts β delete one first')
|
| 183 |
cid = new_id(name)
|
|
@@ -187,16 +189,16 @@ def _create(key, username, name, members=(), note=''):
|
|
| 187 |
def _up(data):
|
| 188 |
data.setdefault(username, {})[cid] = record
|
| 189 |
return data
|
| 190 |
-
|
| 191 |
return cid
|
| 192 |
|
| 193 |
|
| 194 |
-
def create(username, name, members=(), note=''):
|
| 195 |
"""Create a CUSTOMER cohort and return its id β see `_create`."""
|
| 196 |
-
return _create(KEY, username, name, members=members, note=note)
|
| 197 |
|
| 198 |
|
| 199 |
-
def _mutate(key, username, cohort_id, change):
|
| 200 |
def _up(data):
|
| 201 |
book = data.setdefault(username, {})
|
| 202 |
c = book.get(cohort_id)
|
|
@@ -205,10 +207,10 @@ def _mutate(key, username, cohort_id, change):
|
|
| 205 |
change(c)
|
| 206 |
c['updated'] = _now()
|
| 207 |
return data
|
| 208 |
-
|
| 209 |
|
| 210 |
|
| 211 |
-
def _add_members(key, username, cohort_id, pids):
|
| 212 |
"""Union new ids into a cohort, PRESERVING the existing order and appending the new ones.
|
| 213 |
|
| 214 |
Additive on purpose: "add to list" from a view can be clicked twice, and the second click
|
|
@@ -216,54 +218,54 @@ def _add_members(key, username, cohort_id, pids):
|
|
| 216 |
"""
|
| 217 |
def _change(c):
|
| 218 |
c['members'] = _clean_members(list(c.get('members') or []) + list(pids or []))
|
| 219 |
-
_mutate(key, username, cohort_id, _change)
|
| 220 |
|
| 221 |
|
| 222 |
-
def add_members(username, cohort_id, pids):
|
| 223 |
"""Union new ids into a CUSTOMER cohort β see `_add_members`."""
|
| 224 |
-
_add_members(KEY, username, cohort_id, pids)
|
| 225 |
|
| 226 |
|
| 227 |
-
def _remove_members(key, username, cohort_id, pids):
|
| 228 |
drop = {int(p) for p in (pids or []) if str(p).lstrip('-').isdigit()}
|
| 229 |
|
| 230 |
def _change(c):
|
| 231 |
c['members'] = [p for p in _clean_members(c.get('members')) if p not in drop]
|
| 232 |
-
_mutate(key, username, cohort_id, _change)
|
| 233 |
|
| 234 |
|
| 235 |
-
def remove_members(username, cohort_id, pids):
|
| 236 |
-
_remove_members(KEY, username, cohort_id, pids)
|
| 237 |
|
| 238 |
|
| 239 |
-
def _rename(key, username, cohort_id, name):
|
| 240 |
name = str(name or '').strip()[:120]
|
| 241 |
if not name:
|
| 242 |
raise ValueError('a cohort needs a name')
|
| 243 |
-
_mutate(key, username, cohort_id, lambda c: c.__setitem__('name', name))
|
| 244 |
|
| 245 |
|
| 246 |
-
def rename(username, cohort_id, name):
|
| 247 |
-
_rename(KEY, username, cohort_id, name)
|
| 248 |
|
| 249 |
|
| 250 |
-
def _set_note(key, username, cohort_id, note):
|
| 251 |
-
_mutate(key, username, cohort_id, lambda c: c.__setitem__('note', str(note or '')[:2000]))
|
| 252 |
|
| 253 |
|
| 254 |
-
def set_note(username, cohort_id, note):
|
| 255 |
-
_set_note(KEY, username, cohort_id, note)
|
| 256 |
|
| 257 |
|
| 258 |
-
def _delete(key, username, cohort_id):
|
| 259 |
def _up(data):
|
| 260 |
(data.get(username) or {}).pop(cohort_id, None)
|
| 261 |
return data
|
| 262 |
-
|
| 263 |
|
| 264 |
|
| 265 |
-
def delete(username, cohort_id):
|
| 266 |
-
_delete(KEY, username, cohort_id)
|
| 267 |
|
| 268 |
|
| 269 |
def rows_for(cohort, pool_rows):
|
|
@@ -290,42 +292,43 @@ class CohortStore:
|
|
| 290 |
code it already was.
|
| 291 |
"""
|
| 292 |
|
| 293 |
-
__slots__ = ('scope', 'key', 'noun')
|
| 294 |
|
| 295 |
-
def __init__(self, scope):
|
| 296 |
self.scope = str(scope or 'customer').strip().lower()
|
| 297 |
self.key = key_for(self.scope)
|
| 298 |
self.noun = noun_for(self.scope)
|
|
|
|
| 299 |
|
| 300 |
def __repr__(self):
|
| 301 |
return f'CohortStore({self.scope!r} -> {self.key!r})'
|
| 302 |
|
| 303 |
def all_for(self, username):
|
| 304 |
-
return _all_for(self.key, username)
|
| 305 |
|
| 306 |
def visible(self, username, allowed_pids=None):
|
| 307 |
-
return _visible(self.key, username, allowed_pids)
|
| 308 |
|
| 309 |
def create(self, username, name, members=(), note=''):
|
| 310 |
-
return _create(self.key, username, name, members=members, note=note)
|
| 311 |
|
| 312 |
def add_members(self, username, cohort_id, pids):
|
| 313 |
-
_add_members(self.key, username, cohort_id, pids)
|
| 314 |
|
| 315 |
def remove_members(self, username, cohort_id, pids):
|
| 316 |
-
_remove_members(self.key, username, cohort_id, pids)
|
| 317 |
|
| 318 |
def rename(self, username, cohort_id, name):
|
| 319 |
-
_rename(self.key, username, cohort_id, name)
|
| 320 |
|
| 321 |
def set_note(self, username, cohort_id, note):
|
| 322 |
-
_set_note(self.key, username, cohort_id, note)
|
| 323 |
|
| 324 |
def delete(self, username, cohort_id):
|
| 325 |
-
_delete(self.key, username, cohort_id)
|
| 326 |
|
| 327 |
|
| 328 |
-
def scoped(scope=None):
|
| 329 |
"""The cohort store for ONE topic β THE resolution point (R9).
|
| 330 |
|
| 331 |
β Returns THIS MODULE for the customer topic, not a `CohortStore(KEY)`. Two reasons, and both
|
|
@@ -336,9 +339,9 @@ def scoped(scope=None):
|
|
| 336 |
that returned a bound object would silently ignore the patch, leaving a gate green over
|
| 337 |
code it is no longer exercising.
|
| 338 |
"""
|
| 339 |
-
if str(scope or 'customer').strip().lower() in LEGACY_SCOPES:
|
| 340 |
return _sys.modules[__name__]
|
| 341 |
-
return CohortStore(scope)
|
| 342 |
|
| 343 |
|
| 344 |
#: What ONE row of the customer topic is called β the module facade's answer to `CohortStore.noun`,
|
|
|
|
| 40 |
`modules.cohort.visible` (the gates do) keep working through the facade, and every existing
|
| 41 |
positional call site is byte-unaffected.
|
| 42 |
|
| 43 |
+
TENANCY: the API passes its request's ``TenantRuntime`` through ``scoped(..., st=...)``. Both list
|
| 44 |
+
reads and lifecycle writes then resolve through that handle; the module-level ``core.store`` is
|
| 45 |
+
only for legacy callers that have no request runtime. A sandbox or another tenant therefore
|
| 46 |
+
cannot reach tenant #0's Cohort bucket merely because this module was imported there first.
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
No validate(): there is no Odoo aggregate to reconcile a hand-picked list against. Its counts
|
| 49 |
derive from the rows it renders (rule 8b), and the metrics themselves are already validated by
|
|
|
|
| 101 |
return _dt.datetime.now().strftime('%Y-%m-%d %H:%M')
|
| 102 |
|
| 103 |
|
| 104 |
+
def _store_for(st=None):
|
| 105 |
+
"""The request tenant store when supplied, else the legacy module default."""
|
| 106 |
+
return st if st is not None else store
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _all_for(key, username, st=None):
|
| 110 |
"""{cohort_id: {name, members, note, created, updated}} for one user, from ONE bucket.
|
| 111 |
|
| 112 |
Returns {} when the store is unavailable rather than raising: a cohort list that cannot load
|
| 113 |
should degrade to "no cohorts", never to a broken page.
|
| 114 |
"""
|
| 115 |
try:
|
| 116 |
+
return dict((_store_for(st).get(key) or {}).get(username, {}) or {})
|
| 117 |
except Exception:
|
| 118 |
return {}
|
| 119 |
|
| 120 |
|
| 121 |
+
def all_for(username, st=None):
|
| 122 |
"""{cohort_id: {...}} for one user on the CUSTOMER book β see `_all_for`."""
|
| 123 |
+
return _all_for(KEY, username, st=st)
|
| 124 |
|
| 125 |
|
| 126 |
def _clean_members(raw, allowed_pids=None):
|
|
|
|
| 146 |
return out[:MAX_MEMBERS]
|
| 147 |
|
| 148 |
|
| 149 |
+
def _visible(key, username, allowed_pids=None, st=None):
|
| 150 |
"""Every cohort in ONE bucket, with membership already scoped to what this user may open."""
|
| 151 |
return {cid: {**c, 'members': _clean_members(c.get('members'), allowed_pids)}
|
| 152 |
+
for cid, c in _all_for(key, username, st=st).items()}
|
| 153 |
|
| 154 |
|
| 155 |
+
def visible(username, allowed_pids=None, st=None):
|
| 156 |
"""Every CUSTOMER cohort, membership scoped to what this user may open."""
|
| 157 |
+
return _visible(KEY, username, allowed_pids, st=st)
|
| 158 |
|
| 159 |
|
| 160 |
def new_id(name):
|
|
|
|
| 169 |
return f"cohort_{slug or 'list'}_{_dt.datetime.now().strftime('%H%M%S')}"
|
| 170 |
|
| 171 |
|
| 172 |
+
def _create(key, username, name, members=(), note='', st=None):
|
| 173 |
"""Create a cohort in ONE bucket and return its id. Raises ValueError on a blank name or when
|
| 174 |
the per-user cap is reached β refusing loudly beats silently dropping somebody's list.
|
| 175 |
|
|
|
|
| 179 |
name = str(name or '').strip()[:120]
|
| 180 |
if not name:
|
| 181 |
raise ValueError('a cohort needs a name')
|
| 182 |
+
existing = _all_for(key, username, st=st)
|
| 183 |
if len(existing) >= MAX_COHORTS:
|
| 184 |
raise ValueError(f'you already have {MAX_COHORTS} cohorts β delete one first')
|
| 185 |
cid = new_id(name)
|
|
|
|
| 189 |
def _up(data):
|
| 190 |
data.setdefault(username, {})[cid] = record
|
| 191 |
return data
|
| 192 |
+
_store_for(st).update(key, _up)
|
| 193 |
return cid
|
| 194 |
|
| 195 |
|
| 196 |
+
def create(username, name, members=(), note='', st=None):
|
| 197 |
"""Create a CUSTOMER cohort and return its id β see `_create`."""
|
| 198 |
+
return _create(KEY, username, name, members=members, note=note, st=st)
|
| 199 |
|
| 200 |
|
| 201 |
+
def _mutate(key, username, cohort_id, change, st=None):
|
| 202 |
def _up(data):
|
| 203 |
book = data.setdefault(username, {})
|
| 204 |
c = book.get(cohort_id)
|
|
|
|
| 207 |
change(c)
|
| 208 |
c['updated'] = _now()
|
| 209 |
return data
|
| 210 |
+
_store_for(st).update(key, _up)
|
| 211 |
|
| 212 |
|
| 213 |
+
def _add_members(key, username, cohort_id, pids, st=None):
|
| 214 |
"""Union new ids into a cohort, PRESERVING the existing order and appending the new ones.
|
| 215 |
|
| 216 |
Additive on purpose: "add to list" from a view can be clicked twice, and the second click
|
|
|
|
| 218 |
"""
|
| 219 |
def _change(c):
|
| 220 |
c['members'] = _clean_members(list(c.get('members') or []) + list(pids or []))
|
| 221 |
+
_mutate(key, username, cohort_id, _change, st=st)
|
| 222 |
|
| 223 |
|
| 224 |
+
def add_members(username, cohort_id, pids, st=None):
|
| 225 |
"""Union new ids into a CUSTOMER cohort β see `_add_members`."""
|
| 226 |
+
_add_members(KEY, username, cohort_id, pids, st=st)
|
| 227 |
|
| 228 |
|
| 229 |
+
def _remove_members(key, username, cohort_id, pids, st=None):
|
| 230 |
drop = {int(p) for p in (pids or []) if str(p).lstrip('-').isdigit()}
|
| 231 |
|
| 232 |
def _change(c):
|
| 233 |
c['members'] = [p for p in _clean_members(c.get('members')) if p not in drop]
|
| 234 |
+
_mutate(key, username, cohort_id, _change, st=st)
|
| 235 |
|
| 236 |
|
| 237 |
+
def remove_members(username, cohort_id, pids, st=None):
|
| 238 |
+
_remove_members(KEY, username, cohort_id, pids, st=st)
|
| 239 |
|
| 240 |
|
| 241 |
+
def _rename(key, username, cohort_id, name, st=None):
|
| 242 |
name = str(name or '').strip()[:120]
|
| 243 |
if not name:
|
| 244 |
raise ValueError('a cohort needs a name')
|
| 245 |
+
_mutate(key, username, cohort_id, lambda c: c.__setitem__('name', name), st=st)
|
| 246 |
|
| 247 |
|
| 248 |
+
def rename(username, cohort_id, name, st=None):
|
| 249 |
+
_rename(KEY, username, cohort_id, name, st=st)
|
| 250 |
|
| 251 |
|
| 252 |
+
def _set_note(key, username, cohort_id, note, st=None):
|
| 253 |
+
_mutate(key, username, cohort_id, lambda c: c.__setitem__('note', str(note or '')[:2000]), st=st)
|
| 254 |
|
| 255 |
|
| 256 |
+
def set_note(username, cohort_id, note, st=None):
|
| 257 |
+
_set_note(KEY, username, cohort_id, note, st=st)
|
| 258 |
|
| 259 |
|
| 260 |
+
def _delete(key, username, cohort_id, st=None):
|
| 261 |
def _up(data):
|
| 262 |
(data.get(username) or {}).pop(cohort_id, None)
|
| 263 |
return data
|
| 264 |
+
_store_for(st).update(key, _up)
|
| 265 |
|
| 266 |
|
| 267 |
+
def delete(username, cohort_id, st=None):
|
| 268 |
+
_delete(KEY, username, cohort_id, st=st)
|
| 269 |
|
| 270 |
|
| 271 |
def rows_for(cohort, pool_rows):
|
|
|
|
| 292 |
code it already was.
|
| 293 |
"""
|
| 294 |
|
| 295 |
+
__slots__ = ('scope', 'key', 'noun', 'st')
|
| 296 |
|
| 297 |
+
def __init__(self, scope, st=None):
|
| 298 |
self.scope = str(scope or 'customer').strip().lower()
|
| 299 |
self.key = key_for(self.scope)
|
| 300 |
self.noun = noun_for(self.scope)
|
| 301 |
+
self.st = st
|
| 302 |
|
| 303 |
def __repr__(self):
|
| 304 |
return f'CohortStore({self.scope!r} -> {self.key!r})'
|
| 305 |
|
| 306 |
def all_for(self, username):
|
| 307 |
+
return _all_for(self.key, username, st=self.st)
|
| 308 |
|
| 309 |
def visible(self, username, allowed_pids=None):
|
| 310 |
+
return _visible(self.key, username, allowed_pids, st=self.st)
|
| 311 |
|
| 312 |
def create(self, username, name, members=(), note=''):
|
| 313 |
+
return _create(self.key, username, name, members=members, note=note, st=self.st)
|
| 314 |
|
| 315 |
def add_members(self, username, cohort_id, pids):
|
| 316 |
+
_add_members(self.key, username, cohort_id, pids, st=self.st)
|
| 317 |
|
| 318 |
def remove_members(self, username, cohort_id, pids):
|
| 319 |
+
_remove_members(self.key, username, cohort_id, pids, st=self.st)
|
| 320 |
|
| 321 |
def rename(self, username, cohort_id, name):
|
| 322 |
+
_rename(self.key, username, cohort_id, name, st=self.st)
|
| 323 |
|
| 324 |
def set_note(self, username, cohort_id, note):
|
| 325 |
+
_set_note(self.key, username, cohort_id, note, st=self.st)
|
| 326 |
|
| 327 |
def delete(self, username, cohort_id):
|
| 328 |
+
_delete(self.key, username, cohort_id, st=self.st)
|
| 329 |
|
| 330 |
|
| 331 |
+
def scoped(scope=None, st=None):
|
| 332 |
"""The cohort store for ONE topic β THE resolution point (R9).
|
| 333 |
|
| 334 |
β Returns THIS MODULE for the customer topic, not a `CohortStore(KEY)`. Two reasons, and both
|
|
|
|
| 339 |
that returned a bound object would silently ignore the patch, leaving a gate green over
|
| 340 |
code it is no longer exercising.
|
| 341 |
"""
|
| 342 |
+
if st is None and str(scope or 'customer').strip().lower() in LEGACY_SCOPES:
|
| 343 |
return _sys.modules[__name__]
|
| 344 |
+
return CohortStore(scope, st=st)
|
| 345 |
|
| 346 |
|
| 347 |
#: What ONE row of the customer topic is called β the module facade's answer to `CohortStore.noun`,
|