El proxy de descarga del expediente comprobaba el status de EFC DENTRO del generador de streaming. Starlette manda la linea de estado en cuanto construye la StreamingResponse -antes de pedir el primer trozo-, asi que cuando el generador descubria el 404 la respuesta ya habia salido con 200: el usuario recibia un archivo vacio o cortado en vez del error. La traduccion asimetrica que fija el ticket -404 pasa como 404, todo lo demas como 502- estaba escrita pero nunca llegaba a ocurrir. Ahora la conexion se abre y su status se valida ANTES de construir la respuesta, con client.send(peticion, stream=True). El streaming se conserva intacto: lo unico que se adelanta es la cabecera de EFC, que por protocolo llega antes que el cuerpo. El cliente httpx se sigue creando fuera de todo `async with` y cerrandose en el finally, que era la parte que si estaba bien. De paso, un fallo de red contra EFC (timeout, conexion rechazada) daba un 500 opaco y dejaba el cliente httpx sin cerrar; ahora sale 502 y se cierra. Es el mismo camino de codigo que el ticket manda cubrir. Se agregan las pruebas del proxy que el ticket exigia y que no existian: streaming real -el cliente sigue vivo mientras se consumen los trozos y se cierra al terminar-, la pertenencia validada antes de tocar EFC, el organizacion_id del expediente en la peticion, que ninguna URL de almacenamiento se filtre al cuerpo ni a los headers, y la traduccion completa de errores. Todo contra httpx.MockTransport, sin red. stream_document y su ruta pasan a async por el cambio; la prueba del 409 en test_expediente_upload gana su await, sin el cual la corrutina no se ejecutaba y la prueba pasaba sin probar nada. Refs: T2026-08-046 (fase 7)
183 lines
6.9 KiB
Python
183 lines
6.9 KiB
Python
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.database import get_core_db
|
|
from core.efc_client import EfcClientError
|
|
from core.security import get_current_user
|
|
|
|
from . import service
|
|
from .dto import (
|
|
ExpedienteCompleteInput,
|
|
ExpedienteDocumentResponse,
|
|
ExpedienteEnsureInput,
|
|
ExpedienteResponse,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/expedientes", response_model=list[ExpedienteResponse])
|
|
def list_expedientes(
|
|
company_id: int = Query(..., description="Company ID"),
|
|
service_request_id: int | None = Query(None, description="Filtrar por solicitud"),
|
|
account_id: int | None = Query(None, description="Filtrar por cliente"),
|
|
status_filter: str | None = Query(None, alias="status", description="abierto|completado|cerrado"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id = current_user["tenant_id"]
|
|
return service.list_expedientes(
|
|
db, tenant_id, company_id, service_request_id, account_id, status_filter
|
|
)
|
|
|
|
|
|
@router.get("/expedientes/{expediente_id}", response_model=ExpedienteResponse)
|
|
def get_expediente(
|
|
expediente_id: int,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id = current_user["tenant_id"]
|
|
return service.get_expediente(db, expediente_id, tenant_id, company_id)
|
|
|
|
|
|
@router.post("/expedientes/ensure", response_model=ExpedienteResponse)
|
|
def ensure_expediente(
|
|
payload: ExpedienteEnsureInput,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
"""Devuelve el expediente de una solicitud, creándolo si hace falta. Idempotente.
|
|
|
|
Responde 200 y no 201 justamente porque es idempotente: el llamador no puede distinguir —ni le
|
|
importa— si el expediente ya estaba.
|
|
"""
|
|
tenant_id = current_user["tenant_id"]
|
|
user_id = current_user.get("sub") or current_user.get("id")
|
|
return service.ensure_expediente(db, payload.service_request_id, tenant_id, company_id, user_id)
|
|
|
|
|
|
@router.post("/expedientes/{expediente_id}/completar", response_model=ExpedienteResponse)
|
|
def complete_expediente(
|
|
expediente_id: int,
|
|
payload: ExpedienteCompleteInput,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id = current_user["tenant_id"]
|
|
user_id = current_user.get("sub") or current_user.get("id")
|
|
return service.complete_expediente(db, expediente_id, payload, tenant_id, company_id, user_id)
|
|
|
|
|
|
@router.delete("/expedientes/{expediente_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_expediente(
|
|
expediente_id: int,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id = current_user["tenant_id"]
|
|
service.delete_expediente(db, expediente_id, tenant_id, company_id)
|
|
|
|
|
|
# ── Documentos del expediente ────────────────────────────────────────────────
|
|
|
|
|
|
@router.get(
|
|
"/expedientes/{expediente_id}/documentos",
|
|
response_model=list[ExpedienteDocumentResponse],
|
|
)
|
|
def list_expediente_documents(
|
|
expediente_id: int,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id = current_user["tenant_id"]
|
|
return service.list_expediente_documents(db, expediente_id, tenant_id, company_id)
|
|
|
|
|
|
@router.post(
|
|
"/expedientes/{expediente_id}/documentos",
|
|
response_model=ExpedienteDocumentResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def upload_expediente_document(
|
|
expediente_id: int,
|
|
file: UploadFile = File(...),
|
|
doc_type: str = Form(...),
|
|
name: str | None = Form(None),
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
"""Subida de un paso: guarda, registra y encola la entrega a EFC.
|
|
|
|
Responde **201 aunque EFC esté caído**: el archivo ya está a salvo en el CRM y el carril lo
|
|
entrega cuando EFC vuelva. Perder el trabajo del usuario porque un sistema de terceros no
|
|
contesta sería el peor intercambio posible.
|
|
"""
|
|
tenant_id = current_user["tenant_id"]
|
|
user_id = current_user.get("sub") or current_user.get("id")
|
|
return await service.attach_document(
|
|
db, expediente_id, file, doc_type, tenant_id, company_id, name, user_id
|
|
)
|
|
|
|
|
|
@router.get("/expedientes/{expediente_id}/documentos/{document_id}/archivo")
|
|
async def download_expediente_document(
|
|
expediente_id: int,
|
|
document_id: int,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
"""Proxy de descarga hacia EFC, con streaming.
|
|
|
|
La traducción de errores es asimétrica **a propósito**: cualquier ``EfcClientError`` sale como
|
|
502 —es un fallo de la integración, no del usuario—, salvo un 404 de EFC, que sale como 404
|
|
porque significa que ese documento realmente no está.
|
|
|
|
Es ``async`` porque el servicio abre la conexión con EFC y **comprueba su status antes** de que
|
|
esta función devuelva la ``StreamingResponse``: una vez devuelta, el status ya se envió y
|
|
traducir el error sería tarde (ver ``service._abrir_upstream``).
|
|
"""
|
|
tenant_id = current_user["tenant_id"]
|
|
try:
|
|
iterador, content_type, filename = await service.stream_document(
|
|
db, expediente_id, document_id, tenant_id, company_id
|
|
)
|
|
except EfcClientError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND
|
|
if exc.status_code == 404
|
|
else status.HTTP_502_BAD_GATEWAY,
|
|
detail="No se pudo obtener el archivo del expediente electrónico.",
|
|
) from exc
|
|
|
|
return StreamingResponse(
|
|
iterador,
|
|
media_type=content_type,
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/expedientes/{expediente_id}/documentos/{document_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
def detach_expediente_document(
|
|
expediente_id: int,
|
|
document_id: int,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
"""Desasocia el documento del CRM. **No lo destruye en EFC** (ver ``service.detach_document``)."""
|
|
tenant_id = current_user["tenant_id"]
|
|
service.detach_document(db, expediente_id, document_id, tenant_id, company_id)
|