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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,5 @@ 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
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ O worker executa continuamente essa fila, recupera o job, envia o arquivo ao Clo

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.
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`. A conexão usa TLS obrigatoriamente (`REDIS_SSL = True` em `app/config.py`), sem uma variável de ambiente para desativá-lo; use um serviço Redis com TLS habilitado. As configurações de conexão devem estar disponíveis para a API e para o worker.

## ✨ Funcionalidades

Expand Down Expand Up @@ -82,12 +82,15 @@ 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
REDIS_DB=0
REDIS_PASSWORD=
```

`REDIS_DB` e `REDIS_PASSWORD` são opcionais conforme a configuração do serviço. A conexão Redis usa TLS obrigatoriamente; se `REDIS_HOST` for `localhost`, o Redis local também precisa estar configurado para TLS.

Nunca versione senhas, tokens ou credenciais do Cloudinary. O arquivo `.env.example` deve conter somente valores de referência.

## 🚀 Execução local
Expand Down Expand Up @@ -173,12 +176,11 @@ Essa resposta não representa a confirmação final do Cloudinary. O status fina

```http
POST /anexos/ia
Authorization: Bearer <ACTA_IA_TOKEN>
X-Acta-Usuario-Id: <ID_DO_USUARIO>
Authorization: Bearer <TOKEN>
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.
A rota `/ia` usa a mesma autenticação Bearer de `POST /anexos`: a API consulta o usuário na `acta-pg-api` e utiliza o contexto retornado para verificar a empresa do ciclo. Não há token de serviço ou cabeçalho de usuário separado para essa rota.

Os campos do formulário e a resposta `202 Accepted` são os mesmos de `POST /anexos`.

Expand All @@ -191,14 +193,15 @@ GET /anexos/{id_anexo}
Authorization: Bearer <TOKEN>
```

Para a IA:
Para a rota de integração da IA:

```http
GET /anexos/ia/{id_anexo}
Authorization: Bearer <ACTA_IA_TOKEN>
X-Acta-Usuario-Id: <ID_DO_USUARIO>
Authorization: Bearer <TOKEN>
```

Essa rota usa a mesma autenticação Bearer de `GET /anexos/{id_anexo}`.

A resposta informa o status atual, se o envio terminou com sucesso e a URL gerada pelo Cloudinary:

```json
Expand Down Expand Up @@ -277,8 +280,6 @@ Os testes usam dublês para banco, Redis e Cloudinary. Portanto, a suíte valida
└── 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`
Expand Down
19 changes: 19 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from os import getenv

from dotenv import load_dotenv

load_dotenv()

DB_URL = getenv('DB_URL')

CLOUDINARY_CLOUD_NAME = getenv('CLOUDINARY_CLOUD_NAME')
CLOUDINARY_API_KEY = getenv('CLOUDINARY_API_KEY')
CLOUDINARY_API_SECRET = getenv('CLOUDINARY_API_SECRET')

ACTA_PG_API_URL = getenv('ACTA_PG_API_URL')

REDIS_HOST = getenv('REDIS_HOST')
REDIS_PORT = getenv('REDIS_PORT')
REDIS_DB = getenv('REDIS_DB')
REDIS_PASSWORD = getenv('REDIS_PASSWORD')
REDIS_SSL = True
7 changes: 1 addition & 6 deletions app/database/postgres.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
from contextlib import contextmanager
from fastapi import HTTPException
from dotenv import load_dotenv
from os import getenv
from psycopg2 import connect

load_dotenv()

DB_URL = getenv('DB_URL')
from app.config import DB_URL

def _conectar():
return connect(DB_URL)
Expand Down
17 changes: 6 additions & 11 deletions app/database/redis.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
from fastapi import HTTPException
from redis import Redis, ConnectionPool
from redis import Redis, ConnectionPool, SSLConnection
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')
from app.config import REDIS_DB, REDIS_HOST, REDIS_PASSWORD, REDIS_PORT, REDIS_SSL

@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)
pool_options = {'host': REDIS_HOST, 'port': REDIS_PORT, 'db': REDIS_DB, 'password': REDIS_PASSWORD, 'max_connections': 20, 'retry_on_timeout': True}
if REDIS_SSL:
pool_options['connection_class'] = SSLConnection
return ConnectionPool(**pool_options)

def get_conn() -> Redis:
try:
Expand Down
8 changes: 4 additions & 4 deletions app/routes/anexos.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from app.database.postgres import get_conn
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
from app.services.autenticacao import obter_usuario_autenticado

router = APIRouter(prefix='/anexos', tags=['anexos'])

Expand All @@ -12,13 +12,13 @@ async def post_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends
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)):
async def post_anexo_ia(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.get('/ia/{id_anexo}')
def get_anexo_ia(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_contexto_ia)):
def get_anexo_ia(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)):
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 consultar_anexo(conn, id_anexo, usuario.id_empresa)
return consultar_anexo(conn, id_anexo, usuario.id_empresa)
10 changes: 8 additions & 2 deletions app/services/anexos.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,20 @@ def _validar_ciclo_empresa(conn, id_ciclo: int, id_empresa: int) -> 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:
tipo_arquivo = arquivo.content_type or extensao

# Corrige mime type genérico
if extensao == 'txt' and tipo_arquivo == 'application/octet-stream':
tipo_arquivo = 'text/plain'

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))
(usuario.id_empresa, dados.id_ciclo, usuario.id_usuario, dados.id_origem, arquivo.filename, tipo_arquivo, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value))

id_anexo = cursor.fetchone()[0]
conn.commit()
Expand All @@ -96,4 +102,4 @@ def _atualizar_status_erro(conn, id_anexo: int, id_usuario: int) -> None:
(str(id_usuario),)
)
cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (Status.ERRO.value, id_anexo))
conn.commit()
conn.commit()
46 changes: 13 additions & 33 deletions app/services/autenticacao.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,10 @@
from hmac import compare_digest
from os import getenv
from httpx import AsyncClient, RequestError
from fastapi import Depends, Header, HTTPException, Security
from fastapi import HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.database.postgres import get_conn
from app.config import ACTA_PG_API_URL
from app.schemas import UsuarioAutenticado

ACTA_PG_API_URL = getenv('ACTA_PG_API_URL')
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}'}

Expand All @@ -56,6 +26,16 @@ async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials =
raise HTTPException(502, 'Falha ao consultar usuário autenticado')

try:
return UsuarioAutenticado.model_validate(resposta.json())
dados_usuario = resposta.json()
if not isinstance(dados_usuario, dict):
raise ValueError
except (ValueError, TypeError):
raise HTTPException(502, 'Resposta inválida de acta-pg-api')

if dados_usuario.get('status') != 'ATIVO':
raise HTTPException(403, 'Usuário não autorizado')

try:
return UsuarioAutenticado.model_validate(dados_usuario)
except (ValueError, TypeError):
raise HTTPException(502, 'Resposta inválida de acta-pg-api')
18 changes: 8 additions & 10 deletions app/services/cloudinary.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
from cloudinary import config
from cloudinary import config as cloudinary_config
from cloudinary.uploader import upload
from os import getenv
from dotenv import load_dotenv
from app.config import CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, CLOUDINARY_CLOUD_NAME

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)
cloudinary_config(
cloud_name=CLOUDINARY_CLOUD_NAME,
api_key=CLOUDINARY_API_KEY,
api_secret=CLOUDINARY_API_SECRET,
secure=True,
)

# reforçar tipo raw, cloudinary não sabe lidar com esses arquivos
EXTENSOES_RAW = {'docx', 'pptx', 'xlsx', 'csv', 'txt'}
Expand Down
Loading