Merge pull request #37504 from Pearson-Advance/felipeb/learning-microfrontend-url-site-aware

feat: make LEARNING_MICROFRONTEND_URL site aware.
This commit is contained in:
Feanil Patel
2025-12-23 10:58:34 -05:00
committed by GitHub
10 changed files with 205 additions and 18 deletions

View File

@@ -68,7 +68,8 @@ def send_course_enrollment_email(
"LMS_ROOT_URL", settings.LMS_ROOT_URL
),
"learning_base_url": configuration_helpers.get_value(
"LEARNING_MICROFRONTEND_URL", settings.LEARNING_MICROFRONTEND_URL
"LEARNING_MICROFRONTEND_URL",
settings.LEARNING_MICROFRONTEND_URL,
),
"track_mode": track_mode
}

View File

@@ -388,3 +388,44 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase):
)
pytest.raises(Exception, task.get)
self.assertEqual(mock_get_email_client.call_count, (MAX_RETRIES + 1))
@patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
@patch("common.djangoapps.student.tasks.get_owners_for_course")
@patch("common.djangoapps.student.tasks.get_course_run_details")
@patch("common.djangoapps.student.tasks.get_course_dates_for_email")
@patch("common.djangoapps.student.tasks.get_email_client")
def test_learning_base_url_uses_site_config(
self,
mock_get_email_client,
mock_get_course_dates_for_email,
mock_get_course_run_details,
mock_get_owners_for_course,
mock_get_course_uuid_for_course,
):
"""Test Braze payload sets learning_base_url from site-configured MFE URL."""
mock_get_course_uuid_for_course.return_value = self.course_uuid
mock_get_owners_for_course.return_value = self._get_course_owners()
mock_get_course_run_details.return_value = self._get_course_run()
mock_get_course_dates_for_email.return_value = self._get_course_dates()
siteconf_url = "https://learningmfe.siteconf"
def _get_value(key, default):
return siteconf_url if key == "LEARNING_MICROFRONTEND_URL" else default
with patch(
"common.djangoapps.student.tasks.configuration_helpers.get_value",
side_effect=_get_value,
) as mock_get_value:
send_course_enrollment_email.apply_async(kwargs=self.send_course_enrollment_email_kwargs)
expected = self._get_canvas_properties()
expected["learning_base_url"] = siteconf_url
mock_get_email_client.return_value.send_canvas_message.assert_called_with(
canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID,
recipients=[{"external_user_id": self.user.id}],
canvas_entry_properties=expected,
)
mock_get_value.assert_any_call("LEARNING_MICROFRONTEND_URL", settings.LEARNING_MICROFRONTEND_URL)
mock_get_value.assert_any_call("LMS_ROOT_URL", settings.LMS_ROOT_URL)

View File

@@ -73,9 +73,11 @@ def send_ace_message(goal, session_id):
message_context = get_base_template_context(site)
course_home_url = get_learning_mfe_home_url(course_key=goal.course_key, url_fragment='home')
goals_unsubscribe_url = f'{settings.LEARNING_MICROFRONTEND_URL}/goal-unsubscribe/{goal.unsubscribe_token}'
learning_microfrontend_url = configuration_helpers.get_value(
'LEARNING_MICROFRONTEND_URL',
settings.LEARNING_MICROFRONTEND_URL,
)
goals_unsubscribe_url = f'{learning_microfrontend_url}/goal-unsubscribe/{goal.unsubscribe_token}'
language = get_user_preference(user, LANGUAGE_KEY)
# Code to allow displaying different banner images for different languages

View File

