File size: 7,801 Bytes
c3e4cb4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | """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')]
# 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_<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:
# NAMED, not counted. "3 columns are missing" is a sentence the reader cannot act on;
# the column keys are what tells them this is the wrong database for this template.
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 ():
# β A COPY PER APPLY, and `createdBy` STAMPED HERE. The registry's dicts are module-level
# β mutating one would write this caller's username into the template every other tenant
# then reads, which is a cross-tenant leak with no symptom until two people apply the
# same template. Same invariant `routes_nav`'s in-place merge documents from the other
# side.
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:
# The template's own view, watched. `core.alerts.create` stores the view BY ID rather
# than a copy of its filter tree, so an alert made here keeps meaning "tell me about
# this view" even after its owner edits it.
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:
# β THE VIEWS ARE ALREADY WRITTEN AND THAT IS THE POINT: a failed alert must not
# un-apply a template that worked. The response says which half landed rather than
# reporting a total failure over a partial success.
alert_id = None
return {"key": key, "table": body.get("table"), "views": applied,
"alert": alert_id, "alerted": bool(alert_id)}
|