Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/apps/admin_data_tools/urls.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
145 changes: 119 additions & 26 deletions backend/apps/admin_data_tools/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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)
Expand All @@ -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()}",
}
)
11 changes: 10 additions & 1 deletion backend/apps/api/v1/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,16 @@ def check_if_there_is_only_one_raw_data_source_connected(table_object):
raw_data_source_update_html + "<br>" + poll_raw_data_source_html
)

return format_html(update_html + "<br>" + raw_data_source_html)
sync_button_html = ""
if table_obj.updates.count() == 1:
sync_button_html = format_html(
' — <button type="button" onclick="syncUpdateLatest(\'{}\', this)" '
'class="btn btn-secondary btn-sm update-sync-button">'
"Sincronizar com o BigQuery</button>",
str(table_obj.pk),
)

return format_html("{}{}<br>{}", update_html, sync_button_html, raw_data_source_html)

get_update_display.short_description = "Update and Poll Info"

Expand Down
82 changes: 82 additions & 0 deletions backend/apps/core/static/core/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading