@dataif/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/bin/dataif.js +623 -0
- package/package.json +26 -0
- package/scripts/build-template.mjs +72 -0
- package/templates/dataif/README.md +157 -0
- package/templates/dataif/infra/.env.example +119 -0
- package/templates/dataif/infra/.env.stg.example +119 -0
- package/templates/dataif/infra/airflow/Dockerfile +11 -0
- package/templates/dataif/infra/airflow/Dockerfile.release +17 -0
- package/templates/dataif/infra/airflow/requirements.txt +3 -0
- package/templates/dataif/infra/docker-compose.yml +306 -0
- package/templates/dataif/infra/init-db/01-init-dataif.sh +129 -0
- package/templates/dataif/infra/init-db/pnp-curated-views.sqlinc +444 -0
- package/templates/dataif/infra/init-db/pnp-raw-staging-curated.sqlinc +701 -0
- package/templates/dataif/infra/keycloak/Dockerfile +4 -0
- package/templates/dataif/infra/keycloak/realm-dataif.json +73 -0
- package/templates/dataif/infra/ollama/Dockerfile +9 -0
- package/templates/dataif/infra/ollama/bootstrap-model.sh +100 -0
- package/templates/dataif/infra/ollama/sabia-7b.Modelfile +14 -0
- package/templates/dataif/infra/postgres/Dockerfile +4 -0
- package/templates/dataif/pipelines/airflow/dags/generated/.gitkeep +1 -0
- package/templates/dataif/pipelines/airflow/dags/generated/2020_financeiro_fcc6f1f3_sync.py +9 -0
- package/templates/dataif/pipelines/dataif_pipelines/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/airflow/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/airflow/pnp_pipeline_factory.py +167 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/connector.py +28 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/types.py +14 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/config.py +19 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/connector.py +558 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/powerbi_microdados.py +728 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/transform.py +296 -0
- package/templates/dataif/pipelines/dataif_pipelines/jobs/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/jobs/nilo_pipeline.py +112 -0
- package/templates/dataif/pipelines/dataif_pipelines/orchestration/__init__.py +21 -0
- package/templates/dataif/pipelines/dataif_pipelines/orchestration/pnp_workflow.py +783 -0
- package/templates/dataif/pipelines/dataif_pipelines/repositories/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/repositories/pnp_raw_repository.py +860 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/__init__.py +19 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_curated_service.py +66 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_download_service.py +534 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_quality_service.py +9 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_raw_ingestion_service.py +124 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_staging_service.py +271 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/powerbi_catalog_service.py +159 -0
- package/templates/dataif/pipelines/sql/staging/020_pnp_matriculas.sql +112 -0
- package/templates/dataif/pipelines/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
- package/templates/dataif/pipelines/sql/staging/040_pnp_servidores.sql +90 -0
- package/templates/dataif/pipelines/sql/staging/050_pnp_financeiro.sql +72 -0
- package/templates/dataif/pipelines/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
- package/templates/dataif/pipelines/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
- package/templates/dataif/pipelines/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
- package/templates/dataif/pipelines/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
- package/templates/dataif/pipelines/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
- package/templates/dataif/pipelines/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
- package/templates/dataif/pipelines/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
- package/templates/dataif/pipelines/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
- package/templates/dataif/scripts/configure-env.sh +149 -0
- package/templates/dataif/scripts/create_metabase_pnp_dashboard.py +943 -0
- package/templates/dataif/scripts/create_metabase_pnp_matriculas_dashboard.py +580 -0
- package/templates/dataif/scripts/deploy.sh +79 -0
- package/templates/dataif/scripts/fix_metabase_template_tag_ids.py +91 -0
- package/templates/dataif/scripts/pnp_powerbi_microdados_probe.py +14 -0
- package/templates/dataif/scripts/pnp_validate_raw_run.py +330 -0
- package/templates/dataif/scripts/publish-images.sh +31 -0
- package/templates/dataif/scripts/sync_metabase_dashboard_field_filters.py +241 -0
- package/templates/dataif/scripts/use-vanna-ollama.sh +139 -0
- package/templates/dataif/services/api/.dockerignore +18 -0
- package/templates/dataif/services/api/Dockerfile +12 -0
- package/templates/dataif/services/api/app/__init__.py +1 -0
- package/templates/dataif/services/api/app/auth.py +48 -0
- package/templates/dataif/services/api/app/config.py +59 -0
- package/templates/dataif/services/api/app/keycloak_admin.py +215 -0
- package/templates/dataif/services/api/app/main.py +2432 -0
- package/templates/dataif/services/api/app/metabase_admin.py +191 -0
- package/templates/dataif/services/api/app/metabase_bootstrap.py +44 -0
- package/templates/dataif/services/api/app/metabase_embed.py +15 -0
- package/templates/dataif/services/api/app/pnp_dag_provisioner.py +113 -0
- package/templates/dataif/services/api/app/pnp_instance_repository.py +951 -0
- package/templates/dataif/services/api/app/pnp_powerbi.py +438 -0
- package/templates/dataif/services/api/app/vanna_client.py +32 -0
- package/templates/dataif/services/api/requirements.txt +9 -0
- package/templates/dataif/services/vanna/.dockerignore +18 -0
- package/templates/dataif/services/vanna/Dockerfile +12 -0
- package/templates/dataif/services/vanna/app/config.py +57 -0
- package/templates/dataif/services/vanna/app/main.py +108 -0
- package/templates/dataif/services/vanna/app/runtime_config.py +114 -0
- package/templates/dataif/services/vanna/app/sql_guard.py +123 -0
- package/templates/dataif/services/vanna/app/vanna_engine.py +382 -0
- package/templates/dataif/services/vanna/requirements.txt +8 -0
- package/templates/dataif/services/web/.dockerignore +13 -0
- package/templates/dataif/services/web/Dockerfile +16 -0
- package/templates/dataif/services/web/index.html +12 -0
- package/templates/dataif/services/web/nginx.conf +74 -0
- package/templates/dataif/services/web/package-lock.json +4397 -0
- package/templates/dataif/services/web/package.json +32 -0
- package/templates/dataif/services/web/postcss.config.mjs +5 -0
- package/templates/dataif/services/web/src/App.jsx +2817 -0
- package/templates/dataif/services/web/src/adminAuth.js +245 -0
- package/templates/dataif/services/web/src/assets/avatar_placeholder.png +0 -0
- package/templates/dataif/services/web/src/assets/github_logo_icon_229278.svg +1 -0
- package/templates/dataif/services/web/src/assets/if-logo.png +0 -0
- package/templates/dataif/services/web/src/assets/if.svg +0 -0
- package/templates/dataif/services/web/src/assets/pnp-horizontal.svg +1 -0
- package/templates/dataif/services/web/src/components/AppHeader.jsx +233 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/mobile-header.tsx +56 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-account-card.tsx +209 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item-button.tsx +67 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item.tsx +108 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-list.tsx +83 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/config.ts +23 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/header-navigation.tsx +240 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-base.tsx +376 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-dot.tsx +52 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-line.tsx +48 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination.tsx +328 -0
- package/templates/dataif/services/web/src/components/application/tabs/tabs.tsx +223 -0
- package/templates/dataif/services/web/src/components/base/avatar/avatar-label-group.tsx +28 -0
- package/templates/dataif/services/web/src/components/base/avatar/avatar.tsx +129 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-add-button.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-company-icon.tsx +24 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-online-indicator.tsx +29 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/index.tsx +4 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/verified-tick.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/badges/badge-types.ts +264 -0
- package/templates/dataif/services/web/src/components/base/badges/badges.tsx +415 -0
- package/templates/dataif/services/web/src/components/base/button-group/button-group.tsx +104 -0
- package/templates/dataif/services/web/src/components/base/buttons/button.tsx +267 -0
- package/templates/dataif/services/web/src/components/base/input/hint-text.tsx +31 -0
- package/templates/dataif/services/web/src/components/base/input/input.tsx +269 -0
- package/templates/dataif/services/web/src/components/base/input/label.tsx +48 -0
- package/templates/dataif/services/web/src/components/base/radio-buttons/radio-buttons.tsx +127 -0
- package/templates/dataif/services/web/src/components/base/select/combobox.tsx +150 -0
- package/templates/dataif/services/web/src/components/base/select/multi-select.tsx +361 -0
- package/templates/dataif/services/web/src/components/base/select/popover.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/select/select-item.tsx +95 -0
- package/templates/dataif/services/web/src/components/base/select/select-native.tsx +67 -0
- package/templates/dataif/services/web/src/components/base/select/select.tsx +144 -0
- package/templates/dataif/services/web/src/components/base/tags/base-components/tag-close-x.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/tooltip/tooltip.tsx +107 -0
- package/templates/dataif/services/web/src/components/foundations/dot-icon.tsx +22 -0
- package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo-minimal.tsx +170 -0
- package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo.tsx +58 -0
- package/templates/dataif/services/web/src/hooks/use-breakpoint.ts +34 -0
- package/templates/dataif/services/web/src/hooks/use-resize-observer.ts +67 -0
- package/templates/dataif/services/web/src/main.jsx +14 -0
- package/templates/dataif/services/web/src/providers/theme-provider.jsx +62 -0
- package/templates/dataif/services/web/src/styles/globals.css +60 -0
- package/templates/dataif/services/web/src/styles/theme.css +1326 -0
- package/templates/dataif/services/web/src/styles/typography.css +430 -0
- package/templates/dataif/services/web/src/styles.css +1287 -0
- package/templates/dataif/services/web/src/utils/cx.ts +24 -0
- package/templates/dataif/services/web/src/utils/is-react-component.ts +33 -0
- package/templates/dataif/services/web/vite.config.js +14 -0
- package/templates/dataif/sql/ddl/001_schemas.sql +6 -0
- package/templates/dataif/sql/ddl/003_pnp_raw_staging_curated.sql +699 -0
- package/templates/dataif/sql/migrations/001_pnp_phase1_backfill.sql +3 -0
- package/templates/dataif/sql/migrations/002_pnp_phase2_admin_config_backfill.sql +184 -0
- package/templates/dataif/sql/migrations/003_pnp_phase3_raw_tabular_backfill.sql +3 -0
- package/templates/dataif/sql/migrations/004_pnp_phase3_raw_backfill_support_index.sql +3 -0
- package/templates/dataif/sql/migrations/005_pnp_phase7_staging_support_indexes.sql +2 -0
- package/templates/dataif/sql/migrations/006_pnp_phase7_staging_autovacuum_tuning.sql +2 -0
- package/templates/dataif/sql/migrations/007_pnp_phase7b_run_packages.sql +20 -0
- package/templates/dataif/sql/migrations/008_pnp_phase7a_pipeline_endpoints.sql +169 -0
- package/templates/dataif/sql/migrations/009_pnp_phase8_curated.sql +35 -0
- package/templates/dataif/sql/migrations/010_pnp_phase10_staging_incremental_upsert.sql +3 -0
- package/templates/dataif/sql/migrations/010_pnp_pipeline_uuid.sql +51 -0
- package/templates/dataif/sql/migrations/011_app_settings.sql +7 -0
- package/templates/dataif/sql/staging/020_pnp_matriculas.sql +112 -0
- package/templates/dataif/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
- package/templates/dataif/sql/staging/040_pnp_servidores.sql +90 -0
- package/templates/dataif/sql/staging/050_pnp_financeiro.sql +72 -0
- package/templates/dataif/sql/views_curated/003_vw_pnp_microdados_admin.sql +160 -0
- package/templates/dataif/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
- package/templates/dataif/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
- package/templates/dataif/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
- package/templates/dataif/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
- package/templates/dataif/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
- package/templates/dataif/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
- package/templates/dataif/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
- package/templates/dataif/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import HTTPException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MetabaseAdminClient:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
base_url: str,
|
|
14
|
+
admin_email: str,
|
|
15
|
+
admin_password: str,
|
|
16
|
+
timeout_seconds: float,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.base_url = base_url.rstrip("/")
|
|
19
|
+
self.admin_email = admin_email
|
|
20
|
+
self.admin_password = admin_password
|
|
21
|
+
self.timeout_seconds = timeout_seconds
|
|
22
|
+
|
|
23
|
+
def session_properties(self) -> dict[str, Any]:
|
|
24
|
+
return self._raw_request("GET", "/api/session/properties", expected_status={200}, auth_mode="none")
|
|
25
|
+
|
|
26
|
+
def list_admin_users(self) -> list[dict[str, Any]]:
|
|
27
|
+
items = self._request("GET", "/api/user", expected_status={200})
|
|
28
|
+
if not isinstance(items, list):
|
|
29
|
+
return []
|
|
30
|
+
return [self._normalize_user(item) for item in items if isinstance(item, dict) and bool(item.get("is_superuser"))]
|
|
31
|
+
|
|
32
|
+
def find_user_by_email(self, email: str) -> dict[str, Any] | None:
|
|
33
|
+
target = email.strip().lower()
|
|
34
|
+
if not target:
|
|
35
|
+
return None
|
|
36
|
+
for item in self.list_admin_users():
|
|
37
|
+
if str(item.get("email") or "").strip().lower() == target:
|
|
38
|
+
return item
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def create_admin_user(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
email: str,
|
|
45
|
+
password: str,
|
|
46
|
+
first_name: str,
|
|
47
|
+
last_name: str,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
existing = self.find_user_by_email(email)
|
|
50
|
+
if existing:
|
|
51
|
+
raise HTTPException(status_code=409, detail=f"Metabase admin already exists for email: {email}")
|
|
52
|
+
|
|
53
|
+
payload = {
|
|
54
|
+
"email": email,
|
|
55
|
+
"first_name": first_name,
|
|
56
|
+
"last_name": last_name,
|
|
57
|
+
"password": password,
|
|
58
|
+
"is_superuser": True,
|
|
59
|
+
}
|
|
60
|
+
created = self._request("POST", "/api/user", json=payload, expected_status={200})
|
|
61
|
+
if not isinstance(created, dict):
|
|
62
|
+
raise HTTPException(status_code=502, detail="Metabase did not return the created user")
|
|
63
|
+
return self._normalize_user(created)
|
|
64
|
+
|
|
65
|
+
def delete_user(self, user_id: int | str) -> None:
|
|
66
|
+
self._request("DELETE", f"/api/user/{user_id}", expected_status={200, 204})
|
|
67
|
+
|
|
68
|
+
def ensure_initial_admin(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
first_name: str,
|
|
72
|
+
last_name: str,
|
|
73
|
+
site_name: str,
|
|
74
|
+
allow_tracking: bool,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
properties = self.session_properties()
|
|
77
|
+
if bool(properties.get("has-user-setup", True)):
|
|
78
|
+
existing = self.find_user_by_email(self.admin_email)
|
|
79
|
+
if existing:
|
|
80
|
+
return {"bootstrapped": False, "user": existing}
|
|
81
|
+
return {"bootstrapped": False, "user": None}
|
|
82
|
+
|
|
83
|
+
setup_token = str(properties.get("setup-token") or "").strip()
|
|
84
|
+
if not setup_token:
|
|
85
|
+
raise HTTPException(status_code=502, detail="Metabase setup token was not returned")
|
|
86
|
+
|
|
87
|
+
payload = {
|
|
88
|
+
"token": setup_token,
|
|
89
|
+
"user": {
|
|
90
|
+
"email": self.admin_email,
|
|
91
|
+
"first_name": first_name,
|
|
92
|
+
"last_name": last_name,
|
|
93
|
+
"password": self.admin_password,
|
|
94
|
+
},
|
|
95
|
+
"prefs": {
|
|
96
|
+
"site_name": site_name,
|
|
97
|
+
"allow_tracking": allow_tracking,
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
self._raw_request("POST", "/api/setup", json=payload, expected_status={200}, auth_mode="none")
|
|
101
|
+
user = self.find_user_by_email(self.admin_email)
|
|
102
|
+
return {"bootstrapped": True, "user": user}
|
|
103
|
+
|
|
104
|
+
def _normalize_user(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
return {
|
|
106
|
+
"id": payload.get("id"),
|
|
107
|
+
"email": payload.get("email") or "",
|
|
108
|
+
"first_name": payload.get("first_name") or "",
|
|
109
|
+
"last_name": payload.get("last_name") or "",
|
|
110
|
+
"is_superuser": bool(payload.get("is_superuser")),
|
|
111
|
+
"is_active": bool(payload.get("is_active", True)),
|
|
112
|
+
"common_name": payload.get("common_name") or "",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def _request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
json: object | None = None,
|
|
121
|
+
expected_status: set[int],
|
|
122
|
+
) -> Any:
|
|
123
|
+
return self._raw_request(method, path, json=json, expected_status=expected_status, auth_mode="session")
|
|
124
|
+
|
|
125
|
+
def _raw_request(
|
|
126
|
+
self,
|
|
127
|
+
method: str,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
json: object | None = None,
|
|
131
|
+
expected_status: set[int],
|
|
132
|
+
auth_mode: str,
|
|
133
|
+
) -> Any:
|
|
134
|
+
url = f"{self.base_url}{path}"
|
|
135
|
+
headers: dict[str, str] = {}
|
|
136
|
+
if auth_mode == "session":
|
|
137
|
+
headers["X-Metabase-Session"] = self._session_id()
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
with httpx.Client(timeout=self.timeout_seconds, follow_redirects=True) as client:
|
|
141
|
+
response = client.request(method, url, json=json, headers=headers)
|
|
142
|
+
except httpx.RequestError as exc:
|
|
143
|
+
raise HTTPException(status_code=502, detail=f"Metabase request failed: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
if response.status_code not in expected_status:
|
|
146
|
+
raise HTTPException(status_code=response.status_code, detail=_metabase_error_detail(response, "Metabase request failed"))
|
|
147
|
+
|
|
148
|
+
if response.status_code == 204 or not response.text.strip():
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
return response.json()
|
|
152
|
+
except ValueError:
|
|
153
|
+
return response.text
|
|
154
|
+
|
|
155
|
+
def _session_id(self) -> str:
|
|
156
|
+
try:
|
|
157
|
+
with httpx.Client(timeout=self.timeout_seconds, follow_redirects=True) as client:
|
|
158
|
+
response = client.post(
|
|
159
|
+
f"{self.base_url}/api/session",
|
|
160
|
+
json={"username": self.admin_email, "password": self.admin_password},
|
|
161
|
+
)
|
|
162
|
+
except httpx.RequestError as exc:
|
|
163
|
+
raise HTTPException(status_code=502, detail=f"Metabase session request failed: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
if response.status_code >= 400:
|
|
166
|
+
raise HTTPException(status_code=response.status_code, detail=_metabase_error_detail(response, "Metabase session request failed"))
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
payload = response.json()
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
raise HTTPException(status_code=502, detail="Metabase session response was not valid JSON") from exc
|
|
172
|
+
|
|
173
|
+
session_id = payload.get("id")
|
|
174
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
175
|
+
raise HTTPException(status_code=502, detail="Metabase session response did not include an id")
|
|
176
|
+
return session_id
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _metabase_error_detail(response: httpx.Response, fallback: str) -> str:
|
|
180
|
+
try:
|
|
181
|
+
payload = response.json()
|
|
182
|
+
except ValueError:
|
|
183
|
+
payload = None
|
|
184
|
+
if isinstance(payload, dict):
|
|
185
|
+
detail = payload.get("message") or payload.get("errors") or payload.get("error")
|
|
186
|
+
if isinstance(detail, str) and detail.strip():
|
|
187
|
+
return detail
|
|
188
|
+
if isinstance(detail, dict):
|
|
189
|
+
return str(detail)
|
|
190
|
+
text = response.text.strip()
|
|
191
|
+
return text or fallback
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException
|
|
7
|
+
|
|
8
|
+
from .config import settings
|
|
9
|
+
from .metabase_admin import MetabaseAdminClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
client = MetabaseAdminClient(
|
|
14
|
+
base_url=settings.metabase_api_url,
|
|
15
|
+
admin_email=settings.metabase_admin_email,
|
|
16
|
+
admin_password=settings.metabase_admin_password,
|
|
17
|
+
timeout_seconds=max(settings.nilo_timeout_seconds, 30.0),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
deadline = time.monotonic() + 180
|
|
21
|
+
last_error = "Metabase did not become ready"
|
|
22
|
+
while time.monotonic() < deadline:
|
|
23
|
+
try:
|
|
24
|
+
outcome = client.ensure_initial_admin(
|
|
25
|
+
first_name=settings.metabase_admin_first_name,
|
|
26
|
+
last_name=settings.metabase_admin_last_name,
|
|
27
|
+
site_name=settings.metabase_site_name,
|
|
28
|
+
allow_tracking=settings.metabase_allow_tracking,
|
|
29
|
+
)
|
|
30
|
+
state = "bootstrapped" if outcome.get("bootstrapped") else "already_configured"
|
|
31
|
+
print(f"metabase_bootstrap={state}")
|
|
32
|
+
return 0
|
|
33
|
+
except HTTPException as exc:
|
|
34
|
+
last_error = str(exc.detail)
|
|
35
|
+
except Exception as exc: # pragma: no cover - defensive bootstrap path
|
|
36
|
+
last_error = str(exc)
|
|
37
|
+
time.sleep(3)
|
|
38
|
+
|
|
39
|
+
print(f"metabase_bootstrap_failed={last_error}", file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_signed_dashboard_url(site_url: str, embed_secret: str, dashboard_id: int, params: dict[str, object] | None = None) -> str:
|
|
9
|
+
payload = {
|
|
10
|
+
"resource": {"dashboard": int(dashboard_id)},
|
|
11
|
+
"params": params or {},
|
|
12
|
+
"exp": int(time.time()) + (10 * 60),
|
|
13
|
+
}
|
|
14
|
+
token = jwt.encode(payload, embed_secret, algorithm="HS256")
|
|
15
|
+
return f"{site_url}/embed/dashboard/{token}#bordered=true&titled=true"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from croniter import croniter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _pipeline_slug(value: str) -> str:
|
|
9
|
+
normalized = "".join(char.lower() if char.isalnum() else "_" for char in value.strip())
|
|
10
|
+
collapsed = "_".join(part for part in normalized.split("_") if part)
|
|
11
|
+
for prefix in ("pnp_pipe_", "pipe_", "pnp_"):
|
|
12
|
+
if collapsed.startswith(prefix):
|
|
13
|
+
collapsed = collapsed[len(prefix):]
|
|
14
|
+
break
|
|
15
|
+
if collapsed.startswith("pnp_"):
|
|
16
|
+
collapsed = collapsed[len("pnp_"):]
|
|
17
|
+
return collapsed or "pipeline"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_pipeline_dag_id(instance_key: str, pipeline_id: str | None = None) -> str:
|
|
21
|
+
token = ""
|
|
22
|
+
if pipeline_id:
|
|
23
|
+
token = pipeline_id.replace("-", "").lower()[:8]
|
|
24
|
+
slug = _pipeline_slug(instance_key)
|
|
25
|
+
return f"{slug}_{token}_sync" if token else f"{slug}_sync"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _legacy_pipeline_dag_id(instance_key: str) -> str:
|
|
29
|
+
normalized = "".join(char.lower() if char.isalnum() else "_" for char in instance_key.strip())
|
|
30
|
+
collapsed = "_".join(part for part in normalized.split("_") if part)
|
|
31
|
+
return f"pnp_pipeline__{collapsed or 'instance'}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_generated_dags_dir() -> Path:
|
|
35
|
+
resolved = Path(__file__).resolve()
|
|
36
|
+
repo_root_candidate = next(
|
|
37
|
+
(parent for parent in resolved.parents if (parent / "pipelines" / "airflow" / "dags").exists()),
|
|
38
|
+
None,
|
|
39
|
+
)
|
|
40
|
+
candidates = [Path("/app/pipelines/airflow/dags/generated")]
|
|
41
|
+
if repo_root_candidate is not None:
|
|
42
|
+
candidates.append(repo_root_candidate / "pipelines" / "airflow" / "dags" / "generated")
|
|
43
|
+
for candidate in candidates:
|
|
44
|
+
try:
|
|
45
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return candidate
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
raise RuntimeError("PNP generated DAG directory is not writable")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dag_file_path(instance_key: str, pipeline_id: str | None = None) -> Path:
|
|
53
|
+
return _resolve_generated_dags_dir() / f"{build_pipeline_dag_id(instance_key, pipeline_id)}.py"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _legacy_dag_file_path(instance_key: str) -> Path:
|
|
57
|
+
return _resolve_generated_dags_dir() / f"{_legacy_pipeline_dag_id(instance_key)}.py"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_airflow_schedule(schedule: str | None, *, is_active: bool) -> str | None:
|
|
61
|
+
if not is_active or not schedule or not schedule.strip():
|
|
62
|
+
return None
|
|
63
|
+
normalized = schedule.strip()
|
|
64
|
+
if not croniter.is_valid(normalized):
|
|
65
|
+
return None
|
|
66
|
+
return normalized
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_pipeline_dag_file(*, dag_id: str, instance_key: str, pipeline_id: str, schedule: str | None, is_active: bool) -> str:
|
|
70
|
+
normalized_schedule = _normalize_airflow_schedule(schedule, is_active=is_active)
|
|
71
|
+
schedule_literal = repr(normalized_schedule) if normalized_schedule else "None"
|
|
72
|
+
return f"""from dataif_pipelines.airflow.pnp_pipeline_factory import build_pipeline_dag
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
dag = build_pipeline_dag(
|
|
76
|
+
dag_id={dag_id!r},
|
|
77
|
+
pipeline_id={pipeline_id!r},
|
|
78
|
+
instance_key={instance_key!r},
|
|
79
|
+
schedule={schedule_literal},
|
|
80
|
+
)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def provision_pipeline_dag(*, pipeline_id: str, instance_key: str, schedule: str | None, is_active: bool) -> str:
|
|
85
|
+
dag_id = build_pipeline_dag_id(instance_key, pipeline_id)
|
|
86
|
+
legacy_target = _legacy_dag_file_path(instance_key)
|
|
87
|
+
if legacy_target.exists():
|
|
88
|
+
legacy_target.unlink()
|
|
89
|
+
target = _dag_file_path(instance_key, pipeline_id)
|
|
90
|
+
target.write_text(
|
|
91
|
+
render_pipeline_dag_file(
|
|
92
|
+
dag_id=dag_id,
|
|
93
|
+
pipeline_id=pipeline_id,
|
|
94
|
+
instance_key=instance_key,
|
|
95
|
+
schedule=schedule,
|
|
96
|
+
is_active=is_active,
|
|
97
|
+
),
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
if not target.exists():
|
|
101
|
+
raise RuntimeError(f"Failed to provision DAG file for pipeline {instance_key}")
|
|
102
|
+
return dag_id
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def remove_pipeline_dag(*, instance_key: str, pipeline_id: str | None = None) -> str:
|
|
106
|
+
dag_id = build_pipeline_dag_id(instance_key, pipeline_id)
|
|
107
|
+
target = _dag_file_path(instance_key, pipeline_id)
|
|
108
|
+
if target.exists():
|
|
109
|
+
target.unlink()
|
|
110
|
+
legacy_target = _legacy_dag_file_path(instance_key)
|
|
111
|
+
if legacy_target.exists():
|
|
112
|
+
legacy_target.unlink()
|
|
113
|
+
return dag_id
|