feat(org): contacto-empresa en la agenda con los telefonos de sus personas etiquetados
sync_org_contact_cards crea un contacto de agenda por organizacion (uid org-<slug>); el push compone su vCard via _org_contact_vcard con un item.TEL + item.X-ABLabel por persona de contacto (nombre + rol) desde derived.org_contacts. Asi, al abrir la empresa en el movil, se ven todos los telefonos identificados por persona. Sin campos OSINT (misma privacidad que el resto de la agenda). Nuevo endpoint POST /api/org/sync-contact-cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -399,6 +399,12 @@ def create_app(cfg: Config) -> FastAPI:
|
|||||||
# personas de contacto con teléfono y rol (desde derived.org_contacts).
|
# personas de contacto con teléfono y rol (desde derived.org_contacts).
|
||||||
return _guard(lambda: writes.render_all_org_contacts(cfg))
|
return _guard(lambda: writes.render_all_org_contacts(cfg))
|
||||||
|
|
||||||
|
@app.post("/api/org/sync-contact-cards")
|
||||||
|
def org_sync_contact_cards() -> dict:
|
||||||
|
# Crea/actualiza un contacto de agenda por organización (uid org-<slug>);
|
||||||
|
# el push compone su vCard con los teléfonos de sus personas etiquetados.
|
||||||
|
return _guard(lambda: writes.sync_org_contact_cards(cfg))
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from .registry_bridge import (
|
|||||||
build_vcard,
|
build_vcard,
|
||||||
caldav_put_event,
|
caldav_put_event,
|
||||||
carddav_put_vcard,
|
carddav_put_vcard,
|
||||||
|
contact_import_key,
|
||||||
create_obsidian_note,
|
create_obsidian_note,
|
||||||
dav_delete_resource,
|
dav_delete_resource,
|
||||||
dav_get_resource,
|
dav_get_resource,
|
||||||
@@ -356,6 +357,12 @@ def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Texto vCard 3.0 listo para PUT a Xandikos.
|
Texto vCard 3.0 listo para PUT a Xandikos.
|
||||||
"""
|
"""
|
||||||
|
# Contacto-empresa (uid 'org-<slug>'): el vCard lleva los teléfonos de las
|
||||||
|
# personas de contacto de la organización, cada uno etiquetado con el nombre
|
||||||
|
# de la persona (item.X-ABLabel). Lo gestiona _org_contact_vcard.
|
||||||
|
if str(uid).startswith("org-"):
|
||||||
|
return _org_contact_vcard(cfg, uid, fields.get("fn") or fields.get("nombre"))
|
||||||
|
|
||||||
tels = _as_list(fields.get("tels") or fields.get("telefonos"))
|
tels = _as_list(fields.get("tels") or fields.get("telefonos"))
|
||||||
emails = _as_list(fields.get("emails") or fields.get("correos"))
|
emails = _as_list(fields.get("emails") or fields.get("correos"))
|
||||||
fn = fields.get("fn") or fields.get("nombre")
|
fn = fields.get("fn") or fields.get("nombre")
|
||||||
@@ -381,6 +388,107 @@ def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str:
|
|||||||
return build_vcard(contact)
|
return build_vcard(contact)
|
||||||
|
|
||||||
|
|
||||||
|
def _vc_escape(value) -> str:
|
||||||
|
"""Escapa un valor de texto para una línea vCard (sin \\r; \\n,;, escapados)."""
|
||||||
|
return (
|
||||||
|
str(value)
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\r", "")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace(",", "\\,")
|
||||||
|
.replace(";", "\\;")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _org_contact_vcard(cfg: Config, uid: str, fn) -> str:
|
||||||
|
"""vCard de una EMPRESA con los teléfonos de sus personas de contacto.
|
||||||
|
|
||||||
|
Cada teléfono va en una propiedad agrupada ``item<N>.TEL`` etiquetada con
|
||||||
|
``item<N>.X-ABLabel`` = "<persona> (<rol>)", leídos de
|
||||||
|
``derived.org_contacts``. Así, al abrir la empresa en el móvil, se ven todos
|
||||||
|
los teléfonos identificados por la persona de contacto. Sin ningún campo OSINT
|
||||||
|
(mismo criterio de privacidad que _compose_agenda_vcard).
|
||||||
|
"""
|
||||||
|
slug = uid[len("org-"):] if str(uid).startswith("org-") else str(uid)
|
||||||
|
name = str(fn).strip() if fn else slug
|
||||||
|
res = duckdb_query_readonly(
|
||||||
|
cfg.db_path,
|
||||||
|
"SELECT persona, rol, telefono FROM derived.org_contacts "
|
||||||
|
"WHERE org_slug = ? ORDER BY persona, telefono",
|
||||||
|
[slug],
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
rows = res.get("rows") or []
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:3.0",
|
||||||
|
"UID:%s" % _vc_escape(uid),
|
||||||
|
"FN:%s" % _vc_escape(name),
|
||||||
|
"ORG:%s" % _vc_escape(name),
|
||||||
|
]
|
||||||
|
seen = set()
|
||||||
|
i = 0
|
||||||
|
for r in rows:
|
||||||
|
tel = str(r.get("telefono") or "").strip()
|
||||||
|
if not tel or tel in seen:
|
||||||
|
continue
|
||||||
|
seen.add(tel)
|
||||||
|
i += 1
|
||||||
|
persona = (r.get("persona") or "contacto").strip()
|
||||||
|
rol = (r.get("rol") or "").strip()
|
||||||
|
label = "%s (%s)" % (persona, rol) if rol and rol != "contacto" else persona
|
||||||
|
lines.append("item%d.TEL;TYPE=CELL:%s" % (i, _vc_escape(tel)))
|
||||||
|
lines.append("item%d.X-ABLabel:%s" % (i, _vc_escape(label)))
|
||||||
|
lines.append("END:VCARD")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def sync_org_contact_cards(cfg: Config) -> dict:
|
||||||
|
"""Crea/actualiza un contacto de agenda por organización con contactos.
|
||||||
|
|
||||||
|
Por cada organización con filas en ``derived.org_contacts`` inserta (o
|
||||||
|
refresca) una fila en ``contacts`` con uid ``org-<slug>`` y el nombre de la
|
||||||
|
empresa. El vCard real (con los teléfonos etiquetados por persona) lo compone
|
||||||
|
el push via ``_org_contact_vcard``. Idempotente y no destructivo. Devuelve el
|
||||||
|
número de tarjetas-empresa sincronizadas.
|
||||||
|
"""
|
||||||
|
orgs = duckdb_query_readonly(
|
||||||
|
cfg.db_path,
|
||||||
|
"SELECT DISTINCT d.org_slug, d.org_nombre FROM derived.org_contacts d "
|
||||||
|
"ORDER BY d.org_slug",
|
||||||
|
[],
|
||||||
|
100000,
|
||||||
|
)
|
||||||
|
rows = orgs.get("rows") or []
|
||||||
|
upserted = 0
|
||||||
|
for o in rows:
|
||||||
|
slug = o.get("org_slug")
|
||||||
|
nombre = o.get("org_nombre") or slug
|
||||||
|
uid = "org-%s" % slug
|
||||||
|
row = {
|
||||||
|
"uid": uid,
|
||||||
|
"collection": cfg.dav_contacts_collection,
|
||||||
|
"etag": None,
|
||||||
|
"fn": nombre,
|
||||||
|
"tels": "[]",
|
||||||
|
"emails": "[]",
|
||||||
|
"raw": "",
|
||||||
|
"note_path": None,
|
||||||
|
"updated_at": _now(),
|
||||||
|
"import_key": contact_import_key(nombre, [], []),
|
||||||
|
}
|
||||||
|
res = duckdb_upsert(
|
||||||
|
cfg.db_path,
|
||||||
|
"contacts",
|
||||||
|
[row],
|
||||||
|
key_cols=["uid"],
|
||||||
|
update_cols=["collection", "fn", "updated_at", "import_key"],
|
||||||
|
)
|
||||||
|
if res.get("status") == "ok":
|
||||||
|
upserted += 1
|
||||||
|
return {"status": "ok", "org_cards": upserted}
|
||||||
|
|
||||||
|
|
||||||
def _dedup_keep_order(items: list) -> list:
|
def _dedup_keep_order(items: list) -> list:
|
||||||
"""Deduplica una lista de strings preservando orden (case-insensitive)."""
|
"""Deduplica una lista de strings preservando orden (case-insensitive)."""
|
||||||
seen, out = set(), []
|
seen, out = set(), []
|
||||||
|
|||||||
Reference in New Issue
Block a user