Files
plantillas-proyectos/backend/api/v1/modules/a76/expediente_archivos/routes.py

400 lines
16 KiB
Python

from __future__ import annotations
from datetime import datetime
import io
import mimetypes
import os
import zipfile
from typing import Any, Dict
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from core.config import settings
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.s3_keys import expediente_archivo_document_key
from core.storage_s3 import delete_object_if_exists, get_object_bytes, object_exists, put_object_bytes
from .dto import (
DigitalizacionTaskDetailResponse,
DigitalizarRequest,
DigitalizarResponse,
ExpedienteArchivoCreateDTO,
ExpedienteArchivoListResponse,
ExpedienteArchivoResponseDTO,
ExpedienteArchivoUpdateDTO,
RegistrarDigitalizacionRequest,
)
from .models import ExpedienteArchivo
from .service import ExpedienteArchivoService
from .tasks import digitalizar_task
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/expediente-archivos")
def _remove_stored_document_path(path: str | None) -> None:
raw = (path or "").strip()
if not raw:
return
try:
if settings.use_s3_object_storage and not os.path.isabs(raw):
delete_object_if_exists(raw)
return
if os.path.exists(raw):
os.remove(raw)
except Exception:
logger.warning("No se pudo eliminar archivo previo de expediente: %s", raw, exc_info=True)
# ──────────────────────────────────────────────────────────────────────────── #
# CRUD #
# ──────────────────────────────────────────────────────────────────────────── #
@router.get("/", response_model=ExpedienteArchivoListResponse)
def list_expediente_archivos(
company_id: int = Query(...),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
search: str = Query(None),
status: str = Query(None),
rfc_consulta: str = Query(None),
e_document: str = Query(None),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return ExpedienteArchivoService.list(
db, company_id, tenant_id, page, page_size,
search=search, status=status, rfc_consulta=rfc_consulta, e_document=e_document
)
@router.get("/{record_id}", response_model=ExpedienteArchivoResponseDTO)
def get_expediente_archivo(
record_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
return ExpedienteArchivoResponseDTO.model_validate(record)
@router.post("/", response_model=ExpedienteArchivoResponseDTO, status_code=status.HTTP_201_CREATED)
def create_expediente_archivo(
dto: ExpedienteArchivoCreateDTO,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.create(db, dto, company_id, tenant_id)
return ExpedienteArchivoResponseDTO.model_validate(record)
@router.put("/{record_id}", response_model=ExpedienteArchivoResponseDTO)
def update_expediente_archivo(
record_id: int,
dto: ExpedienteArchivoUpdateDTO,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
record = ExpedienteArchivoService.update(db, record, dto)
return ExpedienteArchivoResponseDTO.model_validate(record)
@router.delete("/{record_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_expediente_archivo(
record_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
_remove_stored_document_path(record.archivo_digitalizado_en)
ExpedienteArchivoService.delete(db, record)
return None
_ARTIFACT_TYPE_MAP = {
"acuse": ("acuse_pdf_path", "application/pdf"),
"envio-xml": ("envio_xml_path", "application/xml"),
"respuesta-xml": ("respuesta_xml_path", "application/xml"),
"consulta-envio-xml": ("consulta_envio_xml_path", "application/xml"),
"consulta-respuesta-xml": ("consulta_respuesta_xml_path", "application/xml"),
}
@router.get("/{record_id}/artifacts/{artifact_type}", response_class=Response)
def download_artifact(
record_id: int,
artifact_type: str,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Descarga un artefacto de digitalización (acuse PDF o XMLs) desde S3."""
tenant_id = validate_access_to_resource(db, company_id, current_user)
if artifact_type not in _ARTIFACT_TYPE_MAP:
raise HTTPException(status_code=400, detail=f"Tipo de artefacto no válido: {artifact_type}")
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
field_name, content_type = _ARTIFACT_TYPE_MAP[artifact_type]
key = (getattr(record, field_name, None) or "").strip()
if not key or key == "inline":
raise HTTPException(status_code=404, detail="Artefacto no disponible para este expediente.")
if not object_exists(key):
raise HTTPException(status_code=404, detail="El archivo no se encontró en el almacenamiento.")
ext = ".pdf" if content_type == "application/pdf" else ".xml"
filename = f"{artifact_type}_{record.e_document or record_id}{ext}"
return Response(
content=get_object_bytes(key),
media_type=content_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{record_id}/artifacts-zip", response_class=Response)
def download_artifacts_zip(
record_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Descarga todos los artefactos disponibles de un expediente en un archivo ZIP."""
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
base_name = record.e_document or str(record_id)
artifact_files = [
("acuse", "acuse_pdf_path", f"acuse_{base_name}.pdf"),
("envio-xml", "envio_xml_path", f"envio_{base_name}.xml"),
("respuesta-xml", "respuesta_xml_path", f"respuesta_{base_name}.xml"),
("consulta-envio-xml", "consulta_envio_xml_path", f"consulta_envio_{base_name}.xml"),
("consulta-respuesta-xml", "consulta_respuesta_xml_path", f"consulta_respuesta_{base_name}.xml"),
]
buf = io.BytesIO()
added = 0
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for _type, field_name, filename in artifact_files:
key = (getattr(record, field_name, None) or "").strip()
if not key or key == "inline":
continue
if not object_exists(key):
continue
zf.writestr(filename, get_object_bytes(key))
added += 1
if added == 0:
raise HTTPException(status_code=404, detail="No hay artefactos disponibles para este expediente.")
buf.seek(0)
zip_filename = f"expediente_{base_name}.zip"
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
)
@router.post("/{record_id}/upload", response_model=Dict[str, Any])
async def upload_expediente_archivo_file(
record_id: int,
company_id: int = Query(...),
file: UploadFile = File(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="El archivo está vacío.")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Borrar artefactos de digitalización previa al subir documento nuevo
_ARTIFACT_FIELDS = [
"acuse_pdf_path",
"envio_xml_path",
"respuesta_xml_path",
"consulta_envio_xml_path",
"consulta_respuesta_xml_path",
]
for _field in _ARTIFACT_FIELDS:
_old_key = (getattr(record, _field, None) or "").strip()
if _old_key and _old_key != "inline":
try:
delete_object_if_exists(_old_key)
except Exception:
logger.warning("No se pudo eliminar artefacto previo %s=%s", _field, _old_key, exc_info=True)
setattr(record, _field, None)
# El documento fuente cambió — la digitalización anterior ya no aplica
record.status = "pending"
record.e_document = None
record.num_operacion = None
record.task_id = None
record.external_task_id = None
try:
if settings.use_s3_object_storage:
key = expediente_archivo_document_key(
tenant_id,
company_id,
record.id,
timestamp,
file.filename or "documento.pdf",
)
ct = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream"
_remove_stored_document_path(record.archivo_digitalizado_en)
put_object_bytes(key, content, content_type=ct)
stored = key
else:
key = expediente_archivo_document_key(
tenant_id,
company_id,
record.id,
timestamp,
file.filename or "documento.pdf",
)
base = os.path.join("uploads", "expediente_archivos", str(company_id), str(record.id))
os.makedirs(base, exist_ok=True)
filename = key.rsplit("/", 1)[-1]
stored = os.path.join(base, filename)
_remove_stored_document_path(record.archivo_digitalizado_en)
with open(stored, "wb") as destination:
destination.write(content)
record.archivo_digitalizado_en = stored
record.nombre_archivo = file.filename or record.nombre_archivo
db.add(record)
db.commit()
db.refresh(record)
except HTTPException:
db.rollback()
raise
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
db.rollback()
raise HTTPException(status_code=500, detail=f"Error saving file: {str(exc)}") from exc
return {
"message": "Archivo cargado correctamente",
"record_id": record.id,
"path": stored,
"nombre_archivo": record.nombre_archivo,
}
# ──────────────────────────────────────────────────────────────────────────── #
# Digitalización #
# ──────────────────────────────────────────────────────────────────────────── #
@router.post("/digitalizar/{record_id}", response_model=DigitalizarResponse)
def digitalizar_expediente_archivo(
record_id: int,
body: DigitalizarRequest,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Lanza la tarea Celery de digitalización para un expediente existente.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
task = digitalizar_task.apply_async(
kwargs={
"expediente_id": record_id,
"request_data": body.model_dump(),
"company_id": company_id,
"tenant_id": tenant_id,
},
headers={
"rls_tenant_id": str(int(tenant_id)),
"rls_company_id": str(int(company_id)),
},
)
return DigitalizarResponse(
task_id=task.id,
message="Tarea de digitalización iniciada.",
status="pending",
)
@router.post("/registrar-digitalizacion/", response_model=Dict[str, Any])
def registrar_digitalizacion(
body: RegistrarDigitalizacionRequest,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Lanza tareas de digitalización en batch para múltiples expedientes.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
launched = []
errors_list = []
for record_id in body.ids_archivos:
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
errors_list.append({"id": record_id, "error": "No encontrado"})
continue
task = digitalizar_task.apply_async(
kwargs={
"expediente_id": record_id,
"request_data": {"rfc_consulta": body.rfc_consulta},
"company_id": company_id,
"tenant_id": tenant_id,
},
headers={
"rls_tenant_id": str(int(tenant_id)),
"rls_company_id": str(int(company_id)),
},
)
launched.append({"id": record_id, "task_id": task.id})
return {"launched": launched, "errors": errors_list}
@router.get("/status-digitalizacion-task/{task_id}", response_model=DigitalizacionTaskDetailResponse)
def get_digitalizacion_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Consulta el estado de una tarea Celery de digitalización.
"""
return ExpedienteArchivoService.get_task_status(task_id)