feature/implementacion de gestor de informacion y archivos minIO

This commit is contained in:
Dulce
2026-04-22 11:10:05 -06:00
parent 69d07f2713
commit 39504e196c
23 changed files with 2272 additions and 391 deletions

143
api/utils/minio_client.py Normal file
View File

@@ -0,0 +1,143 @@
# backend/utils/minio_client.py
from datetime import timedelta
import os
from minio import Minio
from minio.error import S3Error
from django.conf import settings
from typing import Optional, BinaryIO
import logging
logger = logging.getLogger(__name__)
class MinIOClient:
"""Cliente singleton para MinIO con operaciones avanzadas"""
_instance = None
_client = None
_bucket_name = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._client is None and settings.STORAGE_BACKEND == 'minio':
self._initialize_client()
def _initialize_client(self):
"""Inicializa el cliente de MinIO"""
try:
endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
access_key = os.getenv('MINIO_ACCESS_KEY')
secret_key = os.getenv('MINIO_SECRET_KEY')
secure = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
self._client = Minio(
endpoint=endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure
)
self._bucket_name = os.environ.get('MINIO_BUCKET_NAME', 'efc-backend-dev')
# Asegurar que el bucket existe
if not self._client.bucket_exists(self._bucket_name):
self._client.make_bucket(self._bucket_name)
except Exception as e:
raise
def upload_file(
self,
object_name: str,
file_path: str = None,
file_data: BinaryIO = None,
content_type: str = None,
metadata: dict = None
) -> bool:
"""
Sube un archivo a MinIO
Args:
object_name: Ruta del objeto en el bucket (ej: 'documents/archivo.xml')
file_path: Ruta local del archivo (opcional)
file_data: Datos del archivo en memoria (opcional)
content_type: MIME type del archivo
metadata: Metadatos adicionales
Returns:
bool: True si se subió correctamente
"""
try:
if file_path:
self._client.fput_object(
bucket_name=self._bucket_name,
object_name=object_name,
file_path=file_path,
content_type=content_type,
metadata=metadata
)
elif file_data:
self._client.put_object(
bucket_name=self._bucket_name,
object_name=object_name,
data=file_data,
length=-1,
part_size=10*1024*1024, # 10MB
content_type=content_type,
metadata=metadata
)
else:
raise ValueError("You must provide file_path or file_data")
return True
except S3Error as e:
return False
def get_file_url(self, object_name: str, expires: int = 3600) -> Optional[str]:
"""Genera una URL firmada para acceder al archivo"""
try:
url = self._client.presigned_get_object(
bucket_name=self._bucket_name,
object_name=object_name,
expires=timedelta(seconds=expires)
)
# Reemplazar endpoint interno por público si está configurado
public_endpoint = os.getenv('MINIO_PUBLIC_ENDPOINT')
if public_endpoint and url:
internal_endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
url = url.replace(internal_endpoint, public_endpoint)
return url
except S3Error as e:
return None
def delete_file(self, object_name: str) -> bool:
"""Elimina un archivo del bucket"""
try:
self._client.remove_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error as e:
return False
def file_exists(self, object_name: str) -> bool:
"""Verifica si un archivo existe en el bucket"""
try:
self._client.stat_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error:
return False
# Singleton para uso global
minio_client = MinIOClient()

View File

