diff --git a/backend/apps/admin_data_tools/bigquery_sync.py b/backend/apps/admin_data_tools/bigquery_sync.py new file mode 100644 index 00000000..e0e5d224 --- /dev/null +++ b/backend/apps/admin_data_tools/bigquery_sync.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +from django.http import JsonResponse +from django.views import View + +from backend.apps.api.v1.models import Table +from backend.custom.client import get_gbq_client +from backend.custom.environment import is_prd + +from .constants import BQ_LEGACY_TYPE_ALIASES + + +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}" + + +class CheckMetadadosView(View): + """Compara o schema real da tabela no BigQuery com as colunas cadastradas na API. + + Acionada pelo botão "Checar Metadados" na página de admin de uma `Table` + (``backend/templates/admin/change_form.html``) via + ``POST /admin-tools/check-metadados/``. Mesma checagem do + ``.github/workflows/scripts/check_metadata.py`` (repo pipelines), mas lendo + ``bq_client.get_table(...).schema`` em vez de consultar `INFORMATION_SCHEMA` + — é metadado da tabela, não uma query faturada. + + Chamada de dentro do admin autenticado (não machine-to-machine como as + demais views deste módulo), então mantém a proteção de CSRF padrão do + Django em vez do bearer token usado acima. + + Compara sempre contra o projeto do BigQuery correspondente ao ambiente do + próprio admin (``is_prd()``): em staging/dev contra ``basedosdados-dev`` + — onde os flows escrevem antes de promover pra prod —, em prod contra + ``basedosdados``. Sem isso, staging acabaria comparando contra dados que + ainda nem foram promovidos. + """ + + def post(self, request): + """Handle the check-metadados request. + + Args: + request: Incoming Django HTTP request, com ``table_id`` no POST. + + Returns: + ``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) + + cloud_table = selected_table.cloud_tables.first() + if not cloud_table: + return JsonResponse( + { + "status": "erro", + "erro": "Tabela sem CloudTable vinculada — não é possível checar o BigQuery.", + } + ) + + 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}"}) + + 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[dict] = [] + + for name, field in bq_columns.items(): + column = db_columns.get(name) + if column is None: + 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 "").lower() + if _bq_type_to_api_type(bq_type) != api_type: + discrepancias.append( + { + "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( + { + "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({"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."} + ) + + update.latest = bq_table.modified + update.save(update_fields=["latest"]) + + return JsonResponse( + { + "status": "sucesso", + "mensagem": f"Update.latest sincronizado: {bq_table.modified.isoformat()}", + } + ) diff --git a/backend/apps/admin_data_tools/column_import.py b/backend/apps/admin_data_tools/column_import.py new file mode 100644 index 00000000..35d78031 --- /dev/null +++ b/backend/apps/admin_data_tools/column_import.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +import re +from typing import Dict, List + +import pandas as pd +from django.db import transaction +from django.http import JsonResponse +from django.views import View +from loguru import logger + +from backend.apps.api.v1.models import BigQueryType, Column, Table + +logger = logger.bind(module="admin_data_tools.column_import") + + +class ColumnImportError(Exception): + """Erro de dados na planilha de arquitetura, com mensagem segura pra mostrar ao usuário.""" + + def __init__(self, message: str, column_name: str | None = None): + super().__init__(message) + self.column_name = column_name + + +_BQ_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _validar_identificador_bq(valor: str, campo: str, column_name: str) -> None: + """Confere se um valor da planilha é um identificador válido do BigQuery. + + Args: + valor: Trecho a validar (nome de coluna, dataset ou tabela). + campo: Descrição do campo de origem, usada na mensagem de erro. + column_name: Nome da coluna da planilha sendo processada, pra contextualizar o erro. + + Raises: + ColumnImportError: Se `valor` tiver espaço, ponto ou qualquer caractere fora de + letras/números/underscore, ou começar com número. + """ + if not _BQ_IDENTIFIER_RE.match(valor): + raise ColumnImportError( + f"{campo} '{valor}' não é um identificador válido do BigQuery — use apenas " + "letras, números e underscore, sem começar com número (confira se não há " + "espaços, pontos ou outros caracteres a mais).", + column_name=column_name, + ) + + +def _read_architecture_table(url: str) -> pd.DataFrame: + """Baixa e normaliza a planilha de arquitetura a partir de um link do Google Sheets.""" + id_spreadsheets = url.split("/")[-2] + + spreadsheets_raw_url = ( + f"https://docs.google.com/spreadsheets/d/{id_spreadsheets}/gviz/tq?tqx=out:csv" + ) + + df_architecture = pd.read_csv(spreadsheets_raw_url, dtype=str) + df_architecture = df_architecture.loc[df_architecture["name"] != "(excluido)"] + df_architecture.fillna("", inplace=True) + + return df_architecture + + +def _create_column(selected_table: Table, tables_dict: Dict[str, Table], row: pd.Series) -> Column: + """Cria uma `Column` a partir de uma linha da planilha de arquitetura. + + Args: + selected_table: Tabela em que a coluna será criada. + tables_dict: Mapa `gbq_slug -> Table` de todas as tabelas cadastradas, + usado pra resolver referências de `directory_column`. + row: Linha da planilha de arquitetura, já normalizada. + + Returns: + A `Column` recém-criada (ainda não associada via `.columns.set(...)`). + + Raises: + ColumnImportError: Se o `bigquery_type`, o nome da coluna ou o + `directory_column` da linha forem inválidos ou não corresponderem + a nada cadastrado. + """ + column_name = row["name"] + _validar_identificador_bq(column_name, "Nome de coluna", column_name) + + row_bqtype = row["bigquery_type"].strip().upper() + try: + bqtype = BigQueryType.objects.get(name=row_bqtype) + except BigQueryType.DoesNotExist as exc: + raise ColumnImportError( + f"Tipo BigQuery '{row_bqtype}' não é reconhecido.", column_name=column_name + ) from exc + + directory_column = None + + if row["directory_column"]: + directory_column_raw = row["directory_column"] + + if directory_column_raw.count(":") != 1: + raise ColumnImportError( + f"O campo directory_column ('{directory_column_raw}') deveria ter exatamente " + "um ':' separando a tabela da coluna, no formato 'dataset.tabela:coluna' " + "(ex.: 'br_bd_diretorios_data_tempo.ano:ano').", + column_name=column_name, + ) + + table_slug_part, directory_column_name = ( + part.strip() for part in directory_column_raw.split(":") + ) + + if table_slug_part.count(".") != 1: + raise ColumnImportError( + f"O campo directory_column ('{directory_column_raw}') deveria referenciar a " + "tabela como 'dataset.tabela' (um único ponto), no formato " + "'dataset.tabela:coluna' (ex.: 'br_bd_diretorios_data_tempo.ano:ano').", + column_name=column_name, + ) + + dataset_slug, table_slug = (part.strip() for part in table_slug_part.split(".")) + _validar_identificador_bq(dataset_slug, "Dataset em directory_column", column_name) + _validar_identificador_bq(table_slug, "Tabela em directory_column", column_name) + _validar_identificador_bq(directory_column_name, "Coluna em directory_column", column_name) + + table_full_slug = f"basedosdados.{table_slug_part}" + + try: + table_directory = tables_dict[table_full_slug] + except KeyError as exc: + raise ColumnImportError( + f"Nenhuma tabela cadastrada corresponde a '{table_full_slug}'.", + column_name=column_name, + ) from exc + + try: + directory_column = table_directory.columns.get(name=directory_column_name) + except Column.DoesNotExist as exc: + raise ColumnImportError( + f"Coluna '{directory_column_name}' não encontrada na tabela de diretório " + f"'{table_full_slug}'.", + column_name=column_name, + ) from exc + + return selected_table.columns.create( + name=row["name"], + description=row["description"], + covered_by_dictionary=row["covered_by_dictionary"] == "yes", + measurement_unit=row["measurement_unit"], + contains_sensitive_data=row["has_sensitive_data"] == "yes", + observations=row["observations"], + bigquery_type=bqtype, + directory_primary_key=directory_column, + ) + + +class UploadColumnsView(View): + """Importa colunas de uma tabela a partir de uma planilha de arquitetura do Google Sheets. + + Acionada pelo botão "Importar Colunas" na página de admin de uma `Table` + (``backend/templates/admin/change_form.html``) via + ``POST /admin-tools/upload-columns/``. + + Substitui as colunas existentes da tabela dentro de uma transação — se + qualquer linha da planilha falhar (tipo BigQuery desconhecido, nome de + coluna ou `directory_column` inválido, referência a uma tabela de + diretório inexistente), nenhuma coluna é perdida. + """ + + def post(self, request): + """Handle the upload-columns request. + + Args: + request: Incoming Django HTTP request, com ``table_id`` e + ``link_arquitetura`` no POST. + + Returns: + ``JsonResponse`` com ``status`` ("sucesso" ou "erro"). Em erro, + também traz ``erro`` (mensagem) e, quando aplicável, ``coluna`` + (nome da coluna da planilha que causou o problema). + """ + table_id = request.POST.get("table_id") + link = request.POST.get("link_arquitetura") + + try: + selected_table = Table.objects.get(id=table_id) + except Table.DoesNotExist: + return JsonResponse( + {"status": "erro", "erro": f"Tabela com ID '{table_id}' não encontrada."} + ) + + try: + architecture = _read_architecture_table(link) + except Exception as exc: + return JsonResponse( + { + "status": "erro", + "erro": f"Não foi possível ler a planilha de arquitetura: {exc}", + } + ) + + tables_dict: Dict[str, Table] = {table.gbq_slug: table for table in Table.objects.all()} + + try: + with transaction.atomic(): + selected_table.columns.all().delete() + + columns: List[Column] = [ + _create_column(selected_table=selected_table, tables_dict=tables_dict, row=row) + for _, row in architecture.iterrows() + ] + + selected_table.columns.set(columns) + except ColumnImportError as exc: + return JsonResponse({"status": "erro", "erro": str(exc), "coluna": exc.column_name}) + except Exception as exc: + logger.error(f"Falha inesperada ao importar colunas da tabela {table_id}: {exc}") + return JsonResponse( + {"status": "erro", "erro": f"Erro inesperado ao importar colunas: {exc}"} + ) + + return JsonResponse({"status": "sucesso", "mensagem": "Colunas salvas com sucesso!"}) diff --git a/backend/apps/admin_data_tools/views.py b/backend/apps/admin_data_tools/flow_monitoring.py similarity index 60% rename from backend/apps/admin_data_tools/views.py rename to backend/apps/admin_data_tools/flow_monitoring.py index 24872b8e..f5b0066d 100644 --- a/backend/apps/admin_data_tools/views.py +++ b/backend/apps/admin_data_tools/flow_monitoring.py @@ -11,31 +11,13 @@ from django.views.decorators.csrf import csrf_exempt from loguru import logger -from backend.apps.api.v1.models import Table -from backend.custom.client import get_gbq_client, send_discord_message -from backend.custom.environment import is_prd +from backend.custom.client import send_discord_message from ._prefect3_client import Prefect3Client -from .constants import BQ_LEGACY_TYPE_ALIASES, DBT_TASK_NAMES, FAILED_STATES, STATE_MESSAGES_IGNORE +from .constants import DBT_TASK_NAMES, FAILED_STATES, STATE_MESSAGES_IGNORE from .models import DisabledFlowSchedule -logger = logger.bind(module="admin_data_tools") - - -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}" +logger = logger.bind(module="admin_data_tools.flow_monitoring") def _is_dbt_task(name: str) -> bool: @@ -315,168 +297,3 @@ def _notify_disabled( f"Motivo: {cause}\n" f"[Ver no admin]({change_url}) · [Ver no Prefect]({deployment_url})" ) - - -class CheckMetadadosView(View): - """Compara o schema real da tabela no BigQuery com as colunas cadastradas na API. - - Acionada pelo botão "Checar Metadados" na página de admin de uma `Table` - (``backend/templates/admin/change_form.html``) via - ``POST /admin-tools/check-metadados/``. Mesma checagem do - ``.github/workflows/scripts/check_metadata.py`` (repo pipelines), mas lendo - ``bq_client.get_table(...).schema`` em vez de consultar `INFORMATION_SCHEMA` - — é metadado da tabela, não uma query faturada. - - Chamada de dentro do admin autenticado (não machine-to-machine como as - demais views deste módulo), então mantém a proteção de CSRF padrão do - Django em vez do bearer token usado acima. - - Compara sempre contra o projeto do BigQuery correspondente ao ambiente do - próprio admin (``is_prd()``): em staging/dev contra ``basedosdados-dev`` - — onde os flows escrevem antes de promover pra prod —, em prod contra - ``basedosdados``. Sem isso, staging acabaria comparando contra dados que - ainda nem foram promovidos. - """ - - def post(self, request): - """Handle the check-metadados request. - - Args: - request: Incoming Django HTTP request, com ``table_id`` no POST. - - Returns: - ``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) - - cloud_table = selected_table.cloud_tables.first() - if not cloud_table: - return JsonResponse( - { - "status": "erro", - "erro": "Tabela sem CloudTable vinculada — não é possível checar o BigQuery.", - } - ) - - 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}"}) - - 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[dict] = [] - - for name, field in bq_columns.items(): - column = db_columns.get(name) - if column is None: - 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 "").lower() - if _bq_type_to_api_type(bq_type) != api_type: - discrepancias.append( - { - "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( - { - "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({"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."} - ) - - update.latest = bq_table.modified - update.save(update_fields=["latest"]) - - return JsonResponse( - { - "status": "sucesso", - "mensagem": f"Update.latest sincronizado: {bq_table.modified.isoformat()}", - } - ) diff --git a/backend/apps/admin_data_tools/urls.py b/backend/apps/admin_data_tools/urls.py index 304e6cdf..7257977d 100644 --- a/backend/apps/admin_data_tools/urls.py +++ b/backend/apps/admin_data_tools/urls.py @@ -1,12 +1,9 @@ # -*- coding: utf-8 -*- from django.urls import path -from .views import ( - CheckMetadadosView, - FlowFailedWebhookView, - SyncDeploymentsView, - SyncUpdateLatestView, -) +from .bigquery_sync import CheckMetadadosView, SyncUpdateLatestView +from .column_import import UploadColumnsView +from .flow_monitoring import FlowFailedWebhookView, SyncDeploymentsView urlpatterns = [ path("admin-tools/sync-deployments/", SyncDeploymentsView.as_view(), name="sync-deployments"), @@ -17,4 +14,5 @@ SyncUpdateLatestView.as_view(), name="sync-update-latest", ), + path("admin-tools/upload-columns/", UploadColumnsView.as_view(), name="upload-columns"), ] diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index e7fe655a..e9bdd11f 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -9,7 +9,6 @@ DatasetRedirectView, columns_view, table_stats, - upload_columns, ) from backend.apps.api.v1.views_export import ExportTablesView @@ -32,7 +31,6 @@ def graphql_view(): path("dataset/", DatasetRedirectView.as_view()), path("dataset_redirect/", DatasetRedirectView.as_view()), path("tables/stats/", table_stats), - path("upload_columns/", upload_columns), path("columns/", columns_view), path("tables//columns/", columns_view), path("columns//", columns_view), diff --git a/backend/apps/api/v1/views.py b/backend/apps/api/v1/views.py index 98993aac..6eeb1703 100644 --- a/backend/apps/api/v1/views.py +++ b/backend/apps/api/v1/views.py @@ -2,10 +2,8 @@ from __future__ import annotations from datetime import timedelta -from typing import Dict, List from urllib.parse import urlparse -import pandas as pd from django.core.serializers import serialize from django.db.models import Sum from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse @@ -13,7 +11,6 @@ from django.views import View from backend.apps.api.v1.models import ( - BigQueryType, CloudTable, Column, Dataset, @@ -49,87 +46,6 @@ def get(self, request, *args, **kwargs): return HttpResponseRedirect(f"{domain}/404") -def upload_columns(request: HttpRequest): - # Aqui vai sua função - - token, table_id, dataset_id, link = request.POST.values() - - selected_table = Table.objects.get(id=table_id) - - selected_table.columns.all().delete() - - architecture = read_architecture_table(link) - - tables_dict: Dict[str, Table] = {table.gbq_slug: table for table in Table.objects.all()} - - columns: List[Column] = [ - create_columns(selected_table=selected_table, tables_dict=tables_dict, row=row) - for _, row in architecture.iterrows() - ] - - selected_table.columns.set(columns) - - resultado = "Colunas Salvas com sucesso!" - - print(token, table_id, dataset_id, link) - - return JsonResponse({"status": "sucesso", "mensagem": resultado}) - - -def read_architecture_table(url: str) -> pd.DataFrame: - id_spreadsheets = url.split("/")[-2] - - spreadsheets_raw_url = ( - f"https://docs.google.com/spreadsheets/d/{id_spreadsheets}/gviz/tq?tqx=out:csv" - ) - - df_architecture = pd.read_csv(spreadsheets_raw_url, dtype=str) - - df_architecture = df_architecture.loc[df_architecture["name"] != "(excluido)"] - - df_architecture.fillna("", inplace=True) - - return df_architecture - - -def create_columns(selected_table: Table, tables_dict: Dict[str, Table], row: pd.Series) -> Column: - # Pegar ID do BigQueryType Model - - row_bqtype = row["bigquery_type"].strip().upper() - bqtype = BigQueryType.objects.get(name=row_bqtype) - - # Pegar ID da coluna Diretorio - - directory_column = None - - if row["directory_column"]: - full_slug_models = "basedosdados.{table_full_slug}" - - table_full_slug = row["directory_column"].split(":")[0] - table_full_slug = full_slug_models.format(table_full_slug=table_full_slug) - - directory_column_name = row["directory_column"].split(":")[1] - - table_directory = tables_dict[table_full_slug] - - directory_column = table_directory.columns.get(name=directory_column_name) - - # Definir Coluna - - column = selected_table.columns.create( - name=row["name"], - description=row["description"], - covered_by_dictionary=row["covered_by_dictionary"] == "yes", - measurement_unit=row["measurement_unit"], - contains_sensitive_data=row["has_sensitive_data"] == "yes", - observations=row["observations"], - bigquery_type=bqtype, - directory_primary_key=directory_column, - ) - - return column - - def table_stats(request: HttpRequest): """ Calculates and returns statistics about the tables and datasets. diff --git a/backend/apps/core/static/core/css/main.css b/backend/apps/core/static/core/css/main.css index 53354373..3660bec1 100644 --- a/backend/apps/core/static/core/css/main.css +++ b/backend/apps/core/static/core/css/main.css @@ -68,7 +68,8 @@ z-index: 1000; } .metadados-badge--somente_bigquery, -.metadados-badge--somente_api { +.metadados-badge--somente_api, +.metadados-badge--erro_colunas { background-color: #dc3545; } @@ -92,10 +93,12 @@ z-index: 1000; .metadados-sucesso { color: #28a745; font-weight: bold; + overflow-wrap: break-word; } .metadados-erro-geral { color: #dc3545; + overflow-wrap: break-word; } .sync-loading { diff --git a/backend/apps/core/static/core/js/ferramentas.js b/backend/apps/core/static/core/js/ferramentas.js index 382688df..728eecf7 100644 --- a/backend/apps/core/static/core/js/ferramentas.js +++ b/backend/apps/core/static/core/js/ferramentas.js @@ -1,6 +1,7 @@ const modal = document.getElementById('dadosModal'); -const closeButton = document.querySelector('.close-button'); +const closeButton = document.getElementById('dadosCloseButton'); const dadosForm = document.getElementById('dadosForm'); +const dadosResultado = document.getElementById('dadosResultado'); const loadingOverlay = document.getElementById('loadingOverlay'); function mostrarCarregamento() { @@ -14,6 +15,7 @@ loadingOverlay.style.display = 'none'; // Função para abrir a modal function abrirModal() { +dadosResultado.innerHTML = ''; modal.style.display = 'block'; } @@ -37,12 +39,44 @@ if (event.target === syncUpdateModal) { } } +function mostrarResultadoDados(sucesso, mensagem, coluna) { + dadosResultado.innerHTML = ''; + + if (sucesso || !coluna) { + const p = document.createElement('p'); + p.className = sucesso ? 'metadados-sucesso' : 'metadados-erro-geral'; + p.textContent = mensagem; + dadosResultado.appendChild(p); + return; + } + + const linha = document.createElement('div'); + linha.className = 'metadados-linha'; + + const badge = document.createElement('span'); + badge.className = 'metadados-badge metadados-badge--erro_colunas'; + badge.textContent = 'Erro'; + linha.appendChild(badge); + + const codigo = document.createElement('code'); + codigo.className = 'metadados-coluna'; + codigo.textContent = coluna; + linha.appendChild(codigo); + + const detalhe = document.createElement('span'); + detalhe.className = 'metadados-detalhe'; + detalhe.textContent = mensagem; + linha.appendChild(detalhe); + + dadosResultado.appendChild(linha); +} + function processar() { const formData = new FormData(dadosForm); mostrarCarregamento(); -fetch('/upload_columns/', { +fetch('/admin-tools/upload-columns/', { method: 'POST', body: formData, headers: { @@ -51,11 +85,10 @@ fetch('/upload_columns/', { }) .then(response => response.json()) .then(data => { - alert('Dados enviados com sucesso!' + data); - fecharModal(); + mostrarResultadoDados(data.status === 'sucesso', data.mensagem || data.erro, data.coluna); }) .catch(error => { - alert('Erro ao enviar dados: ' + error); + mostrarResultadoDados(false, 'Erro ao enviar dados: ' + error); }) .finally(() => { esconderCarregamento(); // Esconde o carregamento independente do resultado diff --git a/backend/templates/admin/change_form.html b/backend/templates/admin/change_form.html index bab29f41..70909284 100644 --- a/backend/templates/admin/change_form.html +++ b/backend/templates/admin/change_form.html @@ -17,10 +17,10 @@ {{ block.super }} {% if opts.model_name == 'table' %} -