improve colors
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient, generate_blob_sas, BlobSasPermissions
|
||||
from azure.core.exceptions import ResourceNotFoundError, ResourceExistsError
|
||||
from typing import List, Optional, BinaryIO
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import BinaryIO, List, Optional
|
||||
|
||||
from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError
|
||||
from azure.storage.blob import (
|
||||
BlobClient,
|
||||
BlobSasPermissions,
|
||||
BlobServiceClient,
|
||||
ContainerClient,
|
||||
generate_blob_sas,
|
||||
)
|
||||
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -13,7 +21,7 @@ class AzureBlobService:
|
||||
"""
|
||||
Servicio para interactuar con Azure Blob Storage
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Inicializar el cliente de Azure Blob Storage"""
|
||||
try:
|
||||
@@ -21,7 +29,9 @@ class AzureBlobService:
|
||||
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}")
|
||||
logger.info(
|
||||
f"Cliente de Azure Blob Storage inicializado para container: {self.container_name}"
|
||||
)
|
||||
|
||||
# Configurar CORS automáticamente al inicializar
|
||||
self._configure_cors()
|
||||
@@ -45,7 +55,7 @@ class AzureBlobService:
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"],
|
||||
allowed_headers=["*"],
|
||||
exposed_headers=["*"],
|
||||
max_age_in_seconds=3600
|
||||
max_age_in_seconds=3600,
|
||||
)
|
||||
|
||||
# Aplicar la configuración CORS
|
||||
@@ -55,15 +65,19 @@ class AzureBlobService:
|
||||
except Exception as e:
|
||||
# No fallar si CORS no se puede configurar (puede que ya esté configurado)
|
||||
logger.warning(f"No se pudo configurar CORS automáticamente: {e}")
|
||||
logger.warning("Asegúrate de configurar CORS manualmente en Azure Portal si es necesario")
|
||||
|
||||
logger.warning(
|
||||
"Asegúrate de configurar CORS manualmente en Azure Portal si es necesario"
|
||||
)
|
||||
|
||||
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 = 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
|
||||
@@ -73,217 +87,249 @@ class AzureBlobService:
|
||||
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:
|
||||
|
||||
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
|
||||
|
||||
tema: Tema/carpeta donde guardar el archivo (se normaliza a lowercase)
|
||||
|
||||
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
|
||||
|
||||
# Normalizar tema a lowercase para consistencia
|
||||
tema_normalized = tema.lower() if tema else ""
|
||||
|
||||
# Construir la ruta completa con tema normalizado
|
||||
full_blob_name = (
|
||||
f"{tema_normalized}/{blob_name}" if tema_normalized else blob_name
|
||||
)
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
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,
|
||||
"tema": tema_normalized,
|
||||
"size": blob_properties.size,
|
||||
"last_modified": blob_properties.last_modified,
|
||||
"url": blob_client.url
|
||||
"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
|
||||
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
bytes: Contenido del archivo
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
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
|
||||
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
bool: True si se eliminó exitosamente
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
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)
|
||||
|
||||
tema: Tema/carpeta específica (opcional) - filtrado case-insensitive
|
||||
|
||||
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)
|
||||
|
||||
container_client = self.blob_service_client.get_container_client(
|
||||
self.container_name
|
||||
)
|
||||
|
||||
# Obtener todos los blobs para hacer filtrado case-insensitive
|
||||
blobs = container_client.list_blobs()
|
||||
|
||||
files = []
|
||||
tema_lower = tema.lower() if tema else ""
|
||||
|
||||
for blob in blobs:
|
||||
# Extraer información del blob
|
||||
blob_tema = os.path.dirname(blob.name) if "/" in blob.name else ""
|
||||
|
||||
# Filtrar por tema de manera case-insensitive si se proporciona
|
||||
if tema and blob_tema.lower() != tema_lower:
|
||||
continue
|
||||
|
||||
blob_info = {
|
||||
"name": os.path.basename(blob.name),
|
||||
"full_path": blob.name,
|
||||
"tema": os.path.dirname(blob.name) if "/" in blob.name else "",
|
||||
"tema": blob_tema,
|
||||
"size": blob.size,
|
||||
"last_modified": blob.last_modified,
|
||||
"content_type": blob.content_settings.content_type if blob.content_settings else None
|
||||
"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 ""))
|
||||
|
||||
logger.info(
|
||||
f"Listados {len(files)} archivos"
|
||||
+ (f" en tema '{tema}' (case-insensitive)" 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
|
||||
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
dict: Información del archivo
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
# Extraer el tema real del path encontrado
|
||||
real_tema = (
|
||||
os.path.dirname(full_blob_name) if "/" in full_blob_name else ""
|
||||
)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
real_tema = ""
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
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,
|
||||
"tema": real_tema,
|
||||
"size": properties.size,
|
||||
"last_modified": properties.last_modified,
|
||||
"content_type": properties.content_settings.content_type,
|
||||
"url": blob_client.url
|
||||
"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
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
str: URL de descarga
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
return blob_client.url
|
||||
@@ -292,7 +338,9 @@ class AzureBlobService:
|
||||
logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def generate_sas_url(self, blob_name: str, tema: str = "", expiry_hours: int = 1) -> str:
|
||||
async def generate_sas_url(
|
||||
self, blob_name: str, tema: str = "", expiry_hours: int = 1
|
||||
) -> str:
|
||||
"""
|
||||
Generar una URL SAS (Shared Access Signature) temporal para acceder a un archivo
|
||||
|
||||
@@ -301,7 +349,7 @@ class AzureBlobService:
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
expiry_hours: Horas de validez de la URL (por defecto 1 hora)
|
||||
|
||||
Returns:
|
||||
@@ -310,13 +358,15 @@ class AzureBlobService:
|
||||
try:
|
||||
from azure.storage.blob import ContentSettings
|
||||
|
||||
# Construir la ruta completa del blob
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
# Verificar que el archivo existe antes de generar el SAS
|
||||
@@ -327,11 +377,13 @@ class AzureBlobService:
|
||||
# Esto hace que el navegador muestre el PDF en lugar de descargarlo
|
||||
try:
|
||||
content_settings = ContentSettings(
|
||||
content_type='application/pdf',
|
||||
content_disposition='inline' # Clave para mostrar en navegador
|
||||
content_type="application/pdf",
|
||||
content_disposition="inline", # Clave para mostrar en navegador
|
||||
)
|
||||
blob_client.set_http_headers(content_settings=content_settings)
|
||||
logger.info(f"Headers configurados para visualización inline de '{full_blob_name}'")
|
||||
logger.info(
|
||||
f"Headers configurados para visualización inline de '{full_blob_name}'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"No se pudieron configurar headers inline: {e}")
|
||||
|
||||
@@ -342,9 +394,9 @@ class AzureBlobService:
|
||||
# Extraer la account key del connection string para generar el SAS
|
||||
# El SAS necesita la account key para firmar el token
|
||||
account_key = None
|
||||
for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(';'):
|
||||
if part.startswith('AccountKey='):
|
||||
account_key = part.split('=', 1)[1]
|
||||
for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(";"):
|
||||
if part.startswith("AccountKey="):
|
||||
account_key = part.split("=", 1)[1]
|
||||
break
|
||||
|
||||
if not account_key:
|
||||
@@ -358,13 +410,15 @@ class AzureBlobService:
|
||||
account_key=account_key,
|
||||
permission=BlobSasPermissions(read=True), # Solo permisos de lectura
|
||||
expiry=expiry_time,
|
||||
start=start_time
|
||||
start=start_time,
|
||||
)
|
||||
|
||||
# Construir la URL completa con el SAS token
|
||||
sas_url = f"{blob_client.url}?{sas_token}"
|
||||
|
||||
logger.info(f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)")
|
||||
logger.info(
|
||||
f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)"
|
||||
)
|
||||
return sas_url
|
||||
|
||||
except FileNotFoundError:
|
||||
@@ -374,6 +428,47 @@ class AzureBlobService:
|
||||
logger.error(f"Error generando SAS URL para '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def _find_blob_case_insensitive(self, blob_name: str, tema: str) -> str:
|
||||
"""
|
||||
Buscar un blob de manera case-insensitive
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo a buscar
|
||||
tema: Tema donde buscar (case-insensitive)
|
||||
|
||||
Returns:
|
||||
str: Ruta completa del blob encontrado
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Si no se encuentra el archivo
|
||||
"""
|
||||
try:
|
||||
container_client = self.blob_service_client.get_container_client(
|
||||
self.container_name
|
||||
)
|
||||
blobs = container_client.list_blobs()
|
||||
|
||||
tema_lower = tema.lower()
|
||||
blob_name_lower = blob_name.lower()
|
||||
|
||||
for blob in blobs:
|
||||
blob_tema = os.path.dirname(blob.name) if "/" in blob.name else ""
|
||||
current_blob_name = os.path.basename(blob.name)
|
||||
|
||||
if (
|
||||
blob_tema.lower() == tema_lower
|
||||
and current_blob_name.lower() == blob_name_lower
|
||||
):
|
||||
return blob.name
|
||||
|
||||
# Si no se encuentra, usar la construcción original para que falle apropiadamente
|
||||
return f"{tema}/{blob_name}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error buscando blob case-insensitive: {e}")
|
||||
# Fallback a construcción original
|
||||
return f"{tema}/{blob_name}"
|
||||
|
||||
|
||||
# Instancia global del servicio
|
||||
azure_service = AzureBlobService()
|
||||
azure_service = AzureBlobService()
|
||||
|
||||
Reference in New Issue
Block a user