diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index be1f321609..03061dcb06 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -527,8 +527,12 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem """ user = request.user + account_microfrontend_url = configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ) if not UserProfile.objects.filter(user=user).exists(): - return redirect(settings.ACCOUNT_MICROFRONTEND_URL) + return redirect(account_microfrontend_url) if learner_home_mfe_enabled(): return redirect(settings.LEARNER_HOME_MICROFRONTEND_URL) @@ -633,7 +637,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem "Go to {link_start}your Account Settings{link_end}.") ).format( link_start=HTML("").format( - account_setting_page=settings.ACCOUNT_MICROFRONTEND_URL, + account_setting_page=account_microfrontend_url, ), link_end=HTML("") ) @@ -902,7 +906,10 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem except DashboardRenderStarted.RenderInvalidDashboard as exc: response = render_to_response(exc.dashboard_template, exc.template_context) except DashboardRenderStarted.RedirectToPage as exc: - response = HttpResponseRedirect(exc.redirect_to or settings.ACCOUNT_MICROFRONTEND_URL) + response = HttpResponseRedirect( + exc.redirect_to or + account_microfrontend_url + ) except DashboardRenderStarted.RenderCustomResponse as exc: response = exc.response else: diff --git a/common/djangoapps/third_party_auth/api/tests/test_views.py b/common/djangoapps/third_party_auth/api/tests/test_views.py index 61740268db..8f36305d33 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_views.py +++ b/common/djangoapps/third_party_auth/api/tests/test_views.py @@ -3,6 +3,7 @@ Tests for the Third Party Auth REST API """ import urllib +from types import SimpleNamespace from unittest.mock import patch import ddt @@ -447,3 +448,24 @@ class TestThirdPartyAuthUserStatusView(ThirdPartyAuthTestMixin, APITestCase): 'connect_url': f'/auth/login/google-oauth2/?auth_entry=account_settings&next={next_url}', 'connected': False, 'id': 'oa2-google-oauth2' }]) + + def test_get_uses_site_config_account_mfe_url(self): + """ + The providers API uses site-configured ACCOUNT_MICROFRONTEND_URL in the next link. + """ + self.client.login(username=self.user.username, password=PASSWORD) + siteconf_url = "https://accounts.siteconf.example" + + helpers_stub = SimpleNamespace( + get_value=lambda key, default=None, *args, **kwargs: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default + ) + + with patch("common.djangoapps.third_party_auth.api.views.configuration_helpers", new=helpers_stub): + response = self.client.get(self.url) + + assert response.status_code == 200 + providers = response.data + google = next(p for p in providers if p["id"].endswith("google-oauth2")) + qs = urllib.parse.parse_qs(urllib.parse.urlparse(google["connect_url"]).query) + assert qs.get("next") == [siteconf_url] diff --git a/common/djangoapps/third_party_auth/api/views.py b/common/djangoapps/third_party_auth/api/views.py index 89d55e2eec..5d9643c049 100644 --- a/common/djangoapps/third_party_auth/api/views.py +++ b/common/djangoapps/third_party_auth/api/views.py @@ -22,6 +22,7 @@ from openedx.core.lib.api.authentication import ( BearerAuthenticationAllowInactiveUser ) from openedx.core.lib.api.permissions import ApiKeyHeaderPermission +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from common.djangoapps.third_party_auth import pipeline from common.djangoapps.third_party_auth.api import serializers from common.djangoapps.third_party_auth.api.permissions import TPA_PERMISSIONS @@ -468,7 +469,10 @@ class ThirdPartyAuthUserStatusView(APIView): state.provider.provider_id, pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, # The url the user should be directed to after the auth process has completed. - redirect_url=settings.ACCOUNT_MICROFRONTEND_URL, + redirect_url=configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ), ), 'accepts_logins': state.provider.accepts_logins, # If the user is connected, sending a POST request to this url removes the connection diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 4f18b224fe..0e7c10d622 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -111,7 +111,10 @@ def _get_course_email_context(course): 'course_url': course_url, 'course_image_url': image_url, 'course_end_date': course_end_date, - 'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL, + 'account_settings_url': configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ), 'email_settings_url': '{}{}'.format(lms_root_url, reverse('dashboard')), 'logo_url': get_logo_url_for_email(), 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), diff --git a/lms/djangoapps/bulk_email/tests/test_tasks.py b/lms/djangoapps/bulk_email/tests/test_tasks.py index c4080b872b..caf71bfdb4 100644 --- a/lms/djangoapps/bulk_email/tests/test_tasks.py +++ b/lms/djangoapps/bulk_email/tests/test_tasks.py @@ -463,6 +463,38 @@ class TestBulkEmailInstructorTask(InstructorTaskCourseTestCase): assert 'email_settings_url' in result assert 'platform_name' in result + def test_account_settings_url_uses_site_config_value(self): + """ + If site configuration defines ACCOUNT_MICROFRONTEND_URL, the email context + should use that value. + """ + siteconf_url = "https://accounts.siteconf.example" + + with patch( + "lms.djangoapps.bulk_email.tasks.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default, + ): + ctx = _get_course_email_context(self.course) + + assert ctx["account_settings_url"] == siteconf_url + + def test_account_settings_url_falls_back_to_settings(self): + """ + If site configuration does not override, fall back to settings.ACCOUNT_MICROFRONTEND_URL. + """ + fallback = "https://accounts.settings.example" + + with override_settings(ACCOUNT_MICROFRONTEND_URL=fallback): + with patch( + "lms.djangoapps.bulk_email.tasks.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: default, + ): + ctx = _get_course_email_context(self.course) + + assert ctx["account_settings_url"] == fallback + assert ctx["account_settings_url"] == settings.ACCOUNT_MICROFRONTEND_URL + @override_settings(BULK_COURSE_EMAIL_LAST_LOGIN_ELIGIBILITY_PERIOD=1) def test_ineligible_recipients_filtered_by_last_login(self): """ diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 583d5ee64b..1c0b6aa57f 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -818,6 +818,56 @@ class ViewsTestCase(BaseViewsTestCase): response = self.client.get(url) self.assertRedirects(response, reverse('signin_user') + '?next=' + url) + def test_financial_assistance_form_uses_site_config_account_mfe_url(self): + """ + When site configuration defines ACCOUNT_MICROFRONTEND_URL, the view should + pass that URL in the render context as 'account_settings_url'. + """ + siteconf_url = "https://accounts.siteconf.example" + captured = {} + + def fake_render(template_name, context, *args, **kwargs): + captured['context'] = context + return HttpResponse("ok") + + with patch.object(views, 'render_to_response', new=fake_render): + with patch.object( + views.configuration_helpers, + 'get_value', + side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default, + ): + resp = self.client.get(reverse('financial_assistance_form')) + + assert resp.status_code == 200 + assert 'context' in captured + assert captured['context']['account_settings_url'] == siteconf_url + + def test_financial_assistance_form_falls_back_to_settings_for_account_mfe(self): + """ + If site configuration doesn't override, fall back to settings.ACCOUNT_MICROFRONTEND_URL. + """ + fallback = "https://accounts.settings.example" + captured = {} + + def fake_render(template_name, context, *args, **kwargs): + captured['context'] = context + return HttpResponse("ok") + + with override_settings(ACCOUNT_MICROFRONTEND_URL=fallback): + with patch.object(views, 'render_to_response', new=fake_render): + with patch.object( + views.configuration_helpers, + 'get_value', + side_effect=lambda key, default=None, *a, **k: default, + ): + resp = self.client.get(reverse('financial_assistance_form')) + + assert resp.status_code == 200 + assert 'context' in captured + assert captured['context']['account_settings_url'] == fallback + assert captured['context']['account_settings_url'] == settings.ACCOUNT_MICROFRONTEND_URL + # Patching 'lms.djangoapps.courseware.views.views.get_programs' would be ideal, # but for some unknown reason that patch doesn't seem to be applied. diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index c37034016f..590a5f5ace 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -2232,7 +2232,10 @@ def financial_assistance_form(request, course_id=None): 'header_text': _get_fa_header(FINANCIAL_ASSISTANCE_HEADER), 'course_id': course_id, 'dashboard_url': reverse('dashboard'), - 'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL, + 'account_settings_url': configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ), 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'user_details': { 'email': user.email, diff --git a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py index 0bfef6d0ac..5061bbd8da 100644 --- a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py +++ b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py @@ -22,6 +22,7 @@ from lms.djangoapps.verify_student.message_types import VerificationExpiry from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.lib.celery.task_utils import emulate_http_request @@ -188,7 +189,13 @@ class Command(BaseCommand): return True site = Site.objects.get_current() - account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + account_base_url = ( + configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ) or + "" + ).rstrip('/') message_context = get_base_template_context(site) message_context.update({ 'platform_name': settings.PLATFORM_NAME, diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py index 874051c0c8..54f527e2cd 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py @@ -3,6 +3,7 @@ Tests for django admin command `send_verification_expiry_email` in the verify_st """ +from contextlib import nullcontext from datetime import timedelta from unittest.mock import patch @@ -302,3 +303,68 @@ class TestSendVerificationExpiryEmail(MockS3Boto3Mixin, TestCase): assert verifications[0].expiry_email_date is None assert verifications[1].expiry_email_date is not None assert mock_ace.send.call_count == 2 + + def _run_cmd_and_get_verification_link(self, get_value_side_effect, *, fallback=None): + """ + Create one expired, approved verification; run the command with patches; + return the lms_verification_link computed in the message context. + """ + self.create_expired_software_secure_photo_verification() + + captured_contexts = [] + + class FakeVerificationExpiry: + """Mock message class capturing context for verification expiry tests.""" + def __init__(self, context): + captured_contexts.append(context) + + def personalize(self, recipient, language=None, user_context=None): + return object() + + settings_ctx = ( + override_settings(ACCOUNT_MICROFRONTEND_URL=fallback) + if fallback is not None else + nullcontext() + ) + + module = ( + "lms.djangoapps.verify_student.management.commands." + "send_verification_expiry_email" + ) + + with ( + settings_ctx, + patch(f"{module}.VerificationExpiry", new=FakeVerificationExpiry), + patch(f"{module}.ace.send") as mock_send, + patch( + f"{module}.configuration_helpers.get_value", + side_effect=get_value_side_effect, + ), + ): + call_command("send_verification_expiry_email") + assert mock_send.called, "Expected at least one email to be sent" + assert captured_contexts, ("VerificationExpiry(context=...) should have been called") + + return captured_contexts[0]["lms_verification_link"] + + def test_account_mfe_link_uses_site_config_value(self): + """Test verification link uses site-configured ACCOUNT_MICROFRONTEND_URL.""" + siteconf_url = "https://accounts.siteconf.example/" + + link = self._run_cmd_and_get_verification_link( + get_value_side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default + ) + + assert link == "https://accounts.siteconf.example/id-verification" + + def test_account_mfe_link_falls_back_to_settings(self): + """Test verification link falls back to settings when site config is absent.""" + fallback = "https://accounts.settings.example/" + + link = self._run_cmd_and_get_verification_link( + get_value_side_effect=lambda key, default=None, *a, **k: default, + fallback=fallback, + ) + + assert link == "https://accounts.settings.example/id-verification" diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 5caede3dab..3eabc35cd9 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -251,7 +251,13 @@ class IDVerificationService: Returns a string: Returns URL for IDV on Account Microfrontend """ - account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + account_base_url = ( + configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ) or + "" + ).rstrip('/') location = f'{account_base_url}/id-verification' if course_id: location += f'?course_id={quote(str(course_id))}' diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 4bfa8c27d5..a84a01bf02 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -3,8 +3,10 @@ Tests for the service classes in verify_student. """ import itertools +from contextlib import nullcontext from datetime import datetime, timedelta, timezone from unittest.mock import patch +from urllib.parse import quote import ddt from django.conf import settings @@ -230,6 +232,89 @@ class TestIDVerificationService(ModuleStoreTestCase): expiration_datetime = IDVerificationService.get_expiration_datetime(user, ['approved']) assert expiration_datetime == newest.expiration_datetime + def _get_verify_url_with_config( + self, + *, + get_value_side_effect, + course_id=None, + settings_account_mfe=None, + ): + """ + Build the IDV URL using a patched configuration helper and optional settings override. + Returns the final URL after the (stubbed) filter runs. + """ + settings_ctx = ( + override_settings(ACCOUNT_MICROFRONTEND_URL=settings_account_mfe) + if settings_account_mfe is not None + else nullcontext() + ) + + with settings_ctx: + with patch( + "lms.djangoapps.verify_student.services.configuration_helpers.get_value", + side_effect=get_value_side_effect, + ): + with patch( + "lms.djangoapps.verify_student.services.IDVPageURLRequested.run_filter", + side_effect=lambda url: url, + ): + return IDVerificationService.get_verify_location(course_id) + + def test_get_verify_location_uses_site_config_value(self): + """ + If site config defines ACCOUNT_MICROFRONTEND_URL (with trailing slash), + the base should come from it and the slash should be stripped. + """ + siteconf_url = "https://accounts.siteconf.example/" # trailing slash on purpose + + url = self._get_verify_url_with_config( + get_value_side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default + ) + + assert url == "https://accounts.siteconf.example/id-verification" + + def test_get_verify_location_uses_site_config_value_with_course_id(self): + """ + Same as above, but with a course_id. Ensure proper quoting and base from site config. + """ + course = CourseFactory.create(org='Robot', number='999', display_name='Test Course') + siteconf_url = "https://accounts.siteconf.example/" + + url = self._get_verify_url_with_config( + get_value_side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default, + course_id=course.id, + ) + + expected = f"https://accounts.siteconf.example/id-verification?course_id={quote(str(course.id), safe='')}" + assert url == expected + + def test_get_verify_location_falls_back_to_settings(self): + """ + If site config does not override, fall back to settings.ACCOUNT_MICROFRONTEND_URL + (and strip any trailing slash). + """ + fallback = "https://accounts.settings.example/" # trailing slash on purpose + + url = self._get_verify_url_with_config( + get_value_side_effect=lambda key, default=None, *a, **k: default, + settings_account_mfe=fallback, + ) + + assert url == "https://accounts.settings.example/id-verification" + + def test_get_verify_location_when_site_config_empty(self): + """ + If site config explicitly returns an empty string, we should still produce a valid + absolute path under the LMS domain. + """ + url = self._get_verify_url_with_config( + get_value_side_effect=lambda key, default=None, *a, **k: "" + ) + + assert url == "/id-verification" + @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) @ddt.ddt diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index f3c5fe46ea..0904611ef9 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -1757,6 +1757,73 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerification ) self.assertContains(response, 'Result Unknown not understood', status_code=400) + @patch("lms.djangoapps.verify_student.views.send_verification_status_email.delay") + def test_failed_status_reverify_url_uses_site_config_value(self, mock_delay): + """ + When site config defines ACCOUNT_MICROFRONTEND_URL (with a trailing slash), + the reverify_url in the denial email context should use it and strip the slash. + """ + siteconf_url = "https://accounts.siteconf.example/" + access_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"] + + with patch( + "lms.djangoapps.verify_student.views.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default, + ): + data = { + "EdX-ID": self.receipt_id, + "Result": "FAIL", + "Reason": [{"photoIdReasons": ["Not provided"]}], + "MessageType": "Your photo doesn't meet standards.", + } + json_data = json.dumps(data) + response = self.client.post( + reverse("verify_student_results_callback"), + data=json_data, + content_type="application/json", + HTTP_AUTHORIZATION=f"test {access_key}:testing", + HTTP_DATE="testdate", + ) + + assert response.status_code == 200 + assert mock_delay.called + context = mock_delay.call_args[0][0] + assert context["email_vars"]["reverify_url"] == "https://accounts.siteconf.example/id-verification" + + @patch("lms.djangoapps.verify_student.views.send_verification_status_email.delay") + def test_failed_status_reverify_url_falls_back_to_settings(self, mock_delay): + """ + If site config doesn't override, reverify_url should come from + settings.ACCOUNT_MICROFRONTEND_URL (with trailing slash stripped). + """ + fallback = "https://accounts.settings.example/" + access_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"] + + with override_settings(ACCOUNT_MICROFRONTEND_URL=fallback), patch( + "lms.djangoapps.verify_student.views.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: default, + ): + data = { + "EdX-ID": self.receipt_id, + "Result": "FAIL", + "Reason": [{"photoIdReasons": ["Not provided"]}], + "MessageType": "Your photo doesn't meet standards.", + } + json_data = json.dumps(data) + response = self.client.post( + reverse("verify_student_results_callback"), + data=json_data, + content_type="application/json", + HTTP_AUTHORIZATION=f"test {access_key}:testing", + HTTP_DATE="testdate", + ) + + assert response.status_code == 200 + assert mock_delay.called + context = mock_delay.call_args[0][0] + assert context["email_vars"]["reverify_url"] == "https://accounts.settings.example/id-verification" + class TestReverifyView(TestVerificationBase): """ diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index deda08e0c7..553ec9e540 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1128,7 +1128,13 @@ def results_callback(request): # lint-amnesty, pylint: disable=too-many-stateme log.info("[COSMO-184] Denied verification for receipt_id={receipt_id}.".format(receipt_id=receipt_id)) attempt.deny(json.dumps(reason), error_code=error_code) - account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + account_base_url = ( + configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ) or + "" + ).rstrip('/') reverify_url = f'{account_base_url}/id-verification' verification_status_email_vars['reasons'] = reason verification_status_email_vars['reverify_url'] = reverify_url diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html index a10ec658d2..9071fcdde6 100644 --- a/lms/templates/header/user_dropdown.html +++ b/lms/templates/header/user_dropdown.html @@ -11,6 +11,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name, get_enterprise_learner_portal %> @@ -45,7 +46,7 @@ should_show_order_history = not enterprise_customer_portal and settings.ORDER_HI % endif - + % if should_show_order_history: % endif diff --git a/lms/templates/user_dropdown.html b/lms/templates/user_dropdown.html index 5423c7c4c8..e6cc4932a5 100644 --- a/lms/templates/user_dropdown.html +++ b/lms/templates/user_dropdown.html @@ -8,6 +8,7 @@ from django.utils.translation import gettext as _ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers %> <% @@ -28,7 +29,7 @@ profile_image_url = get_profile_image_urls_for_user(self.real_user)['medium'] @@ -36,7 +37,7 @@ profile_image_url = get_profile_image_urls_for_user(self.real_user)['medium'] % else: diff --git a/openedx/core/djangoapps/notifications/email/tests/test_utils.py b/openedx/core/djangoapps/notifications/email/tests/test_utils.py index bc2d54da6e..df7de6e803 100644 --- a/openedx/core/djangoapps/notifications/email/tests/test_utils.py +++ b/openedx/core/djangoapps/notifications/email/tests/test_utils.py @@ -199,6 +199,38 @@ class TestContextFunctions(ModuleStoreTestCase): assert_list_equal(context['email_digest_updates'], expected_digest_updates) assert_list_equal(context['email_content'], expected_email_content) + def test_email_template_context_notification_settings_url_uses_site_config(self): + """ + When site configuration defines ACCOUNT_MICROFRONTEND_URL (with a trailing slash), + the context should build notification_settings_url from it and strip the slash. + """ + siteconf_url = "https://accounts.siteconf.example/" + + with patch( + "openedx.core.djangoapps.notifications.email.utils.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default, + ): + ctx = create_email_template_context(self.user.username) + + assert ctx["notification_settings_url"] == "https://accounts.siteconf.example/#notifications" + + def test_email_template_context_notification_settings_url_falls_back_to_settings(self): + """ + If site config doesn't override, the context should fall back to + settings.ACCOUNT_MICROFRONTEND_URL (also stripping any trailing slash). + """ + fallback = "https://accounts.settings.example/" + + with override_settings(ACCOUNT_MICROFRONTEND_URL=fallback): + with patch( + "openedx.core.djangoapps.notifications.email.utils.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: default, + ): + ctx = create_email_template_context(self.user.username) + + assert ctx["notification_settings_url"] == "https://accounts.settings.example/#notifications" + class TestWaffleFlag(ModuleStoreTestCase): """ diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index a3fb933304..c3a8f62a29 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -101,7 +101,13 @@ def create_email_template_context(username): 'channel': 'email', 'value': False } - account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + account_base_url = ( + configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ) or + "" + ).rstrip('/') return { "platform_name": settings.PLATFORM_NAME, "mailing_address": settings.CONTACT_MAILING_ADDRESS, diff --git a/openedx/core/djangoapps/password_policy/compliance.py b/openedx/core/djangoapps/password_policy/compliance.py index 8601a55d65..fa5d2d8cfb 100644 --- a/openedx/core/djangoapps/password_policy/compliance.py +++ b/openedx/core/djangoapps/password_policy/compliance.py @@ -9,6 +9,7 @@ from django.conf import settings from django.utils.translation import gettext as _ from openedx.core.djangolib.markup import HTML +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from common.djangoapps.util.date_utils import DEFAULT_SHORT_DATE_FORMAT, strftime_localized from common.djangoapps.util.password_policy_validators import validate_password @@ -97,7 +98,10 @@ def enforce_compliance_on_login(user, password): platform_name=settings.PLATFORM_NAME, deadline=strftime_localized(deadline, DEFAULT_SHORT_DATE_FORMAT), anchor_tag_open=HTML('').format( - account_settings_url=settings.ACCOUNT_MICROFRONTEND_URL + account_settings_url=configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ), ), anchor_tag_close=HTML('') ) diff --git a/openedx/core/djangoapps/password_policy/tests/test_compliance.py b/openedx/core/djangoapps/password_policy/tests/test_compliance.py index 5d8a56d60c..69d57a00cd 100644 --- a/openedx/core/djangoapps/password_policy/tests/test_compliance.py +++ b/openedx/core/djangoapps/password_policy/tests/test_compliance.py @@ -199,3 +199,56 @@ class TestCompliance(TestCase): assert date1 == _get_compliance_deadline_for_user(staff) assert date1 == _get_compliance_deadline_for_user(privileged) assert date1 == _get_compliance_deadline_for_user(user) + + def test_warning_link_uses_site_config_account_mfe_url(self): + """ + When site config defines ACCOUNT_MICROFRONTEND_URL, the warning text should + use that value for the Account Settings anchor href. + """ + user = UserFactory() + password = "irrelevant" + siteconf_url = "https://accounts.siteconf.example" + + with patch( + "openedx.core.djangoapps.password_policy.compliance._check_user_compliance", + return_value=False, + ), patch( + "openedx.core.djangoapps.password_policy.compliance._get_compliance_deadline_for_user", + return_value=datetime.now(ZoneInfo("UTC")) + timedelta(days=1), + ), patch( + "openedx.core.djangoapps.password_policy.compliance.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default, + ): + with pytest.raises(NonCompliantPasswordWarning) as excinfo: + enforce_compliance_on_login(user, password) + + msg = str(excinfo.value) + assert f'Account Settings' in msg + + def test_warning_link_falls_back_to_settings_account_mfe_url(self): + """ + If site config doesn't override, the warning text should fall back to + settings.ACCOUNT_MICROFRONTEND_URL for the anchor href. + """ + user = UserFactory() + password = "irrelevant" + fallback = "https://accounts.settings.example" + + with override_settings(ACCOUNT_MICROFRONTEND_URL=fallback): + with patch( + "openedx.core.djangoapps.password_policy.compliance._check_user_compliance", + return_value=False, + ), patch( + "openedx.core.djangoapps.password_policy.compliance._get_compliance_deadline_for_user", + return_value=datetime.now(ZoneInfo("UTC")) + timedelta(days=1), + ), patch( + "openedx.core.djangoapps.password_policy.compliance.configuration_helpers.get_value", + # Simulate "no override": return default argument + side_effect=lambda key, default=None, *a, **k: default, + ): + with pytest.raises(NonCompliantPasswordWarning) as excinfo: + enforce_compliance_on_login(user, password) + + msg = str(excinfo.value) + assert f'Account Settings' in msg diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py index 3c8da9bd83..0333489a5c 100644 --- a/openedx/core/djangoapps/user_api/legacy_urls.py +++ b/openedx/core/djangoapps/user_api/legacy_urls.py @@ -6,6 +6,8 @@ from django.urls import path, re_path, include from django.views.generic import RedirectView from rest_framework import routers +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + from . import views as user_api_views from .models import UserPreference @@ -17,7 +19,12 @@ urlpatterns = [ # This redirect is needed for backward compatibility with the old URL structure for the authentication # workflows using third-party authentication providers until the authentication workflows fully support # the URL structure with MFEs. - re_path(r'^account(?:/settings)?/?$', RedirectView.as_view(url=settings.ACCOUNT_MICROFRONTEND_URL)), + re_path(r'^account(?:/settings)?/?$', RedirectView.as_view( + url=configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + )), + ), path('user_api/v1/', include(USER_API_ROUTER.urls)), re_path( fr'^user_api/v1/preferences/(?P{UserPreference.KEY_REGEX})/users/$', diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py index 036baf2125..ac050ea687 100644 --- a/openedx/core/djangoapps/user_authn/cookies.py +++ b/openedx/core/djangoapps/user_authn/cookies.py @@ -23,6 +23,7 @@ from openedx.core.djangoapps.oauth_dispatch.api import create_dot_access_token from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from common.djangoapps.util.json_request import JsonResponse from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user @@ -245,7 +246,10 @@ def _get_user_info_cookie_data(request, user): # External sites will need to have fallback mechanisms to handle this case # (most likely just hiding the links). try: - header_urls['account_settings'] = settings.ACCOUNT_MICROFRONTEND_URL + header_urls['account_settings'] = configuration_helpers.get_value( + 'ACCOUNT_MICROFRONTEND_URL', + settings.ACCOUNT_MICROFRONTEND_URL, + ) header_urls['learner_profile'] = urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}') except NoReverseMatch: pass diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index aa2e687102..e39faf384b 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch from urllib.parse import urljoin from django.conf import settings from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from edx_rest_framework_extensions.auth.jwt.decoder import jwt_decode_handler from edx_rest_framework_extensions.auth.jwt.middleware import JwtAuthCookieMiddleware @@ -154,3 +154,41 @@ class CookieTests(TestCase): self._assert_cookies_present(response, cookies_api.JWT_COOKIE_NAMES) self._assert_consistent_expires(response, num_of_unique_expires=1) self._assert_recreate_jwt_from_cookies(response, can_recreate=True) + + @skip_unless_lms + def test_user_info_cookie_account_settings_uses_site_config_value(self): + """ + When site config overrides ACCOUNT_MICROFRONTEND_URL, the user info cookie + should use that exact value for 'account_settings'. + """ + siteconf_url = "https://accounts.siteconf.example" + + with patch( + "openedx.core.djangoapps.user_authn.cookies.configuration_helpers.get_value", + side_effect=lambda key, default=None, *a, **k: + siteconf_url if key == "ACCOUNT_MICROFRONTEND_URL" else default, + ): + data = cookies_api._get_user_info_cookie_data(self.request, self.user) # pylint: disable=protected-access + + assert data["header_urls"]["account_settings"] == siteconf_url + + @skip_unless_lms + def test_user_info_cookie_account_settings_falls_back_to_settings(self): + """ + If site config does not override, the user info cookie should fall back to + settings.ACCOUNT_MICROFRONTEND_URL for 'account_settings'. + """ + fallback = "https://accounts.settings.example" + + with override_settings(ACCOUNT_MICROFRONTEND_URL=fallback): + with patch( + "openedx.core.djangoapps.user_authn.cookies.configuration_helpers.get_value", + # Simulate "no override": return the provided default + side_effect=lambda key, default=None, *a, **k: default, + ): + data = cookies_api._get_user_info_cookie_data( # pylint: disable=protected-access + self.request, + self.user, + ) + + assert data["header_urls"]["account_settings"] == fallback