@@ -12,6 +12,7 @@ import ddt
from django.conf import settings
from django.core.management import call_command
from django.test import TestCase
from django.test.utils import override_settings
from edx_toggles.toggles.testutils import override_waffle_flag
from freezegun import freeze_time
from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import
@@ -235,6 +236,45 @@ class TestGoalReminderEmailCommand(TestCase):
send_ace_message(goal, str(uuid.uuid4()))
assert mock_ace.called is value
def test_goals_unsubscribe_url_uses_site_config(self):
"""Test goals unsubscribe URL uses site-configured MFE base."""
goal = self.make_valid_goal()
with mock.patch('lms.djangoapps.course_goals.management.commands.goal_reminder_email.ace.send') as mock_ace:
with mock.patch(
'lms.djangoapps.course_goals.management.commands.goal_reminder_email.configuration_helpers.get_value',
return_value='https://learning.siteconf',
) as mock_get_value:
assert send_ace_message(goal, str(uuid.uuid4())) is True
assert mock_ace.call_count == 1
msg = mock_ace.call_args[0][0]
assert msg.context[
'goals_unsubscribe_url'
] == f'https://learning.siteconf/goal-unsubscribe/{goal.unsubscribe_token}'
mock_get_value.assert_any_call('LEARNING_MICROFRONTEND_URL', settings.LEARNING_MICROFRONTEND_URL)
def test_goals_unsubscribe_url_falls_back_to_settings(self):
"""Test goals unsubscribe URL falls back to settings when site config is absent."""
default_url = 'https://learning.default'
goal = self.make_valid_goal()
with override_settings(LEARNING_MICROFRONTEND_URL=default_url):
with mock.patch(
'lms.djangoapps.course_goals.management.commands.goal_reminder_email.ace.send',
) as mock_ace:
with mock.patch(
(
'lms.djangoapps.course_goals.management.commands.'
'goal_reminder_email.configuration_helpers.get_value'
),
side_effect=lambda k, d: d,
) as mock_get_value:
assert send_ace_message(goal, str(uuid.uuid4())) is True
assert mock_ace.call_count == 1
msg = mock_ace.call_args[0][0]
assert msg.context['goals_unsubscribe_url'] == f'{default_url}/goal-unsubscribe/{goal.unsubscribe_token}'
mock_get_value.assert_any_call('LEARNING_MICROFRONTEND_URL', default_url)
class TestGoalReminderEmailSES(TestCase):
"""

View File

@@ -5,6 +5,7 @@ from django.conf import settings
from openedx_events.learning.data import UserNotificationData
from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
class EnrollmentNotificationSender:
@@ -21,6 +22,10 @@ class EnrollmentNotificationSender:
"""
Send audit access expiring soon notification to user
"""
learning_microfrontend_url = configuration_helpers.get_value(
'LEARNING_MICROFRONTEND_URL',
settings.LEARNING_MICROFRONTEND_URL,
)
notification_data = UserNotificationData(
user_ids=[int(self.user_id)],
@@ -29,7 +34,7 @@ class EnrollmentNotificationSender:
'audit_access_expiry': self.audit_access_expiry,
},
notification_type='audit_access_expiring_soon',
content_url=f"{settings.LEARNING_MICROFRONTEND_URL}/course/{str(self.course.id)}/home",
content_url=f"{learning_microfrontend_url}/course/{str(self.course.id)}/home",
app_name="enrollments",
course_key=self.course.id,
)

View File

@@ -5,12 +5,15 @@ import unittest
import datetime
from unittest.mock import MagicMock, patch
from django.conf import settings
from django.test.utils import override_settings
import pytest
from openedx.core.djangoapps.enrollments.enrollments_notifications import EnrollmentNotificationSender
from openedx_events.learning.data import UserNotificationData
DEFAULT_MFE_URL = "https://learning.default"
SITE_CONF_MFE_URL = "https://learning.siteconf"
@pytest.mark.django_db
class TestEnrollmentsNotificationSender(unittest.TestCase):
@@ -26,14 +29,20 @@ class TestEnrollmentsNotificationSender(unittest.TestCase):
self.user_id = '123'
self.notification_sender = EnrollmentNotificationSender(self.course, self.user_id, self.expiry_date)
@override_settings(LEARNING_MICROFRONTEND_URL=DEFAULT_MFE_URL)
@patch(
'openedx.core.djangoapps.enrollments.enrollments_notifications.configuration_helpers.get_value',
return_value=SITE_CONF_MFE_URL,
)
@patch('openedx.core.djangoapps.enrollments.enrollments_notifications.USER_NOTIFICATION_REQUESTED.send_event')
def test_send_audit_access_expiring_soon_notification(self, mock_send_notification):
def test_send_audit_access_expiring_soon_notification(self, mock_send_notification, mock_get_value):
"""
Test that audit access expiring soon notification event is sent with correct parameters.
"""
self.notification_sender.send_audit_access_expiring_soon_notification()
mock_get_value.assert_called_once_with('LEARNING_MICROFRONTEND_URL', DEFAULT_MFE_URL)
mock_send_notification.assert_called_once()
notification_data = UserNotificationData(
user_ids=[int(self.user_id)],
@@ -42,8 +51,39 @@ class TestEnrollmentsNotificationSender(unittest.TestCase):
'audit_access_expiry': self.expiry_date,
},
notification_type='audit_access_expiring_soon',
content_url=f"{settings.LEARNING_MICROFRONTEND_URL}/course/{str(self.course.id)}/home",
content_url=f"{SITE_CONF_MFE_URL}/course/{str(self.course.id)}/home",
app_name="enrollments",
course_key=self.course.id,
)
mock_send_notification.assert_called_with(notification_data=notification_data)
@override_settings(LEARNING_MICROFRONTEND_URL=DEFAULT_MFE_URL)
@patch(
'openedx.core.djangoapps.enrollments.enrollments_notifications.configuration_helpers.get_value',
side_effect=lambda key, default: default,
)
@patch('openedx.core.djangoapps.enrollments.enrollments_notifications.USER_NOTIFICATION_REQUESTED.send_event')
def test_send_audit_access_expiring_soon_notification_falls_back_to_settings(
self,
mock_send_event,
mock_get_value
):
"""
Test mocks missing site-config value and verifies default URL and get_value args.
"""
self.notification_sender.send_audit_access_expiring_soon_notification()
mock_get_value.assert_called_once_with('LEARNING_MICROFRONTEND_URL', DEFAULT_MFE_URL)
expected_notification = UserNotificationData(
user_ids=[int(self.user_id)],
context={
'course': self.course.name,
'audit_access_expiry': self.expiry_date,
},
notification_type='audit_access_expiring_soon',
content_url=f"{DEFAULT_MFE_URL}/course/{str(self.course.id)}/home",
app_name="enrollments",
course_key=self.course.id,
)
mock_send_event.assert_called_once_with(notification_data=expected_notification)

View File

@@ -6,6 +6,9 @@ import ddt
import pytest
from django.http.response import Http404
from django.conf import settings
from django.test.utils import override_settings
from unittest.mock import patch
from pytz import utc
from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import
@@ -27,6 +30,7 @@ from openedx.core.djangoapps.notifications.email.utils import (
encrypt_string,
get_course_info,
get_time_ago,
get_unsubscribe_link,
is_email_notification_flag_enabled,
update_user_preferences_from_patch,
)
@@ -93,9 +97,37 @@ class TestUtilFunctions(ModuleStoreTestCase):
assert "1w" == get_time_ago(current_datetime - datetime.timedelta(days=7))
def test_datetime_string(self):
"""Test datetime is formatted as 'Weekday, Mon DD'."""
dt = datetime.datetime(2024, 3, 25)
assert create_datetime_string(dt) == "Monday, Mar 25"
def test_get_unsubscribe_link_uses_site_config(self):
"""Test unsubscribe link uses site-configured MFE URL and encrypted username."""
with patch('openedx.core.djangoapps.notifications.email.utils.configuration_helpers.get_value',
return_value='https://learning.siteconf') as mock_get_value, \
patch('openedx.core.djangoapps.notifications.email.utils.encrypt_string',
return_value='ENC') as mock_encrypt:
url = get_unsubscribe_link(self.user.username)
assert url == 'https://learning.siteconf/preferences-unsubscribe/ENC/'
mock_get_value.assert_called_once_with('LEARNING_MICROFRONTEND_URL', settings.LEARNING_MICROFRONTEND_URL)
mock_encrypt.assert_called_once_with(self.user.username)
def test_get_unsubscribe_link_falls_back_to_settings(self):
"""Test unsubscribe link falls back to settings when site config is absent."""
default_url = 'https://learning.default'
with override_settings(LEARNING_MICROFRONTEND_URL=default_url):
with patch('openedx.core.djangoapps.notifications.email.utils.configuration_helpers.get_value',
side_effect=lambda k, d: d) as mock_get_value, \
patch('openedx.core.djangoapps.notifications.email.utils.encrypt_string',
return_value='ENC') as mock_encrypt:
url = get_unsubscribe_link(self.user.username)
assert url == f'{default_url}/preferences-unsubscribe/ENC/'
mock_get_value.assert_called_once_with('LEARNING_MICROFRONTEND_URL', default_url)
mock_encrypt.assert_called_once_with(self.user.username)
@ddt.ddt
class TestContextFunctions(ModuleStoreTestCase):

View File

@@ -22,6 +22,7 @@ from openedx.core.djangoapps.notifications.email_notifications import EmailCaden
from openedx.core.djangoapps.notifications.events import notification_preference_unsubscribe_event
from openedx.core.djangoapps.notifications.models import NotificationPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from xmodule.modulestore.django import modulestore
from .notification_icons import NotificationTypeIcons
@@ -71,7 +72,12 @@ def get_unsubscribe_link(username):
Returns unsubscribe url for username with patch preferences
"""
encrypted_username = encrypt_string(username)
return f"{settings.LEARNING_MICROFRONTEND_URL}/preferences-unsubscribe/{encrypted_username}/"
learning_microfrontend_url = configuration_helpers.get_value(
'LEARNING_MICROFRONTEND_URL',
settings.LEARNING_MICROFRONTEND_URL,
)
return f'{learning_microfrontend_url}/preferences-unsubscribe/{encrypted_username}/'
def create_email_template_context(username):

