diff --git a/edx_django_utils/storage/__init__.py b/edx_django_utils/storage/__init__.py new file mode 100644 index 00000000..c4a3e516 --- /dev/null +++ b/edx_django_utils/storage/__init__.py @@ -0,0 +1,7 @@ +""" +Storage utilities for edx_django_utils. + +This module exposes helper functions for working with Django storage backends. +""" + +from .utils import get_named_storage diff --git a/edx_django_utils/storage/test_utils.py b/edx_django_utils/storage/test_utils.py new file mode 100644 index 00000000..ecda4a94 --- /dev/null +++ b/edx_django_utils/storage/test_utils.py @@ -0,0 +1,106 @@ +""" +Unit tests for storage utilities in edx_django_utils. +""" + +import ddt +from django.conf import settings +from django.core.files.storage import default_storage +from django.test import TestCase +from django.test.utils import override_settings + +from edx_django_utils.storage.utils import get_named_storage + + +@ddt.ddt +class TestGetNamedStorage(TestCase): + """ + Tests for the get_named_storage utility. + """ + + @ddt.data( + ( + 'avatars', + 'AVATAR_BACKEND', + ( + {'avatars': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + 'OPTIONS': {'location': '/tmp/avatars'} + }}, + None # No legacy config + ), + '/tmp/avatars' + ), + ( + 'avatars', + 'AVATAR_BACKEND', + ( + {}, # Empty STORAGES dict + { + 'class': 'django.core.files.storage.FileSystemStorage', + 'options': {'location': '/tmp/legacy_avatars'} + } + ), + '/tmp/legacy_avatars' + ), + ( + 'certificates', + 'CERTIFICATE_BACKEND', + ( + {'certificates': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + 'OPTIONS': {'location': '/tmp/certificates'} + }}, + None + ), + '/tmp/certificates' + ), + ( + 'certificates', + 'CERTIFICATE_BACKEND', + ( + {}, # Empty STORAGES dict + { + 'class': 'django.core.files.storage.FileSystemStorage', + 'options': {'location': '/tmp/legacy_certificates'} + } + ), + '/tmp/legacy_certificates' + ), + ) + @ddt.unpack + def test_get_named_storage(self, storage_name, legacy_setting_name, config, expected_location): + """ + Test get_named_storage with both STORAGES dict and legacy config. + """ + storages_config, legacy_config = config + + with override_settings(STORAGES=storages_config or {}): + if legacy_config: + setattr(settings, legacy_setting_name, legacy_config) + elif hasattr(settings, legacy_setting_name): + delattr(settings, legacy_setting_name) + + storage = get_named_storage(storage_name, legacy_setting_name=legacy_setting_name) + self.assertEqual(storage.location, expected_location) + + def test_fallback_to_default_storage(self): + """ + Test fallback to default_storage when neither STORAGES dict nor legacy config is defined. + """ + with override_settings(STORAGES={ + 'default': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + 'OPTIONS': {'location': '/tmp/default'} + } + }): + for legacy_setting in ['AVATAR_BACKEND', 'CERTIFICATE_BACKEND', 'PROFILE_IMAGE_BACKEND']: + if hasattr(settings, legacy_setting): + delattr(settings, legacy_setting) + + for storage_name, legacy_setting_name in [ + ('avatars', 'AVATAR_BACKEND'), + ('certificates', 'CERTIFICATE_BACKEND'), + ('profile_image', 'PROFILE_IMAGE_BACKEND'), + ]: + storage = get_named_storage(storage_name, legacy_setting_name=legacy_setting_name) + self.assertEqual(storage, default_storage) diff --git a/edx_django_utils/storage/utils.py b/edx_django_utils/storage/utils.py new file mode 100644 index 00000000..ccd76f33 --- /dev/null +++ b/edx_django_utils/storage/utils.py @@ -0,0 +1,55 @@ +""" +Utilities for working with Django storage backends. + +This module provides helper functions to retrieve storage instances +based on Django's STORAGES setting, legacy configuration, or +fallback to the default storage. +""" + +from django.conf import settings +from django.core.files.storage import default_storage, storages +from django.utils.module_loading import import_string + + +def get_named_storage(name=None, legacy_setting_name=None): + """ + Returns an instance of the configured storage backend. + + This function prioritizes configuration in the following order: + + 1. Use the named storage from Django's STORAGES if `name` is defined. + 2. If not found, check the legacy setting (if `legacy_setting_name` is provided). + 3. If still undefined, fall back to Django's default_storage. + + Args: + name (str, optional): The name of the storage as defined in Django's STORAGES setting. + legacy_setting_name (str, optional): The legacy setting dict to check + for a storage class path and options. + + Returns: + An instance of the configured storage backend. + + Raises: + ValueError: If neither `name` nor `legacy_setting_name` are provided. + ImportError: If the specified storage class cannot be imported. + """ + if not name and not legacy_setting_name: + raise ValueError("You must provide at least 'name' or 'legacy_setting_name'.") + + # 1. Check Django 4.2+ STORAGES dict if `name` is provided + if name: + storages_config = getattr(settings, 'STORAGES', {}) + if name in storages_config: + return storages[name] + + # 2. Check legacy config if `legacy_setting_name` is provided + if legacy_setting_name: + legacy_config = getattr(settings, legacy_setting_name, {}) + storage_class_path = legacy_config.get('class') or legacy_config.get('STORAGE_CLASS') + options = legacy_config.get('options', {}) or legacy_config.get('STORAGE_KWARGS', {}) + if storage_class_path: + storage_class = import_string(storage_class_path) + return storage_class(**options) + + # 3. Fallback to Django's default_storage + return default_storage