Skip to content

Commit 847de8f

Browse files
fix: use COMMUNICATIONS_MICROFRONTEND_URL for bulk_email tab in instructor dashboard v2 API (#38018)
1 parent c562814 commit 847de8f

3 files changed

Lines changed: 195 additions & 19 deletions

File tree

lms/djangoapps/instructor/tests/test_api_v2.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from uuid import uuid4
99

1010
import ddt
11+
from django.test import SimpleTestCase, override_settings
1112
from django.urls import NoReverseMatch
1213
from django.urls import reverse
1314
from opaque_keys import InvalidKeyError
@@ -27,6 +28,7 @@
2728
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
2829
from common.djangoapps.student.models.course_enrollment import CourseEnrollment
2930
from lms.djangoapps.courseware.models import StudentModule
31+
from lms.djangoapps.instructor.views.serializers_v2 import CourseInformationSerializerV2
3032
from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory
3133
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
3234
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
@@ -456,6 +458,44 @@ def test_bulk_email_tab_not_visible(self, feature_enabled, user_attribute, mock_
456458

457459
self.assertNotIn('bulk_email', tab_ids)
458460

461+
@patch('lms.djangoapps.instructor.views.serializers_v2.is_bulk_email_feature_enabled')
462+
@override_settings(COMMUNICATIONS_MICROFRONTEND_URL='http://localhost:1984')
463+
def test_bulk_email_tab_url_uses_communications_mfe(self, mock_bulk_email_enabled):
464+
"""
465+
Test that the bulk_email tab URL uses COMMUNICATIONS_MICROFRONTEND_URL,
466+
not INSTRUCTOR_MICROFRONTEND_URL.
467+
"""
468+
mock_bulk_email_enabled.return_value = True
469+
470+
tabs = self._get_tabs_from_response(self.staff)
471+
bulk_email_tab = next((tab for tab in tabs if tab['tab_id'] == 'bulk_email'), None)
472+
473+
self.assertIsNotNone(bulk_email_tab)
474+
expected_url = f'http://localhost:1984/courses/{self.course.id}/bulk_email'
475+
self.assertEqual(bulk_email_tab['url'], expected_url)
476+
477+
@patch('lms.djangoapps.instructor.views.serializers_v2.is_bulk_email_feature_enabled')
478+
@override_settings(COMMUNICATIONS_MICROFRONTEND_URL=None)
479+
def test_bulk_email_tab_logs_warning_when_communications_mfe_url_not_set(self, mock_bulk_email_enabled):
480+
"""
481+
Test that a warning is logged when COMMUNICATIONS_MICROFRONTEND_URL is not set,
482+
and the resulting URL does not contain 'None'.
483+
"""
484+
mock_bulk_email_enabled.return_value = True
485+
486+
with self.assertLogs('lms.djangoapps.instructor.views.serializers_v2', level='WARNING') as cm:
487+
tabs = self._get_tabs_from_response(self.staff)
488+
489+
self.assertTrue(
490+
any('COMMUNICATIONS_MICROFRONTEND_URL is not configured' in msg for msg in cm.output)
491+
)
492+
bulk_email_tab = next((tab for tab in tabs if tab['tab_id'] == 'bulk_email'), None)
493+
self.assertIsNotNone(bulk_email_tab)
494+
self.assertFalse(
495+
bulk_email_tab['url'].startswith('None'),
496+
f"Tab URL should not start with 'None': {bulk_email_tab['url']}"
497+
)
498+
459499
def test_tabs_have_sort_order(self):
460500
"""
461501
Test that all tabs include a sort_order field.
@@ -503,7 +543,7 @@ def test_tabs_log_warning_when_mfe_url_not_set(self):
503543
tabs = self._get_tabs_from_response(self.staff)
504544

505545
self.assertTrue(
506-
any('INSTRUCTOR_MICROFRONTEND_URL is not set' in msg for msg in cm.output)
546+
any('INSTRUCTOR_MICROFRONTEND_URL is not configured' in msg for msg in cm.output)
507547
)
508548
# Tab URLs should use empty string as base, not "None"
509549
for tab in tabs:
@@ -534,6 +574,62 @@ def test_pacing_self_for_self_paced_course(self):
534574
self.assertEqual(response.data['pacing'], 'self')
535575

536576

577+
class BuildTabUrlTest(SimpleTestCase):
578+
"""
579+
Unit tests for CourseInformationSerializerV2._build_tab_url.
580+
581+
Tests the helper directly to verify URL joining behavior without
582+
going through the full API stack.
583+
"""
584+
585+
def _build(self, setting_name, *parts):
586+
return CourseInformationSerializerV2._build_tab_url(setting_name, *parts) # pylint: disable=protected-access
587+
588+
@override_settings(INSTRUCTOR_MICROFRONTEND_URL='http://localhost:2003')
589+
def test_joins_base_and_path_parts(self):
590+
"""Parts are joined with '/' separators."""
591+
result = self._build('INSTRUCTOR_MICROFRONTEND_URL', 'instructor', 'course-v1:edX+DemoX+Demo', 'grading')
592+
self.assertEqual(result, 'http://localhost:2003/instructor/course-v1:edX+DemoX+Demo/grading')
593+
594+
@override_settings(INSTRUCTOR_MICROFRONTEND_URL='http://localhost:2003/')
595+
def test_strips_trailing_slash_from_base(self):
596+
"""A trailing slash on the base URL does not produce a double slash."""
597+
result = self._build('INSTRUCTOR_MICROFRONTEND_URL', 'instructor', 'course-v1:edX+DemoX+Demo', 'grading')
598+
self.assertEqual(result, 'http://localhost:2003/instructor/course-v1:edX+DemoX+Demo/grading')
599+
600+
@override_settings(INSTRUCTOR_MICROFRONTEND_URL='http://localhost:2003')
601+
def test_strips_slashes_from_path_parts(self):
602+
"""Leading and trailing slashes on path parts are stripped before joining."""
603+
result = self._build('INSTRUCTOR_MICROFRONTEND_URL', '/instructor/', '/course-v1:edX+DemoX+Demo/', '/grading/')
604+
self.assertEqual(result, 'http://localhost:2003/instructor/course-v1:edX+DemoX+Demo/grading')
605+
606+
@override_settings(COMMUNICATIONS_MICROFRONTEND_URL=None)
607+
def test_logs_warning_and_returns_relative_url_when_setting_is_none(self):
608+
"""When the setting is None, a warning is logged and the URL is relative (no 'None' prefix)."""
609+
with self.assertLogs('lms.djangoapps.instructor.views.serializers_v2', level='WARNING') as cm:
610+
result = self._build(
611+
'COMMUNICATIONS_MICROFRONTEND_URL', 'courses', 'course-v1:edX+DemoX+Demo', 'bulk_email'
612+
)
613+
614+
self.assertTrue(any('COMMUNICATIONS_MICROFRONTEND_URL is not configured' in msg for msg in cm.output))
615+
self.assertFalse(result.startswith('None'))
616+
self.assertEqual(result, '/courses/course-v1:edX+DemoX+Demo/bulk_email')
617+
618+
def test_logs_warning_when_setting_does_not_exist(self):
619+
"""When the setting name is not defined at all, behavior matches the None case."""
620+
with self.assertLogs('lms.djangoapps.instructor.views.serializers_v2', level='WARNING') as cm:
621+
result = self._build('NONEXISTENT_MFE_URL', 'instructor', 'course-v1:edX+DemoX+Demo', 'grading')
622+
623+
self.assertTrue(any('NONEXISTENT_MFE_URL is not configured' in msg for msg in cm.output))
624+
self.assertEqual(result, '/instructor/course-v1:edX+DemoX+Demo/grading')
625+
626+
@override_settings(COMMUNICATIONS_MICROFRONTEND_URL='http://localhost:1984/communications/')
627+
def test_base_with_subpath_and_trailing_slash(self):
628+
"""Base URL with a subpath and trailing slash is joined cleanly."""
629+
result = self._build('COMMUNICATIONS_MICROFRONTEND_URL', 'courses', 'course-v1:edX+DemoX+Demo', 'bulk_email')
630+
self.assertEqual(result, 'http://localhost:1984/communications/courses/course-v1:edX+DemoX+Demo/bulk_email')
631+
632+
537633
@ddt.ddt
538634
class InstructorTaskListViewTest(SharedModuleStoreTestCase):
539635
"""

lms/djangoapps/instructor/views/serializers_v2.py

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,37 @@ class CourseInformationSerializerV2(serializers.Serializer):
7676
help_text="Message about analytics dashboard availability"
7777
)
7878

79+
@staticmethod
80+
def _build_tab_url(setting_name, *path_parts):
81+
"""
82+
Build a tab URL from a Django setting and path parts.
83+
84+
Retrieves the base URL from `setting_name`, strips any trailing slash,
85+
then joins the provided path parts (stripping their leading/trailing
86+
slashes) with `/` separators — behaving like ``os.path.join`` for URLs.
87+
88+
Logs a warning and falls back to a relative URL if the setting is unset.
89+
90+
Example:
91+
92+
_build_tab_url('INSTRUCTOR_MICROFRONTEND_URL', 'instructor', course_key, 'grading')
93+
# => 'http://localhost:2003/instructor/course-v1:.../grading'
94+
95+
_build_tab_url('COMMUNICATIONS_MICROFRONTEND_URL', 'courses', course_key, 'bulk_email')
96+
# => 'http://localhost:1984/communications/courses/course-v1:.../bulk_email'
97+
"""
98+
base_url = getattr(settings, setting_name, None)
99+
if base_url is None:
100+
log.warning('%s is not configured.', setting_name)
101+
base_url = ''
102+
parts = [base_url.rstrip('/')] + [str(part).strip('/') for part in path_parts]
103+
return '/'.join(parts)
104+
79105
def get_tabs(self, data):
80106
"""Get serialized course tabs."""
81107
request = data['request']
82108
course = data['course']
83109
course_key = course.id
84-
mfe_base_url = settings.INSTRUCTOR_MICROFRONTEND_URL
85-
86-
if not mfe_base_url:
87-
log.warning('INSTRUCTOR_MICROFRONTEND_URL is not set.')
88-
mfe_base_url = ''
89110

90111
access = {
91112
'admin': request.user.is_staff,
@@ -110,31 +131,56 @@ def get_tabs(self, data):
110131
{
111132
'tab_id': 'course_info',
112133
'title': _('Course Info'),
113-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/course_info',
134+
'url': self._build_tab_url(
135+
'INSTRUCTOR_MICROFRONTEND_URL',
136+
'instructor',
137+
course_key,
138+
'course_info'
139+
),
114140
'sort_order': 10,
115141
},
116142
{
117143
'tab_id': 'enrollments',
118144
'title': _('Enrollments'),
119-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/enrollments',
145+
'url': self._build_tab_url(
146+
'INSTRUCTOR_MICROFRONTEND_URL',
147+
'instructor',
148+
course_key,
149+
'enrollments'
150+
),
120151
'sort_order': 20,
121152
},
122153
{
123-
"tab_id": "course_team",
124-
"title": "Course Team",
125-
"url": f'{mfe_base_url}/instructor/{str(course_key)}/course_team',
154+
'tab_id': 'course_team',
155+
'title': _('Course Team'),
156+
'url': self._build_tab_url(
157+
'INSTRUCTOR_MICROFRONTEND_URL',
158+
'instructor',
159+
course_key,
160+
'course_team'
161+
),
126162
'sort_order': 30,
127163
},
128164
{
129165
'tab_id': 'grading',
130166
'title': _('Grading'),
131-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/grading',
167+
'url': self._build_tab_url(
168+
'INSTRUCTOR_MICROFRONTEND_URL',
169+
'instructor',
170+
course_key,
171+
'grading'
172+
),
132173
'sort_order': 40,
133174
},
134175
{
135176
'tab_id': 'cohorts',
136177
'title': _('Cohorts'),
137-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/cohorts',
178+
'url': self._build_tab_url(
179+
'INSTRUCTOR_MICROFRONTEND_URL',
180+
'instructor',
181+
course_key,
182+
'cohorts'
183+
),
138184
'sort_order': 90,
139185
},
140186
])
@@ -143,23 +189,38 @@ def get_tabs(self, data):
143189
tabs.append({
144190
'tab_id': 'bulk_email',
145191
'title': _('Bulk Email'),
146-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/bulk_email',
192+
'url': self._build_tab_url(
193+
'COMMUNICATIONS_MICROFRONTEND_URL',
194+
'courses',
195+
course_key,
196+
'bulk_email'
197+
),
147198
'sort_order': 100,
148199
})
149200

