V1 de backend funcional
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
42
backend/app/core/config.py
Normal file
42
backend/app/core/config.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import os
|
||||
from typing import List
|
||||
from pydantic import validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
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://frontend:3000", # Docker container name
|
||||
]
|
||||
|
||||
# Azure Blob Storage configuración
|
||||
AZURE_STORAGE_CONNECTION_STRING: str
|
||||
AZURE_STORAGE_ACCOUNT_NAME: str = ""
|
||||
AZURE_CONTAINER_NAME: str = "files"
|
||||
|
||||
@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
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# Instancia global de configuración
|
||||
settings = Settings()
|
||||
130
backend/app/main.py
Normal file
130
backend/app/main.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
import logging
|
||||
|
||||
# Import routers
|
||||
from .routers.files import router as files_router
|
||||
from .core.config import settings
|
||||
# from routers.ai import router as ai_router # futuro con Azure OpenAI
|
||||
|
||||
# Import config
|
||||
|
||||
|
||||
# Configurar logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="File Manager API",
|
||||
description=" DoRa",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
# Configurar CORS para React frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_ORIGINS, # URLs del frontend React
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Middleware para logging de requests
|
||||
@app.middleware("http")
|
||||
async def log_requests(request, call_next):
|
||||
logger.info(f"Request: {request.method} {request.url}")
|
||||
response = await call_next(request)
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request, exc):
|
||||
logger.error(f"Unhandled Exception: {str(exc)}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": True,
|
||||
"message": "Error interno del servidor",
|
||||
"status_code": 500
|
||||
}
|
||||
)
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Endpoint para verificar el estado de la API"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"message": "File Manager API está funcionando correctamente",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Endpoint raíz con información básica de la API"""
|
||||
return {
|
||||
"message": "File Manager API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
}
|
||||
|
||||
# Incluir routers
|
||||
app.include_router(
|
||||
files_router,
|
||||
prefix="/api/v1/files",
|
||||
tags=["files"]
|
||||
)
|
||||
|
||||
# Router para IA
|
||||
# app.include_router(
|
||||
# ai_router,
|
||||
# prefix="/api/v1/ai",
|
||||
# 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}")
|
||||
# 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(
|
||||
"main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
log_level="info"
|
||||
)
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
BIN
backend/app/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/file_models.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/file_models.cpython-312.pyc
Normal file
Binary file not shown.
184
backend/app/models/file_models.py
Normal file
184
backend/app/models/file_models.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
|
||||
class FileUploadRequest(BaseModel):
|
||||
"""Modelo para request de subida de archivo"""
|
||||
tema: Optional[str] = Field(None, description="Tema/carpeta donde guardar el archivo")
|
||||
|
||||
@validator("tema")
|
||||
def validate_tema(cls, v):
|
||||
if v:
|
||||
# Limpiar el tema: solo letras, números, guiones y espacios
|
||||
cleaned = "".join(c for c in v if c.isalnum() or c in "-_ ")
|
||||
return cleaned.strip().lower().replace(" ", "-")
|
||||
return v
|
||||
|
||||
|
||||
class FileInfo(BaseModel):
|
||||
"""Modelo para información de un archivo"""
|
||||
name: str = Field(..., description="Nombre del archivo")
|
||||
full_path: str = Field(..., description="Ruta completa en Azure")
|
||||
tema: Optional[str] = Field(None, description="Tema/carpeta del archivo")
|
||||
size: int = Field(..., description="Tamaño del archivo en bytes")
|
||||
last_modified: datetime = Field(..., description="Fecha de última modificación")
|
||||
content_type: Optional[str] = Field(None, description="Tipo MIME del archivo")
|
||||
url: Optional[str] = Field(None, description="URL de descarga")
|
||||
|
||||
@property
|
||||
def size_mb(self) -> float:
|
||||
"""Tamaño del archivo en MB"""
|
||||
return round(self.size / (1024 * 1024), 2)
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
"""Extensión del archivo"""
|
||||
return os.path.splitext(self.name)[1].lower()
|
||||
|
||||
class Config:
|
||||
# Permitir usar propiedades calculadas en JSON
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
|
||||
class FileListResponse(BaseModel):
|
||||
"""Modelo para respuesta de listado de archivos"""
|
||||
files: List[FileInfo] = Field(..., description="Lista de archivos")
|
||||
total: int = Field(..., description="Total de archivos")
|
||||
tema: Optional[str] = Field(None, description="Tema filtrado (si aplica)")
|
||||
|
||||
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""Modelo para respuesta de subida de archivo"""
|
||||
success: bool = Field(..., description="Indica si la subida fue exitosa")
|
||||
message: str = Field(..., description="Mensaje de respuesta")
|
||||
file: Optional[FileInfo] = Field(None, description="Información del archivo subido")
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
"""Modelo para respuesta de eliminación de archivo"""
|
||||
success: bool = Field(..., description="Indica si la eliminación fue exitosa")
|
||||
message: str = Field(..., description="Mensaje de respuesta")
|
||||
deleted_file: str = Field(..., description="Nombre del archivo eliminado")
|
||||
|
||||
|
||||
class FileBatchDeleteRequest(BaseModel):
|
||||
"""Modelo para request de eliminación múltiple"""
|
||||
files: List[str] = Field(..., description="Lista de nombres de archivos a eliminar")
|
||||
tema: Optional[str] = Field(None, description="Tema donde están los archivos")
|
||||
|
||||
@validator("files")
|
||||
def validate_files_not_empty(cls, v):
|
||||
if not v or len(v) == 0:
|
||||
raise ValueError("La lista de archivos no puede estar vacía")
|
||||
return v
|
||||
|
||||
|
||||
class FileBatchDeleteResponse(BaseModel):
|
||||
"""Modelo para respuesta de eliminación múltiple"""
|
||||
success: bool = Field(..., description="Indica si la operación fue exitosa")
|
||||
message: str = Field(..., description="Mensaje de respuesta")
|
||||
deleted_files: List[str] = Field(..., description="Archivos eliminados exitosamente")
|
||||
failed_files: List[str] = Field(default_factory=list, description="Archivos que no se pudieron eliminar")
|
||||
|
||||
|
||||
class FileBatchDownloadRequest(BaseModel):
|
||||
"""Modelo para request de descarga múltiple"""
|
||||
files: List[str] = Field(..., description="Lista de nombres de archivos a descargar")
|
||||
tema: Optional[str] = Field(None, description="Tema donde están los archivos")
|
||||
zip_name: Optional[str] = Field("archivos", description="Nombre del archivo ZIP")
|
||||
|
||||
@validator("files")
|
||||
def validate_files_not_empty(cls, v):
|
||||
if not v or len(v) == 0:
|
||||
raise ValueError("La lista de archivos no puede estar vacía")
|
||||
return v
|
||||
|
||||
@validator("zip_name")
|
||||
def validate_zip_name(cls, v):
|
||||
# Limpiar nombre del ZIP
|
||||
if v:
|
||||
cleaned = "".join(c for c in v if c.isalnum() or c in "-_")
|
||||
return cleaned or "archivos"
|
||||
return "archivos"
|
||||
|
||||
|
||||
class TemasListResponse(BaseModel):
|
||||
"""Modelo para respuesta de listado de temas"""
|
||||
temas: List[str] = Field(..., description="Lista de temas disponibles")
|
||||
total: int = Field(..., description="Total de temas")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Modelo para respuestas de error"""
|
||||
error: bool = Field(True, description="Indica que es un error")
|
||||
message: str = Field(..., description="Mensaje de error")
|
||||
status_code: int = Field(..., description="Código de estado HTTP")
|
||||
details: Optional[str] = Field(None, description="Detalles adicionales del error")
|
||||
|
||||
|
||||
class FileConflictResponse(BaseModel):
|
||||
"""Modelo para respuesta cuando hay conflicto de archivo existente"""
|
||||
conflict: bool = Field(True, description="Indica que hay conflicto")
|
||||
message: str = Field(..., description="Mensaje explicando el conflicto")
|
||||
existing_file: str = Field(..., description="Nombre del archivo que ya existe")
|
||||
suggested_name: str = Field(..., description="Nombre sugerido para evitar conflicto")
|
||||
tema: Optional[str] = Field(None, description="Tema donde está el archivo")
|
||||
|
||||
|
||||
class FileUploadCheckRequest(BaseModel):
|
||||
"""Modelo para verificar si un archivo existe antes de subir"""
|
||||
filename: str = Field(..., description="Nombre del archivo a verificar")
|
||||
tema: Optional[str] = Field(None, description="Tema donde verificar")
|
||||
|
||||
@validator("tema")
|
||||
def validate_tema(cls, v):
|
||||
if v:
|
||||
# Limpiar el tema: solo letras, números, guiones y espacios
|
||||
cleaned = "".join(c for c in v if c.isalnum() or c in "-_ ")
|
||||
return cleaned.strip().lower().replace(" ", "-")
|
||||
return v
|
||||
|
||||
|
||||
class FileUploadConfirmRequest(BaseModel):
|
||||
"""Modelo para confirmar subida de archivo con decisión del usuario"""
|
||||
filename: str = Field(..., description="Nombre del archivo original")
|
||||
tema: Optional[str] = Field(None, description="Tema donde subir")
|
||||
action: str = Field(..., description="Acción a tomar: 'overwrite', 'rename', 'cancel'")
|
||||
new_filename: Optional[str] = Field(None, description="Nuevo nombre si action es 'rename'")
|
||||
|
||||
@validator("action")
|
||||
def validate_action(cls, v):
|
||||
allowed_actions = ["overwrite", "rename", "cancel"]
|
||||
if v not in allowed_actions:
|
||||
raise ValueError(f"Acción debe ser una de: {', '.join(allowed_actions)}")
|
||||
return v
|
||||
|
||||
@validator("new_filename")
|
||||
def validate_new_filename(cls, v, values):
|
||||
if values.get("action") == "rename" and not v:
|
||||
raise ValueError("new_filename es requerido cuando action es 'rename'")
|
||||
return v
|
||||
|
||||
@validator("tema")
|
||||
def validate_tema(cls, v):
|
||||
if v:
|
||||
# Limpiar el tema: solo letras, números, guiones y espacios
|
||||
cleaned = "".join(c for c in v if c.isalnum() or c in "-_ ")
|
||||
return cleaned.strip().lower().replace(" ", "-")
|
||||
return v
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Modelo para respuesta de health check"""
|
||||
status: str = Field(..., description="Estado de la aplicación")
|
||||
message: str = Field(..., description="Mensaje descriptivo")
|
||||
timestamp: datetime = Field(default_factory=datetime.now, description="Timestamp del check")
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
BIN
backend/app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/files.cpython-312.pyc
Normal file
BIN
backend/app/routers/__pycache__/files.cpython-312.pyc
Normal file
Binary file not shown.
604
backend/app/routers/files.py
Normal file
604
backend/app/routers/files.py
Normal file
@@ -0,0 +1,604 @@
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Query, Form
|
||||
from fastapi.responses import StreamingResponse, Response
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from ..models.file_models import (
|
||||
FileUploadRequest, FileUploadResponse, FileInfo, FileListResponse,
|
||||
FileDeleteResponse, FileBatchDeleteRequest,
|
||||
FileConflictResponse, FileBatchDeleteResponse,
|
||||
FileBatchDownloadRequest, TemasListResponse,
|
||||
FileUploadCheckRequest, FileUploadConfirmRequest, ErrorResponse
|
||||
)
|
||||
from ..services.azure_service import azure_service
|
||||
from ..services.file_service import file_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/upload/check", response_model=FileConflictResponse)
|
||||
async def check_file_before_upload(request: FileUploadCheckRequest):
|
||||
"""
|
||||
Verificar si un archivo ya existe antes de subirlo
|
||||
"""
|
||||
try:
|
||||
# Validar nombre de archivo
|
||||
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
|
||||
)
|
||||
else:
|
||||
# No hay conflicto, se puede subir directamente
|
||||
return FileConflictResponse(
|
||||
conflict=False,
|
||||
message="Archivo disponible para subir",
|
||||
existing_file=request.filename,
|
||||
suggested_name=request.filename,
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/upload/confirm", response_model=FileUploadResponse)
|
||||
async def upload_file_with_confirmation(
|
||||
file: UploadFile = File(...),
|
||||
action: str = Form(...),
|
||||
tema: Optional[str] = Form(None),
|
||||
new_filename: Optional[str] = Form(None)
|
||||
):
|
||||
"""
|
||||
Subir archivo con confirmación de acción para conflictos
|
||||
"""
|
||||
try:
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Crear objeto FileInfo
|
||||
file_info = FileInfo(
|
||||
name=uploaded_file_info["name"],
|
||||
full_path=uploaded_file_info["full_path"],
|
||||
tema=uploaded_file_info["tema"],
|
||||
size=uploaded_file_info["size"],
|
||||
last_modified=uploaded_file_info["last_modified"],
|
||||
url=uploaded_file_info["url"]
|
||||
)
|
||||
|
||||
action_msg = {
|
||||
"overwrite": "sobrescrito",
|
||||
"rename": f"renombrado a '{final_filename}'"
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FileUploadResponse)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
tema: Optional[str] = Form(None)
|
||||
):
|
||||
"""
|
||||
Subir un archivo al almacenamiento
|
||||
"""
|
||||
try:
|
||||
# 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']
|
||||
|
||||
if file_extension not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
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"
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Crear objeto FileInfo
|
||||
file_info = FileInfo(
|
||||
name=uploaded_file_info["name"],
|
||||
full_path=uploaded_file_info["full_path"],
|
||||
tema=uploaded_file_info["tema"],
|
||||
size=uploaded_file_info["size"],
|
||||
last_modified=uploaded_file_info["last_modified"],
|
||||
url=uploaded_file_info["url"]
|
||||
)
|
||||
|
||||
logger.info(f"Archivo '{file.filename}' subido exitosamente al tema '{processed_tema}'")
|
||||
|
||||
return FileUploadResponse(
|
||||
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)}")
|
||||
|
||||
|
||||
@router.get("/", response_model=FileListResponse)
|
||||
async def list_files(tema: Optional[str] = Query(None, description="Filtrar por tema")):
|
||||
"""
|
||||
Listar archivos, opcionalmente filtrados por tema
|
||||
"""
|
||||
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:
|
||||
file_info = FileInfo(
|
||||
name=file_data["name"],
|
||||
full_path=file_data["full_path"],
|
||||
tema=file_data["tema"],
|
||||
size=file_data["size"],
|
||||
last_modified=file_data["last_modified"],
|
||||
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
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listando archivos: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/temas", response_model=TemasListResponse)
|
||||
async def list_temas():
|
||||
"""
|
||||
Listar todos los temas disponibles
|
||||
"""
|
||||
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)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listando temas: {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")
|
||||
):
|
||||
"""
|
||||
Descargar un archivo individual
|
||||
"""
|
||||
try:
|
||||
# Descargar archivo de Azure
|
||||
file_content = await azure_service.download_file(
|
||||
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 ""
|
||||
)
|
||||
|
||||
# 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 ""))
|
||||
|
||||
return Response(
|
||||
content=file_content,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
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)}")
|
||||
|
||||
|
||||
@router.delete("/{filename}", response_model=FileDeleteResponse)
|
||||
async def delete_file(
|
||||
filename: str,
|
||||
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 ""
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/delete-batch", response_model=FileBatchDeleteResponse)
|
||||
async def delete_batch_files(request: FileBatchDeleteRequest):
|
||||
"""
|
||||
Eliminar múltiples archivos
|
||||
"""
|
||||
try:
|
||||
deleted_files = []
|
||||
failed_files = []
|
||||
|
||||
for filename in request.files:
|
||||
try:
|
||||
await azure_service.delete_file(
|
||||
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
|
||||
)
|
||||
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/download-batch")
|
||||
async def download_batch_files(request: FileBatchDownloadRequest):
|
||||
"""
|
||||
Descargar múltiples archivos como ZIP
|
||||
"""
|
||||
try:
|
||||
# Crear ZIP en memoria
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
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 ""
|
||||
)
|
||||
|
||||
# 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}"
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creando ZIP: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tema/{tema}/download-all")
|
||||
async def download_tema_completo(tema: str):
|
||||
"""
|
||||
Descargar todos los archivos de un tema como ZIP
|
||||
"""
|
||||
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}'")
|
||||
|
||||
# Crear ZIP en memoria
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# Agregar al ZIP
|
||||
zip_file.writestr(filename, file_content)
|
||||
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}"
|
||||
}
|
||||
)
|
||||
|
||||
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)}")
|
||||
|
||||
|
||||
@router.delete("/tema/{tema}/delete-all", response_model=FileBatchDeleteResponse)
|
||||
async def delete_tema_completo(tema: str):
|
||||
"""
|
||||
Eliminar todos los archivos de un tema
|
||||
"""
|
||||
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}'")
|
||||
|
||||
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
|
||||
)
|
||||
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}")
|
||||
|
||||
success = len(failed_files) == 0
|
||||
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")
|
||||
|
||||
return FileBatchDeleteResponse(
|
||||
success=success,
|
||||
message=message,
|
||||
deleted_files=deleted_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)}")
|
||||
|
||||
|
||||
@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")
|
||||
):
|
||||
"""
|
||||
Obtener información detallada de un archivo
|
||||
"""
|
||||
try:
|
||||
# Obtener información de Azure
|
||||
file_data = await azure_service.get_file_info(
|
||||
blob_name=filename,
|
||||
tema=tema or ""
|
||||
)
|
||||
|
||||
# Convertir a objeto FileInfo
|
||||
file_info = FileInfo(
|
||||
name=file_data["name"],
|
||||
full_path=file_data["full_path"],
|
||||
tema=file_data["tema"],
|
||||
size=file_data["size"],
|
||||
last_modified=file_data["last_modified"],
|
||||
content_type=file_data.get("content_type"),
|
||||
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")
|
||||
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)}")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
BIN
backend/app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/azure_service.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/azure_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/file_service.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/file_service.cpython-312.pyc
Normal file
Binary file not shown.
266
backend/app/services/azure_service.py
Normal file
266
backend/app/services/azure_service.py
Normal file
@@ -0,0 +1,266 @@
|
||||
from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient
|
||||
from azure.core.exceptions import ResourceNotFoundError, ResourceExistsError
|
||||
from typing import List, Optional, BinaryIO
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureBlobService:
|
||||
"""
|
||||
Servicio para interactuar con Azure Blob Storage
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Inicializar el cliente de Azure Blob Storage"""
|
||||
try:
|
||||
self.blob_service_client = BlobServiceClient.from_connection_string(
|
||||
settings.AZURE_STORAGE_CONNECTION_STRING
|
||||
)
|
||||
self.container_name = settings.AZURE_CONTAINER_NAME
|
||||
logger.info(f"Cliente de Azure Blob Storage inicializado para container: {self.container_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error inicializando Azure Blob Service: {e}")
|
||||
raise e
|
||||
|
||||
async def create_container_if_not_exists(self) -> bool:
|
||||
"""
|
||||
Crear el container si no existe
|
||||
Returns: True si se creó, False si ya existía
|
||||
"""
|
||||
try:
|
||||
container_client = self.blob_service_client.get_container_client(self.container_name)
|
||||
container_client.create_container()
|
||||
logger.info(f"Container '{self.container_name}' creado exitosamente")
|
||||
return True
|
||||
except ResourceExistsError:
|
||||
logger.info(f"Container '{self.container_name}' ya existe")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error creando container: {e}")
|
||||
raise e
|
||||
|
||||
async def upload_file(self, file_data: BinaryIO, blob_name: str, tema: str = "") -> dict:
|
||||
"""
|
||||
Subir un archivo a Azure Blob Storage
|
||||
|
||||
Args:
|
||||
file_data: Datos del archivo
|
||||
blob_name: Nombre del archivo en el blob
|
||||
tema: Tema/carpeta donde guardar el archivo
|
||||
|
||||
Returns:
|
||||
dict: Información del archivo subido
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa con tema si se proporciona
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
)
|
||||
|
||||
# Subir el archivo
|
||||
blob_client.upload_blob(file_data, overwrite=True)
|
||||
|
||||
# Obtener propiedades del blob
|
||||
blob_properties = blob_client.get_blob_properties()
|
||||
|
||||
logger.info(f"Archivo '{full_blob_name}' subido exitosamente")
|
||||
|
||||
return {
|
||||
"name": blob_name,
|
||||
"full_path": full_blob_name,
|
||||
"tema": tema,
|
||||
"size": blob_properties.size,
|
||||
"last_modified": blob_properties.last_modified,
|
||||
"url": blob_client.url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error subiendo archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def download_file(self, blob_name: str, tema: str = "") -> bytes:
|
||||
"""
|
||||
Descargar un archivo de Azure Blob Storage
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
|
||||
Returns:
|
||||
bytes: Contenido del archivo
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
)
|
||||
|
||||
# Descargar el archivo
|
||||
blob_data = blob_client.download_blob()
|
||||
content = blob_data.readall()
|
||||
|
||||
logger.info(f"Archivo '{full_blob_name}' descargado exitosamente")
|
||||
return content
|
||||
|
||||
except ResourceNotFoundError:
|
||||
logger.error(f"Archivo '{full_blob_name}' no encontrado")
|
||||
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
|
||||
except Exception as e:
|
||||
logger.error(f"Error descargando archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def delete_file(self, blob_name: str, tema: str = "") -> bool:
|
||||
"""
|
||||
Eliminar un archivo de Azure Blob Storage
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
|
||||
Returns:
|
||||
bool: True si se eliminó exitosamente
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
)
|
||||
|
||||
# Eliminar el archivo
|
||||
blob_client.delete_blob()
|
||||
|
||||
logger.info(f"Archivo '{full_blob_name}' eliminado exitosamente")
|
||||
return True
|
||||
|
||||
except ResourceNotFoundError:
|
||||
logger.error(f"Archivo '{full_blob_name}' no encontrado para eliminar")
|
||||
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
|
||||
except Exception as e:
|
||||
logger.error(f"Error eliminando archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def list_files(self, tema: str = "") -> List[dict]:
|
||||
"""
|
||||
Listar archivos en el container o en un tema específico
|
||||
|
||||
Args:
|
||||
tema: Tema/carpeta específica (opcional)
|
||||
|
||||
Returns:
|
||||
List[dict]: Lista de archivos con sus propiedades
|
||||
"""
|
||||
try:
|
||||
container_client = self.blob_service_client.get_container_client(self.container_name)
|
||||
|
||||
# Filtrar por tema si se proporciona
|
||||
name_starts_with = f"{tema}/" if tema else None
|
||||
|
||||
blobs = container_client.list_blobs(name_starts_with=name_starts_with)
|
||||
|
||||
files = []
|
||||
for blob in blobs:
|
||||
# Extraer información del blob
|
||||
blob_info = {
|
||||
"name": os.path.basename(blob.name),
|
||||
"full_path": blob.name,
|
||||
"tema": os.path.dirname(blob.name) if "/" in blob.name else "",
|
||||
"size": blob.size,
|
||||
"last_modified": blob.last_modified,
|
||||
"content_type": blob.content_settings.content_type if blob.content_settings else None
|
||||
}
|
||||
files.append(blob_info)
|
||||
|
||||
logger.info(f"Listados {len(files)} archivos" + (f" en tema '{tema}'" if tema else ""))
|
||||
return files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listando archivos: {e}")
|
||||
raise e
|
||||
|
||||
async def get_file_info(self, blob_name: str, tema: str = "") -> dict:
|
||||
"""
|
||||
Obtener información de un archivo específico
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
|
||||
Returns:
|
||||
dict: Información del archivo
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
)
|
||||
|
||||
# Obtener propiedades
|
||||
properties = blob_client.get_blob_properties()
|
||||
|
||||
return {
|
||||
"name": blob_name,
|
||||
"full_path": full_blob_name,
|
||||
"tema": tema,
|
||||
"size": properties.size,
|
||||
"last_modified": properties.last_modified,
|
||||
"content_type": properties.content_settings.content_type,
|
||||
"url": blob_client.url
|
||||
}
|
||||
|
||||
except ResourceNotFoundError:
|
||||
logger.error(f"Archivo '{full_blob_name}' no encontrado")
|
||||
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo info del archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def get_download_url(self, blob_name: str, tema: str = "") -> str:
|
||||
"""
|
||||
Obtener URL de descarga directa para un archivo
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
|
||||
Returns:
|
||||
str: URL de descarga
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
)
|
||||
|
||||
return blob_client.url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
# Instancia global del servicio
|
||||
azure_service = AzureBlobService()
|
||||
243
backend/app/services/file_service.py
Normal file
243
backend/app/services/file_service.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
import logging
|
||||
|
||||
from ..services.azure_service import azure_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileService:
|
||||
"""
|
||||
Servicio para lógica de negocio de archivos
|
||||
"""
|
||||
|
||||
async def check_file_exists(self, filename: str, tema: str = "") -> bool:
|
||||
"""
|
||||
Verificar si un archivo ya existe en el almacenamiento
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo
|
||||
tema: Tema donde buscar el archivo
|
||||
|
||||
Returns:
|
||||
bool: True si el archivo existe, False si no
|
||||
"""
|
||||
try:
|
||||
await azure_service.get_file_info(filename, tema)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error verificando existencia del archivo '{filename}': {e}")
|
||||
return False
|
||||
|
||||
def generate_new_filename(self, original_filename: str, existing_files: list = None) -> str:
|
||||
"""
|
||||
Generar un nuevo nombre de archivo si ya existe uno igual
|
||||
|
||||
Args:
|
||||
original_filename: Nombre original del archivo
|
||||
existing_files: Lista de archivos existentes (opcional)
|
||||
|
||||
Returns:
|
||||
str: Nuevo nombre de archivo (ej: archivo_1.pdf)
|
||||
"""
|
||||
# Separar nombre y extensión
|
||||
name, extension = os.path.splitext(original_filename)
|
||||
|
||||
# Buscar si ya tiene un número al final (ej: archivo_1.pdf)
|
||||
match = re.search(r'(.+)_(\d+)$', name)
|
||||
if match:
|
||||
base_name = match.group(1)
|
||||
current_number = int(match.group(2))
|
||||
else:
|
||||
base_name = name
|
||||
current_number = 0
|
||||
|
||||
# Incrementar número hasta encontrar uno disponible
|
||||
counter = current_number + 1
|
||||
new_filename = f"{base_name}_{counter}{extension}"
|
||||
|
||||
# Si tenemos lista de archivos existentes, verificar contra ella
|
||||
if existing_files:
|
||||
while new_filename in existing_files:
|
||||
counter += 1
|
||||
new_filename = f"{base_name}_{counter}{extension}"
|
||||
|
||||
return new_filename
|
||||
|
||||
async def handle_file_conflict(self, filename: str, tema: str = "") -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Manejar conflicto cuando un archivo ya existe
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo
|
||||
tema: Tema donde está el archivo
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (existe_conflicto, nombre_sugerido)
|
||||
"""
|
||||
exists = await self.check_file_exists(filename, tema)
|
||||
|
||||
if not exists:
|
||||
return False, None
|
||||
|
||||
# Generar nombre alternativo
|
||||
suggested_name = self.generate_new_filename(filename)
|
||||
|
||||
# Verificar que el nombre sugerido tampoco exista
|
||||
counter = 1
|
||||
while await self.check_file_exists(suggested_name, tema):
|
||||
name, extension = os.path.splitext(filename)
|
||||
# Buscar base sin número
|
||||
match = re.search(r'(.+)_(\d+)$', name)
|
||||
base_name = match.group(1) if match else name
|
||||
counter += 1
|
||||
suggested_name = f"{base_name}_{counter}{extension}"
|
||||
|
||||
return True, suggested_name
|
||||
|
||||
def validate_filename(self, filename: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validar que el nombre del archivo sea válido
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo a validar
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (es_valido, mensaje_error)
|
||||
"""
|
||||
if not filename:
|
||||
return False, "Nombre de archivo requerido"
|
||||
|
||||
# Verificar caracteres no permitidos
|
||||
invalid_chars = r'[<>:"/\\|?*]'
|
||||
if re.search(invalid_chars, filename):
|
||||
return False, "El nombre contiene caracteres no permitidos: < > : \" / \\ | ? *"
|
||||
|
||||
# Verificar longitud
|
||||
if len(filename) > 255:
|
||||
return False, "Nombre de archivo demasiado largo (máximo 255 caracteres)"
|
||||
|
||||
# Verificar nombres reservados (Windows)
|
||||
reserved_names = [
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
||||
]
|
||||
name_without_ext = os.path.splitext(filename)[0].upper()
|
||||
if name_without_ext in reserved_names:
|
||||
return False, f"'{name_without_ext}' es un nombre reservado del sistema"
|
||||
|
||||
return True, None
|
||||
|
||||
def validate_file_extension(self, filename: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validar que la extensión del archivo esté permitida
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (es_valido, mensaje_error)
|
||||
"""
|
||||
allowed_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv']
|
||||
|
||||
file_extension = os.path.splitext(filename)[1].lower()
|
||||
|
||||
if not file_extension:
|
||||
return False, "Archivo debe tener una extensión"
|
||||
|
||||
if file_extension not in allowed_extensions:
|
||||
return False, f"Extensión no permitida. Permitidas: {', '.join(allowed_extensions)}"
|
||||
|
||||
return True, None
|
||||
|
||||
def validate_file_size(self, file_size: int) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validar que el tamaño del archivo esté dentro de los límites
|
||||
|
||||
Args:
|
||||
file_size: Tamaño del archivo en bytes
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (es_valido, mensaje_error)
|
||||
"""
|
||||
max_size = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
if file_size <= 0:
|
||||
return False, "El archivo está vacío"
|
||||
|
||||
if file_size > max_size:
|
||||
max_size_mb = max_size / (1024 * 1024)
|
||||
return False, f"Archivo demasiado grande. Tamaño máximo: {max_size_mb}MB"
|
||||
|
||||
return True, None
|
||||
|
||||
def validate_tema(self, tema: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validar que el tema sea válido
|
||||
|
||||
Args:
|
||||
tema: Nombre del tema
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (es_valido, mensaje_error)
|
||||
"""
|
||||
if not tema:
|
||||
return True, None # Tema opcional
|
||||
|
||||
# Verificar caracteres permitidos
|
||||
if not re.match(r'^[a-zA-Z0-9\-_\s]+$', tema):
|
||||
return False, "Tema solo puede contener letras, números, guiones y espacios"
|
||||
|
||||
# Verificar longitud
|
||||
if len(tema) > 50:
|
||||
return False, "Nombre de tema demasiado largo (máximo 50 caracteres)"
|
||||
|
||||
return True, None
|
||||
|
||||
def clean_tema_name(self, tema: str) -> str:
|
||||
"""
|
||||
Limpiar nombre de tema para Azure Storage
|
||||
|
||||
Args:
|
||||
tema: Nombre del tema original
|
||||
|
||||
Returns:
|
||||
str: Nombre limpio para usar como carpeta
|
||||
"""
|
||||
if not tema:
|
||||
return ""
|
||||
|
||||
# Convertir a minúsculas y reemplazar espacios con guiones
|
||||
cleaned = tema.lower().strip()
|
||||
cleaned = re.sub(r'\s+', '-', cleaned) # Espacios múltiples a un guion
|
||||
cleaned = re.sub(r'[^a-z0-9\-_]', '', cleaned) # Solo caracteres permitidos
|
||||
cleaned = re.sub(r'-+', '-', cleaned) # Guiones múltiples a uno
|
||||
cleaned = cleaned.strip('-') # Quitar guiones al inicio/final
|
||||
|
||||
return cleaned
|
||||
|
||||
async def get_existing_files_in_tema(self, tema: str = "") -> list:
|
||||
"""
|
||||
Obtener lista de nombres de archivos existentes en un tema
|
||||
|
||||
Args:
|
||||
tema: Tema donde buscar archivos
|
||||
|
||||
Returns:
|
||||
list: Lista de nombres de archivos
|
||||
"""
|
||||
try:
|
||||
files_data = await azure_service.list_files(tema)
|
||||
return [file_data["name"] for file_data in files_data]
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo archivos del tema '{tema}': {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Instancia global del servicio
|
||||
file_service = FileService()
|
||||
Reference in New Issue
Block a user