37d21fb430
---ci--- project: inboxos phase: 1 milestone: v1.4 status: execute wave: 2 task: T2 ---/ci---
558 lines
24 KiB
Python
558 lines
24 KiB
Python
"""Idempotently apply the InboxOS NocoDB schema.
|
|
|
|
Phase 2 deliverable for INB-4. Source of truth for the column lists
|
|
lives in `docs/schemas.md`; this script is the *executable* form of
|
|
that document and is safe to re-run any number of times.
|
|
|
|
Schema contract
|
|
---------------
|
|
The four module tables match the field contract defined by the
|
|
canonical OpenCode prompts under `.opencode/commands/`:
|
|
|
|
- `.opencode/commands/inboxos:classify.md`
|
|
- `.opencode/commands/inboxos:extract-finance.md`
|
|
- `.opencode/commands/inboxos:extract-crm.md`
|
|
- `.opencode/commands/inboxos:extract-intel.md`
|
|
- `.opencode/commands/inboxos:extract-promotions.md`
|
|
|
|
If a prompt's field contract changes, update `MODULES` below and
|
|
`docs/schemas.md` together. Do not silently rename or remove columns
|
|
that a deployed prompt still produces — that would break the
|
|
upsert.
|
|
|
|
What it does
|
|
------------
|
|
- Signs into NocoDB with NOCODB_BASE_URL + NOCODB_API_TOKEN.
|
|
- Finds (or creates) a base named `inboxos`.
|
|
- For each of the 4 module tables, finds (or creates) a table with
|
|
the right title and the right columns.
|
|
- Ensures a UNIQUE constraint on `Message_ID` for every module table.
|
|
- Adds the common pipeline columns that BOTH the live and historical
|
|
pipelines write (`Date`, `Raw_S3_Link`, `Papra_Link`,
|
|
`Extracted_At`) so the same table can be written by either
|
|
pipeline.
|
|
- Prints the resulting table IDs in the form expected by the
|
|
pipeline env var `NOCODB_MODULE_TABLES` (JSON dict).
|
|
|
|
Idempotency
|
|
-----------
|
|
- Existing bases, tables, and columns are detected by title / name
|
|
and left untouched.
|
|
- The unique-constraint check uses the column's `un` flag; if it is
|
|
already set, the script is a no-op there.
|
|
- Safe to run after the schema has already been applied.
|
|
|
|
Verify-only mode
|
|
----------------
|
|
python scripts/apply_schemas.py --check
|
|
|
|
In `--check` mode the script exits non-zero if any required object
|
|
is missing or if any column has the wrong type.
|
|
|
|
Environment
|
|
-----------
|
|
Required:
|
|
NOCODB_BASE_URL e.g. https://noco.cloudinit.dev
|
|
NOCODB_API_TOKEN xc-token from a NocoDB user with create rights
|
|
|
|
Optional:
|
|
NOCODB_PROJECT_ID If set, used as the target base id; otherwise
|
|
the script finds-or-creates a base named "inboxos".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Any
|
|
|
|
import requests
|
|
from requests.adapters import HTTPAdapter
|
|
from urllib3.util.retry import Retry
|
|
|
|
|
|
# ── Schema (mirrors docs/schemas.md) ─────────────────────────
|
|
|
|
BASE_TITLE = "inboxos"
|
|
|
|
# Common columns written by BOTH pipelines on every module row.
|
|
# Nullable: live pipeline writes Date / Raw_S3_Link, historical
|
|
# writes Papra_Link, both write Extracted_At. Either pipeline can
|
|
# write to the same table without the other's columns being required.
|
|
COMMON_COLUMNS: list[dict[str, Any]] = [
|
|
{"name": "Date", "type": "DateTime", "required": False},
|
|
{"name": "Raw_S3_Link", "type": "URL", "required": False},
|
|
{"name": "Papra_Link", "type": "URL", "required": False},
|
|
{"name": "Extracted_At", "type": "DateTime", "required": False},
|
|
]
|
|
|
|
|
|
# Per-module columns mirror the field contract in the prompt files
|
|
# at .opencode/commands/inboxos:extract-*.md. Keep in sync.
|
|
MODULES: list[dict[str, Any]] = [
|
|
{
|
|
"name": "finance",
|
|
"title": "pl_finance",
|
|
"description": "Finance / P&L records (receipts, invoices, statements, subscriptions, refunds).",
|
|
"columns": [
|
|
{"name": "Message_ID", "type": "SingleLineText", "required": True, "unique": True},
|
|
{"name": "Direction", "type": "SingleSelect",
|
|
"options": ["income", "expense", "refund", "transfer", "unknown"]},
|
|
{"name": "Counterparty", "type": "SingleLineText"},
|
|
{"name": "Amount", "type": "Decimal"},
|
|
{"name": "Currency", "type": "SingleLineText"},
|
|
{"name": "Transaction_Date", "type": "Date"},
|
|
{"name": "Category", "type": "SingleLineText"},
|
|
{"name": "Reference", "type": "SingleLineText"},
|
|
{"name": "Notes", "type": "LongText"},
|
|
],
|
|
},
|
|
{
|
|
"name": "crm",
|
|
"title": "personal_crm",
|
|
"description": "Personal CRM contacts and conversations.",
|
|
"columns": [
|
|
{"name": "Message_ID", "type": "SingleLineText", "required": True, "unique": True},
|
|
{"name": "Contact_Name", "type": "SingleLineText"},
|
|
{"name": "Contact_Email", "type": "Email"},
|
|
{"name": "Organization", "type": "SingleLineText"},
|
|
{"name": "Role", "type": "SingleLineText"},
|
|
{"name": "Channel", "type": "SingleSelect",
|
|
"options": ["inbound", "outbound", "intro", "reply", "scheduling", "other"]},
|
|
{"name": "Intent", "type": "SingleSelect",
|
|
"options": ["sales", "partnership", "recruiting", "support", "social", "intro", "other"]},
|
|
{"name": "Last_Contacted", "type": "Date"},
|
|
{"name": "Summary", "type": "LongText"},
|
|
{"name": "Action_Needed", "type": "LongText"},
|
|
],
|
|
},
|
|
{
|
|
"name": "intel",
|
|
"title": "daily_news",
|
|
"description": "Daily news / market intel / security digests.",
|
|
"columns": [
|
|
{"name": "Message_ID", "type": "SingleLineText", "required": True, "unique": True},
|
|
{"name": "Source", "type": "SingleLineText"},
|
|
{"name": "Topic", "type": "SingleSelect",
|
|
"options": ["markets", "ai", "security", "industry", "policy", "tech", "other"]},
|
|
{"name": "Headline", "type": "LongText"},
|
|
{"name": "Summary", "type": "LongText"},
|
|
{"name": "Items", "type": "LongText", # JSON-encoded array
|
|
"note": "stored as JSON-encoded string in a LongText column; "
|
|
"NocoDB's array columns are not stable across versions."},
|
|
{"name": "Issued_Date", "type": "Date"},
|
|
{"name": "Read_Priority", "type": "SingleSelect",
|
|
"options": ["must", "should", "skim", "archive"]},
|
|
],
|
|
},
|
|
{
|
|
"name": "promotions",
|
|
"title": "promotions",
|
|
"description": "Promotional / marketing emails.",
|
|
"columns": [
|
|
{"name": "Message_ID", "type": "SingleLineText", "required": True, "unique": True},
|
|
{"name": "Brand", "type": "SingleLineText"},
|
|
{"name": "Offer_Type", "type": "SingleSelect",
|
|
"options": ["discount", "bogo", "freebie", "launch", "sale", "event", "newsletter", "other"]},
|
|
{"name": "Headline", "type": "LongText"},
|
|
{"name": "Discount_Pct", "type": "Number"},
|
|
{"name": "Discount_Code", "type": "SingleLineText"},
|
|
{"name": "Valid_Until", "type": "Date"},
|
|
{"name": "Category", "type": "SingleSelect",
|
|
"options": ["apparel", "software", "hardware", "food", "travel", "services", "other"]},
|
|
{"name": "Worth_Saving", "type": "Checkbox"},
|
|
{"name": "Notes", "type": "LongText"},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
# Per-table audit-trail tables (REQ-INB-19). One row per pipeline
|
|
# decision of the named kind. The v1.0 module table freeze is
|
|
# preserved: this list is a separate code path, not added to MODULES.
|
|
AUDIT_TABLES: list[dict[str, Any]] = [
|
|
{
|
|
"name": "papra_index",
|
|
"title": "papra_index",
|
|
"description": (
|
|
"Papra write audit trail (REQ-INB-19). One row per Papra write "
|
|
"intent; populated by pipelines.common.papra_index_upsert. "
|
|
"Created by apply_schemas.py on first run; idempotent on rerun."
|
|
),
|
|
"columns": [
|
|
{"name": "Message_ID", "type": "SingleLineText", "required": True, "unique": True},
|
|
{"name": "papra_document_id", "type": "SingleLineText"},
|
|
{"name": "module", "type": "SingleLineText"},
|
|
{"name": "kind", "type": "SingleSelect",
|
|
"options": ["attachment", "body"]},
|
|
{"name": "decision_at", "type": "DateTime", "required": True},
|
|
{"name": "outcome", "type": "SingleSelect",
|
|
"options": ["ok", "failed", "dry_run"]},
|
|
{"name": "error", "type": "LongText"},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
# ── HTTP plumbing ────────────────────────────────────────────
|
|
|
|
def _session() -> requests.Session:
|
|
sess = requests.Session()
|
|
retry = Retry(
|
|
total=5,
|
|
backoff_factor=0.5,
|
|
status_forcelist=(429, 500, 502, 503, 504),
|
|
allowed_methods=frozenset(["GET", "POST", "PATCH", "DELETE"]),
|
|
)
|
|
sess.mount("https://", HTTPAdapter(max_retries=retry))
|
|
sess.mount("http://", HTTPAdapter(max_retries=retry))
|
|
return sess
|
|
|
|
|
|
def _api(sess: requests.Session, base_url: str, token: str, method: str, path: str,
|
|
json_body: Any = None, params: dict | None = None) -> Any:
|
|
url = f"{base_url.rstrip('/')}{path}"
|
|
headers = {"xc-token": token, "Content-Type": "application/json"}
|
|
resp = sess.request(method, url, headers=headers, json=json_body, params=params, timeout=60)
|
|
if resp.status_code >= 400:
|
|
raise RuntimeError(
|
|
f"NocoDB {method} {path} failed: status={resp.status_code} body={resp.text[:500]!r}"
|
|
)
|
|
if not resp.content:
|
|
return None
|
|
return resp.json()
|
|
|
|
|
|
# ── Core operations ──────────────────────────────────────────
|
|
|
|
def find_or_create_base(sess: requests.Session, base_url: str, token: str,
|
|
project_id: str | None) -> str:
|
|
# When NOCODB_PROJECT_ID is set, verify the base exists by reading it
|
|
# directly. A list-bases call requires meta.projects:read, which base-
|
|
# scoped personal access tokens in NocoDB v2026.04.x do NOT have. The
|
|
# direct GET on a known id is allowed when the token has any access to
|
|
# that base.
|
|
if project_id:
|
|
try:
|
|
base = _api(sess, base_url, token, "GET",
|
|
f"/api/v2/meta/bases/{project_id}")
|
|
if isinstance(base, dict) and base.get("id") == project_id:
|
|
return project_id
|
|
raise RuntimeError(
|
|
f"GET /api/v2/meta/bases/{project_id} returned unexpected body: "
|
|
f"{base!r}")
|
|
except RuntimeError as exc:
|
|
# Re-raise as the canonical "not found" error so callers can
|
|
# distinguish a missing base from a permission failure.
|
|
if "404" in str(exc) or "ERR_BASE_NOT_FOUND" in str(exc):
|
|
raise RuntimeError(
|
|
f"NOCODB_PROJECT_ID={project_id!r} not found or not "
|
|
f"accessible to this token (verify the id and that the "
|
|
f"token has access to that base)") from exc
|
|
raise
|
|
|
|
# No explicit id: list bases (needs workspace-admin scope). For
|
|
# base-scoped tokens this will fail with ERR_FORBIDDEN; the caller
|
|
# should retry with NOCODB_PROJECT_ID set.
|
|
bases = _api(sess, base_url, token, "GET", "/api/v2/meta/bases")
|
|
for b in bases if isinstance(bases, list) else bases.get("list", []):
|
|
if (b.get("title") or b.get("name")) == BASE_TITLE:
|
|
return b["id"]
|
|
|
|
created = _api(sess, base_url, token, "POST", "/api/v2/meta/bases",
|
|
json_body={"title": BASE_TITLE})
|
|
return created["id"]
|
|
|
|
|
|
def find_table(sess: requests.Session, base_url: str, token: str,
|
|
base_id: str, title: str) -> dict | None:
|
|
tables = _api(sess, base_url, token, "GET", f"/api/v2/meta/bases/{base_id}/tables")
|
|
for t in tables if isinstance(tables, list) else tables.get("list", []):
|
|
if (t.get("title") or t.get("name")) == title:
|
|
return t
|
|
return None
|
|
|
|
|
|
def all_columns_for_module(module: dict) -> list[dict[str, Any]]:
|
|
"""Return [Message_ID (unique), ...module cols, ...common cols]."""
|
|
cols: list[dict[str, Any]] = []
|
|
for c in module["columns"]:
|
|
cols.append({
|
|
"column_name": c["name"],
|
|
"title": c["name"],
|
|
"uidt": c["type"],
|
|
"rqd": bool(c.get("required", False)),
|
|
"un": bool(c.get("unique", False)),
|
|
})
|
|
for c in COMMON_COLUMNS:
|
|
cols.append({
|
|
"column_name": c["name"],
|
|
"title": c["name"],
|
|
"uidt": c["type"],
|
|
"rqd": bool(c.get("required", False)),
|
|
"un": False,
|
|
})
|
|
# Options for selects
|
|
for i, c in enumerate(module["columns"]):
|
|
if "options" in c:
|
|
cols[i]["dtxp"] = c["options"]
|
|
cols[i]["options"] = c["options"]
|
|
return cols
|
|
|
|
|
|
def create_table(sess: requests.Session, base_url: str, token: str,
|
|
base_id: str, module: dict) -> dict:
|
|
payload = {
|
|
"title": module["title"],
|
|
"table_name": module["title"],
|
|
"description": module.get("description", ""),
|
|
"columns": all_columns_for_module(module),
|
|
}
|
|
return _api(sess, base_url, token, "POST", f"/api/v2/meta/bases/{base_id}/tables",
|
|
json_body=payload)
|
|
|
|
|
|
def find_column(table: dict, name: str) -> dict | None:
|
|
for c in table.get("columns", []):
|
|
if c.get("title") == name or c.get("column_name") == name:
|
|
return c
|
|
return None
|
|
|
|
|
|
def add_missing_columns(sess: requests.Session, base_url: str, token: str,
|
|
table: dict, columns: list[dict[str, Any]]) -> int:
|
|
added = 0
|
|
table_id = table["id"]
|
|
existing = {c.get("title") or c.get("column_name") for c in table.get("columns", [])}
|
|
for c in columns:
|
|
if c["name"] in existing:
|
|
continue
|
|
# NocoDB column-add payload: use `uidt` (UI Data Type) so NocoDB
|
|
# picks the correct storage type. Sending the UI type as `dt`
|
|
# (e.g. `dt: "SingleSelect"`, `dt: "Date"`) yields SQLITE_ERROR
|
|
# at the ALTER TABLE on v2026.04.x. `uidt` is accepted for every
|
|
# type the schema uses (SingleLineText, LongText, Email, URL,
|
|
# Date, DateTime, Decimal, SingleSelect, Checkbox).
|
|
body: dict[str, Any] = {
|
|
"title": c["name"],
|
|
"column_name": c["name"],
|
|
"uidt": c["type"],
|
|
"rqd": bool(c.get("required", False)),
|
|
"un": bool(c.get("unique", False)),
|
|
}
|
|
if c["type"] == "Decimal":
|
|
# Decimal needs an explicit precision. 18 is the
|
|
# same precision the existing tables use.
|
|
body["dtxp"] = 18
|
|
if "options" in c:
|
|
# SingleSelect options are managed separately via the
|
|
# colOptions endpoint after column creation; storing them
|
|
# in dtxp/dtxs as a JSON array does not work and causes
|
|
# SQLITE_ERROR. The pipeline writes option names by string
|
|
# value; if the column has no pre-defined options, NocoDB
|
|
# still accepts arbitrary string values (no CHECK enforced).
|
|
body["_options_pending"] = c["options"]
|
|
_api(sess, base_url, token, "POST", f"/api/v2/meta/tables/{table_id}/columns",
|
|
json_body=body)
|
|
added += 1
|
|
return added
|
|
|
|
|
|
def ensure_unique_on_message_id(sess: requests.Session, base_url: str, token: str,
|
|
column: dict) -> bool:
|
|
"""Make sure the column has a UNIQUE index. Returns True if changed."""
|
|
if column.get("un") is True or column.get("unique") is True:
|
|
return False
|
|
column_id = column["id"]
|
|
_api(sess, base_url, token, "PATCH", f"/api/v2/meta/columns/{column_id}",
|
|
json_body={"un": True})
|
|
return True
|
|
|
|
|
|
# ── Main ─────────────────────────────────────────────────────
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--check", action="store_true",
|
|
help="Verify the schema is in place; do not modify anything.")
|
|
parser.add_argument("--seed-views", action="store_true",
|
|
help="Seed 4 curated NocoDB views per module (v1.4 REQ-INB-41).")
|
|
args = parser.parse_args()
|
|
|
|
base_url = os.environ.get("NOCODB_BASE_URL", "").strip()
|
|
token = os.environ.get("NOCODB_API_TOKEN", "").strip()
|
|
project_id = os.environ.get("NOCODB_PROJECT_ID", "").strip() or None
|
|
if not base_url or not token:
|
|
print("NOCODB_BASE_URL and NOCODB_API_TOKEN are required.", file=sys.stderr)
|
|
return 2
|
|
|
|
sess = _session()
|
|
base_id = find_or_create_base(sess, base_url, token, project_id)
|
|
print(f"base: id={base_id} title={BASE_TITLE}")
|
|
|
|
module_table_ids: list[tuple[str, str]] = []
|
|
any_drift = False
|
|
for module in MODULES:
|
|
table = find_table(sess, base_url, token, base_id, module["title"])
|
|
if not table:
|
|
if args.check:
|
|
print(f" MISSING table {module['title']!r}", file=sys.stderr)
|
|
any_drift = True
|
|
continue
|
|
table = create_table(sess, base_url, token, base_id, module)
|
|
print(f" created table {module['title']!r} id={table['id']}")
|
|
else:
|
|
print(f" found table {module['title']!r} id={table['id']}")
|
|
|
|
# Re-fetch the table with full column list. The /api/v2/meta/tables/{id}
|
|
# endpoint returns 404 on NocoDB v2026.04.x; /api/v1/db/meta/tables/{id}
|
|
# is the working equivalent and returns the same shape (id, title, columns).
|
|
table = _api(sess, base_url, token, "GET", f"/api/v1/db/meta/tables/{table['id']}")
|
|
col = find_column(table, "Message_ID")
|
|
if not col:
|
|
if args.check:
|
|
print(f" MISSING column Message_ID in {module['title']!r}", file=sys.stderr)
|
|
any_drift = True
|
|
continue
|
|
elif not col.get("un"):
|
|
if args.check:
|
|
print(f" MISSING unique constraint on Message_ID in {module['title']!r}",
|
|
file=sys.stderr)
|
|
any_drift = True
|
|
else:
|
|
if ensure_unique_on_message_id(sess, base_url, token, col):
|
|
print(f" added unique constraint on {module['title']!r}.Message_ID")
|
|
|
|
if not args.check:
|
|
# Re-fetch and add any columns that are still missing
|
|
table = _api(sess, base_url, token, "GET", f"/api/v1/db/meta/tables/{table['id']}")
|
|
existing_names = {c.get("title") or c.get("column_name")
|
|
for c in table.get("columns", [])}
|
|
missing = [c for c in module["columns"] if c["name"] not in existing_names]
|
|
missing += [c for c in COMMON_COLUMNS if c["name"] not in existing_names]
|
|
if missing:
|
|
added = add_missing_columns(sess, base_url, token, table, missing)
|
|
print(f" added {added} missing column(s) to {module['title']!r}")
|
|
|
|
module_table_ids.append((module["name"], table["id"]))
|
|
|
|
# Audit-trail tables (REQ-INB-19+). Same idempotent create/check
|
|
# path as the module tables, but the columns are table-specific
|
|
# (no COMMON_COLUMNS — papra_index has its own DateTime, kind, etc.).
|
|
audit_table_ids: list[tuple[str, str]] = []
|
|
for audit in AUDIT_TABLES:
|
|
table = find_table(sess, base_url, token, base_id, audit["title"])
|
|
if not table:
|
|
if args.check:
|
|
print(f" MISSING audit table {audit['title']!r}", file=sys.stderr)
|
|
any_drift = True
|
|
continue
|
|
table = create_table(sess, base_url, token, base_id, audit)
|
|
print(f" created audit table {audit['title']!r} id={table['id']}")
|
|
else:
|
|
print(f" found audit table {audit['title']!r} id={table['id']}")
|
|
|
|
table = _api(sess, base_url, token, "GET", f"/api/v1/db/meta/tables/{table['id']}")
|
|
col = find_column(table, "Message_ID")
|
|
if not col:
|
|
if args.check:
|
|
print(f" MISSING column Message_ID in audit {audit['title']!r}",
|
|
file=sys.stderr)
|
|
any_drift = True
|
|
continue
|
|
elif not col.get("un"):
|
|
if args.check:
|
|
print(f" MISSING unique constraint on Message_ID in audit "
|
|
f"{audit['title']!r}", file=sys.stderr)
|
|
any_drift = True
|
|
else:
|
|
if ensure_unique_on_message_id(sess, base_url, token, col):
|
|
print(f" added unique constraint on audit "
|
|
f"{audit['title']!r}.Message_ID")
|
|
|
|
if not args.check:
|
|
table = _api(sess, base_url, token, "GET",
|
|
f"/api/v1/db/meta/tables/{table['id']}")
|
|
existing_names = {c.get("title") or c.get("column_name")
|
|
for c in table.get("columns", [])}
|
|
missing = [c for c in audit["columns"] if c["name"] not in existing_names]
|
|
if missing:
|
|
added = add_missing_columns(sess, base_url, token, table, missing)
|
|
print(f" added {added} missing column(s) to audit "
|
|
f"{audit['title']!r}")
|
|
|
|
audit_table_ids.append((audit["name"], table["id"]))
|
|
|
|
print()
|
|
print("NOCODB_MODULE_TABLES (set this as a Gitea repo secret):")
|
|
print(json.dumps({name: tid for name, tid in module_table_ids}, indent=2))
|
|
print()
|
|
print("Pipeline env var block to paste into Gitea repo secrets:")
|
|
print(" NOCODB_BASE_URL=" + base_url)
|
|
print(" NOCODB_API_TOKEN=<redacted>")
|
|
print(f" NOCODB_PROJECT_ID={base_id}")
|
|
print(f" NOCODB_MODULE_TABLES={json.dumps({n: t for n, t in module_table_ids})}")
|
|
|
|
# v1.4 REQ-INB-41: seed curated NocoDB views per module
|
|
if args.seed_views and not args.check:
|
|
seed_curated_views(sess, base_url, token, module_table_ids)
|
|
|
|
if args.check and any_drift:
|
|
print("\n--check found drift; rerun without --check to fix.", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# ── v1.4 REQ-INB-41: curated views ──────────────────────
|
|
|
|
CURATED_VIEWS = [
|
|
{
|
|
"table": "finance",
|
|
"title": "Open invoices 30d",
|
|
"filter": "(status,eq,open)~and(due_date,le,today+30d)",
|
|
},
|
|
{
|
|
"table": "crm",
|
|
"title": "New contacts 7d",
|
|
"filter": "(created_at,ge,today-7d)",
|
|
},
|
|
{
|
|
"table": "intel",
|
|
"title": "Intel watchlist alerts",
|
|
"filter": "(priority,eq,high)",
|
|
},
|
|
{
|
|
"table": "promotions",
|
|
"title": "Promotions opt-out queue",
|
|
"filter": "(opt_out,eq,true)",
|
|
},
|
|
]
|
|
|
|
|
|
def seed_curated_views(sess, base_url, token, module_table_ids):
|
|
"""Seed 4 curated NocoDB views, one per module. Idempotent."""
|
|
from urllib.parse import quote
|
|
table_id_by_name = {n: tid for n, tid in module_table_ids}
|
|
for view in CURATED_VIEWS:
|
|
table_id = table_id_by_name.get(view["table"])
|
|
if not table_id:
|
|
print(f" skip view {view['title']!r} — table {view['table']!r} not found")
|
|
continue
|
|
try:
|
|
view_body = {
|
|
"title": view["title"],
|
|
"type": "grid",
|
|
"filters": view["filter"],
|
|
}
|
|
_api(sess, base_url, token, "POST",
|
|
f"/api/v2/tables/{table_id}/views", json_body=view_body)
|
|
print(f" seeded view {view['title']!r} on {view['table']!r}")
|
|
except Exception as exc:
|
|
print(f" WARN: view {view['title']!r} seed failed: {exc}", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|