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), 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) @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, } ) 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, } ) 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)