Skip to content

feat: removing get_storage_class from COURSE_METADATA_EXPORT_STORAGE #36761

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cms/djangoapps/export_course_metadata/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


from django.conf import settings
from django.core.files.storage import get_storage_class
from common.djangoapps.util.storage import resolve_storage_backend
from storages.backends.s3boto3 import S3Boto3Storage


Expand All @@ -17,4 +17,4 @@ def __init__(self):
bucket = settings.COURSE_METADATA_EXPORT_BUCKET
super().__init__(bucket_name=bucket, custom_domain=None, querystring_auth=True)

course_metadata_export_storage = get_storage_class(settings.COURSE_METADATA_EXPORT_STORAGE)()
course_metadata_export_storage = resolve_storage_backend("COURSE_METADATA_EXPORT_STORAGE")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider renaming 'COURSE_METADATA_EXPORT_STORAGE' to lowercase (e.g., 'course_metadata_export_storage') for consistency with other keys in STORAGES, which are conventionally lowercase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks for your feedback. I'm concerned about this because the current settings key, "COURSE_METADATA_EXPORT_STORAGE", is in uppercase. For that reason, I have a question: how will the transition to migrate these changes work? Will it be merged before the settings are migrated to STORAGES, or can we assume that the STORAGES settings will already exist?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, your concern is valid, so let’s keep the key as-is for now. There isn’t a finalized discussion yet on how the migration to the new STORAGES setting will be handled. At this stage, our focus is on updating deprecated methods for Django 4.2 and ensuring the code remains compatible with future versions like Django 5.x.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, I was referring to the future STORAGES key, e.g.,
STORAGES = {"course_metadata_export_storage": "something"}
—not the existing settings.COURSE_METADATA_EXPORT_STORAGE. I mixed up the two earlier :) but for now no change require since we have no STORAGES plan yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thank you. Here are some ideas on how we could handle the migration:

Case 1:
Stage 1 (no breaking change): Migrate get_storage_class (this PR is an example) and merge it.
Stage 2 (breaking change): Migrate storage settings to the new convention, e.g., STORAGES = {}. and decide if we use lower or upper case in the key.
Stage 3: Ready to migrate to Django 5.

Case 2:
Stage 1 (no breaking change): Migrate get_storage_class (this PR is an example) and merge it.
Stage 2: Ready to migrate to Django 5 because the STORAGES settings is not required
Stage 3 (breaking change): Gradually migrate storage settings to the new convention, e.g., STORAGES = {}, in preparation for upgrading to Django 6.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stage 1 would be merging all these PRs as the initial step.
Stage 2 involves reaching out to the owning teams to get their input on the proposed migration plan. because it will effect the community also.

Looping in @feanil for visibility.

