| """routes_templates.py β WAVE 23 item 7 (ruling R10, contract C12): the template doors. |
| |
| GET /api/v1/templates?table=<page key> 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')] |
| |
| |
| |
| 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_<template>_<suffix>`): a second click updates the same |
| views rather than minting "Past due 2". β That matters more than it sounds β `save_view` |
| de-duplicates NAMES by appending a number, so an unpinned id would make every re-apply a |
| fresh copy and the store would fill with numbered near-duplicates nobody asked for. |
| """ |
| body = body if isinstance(body, dict) else {} |
| fields, _source = _target_or_refuse(session, body.get("table")) |
| vt = _templates() |
| tpl = vt.get(key) |
| if not tpl: |
| raise err(404, "unknown_template", "that template does not exist") |
| missing = vt.missing_columns(tpl, fields) |
| if missing: |
| |
| |
| raise err(400, "missing_columns", |
| "this database does not have the columns that template needs: " |
| + ", ".join(missing)) |
| ws_key = vt.workspace_key(body.get("table")) |
| if not ws_key: |
| raise err(404, "unknown_table", "that database does not exist") |
| if not session.runtime.available(): |
| raise err(503, "store_unavailable", |
| "the tenant store is unavailable β nothing was applied") |
| import core.table_store as table_store |
| ops = table_store.make(ws_key, st=session.runtime) |
| applied = [] |
| for view in tpl.get('views') or (): |
| |
| |
| |
| |
| |
| payload = dict(view) |
| payload['config'] = dict(view.get('config') or {}) |
| payload['createdBy'] = session.uname |
| saved = ops.save_view(session.uname, payload, shared=False) |
| applied.append({"id": payload['id'], "name": saved.get('name') or payload['name']}) |
|
|
| alert_id = None |
| if tpl.get('alert') and applied: |
| |
| |
| |
| topic = ('product' if body.get("table") == 'product_data' |
| else 'customer' if body.get("table") == 'customer_data' |
| else str(body.get("table"))) |
| try: |
| import core.alerts as alerts |
| alert_id = f"al_{uuid.uuid4().hex[:12]}" |
| alerts.create(alert_id, view_id=applied[0]["id"], topic=topic, |
| owner=session.uname, label=applied[0]["name"], |
| st=session.runtime) |
| except Exception: |
| |
| |
| |
| alert_id = None |
| return {"key": key, "table": body.get("table"), "views": applied, |
| "alert": alert_id, "alerted": bool(alert_id)} |
|
|