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