628 lines
21 KiB
Python
628 lines
21 KiB
Python
# 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() |