"""routes_templates.py — WAVE 23 item 7 (ruling R10, contract C12): the template doors. GET /api/v1/templates?table= what can be applied HERE POST /api/v1/templates/{key}/apply {table} apply it, to the CALLING USER's own views Both session-gated. Neither is public and neither is admin-only: applying a template writes the caller's PERSONAL saved views, which is something every user does to their own workspace all day (`table_store.save_view(..., shared=False)`). The registry itself is platform-curated code (`platform/core/view_templates.py`) — R10 explicitly rules out an end-user authoring UI this wave, so there is no write door for the registry at all. ⛔ THE WALL IS THE SAME PREDICATE THE NAV USES, per topic, and it is applied before anything is read or written: · `ut_*` — `user_tables.may_open` (creator, admin, or a share grant), exactly as `routes_tables._defn_or_refuse` does. `session.require` would 403 every ut key, because a user table is deliberately not a module (the split `/nav/schema/{key}` already makes). · a built-in topic — `session.require(key)`, the module grant. A key that is neither is a 404 with the same sentence for both: this route is not a directory of what exists. ⚠ REFUSE, NEVER PARTIALLY APPLY. `view_templates.missing_columns` runs against the TARGET's live contract, and a template naming a column the target does not have comes back 400 with the columns listed. The alternative — writing the views anyway — is the `_seed_wave17` failure verbatim: `clean_filter_tree` DROPS a leaf on an unknown column, and a view whose only condition was dropped shows EVERY row under a name that promises a shortlist. """ import uuid from fastapi import APIRouter, Body, Depends from deps import Session, err, perms, require_session router = APIRouter(prefix="/api/v1") def _templates(): import core.view_templates as view_templates return view_templates def _target_or_refuse(session, table_key): """`(field_keys, source_label)` for a table this session may open — or a refusal. The field keys are the LIVE contract, read the same way each topic's own reader reads it, so a template can never be offered against a column list this end assembled by hand. """ key = str(table_key or '').strip() if not key: raise err(400, "bad_request", "no database was named") if key.startswith('ut_'): import core.user_tables as user_tables defn = user_tables.get(key, st=session.runtime) if not defn: raise err(404, "unknown_table", "that database does not exist") if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime): raise err(403, "forbidden", "that database belongs to another user") fields = [str(f.get('key')) for f in (defn.get('fields') or []) if f.get('key')] # A user table has no fixed topic. Its SOURCE label is left blank so `offer` gates on # columns alone — which is the honest answer for a table whose shape its owner decides, # and is exactly why the column test is the real contract (view_templates' own note). return fields, '' if key not in ('customer_data', 'product_data'): raise err(404, "unknown_table", "that database does not exist") session.require(key) import aios_grid if key == 'product_data': return [f['key'] for f in aios_grid.product_fields()], 'odoo_product' return [f['key'] for f in aios_grid.FIELDS], 'odoo_customer' @router.get("/templates") def list_templates(table: str = "", session: Session = Depends(require_session)): """What can be applied to THIS database. `{table, templates: [...]}`. Filtered by the target's real columns, so the picker cannot offer something the apply door would refuse — one predicate, two callers (`view_templates.offer` wraps `missing_columns`, which is the same function the POST below re-runs). """ fields, source = _target_or_refuse(session, table) vt = _templates() return {"table": table, "templates": vt.offer(fields, source or None)} @router.post("/templates/{key}/apply") def apply_template(key: str, body: dict = Body(default=None), session: Session = Depends(require_session)): """Apply a template to a database, as the CALLING USER's own saved views. IDEMPOTENT BY PINNED VIEW ID (`tpl_