Files
egutierrez 9a28d08e38 feat(metabase): expansion de funciones Python — documents, collections, permissions, validation
Añade un conjunto amplio de funciones al paquete python/functions/metabase:
- Nuevos modulos: collections.py, documents.py, maintenance.py, permissions.py, validation.py (+ test).
- Ampliacion de cards.py, dashboards.py, client.py e __init__.py para exponer las nuevas operaciones.
- Funciones de documentos (create/get/update/delete/archive/copy/move + comentarios), grupos y memberships, permission/collection graphs, copy/move de cards y dashboards, validacion de MBQL/SQL y payloads, actualizacion segura de dashboards y fix_null_ratio.
- .md por funcion con frontmatter para que fn index los registre.
- Actualiza pyproject.toml y uv.lock con las dependencias resultantes.

Impacto: ampliamente mas cobertura de la API de Metabase desde el registry, reutilizable por apps y analisis. No toca Go ni frontend.
2026-04-13 23:31:42 +02:00

390 lines
13 KiB
Python

"""Tests para metabase_validate_card_payload y metabase_validate_dashboard_payload."""
import sys
sys.path.insert(0, "/home/lucas/fn_registry/python/functions")
from metabase.validation import (
metabase_validate_card_payload,
metabase_validate_dashboard_payload,
)
# ---------------------------------------------------------------------------
# metabase_validate_card_payload
# ---------------------------------------------------------------------------
def _base_card() -> dict:
return {
"name": "Revenue by Month",
"display": "line",
"dataset_query": {
"database": 1,
"type": "native",
"native": {"query": "SELECT 1"},
},
}
def test_card_valido_retorna_lista_vacia():
issues = metabase_validate_card_payload(_base_card())
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
def test_card_display_invalido():
payload = _base_card()
payload["display"] = "foobar"
issues = metabase_validate_card_payload(payload)
assert any("display" in i and "foobar" in i for i in issues), issues
def test_card_display_ausente():
payload = _base_card()
del payload["display"]
issues = metabase_validate_card_payload(payload)
assert any("display" in i and "ausente" in i for i in issues), issues
def test_card_name_ausente():
payload = _base_card()
del payload["name"]
issues = metabase_validate_card_payload(payload)
assert any("name" in i and "ausente" in i for i in issues), issues
def test_card_name_vacio():
payload = _base_card()
payload["name"] = " "
issues = metabase_validate_card_payload(payload)
assert any("name" in i for i in issues), issues
def test_card_dataset_query_ausente():
payload = _base_card()
del payload["dataset_query"]
issues = metabase_validate_card_payload(payload)
assert any("dataset_query" in i and "ausente" in i for i in issues), issues
def test_card_dataset_query_sin_database():
payload = _base_card()
del payload["dataset_query"]["database"]
issues = metabase_validate_card_payload(payload)
assert any("database" in i for i in issues), issues
def test_card_nativa_sin_sql():
payload = _base_card()
payload["dataset_query"]["native"]["query"] = ""
issues = metabase_validate_card_payload(payload)
assert any("SQL" in i or "native" in i for i in issues), issues
def test_card_nativa_mbql5():
"""Acepta SQL en stages[0].native (formato MBQL5)."""
payload = {
"name": "MBQL5 card",
"display": "table",
"dataset_query": {
"database": 2,
"stages": [{"native": "SELECT 1"}],
},
}
issues = metabase_validate_card_payload(payload)
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
def test_card_type_invalido():
payload = _base_card()
payload["type"] = "unknown_type"
issues = metabase_validate_card_payload(payload)
assert any("type" in i and "unknown_type" in i for i in issues), issues
def test_card_type_valido():
for t in ("question", "model", "metric"):
payload = _base_card()
payload["type"] = t
issues = metabase_validate_card_payload(payload)
assert issues == [], f"type={t!r} no deberia dar issues: {issues}"
def test_card_visualization_settings_no_dict():
payload = _base_card()
payload["visualization_settings"] = "no es un dict"
issues = metabase_validate_card_payload(payload)
assert any("visualization_settings" in i for i in issues), issues
def test_card_parameters_no_list():
payload = _base_card()
payload["parameters"] = {"not": "a list"}
issues = metabase_validate_card_payload(payload)
assert any("parameters" in i for i in issues), issues
def test_card_archived_no_bool():
payload = _base_card()
payload["archived"] = "yes"
issues = metabase_validate_card_payload(payload)
assert any("archived" in i for i in issues), issues
def test_card_acumula_multiples_errores():
"""Un payload con varios errores debe retornar todos los issues, no solo el primero."""
payload = {
"display": "invalid_display",
"dataset_query": "not a dict",
"archived": "yes",
}
issues = metabase_validate_card_payload(payload)
assert len(issues) >= 3, f"Se esperaban >= 3 issues, got {len(issues)}: {issues}"
# ---------------------------------------------------------------------------
# metabase_validate_dashboard_payload
# ---------------------------------------------------------------------------
def _base_dashboard() -> dict:
return {"name": "My Dashboard"}
def test_dashboard_valido_sin_dashcards():
issues = metabase_validate_dashboard_payload(_base_dashboard(), known_card_ids=set())
assert issues == [], issues
def test_dashboard_valido_con_dashcards():
payload = {
"name": "KPIs",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert issues == [], issues
def test_dashboard_card_id_desconocido():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 999, "row": 0, "col": 0, "size_x": 6, "size_y": 4}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert any("999" in i for i in issues), issues
def test_dashboard_card_virtual_null_permitido():
"""card_id null = dashcard virtual (texto/heading), siempre permitido."""
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": None, "row": 0, "col": 0, "size_x": 6, "size_y": 2}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert issues == [], issues
def test_dashboard_dashcards_solapadas():
payload = {
"name": "Dashboard",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 2, "col": 2, "size_x": 4, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert any("solapan" in i for i in issues), issues
def test_dashboard_dashcards_adyacentes_no_solapan():
"""Dos dashcards que se tocan en el borde NO solapan."""
payload = {
"name": "Dashboard",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert not any("solapan" in i for i in issues), issues
def test_dashboard_col_fuera_de_bounds():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 25, "size_x": 1, "size_y": 1}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("col" in i for i in issues), issues
def test_dashboard_col_mas_size_x_excede_grid():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 20, "size_x": 6, "size_y": 4}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("24" in i for i in issues), issues
def test_dashboard_size_y_fuera_de_bounds():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 0}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("size_y" in i for i in issues), issues
def test_dashboard_name_ausente():
issues = metabase_validate_dashboard_payload({}, known_card_ids=set())
assert any("name" in i and "ausente" in i for i in issues), issues
def test_dashboard_tabs_invalidos():
payload = {"name": "Dashboard", "tabs": [{"name": "Tab1"}]} # falta id
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert any("id" in i for i in issues), issues
def test_dashboard_parameters_no_list():
payload = {"name": "Dashboard", "parameters": "not a list"}
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert any("parameters" in i for i in issues), issues
# ---------------------------------------------------------------------------
# metabase_validate_document_payload
# ---------------------------------------------------------------------------
from metabase.validation import metabase_validate_document_payload
def _base_doc(content):
return {"name": "Notas", "document": {"type": "doc", "content": content}}
def test_document_valido_minimo():
issues = metabase_validate_document_payload(_base_doc([
{"type": "paragraph", "content": [{"type": "text", "text": "hola"}]}
]))
assert issues == [], issues
def test_document_name_ausente():
issues = metabase_validate_document_payload({"document": {"type": "doc", "content": []}})
assert any("name" in i and "ausente" in i for i in issues), issues
def test_document_nodo_desconocido_callout():
issues = metabase_validate_document_payload(_base_doc([
{"type": "callout", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]}
]))
assert any("callout" in i and "no soportado" in i for i in issues), issues
def test_document_nodo_desconocido_taskList():
issues = metabase_validate_document_payload(_base_doc([
{"type": "taskList", "content": []}
]))
assert any("taskList" in i for i in issues), issues
def test_document_mark_desconocido_underline():
issues = metabase_validate_document_payload(_base_doc([
{"type": "paragraph", "content": [
{"type": "text", "marks": [{"type": "underline"}], "text": "x"}
]}
]))
assert any("underline" in i and "no soportado" in i for i in issues), issues
def test_document_heading_level_invalido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "heading", "attrs": {"level": 9}, "content": [{"type": "text", "text": "x"}]}
]))
assert any("level" in i for i in issues), issues
def test_document_cardEmbed_sin_id_ni_slug():
issues = metabase_validate_document_payload(_base_doc([
{"type": "cardEmbed", "attrs": {}}
]))
assert any("cardEmbed" in i and "id" in i for i in issues), issues
def test_document_cardEmbed_con_id_valido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "cardEmbed", "attrs": {"id": 42}}
]))
assert issues == [], issues
def test_document_cardEmbed_slug_desconocido():
issues = metabase_validate_document_payload(
_base_doc([{"type": "cardEmbed", "attrs": {"card": "inventado"}}]),
known_card_slugs={"real_one", "real_two"},
)
assert any("inventado" in i for i in issues), issues
def test_document_flexContainer_demasiados_hijos():
children = [{"type": "cardEmbed", "attrs": {"id": 1}} for _ in range(4)]
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [25, 25, 25, 25]}, "content": children}
]))
assert any("1-3" in i for i in issues), issues
def test_document_flexContainer_hijo_invalido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [100]}, "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}
]}
]))
assert any("flexContainer solo acepta" in i for i in issues), issues
def test_document_flexContainer_columnWidths_mismatch():
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
{"type": "cardEmbed", "attrs": {"id": 1}}
]}
]))
assert any("columnWidths" in i for i in issues), issues
def test_document_vacio_string_es_valido():
issues = metabase_validate_document_payload({"name": "vacio", "document": ""})
assert issues == [], issues
def test_document_kitchen_sink_valido():
"""Simula el kitchen_sink real y debe pasar sin issues."""
content = [
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "T"}]},
{"type": "paragraph", "content": [
{"type": "text", "text": "a "},
{"type": "text", "marks": [{"type": "bold"}], "text": "b"},
{"type": "text", "marks": [{"type": "link", "attrs": {"href": "https://x"}}], "text": "l"},
]},
{"type": "bulletList", "content": [
{"type": "listItem", "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "i"}]}
]}
]},
{"type": "blockquote", "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "q"}]}
]},
{"type": "codeBlock", "attrs": {"language": "py"}, "content": [{"type": "text", "text": "x=1"}]},
{"type": "horizontalRule"},
{"type": "cardEmbed", "attrs": {"id": 1}},
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
{"type": "cardEmbed", "attrs": {"id": 1}},
{"type": "cardEmbed", "attrs": {"id": 2}},
]},
]
issues = metabase_validate_document_payload(_base_doc(content))
assert issues == [], issues