Primer commit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cliente reusable para la API REST de Baserow (instancia self-hosted).
|
||||
|
||||
Patrón análogo a scripts/n8n_workflow_lib.py: autenticación, lectura, mutaciones
|
||||
con dry-run, y backup previo. Las credenciales viven en el bloque `#Baserow` de
|
||||
`n8n/n8n credencials.txt` (host/mail/password) — NUNCA se imprimen.
|
||||
|
||||
API (Baserow):
|
||||
POST /api/user/token-auth/ {username,password} -> {access_token (JWT), ...}
|
||||
Header: Authorization: JWT <access_token>
|
||||
GET /api/applications/
|
||||
GET /api/database/fields/table/{table}/
|
||||
GET /api/database/rows/table/{table}/?user_field_names=true&size=200&page=N
|
||||
PATCH/POST /api/database/rows/table/{table}/[{row_id}/]?user_field_names=true
|
||||
|
||||
Ejemplo:
|
||||
from scripts.baserow_client import BaserowClient
|
||||
c = BaserowClient.from_credentials()
|
||||
rows = c.list_rows(750)
|
||||
c.backup_table(750, label="verificador")
|
||||
c.update_row(750, row_id, {"SC BUCEFALO": "85974 - MP - Eugenia"}, dry_run=True)
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!")
|
||||
import requests # noqa: E402
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
from paths import MIGRATIONS_DIR # noqa: E402
|
||||
|
||||
CRED_PATH = os.path.join(ROOT_DIR, "n8n", "n8n credencials.txt")
|
||||
|
||||
|
||||
class BaserowError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def load_baserow_credentials(path=None):
|
||||
"""Lee el bloque `#Baserow` de n8n/n8n credencials.txt -> (host, mail, password)."""
|
||||
path = path or CRED_PATH
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
if "#Baserow" not in text:
|
||||
raise BaserowError(f"No se encontró el bloque #Baserow en {path}")
|
||||
blk = text.split("#Baserow", 1)[1]
|
||||
host = re.search(r"url/host:\s*(\S+)", blk)
|
||||
mail = re.search(r"mail:\s*(\S+)", blk)
|
||||
pw = re.search(r"password:\s*(\S+)", blk)
|
||||
if not (host and mail and pw):
|
||||
raise BaserowError("Credenciales Baserow incompletas (url/host, mail, password).")
|
||||
return host.group(1).rstrip("/"), mail.group(1), pw.group(1)
|
||||
|
||||
|
||||
class BaserowClient:
|
||||
def __init__(self, host, token, timeout=45):
|
||||
self.host = host.rstrip("/")
|
||||
self._token = token
|
||||
self.timeout = timeout
|
||||
self.headers = {"Authorization": f"JWT {token}", "Content-Type": "application/json"}
|
||||
|
||||
@classmethod
|
||||
def from_credentials(cls, path=None, timeout=45):
|
||||
host, mail, pw = load_baserow_credentials(path)
|
||||
resp = requests.post(
|
||||
f"{host}/api/user/token-auth/",
|
||||
json={"username": mail, "password": pw},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise BaserowError(f"token-auth falló: HTTP {resp.status_code}")
|
||||
data = resp.json()
|
||||
token = data.get("access_token") or data.get("token")
|
||||
if not token:
|
||||
raise BaserowError("token-auth no devolvió access_token/token.")
|
||||
return cls(host, token, timeout=timeout)
|
||||
|
||||
# ---- Lectura ----
|
||||
|
||||
def list_applications(self):
|
||||
r = requests.get(f"{self.host}/api/applications/", headers=self.headers, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def list_fields(self, table):
|
||||
r = requests.get(f"{self.host}/api/database/fields/table/{table}/",
|
||||
headers=self.headers, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def list_rows(self, table, user_field_names=True):
|
||||
"""Devuelve TODAS las filas (paginado)."""
|
||||
rows = []
|
||||
page = 1
|
||||
while True:
|
||||
params = {"size": 200, "page": page}
|
||||
if user_field_names:
|
||||
params["user_field_names"] = "true"
|
||||
r = requests.get(f"{self.host}/api/database/rows/table/{table}/",
|
||||
headers=self.headers, params=params, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
batch = data.get("results", [])
|
||||
rows.extend(batch)
|
||||
if not data.get("next") or not batch:
|
||||
break
|
||||
page += 1
|
||||
return rows
|
||||
|
||||
def backup_table(self, table, label="table"):
|
||||
rows = self.list_rows(table)
|
||||
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe = re.sub(r"[^A-Za-z0-9_-]", "_", label)
|
||||
path = os.path.join(MIGRATIONS_DIR, f"baserow_{safe}_{table}_{ts}.json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(rows, fh, ensure_ascii=False, indent=2)
|
||||
return path
|
||||
|
||||
# ---- Mutaciones (dry-run por defecto) ----
|
||||
|
||||
def update_row(self, table, row_id, fields, *, dry_run=True):
|
||||
"""PATCH una fila (fields por nombre de columna). dry_run no llama API."""
|
||||
action = {"op": "update", "table": table, "row_id": row_id, "fields": fields}
|
||||
if dry_run:
|
||||
return {"dry_run": True, **action}
|
||||
r = requests.patch(
|
||||
f"{self.host}/api/database/rows/table/{table}/{row_id}/",
|
||||
headers=self.headers, params={"user_field_names": "true"},
|
||||
json=fields, timeout=self.timeout,
|
||||
)
|
||||
if r.status_code not in (200, 201):
|
||||
raise BaserowError(f"PATCH fila {row_id} tabla {table}: HTTP {r.status_code} {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def create_row(self, table, fields, *, dry_run=True):
|
||||
"""POST una fila nueva. dry_run no llama API."""
|
||||
action = {"op": "create", "table": table, "fields": fields}
|
||||
if dry_run:
|
||||
return {"dry_run": True, **action}
|
||||
r = requests.post(
|
||||
f"{self.host}/api/database/rows/table/{table}/",
|
||||
headers=self.headers, params={"user_field_names": "true"},
|
||||
json=fields, timeout=self.timeout,
|
||||
)
|
||||
if r.status_code not in (200, 201):
|
||||
raise BaserowError(f"POST fila tabla {table}: HTTP {r.status_code} {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def delete_row(self, table, row_id, *, dry_run=True):
|
||||
"""DELETE una fila. dry_run no llama API. Reversible vía backup_table()."""
|
||||
action = {"op": "delete", "table": table, "row_id": row_id}
|
||||
if dry_run:
|
||||
return {"dry_run": True, **action}
|
||||
r = requests.delete(
|
||||
f"{self.host}/api/database/rows/table/{table}/{row_id}/",
|
||||
headers=self.headers, timeout=self.timeout,
|
||||
)
|
||||
if r.status_code not in (200, 204):
|
||||
raise BaserowError(f"DELETE fila {row_id} tabla {table}: HTTP {r.status_code} {r.text[:300]}")
|
||||
return {"deleted": row_id}
|
||||
|
||||
# ---- DDL: tablas y campos (schema) ----
|
||||
|
||||
def list_tables(self, database_id):
|
||||
r = requests.get(f"{self.host}/api/database/tables/database/{database_id}/",
|
||||
headers=self.headers, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def create_table(self, database_id, name, *, dry_run=True):
|
||||
"""Crea una tabla en una database. Baserow la crea con campos default
|
||||
(Name primario + Notes + Active). dry_run no llama API."""
|
||||
action = {"op": "create_table", "database_id": database_id, "name": name}
|
||||
if dry_run:
|
||||
return {"dry_run": True, **action}
|
||||
r = requests.post(f"{self.host}/api/database/tables/database/{database_id}/",
|
||||
headers=self.headers, json={"name": name}, timeout=self.timeout)
|
||||
if r.status_code not in (200, 201):
|
||||
raise BaserowError(f"POST tabla en db {database_id}: HTTP {r.status_code} {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def create_field(self, table_id, name, field_type="text", *, dry_run=True, **extra):
|
||||
"""Crea un campo en una tabla. field_type: 'text','long_text','boolean','date'..."""
|
||||
body = {"name": name, "type": field_type, **extra}
|
||||
action = {"op": "create_field", "table_id": table_id, **body}
|
||||
if dry_run:
|
||||
return {"dry_run": True, **action}
|
||||
r = requests.post(f"{self.host}/api/database/fields/table/{table_id}/",
|
||||
headers=self.headers, json=body, timeout=self.timeout)
|
||||
if r.status_code not in (200, 201):
|
||||
raise BaserowError(f"POST campo {name!r} tabla {table_id}: HTTP {r.status_code} {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def update_field(self, field_id, fields, *, dry_run=True):
|
||||
"""PATCH un campo (renombrar, cambiar tipo). fields ej {'name':..,'type':'text'}."""
|
||||
action = {"op": "update_field", "field_id": field_id, "fields": fields}
|
||||
if dry_run:
|
||||
return {"dry_run": True, **action}
|
||||
r = requests.patch(f"{self.host}/api/database/fields/{field_id}/",
|
||||
headers=self.headers, json=fields, timeout=self.timeout)
|
||||
if r.status_code not in (200, 201):
|
||||
raise BaserowError(f"PATCH campo {field_id}: HTTP {r.status_code} {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def delete_field(self, field_id, *, dry_run=True):
|
||||
action = {"op": "delete_field", "field_id": field_id}
|
||||
if dry_run:
|
||||
return {"dry_run": True, **action}
|
||||
r = requests.delete(f"{self.host}/api/database/fields/{field_id}/",
|
||||
headers=self.headers, timeout=self.timeout)
|
||||
if r.status_code not in (200, 204):
|
||||
raise BaserowError(f"DELETE campo {field_id}: HTTP {r.status_code} {r.text[:300]}")
|
||||
return {"deleted_field": field_id}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Smoke test read-only: autentica y lista DB/tablas (sin imprimir secretos).
|
||||
c = BaserowClient.from_credentials()
|
||||
print("Auth OK. Aplicaciones:")
|
||||
for app in c.list_applications():
|
||||
print(f" DB id={app.get('id')} name={app.get('name')!r} tablas={len(app.get('tables') or [])}")
|
||||
Reference in New Issue
Block a user