400 lines
16 KiB
Python
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)
|