V1 de backend funcional
This commit is contained in:
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