9a28d08e38
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.
390 lines
13 KiB
Python
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
|