@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.
Files changed (183) hide show
  1. package/README.md +16 -0
  2. package/bin/dataif.js +623 -0
  3. package/package.json +26 -0
  4. package/scripts/build-template.mjs +72 -0
  5. package/templates/dataif/README.md +157 -0
  6. package/templates/dataif/infra/.env.example +119 -0
  7. package/templates/dataif/infra/.env.stg.example +119 -0
  8. package/templates/dataif/infra/airflow/Dockerfile +11 -0
  9. package/templates/dataif/infra/airflow/Dockerfile.release +17 -0
  10. package/templates/dataif/infra/airflow/requirements.txt +3 -0
  11. package/templates/dataif/infra/docker-compose.yml +306 -0
  12. package/templates/dataif/infra/init-db/01-init-dataif.sh +129 -0
  13. package/templates/dataif/infra/init-db/pnp-curated-views.sqlinc +444 -0
  14. package/templates/dataif/infra/init-db/pnp-raw-staging-curated.sqlinc +701 -0
  15. package/templates/dataif/infra/keycloak/Dockerfile +4 -0
  16. package/templates/dataif/infra/keycloak/realm-dataif.json +73 -0
  17. package/templates/dataif/infra/ollama/Dockerfile +9 -0
  18. package/templates/dataif/infra/ollama/bootstrap-model.sh +100 -0
  19. package/templates/dataif/infra/ollama/sabia-7b.Modelfile +14 -0
  20. package/templates/dataif/infra/postgres/Dockerfile +4 -0
  21. package/templates/dataif/pipelines/airflow/dags/generated/.gitkeep +1 -0
  22. package/templates/dataif/pipelines/airflow/dags/generated/2020_financeiro_fcc6f1f3_sync.py +9 -0
  23. package/templates/dataif/pipelines/dataif_pipelines/__init__.py +1 -0
  24. package/templates/dataif/pipelines/dataif_pipelines/airflow/__init__.py +1 -0
  25. package/templates/dataif/pipelines/dataif_pipelines/airflow/pnp_pipeline_factory.py +167 -0
  26. package/templates/dataif/pipelines/dataif_pipelines/connectors/__init__.py +1 -0
  27. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/__init__.py +1 -0
  28. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/connector.py +28 -0
  29. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/types.py +14 -0
  30. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/__init__.py +1 -0
  31. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/config.py +19 -0
  32. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/connector.py +558 -0
  33. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/powerbi_microdados.py +728 -0
  34. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/transform.py +296 -0
  35. package/templates/dataif/pipelines/dataif_pipelines/jobs/__init__.py +1 -0
  36. package/templates/dataif/pipelines/dataif_pipelines/jobs/nilo_pipeline.py +112 -0
  37. package/templates/dataif/pipelines/dataif_pipelines/orchestration/__init__.py +21 -0
  38. package/templates/dataif/pipelines/dataif_pipelines/orchestration/pnp_workflow.py +783 -0
  39. package/templates/dataif/pipelines/dataif_pipelines/repositories/__init__.py +1 -0
  40. package/templates/dataif/pipelines/dataif_pipelines/repositories/pnp_raw_repository.py +860 -0
  41. package/templates/dataif/pipelines/dataif_pipelines/services/__init__.py +19 -0
  42. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_curated_service.py +66 -0
  43. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_download_service.py +534 -0
  44. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_quality_service.py +9 -0
  45. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_raw_ingestion_service.py +124 -0
  46. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_staging_service.py +271 -0
  47. package/templates/dataif/pipelines/dataif_pipelines/services/powerbi_catalog_service.py +159 -0
  48. package/templates/dataif/pipelines/sql/staging/020_pnp_matriculas.sql +112 -0
  49. package/templates/dataif/pipelines/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  50. package/templates/dataif/pipelines/sql/staging/040_pnp_servidores.sql +90 -0
  51. package/templates/dataif/pipelines/sql/staging/050_pnp_financeiro.sql +72 -0
  52. package/templates/dataif/pipelines/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  53. package/templates/dataif/pipelines/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  54. package/templates/dataif/pipelines/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  55. package/templates/dataif/pipelines/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  56. package/templates/dataif/pipelines/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  57. package/templates/dataif/pipelines/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  58. package/templates/dataif/pipelines/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  59. package/templates/dataif/pipelines/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
  60. package/templates/dataif/scripts/configure-env.sh +149 -0
  61. package/templates/dataif/scripts/create_metabase_pnp_dashboard.py +943 -0
  62. package/templates/dataif/scripts/create_metabase_pnp_matriculas_dashboard.py +580 -0
  63. package/templates/dataif/scripts/deploy.sh +79 -0
  64. package/templates/dataif/scripts/fix_metabase_template_tag_ids.py +91 -0
  65. package/templates/dataif/scripts/pnp_powerbi_microdados_probe.py +14 -0
  66. package/templates/dataif/scripts/pnp_validate_raw_run.py +330 -0
  67. package/templates/dataif/scripts/publish-images.sh +31 -0
  68. package/templates/dataif/scripts/sync_metabase_dashboard_field_filters.py +241 -0
  69. package/templates/dataif/scripts/use-vanna-ollama.sh +139 -0
  70. package/templates/dataif/services/api/.dockerignore +18 -0
  71. package/templates/dataif/services/api/Dockerfile +12 -0
  72. package/templates/dataif/services/api/app/__init__.py +1 -0
  73. package/templates/dataif/services/api/app/auth.py +48 -0
  74. package/templates/dataif/services/api/app/config.py +59 -0
  75. package/templates/dataif/services/api/app/keycloak_admin.py +215 -0
  76. package/templates/dataif/services/api/app/main.py +2432 -0
  77. package/templates/dataif/services/api/app/metabase_admin.py +191 -0
  78. package/templates/dataif/services/api/app/metabase_bootstrap.py +44 -0
  79. package/templates/dataif/services/api/app/metabase_embed.py +15 -0
  80. package/templates/dataif/services/api/app/pnp_dag_provisioner.py +113 -0
  81. package/templates/dataif/services/api/app/pnp_instance_repository.py +951 -0
  82. package/templates/dataif/services/api/app/pnp_powerbi.py +438 -0
  83. package/templates/dataif/services/api/app/vanna_client.py +32 -0
  84. package/templates/dataif/services/api/requirements.txt +9 -0
  85. package/templates/dataif/services/vanna/.dockerignore +18 -0
  86. package/templates/dataif/services/vanna/Dockerfile +12 -0
  87. package/templates/dataif/services/vanna/app/config.py +57 -0
  88. package/templates/dataif/services/vanna/app/main.py +108 -0
  89. package/templates/dataif/services/vanna/app/runtime_config.py +114 -0
  90. package/templates/dataif/services/vanna/app/sql_guard.py +123 -0
  91. package/templates/dataif/services/vanna/app/vanna_engine.py +382 -0
  92. package/templates/dataif/services/vanna/requirements.txt +8 -0
  93. package/templates/dataif/services/web/.dockerignore +13 -0
  94. package/templates/dataif/services/web/Dockerfile +16 -0
  95. package/templates/dataif/services/web/index.html +12 -0
  96. package/templates/dataif/services/web/nginx.conf +74 -0
  97. package/templates/dataif/services/web/package-lock.json +4397 -0
  98. package/templates/dataif/services/web/package.json +32 -0
  99. package/templates/dataif/services/web/postcss.config.mjs +5 -0
  100. package/templates/dataif/services/web/src/App.jsx +2817 -0
  101. package/templates/dataif/services/web/src/adminAuth.js +245 -0
  102. package/templates/dataif/services/web/src/assets/avatar_placeholder.png +0 -0
  103. package/templates/dataif/services/web/src/assets/github_logo_icon_229278.svg +1 -0
  104. package/templates/dataif/services/web/src/assets/if-logo.png +0 -0
  105. package/templates/dataif/services/web/src/assets/if.svg +0 -0
  106. package/templates/dataif/services/web/src/assets/pnp-horizontal.svg +1 -0
  107. package/templates/dataif/services/web/src/components/AppHeader.jsx +233 -0
  108. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/mobile-header.tsx +56 -0
  109. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-account-card.tsx +209 -0
  110. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item-button.tsx +67 -0
  111. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item.tsx +108 -0
  112. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-list.tsx +83 -0
  113. package/templates/dataif/services/web/src/components/application/app-navigation/config.ts +23 -0
  114. package/templates/dataif/services/web/src/components/application/app-navigation/header-navigation.tsx +240 -0
  115. package/templates/dataif/services/web/src/components/application/pagination/pagination-base.tsx +376 -0
  116. package/templates/dataif/services/web/src/components/application/pagination/pagination-dot.tsx +52 -0
  117. package/templates/dataif/services/web/src/components/application/pagination/pagination-line.tsx +48 -0
  118. package/templates/dataif/services/web/src/components/application/pagination/pagination.tsx +328 -0
  119. package/templates/dataif/services/web/src/components/application/tabs/tabs.tsx +223 -0
  120. package/templates/dataif/services/web/src/components/base/avatar/avatar-label-group.tsx +28 -0
  121. package/templates/dataif/services/web/src/components/base/avatar/avatar.tsx +129 -0
  122. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-add-button.tsx +32 -0
  123. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-company-icon.tsx +24 -0
  124. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-online-indicator.tsx +29 -0
  125. package/templates/dataif/services/web/src/components/base/avatar/base-components/index.tsx +4 -0
  126. package/templates/dataif/services/web/src/components/base/avatar/base-components/verified-tick.tsx +32 -0
  127. package/templates/dataif/services/web/src/components/base/badges/badge-types.ts +264 -0
  128. package/templates/dataif/services/web/src/components/base/badges/badges.tsx +415 -0
  129. package/templates/dataif/services/web/src/components/base/button-group/button-group.tsx +104 -0
  130. package/templates/dataif/services/web/src/components/base/buttons/button.tsx +267 -0
  131. package/templates/dataif/services/web/src/components/base/input/hint-text.tsx +31 -0
  132. package/templates/dataif/services/web/src/components/base/input/input.tsx +269 -0
  133. package/templates/dataif/services/web/src/components/base/input/label.tsx +48 -0
  134. package/templates/dataif/services/web/src/components/base/radio-buttons/radio-buttons.tsx +127 -0
  135. package/templates/dataif/services/web/src/components/base/select/combobox.tsx +150 -0
  136. package/templates/dataif/services/web/src/components/base/select/multi-select.tsx +361 -0
  137. package/templates/dataif/services/web/src/components/base/select/popover.tsx +32 -0
  138. package/templates/dataif/services/web/src/components/base/select/select-item.tsx +95 -0
  139. package/templates/dataif/services/web/src/components/base/select/select-native.tsx +67 -0
  140. package/templates/dataif/services/web/src/components/base/select/select.tsx +144 -0
  141. package/templates/dataif/services/web/src/components/base/tags/base-components/tag-close-x.tsx +32 -0
  142. package/templates/dataif/services/web/src/components/base/tooltip/tooltip.tsx +107 -0
  143. package/templates/dataif/services/web/src/components/foundations/dot-icon.tsx +22 -0
  144. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo-minimal.tsx +170 -0
  145. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo.tsx +58 -0
  146. package/templates/dataif/services/web/src/hooks/use-breakpoint.ts +34 -0
  147. package/templates/dataif/services/web/src/hooks/use-resize-observer.ts +67 -0
  148. package/templates/dataif/services/web/src/main.jsx +14 -0
  149. package/templates/dataif/services/web/src/providers/theme-provider.jsx +62 -0
  150. package/templates/dataif/services/web/src/styles/globals.css +60 -0
  151. package/templates/dataif/services/web/src/styles/theme.css +1326 -0
  152. package/templates/dataif/services/web/src/styles/typography.css +430 -0
  153. package/templates/dataif/services/web/src/styles.css +1287 -0
  154. package/templates/dataif/services/web/src/utils/cx.ts +24 -0
  155. package/templates/dataif/services/web/src/utils/is-react-component.ts +33 -0
  156. package/templates/dataif/services/web/vite.config.js +14 -0
  157. package/templates/dataif/sql/ddl/001_schemas.sql +6 -0
  158. package/templates/dataif/sql/ddl/003_pnp_raw_staging_curated.sql +699 -0
  159. package/templates/dataif/sql/migrations/001_pnp_phase1_backfill.sql +3 -0
  160. package/templates/dataif/sql/migrations/002_pnp_phase2_admin_config_backfill.sql +184 -0
  161. package/templates/dataif/sql/migrations/003_pnp_phase3_raw_tabular_backfill.sql +3 -0
  162. package/templates/dataif/sql/migrations/004_pnp_phase3_raw_backfill_support_index.sql +3 -0
  163. package/templates/dataif/sql/migrations/005_pnp_phase7_staging_support_indexes.sql +2 -0
  164. package/templates/dataif/sql/migrations/006_pnp_phase7_staging_autovacuum_tuning.sql +2 -0
  165. package/templates/dataif/sql/migrations/007_pnp_phase7b_run_packages.sql +20 -0
  166. package/templates/dataif/sql/migrations/008_pnp_phase7a_pipeline_endpoints.sql +169 -0
  167. package/templates/dataif/sql/migrations/009_pnp_phase8_curated.sql +35 -0
  168. package/templates/dataif/sql/migrations/010_pnp_phase10_staging_incremental_upsert.sql +3 -0
  169. package/templates/dataif/sql/migrations/010_pnp_pipeline_uuid.sql +51 -0
  170. package/templates/dataif/sql/migrations/011_app_settings.sql +7 -0
  171. package/templates/dataif/sql/staging/020_pnp_matriculas.sql +112 -0
  172. package/templates/dataif/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  173. package/templates/dataif/sql/staging/040_pnp_servidores.sql +90 -0
  174. package/templates/dataif/sql/staging/050_pnp_financeiro.sql +72 -0
  175. package/templates/dataif/sql/views_curated/003_vw_pnp_microdados_admin.sql +160 -0
  176. package/templates/dataif/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  177. package/templates/dataif/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  178. package/templates/dataif/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  179. package/templates/dataif/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  180. package/templates/dataif/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  181. package/templates/dataif/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  182. package/templates/dataif/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  183. package/templates/dataif/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