View File

@@ -5,6 +5,7 @@ import ddt
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
from unittest.mock import patch
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
@@ -33,10 +34,14 @@ class IsLearningMfeTests(TestCase):
)
@ddt.unpack
def test_is_request_from_learning_mfe(self, mfe_url, referrer_url, is_mfe):
"""
Test requests are marked as from the Learning MFE when HTTP_REFERER matches LEARNING_MICROFRONTEND_URL.
"""
with override_settings(LEARNING_MICROFRONTEND_URL=mfe_url):
request = self.request_factory.get('/course')
request.META['HTTP_REFERER'] = referrer_url
assert url_helpers.is_request_from_learning_mfe(request) == is_mfe
with patch.object(url_helpers.configuration_helpers, 'get_value', return_value=mfe_url):
request = self.request_factory.get('/course')
request.META['HTTP_REFERER'] = referrer_url
assert url_helpers.is_request_from_learning_mfe(request) == is_mfe
@ddt.ddt
@@ -158,7 +163,8 @@ class GetCoursewareUrlTests(SharedModuleStoreTestCase):
check that the expected path (URL without querystring) is returned by `get_courseware_url`.
"""
block = self.items[structure_level]
url = url_helpers.get_courseware_url(block.location)
with patch.object(url_helpers.configuration_helpers, 'get_value', return_value='http://learning-mfe'):
url = url_helpers.get_courseware_url(block.location)
path = url.split('?')[0]
assert path == expected_path
course_run = self.items['course_run']

View File

@@ -17,6 +17,8 @@ from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.search import path_to_location # lint-amnesty, pylint: disable=wrong-import-order
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
User = get_user_model()
@@ -124,7 +126,11 @@ def make_learning_mfe_courseware_url(
strings. They're only ever used to concatenate a URL string.
`params` is an optional QueryDict object (e.g. request.GET)
"""
mfe_link = f'{settings.LEARNING_MICROFRONTEND_URL}/course/{course_key}'
learning_microfrontend_url = configuration_helpers.get_value(
'LEARNING_MICROFRONTEND_URL',
settings.LEARNING_MICROFRONTEND_URL,
)
mfe_link = f'{learning_microfrontend_url}/course/{course_key}'
get_params = params.copy() if params else None
if preview:
@@ -134,7 +140,7 @@ def make_learning_mfe_courseware_url(
get_params = None
if (unit_key or sequence_key):
mfe_link = f'{settings.LEARNING_MICROFRONTEND_URL}/preview/course/{course_key}'
mfe_link = f'{learning_microfrontend_url}/preview/course/{course_key}'
if sequence_key:
mfe_link += f'/{sequence_key}'
@@ -164,7 +170,11 @@ def get_learning_mfe_home_url(
`url_fragment` is an optional string.
`params` is an optional QueryDict object (e.g. request.GET)
"""
mfe_link = f'{settings.LEARNING_MICROFRONTEND_URL}/course/{course_key}'
learning_microfrontend_url = configuration_helpers.get_value(
'LEARNING_MICROFRONTEND_URL',
settings.LEARNING_MICROFRONTEND_URL,
)
mfe_link = f'{learning_microfrontend_url}/course/{course_key}'
if url_fragment:
mfe_link += f'/{url_fragment}'
@@ -179,9 +189,13 @@ def is_request_from_learning_mfe(request: HttpRequest):
"""
Returns whether the given request was made by the frontend-app-learning MFE.
"""
if not settings.LEARNING_MICROFRONTEND_URL:
url_str = configuration_helpers.get_value(
'LEARNING_MICROFRONTEND_URL',
settings.LEARNING_MICROFRONTEND_URL,
)
if not url_str:
return False
url = urlparse(settings.LEARNING_MICROFRONTEND_URL)
url = urlparse(url_str)
mfe_url_base = f'{url.scheme}://{url.netloc}'
return request.META.get('HTTP_REFERER', '').startswith(mfe_url_base)