From 123e7317b79f421980bb9af83dde0eb5ed5de20a Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Sun, 20 Sep 2026 16:47:27 -0300 Subject: [PATCH 01/33] feat/models: adiciona models e enums para anexo --- .gitignore | 1 + app/models/anexo.py | 23 +++++++++++++++++++++++ app/models/enums.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 app/models/anexo.py create mode 100644 app/models/enums.py diff --git a/.gitignore b/.gitignore index e69de29..4f509e5 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +*.env \ No newline at end of file diff --git a/app/models/anexo.py b/app/models/anexo.py new file mode 100644 index 0000000..ffe88b4 --- /dev/null +++ b/app/models/anexo.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from enums import TipoArquivo, Categoria, TipoImagem, Status + +class AnexoRequest(BaseModel): + id_empresa: int + id_ciclo: int + criado_por: int + id_origem: int + nome_arquivo: str + tipo_arquivo: TipoArquivo | TipoImagem + tamanho_arquivo: int + bucket_arquivo: str + caminho_arquivo: str + categoria: Categoria + descricao: str | None = None + +class Anexo(AnexoRequest): + id: int + status: Status + criado_em: datetime = Field(default_factory=datetime.now) + atualizado_em: datetime | None = None + excluido_em: datetime | None = None \ No newline at end of file diff --git a/app/models/enums.py b/app/models/enums.py new file mode 100644 index 0000000..0cd2281 --- /dev/null +++ b/app/models/enums.py @@ -0,0 +1,36 @@ +from enum import Enum + +class TipoImagem(Enum): + JPEG = 'image/jpeg' + PNG = 'image/png' + WEBP = 'image/webp' + GIF = 'image/gif' + SVG = 'image/svg+xml' + BMP = 'image/bmp' + TIFF = 'image/tiff' + +class TipoArquivo(Enum): + PDF = 'application/pdf' + DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + CSV = 'text/csv' + TXT = 'text/plain' + +class Categoria(Enum): + TREINAMENTO = 'TREINAMENTO' + PLANO_ACAO = 'PLANO_ACAO' + CAUSA_RAIZ = 'CAUSA_RAIZ' + PROBLEMA = 'PROBLEMA' + META = 'META' + RELATORIO = 'RELATORIO' + LICAO_APRENDIDA = 'LICAO_APRENDIDA' + FORMULARIO = 'FORMULARIO' + EVIDENCIA = 'EVIDENCIA' + OUTRO = 'OUTRO' + +class Status(Enum): + PROCESSANDO = 'PROCESSANDO' + ATIVO = 'ATIVO' + ERRO = 'ERRO' + EXCLUIDO = 'EXCLUIDO' \ No newline at end of file From 1d74491a840ce3b7cae5467be59511510d8e1232 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Sun, 20 Sep 2026 22:41:03 -0300 Subject: [PATCH 02/33] =?UTF-8?q?feat/conn:=20adiciona=20m=C3=A9todo=20de?= =?UTF-8?q?=20conex=C3=A3o=20com=20o=20banco=20postgres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 ++++++++ .gitignore | 3 ++- app/database/postgres.py | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 app/database/postgres.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a9566cd --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DB_URL=postgresql://usuario:senha@localhost:5432/nome_do_banco + +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +REDIS_HOST=localhost +REDIS_PORT=6379 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f509e5..dc025ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -*.env \ No newline at end of file +*.env +docs/ \ No newline at end of file diff --git a/app/database/postgres.py b/app/database/postgres.py new file mode 100644 index 0000000..858f817 --- /dev/null +++ b/app/database/postgres.py @@ -0,0 +1,19 @@ +from fastapi import HTTPException +from dotenv import load_dotenv +from os import getenv +from psycopg2 import connect + +load_dotenv() + +DB_URL = getenv('DB_URL') + +def get_conn(): + conn = connect(DB_URL) + + try: + yield conn + except Exception as e: + raise HTTPException(status_code=500, detail='Erro no banco de dados') + finally: + if conn is not None: + conn.close() \ No newline at end of file From 6f0b477bf251d91b7efd8297bd559f76c0b6d705 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Sun, 20 Sep 2026 23:21:52 -0300 Subject: [PATCH 03/33] =?UTF-8?q?feat/redis:=20implementa=20conex=C3=A3o?= =?UTF-8?q?=20do=20redis=20usando=20pool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- app/database/redis.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 app/database/redis.py diff --git a/.gitignore b/.gitignore index dc025ad..6ed6fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.env -docs/ \ No newline at end of file +docs/ +*.pyc \ No newline at end of file diff --git a/app/database/redis.py b/app/database/redis.py new file mode 100644 index 0000000..1fbfcdb --- /dev/null +++ b/app/database/redis.py @@ -0,0 +1,23 @@ +from fastapi import HTTPException +from redis import Redis, ConnectionPool +from redis.exceptions import RedisError +from dotenv import load_dotenv +from os import getenv +from functools import lru_cache + +load_dotenv() + +REDIS_HOST = getenv('REDIS_HOST') +REDIS_PORT = getenv('REDIS_PORT') +REDIS_DB = getenv('REDIS_DB') +REDIS_PASSWORD = getenv('REDIS_PASSWORD') + +@lru_cache # apenas um pool por sessão +def get_pool() -> ConnectionPool: + return ConnectionPool(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, password=REDIS_PASSWORD, max_connections=20, retry_on_timeout=True) + +def get_conn() -> Redis: + try: + return Redis(connection_pool=get_pool()) + except RedisError as re: + raise HTTPException(status_code=503, detail='Redis indisponível') \ No newline at end of file From c91533ffe6cc6bbb34af005a65d1961226da9f2c Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 01:54:31 -0300 Subject: [PATCH 04/33] =?UTF-8?q?feat/enums:=20adiciona=20novas=20categori?= =?UTF-8?q?as=20e=20status=20como=20enums,=20e=20define=20extens=C3=B5es?= =?UTF-8?q?=20permitidas=20para=20tipos=20de=20imagem=20e=20arquivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/enums.py | 48 +++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/app/models/enums.py b/app/models/enums.py index 0cd2281..dd64352 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -1,5 +1,23 @@ from enum import Enum +class Categoria(Enum): + TREINAMENTO = 'TREINAMENTO' + PLANO_ACAO = 'PLANO_ACAO' + CAUSA_RAIZ = 'CAUSA_RAIZ' + PROBLEMA = 'PROBLEMA' + META = 'META' + RELATORIO = 'RELATORIO' + LICAO_APRENDIDA = 'LICAO_APRENDIDA' + FORMULARIO = 'FORMULARIO' + EVIDENCIA = 'EVIDENCIA' + OUTRO = 'OUTRO' + +class Status(Enum): + PROCESSANDO = 'PROCESSANDO' + ATIVO = 'ATIVO' + ERRO = 'ERRO' + EXCLUIDO = 'EXCLUIDO' + class TipoImagem(Enum): JPEG = 'image/jpeg' PNG = 'image/png' @@ -17,20 +35,16 @@ class TipoArquivo(Enum): CSV = 'text/csv' TXT = 'text/plain' -class Categoria(Enum): - TREINAMENTO = 'TREINAMENTO' - PLANO_ACAO = 'PLANO_ACAO' - CAUSA_RAIZ = 'CAUSA_RAIZ' - PROBLEMA = 'PROBLEMA' - META = 'META' - RELATORIO = 'RELATORIO' - LICAO_APRENDIDA = 'LICAO_APRENDIDA' - FORMULARIO = 'FORMULARIO' - EVIDENCIA = 'EVIDENCIA' - OUTRO = 'OUTRO' - -class Status(Enum): - PROCESSANDO = 'PROCESSANDO' - ATIVO = 'ATIVO' - ERRO = 'ERRO' - EXCLUIDO = 'EXCLUIDO' \ No newline at end of file +# não incluso svg, txt e csv porque são texto puro e devem ser validados de outra forma +EXTENSOES_PERMITIDAS = set({ + TipoImagem.JPEG: 'jpg', + TipoImagem.PNG: 'png', + TipoImagem.WEBP: 'webp', + TipoImagem.GIF: 'gif', + TipoImagem.BMP: 'bmp', + TipoImagem.TIFF: 'tif', + TipoArquivo.PDF: 'pdf', + TipoArquivo.DOCX: 'docx', + TipoArquivo.PPTX: 'pptx', + TipoArquivo.XLSX: 'xlsx' +}.values()) \ No newline at end of file From 571b7485fd020a65d46bbe310ea6c1bddc4717cd Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 01:55:13 -0300 Subject: [PATCH 05/33] =?UTF-8?q?feat/validation:=20implementa=20validador?= =?UTF-8?q?es=20para=20tipos=20de=20arquivos,=20incluindo=20TXT,=20CSV=20e?= =?UTF-8?q?=20SVG,=20e=20adiciona=20verifica=C3=A7=C3=A3o=20de=20tamanho?= =?UTF-8?q?=20e=20tipo=20de=20anexo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/utils/sem_magic_bytes.py | 50 ++++++++++++++++++++++++++++++++++++ app/utils/validacao.py | 39 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 app/utils/sem_magic_bytes.py create mode 100644 app/utils/validacao.py diff --git a/app/utils/sem_magic_bytes.py b/app/utils/sem_magic_bytes.py new file mode 100644 index 0000000..f5e56f4 --- /dev/null +++ b/app/utils/sem_magic_bytes.py @@ -0,0 +1,50 @@ +from codecs import getincrementaldecoder +from csv import Sniffer, Error +from fastapi import UploadFile, HTTPException +from defusedxml.ElementTree import fromstring + +async def _validar_text(arquivo: UploadFile) -> str: + # lê apenas primeiros 4kb + header = await arquivo.read(4096) + await arquivo.seek(0) + + if b'\x00' in header: + raise HTTPException(400, 'Arquivo não é um TXT puro') + + try: + # se houver caractere incompleto no final do chunk não lança exceção + # (pode ter sido fatiado ao pegar os primeiros 4kb) + decoder = getincrementaldecoder('utf-8')() + return decoder.decode(header, final=False) + except UnicodeDecodeError: + raise HTTPException(400, 'Arquivo TXT não está em UTF-8') + +async def validar_plain(arquivo: UploadFile) -> None: + await _validar_text(arquivo) + + +async def validar_csv(arquivo: UploadFile) -> None: + amostra = await _validar_text(arquivo) + + try: + Sniffer().sniff(amostra) + except Error: + raise HTTPException(400, 'Arquivo não é um CSV puro') + +async def validar_svg(arquivo: UploadFile) -> None: + conteudo = await arquivo.read() + await arquivo.seek(0) + + try: + root = fromstring(conteudo) + except Exception: + raise HTTPException(400, 'Arquivo não é um SVG puro') + + if not root.tag.endswith('svg'): + raise HTTPException(400, 'Arquivo não é um SVG puro') + +VALIDADORES_TEXTO ={ + 'txt': validar_plain, + 'csv': validar_csv, + 'svg': validar_svg +} \ No newline at end of file diff --git a/app/utils/validacao.py b/app/utils/validacao.py new file mode 100644 index 0000000..fbc9419 --- /dev/null +++ b/app/utils/validacao.py @@ -0,0 +1,39 @@ +import filetype +from fastapi import UploadFile, HTTPException, Path +from models.enums import EXTENSOES_PERMITIDAS +from sem_magic_bytes import VALIDADORES_TEXTO + +TAMANHO_MAXIMO_MB = 10 +TAMANHO_MAXIMO_BYTES = TAMANHO_MAXIMO_MB * 1024 * 1024 + +async def validar_tipo(arquivo: UploadFile) -> str: + # todos os filetypes estão nos primeiros 261 bytes + # não é necessário ler todo o arquivo para saber o tipo + header = await arquivo.read(261) + await arquivo.seek(0) # cursor volta para início do arquivo + + tipo = filetype.guess(header) + if tipo is not None: + if tipo.extension not in EXTENSOES_PERMITIDAS: + raise HTTPException(400, 'Tipo de arquivo não permitido') + return tipo.extension + + extensao = Path(arquivo.filename).suffix().lstrip('.').lower() + validador = VALIDADORES_TEXTO.get(extensao) + + if validador is None: + raise HTTPException(400, 'Tipo de arquivo não reconhecido') + + await validador(arquivo) + return tipo.extension + +def validar_tamanho(arquivo: UploadFile) -> bytes: + if not arquivo.size: + raise HTTPException(400, 'Arquivo vazio') + + if arquivo.size > TAMANHO_MAXIMO_BYTES: + raise HTTPException(413, f'Arquivo excede {TAMANHO_MAXIMO_MB}mb') + +async def validar_anexo(arquivo: UploadFile) -> bytes: + validar_tamanho(arquivo) + return await validar_tipo(arquivo) \ No newline at end of file From f9518da6392303b423c669049a1abaea406c65a3 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 02:03:46 -0300 Subject: [PATCH 06/33] =?UTF-8?q?fix/validacao:=20corrige=20verifica=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20extens=C3=A3o=20de=20arquivo=20e=20ajusta=20tipo?= =?UTF-8?q?s=20de=20retorno=20nas=20fun=C3=A7=C3=B5es=20de=20valida=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/utils/sem_magic_bytes.py | 1 + app/utils/validacao.py | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/utils/sem_magic_bytes.py b/app/utils/sem_magic_bytes.py index f5e56f4..938d3cf 100644 --- a/app/utils/sem_magic_bytes.py +++ b/app/utils/sem_magic_bytes.py @@ -8,6 +8,7 @@ async def _validar_text(arquivo: UploadFile) -> str: header = await arquivo.read(4096) await arquivo.seek(0) + # verifica se existe byte nulo (exclusivos de arquivos que não são texto puro) if b'\x00' in header: raise HTTPException(400, 'Arquivo não é um TXT puro') diff --git a/app/utils/validacao.py b/app/utils/validacao.py index fbc9419..b63c029 100644 --- a/app/utils/validacao.py +++ b/app/utils/validacao.py @@ -1,7 +1,8 @@ import filetype -from fastapi import UploadFile, HTTPException, Path +from fastapi import UploadFile, HTTPException from models.enums import EXTENSOES_PERMITIDAS from sem_magic_bytes import VALIDADORES_TEXTO +from pathlib import Path TAMANHO_MAXIMO_MB = 10 TAMANHO_MAXIMO_BYTES = TAMANHO_MAXIMO_MB * 1024 * 1024 @@ -18,22 +19,22 @@ async def validar_tipo(arquivo: UploadFile) -> str: raise HTTPException(400, 'Tipo de arquivo não permitido') return tipo.extension - extensao = Path(arquivo.filename).suffix().lstrip('.').lower() + extensao = Path(arquivo.filename).suffix.lstrip('.').lower() validador = VALIDADORES_TEXTO.get(extensao) if validador is None: raise HTTPException(400, 'Tipo de arquivo não reconhecido') await validador(arquivo) - return tipo.extension + return extensao -def validar_tamanho(arquivo: UploadFile) -> bytes: +def validar_tamanho(arquivo: UploadFile) -> None: if not arquivo.size: raise HTTPException(400, 'Arquivo vazio') if arquivo.size > TAMANHO_MAXIMO_BYTES: raise HTTPException(413, f'Arquivo excede {TAMANHO_MAXIMO_MB}mb') -async def validar_anexo(arquivo: UploadFile) -> bytes: +async def validar_anexo(arquivo: UploadFile) -> str: validar_tamanho(arquivo) return await validar_tipo(arquivo) \ No newline at end of file From de9ab5ee5da70c7f7bf318f86c2d6976f4b40556 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 09:02:45 -0300 Subject: [PATCH 07/33] =?UTF-8?q?fix/anexo:=20ajusta=20importa=C3=A7=C3=A3?= =?UTF-8?q?o=20de=20enums=20e=20permite=20valores=20nulos=20para=20caminho?= =?UTF-8?q?=5Farquivo=20e=20descricao?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/anexo.py | 4 ++-- app/models/enums.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/anexo.py b/app/models/anexo.py index ffe88b4..97872ad 100644 --- a/app/models/anexo.py +++ b/app/models/anexo.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field from datetime import datetime -from enums import TipoArquivo, Categoria, TipoImagem, Status +from app.models.enums import TipoArquivo, Categoria, TipoImagem, Status class AnexoRequest(BaseModel): id_empresa: int @@ -11,7 +11,7 @@ class AnexoRequest(BaseModel): tipo_arquivo: TipoArquivo | TipoImagem tamanho_arquivo: int bucket_arquivo: str - caminho_arquivo: str + caminho_arquivo: str | None = None categoria: Categoria descricao: str | None = None diff --git a/app/models/enums.py b/app/models/enums.py index dd64352..22cbb77 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -38,6 +38,7 @@ class TipoArquivo(Enum): # não incluso svg, txt e csv porque são texto puro e devem ser validados de outra forma EXTENSOES_PERMITIDAS = set({ TipoImagem.JPEG: 'jpg', + TipoImagem.JPEG: 'jpeg', TipoImagem.PNG: 'png', TipoImagem.WEBP: 'webp', TipoImagem.GIF: 'gif', From 89ebc38e7aa8ffdb38d78b071ef07013f39d6538 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 09:04:14 -0300 Subject: [PATCH 08/33] =?UTF-8?q?feat/postgres:=20refatora=20conex=C3=A3o?= =?UTF-8?q?=20do=20banco=20de=20dados=20com=20context=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/database/postgres.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/database/postgres.py b/app/database/postgres.py index 858f817..f88f4d2 100644 --- a/app/database/postgres.py +++ b/app/database/postgres.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from fastapi import HTTPException from dotenv import load_dotenv from os import getenv @@ -7,13 +8,25 @@ DB_URL = getenv('DB_URL') +def _conectar(): + return connect(DB_URL) + def get_conn(): - conn = connect(DB_URL) + try: + conn = _conectar() + except Exception: + raise HTTPException(status_code=503, detail='Erro no banco de dados') try: yield conn - except Exception as e: - raise HTTPException(status_code=500, detail='Erro no banco de dados') finally: - if conn is not None: - conn.close() \ No newline at end of file + conn.close() + +@contextmanager # permite usar def com with +def conn_worker(): + conn = _conectar() + + try: + yield conn + finally: + conn.close() \ No newline at end of file From 3ec3f99ae15cd6e092506b91bd55dbc926da6bec Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 09:24:26 -0300 Subject: [PATCH 09/33] =?UTF-8?q?feat/schemas:=20adiciona=20classes=20Anex?= =?UTF-8?q?oForm=20e=20UploadJob=20para=20manipula=C3=A7=C3=A3o=20de=20for?= =?UTF-8?q?mul=C3=A1rios=20e=20upload=20de=20anexos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/schemas.py diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..1f3a017 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,27 @@ +from fastapi import Form +from pydantic import BaseModel +from app.models.enums import Categoria + +class AnexoForm(BaseModel): + def __init__( + self, + id_empresa: int = Form(...), + id_ciclo: int = Form(...), + criado_por: int = Form(...), + id_origem: int = Form(...), + categoria: Categoria = Form(...), + descricao: str | None = Form(None), + ): + self.id_empresa = id_empresa + self.id_ciclo = id_ciclo + self.criado_por = criado_por + self.id_origem = id_origem + self.categoria = categoria + self.descricao = descricao + + +class UploadJob(BaseModel): + id_anexo: int + caminho_arquivo: str + nome_original: str + extensao: str \ No newline at end of file From 06693c057772521dfd4be6550e57b8c7b9b40ef8 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 10:04:53 -0300 Subject: [PATCH 10/33] =?UTF-8?q?feat/anexos:=20implementa=20rota=20para?= =?UTF-8?q?=20cria=C3=A7=C3=A3o=20e=20processamento=20de=20anexos,=20inclu?= =?UTF-8?q?indo=20valida=C3=A7=C3=A3o=20e=20gerenciamento=20de=20erros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes/anexos.py | 64 ++++++++++++++++++++++++++++++++++++++++++ app/utils/validacao.py | 4 +-- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 app/routes/anexos.py diff --git a/app/routes/anexos.py b/app/routes/anexos.py new file mode 100644 index 0000000..6cda045 --- /dev/null +++ b/app/routes/anexos.py @@ -0,0 +1,64 @@ +from pathlib import Path +from uuid import uuid4 + +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, status +from rq import Retry + +from app.database.postgres import get_conn +from app.utils.validacao import validar_anexo +from app.services.fila import fila_uploads +from app.services.jobs import processar_upload +from app.models.enums import Status +from app.schemas import AnexoForm, UploadJob + +DIR_TEMP = Path('/tmp/uploads') +DIR_TEMP.mkdir(parents=True, exist_ok=True) + +router = APIRouter(prefix='/anexos', tags=['anexos']) + +@router.post('', status_code=status.HTTP_202_ACCEPTED) +async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn)): + extensao = await validar_anexo(arquivo) + + id_anexo = _inserir_metadados(conn, arquivo, dados, extensao) + caminho_temp = DIR_TEMP / f'{id_anexo}_{uuid4().hex}{Path(arquivo.filename).suffix}' + + try: + caminho_temp.write_bytes(await arquivo.read()) + except Exception: + _atualizar_status_erro(conn, id_anexo) + raise HTTPException(500, 'Falha ao salvar arquivo temporário') + + try: + fila_uploads.enqueue( + processar_upload, UploadJob( + id_anexo=id_anexo, + caminho_arquivo=str(caminho_temp), + nome_original=arquivo.filename, + extensao=extensao), + retry=Retry(max=3, interval=[10, 30, 60]), result_ttl=3600, failure_ttl=86400 + ) + except Exception: + _atualizar_status_erro(conn, id_anexo) + caminho_temp.unlink(missing_ok=True) + raise HTTPException(503, 'Não foi possível enfileirar o processamento') + + return {'id': id_anexo, 'status': Status.PROCESSANDO.value} + +def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str) -> int: + with conn.cursor as cursor: + cursor.execute(""" + INSERT INTO pdca.anexo (id_empresa, id_ciclo, criado_por, id_origem, nome_arquivo, tipo_arquivo, tamanho_arquivo, bucket_arquivo, caminho_arquivo, categoria, descricao, status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, %s, %s) + RETURNING id + """, + (dados.id_empresa, dados.id_ciclo, dados.criado_por, dados.id_origem, arquivo.filename, arquivo.content_type or extensao, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value)) + + id_anexo = cursor.fetchone()[0] + conn.commit() + return id_anexo + +def _atualizar_status_erro(conn, id_anexo: int) -> None: + with conn.cursor() as cursor: + cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (Status.ERRO.value, id_anexo)) + conn.commit() \ No newline at end of file diff --git a/app/utils/validacao.py b/app/utils/validacao.py index b63c029..f01b41c 100644 --- a/app/utils/validacao.py +++ b/app/utils/validacao.py @@ -1,7 +1,7 @@ import filetype from fastapi import UploadFile, HTTPException -from models.enums import EXTENSOES_PERMITIDAS -from sem_magic_bytes import VALIDADORES_TEXTO +from app.models.enums import EXTENSOES_PERMITIDAS +from app.utils.sem_magic_bytes import VALIDADORES_TEXTO from pathlib import Path TAMANHO_MAXIMO_MB = 10 From 9e7046363fbcdd0137aec5c20411c6764e7875bb Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 10:07:33 -0300 Subject: [PATCH 11/33] =?UTF-8?q?feat/fila:=20adiciona=20servi=C3=A7o=20de?= =?UTF-8?q?=20fila=20para=20uploads=20utilizando=20Redis=20com=20timeout?= =?UTF-8?q?=20de=205=20minutos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/fila.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 app/services/fila.py diff --git a/app/services/fila.py b/app/services/fila.py new file mode 100644 index 0000000..0830998 --- /dev/null +++ b/app/services/fila.py @@ -0,0 +1,8 @@ +from rq import Queue +from redis import Redis +from app.database.redis import get_pool + +conn = Redis(connection_pool=get_pool()) + +# 5 minutos de tolerância do job +fila_uploads = Queue('uploads', connection=conn, default_timeout=300) \ No newline at end of file From 8908313c349fd0382320fdb1d8f9c47bcc21cb41 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 11:13:58 -0300 Subject: [PATCH 12/33] =?UTF-8?q?feat/anexos:=20adiciona=20autentica=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20usu=C3=A1rio=20na=20cria=C3=A7=C3=A3o=20de=20ane?= =?UTF-8?q?xos,=20incluindo=20o=20registro=20do=20usu=C3=A1rio=20que=20cri?= =?UTF-8?q?ou=20o=20anexo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +++- app/routes/anexos.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index a9566cd..4b7e759 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,7 @@ CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= +ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 + REDIS_HOST=localhost -REDIS_PORT=6379 \ No newline at end of file +REDIS_PORT=6379 diff --git a/app/routes/anexos.py b/app/routes/anexos.py index 6cda045..529d9c1 100644 --- a/app/routes/anexos.py +++ b/app/routes/anexos.py @@ -9,7 +9,8 @@ from app.services.fila import fila_uploads from app.services.jobs import processar_upload from app.models.enums import Status -from app.schemas import AnexoForm, UploadJob +from app.schemas import AnexoForm, UploadJob, UsuarioAutenticado +from app.services.autenticacao import obter_usuario_autenticado DIR_TEMP = Path('/tmp/uploads') DIR_TEMP.mkdir(parents=True, exist_ok=True) @@ -17,10 +18,10 @@ router = APIRouter(prefix='/anexos', tags=['anexos']) @router.post('', status_code=status.HTTP_202_ACCEPTED) -async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn)): +async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): extensao = await validar_anexo(arquivo) - id_anexo = _inserir_metadados(conn, arquivo, dados, extensao) + id_anexo = _inserir_metadados(conn, arquivo, dados, extensao, usuario) caminho_temp = DIR_TEMP / f'{id_anexo}_{uuid4().hex}{Path(arquivo.filename).suffix}' try: @@ -45,14 +46,15 @@ async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depend return {'id': id_anexo, 'status': Status.PROCESSANDO.value} -def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str) -> int: - with conn.cursor as cursor: +def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str, usuario: UsuarioAutenticado) -> int: + with conn.cursor() as cursor: + cursor.execute("SELECT set_config('app.current_user_id', %s, true)", str(usuario.id_usuario)) cursor.execute(""" INSERT INTO pdca.anexo (id_empresa, id_ciclo, criado_por, id_origem, nome_arquivo, tipo_arquivo, tamanho_arquivo, bucket_arquivo, caminho_arquivo, categoria, descricao, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, %s, %s) RETURNING id """, - (dados.id_empresa, dados.id_ciclo, dados.criado_por, dados.id_origem, arquivo.filename, arquivo.content_type or extensao, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value)) + (usuario.id_empresa, dados.id_ciclo, usuario.id_usuario, dados.id_origem, arquivo.filename, arquivo.content_type or extensao, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value)) id_anexo = cursor.fetchone()[0] conn.commit() From 4db6d614a8f218d55a832af835a9dd446db8680f Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 11:39:03 -0300 Subject: [PATCH 13/33] =?UTF-8?q?feat/autenticacao:=20adiciona=20service?= =?UTF-8?q?=20para=20obter=20usu=C3=A1rio=20autenticado=20via=20token=20Be?= =?UTF-8?q?arer,=20com=20tratamento=20de=20erros=20para=20chamadas=20?= =?UTF-8?q?=C3=A0=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- app/services/autenticacao.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 app/services/autenticacao.py diff --git a/.env.example b/.env.example index 4b7e759..7ad9307 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,4 @@ CLOUDINARY_API_SECRET= ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 REDIS_HOST=localhost -REDIS_PORT=6379 +REDIS_PORT=6379 \ No newline at end of file diff --git a/app/services/autenticacao.py b/app/services/autenticacao.py new file mode 100644 index 0000000..ba8d4aa --- /dev/null +++ b/app/services/autenticacao.py @@ -0,0 +1,29 @@ +from os import getenv +from httpx import AsyncClient, RequestError +from fastapi import HTTPException, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.schemas import UsuarioAutenticado + +ACTA_PG_API_URL = getenv('ACTA_PG_API_URL') +bearer = HTTPBearer() + +async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = Security(bearer)) -> UsuarioAutenticado: + headers = {'Authorization': f'Bearer {credenciais.credentials}'} + + try: + async with AsyncClient(timeout=60) as cliente: + health = await cliente.get(f'{ACTA_PG_API_URL}/api/v1/health') + + if health.is_error: + raise HTTPException(503, 'acta-pg-api indisponível') + + resposta = await cliente.get(f'{ACTA_PG_API_URL}/api/v1/me', headers=headers) + except RequestError: + raise HTTPException(503, 'Não foi possível consultar acta-pg-api') + + if resposta.status_code in (401, 403): + raise HTTPException(401, 'Bearer token inválido') + + if resposta.is_error: + raise HTTPException(502, 'Falha ao consultar usuário autenticado') \ No newline at end of file From d03e605481d872aaa684034c54be4027ddfec4ff Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 11:42:12 -0300 Subject: [PATCH 14/33] =?UTF-8?q?refactor/schemas:=20transforma=20AnexoFor?= =?UTF-8?q?m=20em=20uma=20classe=20simples,=20removendo=20depend=C3=AAncia?= =?UTF-8?q?s=20do=20Pydantic=20e=20adiciona=20a=20classe=20UsuarioAutentic?= =?UTF-8?q?ado=20para=20representar=20usu=C3=A1rios=20autenticados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/schemas.py b/app/schemas.py index 1f3a017..9d9123b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -2,19 +2,15 @@ from pydantic import BaseModel from app.models.enums import Categoria -class AnexoForm(BaseModel): +class AnexoForm: def __init__( self, - id_empresa: int = Form(...), id_ciclo: int = Form(...), - criado_por: int = Form(...), id_origem: int = Form(...), categoria: Categoria = Form(...), descricao: str | None = Form(None), ): - self.id_empresa = id_empresa self.id_ciclo = id_ciclo - self.criado_por = criado_por self.id_origem = id_origem self.categoria = categoria self.descricao = descricao @@ -24,4 +20,8 @@ class UploadJob(BaseModel): id_anexo: int caminho_arquivo: str nome_original: str - extensao: str \ No newline at end of file + extensao: str + +class UsuarioAutenticado(BaseModel): + id_usuario: int + id_empresa: int \ No newline at end of file From f59c97a28f54a1362ca2d8e262412d80ec9a09c6 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 12:30:17 -0300 Subject: [PATCH 15/33] =?UTF-8?q?refactor/schemas:=20utiliza=20Field=20par?= =?UTF-8?q?a=20alias=20em=20UsuarioAutenticado=20e=20ajusta=20URLs=20da=20?= =?UTF-8?q?API=20para=20remover=20duplica=C3=A7=C3=A3o=20e=20melhora=20tra?= =?UTF-8?q?tamento=20de=20resposta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas.py | 7 +++---- app/services/autenticacao.py | 13 +++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/schemas.py b/app/schemas.py index 9d9123b..49943e2 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,5 +1,5 @@ from fastapi import Form -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.models.enums import Categoria class AnexoForm: @@ -15,7 +15,6 @@ def __init__( self.categoria = categoria self.descricao = descricao - class UploadJob(BaseModel): id_anexo: int caminho_arquivo: str @@ -23,5 +22,5 @@ class UploadJob(BaseModel): extensao: str class UsuarioAutenticado(BaseModel): - id_usuario: int - id_empresa: int \ No newline at end of file + id_usuario: int = Field(alias='idUsuario') + id_empresa: int = Field(alias='idEmpresa') \ No newline at end of file diff --git a/app/services/autenticacao.py b/app/services/autenticacao.py index ba8d4aa..1af0279 100644 --- a/app/services/autenticacao.py +++ b/app/services/autenticacao.py @@ -5,7 +5,7 @@ from app.schemas import UsuarioAutenticado -ACTA_PG_API_URL = getenv('ACTA_PG_API_URL') +ACTA_PG_API_URL = f'{getenv('ACTA_PG_API_URL')}/api/v1' bearer = HTTPBearer() async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = Security(bearer)) -> UsuarioAutenticado: @@ -13,12 +13,12 @@ async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = try: async with AsyncClient(timeout=60) as cliente: - health = await cliente.get(f'{ACTA_PG_API_URL}/api/v1/health') + health = await cliente.get(f'{ACTA_PG_API_URL}/health') if health.is_error: raise HTTPException(503, 'acta-pg-api indisponível') - resposta = await cliente.get(f'{ACTA_PG_API_URL}/api/v1/me', headers=headers) + resposta = await cliente.get(f'{ACTA_PG_API_URL}/me', headers=headers) except RequestError: raise HTTPException(503, 'Não foi possível consultar acta-pg-api') @@ -26,4 +26,9 @@ async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = raise HTTPException(401, 'Bearer token inválido') if resposta.is_error: - raise HTTPException(502, 'Falha ao consultar usuário autenticado') \ No newline at end of file + raise HTTPException(502, 'Falha ao consultar usuário autenticado') + + try: + return UsuarioAutenticado.model_validate(resposta.json()) + except (ValueError, TypeError): + raise HTTPException(502, 'Resposta inválida de acta-pg-api') \ No newline at end of file From 2194d0877d52feb228e4c292fcb6ff6b2e2fc185 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Mon, 21 Sep 2026 12:32:39 -0300 Subject: [PATCH 16/33] =?UTF-8?q?refactor/anexos:=20atualiza=20a=20fun?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20tratamento=20de=20erros=20para=20registrar?= =?UTF-8?q?=20o=20usu=C3=A1rio=20que=20gerou=20o=20erro=20e=20ajusta=20a?= =?UTF-8?q?=20execu=C3=A7=C3=A3o=20de=20comandos=20SQL=20para=20usar=20tup?= =?UTF-8?q?las?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes/anexos.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/routes/anexos.py b/app/routes/anexos.py index 529d9c1..132b0de 100644 --- a/app/routes/anexos.py +++ b/app/routes/anexos.py @@ -1,9 +1,7 @@ from pathlib import Path from uuid import uuid4 - from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, status from rq import Retry - from app.database.postgres import get_conn from app.utils.validacao import validar_anexo from app.services.fila import fila_uploads @@ -27,7 +25,7 @@ async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depend try: caminho_temp.write_bytes(await arquivo.read()) except Exception: - _atualizar_status_erro(conn, id_anexo) + _atualizar_status_erro(conn, id_anexo, usuario.id_usuario) raise HTTPException(500, 'Falha ao salvar arquivo temporário') try: @@ -40,7 +38,7 @@ async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depend retry=Retry(max=3, interval=[10, 30, 60]), result_ttl=3600, failure_ttl=86400 ) except Exception: - _atualizar_status_erro(conn, id_anexo) + _atualizar_status_erro(conn, id_anexo, usuario.id_usuario) caminho_temp.unlink(missing_ok=True) raise HTTPException(503, 'Não foi possível enfileirar o processamento') @@ -48,7 +46,7 @@ async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depend def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str, usuario: UsuarioAutenticado) -> int: with conn.cursor() as cursor: - cursor.execute("SELECT set_config('app.current_user_id', %s, true)", str(usuario.id_usuario)) + cursor.execute("SELECT set_config('app.current_user_id', %s, true)", (str(usuario.id_usuario),)) cursor.execute(""" INSERT INTO pdca.anexo (id_empresa, id_ciclo, criado_por, id_origem, nome_arquivo, tipo_arquivo, tamanho_arquivo, bucket_arquivo, caminho_arquivo, categoria, descricao, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, %s, %s) @@ -60,7 +58,11 @@ def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: st conn.commit() return id_anexo -def _atualizar_status_erro(conn, id_anexo: int) -> None: +def _atualizar_status_erro(conn, id_anexo: int, id_usuario: int) -> None: with conn.cursor() as cursor: + cursor.execute( + "SELECT set_config('app.current_user_id', %s, true)", + (str(id_usuario),), + ) cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (Status.ERRO.value, id_anexo)) conn.commit() \ No newline at end of file From a264e0b8c9ab593559a9d3daff3029c6e3eef9c7 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Tue, 22 Sep 2026 18:49:21 -0300 Subject: [PATCH 17/33] =?UTF-8?q?feat/cloudinary:=20adiciona=20servi=C3=A7?= =?UTF-8?q?o=20para=20upload=20de=20arquivos=20ao=20Cloudinary,=20incluind?= =?UTF-8?q?o=20suporte=20para=20tipos=20de=20arquivo=20raw=20e=20configura?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20credenciais=20a=20partir=20de=20vari=C3=A1?= =?UTF-8?q?veis=20de=20ambiente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/cloudinary.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/services/cloudinary.py diff --git a/app/services/cloudinary.py b/app/services/cloudinary.py new file mode 100644 index 0000000..64179b2 --- /dev/null +++ b/app/services/cloudinary.py @@ -0,0 +1,33 @@ +from cloudinary import config +from cloudinary.uploader import upload +from os import getenv +from dotenv import load_dotenv + +load_dotenv() + +CLOUD_NAME = getenv('CLOUDINARY_CLOUD_NAME') +API_KEY = getenv('CLOUDINARY_API_KEY') +API_SECRET = getenv('CLOUDINARY_API_SECRET') + +config(cloud_name=CLOUD_NAME, api_key=API_KEY, api_secret=API_SECRET, secure=True) + +# reforçar tipo raw, cloudinary não sabe lidar com esses arquivos +EXTENSOES_RAW = {'docx', 'pptx', 'xlsx', 'csv', 'txt'} + +# erros serão propagados para a fila no redis +def enviar_cloudinary(caminho_arquivo: str, nome_original: str, extensao: str, pasta: str = 'anexos', public_id: str | None = None) -> dict: + resultado = upload( + caminho_arquivo, + folder=pasta, + public_id=public_id, + resource_type='raw' if extensao in EXTENSOES_RAW else 'auto', + filename=nome_original, + use_filename=True, + unique_filename=True + ) + + return { + 'url': resultado['secure_url'], + 'public_id': resultado['public_id'], + 'resource_type': resultado['resource_type'] + } \ No newline at end of file From 6b28cba50e79a04c06ce84339d55a4aadcd5a519 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Tue, 22 Sep 2026 18:50:00 -0300 Subject: [PATCH 18/33] =?UTF-8?q?feat/jobs:=20implementa=20servi=C3=A7o=20?= =?UTF-8?q?para=20processar=20uploads=20de=20arquivos,=20incluindo=20atual?= =?UTF-8?q?iza=C3=A7=C3=A3o=20de=20status=20e=20tratamento=20de=20erros=20?= =?UTF-8?q?com=20Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/jobs.py | 43 +++++++++++++++++++++++++++++++++++++++++++ worker.py | 9 +++++++++ 2 files changed, 52 insertions(+) create mode 100644 app/services/jobs.py create mode 100644 worker.py diff --git a/app/services/jobs.py b/app/services/jobs.py new file mode 100644 index 0000000..6aec895 --- /dev/null +++ b/app/services/jobs.py @@ -0,0 +1,43 @@ +from rq import get_current_job +from pathlib import Path +from app.database.postgres import conn_worker +from app.services.cloudinary import enviar_cloudinary +from app.models.enums import Status +from app.schemas import UploadJob + +def processar_upload(job: UploadJob) -> None: + with conn_worker() as conn: + try: + _atualizar_status(conn, job.id_anexo, Status.PROCESSANDO) + resultado = enviar_cloudinary( + job.caminho_arquivo, + job.nome_original, + job.extensao, + public_id=f'anexos/{job.id_anexo}', + ) + + _atualizar_sucesso(conn, job.id_anexo, resultado) + _limpar_temp(job.caminho_arquivo) # só remove quando dá certo + except Exception: + job = get_current_job() + ultima_tentativa = job is None or not job.retries_left + + # se não for a útlima tentativa deixa o arquivo e status processando + # redis chama a função de novo... + if ultima_tentativa: + _atualizar_status(conn, job.id_anexo, Status.ERRO) + _limpar_temp(job.caminho_arquivo) + raise + +def _atualizar_status(conn, id_anexo: int, status: Status) -> None: + with conn.cursor() as cursor: + cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (status.value, id_anexo)) + conn.commit() + +def _atualizar_sucesso(conn, id_anexo: int, resultado: dict) -> None: + with conn.cursor() as cursor: + cursor.execute('UPDATE pdca.anexo SET status = %s, caminho_arquivo = %s WHERE id = %s', (Status.ATIVO.value, resultado['url'], id_anexo)) + conn.commit() + +def _limpar_temp(caminho_arquivo: str) -> None: + Path(caminho_arquivo).unlink(missing_ok=True) \ No newline at end of file diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..104bc19 --- /dev/null +++ b/worker.py @@ -0,0 +1,9 @@ +from redis import Redis +from rq import Worker + +from app.database.redis import get_pool +from app.services.fila import fila_uploads + + +if __name__ == '__main__': + Worker([fila_uploads], connection=Redis(connection_pool=get_pool())).work() \ No newline at end of file From 444741679d5ec1065eb7cbbeac3a27e711702ded Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Tue, 22 Sep 2026 18:50:17 -0300 Subject: [PATCH 19/33] =?UTF-8?q?feat/api:=20cria=20a=20aplica=C3=A7=C3=A3?= =?UTF-8?q?o=20FastAPI=20e=20integra=20o=20roteador=20de=20anexos=20para?= =?UTF-8?q?=20importa=C3=A7=C3=A3o=20de=20arquivos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..2302b22 --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from app.routes.anexos import router as anexos_router + +app = FastAPI( + title='ACTA Import API', + description='API de importação de arquivos de .pdf, .docx, .pptx, .txt, .xlsx, .csv e imagens para ciclos PDCA do ACTA' +) + +app.include_router(anexos_router) \ No newline at end of file From 8f0abcda74bc6591fef9287b241e5b4d9d75162b Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Tue, 22 Sep 2026 19:14:38 -0300 Subject: [PATCH 20/33] =?UTF-8?q?test:=20adiciona=20testes=20para=20valida?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20anexos,=20processamento=20de=20uploads=20e?= =?UTF-8?q?=20inser=C3=A7=C3=A3o=20de=20metadados,=20incluindo=20tratament?= =?UTF-8?q?o=20de=20erros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_anexos.py | 104 ++++++++++++++++++++++++++++++++++++++++ tests/test_jobs.py | 56 ++++++++++++++++++++++ tests/test_validacao.py | 52 ++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 tests/test_anexos.py create mode 100644 tests/test_jobs.py create mode 100644 tests/test_validacao.py diff --git a/tests/test_anexos.py b/tests/test_anexos.py new file mode 100644 index 0000000..629eb83 --- /dev/null +++ b/tests/test_anexos.py @@ -0,0 +1,104 @@ +from io import BytesIO +import asyncio +from pathlib import Path +from unittest.mock import ANY, AsyncMock, Mock + +import pytest +from fastapi import UploadFile +from starlette.datastructures import Headers + +from app.models.enums import Categoria, Status +from app.routes import anexos +from app.schemas import AnexoForm, UsuarioAutenticado + + +class Cursor: + def __init__(self, retorno=(42,)): + self.retorno = retorno + self.executados = [] + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, sql, parametros): + self.executados.append((sql, parametros)) + + def fetchone(self): + return self.retorno + + +class Connection: + def __init__(self): + self.cursor_obj = Cursor() + self.commits = 0 + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.commits += 1 + + +def dados(): + return AnexoForm(7, 9, Categoria.EVIDENCIA, "descrição") + + +def usuario(): + return UsuarioAutenticado(idUsuario=3, idEmpresa=5) + + +def test_insere_metadados_com_usuario_autenticado(): + conn = Connection() + arquivo = UploadFile(filename="arquivo.pdf", file=BytesIO(b"pdf"), headers=Headers({"content-type": "application/pdf"})) + arquivo.size = 3 + + resultado = anexos._inserir_metadados(conn, arquivo, dados(), "pdf", usuario()) + + assert resultado == 42 + assert conn.commits == 1 + assert "INSERT INTO pdca.anexo" in conn.cursor_obj.executados[1][0] + assert conn.cursor_obj.executados[1][1][:4] == (5, 7, 3, 9) + + +def test_criar_anexo_enfileira_upload_e_retorna_aceito(monkeypatch): + tmp_path = Path("tests/.tmp_uploads") + tmp_path.mkdir(exist_ok=True) + monkeypatch.setattr(anexos, "DIR_TEMP", tmp_path) + monkeypatch.setattr(anexos, "validar_anexo", AsyncMock(return_value="pdf")) + monkeypatch.setattr(anexos, "_inserir_metadados", Mock(return_value=42)) + enqueue = Mock() + monkeypatch.setattr(anexos.fila_uploads, "enqueue", enqueue) + + arquivo = UploadFile(filename="arquivo.pdf", file=BytesIO(b"pdf")) + arquivo.size = 3 + resposta = asyncio.run(anexos.criar_anexo(arquivo, dados(), Connection(), usuario())) + + assert resposta == {"id": 42, "status": Status.PROCESSANDO.value} + enqueue.assert_called_once() + assert enqueue.call_args.args[1].id_anexo == 42 + assert enqueue.call_args.args[1].extensao == "pdf" + + +def test_criar_anexo_marca_erro_se_falhar_ao_enfileirar(monkeypatch): + tmp_path = Path("tests/.tmp_uploads") + tmp_path.mkdir(exist_ok=True) + monkeypatch.setattr(anexos, "DIR_TEMP", tmp_path) + monkeypatch.setattr(anexos, "validar_anexo", AsyncMock(return_value="pdf")) + monkeypatch.setattr(anexos, "_inserir_metadados", Mock(return_value=42)) + monkeypatch.setattr(anexos.fila_uploads, "enqueue", Mock(side_effect=RuntimeError)) + atualizar = Mock() + monkeypatch.setattr(anexos, "_atualizar_status_erro", atualizar) + + with pytest.raises(Exception) as erro: + asyncio.run(anexos.criar_anexo( + UploadFile(filename="arquivo.pdf", file=BytesIO(b"pdf")), + dados(), + Connection(), + usuario(), + )) + + assert erro.value.status_code == 503 + atualizar.assert_called_once_with(ANY, 42, 3) diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..e72082c --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from app.models.enums import Status +from app.schemas import UploadJob +from app.services import jobs + + +class Cursor: + def __init__(self): + self.executados = [] + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, sql, parametros): + self.executados.append((sql, parametros)) + + +class Connection: + def __init__(self): + self.cursor_obj = Cursor() + self.commits = 0 + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.commits += 1 + + +def test_processa_upload_com_sucesso(monkeypatch): + caminho = Path("tests/.tmp_arquivo.pdf") + caminho.write_bytes(b"pdf") + conexao = Connection() + job = UploadJob(id_anexo=42, caminho_arquivo=str(caminho), nome_original="arquivo.pdf", extensao="pdf") + monkeypatch.setattr(jobs, "conn_worker", lambda: _contexto(conexao)) + monkeypatch.setattr(jobs, "enviar_cloudinary", lambda *args, **kwargs: {"url": "https://arquivo", "public_id": "42", "resource_type": "image"}) + + jobs.processar_upload(job) + + assert not caminho.exists() + assert any(Status.ATIVO.value in parametros for _, parametros in conexao.cursor_obj.executados) + + +class _contexto: + def __init__(self, valor): + self.valor = valor + + def __enter__(self): + return self.valor + + def __exit__(self, *args): + return False diff --git a/tests/test_validacao.py b/tests/test_validacao.py new file mode 100644 index 0000000..71cc757 --- /dev/null +++ b/tests/test_validacao.py @@ -0,0 +1,52 @@ +from io import BytesIO +import asyncio + +import pytest +from fastapi import HTTPException, UploadFile + +from app.utils.validacao import validar_anexo + + +def arquivo(nome: str, conteudo: bytes, tamanho: int | None = None) -> UploadFile: + upload = UploadFile(filename=nome, file=BytesIO(conteudo)) + upload.size = len(conteudo) if tamanho is None else tamanho + return upload + + +def test_aceita_txt_utf8(): + upload = arquivo("evidencia.txt", "conteúdo válido".encode()) + + assert asyncio.run(validar_anexo(upload)) == "txt" + + +def test_rejeita_arquivo_vazio(): + with pytest.raises(HTTPException) as erro: + asyncio.run(validar_anexo(arquivo("vazio.txt", b""))) + + assert erro.value.status_code == 400 + assert erro.value.detail == "Arquivo vazio" + + +def test_rejeita_arquivo_maior_que_o_limite(): + upload = arquivo("grande.txt", b"conteudo", tamanho=10 * 1024 * 1024 + 1) + + with pytest.raises(HTTPException) as erro: + asyncio.run(validar_anexo(upload)) + + assert erro.value.status_code == 413 + + +def test_rejeita_txt_com_byte_nulo(): + with pytest.raises(HTTPException) as erro: + asyncio.run(validar_anexo(arquivo("invalido.txt", b"texto\x00invalido"))) + + assert erro.value.status_code == 400 + assert erro.value.detail == "Arquivo não é um TXT puro" + + +def test_rejeita_extensao_nao_reconhecida(): + with pytest.raises(HTTPException) as erro: + asyncio.run(validar_anexo(arquivo("arquivo.exe", b"conteudo"))) + + assert erro.value.status_code == 400 + assert erro.value.detail == "Tipo de arquivo não reconhecido" From 327d0b63bcbb87487bae4cd9551abf83a04da603 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Tue, 22 Sep 2026 19:17:48 -0300 Subject: [PATCH 21/33] =?UTF-8?q?refactor/tests:=20remove=20espa=C3=A7os?= =?UTF-8?q?=20em=20branco=20desnecess=C3=A1rios=20nos=20testes=20de=20anex?= =?UTF-8?q?os,=20jobs=20e=20valida=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_anexos.py | 11 +---------- tests/test_jobs.py | 7 +------ tests/test_validacao.py | 10 +--------- 3 files changed, 3 insertions(+), 25 deletions(-) diff --git a/tests/test_anexos.py b/tests/test_anexos.py index 629eb83..c9f2dd9 100644 --- a/tests/test_anexos.py +++ b/tests/test_anexos.py @@ -2,16 +2,13 @@ import asyncio from pathlib import Path from unittest.mock import ANY, AsyncMock, Mock - import pytest from fastapi import UploadFile from starlette.datastructures import Headers - from app.models.enums import Categoria, Status from app.routes import anexos from app.schemas import AnexoForm, UsuarioAutenticado - class Cursor: def __init__(self, retorno=(42,)): self.retorno = retorno @@ -29,7 +26,6 @@ def execute(self, sql, parametros): def fetchone(self): return self.retorno - class Connection: def __init__(self): self.cursor_obj = Cursor() @@ -41,15 +37,12 @@ def cursor(self): def commit(self): self.commits += 1 - def dados(): return AnexoForm(7, 9, Categoria.EVIDENCIA, "descrição") - def usuario(): return UsuarioAutenticado(idUsuario=3, idEmpresa=5) - def test_insere_metadados_com_usuario_autenticado(): conn = Connection() arquivo = UploadFile(filename="arquivo.pdf", file=BytesIO(b"pdf"), headers=Headers({"content-type": "application/pdf"})) @@ -62,7 +55,6 @@ def test_insere_metadados_com_usuario_autenticado(): assert "INSERT INTO pdca.anexo" in conn.cursor_obj.executados[1][0] assert conn.cursor_obj.executados[1][1][:4] == (5, 7, 3, 9) - def test_criar_anexo_enfileira_upload_e_retorna_aceito(monkeypatch): tmp_path = Path("tests/.tmp_uploads") tmp_path.mkdir(exist_ok=True) @@ -81,7 +73,6 @@ def test_criar_anexo_enfileira_upload_e_retorna_aceito(monkeypatch): assert enqueue.call_args.args[1].id_anexo == 42 assert enqueue.call_args.args[1].extensao == "pdf" - def test_criar_anexo_marca_erro_se_falhar_ao_enfileirar(monkeypatch): tmp_path = Path("tests/.tmp_uploads") tmp_path.mkdir(exist_ok=True) @@ -101,4 +92,4 @@ def test_criar_anexo_marca_erro_se_falhar_ao_enfileirar(monkeypatch): )) assert erro.value.status_code == 503 - atualizar.assert_called_once_with(ANY, 42, 3) + atualizar.assert_called_once_with(ANY, 42, 3) \ No newline at end of file diff --git a/tests/test_jobs.py b/tests/test_jobs.py index e72082c..6be1aa7 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,10 +1,8 @@ from pathlib import Path - from app.models.enums import Status from app.schemas import UploadJob from app.services import jobs - class Cursor: def __init__(self): self.executados = [] @@ -18,7 +16,6 @@ def __exit__(self, *args): def execute(self, sql, parametros): self.executados.append((sql, parametros)) - class Connection: def __init__(self): self.cursor_obj = Cursor() @@ -30,7 +27,6 @@ def cursor(self): def commit(self): self.commits += 1 - def test_processa_upload_com_sucesso(monkeypatch): caminho = Path("tests/.tmp_arquivo.pdf") caminho.write_bytes(b"pdf") @@ -44,7 +40,6 @@ def test_processa_upload_com_sucesso(monkeypatch): assert not caminho.exists() assert any(Status.ATIVO.value in parametros for _, parametros in conexao.cursor_obj.executados) - class _contexto: def __init__(self, valor): self.valor = valor @@ -53,4 +48,4 @@ def __enter__(self): return self.valor def __exit__(self, *args): - return False + return False \ No newline at end of file diff --git a/tests/test_validacao.py b/tests/test_validacao.py index 71cc757..301156d 100644 --- a/tests/test_validacao.py +++ b/tests/test_validacao.py @@ -1,24 +1,19 @@ from io import BytesIO import asyncio - import pytest from fastapi import HTTPException, UploadFile - from app.utils.validacao import validar_anexo - def arquivo(nome: str, conteudo: bytes, tamanho: int | None = None) -> UploadFile: upload = UploadFile(filename=nome, file=BytesIO(conteudo)) upload.size = len(conteudo) if tamanho is None else tamanho return upload - def test_aceita_txt_utf8(): upload = arquivo("evidencia.txt", "conteúdo válido".encode()) assert asyncio.run(validar_anexo(upload)) == "txt" - def test_rejeita_arquivo_vazio(): with pytest.raises(HTTPException) as erro: asyncio.run(validar_anexo(arquivo("vazio.txt", b""))) @@ -26,7 +21,6 @@ def test_rejeita_arquivo_vazio(): assert erro.value.status_code == 400 assert erro.value.detail == "Arquivo vazio" - def test_rejeita_arquivo_maior_que_o_limite(): upload = arquivo("grande.txt", b"conteudo", tamanho=10 * 1024 * 1024 + 1) @@ -35,7 +29,6 @@ def test_rejeita_arquivo_maior_que_o_limite(): assert erro.value.status_code == 413 - def test_rejeita_txt_com_byte_nulo(): with pytest.raises(HTTPException) as erro: asyncio.run(validar_anexo(arquivo("invalido.txt", b"texto\x00invalido"))) @@ -43,10 +36,9 @@ def test_rejeita_txt_com_byte_nulo(): assert erro.value.status_code == 400 assert erro.value.detail == "Arquivo não é um TXT puro" - def test_rejeita_extensao_nao_reconhecida(): with pytest.raises(HTTPException) as erro: asyncio.run(validar_anexo(arquivo("arquivo.exe", b"conteudo"))) assert erro.value.status_code == 400 - assert erro.value.detail == "Tipo de arquivo não reconhecido" + assert erro.value.detail == "Tipo de arquivo não reconhecido" \ No newline at end of file From 060650b3cadf27938990aede298b78029293c777 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Tue, 22 Sep 2026 19:39:08 -0300 Subject: [PATCH 22/33] =?UTF-8?q?feat/api:=20adiciona=20tratamento=20de=20?= =?UTF-8?q?erros=20personalizados=20na=20API=20e=20atualiza=20README=20com?= =?UTF-8?q?=20informa=C3=A7=C3=B5es=20detalhadas=20sobre=20a=20funcionalid?= =?UTF-8?q?ade=20de=20importa=C3=A7=C3=A3o=20de=20anexos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- README.md | 241 ++++++++++++++++++++++++++++++++++++++++++++++++++++- main.py | 32 ++++++- worker.py | 4 +- 4 files changed, 275 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 6ed6fdf..d276bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.env docs/ -*.pyc \ No newline at end of file +*.pyc +tests/.*/ \ No newline at end of file diff --git a/README.md b/README.md index 1ca57a0..e71db04 100644 --- a/README.md +++ b/README.md @@ -1 +1,240 @@ -# acta-template-repository \ No newline at end of file +# 📎 ACTA Import API + +API responsável pelo recebimento e processamento assíncrono de anexos utilizados nos ciclos PDCA do ACTA. + +O serviço valida o arquivo recebido, registra seus metadados no PostgreSQL, salva uma cópia temporária e envia um job para processamento no Redis. O worker realiza o upload para o Cloudinary e atualiza o status do anexo no banco. + +## 📌 Visão geral + +O `acta-import-api` atua como um serviço especializado do ecossistema ACTA: + +1. recebe o arquivo e os dados do anexo; +2. consulta o usuário autenticado no `acta-pg-api`; +3. valida tamanho, extensão e conteúdo; +4. registra os metadados em `pdca.anexo`; +5. enfileira o processamento no Redis; +6. envia o arquivo ao Cloudinary pelo worker; +7. atualiza o status e o caminho do arquivo no PostgreSQL. + +O processamento do upload é assíncrono. Portanto, uma resposta `202 Accepted` confirma o recebimento e o enfileiramento, mas não confirma que o upload no Cloudinary já terminou. + +## 🧰 Redis e fila de processamento + +O Redis funciona como intermediário entre a API e o worker. A API não envia o arquivo diretamente ao Cloudinary durante a requisição; depois de salvar o arquivo temporário e os metadados, publica um `UploadJob` na fila `uploads`. + +O worker executa continuamente essa fila, recupera o job, envia o arquivo ao Cloudinary e atualiza o registro no PostgreSQL. Assim, o processamento pesado acontece fora da requisição HTTP e o endpoint pode responder rapidamente com `202 Accepted`. + +O Redis armazena a fila e o estado necessário para o RQ controlar a execução. Se o processamento falhar, o job possui até três tentativas, com intervalos de 10, 30 e 60 segundos. O arquivo temporário é mantido durante as tentativas e removido quando o processamento termina com sucesso ou falha definitiva. + +O Redis precisa estar acessível tanto pela API, que enfileira os jobs, quanto pelo worker, que os consome. Em ambientes com autenticação, configure também `REDIS_PASSWORD` no ambiente da aplicação e do worker. + +## ✨ Funcionalidades + +- Upload de arquivos para anexos de ciclos PDCA. +- Validação de arquivos PDF, DOCX, PPTX, XLSX e imagens. +- Validação específica para TXT, CSV e SVG. +- Limite de 10 MB por arquivo. +- Autenticação por Bearer Token delegada ao `acta-pg-api`. +- Registro do usuário e da empresa a partir do contexto autenticado. +- Processamento assíncrono com Redis Queue (RQ). +- Upload de arquivos para o Cloudinary. +- Atualização dos status `PROCESSANDO`, `ATIVO` e `ERRO`. + + + +## 🛠️ Tecnologias + + +| Tecnologia | Uso | +| ------------ | ------------------------------------ | +| Python | Linguagem da aplicação | +| FastAPI | API HTTP | +| PostgreSQL | Metadados dos anexos | +| Redis | Fila de processamento | +| RQ | Execução dos jobs assíncronos | +| Cloudinary | Armazenamento dos arquivos | +| `filetype` | Identificação do tipo por assinatura | +| `defusedxml` | Validação segura de SVG | +| Pytest | Testes unitários | + + + + +## ✅ Pré-requisitos + +- Python 3.11 ou superior. +- PostgreSQL acessível com a tabela `pdca.anexo` já criada. +- Redis acessível para a fila `uploads`. +- Conta e credenciais do Cloudinary. +- `acta-pg-api` acessível para autenticação e consulta do usuário. + +As dependências Python usadas pela aplicação devem estar instaladas no ambiente virtual do projeto. + +## ⚙️ Configuração + +Crie um arquivo `.env` local a partir de `.env.example`: + +```env +DB_URL=postgresql://usuario:senha@localhost:5432/nome_do_banco + +CLOUDINARY_CLOUD_NAME=seu_cloud_name +CLOUDINARY_API_KEY=sua_api_key +CLOUDINARY_API_SECRET=seu_api_secret + +ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 + +REDIS_HOST=localhost +REDIS_PORT=6379 +``` + +Nunca versione senhas, tokens ou credenciais do Cloudinary. O arquivo `.env.example` deve conter somente valores de referência. + +## 🚀 Execução local + +Inicie a API: + +```bash +uvicorn main:app --reload +``` + +Por padrão, o servidor fica disponível em `http://localhost:8000`. + +Em outro terminal, inicie o worker da fila: + +```bash +python worker.py +``` + +A API e o worker precisam estar em execução para que o ciclo completo de importação seja processado. + +## 📚 Documentação + +Com a API em execução: + +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` +- OpenAPI: `http://localhost:8000/openapi.json` + + + +## 🔌 Endpoint principal + + + +### Criar anexo + +```http +POST /anexos +Authorization: Bearer +Content-Type: multipart/form-data +``` + +Campos do formulário: + + +| Campo | Tipo | Obrigatório | Descrição | +| ----------- | ------- | ----------- | -------------------------------- | +| `arquivo` | arquivo | Sim | Arquivo que será importado | +| `id_ciclo` | inteiro | Sim | Identificador do ciclo PDCA | +| `id_origem` | inteiro | Sim | Identificador da origem do anexo | +| `categoria` | enum | Sim | Categoria do anexo | +| `descricao` | texto | Não | Descrição do arquivo | + + +Categorias disponíveis: + +`TREINAMENTO`, `PLANO_ACAO`, `CAUSA_RAIZ`, `PROBLEMA`, `META`, `RELATORIO`, `LICAO_APRENDIDA`, `FORMULARIO`, `EVIDENCIA` e `OUTRO`. + +Exemplo: + +```bash +curl -X POST http://localhost:8000/anexos \ + -H "Authorization: Bearer " \ + -F "arquivo=@./evidencia.pdf" \ + -F "id_ciclo=7" \ + -F "id_origem=9" \ + -F "categoria=EVIDENCIA" \ + -F "descricao=Evidência da verificação do resultado" +``` + +Resposta de recebimento: + +```json +{ + "id": 42, + "status": "PROCESSANDO" +} +``` + +Essa resposta não representa a confirmação final do Cloudinary. O status final deve ser consultado no fluxo de anexos do ACTA ou diretamente no banco. + +## ⚠️ Erros + +O formato confirmado é: + +```json +{ + "mensagens": ["campo: mensagem de validação"], + "httpStatus": 400, + "timestamp": "2026-09-17T10:35:00" +} +``` + +## 📦 Tipos de arquivo + +São aceitos os seguintes formatos: + +- PDF: `.pdf` +- Documentos: `.docx`, `.pptx`, `.xlsx` +- Imagens: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.bmp`, `.tif` +- Texto: `.txt`, `.csv`, `.svg` + +Arquivos de texto são validados por conteúdo, incluindo UTF-8, ausência de byte nulo e estrutura básica de CSV ou SVG. + +## 🔄 Processamento assíncrono + +O endpoint grava o anexo com status `PROCESSANDO` e publica um `UploadJob` na fila `uploads`. + +O worker: + +- atualiza o status do anexo; +- envia o arquivo ao Cloudinary; +- salva o `secure_url` retornado; +- altera o status para `ATIVO` em caso de sucesso; +- altera o status para `ERRO` em caso de falha definitiva; +- remove o arquivo temporário após o processamento concluído. + +Falhas de enfileiramento retornam `503`. Arquivos vazios retornam `400` e arquivos acima de 10 MB retornam `413`. + +## 🧪 Testes + +Execute os testes unitários com: + +```bash +python -m pytest -q -p no:cacheprovider +``` + +Os testes usam dublês para banco, Redis e Cloudinary. Portanto, a suíte valida o comportamento interno da aplicação, mas não substitui uma validação de integração com os serviços reais. + +## 📁 Estrutura + +```text +. +├── app/ +│ ├── database/ # Conexões PostgreSQL e Redis +│ ├── models/ # Enums e modelos relacionados +│ ├── routes/ # Endpoints HTTP +│ ├── services/ # Autenticação, fila, jobs e Cloudinary +│ ├── utils/ # Validação de arquivos +│ └── schemas.py # Contratos de entrada e jobs +├── tests/ # Testes unitários +├── main.py # Inicialização da API +└── worker.py # Inicialização do worker RQ +``` + + + +## 🤝 Links e autoria + +- [Repositório](https://github.com/AppActa/acta-import-api) · [Licença MIT](LICENSE) · `acta.institutojef@gmail.com` +- Contribuições: use *issues* e *pull requests*; há um `[PULL_REQUEST_TEMPLATE.md](PULL_REQUEST_TEMPLATE.md)`. diff --git a/main.py b/main.py index 2302b22..f68194a 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,7 @@ -from fastapi import FastAPI +from datetime import datetime +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from app.routes.anexos import router as anexos_router app = FastAPI( @@ -6,4 +9,31 @@ description='API de importação de arquivos de .pdf, .docx, .pptx, .txt, .xlsx, .csv e imagens para ciclos PDCA do ACTA' ) +def _resposta_erro(status_code: int, mensagens: list[str]) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={ + 'mensagens': mensagens, + 'httpStatus': status_code, + 'timestamp': datetime.now().isoformat(timespec='seconds'), + }, + ) + +@app.exception_handler(HTTPException) +async def tratar_http_exception(_request: Request, exc: HTTPException): + detalhe = exc.detail if isinstance(exc.detail, list) else [str(exc.detail)] + return _resposta_erro(exc.status_code, [str(mensagem) for mensagem in detalhe]) + +@app.exception_handler(RequestValidationError) +async def tratar_erro_validacao(_request: Request, exc: RequestValidationError): + mensagens = [] + for erro in exc.errors(): + campo = '.'.join(str(parte) for parte in erro['loc'] if parte != 'body') + mensagens.append(f"{campo}: {erro['msg']}" if campo else erro['msg']) + return _resposta_erro(422, mensagens) + +@app.exception_handler(Exception) +async def tratar_erro_interno(_request: Request, exc: Exception): + return _resposta_erro(500, ['Erro interno do servidor']) + app.include_router(anexos_router) \ No newline at end of file diff --git a/worker.py b/worker.py index 104bc19..6447851 100644 --- a/worker.py +++ b/worker.py @@ -1,9 +1,9 @@ from redis import Redis -from rq import Worker +from rq import SpawnWorker from app.database.redis import get_pool from app.services.fila import fila_uploads if __name__ == '__main__': - Worker([fila_uploads], connection=Redis(connection_pool=get_pool())).work() \ No newline at end of file + SpawnWorker([fila_uploads], connection=Redis(connection_pool=get_pool())).work() From 793a67fc0f76c8ff4ddb2f1de51ab99ea2e9119e Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 02:42:12 -0300 Subject: [PATCH 23/33] refactor(worker): substitui SpawnWorker por WindowsSimpleWorker e implementa TimerDeathPenalty para gerenciamento de tempo em ambientes Windows --- .env.example | 3 ++- README.md | 14 ++++++++++++++ worker.py | 9 ++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 7ad9307..8a532fa 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 +ACTA_IA_TOKEN=defina-um-token-interno REDIS_HOST=localhost -REDIS_PORT=6379 \ No newline at end of file +REDIS_PORT=6379 diff --git a/README.md b/README.md index e71db04..4e25aeb 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ CLOUDINARY_API_KEY=sua_api_key CLOUDINARY_API_SECRET=seu_api_secret ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 +ACTA_IA_TOKEN=defina-um-token-interno REDIS_HOST=localhost REDIS_PORT=6379 @@ -168,6 +169,19 @@ Resposta de recebimento: Essa resposta não representa a confirmação final do Cloudinary. O status final deve ser consultado no fluxo de anexos do ACTA ou diretamente no banco. +### Criar anexo pela IA + +```http +POST /anexos/ia +Authorization: Bearer +X-Acta-Usuario-Id: +Content-Type: multipart/form-data +``` + +A IA usa uma credencial própria de serviço e informa apenas o usuário em nome de quem está realizando o upload. A API valida se esse usuário está ativo, obtém a empresa diretamente de `public.usuario_sistema` e verifica se o ciclo pertence à mesma empresa. O `id_empresa` e o `firebase_uid` não são recebidos nessa rota. + +Os campos do formulário e a resposta `202 Accepted` são os mesmos de `POST /anexos`. + ## ⚠️ Erros O formato confirmado é: diff --git a/worker.py b/worker.py index 6447851..0855e1a 100644 --- a/worker.py +++ b/worker.py @@ -1,9 +1,12 @@ from redis import Redis -from rq import SpawnWorker - +from rq import SimpleWorker +from rq.timeouts import TimerDeathPenalty from app.database.redis import get_pool from app.services.fila import fila_uploads +# timeout adaptado para windows +class WindowsSimpleWorker(SimpleWorker): + death_penalty_class = TimerDeathPenalty if __name__ == '__main__': - SpawnWorker([fila_uploads], connection=Redis(connection_pool=get_pool())).work() + WindowsSimpleWorker([fila_uploads], connection=Redis(connection_pool=get_pool()),).work() \ No newline at end of file From dde35fb33f2f8a9826d28ad46d3e823013569665 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 02:43:12 -0300 Subject: [PATCH 24/33] refactor/schemas: modifica a classe UploadJob para incluir id_usuario e conteudo, removendo caminho_arquivo --- .env.example | 2 +- app/schemas.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 8a532fa..79f56b5 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,4 @@ ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 ACTA_IA_TOKEN=defina-um-token-interno REDIS_HOST=localhost -REDIS_PORT=6379 +REDIS_PORT=6379 \ No newline at end of file diff --git a/app/schemas.py b/app/schemas.py index 49943e2..477015d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -17,7 +17,8 @@ def __init__( class UploadJob(BaseModel): id_anexo: int - caminho_arquivo: str + id_usuario: int + conteudo: bytes nome_original: str extensao: str From 32cca4546d90d879949e8a75a3abfdb611d29147 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 02:49:16 -0300 Subject: [PATCH 25/33] =?UTF-8?q?refactor/jobs:=20atualiza=20a=20fun=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20processamento=20de=20uploads=20para=20incluir=20?= =?UTF-8?q?id=5Fusuario=20e=20conte=C3=BAdo,=20ajustando=20a=20l=C3=B3gica?= =?UTF-8?q?=20de=20atualiza=C3=A7=C3=A3o=20de=20status=20e=20limpeza=20de?= =?UTF-8?q?=20arquivos=20tempor=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/jobs.py | 50 +++++++++++++++++++++++++------------------- tests/test_jobs.py | 3 +-- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/app/services/jobs.py b/app/services/jobs.py index 6aec895..ab67d07 100644 --- a/app/services/jobs.py +++ b/app/services/jobs.py @@ -1,40 +1,48 @@ -from rq import get_current_job from pathlib import Path +from tempfile import NamedTemporaryFile +from rq import get_current_job from app.database.postgres import conn_worker -from app.services.cloudinary import enviar_cloudinary from app.models.enums import Status from app.schemas import UploadJob +from app.services.cloudinary import enviar_cloudinary def processar_upload(job: UploadJob) -> None: + caminho_temp = None with conn_worker() as conn: try: - _atualizar_status(conn, job.id_anexo, Status.PROCESSANDO) - resultado = enviar_cloudinary( - job.caminho_arquivo, - job.nome_original, - job.extensao, - public_id=f'anexos/{job.id_anexo}', - ) - - _atualizar_sucesso(conn, job.id_anexo, resultado) - _limpar_temp(job.caminho_arquivo) # só remove quando dá certo - except Exception: - job = get_current_job() - ultima_tentativa = job is None or not job.retries_left + _configurar_usuario(conn, job.id_usuario) - # se não for a útlima tentativa deixa o arquivo e status processando - # redis chama a função de novo... + with NamedTemporaryFile(suffix=f'.{job.extensao}', delete=False) as arquivo_temp: + arquivo_temp.write(job.conteudo) + caminho_temp = arquivo_temp.name + _atualizar_status(conn, job.id_anexo, job.id_usuario, Status.PROCESSANDO) + + resultado = enviar_cloudinary(caminho_temp, job.nome_original, job.extensao, public_id=f'anexos/{job.id_anexo}') + + _atualizar_sucesso(conn, job.id_anexo, job.id_usuario, resultado) + except Exception: + rq_job = get_current_job() + ultima_tentativa = rq_job is None or not rq_job.retries_left + if ultima_tentativa: - _atualizar_status(conn, job.id_anexo, Status.ERRO) - _limpar_temp(job.caminho_arquivo) + _atualizar_status(conn, job.id_anexo, job.id_usuario, Status.ERRO) raise + finally: + if caminho_temp: + _limpar_temp(caminho_temp) + +def _configurar_usuario(conn, id_usuario: int) -> None: + with conn.cursor() as cursor: + cursor.execute("SELECT set_config('app.current_user_id', %s, true)", (str(id_usuario),),) -def _atualizar_status(conn, id_anexo: int, status: Status) -> None: +def _atualizar_status(conn, id_anexo: int, id_usuario: int, status: Status) -> None: + _configurar_usuario(conn, id_usuario) with conn.cursor() as cursor: cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (status.value, id_anexo)) conn.commit() -def _atualizar_sucesso(conn, id_anexo: int, resultado: dict) -> None: +def _atualizar_sucesso(conn, id_anexo: int, id_usuario: int, resultado: dict) -> None: + _configurar_usuario(conn, id_usuario) with conn.cursor() as cursor: cursor.execute('UPDATE pdca.anexo SET status = %s, caminho_arquivo = %s WHERE id = %s', (Status.ATIVO.value, resultado['url'], id_anexo)) conn.commit() diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 6be1aa7..a537810 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -31,13 +31,12 @@ def test_processa_upload_com_sucesso(monkeypatch): caminho = Path("tests/.tmp_arquivo.pdf") caminho.write_bytes(b"pdf") conexao = Connection() - job = UploadJob(id_anexo=42, caminho_arquivo=str(caminho), nome_original="arquivo.pdf", extensao="pdf") + job = UploadJob(id_anexo=42, id_usuario=3, conteudo=b"pdf", nome_original="arquivo.pdf", extensao="pdf") monkeypatch.setattr(jobs, "conn_worker", lambda: _contexto(conexao)) monkeypatch.setattr(jobs, "enviar_cloudinary", lambda *args, **kwargs: {"url": "https://arquivo", "public_id": "42", "resource_type": "image"}) jobs.processar_upload(job) - assert not caminho.exists() assert any(Status.ATIVO.value in parametros for _, parametros in conexao.cursor_obj.executados) class _contexto: From 785b3d7d7cd9e5958db9e9ea5f7dfb28444f235d Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 02:51:48 -0300 Subject: [PATCH 26/33] =?UTF-8?q?feat/api:=20adiciona=20novos=20endpoints?= =?UTF-8?q?=20para=20upload=20e=20recupera=C3=A7=C3=A3o=20de=20anexos,=20i?= =?UTF-8?q?ncluindo=20suporte=20a=20contexto=20de=20IA=20e=20valida=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20ciclos=20de=20empresa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes/anexos.py | 57 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/app/routes/anexos.py b/app/routes/anexos.py index 132b0de..80badd9 100644 --- a/app/routes/anexos.py +++ b/app/routes/anexos.py @@ -8,15 +8,31 @@ from app.services.jobs import processar_upload from app.models.enums import Status from app.schemas import AnexoForm, UploadJob, UsuarioAutenticado -from app.services.autenticacao import obter_usuario_autenticado +from app.services.autenticacao import obter_contexto_ia, obter_usuario_autenticado DIR_TEMP = Path('/tmp/uploads') DIR_TEMP.mkdir(parents=True, exist_ok=True) router = APIRouter(prefix='/anexos', tags=['anexos']) -@router.post('', status_code=status.HTTP_202_ACCEPTED) -async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): +@router.post('', status_code=202) +async def post_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): + return await _processar_anexo(arquivo, dados, conn, usuario) + +@router.post('/ia', status_code=202) +async def post_anexo_ia(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_contexto_ia)): + return await _processar_anexo(arquivo, dados, conn, usuario) + +@router.get('/ia/{id_anexo}') +def get_anexo_ia(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_contexto_ia)): + return _get_anexo(conn, id_anexo, usuario.id_empresa) + +@router.get('/{id_anexo}') +def get_anexo(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): + return _get_anexo(conn, id_anexo, usuario.id_empresa) + +async def _processar_anexo(arquivo: UploadFile, dados: AnexoForm, conn, usuario: UsuarioAutenticado): + _validar_ciclo_empresa(conn, dados.id_ciclo, usuario.id_empresa) extensao = await validar_anexo(arquivo) id_anexo = _inserir_metadados(conn, arquivo, dados, extensao, usuario) @@ -32,7 +48,8 @@ async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depend fila_uploads.enqueue( processar_upload, UploadJob( id_anexo=id_anexo, - caminho_arquivo=str(caminho_temp), + id_usuario=usuario.id_usuario, + conteudo=caminho_temp.read_bytes(), nome_original=arquivo.filename, extensao=extensao), retry=Retry(max=3, interval=[10, 30, 60]), result_ttl=3600, failure_ttl=86400 @@ -42,8 +59,40 @@ async def criar_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depend caminho_temp.unlink(missing_ok=True) raise HTTPException(503, 'Não foi possível enfileirar o processamento') + caminho_temp.unlink(missing_ok=True) return {'id': id_anexo, 'status': Status.PROCESSANDO.value} +def _get_anexo(conn, id_anexo: int, id_empresa: int) -> dict: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT id, status, caminho_arquivo + FROM pdca.anexo + WHERE id = %s AND id_empresa = %s AND excluido_em IS NULL + """, + (id_anexo, id_empresa), + ) + anexo = cursor.fetchone() + + if anexo is None: + raise HTTPException(404, 'Anexo não encontrado') + + return { + 'id': anexo[0], + 'status': anexo[1], + 'enviado': anexo[1] == Status.ATIVO.value and bool(anexo[2]), + 'url': anexo[2], + } + +def _validar_ciclo_empresa(conn, id_ciclo: int, id_empresa: int) -> None: + with conn.cursor() as cursor: + cursor.execute( + 'SELECT 1 FROM pdca.ciclo WHERE id = %s AND id_empresa = %s', + (id_ciclo, id_empresa), + ) + if cursor.fetchone() is None: + raise HTTPException(403, 'Ciclo não pertence à empresa do usuário') + def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str, usuario: UsuarioAutenticado) -> int: with conn.cursor() as cursor: cursor.execute("SELECT set_config('app.current_user_id', %s, true)", (str(usuario.id_usuario),)) From f64b029a460100325609b78cbbb96316242d1ec1 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 02:54:07 -0300 Subject: [PATCH 27/33] =?UTF-8?q?feat/api:=20adiciona=20endpoint=20para=20?= =?UTF-8?q?consultar=20o=20processamento=20de=20anexos=20e=20implementa=20?= =?UTF-8?q?valida=C3=A7=C3=A3o=20de=20token=20da=20IA=20na=20autentica?= =?UTF-8?q?=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 ++++++++++++++++++++++++++++ app/services/autenticacao.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e25aeb..e9a4c12 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,34 @@ A IA usa uma credencial própria de serviço e informa apenas o usuário em nome Os campos do formulário e a resposta `202 Accepted` são os mesmos de `POST /anexos`. +### Consultar processamento do anexo + +Para usuários autenticados: + +```http +GET /anexos/{id_anexo} +Authorization: Bearer +``` + +Para a IA: + +```http +GET /anexos/ia/{id_anexo} +Authorization: Bearer +X-Acta-Usuario-Id: +``` + +A resposta informa o status atual, se o envio terminou com sucesso e a URL gerada pelo Cloudinary: + +```json +{ + "id": 42, + "status": "ATIVO", + "enviado": true, + "url": "https://res.cloudinary.com/..." +} +``` + ## ⚠️ Erros O formato confirmado é: diff --git a/app/services/autenticacao.py b/app/services/autenticacao.py index 1af0279..ad4b7ac 100644 --- a/app/services/autenticacao.py +++ b/app/services/autenticacao.py @@ -1,13 +1,40 @@ +from hmac import compare_digest from os import getenv from httpx import AsyncClient, RequestError -from fastapi import HTTPException, Security, status +from fastapi import Depends, Header, HTTPException, Security, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from app.database.postgres import get_conn from app.schemas import UsuarioAutenticado ACTA_PG_API_URL = f'{getenv('ACTA_PG_API_URL')}/api/v1' bearer = HTTPBearer() +async def obter_contexto_ia( + credenciais: HTTPAuthorizationCredentials = Security(bearer), + id_usuario: int = Header(..., alias='X-Acta-Usuario-Id', gt=0), + conn=Depends(get_conn), +) -> UsuarioAutenticado: + token_ia = getenv('ACTA_IA_TOKEN') + if not token_ia or not compare_digest(credenciais.credentials, token_ia): + raise HTTPException(401, 'Token da IA inválido') + + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT id, id_empresa + FROM public.usuario_sistema + WHERE id = %s AND status = 'ATIVO' + """, + (id_usuario,), + ) + usuario = cursor.fetchone() + + if usuario is None: + raise HTTPException(403, 'Usuário da IA não autorizado') + + return UsuarioAutenticado(idUsuario=usuario[0], idEmpresa=usuario[1]) + async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = Security(bearer)) -> UsuarioAutenticado: headers = {'Authorization': f'Bearer {credenciais.credentials}'} From 7752c48fc0d821be654f5ad1a9047df1d38d2c3f Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 03:08:46 -0300 Subject: [PATCH 28/33] =?UTF-8?q?refactor/api:=20move=20l=C3=B3gica=20de?= =?UTF-8?q?=20processamento=20e=20consulta=20de=20anexos=20para=20um=20nov?= =?UTF-8?q?o=20m=C3=B3dulo=20de=20servi=C3=A7os,=20simplificando=20o=20c?= =?UTF-8?q?=C3=B3digo=20do=20roteador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes/anexos.py | 109 +++-------------------------------------- app/services/anexos.py | 99 +++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 101 deletions(-) create mode 100644 app/services/anexos.py diff --git a/app/routes/anexos.py b/app/routes/anexos.py index 80badd9..86b8405 100644 --- a/app/routes/anexos.py +++ b/app/routes/anexos.py @@ -1,117 +1,24 @@ -from pathlib import Path -from uuid import uuid4 -from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, status -from rq import Retry +from fastapi import APIRouter, Depends, File, UploadFile + from app.database.postgres import get_conn -from app.utils.validacao import validar_anexo -from app.services.fila import fila_uploads -from app.services.jobs import processar_upload -from app.models.enums import Status -from app.schemas import AnexoForm, UploadJob, UsuarioAutenticado +from app.schemas import AnexoForm, UsuarioAutenticado +from app.services.anexos import consultar_anexo, processar_anexo from app.services.autenticacao import obter_contexto_ia, obter_usuario_autenticado -DIR_TEMP = Path('/tmp/uploads') -DIR_TEMP.mkdir(parents=True, exist_ok=True) - router = APIRouter(prefix='/anexos', tags=['anexos']) @router.post('', status_code=202) async def post_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): - return await _processar_anexo(arquivo, dados, conn, usuario) + return await processar_anexo(arquivo, dados, conn, usuario) @router.post('/ia', status_code=202) async def post_anexo_ia(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_contexto_ia)): - return await _processar_anexo(arquivo, dados, conn, usuario) + return await processar_anexo(arquivo, dados, conn, usuario) @router.get('/ia/{id_anexo}') def get_anexo_ia(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_contexto_ia)): - return _get_anexo(conn, id_anexo, usuario.id_empresa) + return consultar_anexo(conn, id_anexo, usuario.id_empresa) @router.get('/{id_anexo}') def get_anexo(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): - return _get_anexo(conn, id_anexo, usuario.id_empresa) - -async def _processar_anexo(arquivo: UploadFile, dados: AnexoForm, conn, usuario: UsuarioAutenticado): - _validar_ciclo_empresa(conn, dados.id_ciclo, usuario.id_empresa) - extensao = await validar_anexo(arquivo) - - id_anexo = _inserir_metadados(conn, arquivo, dados, extensao, usuario) - caminho_temp = DIR_TEMP / f'{id_anexo}_{uuid4().hex}{Path(arquivo.filename).suffix}' - - try: - caminho_temp.write_bytes(await arquivo.read()) - except Exception: - _atualizar_status_erro(conn, id_anexo, usuario.id_usuario) - raise HTTPException(500, 'Falha ao salvar arquivo temporário') - - try: - fila_uploads.enqueue( - processar_upload, UploadJob( - id_anexo=id_anexo, - id_usuario=usuario.id_usuario, - conteudo=caminho_temp.read_bytes(), - nome_original=arquivo.filename, - extensao=extensao), - retry=Retry(max=3, interval=[10, 30, 60]), result_ttl=3600, failure_ttl=86400 - ) - except Exception: - _atualizar_status_erro(conn, id_anexo, usuario.id_usuario) - caminho_temp.unlink(missing_ok=True) - raise HTTPException(503, 'Não foi possível enfileirar o processamento') - - caminho_temp.unlink(missing_ok=True) - return {'id': id_anexo, 'status': Status.PROCESSANDO.value} - -def _get_anexo(conn, id_anexo: int, id_empresa: int) -> dict: - with conn.cursor() as cursor: - cursor.execute( - """ - SELECT id, status, caminho_arquivo - FROM pdca.anexo - WHERE id = %s AND id_empresa = %s AND excluido_em IS NULL - """, - (id_anexo, id_empresa), - ) - anexo = cursor.fetchone() - - if anexo is None: - raise HTTPException(404, 'Anexo não encontrado') - - return { - 'id': anexo[0], - 'status': anexo[1], - 'enviado': anexo[1] == Status.ATIVO.value and bool(anexo[2]), - 'url': anexo[2], - } - -def _validar_ciclo_empresa(conn, id_ciclo: int, id_empresa: int) -> None: - with conn.cursor() as cursor: - cursor.execute( - 'SELECT 1 FROM pdca.ciclo WHERE id = %s AND id_empresa = %s', - (id_ciclo, id_empresa), - ) - if cursor.fetchone() is None: - raise HTTPException(403, 'Ciclo não pertence à empresa do usuário') - -def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str, usuario: UsuarioAutenticado) -> int: - with conn.cursor() as cursor: - cursor.execute("SELECT set_config('app.current_user_id', %s, true)", (str(usuario.id_usuario),)) - cursor.execute(""" - INSERT INTO pdca.anexo (id_empresa, id_ciclo, criado_por, id_origem, nome_arquivo, tipo_arquivo, tamanho_arquivo, bucket_arquivo, caminho_arquivo, categoria, descricao, status) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, %s, %s) - RETURNING id - """, - (usuario.id_empresa, dados.id_ciclo, usuario.id_usuario, dados.id_origem, arquivo.filename, arquivo.content_type or extensao, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value)) - - id_anexo = cursor.fetchone()[0] - conn.commit() - return id_anexo - -def _atualizar_status_erro(conn, id_anexo: int, id_usuario: int) -> None: - with conn.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_user_id', %s, true)", - (str(id_usuario),), - ) - cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (Status.ERRO.value, id_anexo)) - conn.commit() \ No newline at end of file + return consultar_anexo(conn, id_anexo, usuario.id_empresa) diff --git a/app/services/anexos.py b/app/services/anexos.py new file mode 100644 index 0000000..d93e348 --- /dev/null +++ b/app/services/anexos.py @@ -0,0 +1,99 @@ +from pathlib import Path +from uuid import uuid4 + +from fastapi import HTTPException, UploadFile +from rq import Retry + +from app.models.enums import Status +from app.schemas import AnexoForm, UploadJob, UsuarioAutenticado +from app.services.fila import fila_uploads +from app.services.jobs import processar_upload +from app.utils.validacao import validar_anexo + +DIR_TEMP = Path('/tmp/uploads') +DIR_TEMP.mkdir(parents=True, exist_ok=True) + +async def processar_anexo(arquivo: UploadFile, dados: AnexoForm, conn, usuario: UsuarioAutenticado): + _validar_ciclo_empresa(conn, dados.id_ciclo, usuario.id_empresa) + extensao = await validar_anexo(arquivo) + + id_anexo = _inserir_metadados(conn, arquivo, dados, extensao, usuario) + caminho_temp = DIR_TEMP / f'{id_anexo}_{uuid4().hex}{Path(arquivo.filename).suffix}' + + try: + caminho_temp.write_bytes(await arquivo.read()) + except Exception: + _atualizar_status_erro(conn, id_anexo, usuario.id_usuario) + raise HTTPException(500, 'Falha ao salvar arquivo temporário') + + try: + fila_uploads.enqueue( + processar_upload, UploadJob( + id_anexo=id_anexo, + id_usuario=usuario.id_usuario, + conteudo=caminho_temp.read_bytes(), + nome_original=arquivo.filename, + extensao=extensao), + retry=Retry(max=3, interval=[10, 30, 60]), result_ttl=3600, failure_ttl=86400 + ) + except Exception: + _atualizar_status_erro(conn, id_anexo, usuario.id_usuario) + caminho_temp.unlink(missing_ok=True) + raise HTTPException(503, 'Não foi possível enfileirar o processamento') + + caminho_temp.unlink(missing_ok=True) + return {'id': id_anexo, 'status': Status.PROCESSANDO.value} + +def consultar_anexo(conn, id_anexo: int, id_empresa: int) -> dict: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT id, status, caminho_arquivo + FROM pdca.anexo + WHERE id = %s AND id_empresa = %s AND excluido_em IS NULL + """, + (id_anexo, id_empresa) + ) + anexo = cursor.fetchone() + + if anexo is None: + raise HTTPException(404, 'Anexo não encontrado') + + return { + 'id': anexo[0], + 'status': anexo[1], + 'enviado': anexo[1] == Status.ATIVO.value and bool(anexo[2]), + 'url': anexo[2] + } + +def _validar_ciclo_empresa(conn, id_ciclo: int, id_empresa: int) -> None: + with conn.cursor() as cursor: + cursor.execute( + 'SELECT 1 FROM pdca.ciclo WHERE id = %s AND id_empresa = %s', + (id_ciclo, id_empresa) + ) + if cursor.fetchone() is None: + raise HTTPException(403, 'Ciclo não pertence à empresa do usuário') + +def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str, usuario: UsuarioAutenticado) -> int: + with conn.cursor() as cursor: + cursor.execute("SELECT set_config('app.current_user_id', %s, true)", (str(usuario.id_usuario),)) + cursor.execute(""" + INSERT INTO pdca.anexo (id_empresa, id_ciclo, criado_por, id_origem, nome_arquivo, tipo_arquivo, tamanho_arquivo, bucket_arquivo, caminho_arquivo, categoria, descricao, status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, %s, %s) + RETURNING id + """, + (usuario.id_empresa, dados.id_ciclo, usuario.id_usuario, dados.id_origem, arquivo.filename, arquivo.content_type or extensao, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value)) + + id_anexo = cursor.fetchone()[0] + conn.commit() + return id_anexo + +def _atualizar_status_erro(conn, id_anexo: int, id_usuario: int) -> None: + with conn.cursor() as cursor: + cursor.execute( + "SELECT set_config('app.current_user_id', %s, true)", + (str(id_usuario),) + ) + cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (Status.ERRO.value, id_anexo)) + conn.commit() From f5a7fbbfb9ed1385510d7d8df06fdea707a3e0d4 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 03:09:02 -0300 Subject: [PATCH 29/33] =?UTF-8?q?refactor:=20ajusta=20formata=C3=A7=C3=A3o?= =?UTF-8?q?=20e=20remove=20espa=C3=A7os=20em=20branco=20desnecess=C3=A1rio?= =?UTF-8?q?s=20em=20v=C3=A1rias=20partes=20do=20c=C3=B3digo,=20incluindo?= =?UTF-8?q?=20fun=C3=A7=C3=B5es=20de=20resposta=20de=20erro=20e=20classes?= =?UTF-8?q?=20de=20esquema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas.py | 4 ++-- app/services/autenticacao.py | 8 ++++---- main.py | 4 ++-- tests/.tmp_arquivo.pdf | Bin 0 -> 3 bytes tests/test_anexos.py | 10 +++++----- 5 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 tests/.tmp_arquivo.pdf diff --git a/app/schemas.py b/app/schemas.py index 477015d..efe6a74 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -8,7 +8,7 @@ def __init__( id_ciclo: int = Form(...), id_origem: int = Form(...), categoria: Categoria = Form(...), - descricao: str | None = Form(None), + descricao: str | None = Form(None) ): self.id_ciclo = id_ciclo self.id_origem = id_origem @@ -24,4 +24,4 @@ class UploadJob(BaseModel): class UsuarioAutenticado(BaseModel): id_usuario: int = Field(alias='idUsuario') - id_empresa: int = Field(alias='idEmpresa') \ No newline at end of file + id_empresa: int = Field(alias='idEmpresa') diff --git a/app/services/autenticacao.py b/app/services/autenticacao.py index ad4b7ac..6c36b5d 100644 --- a/app/services/autenticacao.py +++ b/app/services/autenticacao.py @@ -1,7 +1,7 @@ from hmac import compare_digest from os import getenv from httpx import AsyncClient, RequestError -from fastapi import Depends, Header, HTTPException, Security, status +from fastapi import Depends, Header, HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from app.database.postgres import get_conn @@ -13,7 +13,7 @@ async def obter_contexto_ia( credenciais: HTTPAuthorizationCredentials = Security(bearer), id_usuario: int = Header(..., alias='X-Acta-Usuario-Id', gt=0), - conn=Depends(get_conn), + conn=Depends(get_conn) ) -> UsuarioAutenticado: token_ia = getenv('ACTA_IA_TOKEN') if not token_ia or not compare_digest(credenciais.credentials, token_ia): @@ -26,7 +26,7 @@ async def obter_contexto_ia( FROM public.usuario_sistema WHERE id = %s AND status = 'ATIVO' """, - (id_usuario,), + (id_usuario,) ) usuario = cursor.fetchone() @@ -58,4 +58,4 @@ async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = try: return UsuarioAutenticado.model_validate(resposta.json()) except (ValueError, TypeError): - raise HTTPException(502, 'Resposta inválida de acta-pg-api') \ No newline at end of file + raise HTTPException(502, 'Resposta inválida de acta-pg-api') diff --git a/main.py b/main.py index f68194a..d35c383 100644 --- a/main.py +++ b/main.py @@ -15,8 +15,8 @@ def _resposta_erro(status_code: int, mensagens: list[str]) -> JSONResponse: content={ 'mensagens': mensagens, 'httpStatus': status_code, - 'timestamp': datetime.now().isoformat(timespec='seconds'), - }, + 'timestamp': datetime.now().isoformat(timespec='seconds') + } ) @app.exception_handler(HTTPException) diff --git a/tests/.tmp_arquivo.pdf b/tests/.tmp_arquivo.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6089659d9d286a91c73a692f3143169716660918 GIT binary patch literal 3 KcmXR&Ndo`^fdM-J literal 0 HcmV?d00001 diff --git a/tests/test_anexos.py b/tests/test_anexos.py index c9f2dd9..a9cc625 100644 --- a/tests/test_anexos.py +++ b/tests/test_anexos.py @@ -6,7 +6,7 @@ from fastapi import UploadFile from starlette.datastructures import Headers from app.models.enums import Categoria, Status -from app.routes import anexos +from app.services import anexos from app.schemas import AnexoForm, UsuarioAutenticado class Cursor: @@ -66,7 +66,7 @@ def test_criar_anexo_enfileira_upload_e_retorna_aceito(monkeypatch): arquivo = UploadFile(filename="arquivo.pdf", file=BytesIO(b"pdf")) arquivo.size = 3 - resposta = asyncio.run(anexos.criar_anexo(arquivo, dados(), Connection(), usuario())) + resposta = asyncio.run(anexos.processar_anexo(arquivo, dados(), Connection(), usuario())) assert resposta == {"id": 42, "status": Status.PROCESSANDO.value} enqueue.assert_called_once() @@ -84,12 +84,12 @@ def test_criar_anexo_marca_erro_se_falhar_ao_enfileirar(monkeypatch): monkeypatch.setattr(anexos, "_atualizar_status_erro", atualizar) with pytest.raises(Exception) as erro: - asyncio.run(anexos.criar_anexo( + asyncio.run(anexos.processar_anexo( UploadFile(filename="arquivo.pdf", file=BytesIO(b"pdf")), dados(), Connection(), - usuario(), + usuario() )) assert erro.value.status_code == 503 - atualizar.assert_called_once_with(ANY, 42, 3) \ No newline at end of file + atualizar.assert_called_once_with(ANY, 42, 3) From 3bff18de7a439fbea3d3814b7156ebd32c80f15a Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 03:09:33 -0300 Subject: [PATCH 30/33] =?UTF-8?q?chore:=20remove=20arquivo=20tempor=C3=A1r?= =?UTF-8?q?io=20.tmp=5Farquivo.pdf=20do=20reposit=C3=B3rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/.tmp_arquivo.pdf | Bin 3 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/.tmp_arquivo.pdf diff --git a/tests/.tmp_arquivo.pdf b/tests/.tmp_arquivo.pdf deleted file mode 100644 index 6089659d9d286a91c73a692f3143169716660918..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3 KcmXR&Ndo`^fdM-J From 206c6729fd789afb48608a3da9660b00ba4971b2 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 03:14:02 -0300 Subject: [PATCH 31/33] =?UTF-8?q?fix:=20corrige=20formata=C3=A7=C3=A3o=20d?= =?UTF-8?q?e=20link=20no=20README=20e=20ajusta=20a=20URL=20da=20API=20no?= =?UTF-8?q?=20m=C3=B3dulo=20de=20autentica=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- app/services/autenticacao.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e9a4c12..6e3132b 100644 --- a/README.md +++ b/README.md @@ -279,4 +279,4 @@ Os testes usam dublês para banco, Redis e Cloudinary. Portanto, a suíte valida ## 🤝 Links e autoria - [Repositório](https://github.com/AppActa/acta-import-api) · [Licença MIT](LICENSE) · `acta.institutojef@gmail.com` -- Contribuições: use *issues* e *pull requests*; há um `[PULL_REQUEST_TEMPLATE.md](PULL_REQUEST_TEMPLATE.md)`. +- Contribuições: use *issues* e *pull requests*; há um [PULL_REQUEST_TEMPLATE.md](PULL_REQUEST_TEMPLATE.md). \ No newline at end of file diff --git a/app/services/autenticacao.py b/app/services/autenticacao.py index 6c36b5d..73b6588 100644 --- a/app/services/autenticacao.py +++ b/app/services/autenticacao.py @@ -7,7 +7,7 @@ from app.database.postgres import get_conn from app.schemas import UsuarioAutenticado -ACTA_PG_API_URL = f'{getenv('ACTA_PG_API_URL')}/api/v1' +ACTA_PG_API_URL = getenv('ACTA_PG_API_URL') bearer = HTTPBearer() async def obter_contexto_ia( @@ -58,4 +58,4 @@ async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = try: return UsuarioAutenticado.model_validate(resposta.json()) except (ValueError, TypeError): - raise HTTPException(502, 'Resposta inválida de acta-pg-api') + raise HTTPException(502, 'Resposta inválida de acta-pg-api') \ No newline at end of file From 23b88f8b6d2c4cd3625da6366ae669a5dd8ba8e3 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 03:29:57 -0300 Subject: [PATCH 32/33] =?UTF-8?q?fea/yml:=20ajusta=20a=20l=C3=B3gica=20de?= =?UTF-8?q?=20autentica=C3=A7=C3=A3o=20para=20melhorar=20a=20seguran=C3=A7?= =?UTF-8?q?a=20e=20a=20legibilidade=20do=20c=C3=B3digo,=20al=C3=A9m=20de?= =?UTF-8?q?=20atualizar=20a=20documenta=C3=A7=C3=A3o=20relacionada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-image.yml | 74 +++++++++++++++++++++++++++++++ .github/workflows/validacao.yml | 34 ++++++++++++++ Dockerfile | 16 +++++++ requirements.txt | 11 +++++ 4 files changed, 135 insertions(+) create mode 100644 .github/workflows/build-image.yml create mode 100644 .github/workflows/validacao.yml create mode 100644 Dockerfile create mode 100644 requirements.txt diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..179c0e6 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,74 @@ +name: build-image + +on: + push: + branches: [main] + +permissions: + contents: read + packages: write + pull-requests: read + +jobs: + publicar: + runs-on: ubuntu-latest + + steps: + - name: baixar código + uses: actions/checkout@v4 + + - name: preparar docker builds + uses: docker/setup-buildx-action@v3 + + - name: entrar no GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: construir e publicar imagem + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/appacta/acta-mongo-api:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=https://github.com/AppActa/acta-mongo-api + org.opencontainers.image.revision=${{ github.sha }} + + - name: baixar manifests do platform + uses: actions/checkout@v4 + with: + repository: AppActa/acta-platform + ref: main + token: ${{ secrets.ACTA_PLATFORM_TOKEN }} + + - name: atualizar imagem no platform + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }} + PUSHED_AT: ${{ github.event.head_commit.timestamp }} + run: | + pull_request=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0].html_url' 2>/dev/null || true) + pull_request=${pull_request:-"não identificada"} + + commit_author="${COMMIT_AUTHOR}" + commit_author=${commit_author:-"não identificado"} + + pushed_at="${PUSHED_AT}" + pushed_at=${pushed_at:-"não informado"} + + sed -i "s|^\([[:space:]]*image: ghcr.io/appacta/acta-mongo-api\).*$|\1:sha-${GITHUB_SHA}|" apps/mongo-api.yaml + git diff --quiet -- apps/mongo-api.yaml && exit 0 + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add apps/mongo-api.yaml + git commit -m "chore: atualizar imagem de acta-mongo-api" -m "PR: ${pull_request} + SHA: ${GITHUB_SHA} + Autor do commit: ${commit_author} + Usuário do push: ${GITHUB_ACTOR} + Data do push: ${pushed_at}" + git push origin main \ No newline at end of file diff --git a/.github/workflows/validacao.yml b/.github/workflows/validacao.yml new file mode 100644 index 0000000..a12be50 --- /dev/null +++ b/.github/workflows/validacao.yml @@ -0,0 +1,34 @@ +name: validacao + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + validar: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + + - name: instalar dependencias + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install pytest==9.1.1 + + - name: validar sintaxe + run: python -m compileall -q main.py worker.py app + + - name: executar testes + run: python -m pytest -q -p no:cacheprovider \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9678d0b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN python -m pip install --no-cache-dir -r requirements.txt + +COPY main.py worker.py ./ +COPY app ./app + +EXPOSE 8000 + +CMD ["sh", "-c", "exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7b9527f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.141.1 +uvicorn[standard]==0.52.3 +psycopg2-binary==2.9.13 +redis==8.1.0 +rq==2.12.0 +cloudinary==1.46.2 +filetype==1.2.0 +defusedxml==0.7.1 +python-dotenv==1.2.2 +httpx==0.28.1 +python-multipart==0.0.32 \ No newline at end of file From 082f8866cd7c858ef7e9e10e51780df9cba37dd5 Mon Sep 17 00:00:00 2001 From: Davi Aliaga Date: Wed, 23 Sep 2026 03:46:27 -0300 Subject: [PATCH 33/33] =?UTF-8?q?chore/image:=20atualiza=20a=20imagem=20do?= =?UTF-8?q?=20cont=C3=AAiner=20para=20acta-import-api=20e=20ajusta=20os=20?= =?UTF-8?q?manifests=20correspondentes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-image.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 179c0e6..961e30e 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -32,9 +32,9 @@ jobs: with: context: . push: true - tags: ghcr.io/appacta/acta-mongo-api:sha-${{ github.sha }} + tags: ghcr.io/appacta/acta-import-api:sha-${{ github.sha }} labels: | - org.opencontainers.image.source=https://github.com/AppActa/acta-mongo-api + org.opencontainers.image.source=https://github.com/AppActa/acta-import-api org.opencontainers.image.revision=${{ github.sha }} - name: baixar manifests do platform @@ -60,13 +60,13 @@ jobs: pushed_at="${PUSHED_AT}" pushed_at=${pushed_at:-"não informado"} - sed -i "s|^\([[:space:]]*image: ghcr.io/appacta/acta-mongo-api\).*$|\1:sha-${GITHUB_SHA}|" apps/mongo-api.yaml - git diff --quiet -- apps/mongo-api.yaml && exit 0 + sed -i "s|^\([[:space:]]*image: ghcr.io/appacta/acta-import-api\).*$|\1:sha-${GITHUB_SHA}|" apps/import-api.yaml + git diff --quiet -- apps/import-api.yaml && exit 0 git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add apps/mongo-api.yaml - git commit -m "chore: atualizar imagem de acta-mongo-api" -m "PR: ${pull_request} + git add apps/import-api.yaml + git commit -m "chore: atualizar imagem de acta-import-api" -m "PR: ${pull_request} SHA: ${GITHUB_SHA} Autor do commit: ${commit_author} Usuário do push: ${GITHUB_ACTOR}