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)}