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