diff --git a/common/djangoapps/student/tasks.py b/common/djangoapps/student/tasks.py index c13c5bf96b..2d436c7c9b 100644 --- a/common/djangoapps/student/tasks.py +++ b/common/djangoapps/student/tasks.py @@ -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 } diff --git a/common/djangoapps/student/tests/test_tasks.py b/common/djangoapps/student/tests/test_tasks.py index d5fc3d3db0..0921e4684b 100644 --- a/common/djangoapps/student/tests/test_tasks.py +++ b/common/djangoapps/student/tests/test_tasks.py @@ -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) diff --git a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py index e43fedcb6b..287c613d89 100644 --- a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py @@ -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 diff --git a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py index 46d46832a6..d78031fa48 100644 --- a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py @@ -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): """ diff --git a/openedx/core/djangoapps/enrollments/enrollments_notifications.py b/openedx/core/djangoapps/enrollments/enrollments_notifications.py index 90f8a42d97..e1f92efeec 100644 --- a/openedx/core/djangoapps/enrollments/enrollments_notifications.py +++ b/openedx/core/djangoapps/enrollments/enrollments_notifications.py @@ -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, ) diff --git a/openedx/core/djangoapps/enrollments/tests/test_enrollments_notifications.py b/openedx/core/djangoapps/enrollments/tests/test_enrollments_notifications.py index 5420733107..aebbd96708 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_enrollments_notifications.py +++ b/openedx/core/djangoapps/enrollments/tests/test_enrollments_notifications.py @@ -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) diff --git a/openedx/core/djangoapps/notifications/email/tests/test_utils.py b/openedx/core/djangoapps/notifications/email/tests/test_utils.py index 75c70ae194..3f4d526dc7 100644 --- a/openedx/core/djangoapps/notifications/email/tests/test_utils.py +++ b/openedx/core/djangoapps/notifications/email/tests/test_utils.py @@ -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): diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index 1f761c0860..5298132b51 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -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): diff --git a/openedx/features/course_experience/tests/test_url_helpers.py b/openedx/features/course_experience/tests/test_url_helpers.py index b6f134f780..96baaeb2a5 100644 --- a/openedx/features/course_experience/tests/test_url_helpers.py +++ b/openedx/features/course_experience/tests/test_url_helpers.py @@ -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'] diff --git a/openedx/features/course_experience/url_helpers.py b/openedx/features/course_experience/url_helpers.py index 44cfdb4aa1..e70019dbe2 100644 --- a/openedx/features/course_experience/url_helpers.py +++ b/openedx/features/course_experience/url_helpers.py @@ -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)