add redis backend

This commit is contained in:
Anibal Angulo
2025-11-06 16:24:05 -06:00
parent 86e5c955c5
commit c5e0a451c0
13 changed files with 1302 additions and 650 deletions

View File

@@ -1,6 +1,6 @@
import os
from typing import List
from pydantic import validator
from pydantic import RedisDsn
from pydantic_settings import BaseSettings
@@ -8,20 +8,22 @@ class Settings(BaseSettings):
"""
Configuración básica de la aplicación
"""
# Configuración básica de la aplicación
APP_NAME: str = "File Manager API"
DEBUG: bool = False
HOST: str = "0.0.0.0"
PORT: int = 8000
# Configuración de CORS para React frontend
ALLOWED_ORIGINS: List[str] = [
"http://localhost:3000", # React dev server
"http://localhost:5173",
"http://frontend:3000", # Docker container name
"http://frontend:3000", # Docker container name
]
REDIS_OM_URL: RedisDsn
# Azure Blob Storage configuración
AZURE_STORAGE_CONNECTION_STRING: str
AZURE_STORAGE_ACCOUNT_NAME: str = ""
@@ -52,66 +54,10 @@ class Settings(BaseSettings):
# Schemas storage
SCHEMAS_DIR: str = "./data/schemas"
@validator("AZURE_STORAGE_CONNECTION_STRING")
def validate_azure_connection_string(cls, v):
"""Validar que el connection string de Azure esté presente"""
if not v:
raise ValueError("AZURE_STORAGE_CONNECTION_STRING es requerido")
return v
@validator("QDRANT_URL")
def validate_qdrant_url(cls, v):
"""Validar que la URL de Qdrant esté presente"""
if not v:
raise ValueError("QDRANT_URL es requerido")
return v
@validator("QDRANT_API_KEY")
def validate_qdrant_api_key(cls, v):
"""Validar que la API key de Qdrant esté presente"""
if not v:
raise ValueError("QDRANT_API_KEY es requerido")
return v
@validator("AZURE_OPENAI_ENDPOINT")
def validate_azure_openai_endpoint(cls, v):
"""Validar que el endpoint de Azure OpenAI esté presente"""
if not v:
raise ValueError("AZURE_OPENAI_ENDPOINT es requerido")
return v
@validator("AZURE_OPENAI_API_KEY")
def validate_azure_openai_api_key(cls, v):
"""Validar que la API key de Azure OpenAI esté presente"""
if not v:
raise ValueError("AZURE_OPENAI_API_KEY es requerido")
return v
@validator("GOOGLE_APPLICATION_CREDENTIALS")
def validate_google_credentials(cls, v):
"""Validar que el path de credenciales de Google esté presente"""
if not v:
raise ValueError("GOOGLE_APPLICATION_CREDENTIALS es requerido")
return v
@validator("GOOGLE_CLOUD_PROJECT")
def validate_google_project(cls, v):
"""Validar que el proyecto de Google Cloud esté presente"""
if not v:
raise ValueError("GOOGLE_CLOUD_PROJECT es requerido")
return v
@validator("LANDINGAI_API_KEY")
def validate_landingai_api_key(cls, v):
"""Validar que la API key de LandingAI esté presente"""
if not v:
raise ValueError("LANDINGAI_API_KEY es requerido")
return v
class Config:
env_file = ".env"
case_sensitive = True
# Instancia global de configuración
settings = Settings()
settings = Settings.model_validate({})

View File

