From be5a7b582e6d03bc71ae3d6ec59c7e9e898e1384 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 20:32:28 +0100 Subject: [PATCH] feat: funciones Python para API Metabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade módulo Python con funciones para la API de Metabase en dominio infra. Incluye cliente HTTP, auth, y CRUD de cards, dashboards y users. Proyecto gestionado con uv (pyproject.toml). --- python/.python-version | 1 + python/functions/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 149 bytes python/functions/metabase/__init__.py | 11 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 961 bytes .../__pycache__/cards.cpython-312.pyc | Bin 0 -> 8335 bytes .../__pycache__/client.cpython-312.pyc | Bin 0 -> 3996 bytes .../__pycache__/dashboards.cpython-312.pyc | Bin 0 -> 5460 bytes .../__pycache__/users.cpython-312.pyc | Bin 0 -> 5470 bytes python/functions/metabase/cards.py | 227 ++++++++++++++++++ python/functions/metabase/client.py | 87 +++++++ python/functions/metabase/dashboards.py | 143 +++++++++++ python/functions/metabase/metabase_auth.md | 46 ++++ .../metabase/metabase_create_card.md | 35 +++ .../metabase/metabase_create_dashboard.md | 32 +++ .../metabase/metabase_create_user.md | 32 +++ .../metabase/metabase_deactivate_user.md | 31 +++ .../metabase/metabase_delete_card.md | 32 +++ .../metabase/metabase_delete_dashboard.md | 32 +++ .../metabase/metabase_execute_card.md | 34 +++ .../metabase/metabase_execute_query.md | 32 +++ .../functions/metabase/metabase_get_card.md | 32 +++ .../metabase/metabase_get_dashboard.md | 33 +++ .../functions/metabase/metabase_get_user.md | 32 +++ .../functions/metabase/metabase_list_cards.md | 32 +++ .../metabase/metabase_list_dashboards.md | 33 +++ .../functions/metabase/metabase_list_users.md | 33 +++ .../metabase/metabase_update_card.md | 31 +++ .../metabase/metabase_update_dashboard.md | 39 +++ .../metabase/metabase_update_user.md | 32 +++ python/functions/metabase/users.py | 153 ++++++++++++ python/pyproject.toml | 9 + python/uv.lock | 91 +++++++ 33 files changed, 1325 insertions(+) create mode 100644 python/.python-version create mode 100644 python/functions/__init__.py create mode 100644 python/functions/__pycache__/__init__.cpython-312.pyc create mode 100644 python/functions/metabase/__init__.py create mode 100644 python/functions/metabase/__pycache__/__init__.cpython-312.pyc create mode 100644 python/functions/metabase/__pycache__/cards.cpython-312.pyc create mode 100644 python/functions/metabase/__pycache__/client.cpython-312.pyc create mode 100644 python/functions/metabase/__pycache__/dashboards.cpython-312.pyc create mode 100644 python/functions/metabase/__pycache__/users.cpython-312.pyc create mode 100644 python/functions/metabase/cards.py create mode 100644 python/functions/metabase/client.py create mode 100644 python/functions/metabase/dashboards.py create mode 100644 python/functions/metabase/metabase_auth.md create mode 100644 python/functions/metabase/metabase_create_card.md create mode 100644 python/functions/metabase/metabase_create_dashboard.md create mode 100644 python/functions/metabase/metabase_create_user.md create mode 100644 python/functions/metabase/metabase_deactivate_user.md create mode 100644 python/functions/metabase/metabase_delete_card.md create mode 100644 python/functions/metabase/metabase_delete_dashboard.md create mode 100644 python/functions/metabase/metabase_execute_card.md create mode 100644 python/functions/metabase/metabase_execute_query.md create mode 100644 python/functions/metabase/metabase_get_card.md create mode 100644 python/functions/metabase/metabase_get_dashboard.md create mode 100644 python/functions/metabase/metabase_get_user.md create mode 100644 python/functions/metabase/metabase_list_cards.md create mode 100644 python/functions/metabase/metabase_list_dashboards.md create mode 100644 python/functions/metabase/metabase_list_users.md create mode 100644 python/functions/metabase/metabase_update_card.md create mode 100644 python/functions/metabase/metabase_update_dashboard.md create mode 100644 python/functions/metabase/metabase_update_user.md create mode 100644 python/functions/metabase/users.py create mode 100644 python/pyproject.toml create mode 100644 python/uv.lock diff --git a/python/.python-version b/python/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/python/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/python/functions/__init__.py b/python/functions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/functions/__pycache__/__init__.cpython-312.pyc b/python/functions/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14d1dde4e37d58b36de84ed4550759f7c3bedff0 GIT binary patch literal 149 zcmX@j%ge<81Sx7KGC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!GSSb-&rQ|ODNRl+ z)=$feFG@|%EG{Xk)Gw$k$;i*sPb7v$Eqm);5Z(h+jcZ#}7e5 zqNZ61QBctV5sE~`j`2Cskw|RuX#CFZzT?^XI2!etUF!!QlJ_mfzS75TIdyQJ+u#k0 zSyfR)k8jTojqq znVB}?O8(W%qLCsk!(3K7uc*Z~Zq(b&-dKiFp1j&+d~e?`3U$2iN7|K}Yrj3U_S;iq zsidu~QkP8?@>E8}PCoocepyIe+E!eaU9}<(v-Q!Ys_*g)^`l9cJI!5nk=Lc_O0P@R zdwN zH+mVDx|sckrRg_6klko+JRw4*gm}l!Z8hxolVH6;8K@!(v*0)tTCVH>>N41t`FfKE z$3<#KXOm{ZuE~KANt)zBJlvM1`xrNIq2yD2&)!DkojtPn-r}5pX7|6avrp{o!eRWx bxv=>Y=SM|S!1-lkaDKMkf%DqackQ`9px_z* literal 0 HcmV?d00001 diff --git a/python/functions/metabase/__pycache__/cards.cpython-312.pyc b/python/functions/metabase/__pycache__/cards.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..419861642c5810e216752dcf079dad1293614130 GIT binary patch literal 8335 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 P3^+(;{@aO`6#oAJ8Y2|# literal 0 HcmV?d00001 diff --git a/python/functions/metabase/__pycache__/client.cpython-312.pyc b/python/functions/metabase/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7495427ae1be86bde8fd62c52670f65f314371d GIT binary patch literal 3996 zcma)9Piz#|8Gkc7vmVxK>|h*h2<71*wZ6mGwJleTSX6Xi=l}4m8$@NfwAHnX)Y{NHV!Z%*0({ zDi7sw)q-rQEMX?$>w`~Y@}g$$xvMNFbvcu2OwQOAt9p#yFkD7!1~;f}(CMp}>0F^W zPfbRzFwelr>_3p z)%A>O$uc@Zr&1KkwC8c_hVL;qFZwu!chY@s=jpY%_rmMDR+W0(uxzV(le)Iymgxjr z%G2?(=hgDLob8khyX?4L{>-UUr^YkcHZ#w;#j1H)WUgyDzy!P05qiAE>Ya7E;6!8b zvA0Y)^F27TVcf78Ey4COGfc)^)V|?+<#2nT#S#aq;2$J8^L$>NEhW0%BnfTow|*m@ zq$w>)U9(ZslpaQ-O`^Bwn=nsG!fYD$Mb{OcIph!dutG0*=ZERH5Y{87e(c#T#T2f#NcIv9r!adBHCJ zeJ)paDlBLFCBw}vR&~y9TEMfOtJS?Sh`Z=lODMOSt3sHnBbiHvP z7C$^U8&3?vG42H*D`b0=em?ly!Og_e(W!gNuLib94nmdsX5`@d2cLbgJuj{k&&?%1AVWj?kS(9TEK$@Nf4s!n1NC=yz(wfKo#}F*V+KGPGkaa z2Cd%mvUhAsO?gqaNHg(;^lRyk(o~k(Z`j%YFltH!=26u>#g*z3%ib2c*MM9syjPenba^rEL^bB=916pk4F-DKh(I7`)U2HEGT!a@xMgzk*2e~lfP(lH zFk#1~Y#GHCfEs%F1e*Dj7^%2ZlzF8r9Nk8cIpNDa*O+5&&1V2=00nTcWgtqJ27pAx zp{7;xayFRe6d=v4Q)Se(D6{De$E;Ja>9zrS>^{S=+-?X&;e~KnRCUoaJl`#F?r=ad z!3s`>Avg~7UUh&eeQSBSgJ!cCm7_TaeVl!Sat1?yo&c+h4%frIK_5OKs3cH?o2z^X zHt_vOMsRB~)Lo!(xT}j+hEh@f8m>41r15%>BSmK&f;o2v$O_qcgA9$|^VS-xjc0>L zw+4^iKfZZvYw$;pjsv-;Y-{Of+Tktj@YmXr?ZM%-<}aJu!w0ZcU#)-g^C#LtJ`8+H zZ6JgWf}4QRA3|gORjw0394u*<*t8{5mjKib%vg+rA;7zYAi;!9W@j^Us5-S|J1%=5 z3B9-|LUi}pSh{pX_)!?h zNF1*VIzj6Q6!az+pCB>>gjKfu3Rn#%-iFm~KM)WtRLZAmAs#SAfo3ylx3Qhp^^bhRj%NCF-E>MYoB+({sO3OGPYV=uHDQrLR{kbT@#9Fwk)Q>IMyp^8 z32NLe0=Y|`%Slz+8A_?C`@=f~s?DOP9?iF~w==p=J+x8UAy93?0;nFJ{mZdGpZjY6 z*10P!?6)Rl_0T^xN!{N{O6o{>o;XT`LX}k@Zwa)T;kq9?+!S}-6%J4uVF-_*u}-}9 zySy|2ke2~|CvEsWG37tX7$z{>f7#A9?AX11|BrxRs$;P8j^g10ct~CL;*ei$r;%X@7g!AkqBFda+O}UVeX8pT1PU&GG%>h_O53#ff28Dhq%O zd3+}!{y+0rp}IURpfQ1jHeAOyxaG73=zxE$-8{V->A8DDr1SrZYVk!vjbeHmKBVc0 zr`x60m`6|4dUeEtjGdM^Ga3E^*buEC;(`utL)bK7YU1pv)0uX_#VF&@vH=fn!2geJ zRV)wk{^-!cvQeqotn<5Y{`~m}!Re1Ny%7III;Q6nulPNfqT_~Hv8oqX$SVs`dmIK) zapN61knx)_>mEZAI`JG79`A;kCs;0AY-aA-MR603&eTYEn{Y-BG)z+v&^X2lK-Ys% z1+rykl6(N0N)7&Ecp92~6ba4?%z}uHc*3(ta5H}ciGZ@*!8sV%r3HQ#*5%=Ie+FcQ zyvUODYis${{C5BF+PkaoZk&1Af8T&6a~EKB8IbY7X`A+(m*y!g90*F0g5iNDHy0LuX^sA4?mI|#pnQ> zH}mHG?tS;1b05DR9PCqY{r%K)^XYy?`4_!}SAX!Z`2Y_O6+>B24AqEPYB{zLQOb>Z(Z8)q2cOsjjZv zqT#zPBWA?wu`jfz)EC#Bl1=DC82F0mdio9vWhKXEv=-qor&2Nr*!o+IpzVFh=qWz%q6mT^s6PFNzxWP<@G#0@tuho<3uN939B z7|!&n2M3uY-EeYIBi~DTmewsRJ%<`E|(+6vWmd2WsJqNX{486++v!nOfUIEHPn?x96S1QsB=pZtjUTl^s@UzZKMSM zsLEY08y6ZvNl-`%!lX;Vg*jec9KGq3`KVPb>F(%?T@?HV%n`NGO3l0J*rO{|Tl&x) z4ef13kDII18b`wVZa;`Um?BAY{T#P@O7n~|wD;ZzFH(aWhmWr%*WAzgH~RPeY5cSC zNAIqmIQQkLwefHJC)TxzO*B^;1Cd9uRomlg80{YJgd(s^UH!B2m)O$)lfY42e}CDJ82~>%D-B>73R0k)`v$R!Bs&?MUehYTDJLId`FHo;d0E_A!`6k#V=90ly8 zZumq(b28~Cokv!8Pyj;_aZUwbla+4>RF>jOY*fxro)sv7)sW z%X+0^+BdMqRnx8NmU%~)uy?uVQGK|5J3$(x69w@N-iGA}%ICI$Zz;s@Vmk73dTA#Z zucRl*DW4dn#nXX1PA}bMy2mnponzsB*lz%f@sdsZvx;8C`O7_>Fdr2IsC^RialH@E zF2y-xVhBa!@b(6YL-=@t8Xw0^Vsc==55dP5pIvyCTR$=JZU3cp?UL9_!)U}fjs7-X zZ47qsYKdqiyduGyM-9YYt*fSbSF6V$da=4@#H8rOjri}Uh>PlR)M>8}!i15mYhR?= zgs?~d8?2lJSrdBgDqnS4k-=7_f^fSf-`xs0J2SUM0wpoef-#wX1MH`Z03uZvkC7Xz z6$1j%qItrdoSHT>jIh^GMsjeTO*`df0a5EjJz+zL3e|&CzD7kPqK;jeQ5!VKJK_b(R75(NJ2wC99xX!I8@tDNM`eF zRB$&Jb$tmh0FO(%FO$>8MN4df72*E+I^AwU6?nk@?-;7H^~Fukdp(;Rc|eR}LI~ z^v;8~?oGW&?caDSyQZxzebe)^jnu&hAN?bBWP^>Mrua?Io1rG3T2H_C6B&$e6?J%$XIFTZRi}A&vns+fb&FC1 zq%oBEMEyGGk!6ryhliS7@?d1`tI6;r`DfY0R@su-qt)$>vk*DtwXyUysX7Lm##<*X zc8u6bkt~CgLz&Y_a2dhHMJvqQ&OC_fKt#3>&sAGg~N6G^!HZJ2jE8Ym8g><;1!!9%j zQ-)zZHZQ6{|3(}%B@Tct1!Z48+Xn|TO5hqvA6((ttefMzEfgow>38_Kl+l6Xr&MK2 zHupS96h5=A%}5HL-ooK@5Zuh+_TwBLo}^Tz7SC$>U-9QfKuN5^MOn9r$;x!=;PWkP zJyihB7P|h5kc7=HU%gtmUbs4U`O=kw|M!8+%-Z$>$6~(1g(>p8*ogK+ey|gAzyI$P z2_obHJBQ|Omn22e>c`bK*-_U?<0PB?3Fht)cS+yjGW4cp*olJ_Hth$7Rw~iGG;s=T z?^ApFZw_I0TuJo*ar*xBgI_-S!{_zS?e#au{uyKl`13)@#>8nf6vuGO4m485qT!T^ zML(;dXlukF1`Un)qoEO_%YOyGpUB)r2DhC0i6SG{M?=qrvSU;&J}xL^5rB&O zHEzwgs;V!PcmJ)Vn|;cGLq8;rsF&1@fn&`$J$yH?rzvZiNhR60_Rjr<%@ox)&mU4J z)yLyah03oE_~rA1oAMnw(~)BvN00r`%hZcgbmaY?>c{WHD5!p4$k)!kLq zRj;~Uz3=;8?RNtMeF}cd`@S{*I;trDq)PZ33JR-_QMjuZ%0V3)57%cU5+l*W-V{AfO|6;DN_2#Zq=Pt}JgR_>?(uHX| zRQ{B^`n>M&(vJzuP)K-#Vauty$t~B*RD-AE>Tb*N($`vCEPCm>*)Z|euGJilr{Hc& zSk-jkBYIFdg=x>!teUo^*SS;b z>tpzpEu(3hmaDN}mFL)m-ZUp#4i_bMj$dn;TyWOpqG39|8=V$vfnNgyC&hxJNmFFb z_oBvpuX3ihTzJ>4>V{p4I{7ZvSYEHX=5?ND;|8zkt-4z%vb@1%tL}0m&usLm*Yn+> za#u8VS}un*ZNV(NF^{jb8E@!jz1vf6oW|z#hHjZQ#XwVEFfE;pPqHag<%CgO6uJZb zt=8BXx#buo^k68tE?D(B83(b2&zMzLdYU_~XjKs<;V47zy5}G>bXV6{UG7#qM#oK~ z=r5s&%@U4VvGfKnvbr7>O{Wt0p9}PLtEEEZ(u2&kUA?X`tJUDb_G@cE0MFt3Osi@d z+=`70{N%pkr<6o9NL5Y6MQOL-X& z`O8Iya@V8z%f4AbuoW5Hek2?TFpeU6aPv|C!i(Mr3E+wh#X!S(nDDX!sK5>vGD4*R zk&*6rF^Crq8J3^JUT70n>;|8xx2n1`QL`$7FTfLGaiY2CUa_r-TFU|qY|EJlT_q9d zl$wj)SlATvo&aGhWZ(HUZnu=q31x8j*6C;2f%eWl%bDdHPkXjK%jViU-bLk$+e7Va zZu#0*i?RB^oy1o+yPezpBY&K}J$>)J`#T?hwmkiP|A`g##42nnUY7Q&Vj9&XRfAtd zctteaRhC{)RFI6N#GjPEB)uKta^Mx%8uQ&2{mEaftA~>8w3*eA|)~AfbpHp9EmQ<*`x|9i& zKaE-%wbUE*T{Y0-Q6{GES^Xba@F~F|?-qiUU$?_3W!#Ds>=m?}J#+r&6?7?gQFfpk z4HN3T86aG)jVdhZJn?rvW997`4$x9Qxq!OEMm31JejWUq}hc{KXEs z?W7xFSM0*gQz?+e0E!S`^{QiAk`W47pAps{H*Xt@4(WOj8a`ocikuo-nAnSMAL4S# zxPddKm92Z)n|HTIwmi?OqnTSX&-;{~;r7nG%j)t)DX_!sciuzg($k*pp@8nY|Ni3{ z642u->TyXfujpn!%&!tTy@|yd8w2ZJ0IW@)qPKic@Seq8C-WVYAQqB2gvN#RBqB%MDK%8$KkwkyGNL z9U~J0Wat*r#qWUwHIvs32Zs?KQER$^6NqbKDoHEtDghfuj)wC{#7|*^k0k$X#C9|` zE@o_e-nQ!)9+3zer~LO4a?{I&u7cAzU%Er{QsOJR>k4xoXD0_|Bb+QE+;4pbiCD+3C9|oAB`VIrt%-_7QsEf(y2Mo;%fz;EW{Ipbqq(YnYwhS$PO3T(I7SV zQfDvBi7fz$V00t>2SLb$oAlqy_#5ls*sH*SFXK8kpV&_Za5)UO*XIIHY6RKxidud> zWNDVw5^isYY?$gMl*Lp*)&GkF{0w(wa))!LW7ph+h+FRR!bU+mQ~snpSMHw6|1aE@ zyBdSc4gltK*uxQ+L>pUaxtu_XQ>GL!LbxufpcaBM`qAk;IJTdqQ!s>pnsnrj*rBx_ zH4(mylEm=h*HXMj(4$ z#@|61&a*O<#a6`9njCi9dLJncpz9x~JEDpB5kB75$sa!G8voPAZW|JX7}mTgi9&l(Eh2k+Js3XnS<* zdH?>zR6Dn$lfv_d+)yWtXD6eel%-M+m3pbvrwj~tsCOr)WcpSIs5F<@oH%~Z>L_?T zcI4xUyGpf=rflBY&hLAUep+B(`(b#D>}#-(66|Y{eGT@9M#;Vg`@@^=oVvYu@5s}k zcROUDGe}boQHiEJow&c7COe&Yve$opJ43UcPIM-R5=Za7)Mv DfY%JP literal 0 HcmV?d00001 diff --git a/python/functions/metabase/cards.py b/python/functions/metabase/cards.py new file mode 100644 index 00000000..de22cd3e --- /dev/null +++ b/python/functions/metabase/cards.py @@ -0,0 +1,227 @@ +"""CRUD de cards/preguntas de Metabase y ejecucion de queries.""" + +from .client import MetabaseClient + + +def metabase_list_cards( + client: MetabaseClient, + filter: str = "", + model_id: int = 0, +) -> list[dict]: + """Lista preguntas/cards de Metabase con filtro opcional. + + Endpoint: GET /api/card. No tiene paginacion offset/limit. + + Args: + client: Cliente autenticado. + filter: "all", "mine", "fav", "archived", "recent", "popular", + "database", "table". Vacio = todas. + model_id: ID de database/tabla. Solo aplica con filter "database" o "table". + + Returns: + Lista de dicts, cada uno con: id, name, description, display, + collection_id, database_id, creator_id, archived, dataset_query. + + Example: + >>> cards = metabase_list_cards(client, filter="mine") + >>> for c in cards: + ... print(c["id"], c["name"], c["display"]) + """ + params = {} + if filter: + params["f"] = filter + if model_id > 0: + params["model_id"] = model_id + return client.request("GET", "/api/card", params=params) + + +def metabase_get_card(client: MetabaseClient, card_id: int) -> dict: + """Obtiene los detalles completos de una card/pregunta. + + Endpoint: GET /api/card/:id. + + Args: + client: Cliente autenticado. + card_id: ID de la card. + + Returns: + Dict con: id, name, description, display, dataset_query, + visualization_settings, collection_id, database_id, archived, + creator, created_at, updated_at. + + Example: + >>> card = metabase_get_card(client, 42) + >>> print(card["name"], card["display"]) + >>> print(card["dataset_query"]["native"]["query"]) # SQL + """ + return client.request("GET", f"/api/card/{card_id}") + + +def metabase_create_card( + client: MetabaseClient, + name: str, + dataset_query: dict, + display: str = "table", + collection_id: int = 0, + description: str = "", +) -> dict: + """Crea una nueva card/pregunta en Metabase. + + Endpoint: POST /api/card. + + Args: + client: Cliente autenticado. + name: Nombre de la pregunta. + dataset_query: Query de la card. Estructura: + SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}} + MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}} + display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar", + "area", "row", "combo", "funnel", "scatter", "waterfall", etc. + collection_id: ID de coleccion destino. 0 = root. + description: Descripcion opcional. + + Returns: + Dict con la card creada. + + Example: + >>> card = metabase_create_card(client, "Revenue by Month", { + ... "database": 1, + ... "type": "native", + ... "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"}, + ... }, display="line", description="Monthly revenue trend") + """ + body: dict = { + "name": name, + "dataset_query": dataset_query, + "display": display, + "visualization_settings": {}, + } + if collection_id > 0: + body["collection_id"] = collection_id + if description: + body["description"] = description + return client.request("POST", "/api/card", json=body) + + +def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict: + """Actualiza campos de una card/pregunta en Metabase. + + Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados. + + Args: + client: Cliente autenticado. + card_id: ID de la card. + **fields: Campos a actualizar. Validos: + name (str), description (str), display (str), + dataset_query (dict), visualization_settings (dict), + collection_id (int), archived (bool), + enable_embedding (bool), embedding_params (dict). + + Returns: + Dict con la card actualizada. + + Example: + >>> metabase_update_card(client, 42, name="Updated Name", archived=True) + >>> metabase_update_card(client, 42, dataset_query={ + ... "database": 1, "type": "native", + ... "native": {"query": "SELECT * FROM users LIMIT 100"}, + ... }) + """ + return client.request("PUT", f"/api/card/{card_id}", json=fields) + + +def metabase_delete_card(client: MetabaseClient, card_id: int) -> None: + """Elimina permanentemente una card/pregunta. + + Endpoint: DELETE /api/card/:id. IRREVERSIBLE. + Para soft-delete preferir: metabase_update_card(client, card_id, archived=True) + + Args: + client: Cliente autenticado. + card_id: ID de la card a eliminar. + + Example: + >>> metabase_delete_card(client, 42) + >>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True) + """ + client.request("DELETE", f"/api/card/{card_id}") + + +def metabase_execute_card( + client: MetabaseClient, + card_id: int, + parameters: list[dict] | None = None, +) -> dict: + """Ejecuta la query de una card/pregunta guardada. + + Endpoint: POST /api/card/:id/query. + + Args: + client: Cliente autenticado. + card_id: ID de la card a ejecutar. + parameters: Parametros para queries parametrizadas. Cada parametro: + {"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"} + + Returns: + Dict con resultados: + - status: "completed" o "failed" + - row_count: numero de filas + - running_time: tiempo en ms + - data.columns: nombres de columnas + - data.rows: filas de datos (lista de listas) + - data.cols: metadata de columnas + - data.native_form.query: SQL ejecutado + + Example: + >>> result = metabase_execute_card(client, 42) + >>> for row in result["data"]["rows"]: + ... print(row) + >>> # Con parametros: + >>> result = metabase_execute_card(client, 42, parameters=[ + ... {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"}, + ... ]) + """ + body = {} + if parameters: + body["parameters"] = parameters + return client.request("POST", f"/api/card/{card_id}/query", json=body or None) + + +def metabase_execute_query( + client: MetabaseClient, + database_id: int, + sql: str, + max_results: int = 0, +) -> dict: + """Ejecuta una query SQL ad-hoc sin guardarla como card. + + Endpoint: POST /api/dataset. Util para exploracion rapida y consultas + que no necesitan persistirse. + + Args: + client: Cliente autenticado. + database_id: ID de la database en Metabase. + sql: Query SQL a ejecutar. + max_results: Limite de filas. 0 = default 2000. + + Returns: + Dict con misma estructura que metabase_execute_card: + data.columns, data.rows, row_count, running_time, status. + + Example: + >>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10") + >>> print(f"{result['row_count']} filas en {result['running_time']}ms") + >>> for row in result["data"]["rows"]: + ... print(row) + """ + body: dict = { + "database": database_id, + "type": "native", + "native": {"query": sql}, + } + if max_results > 0: + body["constraints"] = { + "max-results": max_results, + "max-results-bare-rows": max_results, + } + return client.request("POST", "/api/dataset", json=body) diff --git a/python/functions/metabase/client.py b/python/functions/metabase/client.py new file mode 100644 index 00000000..a3b4ceaf --- /dev/null +++ b/python/functions/metabase/client.py @@ -0,0 +1,87 @@ +"""Cliente base para la API REST de Metabase.""" + +import httpx + + +class MetabaseClient: + """Cliente HTTP para una instancia Metabase. + + Attributes: + base_url: URL base sin trailing slash (ej: "http://localhost:3000"). + token: Session token o API key. + _http: Cliente httpx reutilizable con headers de auth. + """ + + def __init__(self, base_url: str, token: str) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self._http = httpx.Client( + base_url=self.base_url, + headers={ + "Content-Type": "application/json", + "X-Metabase-Session": token, + }, + timeout=30.0, + ) + + def request(self, method: str, path: str, **kwargs) -> dict | list | None: + """Ejecuta una peticion HTTP contra la API de Metabase. + + Args: + method: HTTP method (GET, POST, PUT, DELETE). + path: Ruta relativa (ej: "/api/user"). + **kwargs: Argumentos extra para httpx (json, params, etc.). + + Returns: + Respuesta deserializada como dict/list, o None si el body esta vacio. + + Raises: + httpx.HTTPStatusError: Si el status code no es 2xx. + """ + resp = self._http.request(method, path, **kwargs) + resp.raise_for_status() + if not resp.content: + return None + return resp.json() + + def close(self) -> None: + """Cierra el cliente HTTP.""" + self._http.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient: + """Autentica contra Metabase con email y password. + + Crea una sesion via POST /api/session y retorna un MetabaseClient + con el session token listo para usar. El token expira en 14 dias + por defecto (configurable con MAX_SESSION_AGE en Metabase). + + Args: + base_url: URL base de la instancia (ej: "http://localhost:3000"). + email: Email del usuario Metabase. + password: Password del usuario. + + Returns: + MetabaseClient autenticado con session token. + + Raises: + httpx.HTTPStatusError: Si las credenciales son invalidas (401) + o hay rate limiting. + + Example: + >>> client = metabase_auth("http://localhost:3000", "admin@example.com", "pass") + >>> # client listo para usar con todas las funciones CRUD + """ + resp = httpx.post( + f"{base_url.rstrip('/')}/api/session", + json={"username": email, "password": password}, + ) + resp.raise_for_status() + token = resp.json()["id"] + return MetabaseClient(base_url, token) diff --git a/python/functions/metabase/dashboards.py b/python/functions/metabase/dashboards.py new file mode 100644 index 00000000..f298c7ae --- /dev/null +++ b/python/functions/metabase/dashboards.py @@ -0,0 +1,143 @@ +"""CRUD de dashboards de Metabase.""" + +from .client import MetabaseClient + + +def metabase_list_dashboards( + client: MetabaseClient, + filter: str = "", +) -> list[dict]: + """Lista dashboards de Metabase con filtro opcional. + + Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards). + + Args: + client: Cliente autenticado. + filter: "all", "mine" o "archived". Vacio = todas. + + Returns: + Lista de dicts con: id, name, description, collection_id, + creator_id, archived, created_at. + + Example: + >>> dashboards = metabase_list_dashboards(client, filter="mine") + >>> for d in dashboards: + ... print(d["id"], d["name"]) + """ + params = {} + if filter: + params["f"] = filter + return client.request("GET", "/api/dashboard", params=params) + + +def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict: + """Obtiene un dashboard completo incluyendo sus cards. + + Endpoint: GET /api/dashboard/:id. + + Args: + client: Cliente autenticado. + dashboard_id: ID del dashboard. + + Returns: + Dict con: id, name, description, dashcards (lista de cards posicionadas), + parameters (filtros), tabs, collection_id, archived. + + Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y, + col, row, dashboard_tab_id, parameter_mappings, visualization_settings. + + Example: + >>> dash = metabase_get_dashboard(client, 1) + >>> for dc in dash["dashcards"]: + ... print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})") + """ + return client.request("GET", f"/api/dashboard/{dashboard_id}") + + +def metabase_create_dashboard( + client: MetabaseClient, + name: str, + description: str = "", + collection_id: int = 0, +) -> dict: + """Crea un nuevo dashboard vacio en Metabase. + + Endpoint: POST /api/dashboard. + Para agregar cards usar metabase_update_dashboard con dashcards. + + Args: + client: Cliente autenticado. + name: Nombre del dashboard. + description: Descripcion opcional. + collection_id: Coleccion destino. 0 = root. + + Returns: + Dict con el dashboard creado. + + Example: + >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas") + >>> # Agregar cards: + >>> metabase_update_dashboard(client, dash["id"], dashcards=[ + ... {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0}, + ... ]) + """ + body: dict = {"name": name} + if description: + body["description"] = description + if collection_id > 0: + body["collection_id"] = collection_id + return client.request("POST", "/api/dashboard", json=body) + + +def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict: + """Actualiza un dashboard incluyendo metadata, cards y tabs. + + Endpoint: PUT /api/dashboard/:id. + + El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard: + - Agregar card: incluirla con ID negativo (-1, -2, etc.) + - Actualizar card existente: incluirla con su ID positivo + - Eliminar card: omitirla del array + + Args: + client: Cliente autenticado. + dashboard_id: ID del dashboard. + **fields: Campos a actualizar. Validos: + name (str), description (str), archived (bool), + dashcards (list[dict]), tabs (list[dict]), + parameters (list[dict]), collection_id (int). + + Returns: + Dict con el dashboard actualizado. + + Example: + >>> # Cambiar nombre + >>> metabase_update_dashboard(client, 1, name="Updated Name") + >>> + >>> # Agregar card (primero obtener existentes) + >>> dash = metabase_get_dashboard(client, 1) + >>> cards = list(dash["dashcards"]) + >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0}) + >>> metabase_update_dashboard(client, 1, dashcards=cards) + >>> + >>> # Archivar (soft-delete) + >>> metabase_update_dashboard(client, 1, archived=True) + """ + return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields) + + +def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None: + """Elimina permanentemente un dashboard. + + Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE. + Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True) + + Args: + client: Cliente autenticado. + dashboard_id: ID del dashboard a eliminar. + + Example: + >>> metabase_delete_dashboard(client, 1) + >>> # Preferir: metabase_update_dashboard(client, 1, archived=True) + """ + client.request("DELETE", f"/api/dashboard/{dashboard_id}") diff --git a/python/functions/metabase/metabase_auth.md b/python/functions/metabase/metabase_auth.md new file mode 100644 index 00000000..2b3ef8f9 --- /dev/null +++ b/python/functions/metabase/metabase_auth.md @@ -0,0 +1,46 @@ +--- +name: metabase_auth +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient" +description: "Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session." +tags: [metabase, auth, session, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/client.py" +--- + +## Ejemplo + +```python +from functions.metabase import metabase_auth + +client = metabase_auth("http://localhost:3000", "admin@example.com", "pass") +# client listo para usar con todas las funciones CRUD + +# Alternativa con API key: +from functions.metabase import MetabaseClient +client = MetabaseClient("http://localhost:3000", "mb_api_key_xxxxx") +``` + +## Notas + +Dos formas de obtener un client: +- `metabase_auth()`: login con email/password, obtiene session token via POST /api/session +- `MetabaseClient(base_url, api_key)`: constructor directo con API key (recomendado para automatizacion) + +El client es un context manager: `with metabase_auth(...) as client:` + +Errores comunes: +- 401: credenciales invalidas +- Rate limiting en intentos fallidos de login diff --git a/python/functions/metabase/metabase_create_card.md b/python/functions/metabase/metabase_create_card.md new file mode 100644 index 00000000..c99fc347 --- /dev/null +++ b/python/functions/metabase/metabase_create_card.md @@ -0,0 +1,35 @@ +--- +name: metabase_create_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_card(client: MetabaseClient, name: str, dataset_query: dict, display: str = 'table', collection_id: int = 0, description: str = '') -> dict" +description: "Crea una card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card." +tags: [metabase, card, question, create, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +card = metabase_create_card(client, "Revenue", { + "database": 1, "type": "native", + "native": {"query": "SELECT SUM(total) FROM orders"}, +}, display="scalar") +``` + +## Notas + +dataset_query SQL nativo: `{"database": id, "type": "native", "native": {"query": "..."}}` +dataset_query MBQL: `{"database": id, "type": "query", "query": {"source-table": id, ...}}` diff --git a/python/functions/metabase/metabase_create_dashboard.md b/python/functions/metabase/metabase_create_dashboard.md new file mode 100644 index 00000000..d715227e --- /dev/null +++ b/python/functions/metabase/metabase_create_dashboard.md @@ -0,0 +1,32 @@ +--- +name: metabase_create_dashboard +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_dashboard(client: MetabaseClient, name: str, description: str = '', collection_id: int = 0) -> dict" +description: "Crea dashboard vacio en Metabase. Para agregar cards usar metabase_update_dashboard con dashcards. Endpoint: POST /api/dashboard." +tags: [metabase, dashboard, create, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +dash = metabase_create_dashboard(client, "Sales Overview", "KPIs") +# Agregar cards con metabase_update_dashboard +``` + +## Notas + +Se crea vacio. Agregar cards con metabase_update_dashboard(dashcards=[...]). diff --git a/python/functions/metabase/metabase_create_user.md b/python/functions/metabase/metabase_create_user.md new file mode 100644 index 00000000..688d0890 --- /dev/null +++ b/python/functions/metabase/metabase_create_user.md @@ -0,0 +1,32 @@ +--- +name: metabase_create_user +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_user(client: MetabaseClient, first_name: str, last_name: str, email: str, password: str = '', group_ids: list[int] | None = None) -> dict" +description: "Crea un nuevo usuario en Metabase. Sin password envia invitacion por email. Requiere superusuario. Endpoint: POST /api/user." +tags: [metabase, user, create, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123") +user = metabase_create_user(client, "Jane", "Smith", "jane@example.com", group_ids=[1, 3]) +``` + +## Notas + +Email debe ser unico. Error 400 si ya existe. diff --git a/python/functions/metabase/metabase_deactivate_user.md b/python/functions/metabase/metabase_deactivate_user.md new file mode 100644 index 00000000..21ccd678 --- /dev/null +++ b/python/functions/metabase/metabase_deactivate_user.md @@ -0,0 +1,31 @@ +--- +name: metabase_deactivate_user +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None" +description: "Desactiva (soft-delete) un usuario en Metabase. Reactivar con PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id." +tags: [metabase, user, delete, deactivate, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +metabase_deactivate_user(client, 5) +``` + +## Notas + +Soft-delete. El usuario se puede reactivar. diff --git a/python/functions/metabase/metabase_delete_card.md b/python/functions/metabase/metabase_delete_card.md new file mode 100644 index 00000000..eea33cf3 --- /dev/null +++ b/python/functions/metabase/metabase_delete_card.md @@ -0,0 +1,32 @@ +--- +name: metabase_delete_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_delete_card(client: MetabaseClient, card_id: int) -> None" +description: "Elimina permanentemente una card/pregunta. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/card/:id." +tags: [metabase, card, question, delete, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +metabase_delete_card(client, 42) +# Preferir: metabase_update_card(client, 42, archived=True) +``` + +## Notas + +IRREVERSIBLE. Preferir soft-delete con metabase_update_card(archived=True). diff --git a/python/functions/metabase/metabase_delete_dashboard.md b/python/functions/metabase/metabase_delete_dashboard.md new file mode 100644 index 00000000..c4bedb14 --- /dev/null +++ b/python/functions/metabase/metabase_delete_dashboard.md @@ -0,0 +1,32 @@ +--- +name: metabase_delete_dashboard +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None" +description: "Elimina permanentemente un dashboard. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/dashboard/:id." +tags: [metabase, dashboard, delete, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +metabase_delete_dashboard(client, 1) +# Preferir: metabase_update_dashboard(client, 1, archived=True) +``` + +## Notas + +IRREVERSIBLE. Preferir soft-delete con metabase_update_dashboard(archived=True). diff --git a/python/functions/metabase/metabase_execute_card.md b/python/functions/metabase/metabase_execute_card.md new file mode 100644 index 00000000..0c2dbad7 --- /dev/null +++ b/python/functions/metabase/metabase_execute_card.md @@ -0,0 +1,34 @@ +--- +name: metabase_execute_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_execute_card(client: MetabaseClient, card_id: int, parameters: list[dict] | None = None) -> dict" +description: "Ejecuta la query de una card guardada y retorna resultados con columnas y filas. Soporta parametros. Endpoint: POST /api/card/:id/query." +tags: [metabase, card, question, execute, query, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +result = metabase_execute_card(client, 42) +for row in result["data"]["rows"]: + print(row) +``` + +## Notas + +Respuesta: status, row_count, running_time, data.columns, data.rows, data.cols. +Limite default: 2000 filas. Para ad-hoc sin card usar metabase_execute_query. diff --git a/python/functions/metabase/metabase_execute_query.md b/python/functions/metabase/metabase_execute_query.md new file mode 100644 index 00000000..5480c36f --- /dev/null +++ b/python/functions/metabase/metabase_execute_query.md @@ -0,0 +1,32 @@ +--- +name: metabase_execute_query +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_execute_query(client: MetabaseClient, database_id: int, sql: str, max_results: int = 0) -> dict" +description: "Ejecuta query SQL ad-hoc contra Metabase sin guardarla como card. Util para exploracion rapida. Endpoint: POST /api/dataset." +tags: [metabase, query, execute, sql, dataset, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10") +print(f"{result['row_count']} filas en {result['running_time']}ms") +``` + +## Notas + +Misma respuesta que metabase_execute_card. Default 2000 filas, override con max_results. diff --git a/python/functions/metabase/metabase_get_card.md b/python/functions/metabase/metabase_get_card.md new file mode 100644 index 00000000..f568546d --- /dev/null +++ b/python/functions/metabase/metabase_get_card.md @@ -0,0 +1,32 @@ +--- +name: metabase_get_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_card(client: MetabaseClient, card_id: int) -> dict" +description: "Obtiene detalles completos de una card/pregunta de Metabase incluyendo query, visualizacion y metadata. Endpoint: GET /api/card/:id." +tags: [metabase, card, question, get, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +card = metabase_get_card(client, 42) +print(card["name"], card["display"]) +``` + +## Notas + +Error 404 si no existe. diff --git a/python/functions/metabase/metabase_get_dashboard.md b/python/functions/metabase/metabase_get_dashboard.md new file mode 100644 index 00000000..073d21df --- /dev/null +++ b/python/functions/metabase/metabase_get_dashboard.md @@ -0,0 +1,33 @@ +--- +name: metabase_get_dashboard +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict" +description: "Obtiene dashboard completo con dashcards (cards posicionadas), tabs y parametros. Endpoint: GET /api/dashboard/:id." +tags: [metabase, dashboard, get, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +dash = metabase_get_dashboard(client, 1) +for dc in dash["dashcards"]: + print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})") +``` + +## Notas + +Cada dashcard tiene: id, card_id, card, size_x, size_y, col, row, dashboard_tab_id, parameter_mappings. diff --git a/python/functions/metabase/metabase_get_user.md b/python/functions/metabase/metabase_get_user.md new file mode 100644 index 00000000..8dcda611 --- /dev/null +++ b/python/functions/metabase/metabase_get_user.md @@ -0,0 +1,32 @@ +--- +name: metabase_get_user +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_get_user(client: MetabaseClient, user_id: int) -> dict" +description: "Obtiene los detalles de un usuario de Metabase por su ID. Endpoint: GET /api/user/:id." +tags: [metabase, user, get, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +user = metabase_get_user(client, 1) +print(user["email"], user["is_superuser"]) +``` + +## Notas + +Error 404 si el usuario no existe. diff --git a/python/functions/metabase/metabase_list_cards.md b/python/functions/metabase/metabase_list_cards.md new file mode 100644 index 00000000..cb903582 --- /dev/null +++ b/python/functions/metabase/metabase_list_cards.md @@ -0,0 +1,32 @@ +--- +name: metabase_list_cards +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_cards(client: MetabaseClient, filter: str = '', model_id: int = 0) -> list[dict]" +description: "Lista preguntas/cards de Metabase. Filtros: all, mine, fav, archived, recent, popular, database, table. Endpoint: GET /api/card." +tags: [metabase, card, question, list, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +cards = metabase_list_cards(client, filter="mine") +cards = metabase_list_cards(client, filter="database", model_id=1) +``` + +## Notas + +No tiene paginacion offset/limit. Retorna todas las cards que coinciden. diff --git a/python/functions/metabase/metabase_list_dashboards.md b/python/functions/metabase/metabase_list_dashboards.md new file mode 100644 index 00000000..ae87396e --- /dev/null +++ b/python/functions/metabase/metabase_list_dashboards.md @@ -0,0 +1,33 @@ +--- +name: metabase_list_dashboards +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_dashboards(client: MetabaseClient, filter: str = '') -> list[dict]" +description: "Lista dashboards de Metabase. Filtros: all, mine, archived. Retorna resumen sin dashcards. Endpoint: GET /api/dashboard." +tags: [metabase, dashboard, list, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +dashboards = metabase_list_dashboards(client, filter="mine") +for d in dashboards: + print(d["id"], d["name"]) +``` + +## Notas + +Para ver cards de un dashboard usar metabase_get_dashboard. diff --git a/python/functions/metabase/metabase_list_users.md b/python/functions/metabase/metabase_list_users.md new file mode 100644 index 00000000..8a8361f5 --- /dev/null +++ b/python/functions/metabase/metabase_list_users.md @@ -0,0 +1,33 @@ +--- +name: metabase_list_users +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_list_users(client: MetabaseClient, status: str = '', query: str = '', limit: int = 0, offset: int = 0) -> dict" +description: "Lista usuarios de Metabase con filtros opcionales por estado, nombre/email y paginacion. Endpoint: GET /api/user." +tags: [metabase, user, list, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +users = metabase_list_users(client, status="active", query="john@") +for u in users["data"]: + print(u["email"], u["first_name"]) +``` + +## Notas + +Retorna dict paginado con data, total, limit, offset. diff --git a/python/functions/metabase/metabase_update_card.md b/python/functions/metabase/metabase_update_card.md new file mode 100644 index 00000000..8c046148 --- /dev/null +++ b/python/functions/metabase/metabase_update_card.md @@ -0,0 +1,31 @@ +--- +name: metabase_update_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict" +description: "Actualiza campos de una card/pregunta via kwargs. Campos: name, description, display, dataset_query, collection_id, archived. Endpoint: PUT /api/card/:id." +tags: [metabase, card, question, update, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +metabase_update_card(client, 42, name="New Name", archived=True) +``` + +## Notas + +Soft-delete con `archived=True`. Para delete permanente usar metabase_delete_card. diff --git a/python/functions/metabase/metabase_update_dashboard.md b/python/functions/metabase/metabase_update_dashboard.md new file mode 100644 index 00000000..17e1379b --- /dev/null +++ b/python/functions/metabase/metabase_update_dashboard.md @@ -0,0 +1,39 @@ +--- +name: metabase_update_dashboard +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict" +description: "Actualiza dashboard incluyendo metadata, cards y tabs via kwargs. dashcards es el estado completo deseado: nuevas con ID negativo, existentes con positivo, omitidas se eliminan. Endpoint: PUT /api/dashboard/:id." +tags: [metabase, dashboard, update, cards, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +# Agregar card +dash = metabase_get_dashboard(client, 1) +cards = list(dash["dashcards"]) +cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0}) +metabase_update_dashboard(client, 1, dashcards=cards) + +# Archivar +metabase_update_dashboard(client, 1, archived=True) +``` + +## Notas + +dashcards = estado completo. ID negativo = nueva, positivo = existente, omitida = eliminada. +Campos: name, description, archived, dashcards, tabs, parameters, collection_id. diff --git a/python/functions/metabase/metabase_update_user.md b/python/functions/metabase/metabase_update_user.md new file mode 100644 index 00000000..78e0f4c1 --- /dev/null +++ b/python/functions/metabase/metabase_update_user.md @@ -0,0 +1,32 @@ +--- +name: metabase_update_user +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict" +description: "Actualiza campos de un usuario en Metabase via keyword arguments. Campos: first_name, last_name, email, is_superuser, group_ids, locale. Endpoint: PUT /api/user/:id." +tags: [metabase, user, update, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +metabase_update_user(client, 5, first_name="Jane", is_superuser=True) +metabase_update_user(client, 5, group_ids=[1, 3, 5]) +``` + +## Notas + +Solo se modifican los campos pasados como kwargs. diff --git a/python/functions/metabase/users.py b/python/functions/metabase/users.py new file mode 100644 index 00000000..4133b63b --- /dev/null +++ b/python/functions/metabase/users.py @@ -0,0 +1,153 @@ +"""CRUD de usuarios de Metabase.""" + +from .client import MetabaseClient + + +def metabase_list_users( + client: MetabaseClient, + status: str = "", + query: str = "", + limit: int = 0, + offset: int = 0, +) -> dict: + """Lista usuarios de Metabase con filtros opcionales. + + Endpoint: GET /api/user. Requiere permisos de superusuario. + + Args: + client: Cliente autenticado. + status: "active" (default), "deactivated" o "all". + query: Filtro por nombre o email. + limit: Tamanio de pagina (0 = default Metabase). + offset: Offset para paginacion. + + Returns: + Dict con estructura paginada: + - data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.) + - total: numero total de usuarios que coinciden + - limit: tamanio de pagina usado + - offset: offset usado + + Example: + >>> users = metabase_list_users(client, status="active", query="john@") + >>> for u in users["data"]: + ... print(u["email"], u["first_name"]) + """ + params = {} + if status: + params["status"] = status + if query: + params["query"] = query + if limit > 0: + params["limit"] = limit + if offset > 0: + params["offset"] = offset + return client.request("GET", "/api/user", params=params) + + +def metabase_get_user(client: MetabaseClient, user_id: int) -> dict: + """Obtiene un usuario de Metabase por su ID. + + Endpoint: GET /api/user/:id. + + Args: + client: Cliente autenticado. + user_id: ID numerico del usuario. + + Returns: + Dict con datos del usuario: id, email, first_name, last_name, + is_superuser, is_active, common_name, date_joined, last_login, + group_ids, locale. + + Raises: + httpx.HTTPStatusError: 404 si el usuario no existe. + + Example: + >>> user = metabase_get_user(client, 1) + >>> print(user["email"], user["is_superuser"]) + """ + return client.request("GET", f"/api/user/{user_id}") + + +def metabase_create_user( + client: MetabaseClient, + first_name: str, + last_name: str, + email: str, + password: str = "", + group_ids: list[int] | None = None, +) -> dict: + """Crea un nuevo usuario en Metabase. + + Endpoint: POST /api/user. Requiere permisos de superusuario. + + Args: + client: Cliente autenticado con permisos admin. + first_name: Nombre del usuario. + last_name: Apellido del usuario. + email: Email unico del usuario. + password: Password. Vacio = Metabase envia invitacion por email. + group_ids: IDs de grupos a asignar. None = solo grupo default. + + Returns: + Dict con el usuario creado (mismos campos que metabase_get_user). + + Raises: + httpx.HTTPStatusError: 400 si el email ya existe. + + Example: + >>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123") + >>> print(user["id"]) + """ + body: dict = { + "first_name": first_name, + "last_name": last_name, + "email": email, + } + if password: + body["password"] = password + if group_ids: + body["group_ids"] = group_ids + return client.request("POST", "/api/user", json=body) + + +def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict: + """Actualiza campos de un usuario en Metabase. + + Endpoint: PUT /api/user/:id. Requiere permisos de superusuario. + Solo se modifican los campos pasados como keyword arguments. + + Args: + client: Cliente autenticado con permisos admin. + user_id: ID del usuario a actualizar. + **fields: Campos a actualizar. Validos: + first_name (str), last_name (str), email (str), + is_superuser (bool), group_ids (list[int]), + locale (str), login_attributes (dict). + + Returns: + Dict con el usuario actualizado. + + Example: + >>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True) + >>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5]) + """ + return client.request("PUT", f"/api/user/{user_id}", json=fields) + + +def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None: + """Desactiva (soft-delete) un usuario en Metabase. + + Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario. + El usuario no se elimina permanentemente, solo se marca como inactivo. + Para reactivar: PUT /api/user/:id/reactivate. + + Args: + client: Cliente autenticado con permisos admin. + user_id: ID del usuario a desactivar. + + Example: + >>> metabase_deactivate_user(client, 5) + >>> # Para ver desactivados: metabase_list_users(client, status="deactivated") + """ + client.request("DELETE", f"/api/user/{user_id}") diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..03b6a2b3 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "fn-registry-python" +version = "0.1.0" +description = "Funciones Python del fn-registry: Metabase API, ML, utilidades" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "httpx", +] diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 00000000..4ab33db3 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,91 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "fn-registry-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.metadata] +requires-dist = [{ name = "httpx" }] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]