diff --git a/backend/apps/admin_data_tools/urls.py b/backend/apps/admin_data_tools/urls.py index 79fae78f..304e6cdf 100644 --- a/backend/apps/admin_data_tools/urls.py +++ b/backend/apps/admin_data_tools/urls.py @@ -1,10 +1,20 @@ # -*- coding: utf-8 -*- from django.urls import path -from .views import CheckMetadadosView, FlowFailedWebhookView, SyncDeploymentsView +from .views import ( + CheckMetadadosView, + FlowFailedWebhookView, + SyncDeploymentsView, + SyncUpdateLatestView, +) urlpatterns = [ path("admin-tools/sync-deployments/", SyncDeploymentsView.as_view(), name="sync-deployments"), path("admin-tools/flow-failed/", FlowFailedWebhookView.as_view(), name="flow-failed"), path("admin-tools/check-metadados/", CheckMetadadosView.as_view(), name="check-metadados"), + path( + "admin-tools/sync-update-latest/", + SyncUpdateLatestView.as_view(), + name="sync-update-latest", + ), ] diff --git a/backend/apps/admin_data_tools/views.py b/backend/apps/admin_data_tools/views.py index 43e6d29b..ba70fd4e 100644 --- a/backend/apps/admin_data_tools/views.py +++ b/backend/apps/admin_data_tools/views.py @@ -18,6 +18,33 @@ logger = logger.bind(module="admin_data_tools") +# field.field_type (BigQuery client) reports legacy SQL names (INTEGER, +# FLOAT, RECORD, ...); the API's `bigquery_type` catalog uses standard SQL +# names (INT64, FLOAT64, STRUCT, BOOLEAN, ...) — only these three actually +# differ, everything else (STRING, BOOLEAN, DATE, TIMESTAMP, ...) is spelled +# the same on both sides. +_BQ_LEGACY_TYPE_ALIASES: dict[str, str] = { + "integer": "int64", + "float": "float64", + "record": "struct", +} + + +def _bq_type_to_api_type(field_type: str) -> str: + """Translate a BigQuery `field.field_type` (legacy name) to the standard + SQL name used by the API's `bigquery_type` catalog.""" + field_type = field_type.lower() + return _BQ_LEGACY_TYPE_ALIASES.get(field_type, field_type) + + +def _gbq_slug_for_table(cloud_table) -> str: + """Full `project.dataset.table` slug for a CloudTable, in the BigQuery + project matching the current admin environment: `basedosdados` in prod, + `basedosdados-dev` everywhere else (staging/dev/local) — where the flows + write before promoting to prod.""" + gcp_project_id = "basedosdados" if is_prd() else "basedosdados-dev" + return f"{gcp_project_id}.{cloud_table.gcp_dataset_id}.{cloud_table.gcp_table_id}" + _FAILED_STATES = {"Failed", "Crashed"} _DBT_TASK_NAMES = {"run_dbt"} @@ -298,8 +325,11 @@ def post(self, request): request: Incoming Django HTTP request, com ``table_id`` no POST. Returns: - ``JsonResponse`` com ``status`` ("sucesso" ou "erro") e ``mensagem`` - listando as discrepâncias encontradas (ou confirmando consistência). + ``JsonResponse`` com ``status`` ("sucesso" ou "erro") e + ``discrepancias``, uma lista de objetos ``{coluna, tipo, ...}`` — + ``tipo`` é um de ``somente_bigquery``, ``somente_api``, + ``tipo_diferente`` ou ``descricao_diferente``; os dois últimos + também trazem ``bigquery``/``api`` com os valores comparados. """ table_id = request.POST.get("table_id") selected_table = Table.objects.get(id=table_id) @@ -309,61 +339,124 @@ def post(self, request): return JsonResponse( { "status": "erro", - "mensagem": ( - "Tabela sem CloudTable vinculada — não é possível checar o BigQuery." - ), + "erro": "Tabela sem CloudTable vinculada — não é possível checar o BigQuery.", } ) - gcp_project_id = "basedosdados" if is_prd() else "basedosdados-dev" - gbq_slug = f"{gcp_project_id}.{cloud_table.gcp_dataset_id}.{cloud_table.gcp_table_id}" + gbq_slug = _gbq_slug_for_table(cloud_table) try: bq_client = get_gbq_client() bq_table = bq_client.get_table(gbq_slug) except Exception as exc: - return JsonResponse( - {"status": "erro", "mensagem": f"Falha ao consultar o BigQuery: {exc}"} - ) + return JsonResponse({"status": "erro", "erro": f"Falha ao consultar o BigQuery: {exc}"}) bq_columns = {field.name.lower(): field for field in bq_table.schema} db_columns = {column.name.lower(): column for column in selected_table.columns.all()} - discrepancias: list[str] = [] + discrepancias: list[dict] = [] for name, field in bq_columns.items(): column = db_columns.get(name) if column is None: - discrepancias.append( - f"Coluna `{field.name}`: existe no BigQuery, não existe na API" - ) + discrepancias.append({"coluna": field.name, "tipo": "somente_bigquery"}) continue bq_type = (field.field_type or "").upper() - api_type = (column.bigquery_type.name if column.bigquery_type else "").upper() - if bq_type != api_type: + api_type = (column.bigquery_type.name if column.bigquery_type else "").lower() + if _bq_type_to_api_type(bq_type) != api_type: discrepancias.append( - f"Coluna `{field.name}`: tipo diferente " - f"(BigQuery=`{bq_type}`, API=`{api_type}`)" + { + "coluna": field.name, + "tipo": "tipo_diferente", + "bigquery": bq_type, + "api": api_type.upper(), + } ) bq_desc = field.description or "" api_desc = column.description or "" if bq_desc != api_desc: discrepancias.append( - f"Coluna `{field.name}`: descrição diferente " - f"(BigQuery=`{bq_desc}`, API=`{api_desc}`)" + { + "coluna": field.name, + "tipo": "descricao_diferente", + "bigquery": bq_desc, + "api": api_desc, + } ) for name, column in db_columns.items(): if name not in bq_columns: - discrepancias.append( - f"Coluna `{column.name}`: existe na API, não existe no BigQuery" - ) + discrepancias.append({"coluna": column.name, "tipo": "somente_api"}) + + status = "erro" if discrepancias else "sucesso" + return JsonResponse({"status": status, "discrepancias": discrepancias}) + + +class SyncUpdateLatestView(View): + """Sincroniza `Update.latest` (ancorado na Table) com o `last_modified` + real do BigQuery. + + Acionada pelo botão "Sync latest do BigQuery", ao lado do "Update and + Poll Info" na página de admin de uma `Table` + (``backend/apps/api/v1/admin.py::TableAdmin.get_update_display``). Só + faz sentido pro Update ancorado na própria Table — o Update do + RawDataSource guarda a data de competência publicada pela fonte, não + wall-clock, então não tem o que sincronizar contra o BigQuery ali. + + Corrige na hora um `Table.Update.latest` desatualizado sem precisar + esperar o próximo flow rodar (mesmo problema resolvido em pipelines#1883 + para os flows que ainda usavam `poll.py`). + """ + + def post(self, request): + table_id = request.POST.get("table_id") + selected_table = Table.objects.get(id=table_id) + + cloud_table = selected_table.cloud_tables.first() + if not cloud_table: + return JsonResponse( + { + "status": "erro", + "erro": ( + "Tabela sem CloudTable vinculada — não é possível consultar o BigQuery." + ), + } + ) + + updates = list(selected_table.updates.all()) + if len(updates) != 1: + return JsonResponse( + { + "status": "erro", + "erro": ( + f"Tabela tem {len(updates)} Update(s) vinculado(s) — só sincroniza " + "quando há exatamente 1. Resolva a ambiguidade na aba Updates antes." + ), + } + ) + update = updates[0] + + gbq_slug = _gbq_slug_for_table(cloud_table) + + try: + bq_client = get_gbq_client() + bq_table = bq_client.get_table(gbq_slug) + except Exception as exc: + return JsonResponse({"status": "erro", "erro": f"Falha ao consultar o BigQuery: {exc}"}) + + if not bq_table.modified: + return JsonResponse( + {"status": "erro", "erro": "BigQuery não informou last_modified para essa tabela."} + ) - if discrepancias: - return JsonResponse({"status": "erro", "mensagem": "\n".join(discrepancias)}) + update.latest = bq_table.modified + update.save(update_fields=["latest"]) return JsonResponse( - {"status": "sucesso", "mensagem": "Metadados consistentes com o BigQuery."} + { + "status": "sucesso", + "mensagem": f"Update.latest sincronizado: {bq_table.modified.isoformat()}", + } ) diff --git a/backend/apps/api/v1/admin.py b/backend/apps/api/v1/admin.py index 018f9861..30e6f25b 100644 --- a/backend/apps/api/v1/admin.py +++ b/backend/apps/api/v1/admin.py @@ -931,7 +931,16 @@ def check_if_there_is_only_one_raw_data_source_connected(table_object): raw_data_source_update_html + "
" + poll_raw_data_source_html ) - return format_html(update_html + "
" + raw_data_source_html) + sync_button_html = "" + if table_obj.updates.count() == 1: + sync_button_html = format_html( + ' — ", + str(table_obj.pk), + ) + + return format_html("{}{}
{}", update_html, sync_button_html, raw_data_source_html) get_update_display.short_description = "Update and Poll Info" diff --git a/backend/apps/core/static/core/css/main.css b/backend/apps/core/static/core/css/main.css index 7645bc92..53354373 100644 --- a/backend/apps/core/static/core/css/main.css +++ b/backend/apps/core/static/core/css/main.css @@ -37,6 +37,88 @@ z-index: 1000; border-radius: 5px; } +.modal-content--wide { + max-width: 700px; +} + +.update-sync-button { + margin-left: 6px; +} + +.metadados-linha { + display: flex; + align-items: baseline; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid #eee; +} + +.metadados-linha:last-child { + border-bottom: none; +} + +.metadados-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + color: #fff; + white-space: nowrap; +} + +.metadados-badge--somente_bigquery, +.metadados-badge--somente_api { + background-color: #dc3545; +} + +.metadados-badge--tipo_diferente { + background-color: #fd7e14; +} + +.metadados-badge--descricao_diferente { + background-color: #6c757d; +} + +.metadados-coluna { + font-weight: bold; +} + +.metadados-detalhe { + color: #555; + font-size: 13px; +} + +.metadados-sucesso { + color: #28a745; + font-weight: bold; +} + +.metadados-erro-geral { + color: #dc3545; +} + +.sync-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; +} + +.sync-loading .spinner-small { + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + animation: spin-rotate 1s linear infinite; +} + +@keyframes spin-rotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + .form-group { margin-bottom: 15px; } diff --git a/backend/apps/core/static/core/js/ferramentas.js b/backend/apps/core/static/core/js/ferramentas.js index 3068a55f..382688df 100644 --- a/backend/apps/core/static/core/js/ferramentas.js +++ b/backend/apps/core/static/core/js/ferramentas.js @@ -29,6 +29,12 @@ window.onclick = function(event) { if (event.target === modal) { fecharModal(); } +if (event.target === metadadosModal) { + metadadosModal.style.display = 'none'; +} +if (event.target === syncUpdateModal) { + closeSyncUpdateModal(); +} } function processar() { @@ -56,6 +62,68 @@ esconderCarregamento(); // Esconde o carregamento independente do resultado }); }; +const metadadosModal = document.getElementById('metadadosModal'); +const metadadosCloseButton = document.getElementById('metadadosCloseButton'); +const metadadosResultado = document.getElementById('metadadosResultado'); + +const METADADOS_TIPO_LABELS = { + somente_bigquery: 'Só no BigQuery', + somente_api: 'Só na API', + tipo_diferente: 'Tipo diferente', + descricao_diferente: 'Descrição diferente', +}; + +metadadosCloseButton.onclick = function() { + metadadosModal.style.display = 'none'; +} + +function renderDiscrepanciaMetadados(discrepancia) { + const linha = document.createElement('div'); + linha.className = 'metadados-linha'; + + const badge = document.createElement('span'); + badge.className = 'metadados-badge metadados-badge--' + discrepancia.tipo; + badge.textContent = METADADOS_TIPO_LABELS[discrepancia.tipo] || discrepancia.tipo; + linha.appendChild(badge); + + const coluna = document.createElement('code'); + coluna.className = 'metadados-coluna'; + coluna.textContent = discrepancia.coluna; + linha.appendChild(coluna); + + if (discrepancia.bigquery !== undefined || discrepancia.api !== undefined) { + const detalhe = document.createElement('span'); + detalhe.className = 'metadados-detalhe'; + detalhe.textContent = 'BigQuery: ' + (discrepancia.bigquery || '(vazio)') + + ' → API: ' + (discrepancia.api || '(vazio)'); + linha.appendChild(detalhe); + } + + return linha; +} + +function mostrarResultadoMetadados(data) { + metadadosResultado.innerHTML = ''; + + if (data.erro) { + const p = document.createElement('p'); + p.className = 'metadados-erro-geral'; + p.textContent = data.erro; + metadadosResultado.appendChild(p); + } else if (data.status === 'sucesso') { + const p = document.createElement('p'); + p.className = 'metadados-sucesso'; + p.textContent = 'Metadados consistentes com o BigQuery.'; + metadadosResultado.appendChild(p); + } else { + data.discrepancias.forEach(function(discrepancia) { + metadadosResultado.appendChild(renderDiscrepanciaMetadados(discrepancia)); + }); + } + + metadadosModal.style.display = 'block'; +} + function checarMetadados() { const formData = new FormData(); @@ -71,7 +139,7 @@ fetch('/admin-tools/check-metadados/', { }) .then(response => response.json()) .then(data => { - alert(data.mensagem); + mostrarResultadoMetadados(data); }) .catch(error => { alert('Erro ao checar metadados: ' + error); @@ -80,3 +148,83 @@ fetch('/admin-tools/check-metadados/', { esconderCarregamento(); }); }; + +const syncUpdateModal = document.getElementById('syncUpdateModal'); +const syncUpdateCloseButton = document.getElementById('syncUpdateCloseButton'); +const syncUpdateResultado = document.getElementById('syncUpdateResultado'); +let syncUpdateSucceeded = false; + +function closeSyncUpdateModal() { + syncUpdateModal.style.display = 'none'; + if (syncUpdateSucceeded) { + location.reload(); + } +} + +syncUpdateCloseButton.onclick = closeSyncUpdateModal; + +function syncUpdateLatest(tableId, button) { + if (button && button.disabled) { + return; + } + + if (!confirm('Sincronizar a Última Atualização com o last_modified do BigQuery?')) { + return; + } + + if (button) { + button.disabled = true; + } + + syncUpdateSucceeded = false; + syncUpdateResultado.innerHTML = ''; + const loading = document.createElement('div'); + loading.className = 'sync-loading'; + const spinner = document.createElement('div'); + spinner.className = 'spinner-small'; + const texto = document.createElement('span'); + texto.textContent = 'Sincronizando com o BigQuery...'; + loading.appendChild(spinner); + loading.appendChild(texto); + syncUpdateResultado.appendChild(loading); + syncUpdateModal.style.display = 'block'; + + const formData = new FormData(); + formData.append('table_id', tableId); + + fetch('/admin-tools/sync-update-latest/', { + method: 'POST', + body: formData, + headers: { + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value + } + }) + .then(response => response.json()) + .then(data => { + syncUpdateSucceeded = data.status === 'sucesso'; + const p = document.createElement('p'); + p.className = syncUpdateSucceeded ? 'metadados-sucesso' : 'metadados-erro-geral'; + p.textContent = data.mensagem || data.erro; + syncUpdateResultado.innerHTML = ''; + syncUpdateResultado.appendChild(p); + + if (syncUpdateSucceeded) { + const aviso = document.createElement('p'); + aviso.className = 'metadados-detalhe'; + aviso.textContent = 'Ao fechar esta janela, a página será atualizada.'; + syncUpdateResultado.appendChild(aviso); + } + }) + .catch(error => { + const p = document.createElement('p'); + p.className = 'metadados-erro-geral'; + p.textContent = 'Erro ao sincronizar: ' + error; + syncUpdateResultado.innerHTML = ''; + syncUpdateResultado.appendChild(p); + }) + .finally(() => { + if (button) { + button.disabled = false; + } + }); +} diff --git a/backend/templates/admin/change_form.html b/backend/templates/admin/change_form.html index 8bff44bb..bab29f41 100644 --- a/backend/templates/admin/change_form.html +++ b/backend/templates/admin/change_form.html @@ -46,6 +46,28 @@

Importar Colunas

+ + {% endif %}