loopable / api /routes_products.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
e1b3e71 verified
Raw
History Blame Contribute Delete
21.5 kB
"""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")
#: The registry key this surface WILL carry. The gate is live now even though the nav is not, so
#: the day the registry row appears the wall is already the one that was tested.
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
# ⚠ Signature is (cache, key, ttl, build) β€” the wave-15 version passed `ttl=` as a keyword
# after the build lambda and DIED on every call ("multiple values for 'ttl'"). Latent until
# wave 16 because nothing drove this route end-to-end: verify_perm_scope's section H stubs
# the pool a layer below. verify_api's W16 section now exercises the real call.
key = ("product_pool", team_id)
# ⭐ D-10 (wave 24) β€” THE PRODUCT POOL HONOURS THE CONNECTOR PAUSE. It did not, and that was
# a shipped defect on a live surface: the CUSTOMER pool got this guard at DEBT-2 and its
# sibling β€” written from the same template, four files away β€” never did. So pausing Odoo
# froze the Customer grid and the Product grid kept reading live, with the Settings toggle
# reporting success. Sibling caches diverge exactly this way and nothing greps for
# "the other one".
#
# ⚠ NO SNAPSHOT LEG, and that is the honest difference from the customer path rather than an
# omission: `save_pool_snapshots` persists the `odoo_pool` scopes only β€” there is no product
# snapshot bucket to serve. So this serves the in-process copy AT ANY AGE, and answers 503
# naming the pause when there is none. Minting a product snapshot here would be a new
# persistence format at a wave tail; refusing with a sentence is the answer the user can act
# on, and it is the same 503 the customer path already gives for an unsnapshotted scope.
# ⭐ WAVE 29 / R12 β€” THE POOL IS THE CATALOGUE NOW (5,875 active products, not the 2,717 that
# happened to sell in a revenue window). Two consequences that land HERE rather than in
# `product_data`:
# Β· the 503 below now also covers a TRUNCATED catalogue read. `modules.product_data.
# _catalogue_by_code` RAISES instead of degrading, precisely so a short pull arrives as a
# refusal a user can act on rather than as a plausible smaller grid nobody questions;
# `pool_unavailable` names the cause in its message.
# Β· the first build for a scope got slower (MEASURED 2026-08-11: ~44 s consolidated, of
# which the catalogue read is ~3 s; a BU-scoped build is ~9 s). Only a scope's FIRST-ever
# build blocks β€” the stale-while-refresh discipline above is what keeps that off the
# request path afterwards.
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)
# The SAME wall the customer assembly applies, in the same order: rows first (before pids
# are taken, so an out-of-filter row never enters allowed_pids), then the field closure,
# then the values stripped from the rows as well as the field list.
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)
# ⭐⭐ W30-T36 β€” THE TENANT-WIDE STRATUM, MERGED OVER THE PER-USER ONE, IN THE ASSEMBLY SO
# EVERY CONSUMER SEES ONE TRUTH. `routes_grid`'s /workspace route serves
# `workspace["overlays"] = g["ws"].get("overlays")` verbatim, so doing it here means that door
# carries shared values with no change in a file this lane does not own.
#
# ⚠ SAFE TO MUTATE, and that was checked rather than assumed: `table_workspace` reads through
# `store.get`, which deep-copies, so `ws` is a detached copy and nothing writes it back. A
# shared value can therefore never leak INTO the per-user bucket by way of this merge.
# ⚠ SHARED WINS per key β€” these are contract columns, so the tenant-wide value is the answer
# and any per-user leftover under the same key is stale by construction.
_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,
# R9: the Cohorts column's cells, built from THIS topic's lists. Same read-only
# `derived` channel the customer assembly uses β€” the measure half stays empty.
"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"]:
# 403, not 404 β€” the code may exist; it is simply not in this session's catalogue.
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")
# What actually landed, read back from the PRODUCT bucket rather than echoed from the
# request: a refused key or a truncated value must not be reported as accepted.
# ⚠ W30-T36 β€” AND FROM THE SHARED BUCKET TOO. The four shared columns are written to
# `<TABLE_KEY>__shared` by `_ProductTableStore.patch_overlay`, so a read-back that consulted
# only the per-user stratum would find nothing and report every accepted shared write as
# `refused` β€” a correct write reported as a failure, which is the read-path-cannot-witness-
# the-write-path shape ([[read-path-cannot-witness-write-path]]).
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
#: ⭐ WAVE 19 R7 β€” the RI Product table's built-in picture column. `source: 'overlay'` (editable,
#: so a user can upload a different shot for one SKU) with its DEFAULT supplied per render from
#: the row's `code` β€” see `_seed_image`. A stamped overlay value would have been the other way to
#: do it and it is the wrong one twice over: it is a 1,142-row migration that has to be right
#: once, and it FREEZES the code into the cell, so a re-coded SKU would keep pointing at the old
#: master with nothing saying why.
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:
# A non-empty stored value WINS; only an empty cell is seeded. Blank is therefore
# "no override" rather than "blank", which is the documented meaning `_seed_image`
# already gives this column family and the only one that keeps the master reachable
# after a mistaken edit.
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 [])
# R7: the Image column is BUILT IN on this table, injected here rather than added to
# `aios_grid_fields.json`. That file is the ODOO-SOURCED contract β€” every key in it is a
# column `modules.product_data.pool()` reads off a SKU β€” and this one is neither read from
# Odoo nor written to it. Injected after the JSON so the canonical file stays the answer to
# "what does Odoo give us", which is the question `verify_fields_contract` referees.
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]