67 changes: 67 additions & 0 deletions cms/djangoapps/export_course_metadata/test_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
"""

from unittest.mock import patch
from django.test.utils import override_settings
from django.conf import settings

from edx_toggles.toggles.testutils import override_waffle_flag
from xmodule.modulestore.django import SignalHandler
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from common.djangoapps.util.storage import resolve_storage_backend
from storages.backends.s3boto3 import S3Boto3Storage

from .signals import export_course_metadata
from .toggles import EXPORT_COURSE_METADATA_FLAG
Expand Down Expand Up @@ -53,3 +57,66 @@ def test_happy_path(self, patched_content, patched_storage):
patched_storage.save.assert_called_once_with(
f'course_metadata_export/{self.course_key}.json', patched_content.return_value
)

@override_settings(
COURSE_METADATA_EXPORT_STORAGE="cms.djangoapps.export_course_metadata.storage.CourseMetadataExportS3Storage",
DEFAULT_FILE_STORAGE="django.core.files.storage.FileSystemStorage"
)
def test_resolve_default_storage(self):
""" Ensure the default storage is invoked, even if course export storage is configured """
storage = resolve_storage_backend("default")
self.assertEqual(storage.__class__.__name__, "FileSystemStorage")

@override_settings(
COURSE_METADATA_EXPORT_STORAGE="cms.djangoapps.export_course_metadata.storage.CourseMetadataExportS3Storage",
DEFAULT_FILE_STORAGE="django.core.files.storage.FileSystemStorage",
COURSE_METADATA_EXPORT_BUCKET="bucket_name_test"
)
def test_resolve_happy_path_storage(self):
""" Make sure that the correct course export storage is being used """
storage = resolve_storage_backend("COURSE_METADATA_EXPORT_STORAGE")
self.assertEqual(storage.__class__.__name__, "CourseMetadataExportS3Storage")
self.assertEqual(storage.bucket_name, "bucket_name_test")

@override_settings()
def test_resolve_storage_with_no_config(self):
""" If no storage setup is defined, we get FileSystemStorage by default """
del settings.DEFAULT_FILE_STORAGE
del settings.COURSE_METADATA_EXPORT_STORAGE
del settings.COURSE_METADATA_EXPORT_BUCKET
storage = resolve_storage_backend("COURSE_METADATA_EXPORT_STORAGE")
self.assertEqual(storage.__class__.__name__, "FileSystemStorage")

@override_settings(
COURSE_METADATA_EXPORT_STORAGE=None,
COURSE_METADATA_EXPORT_BUCKET="bucket_name_test",
STORAGES={
'COURSE_METADATA_EXPORT_STORAGE': {
'BACKEND': 'cms.djangoapps.export_course_metadata.storage.CourseMetadataExportS3Storage',
'OPTIONS': {}
}
}
)
def test_resolve_storage_using_django5_settings(self):
""" Simulating a Django 4 environment using Django 5 Storages configuration """
storage = resolve_storage_backend("COURSE_METADATA_EXPORT_STORAGE")
self.assertEqual(storage.__class__.__name__, "CourseMetadataExportS3Storage")
self.assertEqual(storage.bucket_name, "bucket_name_test")

@override_settings(
STORAGES={
'COURSE_METADATA_EXPORT_STORAGE': {
'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage',
'OPTIONS': {
'bucket_name': 'bucket_name_test'
}
}
}
)
def test_resolve_storage_using_django5_settings_with_options(self):
""" Ensure we call the storage class with the correct parameters and Django 5 setup """
del settings.COURSE_METADATA_EXPORT_STORAGE
del settings.COURSE_METADATA_EXPORT_BUCKET
storage = resolve_storage_backend("COURSE_METADATA_EXPORT_STORAGE")
self.assertEqual(storage.__class__.__name__, S3Boto3Storage.__name__)
self.assertEqual(storage.bucket_name, "bucket_name_test")
53 changes: 53 additions & 0 deletions common/djangoapps/util/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
""" Utility functions related to django storages """

from django.conf import settings
from django.core.files.storage import default_storage, storages
from django.utils.module_loading import import_string


def resolve_storage_backend(storage_key, options=None):
"""
Configures and returns a Django `Storage` instance, compatible with both Django 4 and Django 5.

Main goal:
Deprecate the use of `django.core.files.storage.get_storage_class`.
How:
Replace `get_storage_class` with direct configuration logic,
ensuring backward compatibility with both Django 4 and Django 5 storage settings.
Returns:
An instance of the configured storage backend.
Raises:
ImportError: If the specified storage class cannot be imported.
"""

storage_path = getattr(settings, storage_key, None)
storages_config = getattr(settings, 'STORAGES', {})

if options is None:
options = {}

if storage_key == "default":
# Use case 1: Default storage
# Works consistently across Django 4.2 and 5.x
# In Django 4.2 and above, `default_storage` uses
# either `DEFAULT_FILE_STORAGE` or `STORAGES['default']`.
return default_storage

if storage_key in storages_config:
# Use case 2: STORAGES is defined
# If STORAGES is present, we retrieve it through the storages API
# settings.py must define STORAGES like:
# STORAGES = {
# "default": {"BACKEND": "...", "OPTIONS": {...}},
# "custom": {"BACKEND": "...", "OPTIONS": {...}},
# }
# See: https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STORAGES
return storages[storage_key]

if not storage_path:
# If no storage settings are defined anywhere, use the default storage
return default_storage

# Fallback to import the storage_path manually
StorageClass = import_string(storage_path)
return StorageClass(**options)
Loading