From cd57ecef813a6731bdbc9cc90213a60fc95698dd Mon Sep 17 00:00:00 2001 From: Turchanikov Arsen Date: Thu, 22 May 2025 17:17:36 +0300 Subject: [PATCH 01/18] fix: correct course catalog visibility for "about" setting Previously, courses with the "Course Visibility in Catalog" setting set to "about" still appeared in the course catalog, which contradicts the expected behavior. --- lms/lib/courseware_search/lms_filter_generator.py | 3 ++- .../courseware_search/test/test_lms_filter_generator.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lms/lib/courseware_search/lms_filter_generator.py b/lms/lib/courseware_search/lms_filter_generator.py index b0c0564df4..5b2592e4cd 100644 --- a/lms/lib/courseware_search/lms_filter_generator.py +++ b/lms/lib/courseware_search/lms_filter_generator.py @@ -9,6 +9,7 @@ from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartiti from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme from common.djangoapps.student.models import CourseEnrollment +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE INCLUDE_SCHEMES = [CohortPartitionScheme, RandomUserPartitionScheme, ] SCHEME_SUPPORTS_ASSIGNMENT = [RandomUserPartitionScheme, ] @@ -63,6 +64,6 @@ class LmsSearchFilterGenerator(SearchFilterGenerator): if not getattr(settings, "SEARCH_SKIP_INVITATION_ONLY_FILTERING", True): exclude_dictionary['invitation_only'] = True if not getattr(settings, "SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING", True): - exclude_dictionary['catalog_visibility'] = 'none' + exclude_dictionary['catalog_visibility'] = [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] return exclude_dictionary diff --git a/lms/lib/courseware_search/test/test_lms_filter_generator.py b/lms/lib/courseware_search/test/test_lms_filter_generator.py index 492cf64d8c..8afa94f70f 100644 --- a/lms/lib/courseware_search/test/test_lms_filter_generator.py +++ b/lms/lib/courseware_search/test/test_lms_filter_generator.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -139,3 +140,9 @@ class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase): assert 'org' not in exclude_dictionary assert 'org' in field_dictionary assert ['TestSite3'] == field_dictionary['org'] + + @patch('django.conf.settings.SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING', False) + def test_excludes_catalog_visibility(self): + _, _, exclude_dictionary = LmsSearchFilterGenerator.generate_field_filters(user=self.user) + assert 'catalog_visibility' in exclude_dictionary + assert exclude_dictionary['catalog_visibility'] == [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] From 714303dbc8475e20b8b11678b986c3e1121fb4fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 09:46:13 +0500 Subject: [PATCH 02/18] feat: Upgrade Python dependency edx-enterprise (#36893) * feat: Upgrade Python dependency edx-enterprise --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 0b1bb96a59..1c1cf2e98c 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -51,7 +51,7 @@ django-stubs<6 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==6.0.3 +edx-enterprise==6.2.2 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index f800d10a2d..37607a3f3e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -461,7 +461,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c49f64a635..d62a8435d2 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -740,7 +740,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 71adb423f6..bcc15ac4a4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -545,7 +545,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 16ba8528f5..dbfa26eb1c 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -570,7 +570,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 0a7d8949819775ba1feebe11fece8bf33c1a1451 Mon Sep 17 00:00:00 2001 From: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:56:46 +0500 Subject: [PATCH 03/18] feat: removed age restriction on profile image upload (#36857) * feat: removed age restriction on profile image upload * test: updated test files * test: updated test files * fix: fixed lint issues * test: updated test files * fix: fixed lint issues --- common/djangoapps/student/models/user.py | 4 ---- .../student/tests/test_parental_controls.py | 20 ---------------- .../user_api/accounts/tests/test_views.py | 13 ++++++----- .../user_authn/tests/test_cookies.py | 23 ++++++++----------- 4 files changed, 16 insertions(+), 44 deletions(-) diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index ad47c942f3..94cb99d0ce 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -696,10 +696,6 @@ def user_profile_pre_save_callback(sender, **kwargs): """ user_profile = kwargs['instance'] - # Remove profile images for users who require parental consent - if user_profile.requires_parental_consent() and user_profile.has_profile_image: - user_profile.profile_image_uploaded_at = None - # Cache "old" field values on the model instance so that they can be # retrieved in the post_save callback when we emit an event with new and # old field values. diff --git a/common/djangoapps/student/tests/test_parental_controls.py b/common/djangoapps/student/tests/test_parental_controls.py index 62cd30707d..a8ae471c61 100644 --- a/common/djangoapps/student/tests/test_parental_controls.py +++ b/common/djangoapps/student/tests/test_parental_controls.py @@ -65,23 +65,3 @@ class ProfileParentalControlsTest(TestCase): self.set_year_of_birth(current_year - 14) assert not self.profile.requires_parental_consent() assert not self.profile.requires_parental_consent(year=current_year) - - def test_profile_image(self): - """Verify that a profile's image obeys parental controls.""" - - # Verify that an image cannot be set for a user with no year of birth set - self.profile.profile_image_uploaded_at = now() - self.profile.save() - assert not self.profile.has_profile_image - - # Verify that an image can be set for an adult user - current_year = now().year - self.set_year_of_birth(current_year - 20) - self.profile.profile_image_uploaded_at = now() - self.profile.save() - assert self.profile.has_profile_image - - # verify that a user's profile image is removed when they switch to requiring parental controls - self.set_year_of_birth(current_year - 10) - self.profile.save() - assert not self.profile.has_profile_image diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 1c92aa22c7..466e1e278a 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -403,17 +403,18 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert data['social_links'] is not None assert data['time_zone'] is None - def _verify_private_account_response(self, response, requires_parental_consent=False): + def _verify_private_account_response(self, response, requires_parental_consent=False, has_profile_image=True): """ Verify that only the public fields are returned if a user does not want to share account fields """ data = response.data assert 3 == len(data) assert PRIVATE_VISIBILITY == data['account_privacy'] - self._verify_profile_image_data(data, not requires_parental_consent) + self._verify_profile_image_data(data, has_profile_image) assert self.user.username == data['username'] - def _verify_full_account_response(self, response, requires_parental_consent=False, year_of_birth=2000): + def _verify_full_account_response(self, response, requires_parental_consent=False, + has_profile_image=True, year_of_birth=2000): """ Verify that all account fields are returned (even those that are not shareable). """ @@ -426,7 +427,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe UserPreference.get_value(self.user, 'account_privacy') ) assert expected_account_privacy == data['account_privacy'] - self._verify_profile_image_data(data, not requires_parental_consent) + self._verify_profile_image_data(data, has_profile_image) assert self.user.username == data['username'] # additional shareable fields (8) @@ -1271,11 +1272,11 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert data['requires_parental_consent'] assert PRIVATE_VISIBILITY == data['account_privacy'] else: - self._verify_private_account_response(response, requires_parental_consent=True) + self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False) # Verify that the shared view is still private response = self.send_get(client, query_parameters='view=shared') - self._verify_private_account_response(response, requires_parental_consent=True) + self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False) @skip_unless_lms diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index d4d9a59fc9..aa2e687102 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -1,8 +1,9 @@ # pylint: disable=missing-docstring -from datetime import date +from datetime import date, datetime import json +from pytz import UTC from unittest.mock import MagicMock, patch from urllib.parse import urljoin from django.conf import settings @@ -20,6 +21,10 @@ from common.djangoapps.student.tests.factories import AnonymousUserFactory, User from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user + + +TEST_PROFILE_IMAGE_UPLOAD_DT = datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC) class CookieTests(TestCase): @@ -27,6 +32,8 @@ class CookieTests(TestCase): super().setUp() self.user = UserFactory.create() self.user.profile = UserProfileFactory.create(user=self.user) + self.user.profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT + self.user.profile.save() # lint-amnesty, pylint: disable=no-member self.request = RequestFactory().get('/') self.request.user = self.user self.request.session = self._get_stub_session() @@ -43,18 +50,6 @@ class CookieTests(TestCase): return urls_obj - def _get_expected_image_urls(self): - expected_image_urls = { - 'full': '/static/default_500.png', - 'large': '/static/default_120.png', - 'medium': '/static/default_50.png', - 'small': '/static/default_30.png' - } - - expected_image_urls = self._convert_to_absolute_uris(self.request, expected_image_urls) - - return expected_image_urls - def _get_expected_header_urls(self): expected_header_urls = { 'logout': reverse('logout'), @@ -112,7 +107,7 @@ class CookieTests(TestCase): 'username': self.user.username, 'email': self.user.email, 'header_urls': self._get_expected_header_urls(), - 'user_image_urls': self._get_expected_image_urls(), + 'user_image_urls': get_profile_image_urls_for_user(self.user), } self.assertDictEqual(actual, expected) From 10a4d12b06a225d9d30000e82eac78dc539145ad Mon Sep 17 00:00:00 2001 From: Muhammad Umar Khan <42294172+mumarkhan999@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:30:32 +0500 Subject: [PATCH 04/18] feat!: remove pyjwkest (#36707) Co-authored-by: M Umar Khan --- lms/envs/test.py | 52 ++++++++++++++++++------------ openedx/core/lib/jwt.py | 49 ++++++++++++++++------------ openedx/core/lib/tests/test_jwt.py | 44 +++++++++---------------- 3 files changed, 76 insertions(+), 69 deletions(-) diff --git a/lms/envs/test.py b/lms/envs/test.py index 46ff557895..628195b902 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -651,28 +651,40 @@ TOKEN_SIGNING = { 'JWT_ISSUER': 'token-test-issuer', 'JWT_SIGNING_ALGORITHM': 'RS512', 'JWT_SUPPORTED_VERSION': '1.2.0', - 'JWT_PRIVATE_SIGNING_JWK': '''{ - "e": "AQAB", - "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", - "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", - "q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", - "p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", - "kid": "token-test-sign", "kty": "RSA" - }''', - 'JWT_PUBLIC_SIGNING_JWK_SET': '''{ - "keys": [ + 'JWT_PRIVATE_SIGNING_JWK': """ + { + "kid": "token-test-sign", + "kty": "RSA", + "key_ops": [ + "sign" + ], + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB", + "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", + "p": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", + "q": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", + "dp": "Azh08H8r2_sJuBXAzx_mQ6iZnAZQ619PnJFOXjTqnMgcaK8iSHLL2CgDIUQwteUcBphgP0uBrfWIBs5jmM8rUtVz4CcrPb5jdjhHjuu4NxmnFbPlhNoOp8OBUjPP3S-h-fPoaFjxDrUqz_zCdPVzp4S6UTkf6Hu-SiI9CFVFZ8E", + "dq": "WQ44_KTIbIej9qnYUPMA1DoaAF8ImVDIdiOp9c79dC7FvCpN3w-lnuugrYDM1j9Tk5bRrY7-JuE6OaKQgOtajoS1BIxjYHj5xAVPD15CVevOihqeq5Zx0ZAAYmmCKRrfUe0iLx2QnIcoKH1-Azs23OXeeo6nysznZjvv9NVJv60", + "qi": "KSWGH607H1kNG2okjYdmVdNgLxTUB-Wye9a9FNFE49UmQIOJeZYXtDzcjk8IiK3g-EU3CqBeDKVUgHvHFu4_Wj3IrIhKYizS4BeFmOcPDvylDQCmJcC9tXLQgHkxM_MEJ7iLn9FOLRshh7GPgZphXxMhezM26Cz-8r3_mACHu84" + } + """, # noqa: E501, + + 'JWT_PUBLIC_SIGNING_JWK_SET': """ + { + "keys": [ { - "kid":"token-test-wrong-key", - "e": "AQAB", - "kty": "RSA", - "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dffgRQLD1qf5D6sprmYfWVokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + "kid": "token-test-sign", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB" }, { - "kid":"token-test-sign", - "e": "AQAB", - "kty": "RSA", - "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + "kid": "token-test-wrong-key", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB" } - ] - }''', + ] + } + """, # noqa: E501 } diff --git a/openedx/core/lib/jwt.py b/openedx/core/lib/jwt.py index 47642b8695..199c6fead8 100644 --- a/openedx/core/lib/jwt.py +++ b/openedx/core/lib/jwt.py @@ -2,12 +2,12 @@ JWT Token handling and signing functions. """ -import json +import jwt from time import time from django.conf import settings -from jwkest import Expired, Invalid, MissingKey, jwk -from jwkest.jws import JWS +from jwt.api_jwk import PyJWK, PyJWKSet +from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None): @@ -40,15 +40,9 @@ def _encode_and_sign(payload): The signing key and algorithm are pulled from settings. """ - keys = jwk.KEYS() - - serialized_keypair = json.loads(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK']) - keys.add(serialized_keypair) + private_key = PyJWK.from_json(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK']) algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM'] - - data = json.dumps(payload) - jws = JWS(data, alg=algorithm) - return jws.sign_compact(keys=keys) + return jwt.encode(payload, key=private_key.key, algorithm=algorithm) def unpack_jwt(token, lms_user_id, now=None): @@ -65,27 +59,40 @@ def unpack_jwt(token, lms_user_id, now=None): Returns a valid, decoded json payload (string). """ now = now or int(time()) - payload = _unpack_and_verify(token) + payload = unpack_and_verify(token) if "lms_user_id" not in payload: - raise MissingKey("LMS user id is missing") + raise MissingRequiredClaimError("LMS user id is missing") if "exp" not in payload: - raise MissingKey("Expiration is missing") + raise MissingRequiredClaimError("Expiration is missing") if payload["lms_user_id"] != lms_user_id: - raise Invalid("User does not match") + raise InvalidSignatureError("User does not match") if payload["exp"] < now: - raise Expired("Token is expired") + raise ExpiredSignatureError("Token is expired") return payload -def _unpack_and_verify(token): +def unpack_and_verify(token): # pylint: disable=inconsistent-return-statements """ Unpack and verify the provided token. The signing key and algorithm are pulled from settings. """ - keys = jwk.KEYS() - keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) - decoded = JWS().verify_compact(token.encode('utf-8'), keys) - return decoded + key_set = [] + key_set.extend( + PyJWKSet.from_json(settings.TOKEN_SIGNING["JWT_PUBLIC_SIGNING_JWK_SET"]).keys + ) + + for i in range(len(key_set)): # pylint: disable=consider-using-enumerate + try: + decoded = jwt.decode( + token, + key=key_set[i].key, + algorithms=["RS256", "RS512"], + options={"verify_signature": True, "verify_aud": False}, + ) + return decoded + except Exception: # pylint: disable=broad-exception-caught + if i == len(key_set) - 1: + raise diff --git a/openedx/core/lib/tests/test_jwt.py b/openedx/core/lib/tests/test_jwt.py index 7a678dd3c0..da1f047e48 100644 --- a/openedx/core/lib/tests/test_jwt.py +++ b/openedx/core/lib/tests/test_jwt.py @@ -2,24 +2,23 @@ Tests for token handling """ import unittest +from time import time -from django.conf import settings -from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk -from jwkest.jws import JWS +from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError from openedx.core.djangolib.testing.utils import skip_unless_lms -from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt +from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt, unpack_and_verify test_user_id = 121 invalid_test_user_id = 120 -test_timeout = 60 -test_now = 1661432902 +test_timeout = 1000 +test_now = int(time()) test_claims = {"foo": "bar", "baz": "quux", "meaning": 42} expected_full_token = { "lms_user_id": test_user_id, - "iat": 1661432902, - "exp": 1661432902 + 60, + "iat": test_now, + "exp": test_now + test_timeout, "iss": "token-test-issuer", # these lines from test_settings.py "version": "1.2.0", # these lines from test_settings.py } @@ -34,7 +33,7 @@ class TestSign(unittest.TestCase): def test_create_jwt(self): token = create_jwt(test_user_id, test_timeout, {}, test_now) - decoded = _verify_jwt(token) + decoded = unpack_and_verify(token) self.assertEqual(expected_full_token, decoded) def test_create_jwt_with_claims(self): @@ -43,7 +42,7 @@ class TestSign(unittest.TestCase): expected_token_with_claims = expected_full_token.copy() expected_token_with_claims.update(test_claims) - decoded = _verify_jwt(token) + decoded = unpack_and_verify(token) self.assertEqual(expected_token_with_claims, decoded) def test_malformed_token(self): @@ -53,19 +52,8 @@ class TestSign(unittest.TestCase): expected_token_with_claims = expected_full_token.copy() expected_token_with_claims.update(test_claims) - with self.assertRaises(BadSignature): - _verify_jwt(token) - - -def _verify_jwt(jwt_token): - """ - Helper function which verifies the signature and decodes the token - from string back to claims form - """ - keys = jwk.KEYS() - keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) - decoded = JWS().verify_compact(jwt_token.encode('utf-8'), keys) - return decoded + with self.assertRaises(InvalidSignatureError): + unpack_and_verify(token) @skip_unless_lms @@ -97,19 +85,19 @@ class TestUnpack(unittest.TestCase): expected_token_with_claims = expected_full_token.copy() expected_token_with_claims.update(test_claims) - with self.assertRaises(BadSignature): + with self.assertRaises(InvalidSignatureError): unpack_jwt(token, test_user_id, test_now) def test_unpack_token_with_invalid_user(self): token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now) - with self.assertRaises(Invalid): + with self.assertRaises(InvalidSignatureError): unpack_jwt(token, test_user_id, test_now) def test_unpack_expired_token(self): token = create_jwt(test_user_id, test_timeout, {}, test_now) - with self.assertRaises(Expired): + with self.assertRaises(ExpiredSignatureError): unpack_jwt(token, test_user_id, test_now + test_timeout + 1) def test_missing_expired_lms_user_id(self): @@ -117,7 +105,7 @@ class TestUnpack(unittest.TestCase): del payload['lms_user_id'] token = _encode_and_sign(payload) - with self.assertRaises(MissingKey): + with self.assertRaises(MissingRequiredClaimError): unpack_jwt(token, test_user_id, test_now) def test_missing_expired_key(self): @@ -125,5 +113,5 @@ class TestUnpack(unittest.TestCase): del payload['exp'] token = _encode_and_sign(payload) - with self.assertRaises(MissingKey): + with self.assertRaises(MissingRequiredClaimError): unpack_jwt(token, test_user_id, test_now) From 1551cc00ced24e5a741bec9f5d2d64b7e78312e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:37:09 +0000 Subject: [PATCH 05/18] feat: Upgrade Python dependency edx-enterprise (#36899) --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 1c1cf2e98c..15d1fa6dad 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -51,7 +51,7 @@ django-stubs<6 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==6.2.2 +edx-enterprise==6.2.3 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 37607a3f3e..a31bd963da 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -461,7 +461,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.2 +edx-enterprise==6.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index d62a8435d2..ace180275f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -740,7 +740,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.2 +edx-enterprise==6.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index bcc15ac4a4..70f0353a75 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -545,7 +545,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.2 +edx-enterprise==6.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index dbfa26eb1c..349543fe1a 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -570,7 +570,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.2 +edx-enterprise==6.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From d6dbc4075c55b8542958904dc538e3cf8c9b0ca9 Mon Sep 17 00:00:00 2001 From: Arslan Ashraf <34372316+arslanashraf7@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:13:37 +0500 Subject: [PATCH 06/18] fix: generate IDV URL only if ACCOUNT_MICROFRONTEND_URL is available (#36898) The api/courseware/course fails for all the verified enrollments if you are not using Account MFE, which means that you probably won't set ACCOUNT_MICROFRONTEND_URL in your settings/configurations. So this PR adds a check safely try to do rstrip. Fixes a bug in https://github.com/openedx/edx-platform/pull/36870 --- .../management/commands/send_verification_expiry_email.py | 2 +- lms/djangoapps/verify_student/services.py | 2 +- lms/djangoapps/verify_student/views.py | 2 +- openedx/core/djangoapps/notifications/email/utils.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py index 7902990fcc..0bfef6d0ac 100644 --- a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py +++ b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py @@ -188,7 +188,7 @@ class Command(BaseCommand): return True site = Site.objects.get_current() - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') message_context = get_base_template_context(site) message_context.update({ 'platform_name': settings.PLATFORM_NAME, diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 4091532bd0..5caede3dab 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -251,7 +251,7 @@ class IDVerificationService: Returns a string: Returns URL for IDV on Account Microfrontend """ - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') location = f'{account_base_url}/id-verification' if course_id: location += f'?course_id={quote(str(course_id))}' diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 0552611902..deda08e0c7 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1128,7 +1128,7 @@ def results_callback(request): # lint-amnesty, pylint: disable=too-many-stateme log.info("[COSMO-184] Denied verification for receipt_id={receipt_id}.".format(receipt_id=receipt_id)) attempt.deny(json.dumps(reason), error_code=error_code) - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') reverify_url = f'{account_base_url}/id-verification' verification_status_email_vars['reasons'] = reason verification_status_email_vars['reverify_url'] = reverify_url diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index b56b3fe97b..43ba900022 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -97,7 +97,7 @@ def create_email_template_context(username): 'channel': 'email', 'value': False } - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') return { "platform_name": settings.PLATFORM_NAME, "mailing_address": settings.CONTACT_MAILING_ADDRESS, From 4cea2ab0415fae87e382fdaeadf791baa44b626b Mon Sep 17 00:00:00 2001 From: Talha Rizwan Date: Thu, 12 Jun 2025 21:36:56 +0500 Subject: [PATCH 07/18] feat: export ora2 summary to DRF (#36555) * feat: export ora2 summary to DRF. --- lms/djangoapps/instructor/views/api.py | 39 ++++++++++++++------- lms/djangoapps/instructor/views/api_urls.py | 2 +- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index a710398a0f..9b2cfda1fa 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2742,22 +2742,35 @@ class ExportOra2DataView(DeveloperErrorViewMixin, APIView): return JsonResponse({"error": str(err)}, status=400) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def export_ora2_summary(request, course_id): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class ExportOra2SummaryView(DeveloperErrorViewMixin, APIView): """ - Pushes a Celery task which will aggregate a summary students' progress in ora2 tasks for a course into a .csv + Pushes a Celery task which will aggregate a summary of students' progress in ora2 tasks for a course into a .csv """ - course_key = CourseKey.from_string(course_id) - report_type = _('ORA summary') - task_api.submit_export_ora2_summary(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - return JsonResponse({"status": success_status}) + @method_decorator(ensure_csrf_cookie) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + def post(self, request, course_id): + """ + Initiates a Celery task to generate an ORA summary report for the specified course. + + Args: + request: The HTTP request object + course_id: The string representation of the course key + + Returns: + Response: A JSON response with a status message indicating the report generation has started + """ + course_key = CourseKey.from_string(course_id) + report_type = _('ORA summary') + try: + task_api.submit_export_ora2_summary(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + return Response({"status": success_status}) + except (AlreadyRunningError, QueueConnectionError, AttributeError) as err: + return JsonResponse({"error": str(err)}, status=400) @transaction.non_atomic_requests diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index ed4cc95b8b..1415541893 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -67,7 +67,7 @@ urlpatterns = [ # Reports.. path('get_course_survey_results', api.GetCourseSurveyResults.as_view(), name='get_course_survey_results'), path('export_ora2_data', api.ExportOra2DataView.as_view(), name='export_ora2_data'), - path('export_ora2_summary', api.export_ora2_summary, name='export_ora2_summary'), + path('export_ora2_summary', api.ExportOra2SummaryView.as_view(), name='export_ora2_summary'), path('export_ora2_submission_files', api.export_ora2_submission_files, name='export_ora2_submission_files'), From 4c0035c66f4810ddf952bc82e7cff5b84231916e Mon Sep 17 00:00:00 2001 From: katrinan029 <71999631+katrinan029@users.noreply.github.com> Date: Thu, 12 Jun 2025 22:42:05 +0000 Subject: [PATCH 08/18] feat: Upgrade Python dependency edx-enterprise version bump Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 15d1fa6dad..f24e706366 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -51,7 +51,7 @@ django-stubs<6 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==6.2.3 +edx-enterprise==6.2.4 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a31bd963da..4d6e2d4e1b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -461,7 +461,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.3 +edx-enterprise==6.2.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ace180275f..84941b0024 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -740,7 +740,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.3 +edx-enterprise==6.2.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 70f0353a75..61f04185d8 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -545,7 +545,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.3 +edx-enterprise==6.2.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 349543fe1a..11bfbbf2ab 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -570,7 +570,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.3 +edx-enterprise==6.2.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 6219c1768ff56920943dd0d6a7de714e746f8618 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Thu, 12 Jun 2025 17:06:55 +0500 Subject: [PATCH 09/18] feat: implemented self paced email UI --- lms/envs/common.py | 4 + .../core/djangoapps/schedules/resolvers.py | 2 + .../edx_ace/courseupdate/email/base_body.html | 91 ++++++++ .../edx_ace/courseupdate/email/body.html | 139 ++++++++++--- .../edx_ace/courseupdate/email/footer.html | 195 ++++++++++++++++++ .../edx_ace/courseupdate/email/head.html | 41 +++- .../email/return_to_course_cta.html | 35 ++++ 7 files changed, 480 insertions(+), 27 deletions(-) create mode 100644 openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html create mode 100644 openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html create mode 100644 openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html diff --git a/lms/envs/common.py b/lms/envs/common.py index 3ace69ab06..7097b4c2a0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5411,6 +5411,10 @@ NOTIFICATION_TYPE_ICONS = {} DEFAULT_NOTIFICATION_ICON_URL = "" NOTIFICATION_DIGEST_LOGO = DEFAULT_EMAIL_LOGO_URL +############## SELF PACED EMAIL ############## +SELF_PACED_BANNER_URL = "" +SELF_PACED_CLOUD_URL = "" + ############## NUDGE EMAILS ############### # .. setting_name: DISABLED_ORGS_FOR_PROGRAM_NUDGE # .. setting_default: [] diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 40d565962b..b3dce6f054 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -548,6 +548,8 @@ class CourseNextSectionUpdate(PrefixedDebugLoggerMixin, RecipientResolver): 'course_id': str(course.id), 'course_ids': [str(course.id)], 'unsubscribe_url': unsubscribe_url, + 'self_paced_banner_url': settings.SELF_PACED_BANNER_URL, + 'self_paced_cloud_url': settings.SELF_PACED_CLOUD_URL, }) template_context.update(_get_upsell_information_for_schedule(user, schedule)) diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html new file mode 100644 index 0000000000..80021696a4 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html @@ -0,0 +1,91 @@ +{% load django_markup %} +{% load i18n %} + +{% load ace %} + +{% load acetags %} + +{% get_current_language as LANGUAGE_CODE %} +{% get_current_language_bidi as LANGUAGE_BIDI %} + +{# This is preview text that is visible in the inbox view of many email clients but not visible in the actual #} +{# email itself. #} + +
+{% block preview_text %}{% endblock %} +
+ +{% for image_src in channel.tracker_image_sources %} + +{% endfor %} + +{% google_analytics_tracking_pixel %} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html index fd43f9933b..b4be9cdfb9 100644 --- a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html @@ -1,6 +1,8 @@ -{% extends 'ace_common/edx_ace/common/base_body.html' %} +{% extends 'schedules/edx_ace/courseupdate/email/base_body.html' %} + {% load i18n %} {% load django_markup %} +{% load static %} {% block preview_text %} {% filter force_escape %} @@ -11,38 +13,123 @@ {% endblock %} {% block content %} - +
+ +{% if route_enabled %} +{% endif %} + + + + + + + + + + + + + + + +
-

- {% blocktrans trimmed asvar tmsg %} - We hope you're enjoying {start_strong}{course_name}{end_strong}! - We want to let you know what you can look forward to in week {week_num}: - {% endblocktrans %} - {% interpolate_html tmsg start_strong=''|safe end_strong=''|safe course_name=course_name|force_escape|safe week_num=week_num|force_escape|safe %} -

    - {% for highlight in week_highlights %} -
  • {{ highlight }}
  • - {% endfor %} -
-

{% filter force_escape %} - {% blocktrans trimmed %} - With self-paced courses, you learn on your own schedule. - We encourage you to spend time with the course each week. - Your focused attention will pay off in the end! - {% endblocktrans %} + {% blocktrans %}This is a routed Account Activation email for {{ routed_profile_name }} ({{ routed_user_email }}): {{ routed_profile_name }}{% endblocktrans %} {% endfilter %} +

- - {% filter force_escape %} - {% blocktrans asvar course_cta_text %}Resume your course now{% endblocktrans %} - {% endfilter %} - {% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text%} - - {% include "ace_common/edx_ace/common/upsell_cta.html"%}
+ {% trans 'Welcome to edX. It’s time for your next career move' as tmsg %}{{ tmsg | force_escape }} +
+

+ {% trans "We hope you’re enjoying Introduction to Data Science with Python!" as tmsg %}{{ tmsg | force_escape }} +

+

+ {% trans "We want to let you know what you can look forward to in week two: " as tmsg %}{{ tmsg | force_escape }} +

+
    +
  • +

    + {% trans "Learn kNN regression" as tmsg %}{{ tmsg | force_escape }} +

    +
  • +
  • +

    + {% trans "Learn linear regression" as tmsg %}{{ tmsg | force_escape }} +

    +
  • +
  • +

    + {% trans "Find out how to choose which model you want" as tmsg %}{{ tmsg | force_escape }} +

    +
  • +
+
+ {% filter force_escape %} + {% blocktrans asvar course_cta_text %}Resume your course {% endblocktrans %} + {% endfilter %} + {% include "schedules/edx_ace/courseupdate/email/return_to_course_cta.html" with course_cta_text=course_cta_text%} +
+   +
+ + + + + + + + + +
+

+ {% trans "Your focused attention will pay off in the end! " as tmsg %}{{ tmsg | force_escape }} + {% trans "With self-paced courses, you learn on your own schedule. It’s a good idea to spend time with the course each week and check in with your goals often." as tmsg %}{{ tmsg | force_escape }} +

+
+ Message Icon +
+
{% endblock %} + +{% block footer%} +{%include 'schedules/edx_ace/courseupdate/email/footer.html'%} +{% endblock%} diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html new file mode 100644 index 0000000000..a7081d0bd3 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html @@ -0,0 +1,195 @@ +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + {% if confirm_activation_link %} + {% endif %} + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + +
+ + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
+ + + + + + +
+ + + + {% if social_media_urls.facebook %} + + {% endif %} + {% if social_media_urls.instagram %} + + {% endif %} + {% if social_media_urls.linkedin %} + + {% endif %} + {% if social_media_urls.twitter %} + + {% endif %} + {% if social_media_urls.reddit %} + + {% endif %} + + +
+ + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on LinkedIn{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Twitter{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Reddit{% endblocktrans %}{% endfilter %} + +
+
+
+
+
+ + + + + + +
+ + + + + + +
+ + + + {% if mobile_store_urls.apple %} + + {% endif %} + + {% if mobile_store_urls.google %} + + {% endif %} + + +
+ + {% trans + + + + {% trans + +
+
+
+
+ {% if disclaimer %} + {{ disclaimer }}
+ {% endif %} + {% trans "edX is the trusted platform for education and learning" as tmsg %}{{ tmsg | force_escape }}.
+
+ © {% now "Y" %} {{ platform_name }} LLC. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }}.
+
+ {% if unsubscribe_link %} + + {%if unsubscribe_text%} {{unsubscribe_text}} {%else%} {% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }} {%endif%} +
+
+ {% endif %} + {{ contact_mailing_address }} +
+
+
\ No newline at end of file diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html index 366ada7ad9..602b11e4ae 100644 --- a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html @@ -1 +1,40 @@ -{% extends 'ace_common/edx_ace/common/base_head.html' %} +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + + +
+ + + + +
+ + + + + + +
 
+ + + + +
+ + + + +
+ + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
+
+
 
+
+
\ No newline at end of file diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html new file mode 100644 index 0000000000..a0f4f8b557 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html @@ -0,0 +1,35 @@ +{% load i18n %} +{% load ace %} + +

+ {# email client support for style sheets is pretty spotty, so we have to inline all of these styles #} + 1 %} + href="{% with_link_tracking dashboard_url %}" + {% else %} + href="{% with_link_tracking course_url %}" + {% endif %} + {% endif %} + style=" + text-decoration: none; + color: white; + background-color: #ED5C13; + text-align: center; + vertical-align: middle; + user-select: none; + font-weight: 500; + font-size: 12px; + text-decoration-style: solid; + display: inline-flex; + flex-direction: row; + border-radius: 30.22px; + "> + {# old email clients require the use of the font tag :( #} + {{ course_cta_text }} + +

From 3703333d9e27796b33e7e0751129780063e6bf40 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Thu, 12 Jun 2025 18:40:19 +0500 Subject: [PATCH 10/18] test: fix test case --- openedx/core/djangoapps/schedules/tests/test_resolvers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py index a23c9dbb21..c981f242fa 100644 --- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py +++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py @@ -271,6 +271,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor @override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street') @override_settings(LOGO_URL_PNG='https://www.logo.png') + @override_settings(SELF_PACED_BANNER_URL = 'https://edx-notifications-static.edx.org/icons/self_paced_banner.jpg') + @override_settings(SELF_PACED_CLOUD_URL = 'https://edx-notifications-static.edx.org/icons/edX_icon-cloud_self-paced-email.png') def test_schedule_context(self): resolver = self.create_resolver() # using this to make sure the select_related stays intact @@ -316,6 +318,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor 'twitter': twitter_url}, 'template_revision': 'release', 'unsubscribe_url': None, + 'self_paced_banner_url': 'https://edx-notifications-static.edx.org/icons/self_paced_banner.jpg', + 'self_paced_cloud_url': 'https://edx-notifications-static.edx.org/icons/edX_icon-cloud_self-paced-email.png', 'week_highlights': ['good stuff 2'], 'week_num': 2, } From 842e0d41f5ae24f5718b55995a150282054897b0 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Fri, 13 Jun 2025 12:44:35 +0500 Subject: [PATCH 11/18] fix: removed hardcoded URLs --- openedx/core/djangoapps/schedules/tests/test_resolvers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py index c981f242fa..053114fc6f 100644 --- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py +++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py @@ -271,8 +271,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor @override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street') @override_settings(LOGO_URL_PNG='https://www.logo.png') - @override_settings(SELF_PACED_BANNER_URL = 'https://edx-notifications-static.edx.org/icons/self_paced_banner.jpg') - @override_settings(SELF_PACED_CLOUD_URL = 'https://edx-notifications-static.edx.org/icons/edX_icon-cloud_self-paced-email.png') + @override_settings(SELF_PACED_BANNER_URL = '') + @override_settings(SELF_PACED_CLOUD_URL = '') def test_schedule_context(self): resolver = self.create_resolver() # using this to make sure the select_related stays intact @@ -318,8 +318,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor 'twitter': twitter_url}, 'template_revision': 'release', 'unsubscribe_url': None, - 'self_paced_banner_url': 'https://edx-notifications-static.edx.org/icons/self_paced_banner.jpg', - 'self_paced_cloud_url': 'https://edx-notifications-static.edx.org/icons/edX_icon-cloud_self-paced-email.png', + 'self_paced_banner_url': '', + 'self_paced_cloud_url': '', 'week_highlights': ['good stuff 2'], 'week_num': 2, } From 2dd3df49c017d44ca77e00a77dede26289ca4a9b Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Fri, 13 Jun 2025 14:52:25 +0500 Subject: [PATCH 12/18] fix: fix spacing issue --- openedx/core/djangoapps/schedules/tests/test_resolvers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py index 053114fc6f..2c37608e5c 100644 --- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py +++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py @@ -271,8 +271,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor @override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street') @override_settings(LOGO_URL_PNG='https://www.logo.png') - @override_settings(SELF_PACED_BANNER_URL = '') - @override_settings(SELF_PACED_CLOUD_URL = '') + @override_settings(SELF_PACED_BANNER_URL='') + @override_settings(SELF_PACED_CLOUD_URL='') def test_schedule_context(self): resolver = self.create_resolver() # using this to make sure the select_related stays intact From 39028b95006c1ec35ade4ee497d4d074c88902e8 Mon Sep 17 00:00:00 2001 From: Ram Chandra Bhavirisetty Date: Fri, 13 Jun 2025 09:40:56 -0600 Subject: [PATCH 13/18] fix(settings): replace DEFAULT_FILE_STORAGE with STORAGES[default] --- cms/envs/common.py | 10 ++++++++-- cms/envs/devstack.py | 4 ++-- cms/envs/devstack_optimized.py | 2 +- cms/envs/openstack.py | 8 ++++---- cms/envs/production.py | 9 +++++---- cms/envs/test.py | 3 +-- cms/envs/test_static_optimized.py | 1 + common/djangoapps/util/file.py | 2 +- lms/envs/common.py | 10 ++++++++-- lms/envs/devstack.py | 4 ++-- lms/envs/devstack_optimized.py | 2 +- lms/envs/openstack.py | 6 +++--- lms/envs/production.py | 2 +- lms/envs/static.py | 2 +- lms/envs/test.py | 15 ++++++++------- lms/envs/test_static_optimized.py | 1 + 16 files changed, 48 insertions(+), 33 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 85af0063d4..f6c65bc5ba 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1301,7 +1301,6 @@ PIPELINE = { 'YUI_BINARY': 'yui-compressor', } -STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage' STATICFILES_STORAGE_KWARGS = {} # List of finder classes that know how to find static files in various locations. @@ -2553,7 +2552,14 @@ BULK_EMAIL_DEFAULT_FROM_EMAIL = 'no-reply@example.com' BULK_EMAIL_LOG_SENT_EMAILS = False ############### Settings for django file storage ################## -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' +STORAGES = { + "default": { + "BACKEND": 'django.core.files.storage.FileSystemStorage' + }, + "staticfiles": { + "BACKEND": 'openedx.core.storage.ProductionStorage', + } +} ###################### Grade Downloads ###################### # These keys are used for all of our asynchronous downloadable files, including diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index d473a23290..9db580e412 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -9,7 +9,7 @@ from os.path import abspath, dirname, join from .production import * # pylint: disable=wildcard-import, unused-wildcard-import # Don't use S3 in devstack, fall back to filesystem -del DEFAULT_FILE_STORAGE +STORAGES['default']['BACKEND'] = 'django.core.files.storage.FileSystemStorage' COURSE_IMPORT_EXPORT_STORAGE = 'django.core.files.storage.FileSystemStorage' USER_TASKS_ARTIFACT_STORAGE = COURSE_IMPORT_EXPORT_STORAGE @@ -56,7 +56,7 @@ FEATURES['ENABLE_VIDEO_UPLOAD_PIPELINE'] = True # Skip packaging and optimization in development PIPELINE['PIPELINE_ENABLED'] = False -STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage' +STORAGES['staticfiles']['BACKEND'] = 'openedx.core.storage.DevelopmentStorage' # Revert to the default set of finders as we don't want the production pipeline STATICFILES_FINDERS = [ diff --git a/cms/envs/devstack_optimized.py b/cms/envs/devstack_optimized.py index b7f4234466..0aa615a729 100644 --- a/cms/envs/devstack_optimized.py +++ b/cms/envs/devstack_optimized.py @@ -33,7 +33,7 @@ DEBUG = True REQUIRE_DEBUG = False # Fetch static files out of the pipeline's static root -STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' +STORAGES['staticfiles']['BACKEND'] = 'pipeline.storage.PipelineManifestStorage' # Serve static files at /static directly from the staticfiles directory under test root. # Note: optimized files for testing are generated with settings from test_static_optimized diff --git a/cms/envs/openstack.py b/cms/envs/openstack.py index 8e35122c76..991762ccaf 100644 --- a/cms/envs/openstack.py +++ b/cms/envs/openstack.py @@ -22,11 +22,11 @@ if AUTH_TOKENS.get('SWIFT_REGION_NAME'): SWIFT_EXTRA_OPTIONS = {'region_name': AUTH_TOKENS['SWIFT_REGION_NAME']} if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'): - DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') + STORAGES["default"]["BACKEND"] = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') elif SWIFT_AUTH_URL and SWIFT_USERNAME and SWIFT_KEY: - DEFAULT_FILE_STORAGE = 'swift.storage.SwiftStorage' + STORAGES["default"]["BACKEND"] = 'swift.storage.SwiftStorage' else: - DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' + STORAGES["default"]["BACKEND"] = 'django.core.files.storage.FileSystemStorage' # Use default file storage class set above for course import/export -COURSE_IMPORT_EXPORT_STORAGE = DEFAULT_FILE_STORAGE +COURSE_IMPORT_EXPORT_STORAGE = STORAGES["default"]["BACKEND"] diff --git a/cms/envs/production.py b/cms/envs/production.py index 4ba05e1ab4..34bd06c426 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -222,7 +222,8 @@ if 'staticfiles' in CACHES: # we need to run asset collection twice, once for local disk and once for S3. # Once we have migrated to service assets off S3, then we can convert this back to # managed by the yaml file contents -STATICFILES_STORAGE = os.environ.get('STATICFILES_STORAGE', STATICFILES_STORAGE) +STORAGES['staticfiles']['BACKEND'] = os.environ.get( + 'STATICFILES_STORAGE', STORAGES['staticfiles']['BACKEND']) CSRF_TRUSTED_ORIGINS = _YAML_TOKENS.get("CSRF_TRUSTED_ORIGINS", []) MKTG_URL_LINK_MAP.update(_YAML_TOKENS.get('MKTG_URL_LINK_MAP', {})) @@ -265,19 +266,19 @@ AWS_QUERYSTRING_EXPIRE = 7 * 24 * 60 * 60 # 7 days # Change to S3Boto3 if we haven't specified another default storage AND we have specified AWS creds. if (not _YAML_TOKENS.get('DEFAULT_FILE_STORAGE')) and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: - DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + STORAGES["default"]["BACKEND"] = 'storages.backends.s3boto3.S3Boto3Storage' if COURSE_IMPORT_EXPORT_BUCKET: COURSE_IMPORT_EXPORT_STORAGE = 'cms.djangoapps.contentstore.storage.ImportExportS3Storage' else: - COURSE_IMPORT_EXPORT_STORAGE = DEFAULT_FILE_STORAGE + COURSE_IMPORT_EXPORT_STORAGE = STORAGES["default"]["BACKEND"] USER_TASKS_ARTIFACT_STORAGE = COURSE_IMPORT_EXPORT_STORAGE if COURSE_METADATA_EXPORT_BUCKET: COURSE_METADATA_EXPORT_STORAGE = 'cms.djangoapps.export_course_metadata.storage.CourseMetadataExportS3Storage' else: - COURSE_METADATA_EXPORT_STORAGE = DEFAULT_FILE_STORAGE + COURSE_METADATA_EXPORT_STORAGE = STORAGES["default"]["BACKEND"] # The normal database user does not have enough permissions to run migrations. # Migrations are run with separate credentials, given as DB_MIGRATION_* diff --git a/cms/envs/test.py b/cms/envs/test.py index a536c52b7e..95935f9761 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -29,7 +29,6 @@ from .common import * from lms.envs.test import ( # pylint: disable=wrong-import-order, disable=unused-import ACCOUNT_MICROFRONTEND_URL, COMPREHENSIVE_THEME_DIRS, # unimport:skip - DEFAULT_FILE_STORAGE, ECOMMERCE_API_URL, ENABLE_COMPREHENSIVE_THEMING, JWT_AUTH, @@ -91,7 +90,7 @@ STATICFILES_DIRS += [ # If we don't add these settings, then Django templates that can't # find pipelined assets will raise a ValueError. # http://stackoverflow.com/questions/12816941/unit-testing-with-django-pipeline -STATICFILES_STORAGE = "pipeline.storage.NonPackagingPipelineStorage" +STORAGES['staticfiles']['BACKEND'] = "pipeline.storage.NonPackagingPipelineStorage" STATIC_URL = "/static/" # Update module store settings per defaults for tests diff --git a/cms/envs/test_static_optimized.py b/cms/envs/test_static_optimized.py index c92d9a7262..fa97d002db 100644 --- a/cms/envs/test_static_optimized.py +++ b/cms/envs/test_static_optimized.py @@ -28,6 +28,7 @@ DATABASES = { # Use RequireJS optimized storage STATICFILES_STORAGE = f"{OptimizedCachedRequireJsStorage.__module__}.{OptimizedCachedRequireJsStorage.__name__}" +STORAGES['staticfiles']['BACKEND'] = STATICFILES_STORAGE # Revert to the default set of finders as we don't want to dynamically pick up files from the pipeline STATICFILES_FINDERS = [ diff --git a/common/djangoapps/util/file.py b/common/djangoapps/util/file.py index b2892e6f42..36a471bab0 100644 --- a/common/djangoapps/util/file.py +++ b/common/djangoapps/util/file.py @@ -78,7 +78,7 @@ def store_uploaded_file( file_storage = DefaultStorage() # If a file already exists with the supplied name, file_storage will make the filename unique. stored_file_name = file_storage.save(stored_file_name, uploaded_file) - if is_private and settings.DEFAULT_FILE_STORAGE == 'storages.backends.s3boto3.S3Boto3Storage': + if is_private and settings.STORAGES["default"]["BACKEND"] == 'storages.backends.s3boto3.S3Boto3Storage': S3Boto3Storage().connection.meta.client.put_object_acl( ACL='private', Bucket=settings.AWS_STORAGE_BUCKET_NAME, diff --git a/lms/envs/common.py b/lms/envs/common.py index 3ace69ab06..9cc2f5888e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2356,7 +2356,6 @@ PIPELINE = { 'UGLIFYJS_BINARY': 'node_modules/.bin/uglifyjs', } -STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage' STATICFILES_STORAGE_KWARGS = {} # List of finder classes that know how to find static files in various locations. @@ -5204,7 +5203,14 @@ VIDEO_UPLOAD_PIPELINE = { } ############### Settings for django file storage ################## -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' +STORAGES = { + "default": { + "BACKEND": 'django.core.files.storage.FileSystemStorage' + }, + "staticfiles": { + "BACKEND": 'openedx.core.storage.ProductionStorage' + } +} ### Proctoring configuration (redirct URLs and keys shared between systems) #### PROCTORING_BACKENDS = { diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index e024e81edf..f712724be2 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -16,7 +16,7 @@ from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType from .production import * # pylint: disable=wildcard-import, unused-wildcard-import # Don't use S3 in devstack, fall back to filesystem -del DEFAULT_FILE_STORAGE +STORAGES['default']['BACKEND'] = 'django.core.files.storage.FileSystemStorage' ORA2_FILEUPLOAD_BACKEND = 'django' @@ -119,7 +119,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ########################### PIPELINE ################################# PIPELINE['PIPELINE_ENABLED'] = False -STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage' +STORAGES['staticfiles']['BACKEND'] = 'openedx.core.storage.DevelopmentStorage' # Revert to the default set of finders as we don't want the production pipeline STATICFILES_FINDERS = [ diff --git a/lms/envs/devstack_optimized.py b/lms/envs/devstack_optimized.py index 415e0e6c29..cfb6177f6c 100644 --- a/lms/envs/devstack_optimized.py +++ b/lms/envs/devstack_optimized.py @@ -34,7 +34,7 @@ DEBUG = True REQUIRE_DEBUG = False # Fetch static files out of the pipeline's static root -STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' +STORAGES['staticfiles']['BACKEND'] = 'pipeline.storage.PipelineManifestStorage' # Serve static files at /static directly from the staticfiles directory under test root. # Note: optimized files for testing are generated with settings from test_static_optimized diff --git a/lms/envs/openstack.py b/lms/envs/openstack.py index d19fdb9c44..33e2c733c0 100644 --- a/lms/envs/openstack.py +++ b/lms/envs/openstack.py @@ -23,10 +23,10 @@ if AUTH_TOKENS.get('SWIFT_REGION_NAME'): SWIFT_EXTRA_OPTIONS = {'region_name': AUTH_TOKENS['SWIFT_REGION_NAME']} if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'): - DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') + STORAGES["default"]["BACKEND"] = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') elif SWIFT_AUTH_URL and SWIFT_USERNAME and SWIFT_KEY: - DEFAULT_FILE_STORAGE = 'swift.storage.SwiftStorage' + STORAGES["default"]["BACKEND"] = 'swift.storage.SwiftStorage' else: - DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' + STORAGES["default"]["BACKEND"] = 'django.core.files.storage.FileSystemStorage' ORA2_FILEUPLOAD_BACKEND = "django" diff --git a/lms/envs/production.py b/lms/envs/production.py index 2587e38836..244c60ba44 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -341,7 +341,7 @@ AWS_BUCKET_ACL = AWS_DEFAULT_ACL # Change to S3Boto3 if we haven't specified another default storage AND we have specified AWS creds. if (not _YAML_TOKENS.get('DEFAULT_FILE_STORAGE')) and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: - DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + STORAGES["default"]["BACKEND"] = 'storages.backends.s3boto3.S3Boto3Storage' # The normal database user does not have enough permissions to run migrations. # Migrations are run with separate credentials, given as DB_MIGRATION_* diff --git a/lms/envs/static.py b/lms/envs/static.py index ab0bea3b48..dd93db2732 100644 --- a/lms/envs/static.py +++ b/lms/envs/static.py @@ -62,7 +62,7 @@ CACHES = { SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' ############################ FILE UPLOADS (for discussion forums) ############################# -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' +STORAGES["default"]["BACKEND"] = 'django.core.files.storage.FileSystemStorage' MEDIA_ROOT = ENV_ROOT / "uploads" MEDIA_URL = "/discussion/upfiles/" FILE_UPLOAD_TEMP_DIR = ENV_ROOT / "uploads" diff --git a/lms/envs/test.py b/lms/envs/test.py index 628195b902..c491dcbf89 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -145,12 +145,6 @@ STATICFILES_DIRS += [ if os.path.isdir(COMMON_TEST_DATA_ROOT / course_dir) ] -# Avoid having to run collectstatic before the unit test suite -# If we don't add these settings, then Django templates that can't -# find pipelined assets will raise a ValueError. -# http://stackoverflow.com/questions/12816941/unit-testing-with-django-pipeline -STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineStorage' - # Don't use compression during tests PIPELINE['JS_COMPRESSOR'] = None @@ -295,7 +289,14 @@ ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = OrderedDict([ ]) ############################ STATIC FILES ############################# -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' +STORAGES = { + "default": { + "BACKEND": 'django.core.files.storage.FileSystemStorage' + }, + "staticfiles": { + "BACKEND": 'pipeline.storage.NonPackagingPipelineStorage' + } +} MEDIA_ROOT = TEST_ROOT / "uploads" MEDIA_URL = "/uploads/" STATICFILES_DIRS.append(("uploads", MEDIA_ROOT)) diff --git a/lms/envs/test_static_optimized.py b/lms/envs/test_static_optimized.py index b57276b040..cf920de4e9 100644 --- a/lms/envs/test_static_optimized.py +++ b/lms/envs/test_static_optimized.py @@ -45,6 +45,7 @@ PROCTORING_BACKENDS = { # Use RequireJS optimized storage STATICFILES_STORAGE = f"{OptimizedCachedRequireJsStorage.__module__}.{OptimizedCachedRequireJsStorage.__name__}" +STORAGES['staticfiles']['BACKEND'] = STATICFILES_STORAGE # Revert to the default set of finders as we don't want to dynamically pick up files from the pipeline STATICFILES_FINDERS = [ From f4c3575bb06086dba62213b594b686d17ccb0aee Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Fri, 13 Jun 2025 12:57:47 -0400 Subject: [PATCH 14/18] Revert "fix(settings): replace DEFAULT_FILE_STORAGE with STORAGES[default]" (#36907) This reverts commit 39028b95006c1ec35ade4ee497d4d074c88902e8. --- cms/envs/common.py | 10 ++-------- cms/envs/devstack.py | 4 ++-- cms/envs/devstack_optimized.py | 2 +- cms/envs/openstack.py | 8 ++++---- cms/envs/production.py | 9 ++++----- cms/envs/test.py | 3 ++- cms/envs/test_static_optimized.py | 1 - common/djangoapps/util/file.py | 2 +- lms/envs/common.py | 10 ++-------- lms/envs/devstack.py | 4 ++-- lms/envs/devstack_optimized.py | 2 +- lms/envs/openstack.py | 6 +++--- lms/envs/production.py | 2 +- lms/envs/static.py | 2 +- lms/envs/test.py | 15 +++++++-------- lms/envs/test_static_optimized.py | 1 - 16 files changed, 33 insertions(+), 48 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index f6c65bc5ba..85af0063d4 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1301,6 +1301,7 @@ PIPELINE = { 'YUI_BINARY': 'yui-compressor', } +STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage' STATICFILES_STORAGE_KWARGS = {} # List of finder classes that know how to find static files in various locations. @@ -2552,14 +2553,7 @@ BULK_EMAIL_DEFAULT_FROM_EMAIL = 'no-reply@example.com' BULK_EMAIL_LOG_SENT_EMAILS = False ############### Settings for django file storage ################## -STORAGES = { - "default": { - "BACKEND": 'django.core.files.storage.FileSystemStorage' - }, - "staticfiles": { - "BACKEND": 'openedx.core.storage.ProductionStorage', - } -} +DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' ###################### Grade Downloads ###################### # These keys are used for all of our asynchronous downloadable files, including diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 9db580e412..d473a23290 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -9,7 +9,7 @@ from os.path import abspath, dirname, join from .production import * # pylint: disable=wildcard-import, unused-wildcard-import # Don't use S3 in devstack, fall back to filesystem -STORAGES['default']['BACKEND'] = 'django.core.files.storage.FileSystemStorage' +del DEFAULT_FILE_STORAGE COURSE_IMPORT_EXPORT_STORAGE = 'django.core.files.storage.FileSystemStorage' USER_TASKS_ARTIFACT_STORAGE = COURSE_IMPORT_EXPORT_STORAGE @@ -56,7 +56,7 @@ FEATURES['ENABLE_VIDEO_UPLOAD_PIPELINE'] = True # Skip packaging and optimization in development PIPELINE['PIPELINE_ENABLED'] = False -STORAGES['staticfiles']['BACKEND'] = 'openedx.core.storage.DevelopmentStorage' +STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage' # Revert to the default set of finders as we don't want the production pipeline STATICFILES_FINDERS = [ diff --git a/cms/envs/devstack_optimized.py b/cms/envs/devstack_optimized.py index 0aa615a729..b7f4234466 100644 --- a/cms/envs/devstack_optimized.py +++ b/cms/envs/devstack_optimized.py @@ -33,7 +33,7 @@ DEBUG = True REQUIRE_DEBUG = False # Fetch static files out of the pipeline's static root -STORAGES['staticfiles']['BACKEND'] = 'pipeline.storage.PipelineManifestStorage' +STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' # Serve static files at /static directly from the staticfiles directory under test root. # Note: optimized files for testing are generated with settings from test_static_optimized diff --git a/cms/envs/openstack.py b/cms/envs/openstack.py index 991762ccaf..8e35122c76 100644 --- a/cms/envs/openstack.py +++ b/cms/envs/openstack.py @@ -22,11 +22,11 @@ if AUTH_TOKENS.get('SWIFT_REGION_NAME'): SWIFT_EXTRA_OPTIONS = {'region_name': AUTH_TOKENS['SWIFT_REGION_NAME']} if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'): - STORAGES["default"]["BACKEND"] = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') + DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') elif SWIFT_AUTH_URL and SWIFT_USERNAME and SWIFT_KEY: - STORAGES["default"]["BACKEND"] = 'swift.storage.SwiftStorage' + DEFAULT_FILE_STORAGE = 'swift.storage.SwiftStorage' else: - STORAGES["default"]["BACKEND"] = 'django.core.files.storage.FileSystemStorage' + DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' # Use default file storage class set above for course import/export -COURSE_IMPORT_EXPORT_STORAGE = STORAGES["default"]["BACKEND"] +COURSE_IMPORT_EXPORT_STORAGE = DEFAULT_FILE_STORAGE diff --git a/cms/envs/production.py b/cms/envs/production.py index 34bd06c426..4ba05e1ab4 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -222,8 +222,7 @@ if 'staticfiles' in CACHES: # we need to run asset collection twice, once for local disk and once for S3. # Once we have migrated to service assets off S3, then we can convert this back to # managed by the yaml file contents -STORAGES['staticfiles']['BACKEND'] = os.environ.get( - 'STATICFILES_STORAGE', STORAGES['staticfiles']['BACKEND']) +STATICFILES_STORAGE = os.environ.get('STATICFILES_STORAGE', STATICFILES_STORAGE) CSRF_TRUSTED_ORIGINS = _YAML_TOKENS.get("CSRF_TRUSTED_ORIGINS", []) MKTG_URL_LINK_MAP.update(_YAML_TOKENS.get('MKTG_URL_LINK_MAP', {})) @@ -266,19 +265,19 @@ AWS_QUERYSTRING_EXPIRE = 7 * 24 * 60 * 60 # 7 days # Change to S3Boto3 if we haven't specified another default storage AND we have specified AWS creds. if (not _YAML_TOKENS.get('DEFAULT_FILE_STORAGE')) and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: - STORAGES["default"]["BACKEND"] = 'storages.backends.s3boto3.S3Boto3Storage' + DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' if COURSE_IMPORT_EXPORT_BUCKET: COURSE_IMPORT_EXPORT_STORAGE = 'cms.djangoapps.contentstore.storage.ImportExportS3Storage' else: - COURSE_IMPORT_EXPORT_STORAGE = STORAGES["default"]["BACKEND"] + COURSE_IMPORT_EXPORT_STORAGE = DEFAULT_FILE_STORAGE USER_TASKS_ARTIFACT_STORAGE = COURSE_IMPORT_EXPORT_STORAGE if COURSE_METADATA_EXPORT_BUCKET: COURSE_METADATA_EXPORT_STORAGE = 'cms.djangoapps.export_course_metadata.storage.CourseMetadataExportS3Storage' else: - COURSE_METADATA_EXPORT_STORAGE = STORAGES["default"]["BACKEND"] + COURSE_METADATA_EXPORT_STORAGE = DEFAULT_FILE_STORAGE # The normal database user does not have enough permissions to run migrations. # Migrations are run with separate credentials, given as DB_MIGRATION_* diff --git a/cms/envs/test.py b/cms/envs/test.py index 95935f9761..a536c52b7e 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -29,6 +29,7 @@ from .common import * from lms.envs.test import ( # pylint: disable=wrong-import-order, disable=unused-import ACCOUNT_MICROFRONTEND_URL, COMPREHENSIVE_THEME_DIRS, # unimport:skip + DEFAULT_FILE_STORAGE, ECOMMERCE_API_URL, ENABLE_COMPREHENSIVE_THEMING, JWT_AUTH, @@ -90,7 +91,7 @@ STATICFILES_DIRS += [ # If we don't add these settings, then Django templates that can't # find pipelined assets will raise a ValueError. # http://stackoverflow.com/questions/12816941/unit-testing-with-django-pipeline -STORAGES['staticfiles']['BACKEND'] = "pipeline.storage.NonPackagingPipelineStorage" +STATICFILES_STORAGE = "pipeline.storage.NonPackagingPipelineStorage" STATIC_URL = "/static/" # Update module store settings per defaults for tests diff --git a/cms/envs/test_static_optimized.py b/cms/envs/test_static_optimized.py index fa97d002db..c92d9a7262 100644 --- a/cms/envs/test_static_optimized.py +++ b/cms/envs/test_static_optimized.py @@ -28,7 +28,6 @@ DATABASES = { # Use RequireJS optimized storage STATICFILES_STORAGE = f"{OptimizedCachedRequireJsStorage.__module__}.{OptimizedCachedRequireJsStorage.__name__}" -STORAGES['staticfiles']['BACKEND'] = STATICFILES_STORAGE # Revert to the default set of finders as we don't want to dynamically pick up files from the pipeline STATICFILES_FINDERS = [ diff --git a/common/djangoapps/util/file.py b/common/djangoapps/util/file.py index 36a471bab0..b2892e6f42 100644 --- a/common/djangoapps/util/file.py +++ b/common/djangoapps/util/file.py @@ -78,7 +78,7 @@ def store_uploaded_file( file_storage = DefaultStorage() # If a file already exists with the supplied name, file_storage will make the filename unique. stored_file_name = file_storage.save(stored_file_name, uploaded_file) - if is_private and settings.STORAGES["default"]["BACKEND"] == 'storages.backends.s3boto3.S3Boto3Storage': + if is_private and settings.DEFAULT_FILE_STORAGE == 'storages.backends.s3boto3.S3Boto3Storage': S3Boto3Storage().connection.meta.client.put_object_acl( ACL='private', Bucket=settings.AWS_STORAGE_BUCKET_NAME, diff --git a/lms/envs/common.py b/lms/envs/common.py index 9cc2f5888e..3ace69ab06 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2356,6 +2356,7 @@ PIPELINE = { 'UGLIFYJS_BINARY': 'node_modules/.bin/uglifyjs', } +STATICFILES_STORAGE = 'openedx.core.storage.ProductionStorage' STATICFILES_STORAGE_KWARGS = {} # List of finder classes that know how to find static files in various locations. @@ -5203,14 +5204,7 @@ VIDEO_UPLOAD_PIPELINE = { } ############### Settings for django file storage ################## -STORAGES = { - "default": { - "BACKEND": 'django.core.files.storage.FileSystemStorage' - }, - "staticfiles": { - "BACKEND": 'openedx.core.storage.ProductionStorage' - } -} +DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' ### Proctoring configuration (redirct URLs and keys shared between systems) #### PROCTORING_BACKENDS = { diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index f712724be2..e024e81edf 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -16,7 +16,7 @@ from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType from .production import * # pylint: disable=wildcard-import, unused-wildcard-import # Don't use S3 in devstack, fall back to filesystem -STORAGES['default']['BACKEND'] = 'django.core.files.storage.FileSystemStorage' +del DEFAULT_FILE_STORAGE ORA2_FILEUPLOAD_BACKEND = 'django' @@ -119,7 +119,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ########################### PIPELINE ################################# PIPELINE['PIPELINE_ENABLED'] = False -STORAGES['staticfiles']['BACKEND'] = 'openedx.core.storage.DevelopmentStorage' +STATICFILES_STORAGE = 'openedx.core.storage.DevelopmentStorage' # Revert to the default set of finders as we don't want the production pipeline STATICFILES_FINDERS = [ diff --git a/lms/envs/devstack_optimized.py b/lms/envs/devstack_optimized.py index cfb6177f6c..415e0e6c29 100644 --- a/lms/envs/devstack_optimized.py +++ b/lms/envs/devstack_optimized.py @@ -34,7 +34,7 @@ DEBUG = True REQUIRE_DEBUG = False # Fetch static files out of the pipeline's static root -STORAGES['staticfiles']['BACKEND'] = 'pipeline.storage.PipelineManifestStorage' +STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' # Serve static files at /static directly from the staticfiles directory under test root. # Note: optimized files for testing are generated with settings from test_static_optimized diff --git a/lms/envs/openstack.py b/lms/envs/openstack.py index 33e2c733c0..d19fdb9c44 100644 --- a/lms/envs/openstack.py +++ b/lms/envs/openstack.py @@ -23,10 +23,10 @@ if AUTH_TOKENS.get('SWIFT_REGION_NAME'): SWIFT_EXTRA_OPTIONS = {'region_name': AUTH_TOKENS['SWIFT_REGION_NAME']} if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'): - STORAGES["default"]["BACKEND"] = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') + DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') elif SWIFT_AUTH_URL and SWIFT_USERNAME and SWIFT_KEY: - STORAGES["default"]["BACKEND"] = 'swift.storage.SwiftStorage' + DEFAULT_FILE_STORAGE = 'swift.storage.SwiftStorage' else: - STORAGES["default"]["BACKEND"] = 'django.core.files.storage.FileSystemStorage' + DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' ORA2_FILEUPLOAD_BACKEND = "django" diff --git a/lms/envs/production.py b/lms/envs/production.py index 244c60ba44..2587e38836 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -341,7 +341,7 @@ AWS_BUCKET_ACL = AWS_DEFAULT_ACL # Change to S3Boto3 if we haven't specified another default storage AND we have specified AWS creds. if (not _YAML_TOKENS.get('DEFAULT_FILE_STORAGE')) and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: - STORAGES["default"]["BACKEND"] = 'storages.backends.s3boto3.S3Boto3Storage' + DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' # The normal database user does not have enough permissions to run migrations. # Migrations are run with separate credentials, given as DB_MIGRATION_* diff --git a/lms/envs/static.py b/lms/envs/static.py index dd93db2732..ab0bea3b48 100644 --- a/lms/envs/static.py +++ b/lms/envs/static.py @@ -62,7 +62,7 @@ CACHES = { SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' ############################ FILE UPLOADS (for discussion forums) ############################# -STORAGES["default"]["BACKEND"] = 'django.core.files.storage.FileSystemStorage' +DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' MEDIA_ROOT = ENV_ROOT / "uploads" MEDIA_URL = "/discussion/upfiles/" FILE_UPLOAD_TEMP_DIR = ENV_ROOT / "uploads" diff --git a/lms/envs/test.py b/lms/envs/test.py index c491dcbf89..628195b902 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -145,6 +145,12 @@ STATICFILES_DIRS += [ if os.path.isdir(COMMON_TEST_DATA_ROOT / course_dir) ] +# Avoid having to run collectstatic before the unit test suite +# If we don't add these settings, then Django templates that can't +# find pipelined assets will raise a ValueError. +# http://stackoverflow.com/questions/12816941/unit-testing-with-django-pipeline +STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineStorage' + # Don't use compression during tests PIPELINE['JS_COMPRESSOR'] = None @@ -289,14 +295,7 @@ ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = OrderedDict([ ]) ############################ STATIC FILES ############################# -STORAGES = { - "default": { - "BACKEND": 'django.core.files.storage.FileSystemStorage' - }, - "staticfiles": { - "BACKEND": 'pipeline.storage.NonPackagingPipelineStorage' - } -} +DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' MEDIA_ROOT = TEST_ROOT / "uploads" MEDIA_URL = "/uploads/" STATICFILES_DIRS.append(("uploads", MEDIA_ROOT)) diff --git a/lms/envs/test_static_optimized.py b/lms/envs/test_static_optimized.py index cf920de4e9..b57276b040 100644 --- a/lms/envs/test_static_optimized.py +++ b/lms/envs/test_static_optimized.py @@ -45,7 +45,6 @@ PROCTORING_BACKENDS = { # Use RequireJS optimized storage STATICFILES_STORAGE = f"{OptimizedCachedRequireJsStorage.__module__}.{OptimizedCachedRequireJsStorage.__name__}" -STORAGES['staticfiles']['BACKEND'] = STATICFILES_STORAGE # Revert to the default set of finders as we don't want to dynamically pick up files from the pipeline STATICFILES_FINDERS = [ From 01d0e1d1a73d0c16f41ad8bb24dbadbae417a24e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:28:47 -0600 Subject: [PATCH 15/18] feat: Upgrade Python dependency edx-enterprise (#36920) * feat: Upgrade Python dependency edx-enterprise Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` * fix: typo fix to trigger tests --------- Co-authored-by: kiram15 <31229189+kiram15@users.noreply.github.com> Co-authored-by: Kira Miller --- docs/decisions/0005-studio-lms-subdomain-boundaries.rst | 2 +- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/decisions/0005-studio-lms-subdomain-boundaries.rst b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst index 1090e3f0bb..34aa774cd2 100644 --- a/docs/decisions/0005-studio-lms-subdomain-boundaries.rst +++ b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst @@ -74,7 +74,7 @@ of Content Groups to Cohorts. While this might sound a little cumbersome, it actually allows for a cleaner separation of concerns. Content Groups describe what the content is: restricted -copyright, advanced material, labratory exercises, etc. Cohorts describe who is +copyright, advanced material, laboratory exercises, etc. Cohorts describe who is consuming that material: on campus students, alumni, the general MOOC audience, etc. The Content Group is an Authoring decision based on the properties of the content itself. The Cohort mapping is a policy decision about the Learning diff --git a/requirements/constraints.txt b/requirements/constraints.txt index f24e706366..260ec2bb81 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -51,7 +51,7 @@ django-stubs<6 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==6.2.4 +edx-enterprise==6.2.5 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 4d6e2d4e1b..b33ae20909 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -461,7 +461,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.4 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 84941b0024..472e969eb0 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -740,7 +740,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.4 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 61f04185d8..dd850e55db 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -545,7 +545,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.4 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 11bfbbf2ab..9f13477279 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -570,7 +570,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.2.4 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 854d04dd33b6e995fd4ca62d4521fd0e541b6e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Mon, 16 Jun 2025 16:28:59 -0500 Subject: [PATCH 16/18] fix: disallow editing components in unit that come from libraries (#36914) --- cms/templates/studio_xblock_wrapper.html | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index 7ecf299722..f6022c6ac0 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -211,12 +211,6 @@ upstream_info = UpstreamLink.try_get_for_block(xblock, log_error=False) % endif - % elif not show_inline: -
  • - - ${_("Details")} - -
  • % endif % endif From 0c493b6ec2eeedb815fc280763c859b9b26106a6 Mon Sep 17 00:00:00 2001 From: Pandi Ganesh Date: Tue, 17 Jun 2025 08:50:00 +0000 Subject: [PATCH 17/18] feat: add Studio API for bulk enable/disable discussions for a course Implemented Studio API for bulk enable/disable discussions for a course. --- .../test_bulk_enabledisable_discussions.py | 117 ++++++++++++++++++ cms/djangoapps/contentstore/views/course.py | 58 ++++++++- cms/urls.py | 3 + 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py diff --git a/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py new file mode 100644 index 0000000000..e9ed344f9d --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py @@ -0,0 +1,117 @@ +""" +Test the enable/disable discussions for all units API endpoint. +""" +import json + +from django.urls import reverse +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory + +from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient +from common.djangoapps.student.tests.factories import UserFactory + + +class BulkEnableDisableDiscussionsTestCase(ModuleStoreTestCase): + """ + Test the enable/disable discussions for all units API endpoint. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory(is_staff=True, is_superuser=True) + self.user.set_password(self.user_password) + self.user.save() + + self.course_key = CourseKey.from_string("course-v1:edx+TestX+2025") + + self.url = reverse('bulk_enable_disable_discussions', args=[str(self.course_key)]) + self.client = AjaxEnabledTestClient() + self.client.login(username=self.user.username, password=self.user_password) + + # Create a test course + self.course = CourseFactory.create( + org=self.course_key.org, + course=self.course_key.course, + run=self.course_key.run, + default_store=ModuleStoreEnum.Type.split, + display_name="EnableDisableDiscussionsTestCase Course", + ) + with self.store.bulk_operations(self.course_key): + section = BlockFactory.create( + parent=self.course, + category='chapter', + display_name="Generated Section", + ) + sequence = BlockFactory.create( + parent=section, + category='sequential', + display_name="Generated Sequence", + ) + unit1 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section1", + discussion_enabled=True, + ) + unit2 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section2", + discussion_enabled=True, + ) + + def test_disable_discussions_for_all_units(self): + """ + Test that the API successfully disables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(False) + + def test_enable_discussions_for_all_units(self): + """ + Test that the API successfully enables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(True) + + def enable_disable_discussions_for_all_units(self, is_enabled): + """ + Test that the API successfully enables/disables discussions for all units. + """ + data = { + "discussion_enabled": is_enabled + } + response = self.client.put(self.url, data=json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 200) + response_data = response.json() + print(response_data) + self.assertEqual(response_data['updated_and_republished'], 0 if is_enabled else 2) + + # Check that all verticals now have discussion_enabled set to the expected value + with self.store.bulk_operations(self.course_key): + verticals = self.store.get_items(self.course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + self.assertEqual(vertical.discussion_enabled, is_enabled) + + def test_permission_denied_for_non_staff(self): + """ + Test that non-staff users are denied access to the API. + """ + # Create a non-staff user + non_staff_user = UserFactory(is_staff=False, is_superuser=False) + non_staff_user.set_password(self.user_password) + non_staff_user.save() + + # Create a new client for the non-staff user + non_staff_client = AjaxEnabledTestClient() + non_staff_client.login(username=non_staff_user.username, password=self.user_password) + + response = non_staff_client.put(self.url, content_type='application/json') + self.assertEqual(response.status_code, 403) + + def test_badrequest_for_empty_request_body(self): + """ + Test that the API returns a 400 for an empty request body. + """ + response = self.client.put(self.url, data=json.dumps({}), content_type='application/json') + self.assertEqual(response.status_code, 400) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 81ad1eb6dd..2a75710be7 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -135,7 +135,7 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing', 'course_notifications_handler', 'textbooks_list_handler', 'textbooks_detail_handler', 'group_configurations_list_handler', 'group_configurations_detail_handler', - 'get_course_and_check_access'] + 'get_course_and_check_access', 'bulk_enable_disable_discussions'] class AccessListFallback(Exception): @@ -1710,6 +1710,62 @@ def group_configurations_detail_handler(request, course_key_string, group_config ) +@login_required +@expect_json +@ensure_csrf_cookie +@require_http_methods(["PUT"]) +def bulk_enable_disable_discussions(request, course_key_string): + """ + API endpoint to enable/disable discussions for all verticals in the course and republish them. + + PUT + json: enable/disable discussions for all units and republish + """ + try: + # Validate the course key + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + return JsonResponseBadRequest({"error": "Invalid course key format"}) + + user = request.user + + # check that logged in user has permissions to update this course + if not has_studio_write_access(user, course_key): + raise PermissionDenied() + + if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'): + return JsonResponseBadRequest({"error": "Only supports json requests"}) + + if 'discussion_enabled' not in request.json: + return JsonResponseBadRequest({"error": "Missing 'discussion_enabled' field in request body"}) + discussion_enabled = request.json['discussion_enabled'] + log.info( + "User %s is attempting to %s discussions for all verticals in course %s", + user.username, + "enable" if discussion_enabled else "disable", + course_key + ) + + if request.method == 'PUT': + try: + store = modulestore() + changed = 0 + with store.bulk_operations(course_key): + verticals = store.get_items(course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + if vertical.discussion_enabled != discussion_enabled: + vertical.discussion_enabled = discussion_enabled + store.update_item(vertical, user.id) + + if store.has_published_version(vertical): + store.publish(vertical.location, user.id) + changed += 1 + return JsonResponse({"updated_and_republished": changed}) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + log.exception("Exception occurred while enabling/disabling discussion: %s", str(e)) + return JsonResponseBadRequest({"error": str(e)}) + + def are_content_experiments_enabled(course): """ Returns True if content experiments have been enabled for the course. diff --git a/cms/urls.py b/cms/urls.py index d01e89d9d2..f2c2c8b31a 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -201,6 +201,9 @@ urlpatterns = oauth2_urlpatterns + [ path('accessibility', contentstore_views.accessibility, name='accessibility'), re_path(fr'api/youtube/courses/{COURSELIKE_KEY_PATTERN}/edx-video-ids$', contentstore_views.get_course_youtube_edx_videos_ids, name='youtube_edx_video_ids'), + re_path(fr'^api/courses/{settings.COURSE_KEY_PATTERN}/bulk_enable_disable_discussions$', + contentstore_views.bulk_enable_disable_discussions, + name='bulk_enable_disable_discussions'), ] if not settings.DISABLE_DEPRECATED_SIGNIN_URL: From 447fd0b6cbbb5a517d3490fee36a1c901c7e8a4d Mon Sep 17 00:00:00 2001 From: Tim McCormack <59623490+timmc-edx@users.noreply.github.com> Date: Tue, 17 Jun 2025 09:33:52 -0400 Subject: [PATCH 18/18] feat: Upgrade to codejail 4.0.0 (#36916) This brings an important security improvement -- codejail won't default to running in unsafe mode, which can happen if certain configuration errors are present. Properly configured installations shouldn't be affected. We just need to adjust some unit tests to opt into unsafe mode. Changes: - Update `edx-codejail` dependency to [version 4.0.0](https://github.com/openedx/codejail/blob/master/CHANGELOG.rst#400---2025-06-13) - Define a `use_unsafe_codejail` decorator that allows running a unit test (or entire TestCase class) in unsafe mode - Use that decorator as needed, based on which tests started failing --- .../tests/test_submitting_problems.py | 2 ++ .../instructor_task/tests/test_integration.py | 2 ++ requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/kernel.in | 3 ++- requirements/edx/testing.txt | 2 +- .../capa/safe_exec/tests/test_safe_exec.py | 4 +++ xmodule/capa/tests/test_capa_problem.py | 3 +++ xmodule/capa/tests/test_html_render.py | 2 ++ xmodule/capa/tests/test_responsetypes.py | 10 +++++++ xmodule/capa/tests/test_util.py | 27 +++++++++++++++++++ xmodule/tests/test_capa_block.py | 2 ++ 13 files changed, 58 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 4d55645d7a..4d04204078 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -28,6 +28,7 @@ from xmodule.capa.tests.response_xml_factory import ( OptionResponseXMLFactory, SchematicResponseXMLFactory ) +from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.xqueue_interface import XQueueInterface from common.djangoapps.course_modes.models import CourseMode from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule @@ -810,6 +811,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems): self.assertEqual(list(kwargs['files'].keys()), filenames.split()) +@use_unsafe_codejail() class TestPythonGradedResponse(TestSubmittingProblems): """ Check that we can submit a schematic and custom response, and it answers properly. diff --git a/lms/djangoapps/instructor_task/tests/test_integration.py b/lms/djangoapps/instructor_task/tests/test_integration.py index 004cba1cda..267f8021cd 100644 --- a/lms/djangoapps/instructor_task/tests/test_integration.py +++ b/lms/djangoapps/instructor_task/tests/test_integration.py @@ -22,6 +22,7 @@ from django.urls import reverse from xmodule.capa.responsetypes import StudentInputError from xmodule.capa.tests.response_xml_factory import CodeResponseXMLFactory, CustomResponseXMLFactory +from xmodule.capa.tests.test_util import use_unsafe_codejail from lms.djangoapps.courseware.model_data import StudentModule from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.instructor_task.api import ( @@ -71,6 +72,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase): @ddt.ddt @override_settings(RATELIMIT_ENABLE=False) +@use_unsafe_codejail() class TestRescoringTask(TestIntegrationTask): """ Integration-style tests for rescoring problems in a background task. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b33ae20909..c1809ae42d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -421,7 +421,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/kernel.in # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via -r requirements/edx/kernel.in edx-completion==4.9 # via -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 472e969eb0..b928da2b66 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -691,7 +691,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/testing.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index dd850e55db..b5413f24f4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -505,7 +505,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/base.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via -r requirements/edx/base.txt edx-completion==4.9 # via -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index caec5c8c04..fcc502755c 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -66,7 +66,8 @@ edx-celeryutils edx-completion edx-django-release-util # Release utils for the edx release pipeline edx-django-sites-extensions -edx-codejail +# Codejail 4 brings important safety improvements (no unsafe mode by default) +edx-codejail>=4.0.0 # edx-django-utils 5.14.1 adds FrontendMonitoringMiddleware edx-django-utils>=5.14.1 # Utilities for cache, monitoring, and plugins edx-drf-extensions diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 9f13477279..8e0301ac7e 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -530,7 +530,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/base.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via -r requirements/edx/base.txt edx-completion==4.9 # via -r requirements/edx/base.txt diff --git a/xmodule/capa/safe_exec/tests/test_safe_exec.py b/xmodule/capa/safe_exec/tests/test_safe_exec.py index d09f8c9d9b..d7679b66aa 100644 --- a/xmodule/capa/safe_exec/tests/test_safe_exec.py +++ b/xmodule/capa/safe_exec/tests/test_safe_exec.py @@ -24,8 +24,10 @@ from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa.safe_exec import safe_exec, update_hash from xmodule.capa.safe_exec.remote_exec import is_codejail_in_darklaunch, is_codejail_rest_service_enabled from xmodule.capa.safe_exec.safe_exec import emsg_normalizers, normalize_error_message +from xmodule.capa.tests.test_util import use_unsafe_codejail +@use_unsafe_codejail() class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring def test_set_values(self): g = {} @@ -530,6 +532,7 @@ class DictCache(object): self.cache[key] = value +@use_unsafe_codejail() class TestSafeExecCaching(unittest.TestCase): """Test that caching works on safe_exec.""" @@ -654,6 +657,7 @@ class TestUpdateHash(unittest.TestCase): assert h1 == h2 +@use_unsafe_codejail() class TestRealProblems(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring def test_802x(self): code = textwrap.dedent("""\ diff --git a/xmodule/capa/tests/test_capa_problem.py b/xmodule/capa/tests/test_capa_problem.py index 74cf4d096f..5f42b91849 100644 --- a/xmodule/capa/tests/test_capa_problem.py +++ b/xmodule/capa/tests/test_capa_problem.py @@ -15,6 +15,7 @@ from markupsafe import Markup from xmodule.capa.correctmap import CorrectMap from xmodule.capa.responsetypes import LoncapaProblemError from xmodule.capa.tests.helpers import new_loncapa_problem +from xmodule.capa.tests.test_util import use_unsafe_codejail from openedx.core.djangolib.markup import HTML @@ -23,6 +24,7 @@ FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS['ENABLE_GRADING_METHOD_IN_PROBLEMS'] = @ddt.ddt +@use_unsafe_codejail() class CAPAProblemTest(unittest.TestCase): """ CAPA problem related tests""" @@ -424,6 +426,7 @@ class CAPAProblemTest(unittest.TestCase): @ddt.ddt +@use_unsafe_codejail() class CAPAMultiInputProblemTest(unittest.TestCase): """ TestCase for CAPA problems with multiple inputtypes """ diff --git a/xmodule/capa/tests/test_html_render.py b/xmodule/capa/tests/test_html_render.py index 0af5f1198e..46ad47d79a 100644 --- a/xmodule/capa/tests/test_html_render.py +++ b/xmodule/capa/tests/test_html_render.py @@ -11,12 +11,14 @@ from unittest import mock import ddt from lxml import etree from xmodule.capa.tests.helpers import new_loncapa_problem, mock_capa_system +from xmodule.capa.tests.test_util import use_unsafe_codejail from openedx.core.djangolib.markup import HTML from .response_xml_factory import CustomResponseXMLFactory, StringResponseXMLFactory @ddt.ddt +@use_unsafe_codejail() class CapaHtmlRenderTest(unittest.TestCase): """ CAPA HTML rendering tests class. diff --git a/xmodule/capa/tests/test_responsetypes.py b/xmodule/capa/tests/test_responsetypes.py index fa0c97fb15..ca9f5eba59 100644 --- a/xmodule/capa/tests/test_responsetypes.py +++ b/xmodule/capa/tests/test_responsetypes.py @@ -37,6 +37,7 @@ from xmodule.capa.tests.response_xml_factory import ( SymbolicResponseXMLFactory, TrueFalseResponseXMLFactory ) +from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.util import convert_files_to_filenames from xmodule.capa.xqueue_interface import dateformat @@ -108,6 +109,7 @@ class ResponseTest(unittest.TestCase): return str(rand.randint(0, 1e9)) +@use_unsafe_codejail() class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = MultipleChoiceResponseXMLFactory @@ -375,6 +377,7 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst assert correct_map.get_correctness('1_2_1') == expected_correctness +@use_unsafe_codejail() class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = OptionResponseXMLFactory @@ -422,6 +425,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri assert correct_map.get_property('1_2_1', 'answervariable') == '$a' +@use_unsafe_codejail() class FormulaResponseTest(ResponseTest): """ Test the FormulaResponse class @@ -571,6 +575,7 @@ class FormulaResponseTest(ResponseTest): assert not list(problem.responders.values())[0].validate_answer('3*y+2*x') +@use_unsafe_codejail() class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = StringResponseXMLFactory @@ -1124,6 +1129,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring assert output[answer_id]['msg'] == 'Invalid grader reply. Please contact the course staff.' +@use_unsafe_codejail() class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = ChoiceResponseXMLFactory @@ -1292,6 +1298,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri self.assert_grade(problem, ['choice_1', 'choice_3'], 'incorrect') +@use_unsafe_codejail() class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = NumericalResponseXMLFactory @@ -1680,6 +1687,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs assert not responder.validate_answer('fish') +@use_unsafe_codejail() class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = CustomResponseXMLFactory @@ -2399,6 +2407,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri assert correct_map.get_msg('1_2_11') == '11' +@use_unsafe_codejail() class SchematicResponseTest(ResponseTest): """ Class containing setup and tests for Schematic responsetype. @@ -2488,6 +2497,7 @@ class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=mis assert expected_points == actual_points, ('%s should have %d points' % (answer_id, expected_points)) +@use_unsafe_codejail() class ChoiceTextResponseTest(ResponseTest): """ Class containing setup and tests for ChoiceText responsetype. diff --git a/xmodule/capa/tests/test_util.py b/xmodule/capa/tests/test_util.py index 92bc039cfa..3176ff9b9a 100644 --- a/xmodule/capa/tests/test_util.py +++ b/xmodule/capa/tests/test_util.py @@ -6,7 +6,9 @@ Tests capa util import unittest +import codejail.safe_exec import ddt +from django.test.utils import TestContextDecorator from lxml import etree from xmodule.capa.tests.helpers import mock_capa_system @@ -167,3 +169,28 @@ class UtilTest(unittest.TestCase): expected_text = '$あなたあなたあなたあなた あなたhi' contextual_text = contextualize_text(text, context) assert expected_text == contextual_text + + +class use_unsafe_codejail(TestContextDecorator): + """ + Tell codejail to run in unsafe mode for the scope of the decorator. + Use this as a decorator on Django TestCase classes or methods. + + This is needed because codejail has significant OS-level setup requirements + which we don't even attempt to fulfill for unit testing purposes. Running + tests in unsafe mode (that is, running code executions in-process, with no + sandboxing) is only safe because we control the contents of the unit tests. + It's not a perfect replica of how safe mode operates but it's generally good + enough for testing the integration and overall behavior. + """ + + def __init__(self): + self.old_be_unsafe = None + super().__init__() + + def enable(self): + self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE + codejail.safe_exec.ALWAYS_BE_UNSAFE = True + + def disable(self): + codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index b48aa10ef8..21582e55ea 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -37,6 +37,7 @@ from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, Stude from xmodule.capa.xqueue_interface import XQueueInterface from xmodule.capa_block import ComplexEncoder, ProblemBlock from xmodule.tests import DATA_DIR +from xmodule.capa.tests.test_util import use_unsafe_codejail from ..capa_block import RANDOMIZATION, SHOWANSWER from . import get_test_system @@ -3635,6 +3636,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi @skip_unless_lms +@use_unsafe_codejail() class ProblemCheckTrackingTest(unittest.TestCase): """ Ensure correct tracking information is included in events emitted during problem checks.