feat(org): derived.org_contacts + materializacion de contactos en la ficha de cada organizacion

Deriva del campo relaciones del frontmatter ("[[org-slug]] — rol") los telefonos
de las personas de contacto de cada organizacion (124 orgs, 185 pares) y los
expone en dos sitios: la tabla derived.org_contacts (consultable) y un bloque
sentinel 'org-contacts' en la ficha .md de cada organizacion (tabla persona/rol/
telefono). Nuevo endpoint POST /api/org/render-contacts. Asi, al buscar una
empresa, aparecen todos los telefonos de sus contactos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:03:44 +02:00
parent d7c28c8f55
commit 36c4e06779
3 changed files with 189 additions and 2 deletions
+99 -1
View File
@@ -26,7 +26,7 @@ import tempfile
import time
from datetime import datetime, timezone
from .config import Config
from .config import SENTINEL_MARKER, Config
from .registry_bridge import (
build_vcard,
caldav_put_event,
@@ -41,7 +41,10 @@ from .registry_bridge import (
duckdb_query_readonly,
duckdb_upsert,
pass_get_secret,
read_obsidian_note,
render_markdown_table,
update_obsidian_note,
upsert_sentinel_block,
)
# Columnas de persons gobernadas por la API estructurada (sin slug, que es la
@@ -1295,3 +1298,98 @@ def pull_dav(cfg: Config) -> dict:
"deleted": deleted,
"unchanged": unchanged,
}
# ---------------------------------------------------------------------------
# Materialización de los contactos de una organización en su ficha .md
# ---------------------------------------------------------------------------
def render_org_contacts(cfg: Config, slug: str) -> dict:
"""Escribe en la ficha de una organización la tabla de sus contactos.
Lee de ``derived.org_contacts`` las personas relacionadas con la organización
(con su rol y teléfono) y vuelca una tabla Markdown dentro de un bloque
sentinel ``org-contacts`` en el cuerpo de la nota, sin tocar el resto de la
prosa. Idempotente: re-renderizar reescribe solo ese bloque.
Devuelve {status:'ok', slug, contactos:N, note_path} o {status:'skip'|'error'}.
"""
slug = (slug or "").strip()
if not slug:
return {"status": "error", "error": "slug vacío"}
org = duckdb_query_readonly(
cfg.db_path, "SELECT note_path FROM organizations WHERE slug = ?", [slug], 1
)
if org.get("status") != "ok" or not org.get("rows"):
return {"status": "error", "error": f"organización desconocida: {slug!r}"}
note_path = org["rows"][0].get("note_path")
if not note_path:
return {"status": "skip", "slug": slug, "reason": "sin note_path"}
contacts = duckdb_query_readonly(
cfg.db_path,
"SELECT persona, rol, telefono FROM derived.org_contacts "
"WHERE org_slug = ? ORDER BY persona, telefono",
[slug],
500,
)
rows = contacts.get("rows") or []
if not rows:
return {"status": "skip", "slug": slug, "reason": "sin contactos"}
table_md = render_markdown_table(
rows, columns=["persona", "rol", "telefono"], max_rows=500
)
content = "### Contactos\n\n" + (table_md or "_(sin contactos)_")
rel = note_path if note_path.endswith(".md") else note_path + ".md"
abs_path = os.path.abspath(os.path.join(cfg.vault_dir, rel))
vault_real = os.path.realpath(cfg.vault_dir)
if not os.path.realpath(abs_path).startswith(vault_real + os.sep):
return {"status": "error", "error": f"note_path fuera del vault: {rel!r}"}
if not os.path.exists(abs_path):
return {"status": "skip", "slug": slug, "reason": "nota inexistente"}
note = read_obsidian_note(abs_path)
new_body = upsert_sentinel_block(
note.get("body", "") or "", "org-contacts", content, marker=SENTINEL_MARKER
)
update_obsidian_note(abs_path, body=new_body)
return {
"status": "ok",
"slug": slug,
"contactos": len(rows),
"note_path": rel,
}
def render_all_org_contacts(cfg: Config) -> dict:
"""Materializa la tabla de contactos en TODAS las organizaciones que tengan.
Recorre las organizaciones con al menos una fila en ``derived.org_contacts``
y llama a ``render_org_contacts`` por cada una. Devuelve conteos agregados.
"""
orgs = duckdb_query_readonly(
cfg.db_path,
"SELECT DISTINCT org_slug FROM derived.org_contacts ORDER BY org_slug",
[],
100000,
)
rendered = skipped = errors = 0
for row in orgs.get("rows") or []:
res = render_org_contacts(cfg, row["org_slug"])
status = res.get("status")
if status == "ok":
rendered += 1
elif status == "skip":
skipped += 1
else:
errors += 1
return {
"status": "ok",
"rendered": rendered,
"skipped": skipped,
"errors": errors,
}