| """routes_products.py β the PRODUCT table's read seam (wave 15 item 9/10, contract C-TOPIC). |
| |
| The proof that C-TOPIC's bet is real: **a second table object is a pool module plus a route, not |
| a second component tree**. Everything structural here is borrowed rather than re-implemented β |
| the permission wall (`core.perm_scope`), the row builder (`aios_grid.rows_from_pool`), the |
| scope-keyed stale-while-refresh cache (`scope_cache`), the error shape, the module gate. |
| |
| Landed nav-less in wave 15 ON PURPOSE (no registry row, so no user could navigate to a |
| half-built surface while the server half hardened); wave 16 shipped the `product_data` |
| registry row, the client `topic` prop and the write path, so the surface has been REACHABLE |
| from the nav since β this header stayed stale until 2026-08-04 (the wave-18 debt sweep) and |
| cost readers a false "you can't get there from here". |
| |
| Wave 16 (C-TOPIC's second half): the WRITE PATH exists now, and it is exactly what the wave-15 |
| header demanded before one could β its OWN table workspace bucket |
| (`modules.product_data.TABLE_OPS`, store key 'product_table_workspace') and its own event |
| validation (the events route builds the ctx over the PRODUCT field contract + product pids + |
| the product ops). β The separate bucket is load-bearing, not tidy: product pids are CRC32 |
| hashes and customer pids are Odoo partner ids β one shared overlay bucket and a hash collision |
| silently writes a product note onto somebody's customer. |
| """ |
| import time |
|
|
| from fastapi import Body, Depends |
|
|
| from fastapi import APIRouter |
|
|
| import scope_cache |
| from deps import Session, err, module_gate |
|
|
| router = APIRouter(prefix="/api/v1") |
|
|
| |
| |
| MODULE = "product_data" |
|
|
| _CACHE_TTL = 900 |
|
|
|
|
| def _pool_for(rt, team_id): |
| """The cached SKU pool for one scope. Same stale-while-refresh discipline as the customer |
| pool: only a scope's FIRST-ever build blocks, and the cache key is the SCOPE, never the user |
| β the customer route learned that the hard way (a per-user payload cached on a scope key |
| served one user's private columns to another).""" |
| import modules.product_data as pd |
|
|
| |
| |
| |
| |
| key = ("product_pool", team_id) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import routes_keychain |
| if routes_keychain.odoo_paused(rt): |
| hit = rt.pool_cache.get(key) |
| if hit: |
| return hit[1] |
| raise err(503, "connector_paused", |
| "this data source is paused and nothing has been read since β " |
| "an admin can resume it under Settings β Connectors") |
|
|
| return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, |
| lambda: pd.pool(team_id=team_id)) |
|
|
|
|
| def scoped_pool(session: Session): |
| """`(pids, team_id, rows_src, fields_base)` β THE PRODUCT WALL, on its own. |
| |
| Extracted from `product_assembly` (wave 19, item 12) so a caller that needs only "which |
| products may this session touch" β record comments, say β asks the SAME question in the same |
| order rather than re-deriving it: pool scope from the permanent filter, rows, THEN the row |
| wall, THEN the pids. Re-deriving it is how a second wall drifts from the first, and the |
| walls are the whole point of this route. |
| |
| β The caller is responsible for the GRANT (`session.require(MODULE)`); this is the row half. |
| """ |
| import core.perm_scope as perm_scope |
|
|
| team_id, _agent = perm_scope.derive_pool_scope(session.user, MODULE) |
| try: |
| rows_src = _pool_for(session.runtime, team_id) |
| except Exception as e: |
| raise err(503, "pool_unavailable", |
| f"the product catalogue could not be built β {str(e)[:160]}") |
| fields_base = pd_fields(consolidated=team_id is None) |
| |
| |
| |
| rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, fields_base) |
| pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None) |
| return pids, team_id, rows_src, fields_base |
|
|
|
|
| def product_assembly(session: Session, scope: str = "product", storage_key: str = "", |
| consume_corrections: bool = True): |
| """The product topic's mirror of `routes_customers.grid_assembly` β SAME g-dict keys, so |
| the /workspace and events routes consume either interchangeably. |
| |
| One deliberate absence, a topic fact rather than a gap: |
| * `measures`/`measure_sets` are EMPTY β `core.measure_resolve` is CUSTOMER-grain (the |
| C-TOPIC v1 descope, booked in the wave doc); the events ctx therefore refuses measure |
| creates on this surface, which is the correct fail-closed shape. |
| |
| β WAVE 19 / R9 β `lists` IS NO LONGER EMPTY. Wave 16 passed `with_cohorts=False` because |
| there was one customer-keyed cohort bucket and product pids are CRC32 hashes of SKU codes; |
| intersecting the two id spaces would have printed a plausible, meaningless member count. The |
| owner's ruling is that a cohort belongs to its database, so `modules.cohort` grew a bucket |
| per topic and the product surface reads `product_cohorts` β ids from this pool, resolved |
| against this pool. `derived` carries their membership cells (the Cohorts column) for the same |
| reason it does on the customer surface; the measure half of that channel stays empty. |
| """ |
| import aios_grid |
| import core.perm_scope as perm_scope |
| import modules.product_data as pd |
| from core import grid_events |
|
|
| pids, team_id, rows_src, fields_base = scoped_pool(session) |
|
|
| ctx = grid_events.EventCtx( |
| uname=session.uname, allowed_pids=pids, fields=[], |
| hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base), |
| admin=session.admin, fallback_ws=None, seen_ids={}, |
| scope_key="product", table=pd.TABLE_OPS) |
| ws = grid_events.table_workspace(ctx, allowed_pids=pids, |
| consume_corrections=consume_corrections) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _shared = pd.shared_cells(pids) |
| if _shared: |
| _ov = dict(ws.get("overlays") or {}) |
| for _pid, _cells in _shared.items(): |
| _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells} |
| ws["overlays"] = _ov |
| workspace, fields, views, lists = aios_grid.workspace_wire( |
| ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key, |
| fields_base=fields_base) |
|
|
| hidden = perm_scope.hidden_keys(session.user, MODULE, fields) |
| if hidden: |
| fields = [f for f in fields if f.get("key") not in hidden] |
| rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src] |
|
|
| return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace, |
| "fields": fields, "views": views, "lists": lists, |
| |
| |
| "derived": aios_grid.cohort_cells(lists), |
| "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"), |
| "team_id": team_id} |
|
|
|
|
| @router.get("/products") |
| def products(session: Session = Depends(module_gate(MODULE))): |
| """The product table for this session's scope β the /customers envelope byte-for-byte |
| (`{fields, rows, today, pulled_at}`) plus two additive keys (`identity`, `scope`). |
| |
| The BU scope is DERIVED FROM THE PERMANENT FILTER (`perm_scope.derive_pool_scope`), exactly |
| as the customer route derives it β and for the same reason, which is worth restating because |
| it is the wave's central lesson: `team_id` shapes the revenue VALUES on each row, so a BU |
| enforced as a post-filter yields a correct row list carrying both units' numbers. |
| """ |
| import aios_grid |
|
|
| g = product_assembly(session) |
| rows = aios_grid.rows_from_pool( |
| g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"]) |
| rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"]) |
| return {"fields": g["fields"], "rows": rows, |
| "today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"), |
| "identity": {"pid": "pid", "businessKey": "code"}, |
| "scope": {"team_id": g["team_id"], "consolidated": g["team_id"] is None}} |
|
|
|
|
| @router.patch("/products/{pid}") |
| def patch_product(pid: int, body: dict = Body(default=None), |
| session: Session = Depends(module_gate(MODULE))): |
| """Write the product table's EDITABLE overlay stratum β the customers PATCH, on the product |
| topic's ctx. Routed through `core.grid_events.handle_one` so the per-key `permissions.edit` |
| wall, the pid wall and the truncation rules stay ONE implementation; the ctx's `table` ops |
| aim the write at the PRODUCT bucket.""" |
| import modules.product_data as pd |
| from core import grid_events |
|
|
| updates = dict(body or {}) |
| if not updates: |
| raise err(400, "empty_patch", "no fields to update") |
| g = product_assembly(session, consume_corrections=False) |
| if pid not in g["pids"]: |
| |
| raise err(403, "out_of_scope", "that product is not in your catalogue") |
| import core.perm_scope as perm_scope |
|
|
| ctx = grid_events.EventCtx( |
| uname=session.uname, allowed_pids=g["pids"], fields=g["fields"], |
| admin=session.admin, fallback_ws=None, seen_ids={}, |
| hidden_keys=perm_scope.hidden_keys( |
| session.user, MODULE, pd_fields(consolidated=g["team_id"] is None)), |
| scope_key="product", table=pd.TABLE_OPS) |
| try: |
| grid_events.handle_one( |
| {"id": f"patch:product:{pid}:{time.time_ns()}", "type": "overlay_patch", |
| "pid": pid, "updates": updates}, ctx) |
| except grid_events.StoreUnavailable: |
| raise err(503, "store_unavailable", |
| "the tenant store is unavailable β your change was not saved") |
|
|
| |
| |
| |
| |
| |
| |
| |
| stored = (grid_events.table_workspace(ctx, allowed_pids=None) |
| .get("overlays") or {}).get(str(pid)) or {} |
| stored = {**stored, **((pd.shared_cells([pid]).get(str(pid))) or {})} |
| accepted = {k: stored.get(k) for k in updates if k in stored} |
| refused = sorted(k for k in updates |
| if k not in accepted or stored.get(k) != str(updates[k])) |
| out = {"pid": pid, "updates": accepted} |
| if refused: |
| out["refused"] = refused |
| return out |
|
|
|
|
| |
| |
| |
| |
| |
| |
| PRODUCT_IMAGE_KEY = "image" |
|
|
|
|
| def _seed_shared(rows, rows_src, fields): |
| """W30-T36 β the supplier master as a per-render DEFAULT for the four SHARED columns. |
| |
| Exactly `_seed_image`'s shape and for the same reasons: no migration, and nothing frozen. An |
| untouched grid renders byte-identically to the read-only version it replaces β including |
| `supplier: "(none)"`, which the pool has always stamped β and **empty means "no override"**, |
| so clearing a cell restores the mastersheet value rather than blanking the column. |
| |
| ββ GATED PER KEY ON THE SERVED FIELD LIST, AND HERE THAT IS A PERMISSION WALL RATHER THAN |
| TIDINESS. `_seed_image`'s docstring predicted this failure in writing β *"An unconditional seed |
| would put the key straight back onto every row AFTER that strip⦠the value here is the visible |
| `code`, so nothing new escapes today; the contract is the point, and a future non-code default |
| would escape."* **`first_cost` IS that future non-code default**: a money number, and |
| `product_assembly` strips a hidden field from BOTH wires precisely so a restricted reader |
| cannot read it off the row payload. An ungated seed would re-attach it after the strip. |
| (Belt and braces, deliberately: `perm_scope.strip_row` has already removed the key from |
| `rows_src` too, so the pool row cannot supply it either.) |
| |
| β JOINED ON `pid`, never on position: `rows_from_pool` iterates the pool, but nothing in the |
| contract promises the two lists stay index-aligned, and an off-by-one here would put one SKU's |
| supplier on another SKU's row β a wrong answer that looks entirely plausible. |
| """ |
| import modules.product_data as pd |
|
|
| served = {f.get("key") for f in (fields or ())} |
| keys = [k for k in pd.SHARED_KEYS() if k in served] |
| if not keys: |
| return rows |
| by_pid = {r.get("pid"): r for r in (rows_src or ())} |
| for row in rows: |
| src = by_pid.get(row.get("pid")) or {} |
| for key in keys: |
| |
| |
| |
| |
| if row.get(key) in (None, ""): |
| row[key] = src.get(key) |
| return rows |
|
|
|
|
| def _seed_image(rows, fields): |
| """R7's "auto-seeded from SKU `code`", as a per-render DEFAULT rather than stored data. |
| |
| Royal's 1,142 masters are named for `default_code`, so an untouched product row already names |
| its own picture; this is what makes them appear with nothing uploaded. A user's own value is |
| NON-EMPTY and therefore wins β the fallback only fills a cell nobody has set. |
| |
| β Clearing the cell restores the SKU's own picture rather than blanking it, and that is the |
| documented meaning of empty on this column ("no override"). A product with no master on file |
| still shows an empty frame, because the reference resolves to a 404 β the honest outcome, and |
| the record modal names the failing reference in words. |
| |
| β GATED ON THE SERVED FIELD LIST, and that is C-PERM, not tidiness. `product_assembly` strips |
| a hidden field from BOTH wires β the field list and the row payload β because narrowing only |
| the first leaves the value sitting in the second where anything can read it. An unconditional |
| seed would put the key straight back onto every row AFTER that strip, re-creating exactly the |
| shape the rule forbids. (The value here is the visible `code`, so nothing new escapes today; |
| the contract is the point, and a future non-code default would escape.) |
| |
| β STATED CONSEQUENCE: hiding `code` blanks this column, because the reference IS the code. |
| That coupling is inherent to seeding from a business key, not a bug β and it fails in the safe |
| direction (an empty frame, never another row's picture). |
| """ |
| if not any(f.get("key") == PRODUCT_IMAGE_KEY for f in fields or ()): |
| return rows |
| for row in rows: |
| if not row.get(PRODUCT_IMAGE_KEY): |
| row[PRODUCT_IMAGE_KEY] = row.get("code") or "" |
| return rows |
|
|
|
|
| def pd_fields(consolidated=True): |
| """The product field contract, minus `product_data.CONSOLIDATED_ONLY` when the caller is scoped. |
| |
| β OWNER RULING 2026-08-11 EMPTIED THAT TUPLE, so today both callers receive the SAME columns |
| and this narrowing is a no-op that is kept, not deleted. Previously a BU-scoped caller lost the |
| whole inventory block, on the grounds that company-wide stock must not sit beside BU-shaped |
| revenue. The owner's answer β *"just scope any inventory with Sales from Fisch, leave the |
| rest"* β splits the block instead: the physical columns stay unscoped (there is one warehouse), |
| the sales-derived ones are recomputed from the unit's own LTM units. The scope difference now |
| lives entirely in the VALUES, which is where a reader can see it. |
| |
| The filter stays because the mechanism is still the right one for the next column that |
| genuinely cannot be BU-shaped; `verify_perm_scope` asserts the tuple is empty so nothing here |
| is silently filtering on a list that grew back. |
| """ |
| import json |
| from pathlib import Path |
|
|
| import aios_grid |
|
|
| doc = json.loads((Path(aios_grid.__file__).resolve().parent / |
| "aios_grid_fields.json").read_text(encoding="utf-8")) |
| fields = list((doc.get("product_data") or {}).get("fields") or []) |
| |
| |
| |
| |
| |
| fields = fields + [{ |
| "key": PRODUCT_IMAGE_KEY, "label": "Image", "type": "image", "source": "overlay", |
| "note": "The product's picture. Empty shows the SKU's own master image; upload one " |
| "from the record panel to override it.", |
| }] |
| if consolidated: |
| return fields |
| import modules.product_data as pd |
| return [f for f in fields if f.get("key") not in pd.CONSOLIDATED_ONLY] |
|
|