from django.core.files.storage import FileSystemStorage from infrasynth.shared.settings_utils import get_setting class _LocalStorage: def __init__(self, config: dict): self._storage = FileSystemStorage() def save(self, name: str, content) -> str: return self._storage.save(name, content) def open(self, name: str, mode: str = "rb"): return self._storage.open(name, mode) def exists(self, name: str) -> bool: return self._storage.exists(name) def url(self, name: str) -> str: return self._storage.url(name) def delete(self, name: str) -> None: self._storage.delete(name) def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: return self._storage.url(name) class _S3Storage: def __init__(self, config: dict): from storages.backends.s3boto3 import S3Boto3Storage options = { "access_key": config.get("ACCESS_KEY"), "secret_key": config.get("SECRET_KEY"), "bucket_name": config.get("BUCKET_NAME"), "region_name": config.get("REGION", "us-east-1"), "endpoint_url": config.get("ENDPOINT_URL"), "querystring_auth": True, "default_acl": None, "file_overwrite": False, } options = {k: v for k, v in options.items() if v is not None} self._storage = S3Boto3Storage(**options) def save(self, name: str, content) -> str: return self._storage.save(name, content) def open(self, name: str, mode: str = "rb"): return self._storage.open(name, mode) def exists(self, name: str) -> bool: return self._storage.exists(name) def url(self, name: str) -> str: return self._storage.url(name) def delete(self, name: str) -> None: self._storage.delete(name) def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: return self._storage.url(name, expire=expiry_seconds) class _GCSStorage: def __init__(self, config: dict): from storages.backends.gcloud import GoogleCloudStorage self._storage = GoogleCloudStorage( bucket_name=config.get("BUCKET_NAME"), project_id=config.get("PROJECT_ID"), credentials=config.get("CREDENTIALS_PATH"), ) def save(self, name: str, content) -> str: return self._storage.save(name, content) def open(self, name: str, mode: str = "rb"): return self._storage.open(name, mode) def exists(self, name: str) -> bool: return self._storage.exists(name) def url(self, name: str) -> str: return self._storage.url(name) def delete(self, name: str) -> None: self._storage.delete(name) def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: return self._storage.url(name, expire=expiry_seconds) class _CloudinaryStorage: def __init__(self, config: dict): import cloudinary from cloudinary.uploader import upload cloudinary.config( cloud_name=config.get("CLOUD_NAME"), api_key=config.get("API_KEY"), api_secret=config.get("API_SECRET"), ) self._upload = upload def save(self, name: str, content) -> str: result = self._upload(content.read(), public_id=name, overwrite=False) return result.get("public_id", name) def open(self, name: str, mode: str = "rb"): raise NotImplementedError("Cloudinary storage does not support direct file open.") def exists(self, name: str) -> bool: import cloudinary.api try: cloudinary.api.resource(name) return True except Exception: return False def url(self, name: str) -> str: import cloudinary.utils return cloudinary.utils.cloudinary_url(name)[0] def delete(self, name: str) -> None: from cloudinary.uploader import destroy destroy(name) def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: import cloudinary.utils return cloudinary.utils.cloudinary_url(name, sign_url=True, expires_at=expiry_seconds)[0] _BUILTIN_BACKENDS: dict[str, type] = { "local": _LocalStorage, "S3": _S3Storage, "s3": _S3Storage, "gcs": _GCSStorage, "cloudinary": _CloudinaryStorage, } # Apps register their own backends at startup (``apps.py:ready()``) so a custom # storage backend never requires editing the kit. _CUSTOM_BACKENDS: dict[str, object] = {} def register_storage_backend(name: str, factory: object) -> None: """Registers a storage backend factory/class under ``name``. ``factory`` is called with the backend's config dict and must implement the storage protocol (``save/open/exists/url/delete/generate_signed_url``). """ _CUSTOM_BACKENDS[str(name)] = factory def get_storage_backend(backend_name: str | None = None): """Returns a storage backend instance by name (defaults to settings config). Resolution order: ``STORAGE_BACKENDS[]["CLASS"]`` (dotted path) → a backend registered via :func:`register_storage_backend` → the built-in backends. An unknown name raises rather than silently falling back to local. """ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string config = getattr(settings, "INFRASYNTH_FILES", {}) if not backend_name: backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local") backend_name = str(backend_name) backend_config = (config.get("STORAGE_BACKENDS", {}) or {}).get(backend_name, {}) or {} class_path = (backend_config or {}).get("CLASS") if class_path: backend_class = import_string(class_path) elif backend_name in _CUSTOM_BACKENDS: backend_class = _CUSTOM_BACKENDS[backend_name] else: backend_class = _BUILTIN_BACKENDS.get(backend_name) if backend_class is None: raise ImproperlyConfigured( f"Unknown storage backend '{backend_name}'. Register it with " "infrasynth.files.storage.register_storage_backend or set " f'INFRASYNTH_FILES["STORAGE_BACKENDS"]["{backend_name}"]["CLASS"].' ) return backend_class(backend_config) def save_file(file_obj, storage_key: str, backend: str = "local"): """Saves a file to the given storage backend, returns the stored key.""" storage = get_storage_backend(backend) return storage.save(storage_key, file_obj)