@@ -1,16 +1,21 @@
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
import logging
from .core.config import settings
from .routers.chunking import router as chunking_router
from .routers.chunking_landingai import router as chunking_landingai_router
from .routers.dataroom import router as dataroom_router
# Import routers
from .routers.files import router as files_router
from .routers.vectors import router as vectors_router
from .routers.chunking import router as chunking_router
from .routers.schemas import router as schemas_router
from .routers.chunking_landingai import router as chunking_landingai_router
from .core.config import settings
from .routers.vectors import router as vectors_router
# from routers.ai import router as ai_router # futuro con Azure OpenAI
# Import config
@@ -18,18 +23,31 @@ from .core.config import settings
# Configurar logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
logger.info("Iniciando File Manager API...")
logger.info(
f"Conectando a Azure Storage Account: {settings.AZURE_STORAGE_ACCOUNT_NAME}"
)
logger.info(f"Conectando a Qdrant: {settings.QDRANT_URL}")
yield
logger.info("Cerrando File Manager API...")
# Cleanup de recursos si es necesario
app = FastAPI(
title="File Manager API",
description=" DoRa",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
redoc_url="/redoc",
)
# Configurar CORS para React frontend
@@ -41,6 +59,7 @@ app.add_middleware(
allow_headers=["*"],
)
# Middleware para logging de requests
@app.middleware("http")
async def log_requests(request, call_next):
@@ -49,19 +68,17 @@ async def log_requests(request, call_next):
logger.info(f"Response: {response.status_code}")
return response
# Manejador global de excepciones
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
logger.error(f"HTTP Exception: {exc.status_code} - {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={
"error": True,
"message": exc.detail,
"status_code": exc.status_code
}
content={"error": True, "message": exc.detail, "status_code": exc.status_code},
)
@app.exception_handler(Exception)
async def general_exception_handler(request, exc):
logger.error(f"Unhandled Exception: {str(exc)}")
@@ -70,10 +87,11 @@ async def general_exception_handler(request, exc):
content={
"error": True,
"message": "Error interno del servidor",
"status_code": 500
}
"status_code": 500,
},
)
# Health check endpoint
@app.get("/health")
async def health_check():
@@ -81,9 +99,10 @@ async def health_check():
return {
"status": "healthy",
"message": "File Manager API está funcionando correctamente",
"version": "1.0.0"
"version": "1.0.0",
}
# Root endpoint
@app.get("/")
async def root():
@@ -92,27 +111,16 @@ async def root():
"message": "File Manager API",
"version": "1.0.0",
"docs": "/docs",
"health": "/health"
"health": "/health",
}
# Incluir routers
app.include_router(
files_router,
prefix="/api/v1/files",
tags=["files"]
)
app.include_router(files_router, prefix="/api/v1/files", tags=["files"])
app.include_router(
vectors_router,
prefix="/api/v1",
tags=["vectors"]
)
app.include_router(vectors_router, prefix="/api/v1", tags=["vectors"])
app.include_router(
chunking_router,
prefix="/api/v1",
tags=["chunking"]
)
app.include_router(chunking_router, prefix="/api/v1", tags=["chunking"])
# Schemas router (nuevo)
app.include_router(schemas_router)
@@ -120,6 +128,8 @@ app.include_router(schemas_router)
# Chunking LandingAI router (nuevo)
app.include_router(chunking_landingai_router)
app.include_router(dataroom_router, prefix="/api/v1")
# Router para IA
# app.include_router(
# ai_router,
@@ -127,21 +137,6 @@ app.include_router(chunking_landingai_router)
# tags=["ai"]
# )
# Evento de startup
@app.on_event("startup")
async def startup_event():
logger.info("Iniciando File Manager API...")
logger.info(f"Conectando a Azure Storage Account: {settings.AZURE_STORAGE_ACCOUNT_NAME}")
logger.info(f"Conectando a Qdrant: {settings.QDRANT_URL}")
# validaciones de conexión a Azure
# Evento de shutdown
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Cerrando File Manager API...")
# Cleanup de recursos si es necesario
if __name__ == "__main__":
uvicorn.run(
@@ -149,5 +144,5 @@ if __name__ == "__main__":
host=settings.HOST,
port=settings.PORT,
reload=settings.DEBUG,
log_level="info"
)
log_level="info",
)

View File

@@ -0,0 +1,10 @@
from redis_om import HashModel, Migrator
class DataRoom(HashModel):
name: str
collection: str
storage: str
Migrator().run()

View File

