diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 2c3ece3133a5..5415502af556 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -2609,6 +2609,93 @@ def test_access(self, is_waffle_enabled, is_public_video, expected_status_code): self.assertEqual(expected_status_code, embed_response.status_code) +class TestGetParagonCssUrls(TestCase): + """ + Unit tests for the _get_paragon_css_urls() helper in courseware views. + + The helper has two code paths: + - Local: MFE_CONFIG_API_URL is None or relative → calls get_mfe_config() directly (no HTTP). + - Remote: MFE_CONFIG_API_URL is an absolute URL → calls requests.get(). + In both cases it builds a list of CSS URLs from PARAGON_THEME_URLS, falling back to + jsDelivr CDN URLs when brand overrides are not configured. + """ + + CDN_CORE = 'https://cdn.jsdelivr.net/npm/@openedx/paragon@23/dist/core.min.css' + CDN_LIGHT = 'https://cdn.jsdelivr.net/npm/@openedx/paragon@23/dist/light.min.css' + BRAND_CORE_URL = 'https://wgu-static.example.com/core.css' + BRAND_LIGHT_URL = 'https://wgu-static.example.com/light.css' + REMOTE_API_URL = 'https://remote.example.com/api/mfe_config/v1' + + PARAGON_THEME_URLS = { + 'core': {'urls': {'brandOverride': BRAND_CORE_URL}}, + 'default': {'light': 'light'}, + 'variants': { + 'light': {'urls': {'brandOverride': BRAND_LIGHT_URL}}, + }, + } + + @override_settings(MFE_CONFIG_API_URL='/api/mfe_config/v1') + @patch('lms.djangoapps.courseware.views.views.get_mfe_config') + def test_local_path_no_brand_overrides(self, mock_get_mfe_config): + """Local path, no PARAGON_THEME_URLS: returns only CDN fallback URLs.""" + mock_get_mfe_config.return_value = {} + + result = views._get_paragon_css_urls() + + mock_get_mfe_config.assert_called_once_with(mfe='learning') + self.assertEqual(result, [self.CDN_LIGHT, self.CDN_CORE]) + + @override_settings(MFE_CONFIG_API_URL='/api/mfe_config/v1') + @patch('lms.djangoapps.courseware.views.views.get_mfe_config') + def test_local_path_with_brand_overrides(self, mock_get_mfe_config): + """Local path, PARAGON_THEME_URLS configured: returns CDN + brand override URLs.""" + mock_get_mfe_config.return_value = {'PARAGON_THEME_URLS': self.PARAGON_THEME_URLS} + + result = views._get_paragon_css_urls() + + mock_get_mfe_config.assert_called_once_with(mfe='learning') + self.assertEqual(result, [ + self.CDN_LIGHT, self.BRAND_LIGHT_URL, + self.CDN_CORE, self.BRAND_CORE_URL, + ]) + + @override_settings(MFE_CONFIG_API_URL=None) + @patch('lms.djangoapps.courseware.views.views.get_mfe_config') + def test_none_url_uses_local_path(self, mock_get_mfe_config): + """When MFE_CONFIG_API_URL is None, falls back to direct Python call (no HTTP).""" + mock_get_mfe_config.return_value = {} + + result = views._get_paragon_css_urls() + + mock_get_mfe_config.assert_called_once_with(mfe='learning') + self.assertEqual(result, [self.CDN_LIGHT, self.CDN_CORE]) + + @override_settings(MFE_CONFIG_API_URL=REMOTE_API_URL) + @patch('lms.djangoapps.courseware.views.views.requests.get') + def test_remote_path_no_brand_overrides(self, mock_requests_get): + """Remote path (absolute URL), no PARAGON_THEME_URLS: returns only CDN fallback URLs.""" + mock_requests_get.return_value.json.return_value = {} + + result = views._get_paragon_css_urls() + + mock_requests_get.assert_called_once_with(f'{self.REMOTE_API_URL}?mfe=learning') + self.assertEqual(result, [self.CDN_LIGHT, self.CDN_CORE]) + + @override_settings(MFE_CONFIG_API_URL=REMOTE_API_URL) + @patch('lms.djangoapps.courseware.views.views.requests.get') + def test_remote_path_with_brand_overrides(self, mock_requests_get): + """Remote path (absolute URL): calls requests.get and returns CDN + brand URLs.""" + mock_requests_get.return_value.json.return_value = {'PARAGON_THEME_URLS': self.PARAGON_THEME_URLS} + + result = views._get_paragon_css_urls() + + mock_requests_get.assert_called_once_with(f'{self.REMOTE_API_URL}?mfe=learning') + self.assertEqual(result, [ + self.CDN_LIGHT, self.BRAND_LIGHT_URL, + self.CDN_CORE, self.BRAND_CORE_URL, + ]) + + class TestRenderXBlockSelfPaced(TestRenderXBlock): # lint-amnesty, pylint: disable=test-inherits-tests """ Test rendering XBlocks for a self-paced course. Relies on the query diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 2c89800d82b6..3b93f49d2995 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -118,6 +118,7 @@ from lms.djangoapps.instructor.views.api import require_global_staff from lms.djangoapps.survey import views as survey_views from lms.djangoapps.verify_student.services import IDVerificationService +from lms.djangoapps.mfe_config_api.api import get_mfe_config from openedx.core.djangoapps.catalog.utils import ( get_course_data, get_course_uuid_for_course, @@ -1724,6 +1725,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta 'is_learning_mfe': is_learning_mfe, 'is_mobile_app': is_mobile_app, 'render_course_wide_assets': True, + 'paragon_css_urls': _get_paragon_css_urls(), } try: @@ -1753,6 +1755,39 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta return render_to_response('courseware/courseware-chromeless.html', context, request=request) +def _get_paragon_css_urls(): + """ + Gets the paragon themes + + Calls the MFE Config API. If the API lives in the current + server (relative path), calls the Python Api directly. If + the server lives in a remote server, makes a request to + the API. + """ + + api_url = getattr(settings, 'MFE_CONFIG_API_URL', None) + + if api_url is None or api_url.startswith('/'): + mfe_config = get_mfe_config(mfe='learning') + else: + response = requests.get(f"{api_url}?mfe=learning") + mfe_config = response.json() + + theme_urls = mfe_config.get('PARAGON_THEME_URLS', {}) + + variant = theme_urls.get('default', {}).get('light') + theme = [url for url in [ + 'https://cdn.jsdelivr.net/npm/@openedx/paragon@23/dist/light.min.css', + theme_urls.get('variants', {}).get(variant, {}).get('urls', {}).get('brandOverride'), + ] if url] + core = [url for url in [ + 'https://cdn.jsdelivr.net/npm/@openedx/paragon@23/dist/core.min.css', + theme_urls.get('core', {}).get('urls', {}).get('brandOverride'), + ] if url] + + return theme + core + + def get_optimization_flags_for_content(block, fragment): """ Return a dict with a set of display options appropriate for the block. @@ -1907,6 +1942,7 @@ def get_template_and_context(self, course, video_block): 'is_learning_mfe': True, 'is_mobile_app': False, 'is_enrolled_in_course': self.get_is_enrolled_in_course(course), + 'paragon_css_urls': _get_paragon_css_urls(), } return 'public_video.html', context diff --git a/lms/djangoapps/mfe_config_api/api.py b/lms/djangoapps/mfe_config_api/api.py new file mode 100644 index 000000000000..25680c538e54 --- /dev/null +++ b/lms/djangoapps/mfe_config_api/api.py @@ -0,0 +1,64 @@ +""" +MFE API funtions. +""" + +from django.conf import settings + +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + + +# The public API is only the following symbols: +__all__ = [ + # API methods + "get_mfe_config", +] + + +def get_mfe_config(mfe=None): + """ + Return the merged MFE configuration for the given MFE app. + """ + # Get values from django settings (level 6) or site configuration (level 5) + legacy_config = _get_legacy_config() + + # Get values from mfe configuration, either from django settings (level 4) or site configuration (level 3) + mfe_config = configuration_helpers.get_value("MFE_CONFIG", settings.MFE_CONFIG) + + # Get values from mfe overrides, either from django settings (level 2) or site configuration (level 1) + mfe_config_overrides = {} + + if mfe: + app_config = configuration_helpers.get_value( + "MFE_CONFIG_OVERRIDES", + settings.MFE_CONFIG_OVERRIDES, + ) + mfe_config_overrides = app_config.get(mfe, {}) + + # Merge the three configs in the order of precedence + return legacy_config | mfe_config | mfe_config_overrides + + +def _get_legacy_config() -> dict: + """ + Return legacy configuration values available in either site configuration or django settings. + """ + return { + "ENABLE_COURSE_SORTING_BY_START_DATE": configuration_helpers.get_value( + "ENABLE_COURSE_SORTING_BY_START_DATE", + settings.FEATURES.get("ENABLE_COURSE_SORTING_BY_START_DATE") + ), + "HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID": configuration_helpers.get_value( + "homepage_promo_video_youtube_id", + None + ), + "HOMEPAGE_COURSE_MAX": configuration_helpers.get_value( + "HOMEPAGE_COURSE_MAX", + getattr(settings, 'HOMEPAGE_COURSE_MAX', None), + ), + "COURSE_ABOUT_TWITTER_ACCOUNT": configuration_helpers.get_value( + "course_about_twitter_account", + getattr(settings, 'PLATFORM_TWITTER_ACCOUNT', None), + ), + "NON_BROWSABLE_COURSES": not settings.FEATURES.get("COURSES_ARE_BROWSABLE"), + "ENABLE_COURSE_DISCOVERY": settings.FEATURES.get("ENABLE_COURSE_DISCOVERY"), + } diff --git a/lms/djangoapps/mfe_config_api/tests/test_views.py b/lms/djangoapps/mfe_config_api/tests/test_views.py index fcf1f1ad29e8..141e257ef44e 100644 --- a/lms/djangoapps/mfe_config_api/tests/test_views.py +++ b/lms/djangoapps/mfe_config_api/tests/test_views.py @@ -32,7 +32,7 @@ def setUp(self): self.mfe_config_api_url = reverse("mfe_config_api:config") return super().setUp() - @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + @patch("lms.djangoapps.mfe_config_api.api.configuration_helpers") def test_get_mfe_config(self, configuration_helpers_mock): """Test the get mfe config from site configuration with the mfe api. @@ -52,7 +52,7 @@ def side_effect(key, default=None): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), {**default_legacy_config, "EXAMPLE_VAR": "value"}) - @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + @patch("lms.djangoapps.mfe_config_api.api.configuration_helpers") def test_get_mfe_config_with_queryparam(self, configuration_helpers_mock): """Test the get mfe config with a query param from site configuration. @@ -120,7 +120,7 @@ def side_effect(key, default=None): expected_response={**default_legacy_config, "EXAMPLE_VAR": "mymfe_value"}, ), ) - @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + @patch("lms.djangoapps.mfe_config_api.api.configuration_helpers") def test_get_mfe_config_with_queryparam_multiple_configs( self, configuration_helpers_mock, @@ -175,7 +175,7 @@ def test_get_mfe_config_with_queryparam_from_django_settings(self): expected = default_legacy_config | settings.MFE_CONFIG | settings.MFE_CONFIG_OVERRIDES["mymfe"] self.assertEqual(response.json(), expected) - @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + @patch("lms.djangoapps.mfe_config_api.api.configuration_helpers") @override_settings(ENABLE_MFE_CONFIG_API=False) def test_404_get_mfe_config(self, configuration_helpers_mock): """Test the 404 not found response from get mfe config. @@ -188,7 +188,7 @@ def test_404_get_mfe_config(self, configuration_helpers_mock): configuration_helpers_mock.get_value.assert_not_called() self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + @patch("lms.djangoapps.mfe_config_api.api.configuration_helpers") def test_get_mfe_config_for_catalog(self, configuration_helpers_mock): """Test the mfe config by explicitly using catalog mfe as an example. @@ -232,7 +232,7 @@ def side_effect(key, default=None): self.assertEqual(data["NON_BROWSABLE_COURSES"], True) self.assertEqual(data["ENABLE_COURSE_DISCOVERY"], False) - @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + @patch("lms.djangoapps.mfe_config_api.api.configuration_helpers") def test_config_order_of_precedence(self, configuration_helpers_mock): """Test the precedence of configuration values by explicitly using catalog MFE as an example. diff --git a/lms/djangoapps/mfe_config_api/views.py b/lms/djangoapps/mfe_config_api/views.py index 0ab71b151b88..df112fe244b6 100644 --- a/lms/djangoapps/mfe_config_api/views.py +++ b/lms/djangoapps/mfe_config_api/views.py @@ -10,7 +10,7 @@ from rest_framework import status from rest_framework.views import APIView -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from .api import get_mfe_config class MFEConfigView(APIView): @@ -72,49 +72,6 @@ def get(self, request): if not settings.ENABLE_MFE_CONFIG_API: return HttpResponseNotFound() - # Get values from django settings (level 6) or site configuration (level 5) - legacy_config = self._get_legacy_config() - - # Get values from mfe configuration, either from django settings (level 4) or site configuration (level 3) - mfe_config = configuration_helpers.get_value("MFE_CONFIG", settings.MFE_CONFIG) - - # Get values from mfe overrides, either from django settings (level 2) or site configuration (level 1) - mfe_config_overrides = {} - if request.query_params.get("mfe"): - mfe = str(request.query_params.get("mfe")) - app_config = configuration_helpers.get_value( - "MFE_CONFIG_OVERRIDES", - settings.MFE_CONFIG_OVERRIDES, - ) - mfe_config_overrides = app_config.get(mfe, {}) - - # Merge the three configs in the order of precedence - merged_config = legacy_config | mfe_config | mfe_config_overrides + merged_config = get_mfe_config(request.query_params.get("mfe")) return JsonResponse(merged_config, status=status.HTTP_200_OK) - - @staticmethod - def _get_legacy_config() -> dict: - """ - Return legacy configuration values available in either site configuration or django settings. - """ - return { - "ENABLE_COURSE_SORTING_BY_START_DATE": configuration_helpers.get_value( - "ENABLE_COURSE_SORTING_BY_START_DATE", - settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"] - ), - "HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID": configuration_helpers.get_value( - "homepage_promo_video_youtube_id", - None - ), - "HOMEPAGE_COURSE_MAX": configuration_helpers.get_value( - "HOMEPAGE_COURSE_MAX", - settings.HOMEPAGE_COURSE_MAX - ), - "COURSE_ABOUT_TWITTER_ACCOUNT": configuration_helpers.get_value( - "course_about_twitter_account", - settings.PLATFORM_TWITTER_ACCOUNT - ), - "NON_BROWSABLE_COURSES": not settings.FEATURES.get("COURSES_ARE_BROWSABLE"), - "ENABLE_COURSE_DISCOVERY": settings.FEATURES["ENABLE_COURSE_DISCOVERY"], - } diff --git a/lms/envs/common.py b/lms/envs/common.py index 06ba0c050aea..0989d573f96c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1680,13 +1680,13 @@ }, 'style-main-v1': { 'source_filenames': [ - 'css/lms-main-v1.css', + # 'css/lms-main-v1.css', ], 'output_filename': 'css/lms-main-v1.css', }, 'style-main-v1-rtl': { 'source_filenames': [ - 'css/lms-main-v1-rtl.css', + # 'css/lms-main-v1-rtl.css', ], 'output_filename': 'css/lms-main-v1-rtl.css', }, @@ -1700,13 +1700,13 @@ }, 'style-course': { 'source_filenames': [ - 'css/lms-course.css', + # 'css/lms-course.css', ], 'output_filename': 'css/lms-course.css', }, 'style-course-rtl': { 'source_filenames': [ - 'css/lms-course-rtl.css', + # 'css/lms-course-rtl.css', ], 'output_filename': 'css/lms-course-rtl.css', }, @@ -3463,6 +3463,11 @@ # .. setting_creation_date: 2022-08-05 MFE_CONFIG_OVERRIDES = {} +# .. setting_name: MFE_CONFIG_API_URL +# .. setting_default: '/api/mfe_config/v1' +# .. setting_description: The URL to get the MFE Config +MFE_CONFIG_API_URL = '/api/mfe_config/v1' + # .. setting_name: MFE_CONFIG_API_CACHE_TIMEOUT # .. setting_default: 60*5 # .. setting_description: The MFE Config API response will be cached during the diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html index deeda26c431d..13311e02fe47 100644 --- a/lms/templates/courseware/courseware-chromeless.html +++ b/lms/templates/courseware/courseware-chromeless.html @@ -33,7 +33,13 @@ <%block name="headextra"> <%static:css group='style-course-vendor'/> -<%static:css group='style-course'/> +% if paragon_css_urls: + % for css_url in paragon_css_urls: + + % endfor + ## LMS CSS used to hide dialogs marked as aria-hidden; Paragon doesn't, so we add it back explicitly. + +%endif ## Utility: Notes % if edx_notes_enabled: <%static:css group='style-student-notes'/> diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 33275894910d..47d24e866324 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -1577,7 +1577,7 @@ def _make_locale_paths(settings): SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT = {} # .. setting_name: SAML_METADATA_URL_ALLOW_PRIVATE_IPS -# .. setting_default: False +# .. setting_default: false # .. setting_description: When False (the default), fetching SAML metadata from # private IP address ranges (RFC 1918: 10.x, 172.16.x, 192.168.x) is blocked # as a defense against SSRF attacks. Set to True only in deployments where the