From 918a7eac71996f14e8d931f92d75f89f54d0387f Mon Sep 17 00:00:00 2001 From: Kenio de Souza Date: Thu, 13 Nov 2025 18:02:08 -0300 Subject: [PATCH] =?UTF-8?q?feat():=20Cria=C3=A7=C3=A3o=20de=20endpoint=20q?= =?UTF-8?q?ue=20cadastra=20multiplos=20documentos=20junto=20ao=20banco=20d?= =?UTF-8?q?e=20dados.=20Criado=20fun=C3=A7=C3=A3o=20global=20que=20grava?= =?UTF-8?q?=20os=20arquivos=20em=20disco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- actions/file/save_file_from_base64.py | 54 ++++++ .../ato_documento_save_multiple_action.py | 19 +++ .../controllers/ato_documento_controller.py | 17 ++ .../endpoints/ato_documento_endpoint.py | 20 +++ .../ato_documento_save_multiple_repository.py | 159 ++++++++++++++++++ .../ato_principal_save_multiple_repository.py | 56 +----- .../schemas/ato_documento_schema.py | 35 +++- .../ato_documento_save_multiple_service.py | 22 +++ 8 files changed, 328 insertions(+), 54 deletions(-) create mode 100644 actions/file/save_file_from_base64.py create mode 100644 packages/v1/administrativo/actions/ato_documento/ato_documento_save_multiple_action.py create mode 100644 packages/v1/administrativo/repositories/ato_documento/ato_documento_save_multiple_repository.py create mode 100644 packages/v1/administrativo/services/ato_documento/ato_documento_save_multiple_service.py diff --git a/actions/file/save_file_from_base64.py b/actions/file/save_file_from_base64.py new file mode 100644 index 0000000..d1e193a --- /dev/null +++ b/actions/file/save_file_from_base64.py @@ -0,0 +1,54 @@ +from fastapi import HTTPException, status +import base64 + + +# Salva o arquivo Base64 em disco e retorna o caminho completo +def save_file_from_base64( + base64_content: str, file_name: str, ato_id: int, upload_dir: str, group_size: int +) -> str: + """ + Decodifica o Base64, salva o arquivo em disco dentro de uma hierarquia + baseada no 'ato_id', e retorna o caminho completo do arquivo salvo. + + Estrutura de diretórios: + storage/ + 100/ + 57/ + documento.pdf + """ + # Garante que o diretório base de uploads existe + upload_dir.mkdir(parents=True, exist_ok=True) + + # --- NOVO BLOCO: define a faixa de agrupamento --- + # Exemplo: IDs 1–100 vão para pasta 100; IDs 101–200 → 200 + faixa_superior = ((ato_id - 1) // group_size + 1) * group_size + + # Define o caminho hierárquico: storage/100/57/ + target_dir = upload_dir / str(faixa_superior) / str(ato_id) + target_dir.mkdir(parents=True, exist_ok=True) + + # Caminho completo do arquivo final + file_path = target_dir / file_name + + # Caminho para visualização via url (relativo ao storage) + # Exemplo: "100/57/documento.pdf" + file_url = f"{faixa_superior}/{ato_id}/{file_name}" + + try: + # Decodifica o Base64 + file_bytes = base64.b64decode(base64_content) + + # Grava o arquivo em disco + with open(file_path, "wb") as f: + f.write(file_bytes) + + # Retorna o caminho completo do arquivo salvo para visualização via URl + return str(file_url) + + except base64.binascii.Error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"O conteúdo do arquivo '{file_name}' não é um Base64 válido.", + ) + except Exception as e: + raise Exception(f"Erro inesperado ao salvar arquivo '{file_name}': {e}") diff --git a/packages/v1/administrativo/actions/ato_documento/ato_documento_save_multiple_action.py b/packages/v1/administrativo/actions/ato_documento/ato_documento_save_multiple_action.py new file mode 100644 index 0000000..5cb5d4e --- /dev/null +++ b/packages/v1/administrativo/actions/ato_documento/ato_documento_save_multiple_action.py @@ -0,0 +1,19 @@ +from typing import List +from packages.v1.administrativo.schemas.ato_documento_schema import ( + AtoDocumentoSaveMultipleSchema, +) +from packages.v1.administrativo.repositories.ato_documento.ato_documento_save_multiple_repository import ( + SaveMultipleRepository, +) + + +class SaveMultipleAction: + + def execute(self, ato_documento: List[AtoDocumentoSaveMultipleSchema]): + save_repository = SaveMultipleRepository() + + # A lista completa é passada diretamente para o Repository. + # O Repository é a única camada que deve iterar. + results = save_repository.execute(ato_documento) + + return results diff --git a/packages/v1/administrativo/controllers/ato_documento_controller.py b/packages/v1/administrativo/controllers/ato_documento_controller.py index 9255701..de38109 100644 --- a/packages/v1/administrativo/controllers/ato_documento_controller.py +++ b/packages/v1/administrativo/controllers/ato_documento_controller.py @@ -5,6 +5,9 @@ from packages.v1.administrativo.services.ato_documento.ato_documento_index_servi from packages.v1.administrativo.services.ato_documento.ato_documento_save_service import ( SaveService, ) +from packages.v1.administrativo.services.ato_documento.ato_documento_save_multiple_service import ( + SaveMultipleService, +) from packages.v1.administrativo.services.ato_documento.ato_documento_show_service import ( ShowService, ) @@ -22,6 +25,7 @@ from packages.v1.administrativo.schemas.ato_documento_schema import ( AtoDocumentoSaveSchema, AtoDocumentoUpdateSchema, AtoDocumentoIdSchema, + AtoDocumentoSaveMultipleSchema, ) @@ -86,6 +90,19 @@ class AtoDocumentoController: "data": save_service.execute(ato_documento_schema), } + # Cadastra múltiplos itens + def save_multiple(self, ato_documento_schema: list[AtoDocumentoSaveMultipleSchema]): + # A lista completa é passada diretamente para o Service. + # O Service e o Action também devem ser corrigidos para parar de iterar. + save_service = SaveMultipleService() + + responses = save_service.execute(ato_documento_schema) + + return { + "message": "Processamento de múltiplos documentos concluído", + "results": responses, # O Service já retorna a lista de resultados + } + # Atualiza os dados de um documento def update( self, ato_documento_id: int, ato_documento_schema: AtoDocumentoUpdateSchema diff --git a/packages/v1/administrativo/endpoints/ato_documento_endpoint.py b/packages/v1/administrativo/endpoints/ato_documento_endpoint.py index ca242d4..0a83c54 100644 --- a/packages/v1/administrativo/endpoints/ato_documento_endpoint.py +++ b/packages/v1/administrativo/endpoints/ato_documento_endpoint.py @@ -10,6 +10,7 @@ from packages.v1.administrativo.schemas.ato_documento_schema import ( AtoDocumentoSaveSchema, AtoDocumentoUpdateSchema, AtoDocumentoIdSchema, + AtoDocumentoSaveMultipleSchema, ) # Inicializa o roteador para as rotas de ato_documento @@ -94,6 +95,25 @@ async def save( return response +# Cadastro de múltiplos itens +@router.post( + "/batch", + status_code=status.HTTP_200_OK, + summary="Cadastra múltiplos documentos", + response_description="Cadastra vários documentos de uma vez", +) +async def save_multiple( + ato_documento_schema: List[AtoDocumentoSaveMultipleSchema], + current_user: dict = Depends(get_current_user), +): + # A lista completa (List[AtoDocumentoSaveMultipleSchema]) é passada + # DIRETAMENTE para o controller (que a passará ao Service, Action e Repository). + # O loop de iteração deve estar APENAS no Repository. + responses = ato_documento_controller.save_multiple(ato_documento_schema) + + return {"success": True, "data": responses} + + # Atualiza os dados de documento @router.put( "/{ato_documento_id}", diff --git a/packages/v1/administrativo/repositories/ato_documento/ato_documento_save_multiple_repository.py b/packages/v1/administrativo/repositories/ato_documento/ato_documento_save_multiple_repository.py new file mode 100644 index 0000000..5e1c54f --- /dev/null +++ b/packages/v1/administrativo/repositories/ato_documento/ato_documento_save_multiple_repository.py @@ -0,0 +1,159 @@ +from typing import List, Optional +from fastapi import HTTPException, status +import traceback +from sqlalchemy import func +from sqlalchemy.orm import Session +from database.mysql import SessionLocal, get_database_settings +from actions.file.save_file_from_base64 import save_file_from_base64 +from packages.v1.administrativo.models.ato_principal_model import AtoPrincipal +from packages.v1.administrativo.models.ato_documento_model import AtoDocumento +from packages.v1.administrativo.schemas.ato_documento_schema import ( + AtoDocumentoSaveMultipleSchema, +) + +import os +from pathlib import Path +import uuid +import datetime + +# --- CONFIGURAÇÕES GLOBAIS --- +DB_SETTINGS = get_database_settings() +AES_KEY = getattr(DB_SETTINGS, "aeskey", None) +GROUP_SIZE = int(getattr(DB_SETTINGS, "group_size", None)) +UPLOAD_DIR = Path(os.environ.get("STORAGE", "./storage")) + + +class SaveMultipleDocumentosRepository: + """ + Repositório responsável por salvar múltiplos documentos (AtoDocumento) + associados a Atos Principais já existentes (identificados pelo código do selo). + """ + + def _save_documento( + self, db: Session, doc_schema: AtoDocumentoSaveMultipleSchema + ) -> AtoDocumento: + """ + Salva um único documento após localizar o Ato Principal correspondente + via código de selo. + """ + codigo_selo = getattr(doc_schema, "codigo_selo", None) + + # 1️ Verifica se o campo 'codigo_selo' foi informado + if not codigo_selo or codigo_selo.strip() == "": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="O campo 'codigo_selo' é obrigatório para associar o documento.", + ) + + # 2️ Busca o ato principal correspondente ao código do selo + ato_principal = ( + db.query(AtoPrincipal) + .filter(AtoPrincipal.codigo_selo == codigo_selo) + .first() + ) + + if not ato_principal: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Nenhum Ato Principal encontrado para o código de selo '{codigo_selo}'.", + ) + + ato_principal_id = ato_principal.ato_principal_id + + # 3️ Extrai e prepara os dados do schema + doc_data = doc_schema.model_dump( + exclude_unset=True, + exclude={"arquivo"}, # não envia base64 ao banco + ) + + # 4️ Processa o arquivo Base64 (se informado) + base64_content = getattr(doc_schema, "arquivo", None) + file_url_path = None + + if base64_content: + # Gera nome de arquivo, se não informado + if not doc_schema.nome_documento: + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = uuid.uuid4().hex[:8] + file_name = f"documento_{timestamp}_{unique_id}.pdf" + else: + file_name = doc_schema.nome_documento + + # Salva o arquivo físico + file_url_path = save_file_from_base64( + base64_content, file_name, ato_principal_id, UPLOAD_DIR, GROUP_SIZE + ) + + # 5️ Atribui o caminho salvo (ou None) + doc_data["url"] = file_url_path + + # 6️ Criptografa os metadados do documento + campos_criptografar = ["url", "nome_documento", "tipo_documento"] + + for campo in campos_criptografar: + valor = doc_data.get(campo) + if isinstance(valor, str) and valor.strip(): + doc_data[campo] = func.aes_encrypt(valor, AES_KEY) + else: + doc_data[campo] = None + + # 7️ Cria o registro no banco + new_documento = AtoDocumento(**doc_data, ato_principal_id=ato_principal_id) + db.add(new_documento) + + return new_documento + + # ------------------------------------------------------- + # Método principal — grava múltiplos documentos + # ------------------------------------------------------- + def execute(self, documentos: List[AtoDocumentoSaveMultipleSchema]): + db = SessionLocal() + results = [] + + if not AES_KEY: + db.close() + raise Exception("A chave AES (aeskey) não está configurada.") + + for doc_schema in documentos: + codigo_selo_log = getattr(doc_schema, "codigo_selo", "SELO_INDISPONÍVEL") + + try: + new_doc = self._save_documento(db, doc_schema) + db.commit() + + results.append( + { + "success": True, + "message": "Documento salvo com sucesso.", + "data": { + "codigo_selo": codigo_selo_log, + "ato_principal_id": new_doc.ato_principal_id, + }, + } + ) + + except HTTPException as he: + db.rollback() + results.append( + { + "success": False, + "error": he.detail, + "data": {"codigo_selo": codigo_selo_log}, + } + ) + + except Exception as e: + db.rollback() + print(f"ERRO AO SALVAR DOCUMENTO (SELO {codigo_selo_log}): {e}") + traceback.print_exc() + + results.append( + { + "success": False, + "error": "Erro interno ao salvar o documento.", + "data": {"codigo_selo": codigo_selo_log}, + } + ) + + db.close() + return results diff --git a/packages/v1/administrativo/repositories/ato_principal/ato_principal_save_multiple_repository.py b/packages/v1/administrativo/repositories/ato_principal/ato_principal_save_multiple_repository.py index 22f2e49..2910e53 100644 --- a/packages/v1/administrativo/repositories/ato_principal/ato_principal_save_multiple_repository.py +++ b/packages/v1/administrativo/repositories/ato_principal/ato_principal_save_multiple_repository.py @@ -4,6 +4,7 @@ import traceback from sqlalchemy import func from sqlalchemy.orm import Session # Importação para tipagem da session from database.mysql import SessionLocal, get_database_settings +from actions.file.save_file_from_base64 import save_file_from_base64 from packages.v1.administrativo.models.ato_principal_model import AtoPrincipal from packages.v1.administrativo.models.ato_parte_model import AtoParte from packages.v1.administrativo.models.ato_documento_model import AtoDocumento @@ -12,7 +13,6 @@ from packages.v1.administrativo.schemas.ato_principal_schema import ( ) # NOVAS IMPORTAÇÕES para lidar com Base64 e Arquivos -import base64 import os from pathlib import Path import uuid @@ -32,56 +32,6 @@ GROUP_SIZE = int( UPLOAD_DIR = Path(os.environ.get("STORAGE", "./storage")) -# Salva o arquivo Base64 em disco e retorna o caminho completo -def _save_file_from_base64(base64_content: str, file_name: str, ato_id: int) -> str: - """ - Decodifica o Base64, salva o arquivo em disco dentro de uma hierarquia - baseada no 'ato_id', e retorna o caminho completo do arquivo salvo. - - Estrutura de diretórios: - storage/ - 100/ - 57/ - documento.pdf - """ - # Garante que o diretório base de uploads existe - UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - - # --- NOVO BLOCO: define a faixa de agrupamento --- - # Exemplo: IDs 1–100 vão para pasta 100; IDs 101–200 → 200 - faixa_superior = ((ato_id - 1) // GROUP_SIZE + 1) * GROUP_SIZE - - # Define o caminho hierárquico: storage/100/57/ - target_dir = UPLOAD_DIR / str(faixa_superior) / str(ato_id) - target_dir.mkdir(parents=True, exist_ok=True) - - # Caminho completo do arquivo final - file_path = target_dir / file_name - - # Caminho para visualização via url (relativo ao storage) - # Exemplo: "100/57/documento.pdf" - file_url = f"{faixa_superior}/{ato_id}/{file_name}" - - try: - # Decodifica o Base64 - file_bytes = base64.b64decode(base64_content) - - # Grava o arquivo em disco - with open(file_path, "wb") as f: - f.write(file_bytes) - - # Retorna o caminho completo do arquivo salvo para visualização via URl - return str(file_url) - - except base64.binascii.Error: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"O conteúdo do arquivo '{file_name}' não é um Base64 válido.", - ) - except Exception as e: - raise Exception(f"Erro inesperado ao salvar arquivo '{file_name}': {e}") - - class SaveMultipleRepository: """ Repositório para salvar múltiplos atos principais com suas partes e documentos, @@ -192,8 +142,8 @@ class SaveMultipleRepository: else: file_name = doc.nome_documento - file_url_path = _save_file_from_base64( - base64_content, file_name, new_ato_id + file_url_path = save_file_from_base64( + base64_content, file_name, new_ato_id, UPLOAD_DIR, GROUP_SIZE ) # 2. Atribui o caminho do arquivo salvo (ou None, se não foi enviado) ao campo 'url' do banco diff --git a/packages/v1/administrativo/schemas/ato_documento_schema.py b/packages/v1/administrativo/schemas/ato_documento_schema.py index 0ada21a..c2a12d7 100644 --- a/packages/v1/administrativo/schemas/ato_documento_schema.py +++ b/packages/v1/administrativo/schemas/ato_documento_schema.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, constr, field_validator, validator +from pydantic import BaseModel, constr, field_validator, model_validator from fastapi import HTTPException, status from typing import Optional from datetime import datetime @@ -68,6 +68,39 @@ class AtoDocumentoSaveSchema(BaseModel): return Text.sanitize_input(v) +# ---------------------------------------------------- +# Schema para Criação (SAVE MULTIPLE): Campos obrigatórios e sem ID +# ---------------------------------------------------- +class AtoDocumentoSaveMultipleSchema(BaseModel): + + # Campos opcionais + arquivo: Optional[str] = None + nome_documento: Optional[str] = None + tipo_documento: Optional[str] = None + codigo_selo: Optional[str] = None + + # ---------------------------------------------------- + # Sanitização dos campos opcionais (remove HTML, espaços, etc.) + # ---------------------------------------------------- + @field_validator( + "arquivo", "nome_documento", "tipo_documento", "codigo_selo", mode="before" + ) + def sanitize_optional_fields(cls, v: Optional[str]): + if v is None: + return None + return Text.sanitize_input(v) + + # ---------------------------------------------------- + # Validação final do schema: exige codigo_selo informado + # ---------------------------------------------------- + @model_validator(mode="after") + def validate_codigo_selo(cls, values): + # Caso o código do selo não tenha sido informado, gera erro + if not values.codigo_selo or values.codigo_selo.strip() == "": + raise ValueError("O campo 'codigo_selo' é obrigatório.") + return values + + # ---------------------------------------------------- # Schema para Atualização (UPDATE): Todos opcionais (parciais) # ---------------------------------------------------- diff --git a/packages/v1/administrativo/services/ato_documento/ato_documento_save_multiple_service.py b/packages/v1/administrativo/services/ato_documento/ato_documento_save_multiple_service.py new file mode 100644 index 0000000..844e706 --- /dev/null +++ b/packages/v1/administrativo/services/ato_documento/ato_documento_save_multiple_service.py @@ -0,0 +1,22 @@ +from fastapi import status, HTTPException + +# Assumindo a existência dos Schemas com o prefixo 'ato_principal' +from packages.v1.administrativo.schemas.ato_documento_schema import ( + AtoDocumentoSaveMultipleSchema, +) + +# Assumindo a existência da Action de salvamento com o novo prefixo +from packages.v1.administrativo.actions.ato_documento.ato_documento_save_multiple_action import ( + SaveMultipleAction, +) + + +class SaveMultipleService: + + def execute(self, ato_documento: list[AtoDocumentoSaveMultipleSchema]): + save_action = SaveMultipleAction() + + # A lista completa é passada diretamente para a Action. + results = save_action.execute(ato_documento) + + return results