@@ -0,0 +1,580 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import random
7
+ import string
8
+ from dataclasses import dataclass
9
+
10
+ import psycopg2
11
+ from psycopg2.extras import RealDictCursor
12
+
13
+
14
+ APP_DB_HOST = os.getenv("METABASE_APP_DB_HOST", "localhost")
15
+ APP_DB_PORT = int(os.getenv("METABASE_APP_DB_PORT", "5433"))
16
+ APP_DB_NAME = os.getenv("METABASE_APP_DB_NAME", "metabaseapp")
17
+ APP_DB_USER = os.getenv("METABASE_APP_DB_USER", "postgres")
18
+ APP_DB_PASSWORD = os.getenv("METABASE_APP_DB_PASSWORD", "postgres")
19
+
20
+ TARGET_YEAR = int(os.getenv("PNP_TARGET_YEAR", "2024"))
21
+ DATABASE_ID = int(os.getenv("METABASE_DATABASE_ID", "2"))
22
+ SOURCE_SCHEMA = os.getenv("PNP_SOURCE_SCHEMA", "curated")
23
+ SOURCE_TABLE = os.getenv("PNP_SOURCE_TABLE", "mv_pnp_dashboard_matriculas")
24
+ SOURCE_TABLE_ID = int(os.getenv("METABASE_SOURCE_TABLE_ID", "35"))
25
+ COLLECTION_ID = int(os.getenv("METABASE_COLLECTION_ID", "5"))
26
+ CREATOR_ID = int(os.getenv("METABASE_CREATOR_ID", "1"))
27
+
28
+ DASHBOARD_NAME = os.getenv(
29
+ "METABASE_DASHBOARD_NAME",
30
+ f"PNP {TARGET_YEAR} - Painel de Matrículas",
31
+ )
32
+ DASHBOARD_DESCRIPTION = (
33
+ f"Painel temático de matrículas da PNP {TARGET_YEAR}, com indicadores de oferta, procura "
34
+ "e composição do corpo discente a partir da view curada de matrículas."
35
+ )
36
+
37
+ SOURCE_FQN = f"{SOURCE_SCHEMA}.{SOURCE_TABLE}"
38
+
39
+ FILTER_SPECS = [
40
+ ("instituicao", "Instituição", "string/="),
41
+ ("regiao", "Região", "string/="),
42
+ ("uf", "UF", "string/="),
43
+ ("municipio", "Município", "string/contains"),
44
+ ("sexo", "Sexo", "string/="),
45
+ ("cor_raca", "Cor / Raça", "string/="),
46
+ ("renda_familiar", "Renda Familiar", "string/="),
47
+ ("faixa_etaria", "Faixa Etária", "string/="),
48
+ ("situacao_matricula", "Situação de Matrícula", "string/="),
49
+ ("modalidade_ensino", "Modalidade de Ensino", "string/="),
50
+ ("tipo_curso", "Tipo de Curso", "string/="),
51
+ ("tipo_oferta", "Tipo de Oferta", "string/="),
52
+ ("turno", "Turno", "string/="),
53
+ ("nome_curso", "Nome do Curso", "string/contains"),
54
+ ]
55
+
56
+ FILTER_SLUGS = tuple(slug for slug, _, _ in FILTER_SPECS)
57
+
58
+
59
+ def random_entity_id(size: int = 21) -> str:
60
+ alphabet = string.ascii_letters + string.digits
61
+ return "".join(random.choice(alphabet) for _ in range(size))
62
+
63
+
64
+ def stable_id(seed: str) -> str:
65
+ return hashlib.md5(seed.encode("utf-8")).hexdigest()[:12]
66
+
67
+
68
+ def json_text(value: object) -> str:
69
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
70
+
71
+
72
+ def fixed_year_where() -> str:
73
+ parts = [f"WHERE ano = {TARGET_YEAR}"]
74
+ for slug in FILTER_SLUGS:
75
+ parts.append(f"[[ AND {{{{{slug}}}}} ]]")
76
+ return "\n".join(parts)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class CardDef:
81
+ name: str
82
+ description: str
83
+ row: int
84
+ col: int
85
+ size_x: int
86
+ size_y: int
87
+ sql: str
88
+ display: str
89
+ visualization_settings: dict
90
+ filters: tuple[str, ...] = FILTER_SLUGS
91
+
92
+
93
+ CARDS = [
94
+ CardDef(
95
+ name=f"PNP {TARGET_YEAR} - KPI Matriculas",
96
+ description=f"Total de matrículas no ano de {TARGET_YEAR}.",
97
+ row=0,
98
+ col=0,
99
+ size_x=6,
100
+ size_y=4,
101
+ sql=f"""
102
+ SELECT COALESCE(SUM(matriculas), 0) AS matriculas
103
+ FROM {SOURCE_FQN}
104
+ {fixed_year_where()}
105
+ """.strip(),
106
+ display="scalar",
107
+ visualization_settings={},
108
+ ),
109
+ CardDef(
110
+ name=f"PNP {TARGET_YEAR} - KPI Inscritos",
111
+ description=f"Total de inscritos no ano de {TARGET_YEAR}.",
112
+ row=0,
113
+ col=6,
114
+ size_x=6,
115
+ size_y=4,
116
+ sql=f"""
117
+ SELECT COALESCE(SUM(inscritos), 0) AS inscritos
118
+ FROM {SOURCE_FQN}
119
+ {fixed_year_where()}
120
+ """.strip(),
121
+ display="scalar",
122
+ visualization_settings={},
123
+ ),
124
+ CardDef(
125
+ name=f"PNP {TARGET_YEAR} - KPI Vagas Ofertadas",
126
+ description=f"Total de vagas ofertadas no ano de {TARGET_YEAR}.",
127
+ row=0,
128
+ col=12,
129
+ size_x=6,
130
+ size_y=4,
131
+ sql=f"""
132
+ SELECT COALESCE(SUM(vagas_ofertadas), 0) AS vagas_ofertadas
133
+ FROM {SOURCE_FQN}
134
+ {fixed_year_where()}
135
+ """.strip(),
136
+ display="scalar",
137
+ visualization_settings={},
138
+ ),
139
+ CardDef(
140
+ name=f"PNP {TARGET_YEAR} - KPI Relacao Inscritos por Vaga",
141
+ description=f"Relação entre inscritos e vagas ofertadas no ano de {TARGET_YEAR}.",
142
+ row=0,
143
+ col=18,
144
+ size_x=6,
145
+ size_y=4,
146
+ sql=f"""
147
+ SELECT ROUND(
148
+ COALESCE(SUM(inscritos), 0) / NULLIF(COALESCE(SUM(vagas_ofertadas), 0), 0),
149
+ 2
150
+ ) AS inscritos_por_vaga
151
+ FROM {SOURCE_FQN}
152
+ {fixed_year_where()}
153
+ """.strip(),
154
+ display="scalar",
155
+ visualization_settings={},
156
+ ),
157
+ CardDef(
158
+ name=f"PNP {TARGET_YEAR} - Matriculas por Situacao",
159
+ description=f"Distribuição das matrículas por situação em {TARGET_YEAR}.",
160
+ row=4,
161
+ col=0,
162
+ size_x=10,
163
+ size_y=7,
164
+ sql=f"""
165
+ SELECT
166
+ situacao_matricula,
167
+ SUM(matriculas) AS matriculas
168
+ FROM {SOURCE_FQN}
169
+ {fixed_year_where()}
170
+ GROUP BY situacao_matricula
171
+ ORDER BY matriculas DESC NULLS LAST
172
+ """.strip(),
173
+ display="bar",
174
+ visualization_settings={
175
+ "graph.dimensions": ["situacao_matricula"],
176
+ "graph.metrics": ["matriculas"],
177
+ },
178
+ ),
179
+ CardDef(
180
+ name=f"PNP {TARGET_YEAR} - Matriculas por Sexo",
181
+ description=f"Distribuição das matrículas por sexo em {TARGET_YEAR}.",
182
+ row=4,
183
+ col=10,
184
+ size_x=6,
185
+ size_y=7,
186
+ sql=f"""
187
+ SELECT
188
+ sexo,
189
+ SUM(matriculas) AS matriculas
190
+ FROM {SOURCE_FQN}
191
+ {fixed_year_where()}
192
+ GROUP BY sexo
193
+ ORDER BY matriculas DESC NULLS LAST
194
+ """.strip(),
195
+ display="pie",
196
+ visualization_settings={
197
+ "graph.dimensions": ["sexo"],
198
+ "graph.metrics": ["matriculas"],
199
+ },
200
+ ),
201
+ CardDef(
202
+ name=f"PNP {TARGET_YEAR} - Matriculas por Tipo de Curso",
203
+ description=f"Distribuição das matrículas por tipo de curso em {TARGET_YEAR}.",
204
+ row=4,
205
+ col=16,
206
+ size_x=8,
207
+ size_y=7,
208
+ sql=f"""
209
+ SELECT
210
+ tipo_curso,
211
+ SUM(matriculas) AS matriculas
212
+ FROM {SOURCE_FQN}
213
+ {fixed_year_where()}
214
+ GROUP BY tipo_curso
215
+ ORDER BY matriculas DESC NULLS LAST
216
+ """.strip(),
217
+ display="row",
218
+ visualization_settings={
219
+ "graph.dimensions": ["tipo_curso"],
220
+ "graph.metrics": ["matriculas"],
221
+ },
222
+ ),
223
+ CardDef(
224
+ name=f"PNP {TARGET_YEAR} - Oferta por Curso",
225
+ description=f"Oferta, procura e matrículas por curso no ano de {TARGET_YEAR}.",
226
+ row=11,
227
+ col=0,
228
+ size_x=24,
229
+ size_y=8,
230
+ sql=f"""
231
+ SELECT
232
+ nome_curso,
233
+ tipo_curso,
234
+ modalidade_ensino,
235
+ tipo_oferta,
236
+ turno,
237
+ SUM(vagas_ofertadas) AS vagas_ofertadas,
238
+ SUM(inscritos) AS inscritos,
239
+ SUM(matriculas) AS matriculas,
240
+ ROUND(
241
+ COALESCE(SUM(inscritos), 0) / NULLIF(COALESCE(SUM(vagas_ofertadas), 0), 0),
242
+ 2
243
+ ) AS inscritos_por_vaga
244
+ FROM {SOURCE_FQN}
245
+ {fixed_year_where()}
246
+ GROUP BY nome_curso, tipo_curso, modalidade_ensino, tipo_oferta, turno
247
+ ORDER BY matriculas DESC NULLS LAST, inscritos DESC NULLS LAST
248
+ LIMIT 25
249
+ """.strip(),
250
+ display="table",
251
+ visualization_settings={},
252
+ ),
253
+ ]
254
+
255
+
256
+ class Publisher:
257
+ def __init__(self) -> None:
258
+ self.conn = psycopg2.connect(
259
+ host=APP_DB_HOST,
260
+ port=APP_DB_PORT,
261
+ dbname=APP_DB_NAME,
262
+ user=APP_DB_USER,
263
+ password=APP_DB_PASSWORD,
264
+ )
265
+ self.conn.autocommit = False
266
+
267
+ def close(self) -> None:
268
+ self.conn.close()
269
+
270
+ def fetchone(self, sql: str, params: tuple | None = None) -> dict | None:
271
+ with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
272
+ cur.execute(sql, params)
273
+ return cur.fetchone()
274
+
275
+ def fetchall(self, sql: str, params: tuple | None = None) -> list[dict]:
276
+ with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
277
+ cur.execute(sql, params)
278
+ return list(cur.fetchall())
279
+
280
+ def execute(self, sql: str, params: tuple | None = None) -> None:
281
+ with self.conn.cursor() as cur:
282
+ cur.execute(sql, params)
283
+
284
+ def insert_returning_id(self, sql: str, params: tuple) -> int:
285
+ with self.conn.cursor() as cur:
286
+ cur.execute(sql, params)
287
+ row = cur.fetchone()
288
+ if row is None:
289
+ raise RuntimeError("Insert did not return an id")
290
+ return int(row[0])
291
+
292
+ def field_map(self) -> dict[str, int]:
293
+ rows = self.fetchall(
294
+ """
295
+ SELECT f.name, f.id
296
+ FROM metabase_field f
297
+ JOIN metabase_table t ON t.id = f.table_id
298
+ WHERE t.id = %s
299
+ ORDER BY f.id
300
+ """,
301
+ (SOURCE_TABLE_ID,),
302
+ )
303
+ return {row["name"]: int(row["id"]) for row in rows}
304
+
305
+ def template_tags(self, card: CardDef, field_map: dict[str, int]) -> dict:
306
+ tags = {}
307
+ for slug in card.filters:
308
+ label = next(label for current_slug, label, _ in FILTER_SPECS if current_slug == slug)
309
+ widget = next(widget for current_slug, _, widget in FILTER_SPECS if current_slug == slug)
310
+ tag = {
311
+ "id": stable_id(f"{card.name}:{slug}"),
312
+ "name": slug,
313
+ "display-name": label,
314
+ "type": "dimension",
315
+ "widget-type": widget,
316
+ "required": False,
317
+ "dimension": ["field", field_map[slug], None],
318
+ }
319
+ if widget == "string/contains":
320
+ tag["options"] = {"case-sensitive": False}
321
+ tags[slug] = tag
322
+ return tags
323
+
324
+ def card_parameters(self, template_tags: dict) -> list[dict]:
325
+ parameters = []
326
+ for slug, tag in template_tags.items():
327
+ parameter = {
328
+ "id": tag["id"],
329
+ "type": tag["widget-type"],
330
+ "target": ["dimension", ["template-tag", slug]],
331
+ "name": tag["display-name"],
332
+ "slug": slug,
333
+ "required": False,
334
+ "isMultiSelect": True,
335
+ }
336
+ if "options" in tag:
337
+ parameter["options"] = tag["options"]
338
+ parameters.append(parameter)
339
+ return parameters
340
+
341
+ def dashboard_parameters(self) -> list[dict]:
342
+ return [
343
+ {
344
+ "id": f"p_{slug}",
345
+ "name": label,
346
+ "slug": slug,
347
+ "type": widget,
348
+ "sectionId": "number" if widget.startswith("number") else "string",
349
+ }
350
+ for slug, label, widget in FILTER_SPECS
351
+ ]
352
+
353
+ def dashcard_parameter_mappings(self, card: CardDef) -> list[dict]:
354
+ return [
355
+ {
356
+ "parameter_id": f"p_{slug}",
357
+ "card_id": None,
358
+ "target": ["dimension", ["template-tag", slug], {"stage-number": 0}],
359
+ }
360
+ for slug in card.filters
361
+ ]
362
+
363
+ def archive_existing_cards(self) -> None:
364
+ self.execute(
365
+ """
366
+ UPDATE report_card
367
+ SET archived = TRUE,
368
+ archived_directly = TRUE,
369
+ updated_at = NOW()
370
+ WHERE name = ANY(%s)
371
+ """,
372
+ ([card.name for card in CARDS],),
373
+ )
374
+
375
+ def delete_existing_dashboard(self) -> None:
376
+ dashboard = self.fetchone(
377
+ "SELECT id FROM report_dashboard WHERE name = %s AND archived = FALSE ORDER BY id DESC LIMIT 1",
378
+ (DASHBOARD_NAME,),
379
+ )
380
+ if not dashboard:
381
+ return
382
+
383
+ dashboard_id = int(dashboard["id"])
384
+ self.execute("DELETE FROM report_dashboardcard WHERE dashboard_id = %s", (dashboard_id,))
385
+ self.execute("DELETE FROM dashboard_tab WHERE dashboard_id = %s", (dashboard_id,))
386
+ self.execute("DELETE FROM report_dashboard WHERE id = %s", (dashboard_id,))
387
+
388
+ def create_dashboard(self) -> int:
389
+ return self.insert_returning_id(
390
+ """
391
+ INSERT INTO report_dashboard (
392
+ created_at,
393
+ updated_at,
394
+ name,
395
+ description,
396
+ creator_id,
397
+ parameters,
398
+ archived,
399
+ collection_id,
400
+ entity_id,
401
+ auto_apply_filters,
402
+ width,
403
+ last_viewed_at
404
+ ) VALUES (
405
+ NOW(),
406
+ NOW(),
407
+ %s,
408
+ %s,
409
+ %s,
410
+ %s,
411
+ FALSE,
412
+ %s,
413
+ %s,
414
+ TRUE,
415
+ 'fixed',
416
+ NOW()
417
+ )
418
+ RETURNING id
419
+ """,
420
+ (
421
+ DASHBOARD_NAME,
422
+ DASHBOARD_DESCRIPTION,
423
+ CREATOR_ID,
424
+ json_text(self.dashboard_parameters()),
425
+ COLLECTION_ID,
426
+ random_entity_id(),
427
+ ),
428
+ )
429
+
430
+ def create_card(self, card: CardDef, field_map: dict[str, int]) -> int:
431
+ template_tags = self.template_tags(card, field_map)
432
+ dataset_query = {
433
+ "database": DATABASE_ID,
434
+ "type": "native",
435
+ "native": {
436
+ "query": card.sql,
437
+ "template-tags": template_tags,
438
+ },
439
+ }
440
+ return self.insert_returning_id(
441
+ """
442
+ INSERT INTO report_card (
443
+ created_at,
444
+ updated_at,
445
+ name,
446
+ description,
447
+ display,
448
+ dataset_query,
449
+ visualization_settings,
450
+ creator_id,
451
+ database_id,
452
+ table_id,
453
+ query_type,
454
+ archived,
455
+ collection_id,
456
+ entity_id,
457
+ parameters,
458
+ parameter_mappings,
459
+ last_used_at
460
+ ) VALUES (
461
+ NOW(),
462
+ NOW(),
463
+ %s,
464
+ %s,
465
+ %s,
466
+ %s,
467
+ %s,
468
+ %s,
469
+ %s,
470
+ %s,
471
+ 'native',
472
+ FALSE,
473
+ %s,
474
+ %s,
475
+ %s,
476
+ '[]',
477
+ NOW()
478
+ )
479
+ RETURNING id
480
+ """,
481
+ (
482
+ card.name,
483
+ card.description,
484
+ card.display,
485
+ json_text(dataset_query),
486
+ json_text(card.visualization_settings),
487
+ CREATOR_ID,
488
+ DATABASE_ID,
489
+ SOURCE_TABLE_ID,
490
+ COLLECTION_ID,
491
+ random_entity_id(),
492
+ json_text(self.card_parameters(template_tags)),
493
+ ),
494
+ )
495
+
496
+ def attach_card(self, dashboard_id: int, card_id: int, card: CardDef) -> int:
497
+ parameter_mappings = self.dashcard_parameter_mappings(card)
498
+ for mapping in parameter_mappings:
499
+ mapping["card_id"] = card_id
500
+ return self.insert_returning_id(
501
+ """
502
+ INSERT INTO report_dashboardcard (
503
+ created_at,
504
+ updated_at,
505
+ size_x,
506
+ size_y,
507
+ row,
508
+ col,
509
+ card_id,
510
+ dashboard_id,
511
+ parameter_mappings,
512
+ visualization_settings,
513
+ entity_id
514
+ ) VALUES (
515
+ NOW(),
516
+ NOW(),
517
+ %s,
518
+ %s,
519
+ %s,
520
+ %s,
521
+ %s,
522
+ %s,
523
+ %s,
524
+ '{}',
525
+ %s
526
+ )
527
+ RETURNING id
528
+ """,
529
+ (
530
+ card.size_x,
531
+ card.size_y,
532
+ card.row,
533
+ card.col,
534
+ card_id,
535
+ dashboard_id,
536
+ json_text(parameter_mappings),
537
+ random_entity_id(),
538
+ ),
539
+ )
540
+
541
+ def publish(self) -> dict:
542
+ field_map = self.field_map()
543
+ missing = [slug for slug in FILTER_SLUGS if slug not in field_map]
544
+ if missing:
545
+ raise RuntimeError(f"Missing Metabase field ids for filters: {', '.join(missing)}")
546
+
547
+ self.archive_existing_cards()
548
+ self.delete_existing_dashboard()
549
+ dashboard_id = self.create_dashboard()
550
+
551
+ created = []
552
+ for card in CARDS:
553
+ card_id = self.create_card(card, field_map)
554
+ dashcard_id = self.attach_card(dashboard_id, card_id, card)
555
+ created.append({"card_id": card_id, "dashcard_id": dashcard_id, "name": card.name})
556
+
557
+ self.conn.commit()
558
+ return {
559
+ "dashboard_id": dashboard_id,
560
+ "dashboard_name": DASHBOARD_NAME,
561
+ "cards_created": len(created),
562
+ "collection_id": COLLECTION_ID,
563
+ }
564
+
565
+
566
+ def main() -> int:
567
+ publisher = Publisher()
568
+ try:
569
+ result = publisher.publish()
570
+ print(json.dumps(result, ensure_ascii=False))
571
+ except Exception:
572
+ publisher.conn.rollback()
573
+ raise
574
+ finally:
575
+ publisher.close()
576
+ return 0
577
+
578
+
579
+ if __name__ == "__main__":
580
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ INFRA_DIR="${ROOT_DIR}/infra"
6
+ ENV_FILE="${INFRA_DIR}/.env"
7
+ STG_TEMPLATE="${INFRA_DIR}/.env.stg.example"
8
+ PROD_TEMPLATE="${INFRA_DIR}/.env.example"
9
+ COMPOSE_FILE="${INFRA_DIR}/docker-compose.yml"
10
+
11
+ usage() {
12
+ cat <<'USAGE'
13
+ Uso: ./scripts/deploy.sh stg|prod [--llm]
14
+
15
+ stg Sobe ambiente local de teste com variaveis presetadas.
16
+ prod Sobe ambiente local de producao com configuração interativa.
17
+
18
+ Opcoes:
19
+ --llm Inclui Ollama e bootstrap do modelo local.
20
+
21
+ Variaveis:
22
+ DATAIF_FORCE_ENV=true Recria infra/.env a partir do template do modo.
23
+ DATAIF_DEPLOY_CONFIG_ONLY=true Configura e valida sem subir containers.
24
+ USAGE
25
+ }
26
+
27
+ mode="${1:-}"
28
+ shift || true
29
+
30
+ if [ "${mode}" != "stg" ] && [ "${mode}" != "prod" ]; then
31
+ usage >&2
32
+ exit 1
33
+ fi
34
+
35
+ compose_args=(--env-file "${ENV_FILE}" -f "${COMPOSE_FILE}")
36
+
37
+ for arg in "$@"; do
38
+ case "${arg}" in
39
+ --llm) compose_args+=(--profile llm) ;;
40
+ -h|--help)
41
+ usage
42
+ exit 0
43
+ ;;
44
+ *)
45
+ printf 'Opcao invalida: %s\n' "${arg}" >&2
46
+ usage >&2
47
+ exit 1
48
+ ;;
49
+ esac
50
+ done
51
+
52
+ if [ "${mode}" = "stg" ]; then
53
+ if [ ! -f "${ENV_FILE}" ] || [ "${DATAIF_FORCE_ENV:-false}" = "true" ]; then
54
+ cp "${STG_TEMPLATE}" "${ENV_FILE}"
55
+ chmod 600 "${ENV_FILE}"
56
+ printf 'Arquivo stg criado: %s\n' "${ENV_FILE}"
57
+ else
58
+ printf 'Arquivo existente mantido: %s\n' "${ENV_FILE}"
59
+ fi
60
+ else
61
+ if [ ! -f "${ENV_FILE}" ] || [ "${DATAIF_FORCE_ENV:-false}" = "true" ]; then
62
+ DATAIF_ENV_FILE="${ENV_FILE}" "${ROOT_DIR}/scripts/configure-env.sh"
63
+ else
64
+ printf 'Arquivo existente mantido: %s\n' "${ENV_FILE}"
65
+ printf 'Para reconfigurar: DATAIF_FORCE_ENV=true ./scripts/deploy.sh prod\n'
66
+ fi
67
+ fi
68
+
69
+ docker compose "${compose_args[@]}" config >/dev/null
70
+
71
+ if [ "${DATAIF_DEPLOY_CONFIG_ONLY:-false}" = "true" ]; then
72
+ printf 'Configuracao validada: %s\n' "${ENV_FILE}"
73
+ exit 0
74
+ fi
75
+
76
+ docker compose "${compose_args[@]}" up -d --build
77
+
78
+ printf 'DataIF %s ativo.\n' "${mode}"
79
+ printf 'Web: http://localhost:%s\n' "$(awk -F= '$1 == "WEB_PORT" {print $2}' "${ENV_FILE}")"