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 @@