| """routes_keychain.py β Keychains + Connectors admin surfaces (wave 18, C7 / R3).
|
|
|
| Keychain: encrypted per-tenant credential entries (`core/keychain.py`). The routes NEVER
|
| return a decrypted field β list rows carry a masked preview, and the decrypt function is a
|
| connector-layer internal. Connectors: the tenant's data sources as STATUS rows β Royal's
|
| env-configured Odoo, keychain-held sources β plus R3's guardrail: the **Unsynced records**
|
| count (rows holding overlay data whose pids the current pool no longer serves; counted and
|
| drillable, never silently dropped) and a pause toggle whose v1 semantics are stated honestly
|
| in the payload (`pausedNote`): pausing marks intent and warns; the source cutover ships with
|
| the keychain cutover wave R3 staged.
|
| """
|
| import os
|
|
|
| from fastapi import Body, Depends
|
| from fastapi import APIRouter
|
|
|
| from deps import Session, err, require_session
|
|
|
|
|
|
|
|
|
|
|
| router = APIRouter(prefix="/api/v1")
|
|
|
|
|
|
|
|
|
| from harness.runtime import (CONNECTOR_FLAGS_KEY as _CONNECTOR_FLAGS_KEY,
|
| ENV_ODOO_FLAG_KEY as _ENV_ODOO_FLAG_KEY)
|
|
|
|
|
| SNAPSHOT_KEY = "connector_snapshots"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| ENV_ODOO_TENANT = "royal-imports"
|
|
|
|
|
| def env_odoo_available(rt):
|
| """Does the PROCESS ENVIRONMENT offer an Odoo connection to THIS tenant? (W32-T10.)
|
|
|
| β THE ONE NORMALIZER FOR ONE QUESTION, and it exists because there were two answers to it.
|
| `routes_connectors.directory._odoo` read a bare `os.environ.get("ODOO_URL")` with **no tenant
|
| guard** β one process, every tenant β so a nurilab admin opening Connectors was told Odoo was
|
| `connected` and offered "Manage keys" for a credential belonging to another company. This
|
| module asked the same question correctly two functions below, which is the whole shape of
|
| [[one-question-two-normalizers]]: the correct copy hides the wrong one until somebody signs in
|
| as the second tenant.
|
|
|
| β NOT the same question as `rt.odoo_flag_key() == ENV_ODOO_FLAG_KEY`. That one answers *which
|
| source WINS*, so it goes False the moment a keychain entry exists β correct for a pause flag,
|
| wrong for "should the environment row be listed at all", which is what the connectors pane
|
| needs in order to show an inactive env source beside an active keychain one.
|
|
|
| Never raises: a runtime that cannot answer is not connected. Fail closed β a missing guard is
|
| how another tenant's environment got reported as this tenant's connection in the first place.
|
| """
|
| try:
|
| if not (os.environ.get("ODOO_URL") or "").strip():
|
| return False
|
| return str(getattr(rt, "key", "") or "") == ENV_ODOO_TENANT
|
| except Exception:
|
| return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| SCOPES = ("business", "personal")
|
|
|
|
|
|
|
|
|
| DEFAULT_SCOPE = "business"
|
|
|
|
|
|
|
| SCOPE_KEY = "keychain_scopes"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| TENANT_WIDE_TYPES = ("odoo", "meta_ads")
|
|
|
|
|
| def clean_scope(raw, default=DEFAULT_SCOPE):
|
| """A scope word from the wire, or None when the caller said something we do not speak.
|
|
|
| Distinguishing "said nothing" (β the default) from "said nonsense" (β 400) is the whole
|
| reason this returns None rather than falling back: a typo'd `"personel"` silently becoming
|
| business-wide is exactly the failure a scope picker exists to prevent.
|
| """
|
| if raw is None or (isinstance(raw, str) and not raw.strip()):
|
| return default
|
| got = str(raw).strip().lower()
|
| return got if got in SCOPES else None
|
|
|
|
|
| def _scope_rows(rt):
|
| try:
|
| return dict(rt.get(SCOPE_KEY) or {})
|
| except Exception:
|
| return {}
|
|
|
|
|
| def entry_scope(rt, entry_id, rows=None):
|
| """`(scope, owner)` for one entry. `rows` is the bucket, passed in when walking a list so a
|
| census does not re-read the store once per entry."""
|
| r = (rows if rows is not None else _scope_rows(rt)).get(str(entry_id))
|
| if not isinstance(r, dict):
|
| return DEFAULT_SCOPE, ""
|
| return (str(r.get("scope") or DEFAULT_SCOPE), str(r.get("owner") or ""))
|
|
|
|
|
| def may_see(scope, owner, uname):
|
| """R4's visibility rule: business-wide is everyone's, personal is its owner's.
|
|
|
| β AND AN ADMIN IS NOT AN EXCEPTION. R4 says a personal entry belongs to a person; the
|
| per-user OAuth slots one module down have made the same call since wave 22 (*"a refresh token
|
| is identity, not infrastructure"*). An admin who could read every member's personal credential
|
| would make "personal" a label rather than a boundary.
|
| """
|
| return scope != "personal" or str(owner) == str(uname)
|
|
|
|
|
| def visible_entries(rt, uname, is_admin=False):
|
| """This USER's view of the keychain: every business entry plus their own personal ones, each
|
| row carrying its `scope` and `owner` so no caller has to ask a second time.
|
|
|
| β THE MASKED PREVIEW IS NOT PART OF "VISIBLE". R4 opens this room to members so they can
|
| hold a connection of their own; it does not hand them four characters of the workspace's Odoo
|
| key. So a business row a member did not create arrives WITHOUT `preview` β they can see that
|
| the connection exists and is theirs to use, which is the whole of what R4 grants. The default
|
| is the RESTRICTED one deliberately: a caller that forgets the argument leaks nothing.
|
| """
|
| rows = _scope_rows(rt)
|
| out = []
|
| for e in _kc().list_entries(rt):
|
| scope, owner = entry_scope(rt, e["id"], rows)
|
| if not owner:
|
| owner = str(e.get("createdBy") or "")
|
| if not may_see(scope, owner, uname):
|
| continue
|
| row = {**e, "scope": scope, "owner": owner}
|
| if not (is_admin or str(owner) == str(uname)):
|
| row["preview"] = ""
|
| out.append(row)
|
| return out
|
|
|
|
|
| def _write_scope(rt, entry_id, scope, owner):
|
| """Persist one entry's scope. A `business` entry CLEARS its row rather than writing the
|
| default, so the store holds one spelling of the common case."""
|
| def _up(cur):
|
| if scope == DEFAULT_SCOPE:
|
| cur.pop(str(entry_id), None)
|
| else:
|
| cur[str(entry_id)] = {"scope": scope, "owner": str(owner or "")}
|
| return cur
|
|
|
| rt.update(SCOPE_KEY, _up, flush="sync")
|
| return True
|
|
|
|
|
| def _may_touch(session, row):
|
| """May this session change or delete `row`? An admin owns the business-wide ones; a member
|
| owns their own personal ones. Anything else is not theirs to move."""
|
| if row.get("scope") == "personal":
|
| return str(row.get("owner") or "") == str(session.uname)
|
| return bool(session.admin)
|
|
|
|
|
| def _kc():
|
| import core.keychain as keychain
|
| return keychain
|
|
|
|
|
| def _resolved_odoo_key(rt):
|
| """`(source, flag_key)` β which source would serve this tenant's Odoo queries, in both the
|
| shapes this module needs: the display string (`env` / `keychain:<id>`) and the key the pause
|
| flag is stored under.
|
|
|
| β D-10 (wave 24): THE RESOLUTION ITSELF NOW LIVES IN ONE PLACE, `TenantRuntime.odoo_flag_key`,
|
| beside the `odoo_source()` it must agree with. This function had its own copy of the same
|
| three rules β first unlocked keychain odoo entry, else env for tenant #0, else nothing β and
|
| a second copy is exactly how a pause flag comes to be written against one resolution and read
|
| against another, freezing nothing while the UI reports success. The two SHAPES stay here
|
| because they are this module's presentation concern; the DECISION does not.
|
| """
|
| flag_key = rt.odoo_flag_key()
|
| if not flag_key:
|
| return None, None
|
| return ("env" if flag_key == _ENV_ODOO_FLAG_KEY else f"keychain:{flag_key}"), flag_key
|
|
|
|
|
| def odoo_paused(rt):
|
| """True when the tenant's RESOLVED Odoo source carries the pause flag. Pausing an entry
|
| that is not the resolved source freezes nothing β it serves nothing.
|
|
|
| β D-10: a thin delegate now. The implementation moved to `TenantRuntime.odoo_paused` so the
|
| measure mirror (`harness/datastore.py`, which cannot import this layer) asks the SAME question
|
| the customer pool does. This name stays because `routes_customers`, `routes_products` and
|
| `verify_api` all call it β moving the logic without moving the door keeps one answer and
|
| costs no caller a change.
|
| """
|
| return bool(rt.odoo_paused())
|
|
|
|
|
| def _snap_scope_key(team_id, agent):
|
| return f"t={team_id}|a={agent}"
|
|
|
|
|
| def load_pool_snapshot(rt, team_id, agent):
|
| """(ts, rows) from the persisted snapshot for this exact scope, or None. NEVER a wider
|
| scope's rows β serving the consolidated snapshot to a scoped user would widen their book."""
|
| try:
|
| snap = (rt.get(SNAPSHOT_KEY) or {}).get("odoo_pool") or {}
|
| e = snap.get(_snap_scope_key(team_id, agent))
|
| if isinstance(e, dict) and isinstance(e.get("rows"), list):
|
| return float(e.get("ts") or 0), e["rows"]
|
| except Exception:
|
| pass
|
| return None
|
|
|
|
|
| def save_pool_snapshots(rt, taken_by=""):
|
| """Persist every currently-cached pool scope as the pause-time snapshot ('the last
|
| successful sync', made concrete). Ensures the consolidated default scope exists first so
|
| a pause on a cold process still captures something to serve."""
|
| import time as _time
|
| import routes_customers as _rc
|
| try:
|
| _rc._pool_for(rt, None, None)
|
| except Exception:
|
| pass
|
| pools = {}
|
| for key, entry in list(rt.pool_cache.items()):
|
| if (isinstance(key, tuple) and len(key) == 3 and key[0] == "pool"
|
| and isinstance(entry, tuple) and len(entry) == 2
|
| and isinstance(entry[1], list)):
|
| pools[_snap_scope_key(key[1], key[2])] = {"ts": entry[0], "rows": entry[1]}
|
| if not pools:
|
| return 0
|
|
|
| def _up(cur):
|
| cur["odoo_pool"] = pools
|
| cur["taken"] = _time.strftime("%Y-%m-%dT%H:%M:%S")
|
| cur["takenBy"] = str(taken_by or "")
|
| return cur
|
|
|
| rt.update(SNAPSHOT_KEY, _up, flush="sync")
|
| return len(pools)
|
|
|
|
|
| def _rel_reconnect(rt):
|
| """Lift the W32-T16 freeze. Its own function so `add_key` and the reconnect route cannot
|
| disagree about what "resume" means."""
|
| import odoo_relational as rel
|
|
|
| def _up(cur):
|
| cur = cur if isinstance(cur, dict) else {}
|
| cur["frozen"] = False
|
| cur.pop("frozenAt", None)
|
| cur.pop("frozenBy", None)
|
| return cur
|
|
|
| rt.update(rel.CONFIG_KEY, _up, flush="sync")
|
| return True
|
|
|
|
|
| def _own_row(session, entry_id):
|
| """The visible row for `entry_id`, or a 404. β A 404 rather than a 403 for an entry the
|
| caller cannot see: telling a member that somebody else's personal credential EXISTS is the
|
| disclosure the scope is for."""
|
| row = next((e for e in visible_entries(session.runtime, session.uname,
|
| bool(session.admin))
|
| if e["id"] == str(entry_id)), None)
|
| if row is None:
|
| raise err(404, "no_entry", "no such key")
|
| return row
|
|
|
|
|
| @router.get("/admin/keychain")
|
| def list_keychain(session: Session = Depends(require_session)):
|
| """β W32-T11 / R4 β SESSION-GATED, NOT ADMIN-GATED, and that is the ruling not a relaxation.
|
| R4 puts a PERSONAL connection in every member's hands, so a room only an admin can open would
|
| ship the feature and no door to it. The wall moved INTO the payload: a member sees the
|
| business-wide entries and their own, never anybody else's personal one."""
|
| kc = _kc()
|
| return {"entries": visible_entries(session.runtime, session.uname,
|
| bool(session.admin)),
|
| "locked": not kc.unlocked(),
|
|
|
|
|
| "scopes": list(SCOPES), "canBusiness": bool(session.admin),
|
| "tenantWideTypes": list(TENANT_WIDE_TYPES)}
|
|
|
|
|
| @router.post("/admin/keychain", status_code=201)
|
| def add_key(body: dict = Body(default=None), session: Session = Depends(require_session)):
|
| kc = _kc()
|
| body = body or {}
|
| if not session.runtime.available():
|
| raise err(503, "store_unavailable", "the tenant store is unavailable")
|
| scope = clean_scope(body.get("scope"))
|
| if scope is None:
|
| raise err(400, "bad_scope",
|
| f"scope must be one of {', '.join(SCOPES)}")
|
| if scope == "business" and not session.admin:
|
| raise err(403, "not_admin",
|
| "a business-wide connection applies to everyone in this workspace, so only an "
|
| "administrator can create one. You can add it as a personal connection instead.")
|
| etype = str(body.get("type") or "").strip().lower()
|
| if scope == "personal" and etype in TENANT_WIDE_TYPES:
|
|
|
|
|
|
|
| raise err(400, "scope_not_available",
|
| f"a {etype} connection is what this whole workspace's databases are read "
|
| f"through, so it is always business-wide β it cannot be a personal connection. "
|
| f"An administrator can add it for everyone.")
|
| try:
|
| row = kc.add_entry(session.runtime, body.get("label"), body.get("type"),
|
| body.get("fields"), session.uname)
|
| except kc.KeychainLocked as e:
|
| raise err(503, "keychain_locked",
|
| f"the keychain is locked β {e}. A secret is never stored unencrypted.")
|
| except ValueError as e:
|
| raise err(400, "bad_entry", str(e))
|
| except Exception:
|
| raise err(503, "store_unavailable", "the entry was not saved β try again")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if etype == "odoo":
|
| try:
|
| _rel_reconnect(session.runtime)
|
| except Exception:
|
| pass
|
| if scope != DEFAULT_SCOPE:
|
| try:
|
| _write_scope(session.runtime, row["id"], scope, session.uname)
|
| except Exception:
|
| try:
|
| kc.delete_entry(session.runtime, row["id"])
|
| except Exception:
|
| pass
|
| raise err(503, "store_unavailable",
|
| "the key was not saved β its sharing setting could not be stored, so "
|
| "nothing was kept. Try again.")
|
| return {"entry": {**row, "scope": scope, "owner": session.uname}}
|
|
|
|
|
| @router.put("/admin/keychain/{entry_id}")
|
| def update_key(entry_id: str, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """Contract C1's scope door. Only the scope moves β a stored secret is never re-openable, so
|
| "edit this key" means "replace it" and that is `DELETE` + `POST`."""
|
| row = _own_row(session, entry_id)
|
| scope = clean_scope((body or {}).get("scope"), default=None)
|
| if scope is None:
|
| raise err(400, "bad_scope", f"scope must be one of {', '.join(SCOPES)}")
|
| if scope == "business" and not session.admin:
|
| raise err(403, "not_admin",
|
| "a business-wide connection applies to everyone in this workspace, so only an "
|
| "administrator can make one business-wide.")
|
| if not _may_touch(session, row):
|
| raise err(403, "not_yours", "this connection is not yours to change")
|
| if scope == "personal" and str(row.get("type") or "") in TENANT_WIDE_TYPES:
|
| raise err(400, "scope_not_available",
|
| f"a {row.get('type')} connection is what this whole workspace's databases are "
|
| f"read through, so it is always business-wide.")
|
| owner = row.get("owner") or session.uname
|
| try:
|
| _write_scope(session.runtime, entry_id, scope, owner)
|
| except Exception:
|
| raise err(503, "store_unavailable", "the change was not saved β try again")
|
| return {"entry": {**row, "scope": scope, "owner": owner if scope == "personal" else ""}}
|
|
|
|
|
| @router.delete("/admin/keychain/{entry_id}")
|
| def delete_key(entry_id: str, session: Session = Depends(require_session)):
|
| row = _own_row(session, entry_id)
|
| if not _may_touch(session, row):
|
| raise err(403, "not_yours", "this connection is not yours to delete")
|
| try:
|
| _kc().delete_entry(session.runtime, entry_id)
|
| _write_scope(session.runtime, entry_id, DEFAULT_SCOPE, "")
|
| except Exception:
|
| raise err(503, "store_unavailable", "the delete did not land β try again")
|
| return {"ok": True}
|
|
|
|
|
| @router.post("/admin/keychain/{entry_id}/test")
|
| def test_key(entry_id: str, session: Session = Depends(require_session)):
|
| _own_row(session, entry_id)
|
| return _kc().test_entry(session.runtime, entry_id)
|
|
|
|
|
| def _unsynced_customer_records(session):
|
| """R3's guardrail, tenant #0's customer topic: overlay-holding pids the CURRENT pool no
|
| longer serves. Overlays are unioned across EVERY user of the table (the guardrail is a
|
| tenant fact, not a per-user one). Honest degradation: when the pool cannot be built the
|
| answer is `known: False`, never a fabricated zero."""
|
| try:
|
| import core.table_store as table_store
|
| bucket = session.runtime.get("customer_table_workspace") or {}
|
| overlay_pids = {}
|
| for uname, ws in bucket.items():
|
| if uname == table_store.SHARED_KEY or not isinstance(ws, dict):
|
| continue
|
| for pid, cells in (ws.get("overlays") or {}).items():
|
| if isinstance(cells, dict) and cells:
|
| overlay_pids.setdefault(str(pid), cells)
|
| if not overlay_pids:
|
| return {"known": True, "count": 0, "rows": []}
|
| from routes_customers import allowed_pids
|
| pool = {str(p) for p in allowed_pids(session)}
|
| orphans = sorted((p for p in overlay_pids if p not in pool), key=lambda x: int(x)
|
| if str(x).isdigit() else 0)
|
| rows = []
|
| for p in orphans[:50]:
|
| cells = overlay_pids[p]
|
| hint = next((str(v) for v in cells.values() if str(v).strip()), "")
|
| rows.append({"pid": int(p) if str(p).isdigit() else p,
|
| "fields": len(cells), "hint": hint[:80]})
|
| return {"known": True, "count": len(orphans), "rows": rows,
|
| "shown": min(len(orphans), 50)}
|
| except Exception as e:
|
| return {"known": False, "count": None, "rows": [],
|
| "note": f"pool unavailable β {type(e).__name__}"}
|
|
|
|
|
| @router.get("/admin/connectors")
|
| def connectors(session: Session = Depends(require_session)):
|
| kc = _kc()
|
| flags = session.runtime.get(_CONNECTOR_FLAGS_KEY) or {}
|
|
|
|
|
|
|
| entries = visible_entries(session.runtime, session.uname, bool(session.admin))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _scopes = _scope_rows(session.runtime)
|
| first_odoo = next((e["id"] for e in kc.list_entries(session.runtime)
|
| if e["type"] == "odoo"
|
| and entry_scope(session.runtime, e["id"], _scopes)[0] != "personal"), None)
|
|
|
|
|
|
|
| if first_odoo and kc.unlocked():
|
| resolved = f"keychain:{first_odoo}"
|
| elif env_odoo_available(session.runtime):
|
| resolved = "env"
|
| else:
|
| resolved = None
|
| rows = []
|
| if env_odoo_available(session.runtime):
|
| rows.append({"key": _ENV_ODOO_FLAG_KEY, "label": "Odoo (environment)", "type": "odoo",
|
| "source": "env", "active": resolved == "env",
|
|
|
|
|
| "scope": DEFAULT_SCOPE, "owner": "",
|
| "paused": bool((flags.get(_ENV_ODOO_FLAG_KEY) or {}).get("paused"))})
|
| for e in entries:
|
| rows.append({"key": e["id"], "label": e["label"], "type": e["type"],
|
| "source": "keychain", "preview": e["preview"],
|
| "scope": e.get("scope") or DEFAULT_SCOPE, "owner": e.get("owner") or "",
|
| "active": (e["type"] == "odoo" and resolved == f"keychain:{e['id']}"),
|
| "paused": bool((flags.get(e["id"]) or {}).get("paused"))})
|
| out = {"connectors": rows, "locked": not kc.unlocked(), "resolved": resolved,
|
| "scopes": list(SCOPES), "canBusiness": bool(session.admin),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| "pausedNote": ("Pausing a connector never deletes data β notes, custom fields and "
|
| "views stay, and nothing reaches the source while it is paused. "
|
| "Anything this server has already read keeps showing: the customer "
|
| "workspace serves its pause-time snapshot, the product list serves "
|
| "the last copy read since startup, and measure columns keep answering "
|
| "from the mirror as it stood when you paused. What has NOT been read "
|
| "cannot be shown β a scope with no snapshot, or the product list after "
|
| "a restart, reports that the source is paused instead of showing "
|
| "figures. Resume to start reading live again.")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if session.tenant == "royal-imports" and session.admin:
|
| out["unsynced"] = _unsynced_customer_records(session)
|
| return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _rel():
|
| import odoo_relational as rel
|
| return rel
|
|
|
|
|
| def _odoo_entry(session):
|
| """The keychain entry SERVING this tenant's Odoo, or None when the environment is (or nothing
|
| is). Business-scoped by construction β `TENANT_WIDE_TYPES` refuses a personal one."""
|
| kc = _kc()
|
| scopes = _scope_rows(session.runtime)
|
| for e in kc.list_entries(session.runtime):
|
| if e["type"] == "odoo" and entry_scope(session.runtime, e["id"], scopes)[0] != "personal":
|
| return e
|
| return None
|
|
|
|
|
| def _odoo_source_fields(session, entry):
|
| """`(serverDb, serverUrl, apiUser, editable)` β what the panel may SHOW about the connection.
|
|
|
| β NEVER THE SECRET. `read_fields` is documented as the connector layer's internal and no
|
| route returns its output; this returns the three fields that identify WHICH server, and the
|
| api key is not among them. The masked preview is the entry's own and was computed at write.
|
| β The ENVIRONMENT source is not editable and says so: it is the deployment's `.env`, shared by
|
| the process, and an admin editing it from a tenant screen would be editing the container.
|
| """
|
| if entry is None:
|
| return (os.environ.get("ODOO_DB", ""), os.environ.get("ODOO_URL", ""),
|
| os.environ.get("ODOO_USER", ""), False)
|
| try:
|
| f = _kc().read_fields(session.runtime, entry["id"]) or {}
|
| except Exception:
|
| return ("", "", "", True)
|
| return (str(f.get("db") or ""), str(f.get("url") or ""), str(f.get("user") or ""), True)
|
|
|
|
|
| def _odoo_admin(session):
|
| """C2's doors are admin doors: they show the credential that serves EVERYONE and can turn the
|
| whole workspace's databases off. R4's personal scope has nothing to say here β a tenant-wide
|
| type cannot be personal in the first place."""
|
| if not session.admin:
|
| raise err(403, "not_admin",
|
| "the Odoo connection serves this whole workspace, so only an administrator can "
|
| "configure it")
|
|
|
|
|
| @router.get("/admin/connectors/odoo/config")
|
| def odoo_config(session: Session = Depends(require_session)):
|
| """Contract C2's read: `{serverDb, grids, syncEvery, canDisconnect}` and the rest of what a
|
| person needs to see before changing any of it."""
|
| _odoo_admin(session)
|
| rel = _rel()
|
| entry = _odoo_entry(session)
|
| server_db, server_url, api_user, editable = _odoo_source_fields(session, entry)
|
| cfg = rel.read_config(session.runtime)
|
| return {
|
| "applicable": bool(rel.is_royal(session.tenant)),
|
| "source": "keychain" if entry else ("env" if env_odoo_available(session.runtime)
|
| else "none"),
|
| "entryId": (entry or {}).get("id", ""),
|
| "label": (entry or {}).get("label", "Odoo (environment)"),
|
| "preview": (entry or {}).get("preview", ""),
|
| "serverDb": server_db, "serverUrl": server_url, "apiUser": api_user,
|
| "serverDbEditable": editable,
|
| "grids": rel.grid_choices(session.runtime),
|
| "syncEvery": cfg["syncEvery"],
|
| "syncOptions": list(rel.SYNC_PRESETS),
|
| "syncFloorSeconds": rel.SYNC_FLOOR_SECONDS,
|
| "frozen": cfg["frozen"], "frozenAt": cfg["frozenAt"],
|
|
|
|
|
|
|
| "canDisconnect": bool(entry) or (env_odoo_available(session.runtime)
|
| and not cfg["frozen"]),
|
| }
|
|
|
|
|
| @router.put("/admin/connectors/odoo/config")
|
| def odoo_config_put(body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """Contract C2's write. Grids, cadence and the server database β each optional, each REPORTED
|
| back rather than silently applied."""
|
| _odoo_admin(session)
|
| rel = _rel()
|
| body = body or {}
|
| notes = []
|
|
|
| grids = body.get("grids")
|
| known = {c["key"] for c in rel.grid_choices(session.runtime)}
|
| clean_grids = None
|
| if isinstance(grids, dict):
|
| unknown = sorted(str(k) for k in grids if str(k) not in known)
|
| if unknown:
|
|
|
|
|
| raise err(400, "unknown_grid",
|
| f"this connector has no grid called {', '.join(unknown)}")
|
| clean_grids = {str(k): bool(v) for k, v in grids.items()}
|
| if clean_grids and not any(clean_grids.get(k, True) for k in known):
|
| notes.append("every grid is switched off β nothing will be materialised on the next "
|
| "sync, and the databases you already have are left untouched")
|
|
|
| every = body.get("syncEvery")
|
| clean_every = None
|
| if every is not None:
|
| clean_every = str(every).strip().lower()
|
| if clean_every not in rel.SYNC_PRESETS:
|
|
|
|
|
|
|
|
|
| clean_every = rel.DEFAULT_SYNC
|
| notes.append(f"{every!r} is not an interval this connector offers, and anything under "
|
| f"{rel.SYNC_FLOOR_SECONDS // 60} minutes is not available at all β the "
|
| f"sync interval was set to the {rel.DEFAULT_SYNC} floor instead")
|
|
|
| server_db = body.get("serverDb")
|
| if server_db is not None:
|
| server_db = " ".join(str(server_db).split())[:80]
|
|
|
| def _up(cur):
|
| cur = cur if isinstance(cur, dict) else {}
|
| if clean_grids is not None:
|
| cur.setdefault("grids", {}).update(clean_grids)
|
| if clean_every is not None:
|
| cur["syncEvery"] = clean_every
|
| return cur
|
|
|
| try:
|
| session.runtime.update(rel.CONFIG_KEY, _up, flush="sync")
|
| except Exception:
|
| raise err(503, "store_unavailable", "the change was not saved β try again")
|
|
|
| if server_db:
|
| notes.append(_rewrite_server_db(session, server_db))
|
|
|
| out = odoo_config(session)
|
| return {**out, "notes": [n for n in notes if n]}
|
|
|
|
|
| def _rewrite_server_db(session, server_db):
|
| """Point the stored Odoo credential at a different server database (R9's first reading).
|
|
|
| β THERE IS NO "UPDATE ENTRY" IN THE KEYCHAIN, and writing one here would be a SECOND copy of
|
| how a secret is encrypted and previewed β the thing `core/keychain.py` exists to hold alone.
|
| So this is add-then-delete through the module's own doors, with the side rows (pause flag,
|
| scope) carried across because they are keyed by ENTRY ID.
|
| β THE ORDER IS DELIBERATE AND THE WINDOW IS REAL: for the moment between the add and the
|
| delete this tenant has TWO odoo entries, and `odoo_creds` takes the first by id sort β so a
|
| resync landing inside that window could read the OLD database. The alternative order can
|
| leave the workspace with no credential at all, which is worse than one stale read. Milliseconds
|
| of ambiguity beats a lost key.
|
| """
|
| kc = _kc()
|
| entry = _odoo_entry(session)
|
| if entry is None:
|
| return ("the server database is set on this deployment's environment, not in the "
|
| "keychain, so it was not changed here")
|
| try:
|
| fields = kc.read_fields(session.runtime, entry["id"]) or {}
|
| except kc.KeychainLocked as e:
|
| raise err(503, "keychain_locked", f"the keychain is locked β {e}")
|
| if not fields:
|
| raise err(400, "bad_entry", "this credential could not be read back to be changed")
|
| if str(fields.get("db") or "") == server_db:
|
| return ""
|
| fields["db"] = server_db
|
| try:
|
| new = kc.add_entry(session.runtime, entry["label"], "odoo", fields, session.uname)
|
| except Exception:
|
| raise err(503, "store_unavailable",
|
| "the server database was not changed β the existing connection is untouched")
|
|
|
| try:
|
| flags = session.runtime.get(_CONNECTOR_FLAGS_KEY) or {}
|
| if entry["id"] in flags:
|
| def _mv(cur):
|
| cur[new["id"]] = cur.pop(entry["id"], {})
|
| return cur
|
| session.runtime.update(_CONNECTOR_FLAGS_KEY, _mv)
|
| kc.delete_entry(session.runtime, entry["id"])
|
| _write_scope(session.runtime, entry["id"], DEFAULT_SCOPE, "")
|
| except Exception:
|
| return (f"the connection now points at {server_db}, but the previous credential could "
|
| f"not be removed β delete it under Keychains")
|
| return f"the connection now points at the {server_db} database"
|
|
|
|
|
| @router.post("/admin/connectors/odoo/disconnect")
|
| def odoo_disconnect(session: Session = Depends(require_session)):
|
| """R10 β remove the credential and FREEZE the grids as static data.
|
|
|
| β DISTINCT FROM PAUSE, and the difference is the credential. Pause is temporary and keeps the
|
| key; disconnect deletes it and marks the databases frozen so nothing refreshes them again β
|
| including the boot rebuild and the resync loop, which for tenant #0 would otherwise
|
| re-materialise from the process ENVIRONMENT and quietly undo the disconnect.
|
| ββ AND IT DELETES NOTHING ELSE. The owner's words are *"so we don't fuck up"*: every row and
|
| every FIELD DEFINITION stays, user-added columns included, because a field a person added is
|
| the thing a naive freeze drops first. This route never touches `fields` or `rows` β it writes
|
| one flag in a different bucket, which is what makes that guarantee structural rather than
|
| careful.
|
| """
|
| _odoo_admin(session)
|
| rel = _rel()
|
| import datetime as _dt
|
| entry = _odoo_entry(session)
|
| if not entry and not env_odoo_available(session.runtime):
|
| raise err(400, "not_connected", "this workspace has no Odoo connection to disconnect")
|
|
|
| def _up(cur):
|
| cur = cur if isinstance(cur, dict) else {}
|
| cur["frozen"] = True
|
| cur["frozenAt"] = _dt.datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
| cur["frozenBy"] = str(session.uname)
|
| return cur
|
|
|
|
|
|
|
|
|
|
|
| try:
|
| session.runtime.update(rel.CONFIG_KEY, _up, flush="sync")
|
| except Exception:
|
| raise err(503, "store_unavailable", "nothing was disconnected β try again")
|
| removed = ""
|
| if entry is not None:
|
| try:
|
| _kc().delete_entry(session.runtime, entry["id"])
|
| _write_scope(session.runtime, entry["id"], DEFAULT_SCOPE, "")
|
| removed = entry["id"]
|
| except Exception:
|
| raise err(503, "store_unavailable",
|
| "the databases are frozen but the stored credential was not removed β "
|
| "delete it under Keychains")
|
| return {"frozen": True, "removedEntry": removed,
|
| "note": "Your Odoo databases are frozen: every row and every column you had is still "
|
| "there and still readable, and nothing is being refreshed. Reconnecting adds "
|
| "the key back and resumes into the same databases."}
|
|
|
|
|
| @router.post("/admin/connectors/odoo/reconnect")
|
| def odoo_reconnect(session: Session = Depends(require_session)):
|
| """R10's second sentence β *"Reconnecting resumes into the same tables."*
|
|
|
| It clears the freeze and nothing else: the tables were never dropped, so there is nothing to
|
| recreate. A tenant whose source was a keychain entry adds it back under Keychains first; this
|
| is the switch that lets the sync path see it again.
|
| """
|
| _odoo_admin(session)
|
| try:
|
| _rel_reconnect(session.runtime)
|
| except Exception:
|
| raise err(503, "store_unavailable", "the change was not saved β try again")
|
| connected = bool(_odoo_entry(session)) or env_odoo_available(session.runtime)
|
| return {"frozen": False, "connected": connected,
|
| "note": ("Odoo is connected again and the databases you already had will refresh in "
|
| "place." if connected else
|
| "The freeze is lifted, but there is no Odoo credential yet β add one under "
|
| "Keychains and the databases resume into the same tables.")}
|
|
|
|
|
| @router.post("/admin/connectors/{key}/pause")
|
| def pause_connector(key: str, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| paused = bool((body or {}).get("paused"))
|
| if not session.runtime.available():
|
| raise err(503, "store_unavailable", "the tenant store is unavailable")
|
|
|
|
|
|
|
| row = next((e for e in visible_entries(session.runtime, session.uname,
|
| bool(session.admin))
|
| if e["id"] == str(key)), None)
|
| if row is not None:
|
| if not _may_touch(session, row):
|
| raise err(403, "not_yours", "this connection is not yours to pause")
|
| elif not session.admin:
|
| raise err(403, "forbidden", "administrators only")
|
|
|
|
|
|
|
|
|
| snapshots = 0
|
| _, flag_key = _resolved_odoo_key(session.runtime)
|
| if paused and flag_key and str(key) == flag_key:
|
| snapshots = save_pool_snapshots(session.runtime, taken_by=session.uname)
|
|
|
| def _up(cur):
|
| cur[str(key)] = {"paused": paused}
|
| return cur
|
|
|
| try:
|
| session.runtime.update(_CONNECTOR_FLAGS_KEY, _up)
|
| except Exception:
|
| raise err(503, "store_unavailable", "the change was not saved β try again")
|
| return {"key": key, "paused": paused, "snapshots": snapshots}
|
|
|