@@ -0,0 +1,628 @@
# backend/utils/storage_service.py
import os
import logging
import mimetypes
import shutil
from uuid import uuid4
from typing import Optional, Union, Literal
from pathlib import Path
from enum import Enum
from django.core.files.uploadedfile import UploadedFile
from django.conf import settings
from .minio_client import minio_client
logger = logging.getLogger(__name__)
class StorageCategory(str, Enum):
"""Categorías de almacenamiento disponibles"""
DOCUMENTS = "documents"
DATASTAGE = "datastage"
REPORTS = "reports"
VUCEM_CERTS = "vucem_certs"
VUCEM_KEYS = "vucem_keys"
class StorageService:
"""
Servicio para gestionar el almacenamiento de archivos.
Estructura aislada por organización:
org_{id}/
├── documents/{pedimento_app o unknown}/
├── datastage/
├── reports/
├── vucem_certs/
└── vucem_keys/
"""
def __init__(self):
self.client = minio_client
self.storage_backend = getattr(settings, 'STORAGE_BACKEND', 'local')
self.local_media_root = getattr(settings, 'MEDIA_ROOT', 'media')
self.debug = getattr(settings, 'DEBUG', False)
def _generate_filename(self, original_filename: str) -> str:
"""Genera un nombre de archivo único para evitar colisiones"""
name, ext = os.path.splitext(original_filename)
unique_id = str(uuid4())[:8]
return f"{name}_{unique_id}{ext}"
def _get_content_type(self, filename: str) -> Optional[str]:
"""Determina el content-type basado en la extensión del archivo"""
content_type, _ = mimetypes.guess_type(filename)
return content_type
def _sanitize_folder_name(self, name: str) -> str:
"""
Sanitizar nombres de carpetas reemplazando caracteres problematicos.
Los guiones (-) son validos.
"""
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
name = name.replace(char, '_')
return name
def _build_base_path(self, organizacion_id: Union[int, str]) -> str:
"""Construye la ruta base para una organización"""
return f"org_{organizacion_id}"
def _build_document_path(
self,
organizacion_id: Union[int, str],
filename: str,
pedimento_app: Optional[str] = None
) -> str:
"""
Construye ruta para DOCUMENTS:
org_{id}/documents/{pedimento_app o unknown}/archivo
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
return f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
def _build_generic_path(
self,
organizacion_id: Union[int, str],
filename: str,
category: StorageCategory,
subfolder: Optional[str] = None
) -> str:
"""
Construye ruta para categorías genéricas:
org_{id}/{category}/{subfolder}/{archivo}
o
org_{id}/{category}/{archivo}
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if subfolder:
safe_subfolder = self._sanitize_folder_name(subfolder)
return f"{base}/{category.value}/{safe_subfolder}/{safe_filename}"
else:
return f"{base}/{category.value}/{safe_filename}"
def _save_file(
self,
file: UploadedFile,
object_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""Guarda el archivo según el backend configurado"""
meta = metadata or {}
meta['original_filename'] = file.name
content_type = self._get_content_type(file.name)
if self.storage_backend == 'minio':
return self._save_to_minio(file, object_path, content_type, meta)
else:
return self._save_to_local(file, object_path)
def _save_to_minio(
self,
file: UploadedFile,
object_path: str,
content_type: Optional[str],
metadata: dict
) -> Optional[str]:
"""Guarda archivo en MinIO"""
try:
file.seek(0)
success = self.client.upload_file(
object_name=object_path,
file_data=file,
content_type=content_type,
metadata=metadata
)
if success:
return object_path
else:
return None
except Exception as e:
return None
def _save_to_local(self, file: UploadedFile, object_path: str) -> Optional[str]:
"""Guarda archivo en sistema local"""
try:
full_path = Path(self.local_media_root) / object_path
full_path.parent.mkdir(parents=True, exist_ok=True)
with open(full_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
return object_path
except Exception as e:
return None
def save_document(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento en la categoría 'documents'.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización (obligatorio)
pedimento_app: Identificador del pedimento (opcional, ej: '24-23-1653-4003611')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_document(file, 123, '24-23-1653-4003611')
'org_123/documents/24-23-1653-4003611/documento_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
object_path = self._build_document_path(organizacion_id, file.name, pedimento_app)
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown'
})
return self._save_file(file, object_path, meta)
def save_document_from_path(
self,
file_path: str,
file_name: str,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento desde una ruta de archivo en disco.
Útil para archivos temporales ya extraídos.
Args:
file_path: Ruta completa del archivo en disco
file_name: Nombre del archivo
organizacion_id: ID de la organización
pedimento_app: Identificador del pedimento (opcional)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
"""
if not file_path or not os.path.exists(file_path):
return None
if not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file_name)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
object_path = f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
# Metadatos
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown',
'original_filename': file_name
})
content_type = self._get_content_type(file_name)
# Guardar según backend
if self.storage_backend == 'minio':
try:
self.client._client.fput_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=file_path,
content_type=content_type,
metadata=meta
)
return object_path
except Exception as e:
return None
else:
try:
dest_path = Path(self.local_media_root) / object_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file_path, dest_path)
return object_path
except Exception as e:
return None
def save_datastage(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en la categoría 'datastage' (.zip, .jar, .rar, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de datastage
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_datastage(file, 123)
'org_123/datastage/proceso_a1b2c3d4.zip'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.DATASTAGE, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.DATASTAGE.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_report(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un reporte en la categoría 'reports' (.pdf, .xlsx, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de reports (ej: 'mensuales', '2025')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_report(file, 123, '2025/enero')
'org_123/reports/2025/enero/reporte_x1y2z3w4.pdf'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.REPORTS, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.REPORTS.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_vucem_cert(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un certificado VUCEM en la categoría 'vucem_certs'.
Args:
file: Archivo de certificado
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_cert(file, 123)
'org_123/vucem_certs/certificado_a1b2c3d4.cer'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_CERTS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_CERTS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_vucem_key(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda una llave VUCEM en la categoría 'vucem_keys'.
Args:
file: Archivo de llave
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_key(file, 123)
'org_123/vucem_keys/llave_a1b2c3d4.key'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_KEYS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_KEYS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_custom(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
custom_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en una ruta personalizada dentro de la organización.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
custom_path: Ruta personalizada (se antepone org_{id}/)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_custom(file, 123, 'temp/procesando/archivo.xml')
'org_123/temp/procesando/archivo_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file.name)
# Combinar custom_path con el nombre del archivo
if custom_path.endswith('/'):
object_path = f"{base}/{custom_path}{safe_filename}"
else:
object_path = f"{base}/{custom_path}/{safe_filename}"
meta = metadata or {}
meta.update({
'organizacion_id': str(organizacion_id),
'custom_path': custom_path
})
return self._save_file(file, object_path, meta)
def get_file_url(self, object_path: str, expires: int = 3600) -> Optional[str]:
"""
Obtiene una URL para acceder al documento.
En desarrollo, reemplaza 'minio' por 'localhost' para acceso desde el navegador.
"""
if not object_path:
return None
if self.storage_backend == 'minio':
url = self.client.get_file_url(object_path, expires)
# En desarrollo, reemplazar 'minio:9000' por 'localhost:9000'
if url and self.debug:
url = url.replace('minio:9000', 'localhost:9000')
return url
else:
return f"{settings.MEDIA_URL}{object_path}"
def delete_file(self, object_path: str) -> bool:
"""Elimina un archivo"""
if self.storage_backend == 'minio':
return self.client.delete_file(object_path)
else:
try:
full_path = Path(self.local_media_root) / object_path
if full_path.exists():
full_path.unlink()
return True
return False
except Exception as e:
return False
def file_exists(self, object_path: str) -> bool:
"""Verifica si un archivo existe (MinIO o local)"""
if not object_path:
return False
# Si la ruta empieza con 'org_', es MinIO
if object_path.startswith('org_'):
if self.storage_backend == 'minio':
return self.client.file_exists(object_path)
else:
return (Path(self.local_media_root) / object_path).exists()
else:
# Ruta local antigua (ej: 'documents/archivo.xml')
# Siempre verificar en MEDIA_ROOT
return (Path(self.local_media_root) / object_path).exists()
def download_file(self, object_path: str, destination_path: str) -> bool:
"""
Descarga un archivo de MinIO al sistema de archivos local.
"""
if not object_path:
return False
if self.storage_backend == 'minio':
try:
self.client._client.fget_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=destination_path
)
return True
except Exception as e:
return False
else:
import shutil
src = Path(self.local_media_root) / object_path
if src.exists():
shutil.copy(src, destination_path)
return True
return False
def is_minio_path(self, path):
if not path:
return False
return path.startswith('org_')
# =============================================================================================================
# POR AHORA NO FUERON SOLICITADOS PERO POR EL PROBLEMA DEL 15/04/2026, CONSIDERO PRUDENTE PODER TENER ESTOS
# DOS METODOS PARA NO COMPLICARNOS EN UN FUTURO, EN CASO DE SER NECESARIOS
# =============================================================================================================
# def delete_organization_folder(self, organizacion_id: Union[int, str]) -> bool:
# """
# Elimina TODOS los archivos de una organización.
# Útil cuando un cliente se va y necesitas borrar sus datos.
# Esta operación es IRREVERSIBLE.
# """
# prefix = f"org_{organizacion_id}/"
# if self.storage_backend == 'minio':
# try:
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# self.client.delete_file(obj.object_name)
# return True
# except Exception as e:
# return False
# else:
# try:
# import shutil
# full_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if full_path.exists():
# shutil.rmtree(full_path)
# return True
# except Exception as e:
# return False
# def export_organization_files(
# self,
# organizacion_id: Union[int, str],
# output_zip_path: str
# ) -> bool:
# """
# Exporta TODOS los archivos de una organización a un ZIP.
# Útil para entregar datos a un cliente que se va.
# Args:
# organizacion_id: ID de la organización
# output_zip_path: Ruta donde guardar el ZIP
# Returns: bool
# """
# import zipfile
# from io import BytesIO
# prefix = f"org_{organizacion_id}/"
# try:
# with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# if self.storage_backend == 'minio':
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# response = self.client._client.get_object(self.client._bucket_name,obj.object_name)
# data = response.read()
# zip_path = obj.object_name.replace(prefix, '', 1)
# zipf.writestr(zip_path, data)
# response.close()
# else:
# local_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if local_path.exists():
# for file_path in local_path.rglob('*'):
# if file_path.is_file():
# zip_path = str(file_path.relative_to(local_path))
# zipf.write(file_path, zip_path)
# return True
# except Exception as e:
# return False
# Singleton para uso global
storage_service = StorageService()