Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions lms/djangoapps/courseware/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions lms/djangoapps/courseware/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions lms/djangoapps/mfe_config_api/api.py
Original file line number Diff line number Diff line change
@@ -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"),
}
12 changes: 6 additions & 6 deletions lms/djangoapps/mfe_config_api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
47 changes: 2 additions & 45 deletions lms/djangoapps/mfe_config_api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"],
}
13 changes: 9 additions & 4 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand All @@ -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',
},
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion lms/templates/courseware/courseware-chromeless.html
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<link rel="stylesheet" href="${css_url}" type="text/css">
% endfor
## LMS CSS used to hide dialogs marked as aria-hidden; Paragon doesn't, so we add it back explicitly.
<style>[role="dialog"][aria-hidden="true"] { display: none !important; }</style>
%endif
## Utility: Notes
% if edx_notes_enabled:
<%static:css group='style-student-notes'/>
Expand Down
Loading
Loading