150201
if access['instructor'] and is_enabled_for_course(course_key):
151202
tabs.append({
152203
'tab_id': 'date_extensions',
153204
'title': _('Date Extensions'),
154-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/date_extensions',
205+
'url': self._build_tab_url(
206+
'INSTRUCTOR_MICROFRONTEND_URL',
207+
'instructor',
208+
course_key,
209+
'date_extensions'
210+
),
155211
'sort_order': 50,
156212
})
157213

158214
if access['data_researcher']:
159215
tabs.append({
160216
'tab_id': 'data_downloads',
161217
'title': _('Data Downloads'),
162-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/data_downloads',
218+
'url': self._build_tab_url(
219+
'INSTRUCTOR_MICROFRONTEND_URL',
220+
'instructor',
221+
course_key,
222+
'data_downloads'
223+
),
163224
'sort_order': 60,
164225
})
165226

@@ -174,7 +235,12 @@ def get_tabs(self, data):
174235
tabs.append({
175236
'tab_id': 'open_responses',
176237
'title': _('Open Responses'),
177-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/open_responses',
238+
'url': self._build_tab_url(
239+
'INSTRUCTOR_MICROFRONTEND_URL',
240+
'instructor',
241+
course_key,
242+
'open_responses'
243+
),
178244
'sort_order': 70,
179245
})
180246

