diff --git a/lms/djangoapps/mfe_config_api/tests/test_views.py b/lms/djangoapps/mfe_config_api/tests/test_views.py index 0dfc63e82790..10985f8115e9 100644 --- a/lms/djangoapps/mfe_config_api/tests/test_views.py +++ b/lms/djangoapps/mfe_config_api/tests/test_views.py @@ -7,11 +7,13 @@ import ddt from django.core.cache import cache from django.conf import settings -from django.test import override_settings +from django.test import SimpleTestCase, override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase +from lms.djangoapps.mfe_config_api.views import mfe_name_to_app_id + # Default legacy configuration values, used in tests to build a correct expected response default_legacy_config = { "COURSE_ABOUT_TWITTER_ACCOUNT": "@YourPlatformTwitterAccount", @@ -294,3 +296,422 @@ def side_effect(key, default=None): # Value in original MFE_CONFIG not overridden by catalog config should be preserved self.assertEqual(data["PRESERVED_SETTING"], "preserved") + + +class MfeNameToAppIdTests(SimpleTestCase): + """Tests for the mfe_name_to_app_id helper.""" + + def test_simple_name(self): + self.assertEqual(mfe_name_to_app_id("authn"), "org.openedx.frontend.app.authn") + + def test_kebab_case_name(self): + self.assertEqual( + mfe_name_to_app_id("learner-dashboard"), + "org.openedx.frontend.app.learnerDashboard", + ) + + def test_mapped_alias(self): + """course-authoring is an alias for authoring in the explicit map.""" + self.assertEqual( + mfe_name_to_app_id("course-authoring"), + "org.openedx.frontend.app.authoring", + ) + + def test_fallback_for_unknown_name(self): + """Unknown names fall back to programmatic kebab-to-camelCase conversion.""" + self.assertEqual( + mfe_name_to_app_id("admin-portal-enterprise"), + "org.openedx.frontend.app.adminPortalEnterprise", + ) + + +class FrontendSiteConfigTestCase(APITestCase): + """Tests for the FrontendSiteConfigView endpoint.""" + + def setUp(self): + self.url = reverse("frontend_site_config:frontend_site_config") + cache.clear() + return super().setUp() + + @override_settings(ENABLE_MFE_CONFIG_API=False) + def test_404_when_disabled(self): + """API returns 404 when ENABLE_MFE_CONFIG_API is False.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_site_level_keys_translated(self, configuration_helpers_mock): + """Keys that map to RequiredSiteConfig/OptionalSiteConfig appear at the top level in camelCase.""" + mfe_config = { + "SITE_NAME": "Test Site", + "BASE_URL": "https://apps.example.com", + "LMS_BASE_URL": "https://courses.example.com", + "LOGIN_URL": "https://courses.example.com/login", + "LOGOUT_URL": "https://courses.example.com/logout", + "LOGO_URL": "https://courses.example.com/logo.png", + "ACCESS_TOKEN_COOKIE_NAME": "edx-jwt", + "LANGUAGE_PREFERENCE_COOKIE_NAME": "lang-pref", + "USER_INFO_COOKIE_NAME": "edx-user-info", + "CSRF_TOKEN_API_PATH": "/csrf/api/v1/token", + "REFRESH_ACCESS_TOKEN_API_PATH": "/login_refresh", + "SEGMENT_KEY": "abc123", + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return mfe_config + if key == "MFE_CONFIG_OVERRIDES": + return {} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + # RequiredSiteConfig + self.assertEqual(data["siteName"], "Test Site") + self.assertEqual(data["baseUrl"], "https://apps.example.com") + self.assertEqual(data["lmsBaseUrl"], "https://courses.example.com") + self.assertEqual(data["loginUrl"], "https://courses.example.com/login") + self.assertEqual(data["logoutUrl"], "https://courses.example.com/logout") + # OptionalSiteConfig + self.assertEqual(data["headerLogoImageUrl"], "https://courses.example.com/logo.png") + self.assertEqual(data["accessTokenCookieName"], "edx-jwt") + self.assertEqual(data["languagePreferenceCookieName"], "lang-pref") + self.assertEqual(data["userInfoCookieName"], "edx-user-info") + self.assertEqual(data["csrfTokenApiPath"], "/csrf/api/v1/token") + self.assertEqual(data["refreshAccessTokenApiPath"], "/login_refresh") + self.assertEqual(data["segmentKey"], "abc123") + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_unmapped_keys_in_app_config(self, configuration_helpers_mock): + """Keys that don't map to SiteConfig fields are included in each app's config.""" + mfe_config = { + "LMS_BASE_URL": "https://courses.example.com", + "CREDENTIALS_BASE_URL": "https://credentials.example.com", + "STUDIO_BASE_URL": "https://studio.example.com", + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return mfe_config + if key == "MFE_CONFIG_OVERRIDES": + return {"authn": {"SOME_KEY": "value"}} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + # Site-level key translated to top level + self.assertEqual(data["lmsBaseUrl"], "https://courses.example.com") + # Unmapped MFE_CONFIG keys appear in commonAppConfig (not at the top level) + self.assertNotIn("CREDENTIALS_BASE_URL", data) + common = data["commonAppConfig"] + self.assertEqual(common["CREDENTIALS_BASE_URL"], "https://credentials.example.com") + self.assertEqual(common["STUDIO_BASE_URL"], "https://studio.example.com") + # Legacy config keys also appear in commonAppConfig + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, common) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_apps_from_overrides(self, configuration_helpers_mock): + """Each MFE_CONFIG_OVERRIDES entry becomes an app with shared base config + overrides.""" + mfe_config_overrides = { + "authn": { + "ALLOW_PUBLIC_ACCOUNT_CREATION": True, + "ACTIVATION_EMAIL_SUPPORT_LINK": None, + }, + "learner-dashboard": { + "LEARNING_BASE_URL": "http://apps.local.openedx.io:2000", + "ENABLE_PROGRAMS": False, + }, + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "SHARED_SETTING": "shared_value", + } + if key == "MFE_CONFIG_OVERRIDES": + return mfe_config_overrides + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertIn("apps", data) + self.assertEqual(len(data["apps"]), 2) + + # Shared config (unmapped MFE_CONFIG keys + legacy config) is in commonAppConfig. + common = data["commonAppConfig"] + self.assertEqual(common["SHARED_SETTING"], "shared_value") + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, common) + + # Apps should be sorted by MFE name; each carries only its own overrides. + authn = data["apps"][0] + self.assertEqual(authn["appId"], "org.openedx.frontend.app.authn") + self.assertEqual(authn["config"]["ALLOW_PUBLIC_ACCOUNT_CREATION"], True) + self.assertIsNone(authn["config"]["ACTIVATION_EMAIL_SUPPORT_LINK"]) + # Shared keys are NOT duplicated into per-app config + self.assertNotIn("SHARED_SETTING", authn["config"]) + + dashboard = data["apps"][1] + self.assertEqual(dashboard["appId"], "org.openedx.frontend.app.learnerDashboard") + self.assertEqual(dashboard["config"]["LEARNING_BASE_URL"], "http://apps.local.openedx.io:2000") + self.assertEqual(dashboard["config"]["ENABLE_PROGRAMS"], False) + self.assertNotIn("SHARED_SETTING", dashboard["config"]) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_app_overrides_separate_from_common(self, configuration_helpers_mock): + """App-specific overrides appear in per-app config; shared keys in commonAppConfig.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return {"SOME_KEY": "base_value"} + if key == "MFE_CONFIG_OVERRIDES": + return {"authn": {"SOME_KEY": "overridden_value"}} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertEqual(data["commonAppConfig"]["SOME_KEY"], "base_value") + self.assertEqual(data["apps"][0]["config"]["SOME_KEY"], "overridden_value") + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_site_level_keys_stripped_from_app_overrides(self, configuration_helpers_mock): + """Site-level keys in MFE_CONFIG_OVERRIDES are stripped from app config.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "LOGO_URL": "https://courses.example.com/logo.png", + } + if key == "MFE_CONFIG_OVERRIDES": + return { + "authn": { + "BASE_URL": "https://authn.example.com", + "LOGIN_URL": "https://authn.example.com/login", + "APP_SPECIFIC_KEY": "app_value", + }, + } + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + app_config = data["apps"][0]["config"] + # Site-level keys from overrides must not appear in app config + self.assertNotIn("BASE_URL", app_config) + self.assertNotIn("LOGIN_URL", app_config) + # Non-site-level override keys are kept + self.assertEqual(app_config["APP_SPECIFIC_KEY"], "app_value") + # Site-level keys from overrides also must not appear in commonAppConfig + self.assertNotIn("BASE_URL", data["commonAppConfig"]) + self.assertNotIn("LOGIN_URL", data["commonAppConfig"]) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_no_apps_when_no_overrides(self, configuration_helpers_mock): + """The apps key is omitted when MFE_CONFIG_OVERRIDES is empty.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return {"LMS_BASE_URL": "https://courses.example.com"} + if key == "MFE_CONFIG_OVERRIDES": + return {} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertNotIn("apps", data) + # commonAppConfig is still present with legacy keys + self.assertIn("commonAppConfig", data) + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, data["commonAppConfig"]) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_unmapped_keys_in_common_app_config_without_overrides(self, configuration_helpers_mock): + """Unmapped MFE_CONFIG keys appear in commonAppConfig even without overrides.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "CREDENTIALS_BASE_URL": "https://credentials.example.com", + "STUDIO_BASE_URL": "https://studio.example.com", + } + if key == "MFE_CONFIG_OVERRIDES": + return {} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + # Site-level key is promoted to the top level + self.assertEqual(data["lmsBaseUrl"], "https://courses.example.com") + # Unmapped keys are preserved in commonAppConfig + common = data["commonAppConfig"] + self.assertEqual(common["CREDENTIALS_BASE_URL"], "https://credentials.example.com") + self.assertEqual(common["STUDIO_BASE_URL"], "https://studio.example.com") + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_invalid_override_entry_skipped(self, configuration_helpers_mock): + """Non-dict override entries are silently skipped.""" + mfe_config_overrides = { + "authn": {"SOME_KEY": "value"}, + "broken": "not-a-dict", + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return {} + if key == "MFE_CONFIG_OVERRIDES": + return mfe_config_overrides + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertEqual(len(data["apps"]), 1) + self.assertEqual(data["apps"][0]["appId"], "org.openedx.frontend.app.authn") + + def test_from_django_settings(self): + """When there is no site configuration, the API uses django settings.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + # settings.MFE_CONFIG in test.py has LANGUAGE_PREFERENCE_COOKIE_NAME and LOGO_URL + self.assertEqual(data.get("languagePreferenceCookieName"), "example-language-preference") + self.assertEqual(data.get("headerLogoImageUrl"), "https://courses.example.com/logo.png") + + # Legacy config keys live in commonAppConfig + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, data["commonAppConfig"]) + + # MFE_CONFIG_OVERRIDES in test.py has mymfe and yourmfe + self.assertIn("apps", data) + app_ids = [app["appId"] for app in data["apps"]] + self.assertIn("org.openedx.frontend.app.mymfe", app_ids) + self.assertIn("org.openedx.frontend.app.yourmfe", app_ids) + + # Site-level keys from overrides (LANGUAGE_PREFERENCE_COOKIE_NAME, + # LOGO_URL in test settings) are stripped from per-app config + for app in data["apps"]: + self.assertNotIn("LANGUAGE_PREFERENCE_COOKIE_NAME", app["config"]) + self.assertNotIn("LOGO_URL", app["config"]) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_frontend_site_config_overrides_translated(self, configuration_helpers_mock): + """FRONTEND_SITE_CONFIG takes highest precedence, overriding translated MFE_CONFIG values.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "LOGIN_URL": "https://courses.example.com/login", + "LOGOUT_URL": "https://courses.example.com/logout", + } + if key == "MFE_CONFIG_OVERRIDES": + return {} + if key == "FRONTEND_SITE_CONFIG": + return { + "logoutUrl": "https://courses.example.com/custom-logout", + "externalRoutes": [ + {"role": "learnerDashboard", "url": "https://courses.example.com/dashboard"}, + ], + } + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + # Translated value is overridden by FRONTEND_SITE_CONFIG + self.assertEqual(data["logoutUrl"], "https://courses.example.com/custom-logout") + # Translated value not in FRONTEND_SITE_CONFIG is preserved + self.assertEqual(data["loginUrl"], "https://courses.example.com/login") + # New keys from FRONTEND_SITE_CONFIG are included + self.assertEqual( + data["externalRoutes"], + [{"role": "learnerDashboard", "url": "https://courses.example.com/dashboard"}], + ) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_frontend_site_config_deep_merges_common_app_config(self, configuration_helpers_mock): + """FRONTEND_SITE_CONFIG commonAppConfig is merged with (not replacing) translated values.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "CREDENTIALS_BASE_URL": "https://credentials.example.com", + } + if key == "MFE_CONFIG_OVERRIDES": + return {} + if key == "FRONTEND_SITE_CONFIG": + return { + "commonAppConfig": { + "CREDENTIALS_BASE_URL": "https://new-credentials.example.com", + "NEW_KEY": "new_value", + }, + } + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + common = data["commonAppConfig"] + # FRONTEND_SITE_CONFIG overrides individual keys + self.assertEqual(common["CREDENTIALS_BASE_URL"], "https://new-credentials.example.com") + # New keys from FRONTEND_SITE_CONFIG are added + self.assertEqual(common["NEW_KEY"], "new_value") + # Legacy translated keys are preserved + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, common) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_frontend_site_config_deep_merges_apps(self, configuration_helpers_mock): + """FRONTEND_SITE_CONFIG apps are merged by appId with translated app entries.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return {"LMS_BASE_URL": "https://courses.example.com"} + if key == "MFE_CONFIG_OVERRIDES": + return { + "authn": {"LEGACY_KEY": "legacy_value", "SHARED_KEY": "old_value"}, + } + if key == "FRONTEND_SITE_CONFIG": + return { + "apps": [ + { + "appId": "org.openedx.frontend.app.authn", + "config": {"SHARED_KEY": "new_value", "NEW_KEY": "added"}, + }, + { + "appId": "org.openedx.frontend.app.brand.new", + "config": {"BRAND_NEW_KEY": "value"}, + }, + ], + } + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + apps_by_id = {app["appId"]: app for app in data["apps"]} + # Existing app's config is merged, not replaced + authn = apps_by_id["org.openedx.frontend.app.authn"]["config"] + self.assertEqual(authn["LEGACY_KEY"], "legacy_value") + self.assertEqual(authn["SHARED_KEY"], "new_value") + self.assertEqual(authn["NEW_KEY"], "added") + # Brand new app from FRONTEND_SITE_CONFIG is appended + brand_new = apps_by_id["org.openedx.frontend.app.brand.new"]["config"] + self.assertEqual(brand_new["BRAND_NEW_KEY"], "value") diff --git a/lms/djangoapps/mfe_config_api/urls.py b/lms/djangoapps/mfe_config_api/urls.py index 8f63406a9afd..ee301dca7aa5 100644 --- a/lms/djangoapps/mfe_config_api/urls.py +++ b/lms/djangoapps/mfe_config_api/urls.py @@ -1,10 +1,15 @@ -""" URLs configuration for the mfe api.""" +"""URLs configuration for the mfe api.""" from django.urls import path -from lms.djangoapps.mfe_config_api.views import MFEConfigView +from lms.djangoapps.mfe_config_api.views import FrontendSiteConfigView, MFEConfigView -app_name = 'mfe_config_api' -urlpatterns = [ - path('', MFEConfigView.as_view(), name='config'), +app_name = "mfe_config_api" + +mfe_config_urls = [ + path("", MFEConfigView.as_view(), name="config"), +] + +frontend_site_config_urls = [ + path("", FrontendSiteConfigView.as_view(), name="frontend_site_config"), ] diff --git a/lms/djangoapps/mfe_config_api/views.py b/lms/djangoapps/mfe_config_api/views.py index 0ab71b151b88..8ac144cad85b 100644 --- a/lms/djangoapps/mfe_config_api/views.py +++ b/lms/djangoapps/mfe_config_api/views.py @@ -13,6 +13,84 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +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"], + } + + +def get_mfe_config() -> dict: + """Return common MFE configuration from settings or site configuration. + + Returns: + A dictionary of configuration values shared across all MFEs. + """ + mfe_config = ( + configuration_helpers.get_value("MFE_CONFIG", settings.MFE_CONFIG) or {} + ) + if not isinstance(mfe_config, dict): + return {} + return mfe_config + + +def get_mfe_config_overrides() -> dict: + """Return all MFE-specific overrides from settings or site configuration. + + Returns: + A dictionary keyed by MFE name, where each value is a dict of + per-MFE overrides. Non-dict entries are filtered out. + """ + mfe_config_overrides = ( + configuration_helpers.get_value( + "MFE_CONFIG_OVERRIDES", + settings.MFE_CONFIG_OVERRIDES, + ) + or {} + ) + if not isinstance(mfe_config_overrides, dict): + return {} + + return { + name: overrides + for name, overrides in mfe_config_overrides.items() + if isinstance(overrides, dict) + } + + +def get_frontend_site_config() -> dict: + """Return frontend site configuration from settings or site configuration. + + Unlike MFE_CONFIG, this setting is already in frontend-base's expected + camelCase format and requires no translation. + """ + frontend_site_config = ( + configuration_helpers.get_value( + "FRONTEND_SITE_CONFIG", settings.FRONTEND_SITE_CONFIG + ) + or {} + ) + if not isinstance(frontend_site_config, dict): + return {} + return frontend_site_config + + class MFEConfigView(APIView): """ Provides an API endpoint to get the MFE configuration from settings (or site configuration). @@ -22,7 +100,7 @@ class MFEConfigView(APIView): @apidocs.schema( parameters=[ apidocs.query_parameter( - 'mfe', + "mfe", str, description="Name of an MFE (a.k.a. an APP_ID).", ), @@ -38,8 +116,8 @@ def get(self, request): See [DEPR ticket](https://github.com/openedx/edx-platform/issues/37210) for more details. - The compatability means that settings from the legacy locations will continue to work but - the settings listed below in the `_get_legacy_config` function should be added to the MFE + The compatibility means that settings from the legacy locations will continue to work but + the settings listed below in the `get_legacy_config` function should be added to the MFE config by operators. **Usage** @@ -72,49 +150,184 @@ 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() + mfe_name = ( + str(request.query_params.get("mfe")) + if request.query_params.get("mfe") + else None + ) - # 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) + merged_config = ( + get_legacy_config() + | get_mfe_config() + | get_mfe_config_overrides().get(mfe_name, {}) + ) - # 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, {}) + return JsonResponse(merged_config, status=status.HTTP_200_OK) - # Merge the three configs in the order of precedence - merged_config = legacy_config | mfe_config | mfe_config_overrides - return JsonResponse(merged_config, status=status.HTTP_200_OK) +# Translation map from legacy SCREAMING_SNAKE_CASE MFE_CONFIG keys to +# camelCase field names matching frontend-base's RequiredSiteConfig and +# OptionalSiteConfig interfaces. +# See https://github.com/openedx/frontend-base/blob/main/types.ts +SITE_CONFIG_TRANSLATION_MAP: dict[str, str] = { + # RequiredSiteConfig + "SITE_NAME": "siteName", + "BASE_URL": "baseUrl", + "LMS_BASE_URL": "lmsBaseUrl", + "LOGIN_URL": "loginUrl", + "LOGOUT_URL": "logoutUrl", + # OptionalSiteConfig + "LOGO_URL": "headerLogoImageUrl", + "ACCESS_TOKEN_COOKIE_NAME": "accessTokenCookieName", + "LANGUAGE_PREFERENCE_COOKIE_NAME": "languagePreferenceCookieName", + "USER_INFO_COOKIE_NAME": "userInfoCookieName", + "CSRF_TOKEN_API_PATH": "csrfTokenApiPath", + "REFRESH_ACCESS_TOKEN_API_PATH": "refreshAccessTokenApiPath", + "SEGMENT_KEY": "segmentKey", +} - @staticmethod - def _get_legacy_config() -> dict: - """ - Return legacy configuration values available in either site configuration or django settings. + +# Translation map from known MFE names to reverse-domain appIds. +MFE_NAME_TO_APP_ID: dict[str, str] = { + "account": "org.openedx.frontend.app.account", + "admin-console": "org.openedx.frontend.app.adminConsole", + "authn": "org.openedx.frontend.app.authn", + "authoring": "org.openedx.frontend.app.authoring", + "catalog": "org.openedx.frontend.app.catalog", + "communications": "org.openedx.frontend.app.communications", + "course-authoring": "org.openedx.frontend.app.authoring", + "discussions": "org.openedx.frontend.app.discussions", + "gradebook": "org.openedx.frontend.app.gradebook", + "learner-dashboard": "org.openedx.frontend.app.learnerDashboard", + "learner-record": "org.openedx.frontend.app.learnerRecord", + "learning": "org.openedx.frontend.app.learning", + "ora-grading": "org.openedx.frontend.app.oraGrading", + "profile": "org.openedx.frontend.app.profile", +} + + +def mfe_name_to_app_id(mfe_name: str) -> str: + """Convert a legacy MFE name to a frontend-base appId. + + Uses an explicit mapping of known MFE names to reverse-domain appIds. + Falls back to a programmatic kebab-to-camelCase conversion for unknown names. + """ + app_id = MFE_NAME_TO_APP_ID.get(mfe_name) + if app_id: + return app_id + + parts = mfe_name.split("-") + camel_case = parts[0] + "".join(part.capitalize() for part in parts[1:]) + return f"org.openedx.frontend.app.{camel_case}" + + +def translate_legacy_mfe_config() -> dict: + """Translate legacy MFE_CONFIG/MFE_CONFIG_OVERRIDES into frontend-base site config format. + + This entire function is a compatibility layer that can be removed once legacy + MFE configuration (MFE_CONFIG, MFE_CONFIG_OVERRIDES, and the related + get_legacy_config/get_mfe_config/get_mfe_config_overrides helpers) is fully + deprecated. + + Returns a dict in the shape expected by frontend-base's SiteConfig. + """ + mfe_config = get_mfe_config() + mfe_config_overrides = get_mfe_config_overrides() + + # Split MFE_CONFIG into site-level (translated to camelCase) and app-level. + # Legacy config seeds common_app_config at lowest precedence. + site_config = {} + common_app_config = get_legacy_config() + for key, value in mfe_config.items(): + if key in SITE_CONFIG_TRANSLATION_MAP: + site_config[SITE_CONFIG_TRANSLATION_MAP[key]] = value + else: + common_app_config[key] = value + + site_config["commonAppConfig"] = common_app_config + + # Build the apps array from MFE_CONFIG_OVERRIDES. Site-level keys are + # stripped from per-app overrides so they don't leak into app config. + apps = [] + for mfe_name in sorted(mfe_config_overrides): + overrides = { + k: v + for k, v in mfe_config_overrides[mfe_name].items() + if k not in SITE_CONFIG_TRANSLATION_MAP + } + apps.append( + { + "appId": mfe_name_to_app_id(mfe_name), + "config": overrides, + } + ) + + if apps: + site_config["apps"] = apps + + return site_config + + +class FrontendSiteConfigView(APIView): + """ + Provides the frontend site configuration endpoint. + + Returns the contents of ``FRONTEND_SITE_CONFIG`` merged on top of a + compatibility translation of the legacy ``MFE_CONFIG`` / + ``MFE_CONFIG_OVERRIDES`` settings. Once legacy configuration is fully + deprecated, the translation layer can be removed and this view will + simply return ``FRONTEND_SITE_CONFIG`` as-is. + + See `frontend-base SiteConfig + `_. + """ + + @method_decorator(cache_page(settings.MFE_CONFIG_API_CACHE_TIMEOUT)) + def get(self, request): """ - 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"], + Return frontend site configuration. + + **Usage** + + GET /api/frontend_site_config/v1/ + + **GET Response Values** + ``` + { + "siteName": "My Open edX Site", + "baseUrl": "https://apps.example.com", + "lmsBaseUrl": "https://courses.example.com", + "loginUrl": "https://courses.example.com/login", + "logoutUrl": "https://courses.example.com/logout", + ... } + ``` + """ + if not settings.ENABLE_MFE_CONFIG_API: + return HttpResponseNotFound() + + # Legacy translation (removable once MFE_CONFIG is deprecated). + site_config = translate_legacy_mfe_config() + + # FRONTEND_SITE_CONFIG takes highest precedence. Deep-merge + # nested keys so that the translated legacy values are extended + # rather than clobbered. + frontend_site_config = get_frontend_site_config() + + # Deep-merge commonAppConfig. + if "commonAppConfig" in frontend_site_config and "commonAppConfig" in site_config: + site_config["commonAppConfig"].update(frontend_site_config.pop("commonAppConfig")) + + # Merge apps by appId. + if "apps" in frontend_site_config and "apps" in site_config: + existing_apps = {app["appId"]: app for app in site_config["apps"]} + for app in frontend_site_config.pop("apps"): + app_id = app.get("appId") + if app_id and app_id in existing_apps: + existing_apps[app_id]["config"].update(app.get("config", {})) + else: + site_config["apps"].append(app) + + site_config.update(frontend_site_config) + + return JsonResponse(site_config, status=status.HTTP_200_OK) diff --git a/lms/envs/common.py b/lms/envs/common.py index 859d8ca56da7..107bed3bd7e2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3074,6 +3074,25 @@ # .. setting_creation_date: 2022-08-05 MFE_CONFIG_OVERRIDES = {} +# .. setting_name: FRONTEND_SITE_CONFIG +# .. setting_implementation: DjangoSetting +# .. setting_default: {} +# .. setting_description: Frontend site configuration in frontend-base's native camelCase +# format. Unlike MFE_CONFIG, values here require no translation and are passed through +# to the /api/frontend_site_config/v1/ endpoint as-is, at the highest precedence (overriding any +# values translated from MFE_CONFIG). +# See https://github.com/openedx/frontend-base/blob/main/types.ts for the expected +# SiteConfig schema. +# Example: { +# "externalRoutes": [ +# {"role": "learnerDashboard", "url": "https://courses.example.com/dashboard"} +# ], +# "logoutUrl": "https://courses.example.com/logout" +# } +# .. setting_use_cases: open_edx +# .. setting_creation_date: 2026-04-04 +FRONTEND_SITE_CONFIG = {} + # .. 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/urls.py b/lms/urls.py index 25c5a04f78cd..2f020c362502 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -25,6 +25,7 @@ xqueue_callback ) from lms.djangoapps.courseware.views import views as courseware_views +from lms.djangoapps.mfe_config_api.urls import mfe_config_urls, frontend_site_config_urls from lms.djangoapps.courseware.views.index import CoursewareIndex from lms.djangoapps.courseware.views.views import CourseTabView, EnrollStaffView, StaticCourseTabView from lms.djangoapps.debug import views as debug_views @@ -1055,7 +1056,8 @@ # MFE API urls urlpatterns += [ - path('api/mfe_config/v1', include(('lms.djangoapps.mfe_config_api.urls', 'lms.djangoapps.mfe_config_api'), namespace='mfe_config_api')) + path('api/mfe_config/v1/', include((mfe_config_urls, 'lms.djangoapps.mfe_config_api'), namespace='mfe_config_api')), + path('api/frontend_site_config/v1/', include((frontend_site_config_urls, 'lms.djangoapps.mfe_config_api'), namespace='frontend_site_config')) ] urlpatterns += [