V1 de backend funcional

This commit is contained in:
Sebastian
2025-09-08 21:46:10 +00:00
commit 48f53280be
58 changed files with 7646 additions and 0 deletions

View File

View 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()

View 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()