@@ -186,7 +252,12 @@ def get_tabs(self, data):
186252
tabs.append({
187253
'tab_id': 'certificates',
188254
'title': _('Certificates'),
189-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/certificates',
255+
'url': self._build_tab_url(
256+
'INSTRUCTOR_MICROFRONTEND_URL',
257+
'instructor',
258+
course_key,
259+
'certificates'
260+
),
190261
'sort_order': 80,
191262
})
192263

@@ -203,7 +274,12 @@ def get_tabs(self, data):
203274
tabs.append({
204275
'tab_id': 'special_exams',
205276
'title': _('Special Exams'),
206-
'url': f'{mfe_base_url}/instructor/{str(course_key)}/special_exams',
277+
'url': self._build_tab_url(
278+
'INSTRUCTOR_MICROFRONTEND_URL',
279+
'instructor',
280+
course_key,
281+
'special_exams'
282+
),
207283
'sort_order': 110,
208284
})
209285

lms/envs/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2845,6 +2845,10 @@
28452845
# .. setting_default: None
28462846
# .. setting_description: Base URL of the micro-frontend-based instructor app.
28472847
INSTRUCTOR_MICROFRONTEND_URL = None
2848+
# .. setting_name: COMMUNICATIONS_MICROFRONTEND_URL
2849+
# .. setting_default: None
2850+
# .. setting_description: Base URL of the micro-frontend-based communications app.
2851+
COMMUNICATIONS_MICROFRONTEND_URL = None
28482852
# .. setting_name: DISCUSSION_SPAM_URLS
28492853
# .. setting_default: []
28502854
# .. setting_description: Urls to filter from discussion content to avoid spam

0 commit comments

Comments
 (0)