@@ -0,0 +1,150 @@
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..models.dataroom import DataRoom
from ..models.vector_models import CollectionCreateRequest
from ..services.vector_service import vector_service
logger = logging.getLogger(__name__)
class DataroomCreate(BaseModel):
name: str
collection: str = ""
storage: str = ""
router = APIRouter(prefix="/dataroom", tags=["Dataroom"])
@router.get("/")
async def list_datarooms():
"""
Listar todos los temas disponibles
"""
try:
# Get all DataRoom instances
datarooms: list[DataRoom] = DataRoom.find().all()
logger.info(f"Found {len(datarooms)} datarooms in Redis")
# Convert to list of dictionaries
dataroom_list = [
{"name": room.name, "collection": room.collection, "storage": room.storage}
for room in datarooms
]
logger.info(f"Returning dataroom list: {dataroom_list}")
return {"datarooms": dataroom_list}
except Exception as e:
logger.error(f"Error listing datarooms: {e}")
raise HTTPException(
status_code=500, detail=f"Error listing datarooms: {str(e)}"
)
@router.post("/")
async def create_dataroom(dataroom: DataroomCreate):
"""
Crear un nuevo dataroom y su colección vectorial asociada
"""
try:
# Create new DataRoom instance
new_dataroom = DataRoom(
name=dataroom.name, collection=dataroom.collection, storage=dataroom.storage
)
# Save to Redis
new_dataroom.save()
# Create the vector collection for this dataroom
try:
# First check if collection already exists
collection_exists_response = await vector_service.check_collection_exists(
dataroom.name
)
if not collection_exists_response.exists:
# Only create if it doesn't exist
collection_request = CollectionCreateRequest(
collection_name=dataroom.name,
vector_size=3072, # Default vector size for embeddings
distance="Cosine", # Default distance metric
)
await vector_service.create_collection(collection_request)
logger.info(f"Collection '{dataroom.name}' created successfully")
else:
logger.info(
f"Collection '{dataroom.name}' already exists, skipping creation"
)
except Exception as e:
# Log the error but don't fail the dataroom creation
logger.warning(
f"Could not create collection for dataroom '{dataroom.name}': {e}"
)
return {
"message": "Dataroom created successfully",
"dataroom": {
"name": new_dataroom.name,
"collection": new_dataroom.collection,
"storage": new_dataroom.storage,
},
}
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error creating dataroom: {str(e)}"
)
@router.delete("/{dataroom_name}")
async def delete_dataroom(dataroom_name: str):
"""
Eliminar un dataroom y su colección vectorial asociada
"""
try:
# First check if dataroom exists
existing_datarooms = DataRoom.find().all()
dataroom_exists = any(room.name == dataroom_name for room in existing_datarooms)
if not dataroom_exists:
raise HTTPException(
status_code=404, detail=f"Dataroom '{dataroom_name}' not found"
)
# Delete the vector collection first
try:
collection_exists = await vector_service.check_collection_exists(
dataroom_name
)
if collection_exists.exists:
await vector_service.delete_collection(dataroom_name)
logger.info(
f"Collection '{dataroom_name}' deleted from vector database"
)
except Exception as e:
logger.warning(
f"Could not delete collection '{dataroom_name}' from vector database: {e}"
)
# Continue with dataroom deletion even if collection deletion fails
# Delete the dataroom from Redis
for room in existing_datarooms:
if room.name == dataroom_name:
# Delete using the primary key
DataRoom.delete(room.pk)
logger.info(f"Dataroom '{dataroom_name}' deleted from Redis")
break
return {
"message": "Dataroom deleted successfully",
"dataroom_name": dataroom_name,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting dataroom '{dataroom_name}': {e}")
raise HTTPException(
status_code=500, detail=f"Error deleting dataroom: {str(e)}"
)

View File

@@ -1,18 +1,28 @@
from fastapi import APIRouter, UploadFile, File, HTTPException, Query, Form
from fastapi.responses import StreamingResponse, Response
from typing import Optional, List
import io
import logging
import os
import zipfile
import io
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import Response, StreamingResponse
from ..models.dataroom import DataRoom
from ..models.file_models import (
FileUploadRequest, FileUploadResponse, FileInfo, FileListResponse,
FileDeleteResponse, FileBatchDeleteRequest,
FileConflictResponse, FileBatchDeleteResponse,
FileBatchDownloadRequest, TemasListResponse,
FileUploadCheckRequest, FileUploadConfirmRequest, ErrorResponse
ErrorResponse,
FileBatchDeleteRequest,
FileBatchDeleteResponse,
FileBatchDownloadRequest,
FileConflictResponse,
FileDeleteResponse,
FileInfo,
FileListResponse,
FileUploadCheckRequest,
FileUploadConfirmRequest,
FileUploadRequest,
FileUploadResponse,
TemasListResponse,
)
from ..services.azure_service import azure_service
from ..services.file_service import file_service
@@ -31,27 +41,27 @@ async def check_file_before_upload(request: FileUploadCheckRequest):
is_valid, error_msg = file_service.validate_filename(request.filename)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Validar extensión
is_valid, error_msg = file_service.validate_file_extension(request.filename)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Limpiar tema
clean_tema = file_service.clean_tema_name(request.tema or "")
# Verificar si existe conflicto
has_conflict, suggested_name = await file_service.handle_file_conflict(
request.filename, clean_tema
)
if has_conflict:
return FileConflictResponse(
conflict=True,
message=f"El archivo '{request.filename}' ya existe en el tema '{clean_tema or 'general'}'",
existing_file=request.filename,
suggested_name=suggested_name,
tema=clean_tema
tema=clean_tema,
)
else:
# No hay conflicto, se puede subir directamente
@@ -60,14 +70,16 @@ async def check_file_before_upload(request: FileUploadCheckRequest):
message="Archivo disponible para subir",
existing_file=request.filename,
suggested_name=request.filename,
tema=clean_tema
tema=clean_tema,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error verificando archivo '{request.filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/upload/confirm", response_model=FileUploadResponse)
@@ -75,7 +87,7 @@ async def upload_file_with_confirmation(
file: UploadFile = File(...),
action: str = Form(...),
tema: Optional[str] = Form(None),
new_filename: Optional[str] = Form(None)
new_filename: Optional[str] = Form(None),
):
"""
Subir archivo con confirmación de acción para conflictos
@@ -84,61 +96,54 @@ async def upload_file_with_confirmation(
# Validar archivo
if not file.filename:
raise HTTPException(status_code=400, detail="Nombre de archivo requerido")
# Crear request de confirmación para validaciones
confirm_request = FileUploadConfirmRequest(
filename=file.filename,
tema=tema,
action=action,
new_filename=new_filename
filename=file.filename, tema=tema, action=action, new_filename=new_filename
)
# Si la acción es cancelar, no hacer nada
if confirm_request.action == "cancel":
return FileUploadResponse(
success=False,
message="Subida cancelada por el usuario",
file=None
success=False, message="Subida cancelada por el usuario", file=None
)
# Determinar el nombre final del archivo
final_filename = file.filename
if confirm_request.action == "rename" and confirm_request.new_filename:
final_filename = confirm_request.new_filename
# Validar extensión del archivo final
is_valid, error_msg = file_service.validate_file_extension(final_filename)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Leer contenido del archivo
file_content = await file.read()
# Validar tamaño del archivo
is_valid, error_msg = file_service.validate_file_size(len(file_content))
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Limpiar tema
clean_tema = file_service.clean_tema_name(confirm_request.tema or "")
# Si es sobrescribir, verificar que el archivo original exista
if confirm_request.action == "overwrite":
exists = await file_service.check_file_exists(file.filename, clean_tema)
if not exists:
raise HTTPException(
status_code=404,
detail=f"Archivo '{file.filename}' no existe para sobrescribir"
status_code=404,
detail=f"Archivo '{file.filename}' no existe para sobrescribir",
)
# Subir archivo a Azure
file_stream = io.BytesIO(file_content)
uploaded_file_info = await azure_service.upload_file(
file_data=file_stream,
blob_name=final_filename,
tema=clean_tema
file_data=file_stream, blob_name=final_filename, tema=clean_tema
)
# Crear objeto FileInfo
file_info = FileInfo(
name=uploaded_file_info["name"],
@@ -146,75 +151,95 @@ async def upload_file_with_confirmation(
tema=uploaded_file_info["tema"],
size=uploaded_file_info["size"],
last_modified=uploaded_file_info["last_modified"],
url=uploaded_file_info["url"]
url=uploaded_file_info["url"],
)
action_msg = {
"overwrite": "sobrescrito",
"rename": f"renombrado a '{final_filename}'"
"rename": f"renombrado a '{final_filename}'",
}
logger.info(f"Archivo '{file.filename}' {action_msg.get(confirm_request.action, 'subido')} exitosamente")
logger.info(
f"Archivo '{file.filename}' {action_msg.get(confirm_request.action, 'subido')} exitosamente"
)
return FileUploadResponse(
success=True,
message=f"Archivo {action_msg.get(confirm_request.action, 'subido')} exitosamente",
file=file_info
file=file_info,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error en subida confirmada: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/upload", response_model=FileUploadResponse)
async def upload_file(
file: UploadFile = File(...),
tema: Optional[str] = Form(None)
):
async def upload_file(file: UploadFile = File(...), tema: Optional[str] = Form(None)):
"""
Subir un archivo al almacenamiento
"""
try:
# Validar que el dataroom existe si se proporciona un tema
if tema:
existing_datarooms = DataRoom.find().all()
dataroom_exists = any(room.name == tema for room in existing_datarooms)
if not dataroom_exists:
raise HTTPException(
status_code=400,
detail=f"El dataroom '{tema}' no existe. Créalo primero antes de subir archivos.",
)
# Validar archivo
if not file.filename:
raise HTTPException(status_code=400, detail="Nombre de archivo requerido")
# Validar extensión del archivo
file_extension = os.path.splitext(file.filename)[1].lower()
allowed_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv']
allowed_extensions = [
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".txt",
".csv",
]
if file_extension not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Tipo de archivo no permitido. Extensiones permitidas: {', '.join(allowed_extensions)}"
status_code=400,
detail=f"Tipo de archivo no permitido. Extensiones permitidas: {', '.join(allowed_extensions)}",
)
# Leer contenido del archivo
file_content = await file.read()
# Validar tamaño del archivo (100MB máximo)
max_size = 100 * 1024 * 1024 # 100MB
if len(file_content) > max_size:
raise HTTPException(
status_code=400,
detail=f"Archivo demasiado grande. Tamaño máximo permitido: 100MB"
detail=f"Archivo demasiado grande. Tamaño máximo permitido: 100MB",
)
# Procesar tema
upload_request = FileUploadRequest(tema=tema)
processed_tema = upload_request.tema or ""
# Subir archivo a Azure
file_stream = io.BytesIO(file_content)
uploaded_file_info = await azure_service.upload_file(
file_data=file_stream,
blob_name=file.filename,
tema=processed_tema
file_data=file_stream, blob_name=file.filename, tema=processed_tema
)
# Crear objeto FileInfo
file_info = FileInfo(
name=uploaded_file_info["name"],
@@ -222,22 +247,24 @@ async def upload_file(
tema=uploaded_file_info["tema"],
size=uploaded_file_info["size"],
last_modified=uploaded_file_info["last_modified"],
url=uploaded_file_info["url"]
url=uploaded_file_info["url"],
)
logger.info(f"Archivo '{file.filename}' subido exitosamente al tema '{processed_tema}'")
logger.info(
f"Archivo '{file.filename}' subido exitosamente al tema '{processed_tema}'"
)
return FileUploadResponse(
success=True,
message="Archivo subido exitosamente",
file=file_info
success=True, message="Archivo subido exitosamente", file=file_info
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error subiendo archivo: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/", response_model=FileListResponse)
@@ -248,7 +275,7 @@ async def list_files(tema: Optional[str] = Query(None, description="Filtrar por
try:
# Obtener archivos de Azure
files_data = await azure_service.list_files(tema=tema or "")
# Convertir a objetos FileInfo
files_info = []
for file_data in files_data:
@@ -258,21 +285,22 @@ async def list_files(tema: Optional[str] = Query(None, description="Filtrar por
tema=file_data["tema"],
size=file_data["size"],
last_modified=file_data["last_modified"],
content_type=file_data.get("content_type")
content_type=file_data.get("content_type"),
)
files_info.append(file_info)
logger.info(f"Listados {len(files_info)} archivos" + (f" del tema '{tema}'" if tema else ""))
return FileListResponse(
files=files_info,
total=len(files_info),
tema=tema
logger.info(
f"Listados {len(files_info)} archivos"
+ (f" del tema '{tema}'" if tema else "")
)
return FileListResponse(files=files_info, total=len(files_info), tema=tema)
except Exception as e:
logger.error(f"Error listando archivos: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/temas", response_model=TemasListResponse)
@@ -283,31 +311,30 @@ async def list_temas():
try:
# Obtener todos los archivos
files_data = await azure_service.list_files()
# Extraer temas únicos
temas = set()
for file_data in files_data:
if file_data["tema"]:
temas.add(file_data["tema"])
temas_list = sorted(list(temas))
logger.info(f"Encontrados {len(temas_list)} temas")
return TemasListResponse(
temas=temas_list,
total=len(temas_list)
)
return TemasListResponse(temas=temas_list, total=len(temas_list))
except Exception as e:
logger.error(f"Error listando temas: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/{filename}/download")
async def download_file(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
):
"""
Descargar un archivo individual
@@ -315,64 +342,71 @@ async def download_file(
try:
# Descargar archivo de Azure
file_content = await azure_service.download_file(
blob_name=filename,
tema=tema or ""
blob_name=filename, tema=tema or ""
)
# Obtener información del archivo para content-type
file_info = await azure_service.get_file_info(
blob_name=filename,
tema=tema or ""
blob_name=filename, tema=tema or ""
)
# Determinar content-type
content_type = file_info.get("content_type", "application/octet-stream")
logger.info(f"Descargando archivo '{filename}'" + (f" del tema '{tema}'" if tema else ""))
logger.info(
f"Descargando archivo '{filename}'"
+ (f" del tema '{tema}'" if tema else "")
)
return Response(
content=file_content,
media_type=content_type,
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error descargando archivo '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.delete("/{filename}", response_model=FileDeleteResponse)
async def delete_file(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
):
"""
Eliminar un archivo
"""
try:
# Eliminar archivo de Azure
await azure_service.delete_file(
blob_name=filename,
tema=tema or ""
await azure_service.delete_file(blob_name=filename, tema=tema or "")
logger.info(
f"Archivo '{filename}' eliminado exitosamente"
+ (f" del tema '{tema}'" if tema else "")
)
logger.info(f"Archivo '{filename}' eliminado exitosamente" + (f" del tema '{tema}'" if tema else ""))
return FileDeleteResponse(
success=True,
message="Archivo eliminado exitosamente",
deleted_file=filename
deleted_file=filename,
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error eliminando archivo '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/delete-batch", response_model=FileBatchDeleteResponse)
@@ -383,34 +417,35 @@ async def delete_batch_files(request: FileBatchDeleteRequest):
try:
deleted_files = []
failed_files = []
for filename in request.files:
try:
await azure_service.delete_file(
blob_name=filename,
tema=request.tema or ""
blob_name=filename, tema=request.tema or ""
)
deleted_files.append(filename)
logger.info(f"Archivo '{filename}' eliminado exitosamente")
except Exception as e:
failed_files.append(filename)
logger.error(f"Error eliminando archivo '{filename}': {e}")
success = len(failed_files) == 0
message = f"Eliminados {len(deleted_files)} archivos exitosamente"
if failed_files:
message += f", {len(failed_files)} archivos fallaron"
return FileBatchDeleteResponse(
success=success,
message=message,
deleted_files=deleted_files,
failed_files=failed_files
failed_files=failed_files,
)
except Exception as e:
logger.error(f"Error en eliminación batch: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/download-batch")
@@ -421,44 +456,43 @@ async def download_batch_files(request: FileBatchDownloadRequest):
try:
# Crear ZIP en memoria
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for filename in request.files:
try:
# Descargar archivo de Azure
file_content = await azure_service.download_file(
blob_name=filename,
tema=request.tema or ""
blob_name=filename, tema=request.tema or ""
)
# Agregar al ZIP
zip_file.writestr(filename, file_content)
logger.info(f"Archivo '{filename}' agregado al ZIP")
except Exception as e:
logger.error(f"Error agregando '{filename}' al ZIP: {e}")
# Continuar con otros archivos
continue
zip_buffer.seek(0)
# Generar nombre del ZIP
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"{request.zip_name}_{timestamp}.zip"
logger.info(f"ZIP creado exitosamente: {zip_filename}")
return StreamingResponse(
io.BytesIO(zip_buffer.read()),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={zip_filename}"
}
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
)
except Exception as e:
logger.error(f"Error creando ZIP: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/tema/{tema}/download-all")
@@ -469,54 +503,58 @@ async def download_tema_completo(tema: str):
try:
# Obtener todos los archivos del tema
files_data = await azure_service.list_files(tema=tema)
if not files_data:
raise HTTPException(status_code=404, detail=f"No se encontraron archivos en el tema '{tema}'")
raise HTTPException(
status_code=404,
detail=f"No se encontraron archivos en el tema '{tema}'",
)
# Crear ZIP en memoria
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for file_data in files_data:
try:
filename = file_data["name"]
# Descargar archivo de Azure
file_content = await azure_service.download_file(
blob_name=filename,
tema=tema
blob_name=filename, tema=tema
)
# Agregar al ZIP
zip_file.writestr(filename, file_content)
logger.info(f"Archivo '{filename}' agregado al ZIP del tema '{tema}'")
logger.info(
f"Archivo '{filename}' agregado al ZIP del tema '{tema}'"
)
except Exception as e:
logger.error(f"Error agregando '{filename}' al ZIP: {e}")
# Continuar con otros archivos
continue
zip_buffer.seek(0)
# Generar nombre del ZIP
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"{tema}_{timestamp}.zip"
logger.info(f"ZIP del tema '{tema}' creado exitosamente: {zip_filename}")
return StreamingResponse(
io.BytesIO(zip_buffer.read()),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={zip_filename}"
}
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creando ZIP del tema '{tema}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.delete("/tema/{tema}/delete-all", response_model=FileBatchDeleteResponse)
@@ -527,51 +565,59 @@ async def delete_tema_completo(tema: str):
try:
# Obtener todos los archivos del tema
files_data = await azure_service.list_files(tema=tema)
if not files_data:
raise HTTPException(status_code=404, detail=f"No se encontraron archivos en el tema '{tema}'")
raise HTTPException(
status_code=404,
detail=f"No se encontraron archivos en el tema '{tema}'",
)
deleted_files = []
failed_files = []
for file_data in files_data:
filename = file_data["name"]
try:
await azure_service.delete_file(
blob_name=filename,
tema=tema
)
await azure_service.delete_file(blob_name=filename, tema=tema)
deleted_files.append(filename)
logger.info(f"Archivo '{filename}' eliminado del tema '{tema}'")
except Exception as e:
failed_files.append(filename)
logger.error(f"Error eliminando archivo '{filename}' del tema '{tema}': {e}")
logger.error(
f"Error eliminando archivo '{filename}' del tema '{tema}': {e}"
)
success = len(failed_files) == 0
message = f"Tema '{tema}': eliminados {len(deleted_files)} archivos exitosamente"
message = (
f"Tema '{tema}': eliminados {len(deleted_files)} archivos exitosamente"
)
if failed_files:
message += f", {len(failed_files)} archivos fallaron"
logger.info(f"Eliminación completa del tema '{tema}': {len(deleted_files)} exitosos, {len(failed_files)} fallidos")
logger.info(
f"Eliminación completa del tema '{tema}': {len(deleted_files)} exitosos, {len(failed_files)} fallidos"
)
return FileBatchDeleteResponse(
success=success,
message=message,
deleted_files=deleted_files,
failed_files=failed_files
failed_files=failed_files,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error eliminando tema '{tema}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/{filename}/info", response_model=FileInfo)
async def get_file_info(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
):
"""
Obtener información detallada de un archivo
@@ -579,8 +625,7 @@ async def get_file_info(
try:
# Obtener información de Azure
file_data = await azure_service.get_file_info(
blob_name=filename,
tema=tema or ""
blob_name=filename, tema=tema or ""
)
# Convertir a objeto FileInfo
@@ -591,24 +636,30 @@ async def get_file_info(
size=file_data["size"],
last_modified=file_data["last_modified"],
content_type=file_data.get("content_type"),
url=file_data.get("url")
url=file_data.get("url"),
)
logger.info(f"Información obtenida para archivo '{filename}'")
return file_info
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error obteniendo info del archivo '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/{filename}/preview-url")
async def get_file_preview_url(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
expiry_hours: int = Query(1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24)
expiry_hours: int = Query(
1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24
),
):
"""
Generar una URL temporal (SAS) para vista previa de archivos
@@ -633,23 +684,28 @@ async def get_file_preview_url(
try:
# Generar SAS URL usando el servicio de Azure
sas_url = await azure_service.generate_sas_url(
blob_name=filename,
tema=tema or "",
expiry_hours=expiry_hours
blob_name=filename, tema=tema or "", expiry_hours=expiry_hours
)
logger.info(f"SAS URL generada para preview de '{filename}'" + (f" del tema '{tema}'" if tema else ""))
logger.info(
f"SAS URL generada para preview de '{filename}'"
+ (f" del tema '{tema}'" if tema else "")
)
return {
"success": True,
"filename": filename,
"url": sas_url,
"expiry_hours": expiry_hours,
"message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})"
"message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})",
}
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error generando preview URL para '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)