From 81bdfdd8d2ef17d671a95c4e58a1acfe6bf686f4 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 3 Dec 2025 11:42:16 -0700 Subject: [PATCH 1/4] feat: Adds course_run field to CourseInformationSerializer v2 Adds sort_order field to tabs JSON in the tabs list in CourseInformationSerializer v2 serializer Move course_run below course_number and also move the serializer method. Auto-format removing extraneous white space. Add trailing commas to the instructor course tabs list to improve the diff going forward. Add comment about sort order --- .../instructor/tests/test_api_v2.py | 11 +++ lms/djangoapps/instructor/views/api_v2.py | 70 +++++++++++++++++++ .../instructor/views/serializers_v2.py | 54 +++++++++++--- 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index d969858b37..4747b447d4 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -110,6 +110,7 @@ class CourseMetadataViewTest(SharedModuleStoreTestCase): self.assertEqual(data['display_name'], 'Demonstration Course') self.assertEqual(data['org'], 'edX') self.assertEqual(data['course_number'], 'DemoX') + self.assertEqual(data['course_run'], 'Demo_Course') self.assertEqual(data['pacing'], 'instructor') # Verify enrollment counts structure @@ -346,6 +347,16 @@ class CourseMetadataViewTest(SharedModuleStoreTestCase): tab_ids = [tab['tab_id'] for tab in tabs] self.assertIn('certificates', tab_ids) + def test_tabs_have_sort_order(self): + """ + Test that all tabs include a sort_order field. + """ + tabs = self._get_tabs_from_response(self.staff) + + for tab in tabs: + self.assertIn('sort_order', tab) + self.assertIsInstance(tab['sort_order'], int) + def test_disable_buttons_false_for_small_course(self): """ Test that disable_buttons is False for courses with <=200 enrollments. diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 3cc8c32463..cc76cd80cd 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -135,6 +135,76 @@ class CourseMetadataView(DeveloperErrorViewMixin, APIView): """ Retrieve comprehensive course information including metadata, enrollment statistics, dashboard configuration, and user permissions. + + **Use Cases** + + Retrieve comprehensive course metadata including enrollment counts, dashboard configuration, + permissions, and navigation sections. + + **Example Requests** + + GET /api/instructor/v2/courses/{course_id} + + **Response Values** + + { + "course_id": "course-v1:edX+DemoX+Demo_Course", + "display_name": "Demonstration Course", + "org": "edX", + "course_number": "DemoX", + "enrollment_start": "2013-02-05T00:00:00Z", + "enrollment_end": null, + "start": "2013-02-05T05:00:00Z", + "end": "2024-12-31T23:59:59Z", + "pacing": "instructor", + "has_started": true, + "has_ended": false, + "total_enrollment": 150, + "enrollment_counts": { + "total": 150, + "audit": 100, + "verified": 40, + "honor": 10 + }, + "num_sections": 12, + "grade_cutoffs": "A is 0.9, B is 0.8, C is 0.7, D is 0.6", + "course_errors": [], + "studio_url": "https://studio.example.com/course/course-v1:edX+DemoX+2024", + "permissions": { + "admin": false, + "instructor": true, + "finance_admin": false, + "sales_admin": false, + "staff": true, + "forum_admin": true, + "data_researcher": false + }, + "tabs": [ + { + "tab_id": "courseware", + "title": "Course", + "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/courseware" + }, + { + "tab_id": "progress", + "title": "Progress", + "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/progress" + }, + ], + "disable_buttons": false, + "analytics_dashboard_message": "To gain insights into student enrollment and participation..." + } + + **Parameters** + + course_key: Course key for the course. + + **Returns** + + * 200: OK - Returns course metadata + * 401: Unauthorized - User is not authenticated + * 403: Forbidden - User lacks instructor permissions + * 404: Not Found - Course does not exist """ course_key = CourseKey.from_string(course_id) course = get_course_by_id(course_key) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index e651cdb4fc..f6fb59f069 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -19,6 +19,7 @@ from common.djangoapps.student.roles import ( CourseSalesAdminRole, CourseStaffRole, ) +from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled from lms.djangoapps.certificates.models import ( CertificateGenerationConfiguration ) @@ -44,6 +45,7 @@ class CourseInformationSerializer(serializers.Serializer): display_name = serializers.SerializerMethodField(help_text="Course display name") org = serializers.SerializerMethodField(help_text="Organization identifier") course_number = serializers.SerializerMethodField(help_text="Course number") + course_run = serializers.SerializerMethodField(help_text="Course run identifier") enrollment_start = serializers.SerializerMethodField(help_text="Enrollment start date (ISO 8601 with timezone)") enrollment_end = serializers.SerializerMethodField(help_text="Enrollment end date (ISO 8601 with timezone)") start = serializers.SerializerMethodField(help_text="Course start date (ISO 8601 with timezone)") @@ -83,47 +85,70 @@ class CourseInformationSerializer(serializers.Serializer): 'data_researcher': request.user.has_perm(permissions.CAN_RESEARCH, course_key), } tabs = [] + + # NOTE: The Instructor experience can be extended via FE plugins that insert tabs + # dynamically using explicit priority values. The sort_order field provides a stable + # ordering contract so plugins created via the FE can reliably position themselves + # relative to backend-defined tabs (e.g., "insert between Grading and Course Team"). + # Without explicit sort_order values, there's no deterministic way to interleave + # backend tabs with plugin-inserted tabs, and tab order could shift based on + # load/config timing. if access['staff']: tabs.extend([ { 'tab_id': 'course_info', 'title': _('Course Info'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/course_info' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/course_info', + 'sort_order': 10, }, { 'tab_id': 'enrollments', 'title': _('Enrollments'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/enrollments' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/enrollments', + 'sort_order': 20, }, { "tab_id": "course_team", "title": "Course Team", - "url": f'{mfe_base_url}/instructor/{str(course_key)}/course_team' + "url": f'{mfe_base_url}/instructor/{str(course_key)}/course_team', + 'sort_order': 30, }, { 'tab_id': 'grading', 'title': _('Grading'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/grading' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/grading', + 'sort_order': 40, }, { 'tab_id': 'cohorts', 'title': _('Cohorts'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/cohorts' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/cohorts', + 'sort_order': 90, }, ]) + if access['staff'] and is_bulk_email_feature_enabled(course_key): + tabs.append({ + 'tab_id': 'bulk_email', + 'title': _('Bulk Email'), + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/bulk_email', + 'sort_order': 100, + }) + if access['instructor'] and is_enabled_for_course(course_key): tabs.append({ 'tab_id': 'date_extensions', 'title': _('Date Extensions'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/date_extensions' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/date_extensions', + 'sort_order': 50, }) if access['data_researcher']: tabs.append({ 'tab_id': 'data_downloads', 'title': _('Data Downloads'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/data_downloads' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/data_downloads', + 'sort_order': 60, }) openassessment_blocks = modulestore().get_items( @@ -137,7 +162,8 @@ class CourseInformationSerializer(serializers.Serializer): tabs.append({ 'tab_id': 'open_responses', 'title': _('Open Responses'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/open_responses' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/open_responses', + 'sort_order': 70, }) # Note: This is hidden for all CCXs @@ -148,7 +174,8 @@ class CourseInformationSerializer(serializers.Serializer): tabs.append({ 'tab_id': 'certificates', 'title': _('Certificates'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/certificates' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/certificates', + 'sort_order': 80, }) user_has_access = any([ @@ -164,7 +191,8 @@ class CourseInformationSerializer(serializers.Serializer): tabs.append({ 'tab_id': 'special_exams', 'title': _('Special Exams'), - 'url': f'{mfe_base_url}/instructor/{str(course_key)}/special_exams' + 'url': f'{mfe_base_url}/instructor/{str(course_key)}/special_exams', + 'sort_order': 110, }) # We provide the tabs in a specific order based on how it was @@ -202,6 +230,11 @@ class CourseInformationSerializer(serializers.Serializer): """Get course number.""" return data['course'].id.course + def get_course_run(self, data): + """Get course run identifier""" + course_id = data['course'].id + return course_id.run if course_id.run is not None else '' + def get_enrollment_start(self, data): """Get enrollment start date.""" return data['course'].enrollment_start @@ -323,6 +356,7 @@ class CourseInformationSerializer(serializers.Serializer): return get_analytics_dashboard_message(data['course'].id) + class InstructorTaskSerializer(serializers.Serializer): """Serializer for instructor task details.""" task_id = serializers.UUIDField() From baaa666435869f4c43ef149bcdb9ce8ea6347cab Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 3 Dec 2025 11:44:01 -0700 Subject: [PATCH 2/4] fix: Fixes a bug in OpenAPI schema generation Fixes a bug in OpenAPI schema generation that would fail due to overlapping namespaces between v1 and v2 APIs --- lms/djangoapps/instructor/views/api.py | 3 +++ lms/djangoapps/instructor/views/serializers_v2.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 7778f6a2a7..32f8d8e02a 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2467,6 +2467,9 @@ class InstructorTaskSerializer(serializers.Serializer): # pylint: disable=abstr duration_sec = serializers.CharField(help_text=_("Task duration information, if known")) task_message = serializers.CharField(help_text=_("User-friendly task status information, if available.")) + class Meta: + ref_name = "instructor.InstructorTask.v1" + class InstructorTasksListSerializer(serializers.Serializer): # pylint: disable=abstract-method """ diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index f6fb59f069..bba3d95143 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -370,6 +370,9 @@ class InstructorTaskSerializer(serializers.Serializer): task_input = serializers.CharField() task_output = serializers.CharField(allow_null=True) + class Meta: + ref_name = "instructor.InstructorTask.v2" + class InstructorTaskListSerializer(serializers.Serializer): tasks = InstructorTaskSerializer(many=True) From 88f2856c823ddec51e5153855491edc11784a7d2 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 3 Dec 2025 11:56:52 -0700 Subject: [PATCH 3/4] feat: Adds the bulk_email tab for staff level users in CourseInformationSerializer v2 serializer. Rename v2 InstructorTaskSerializer to InstructorTaskSerializerV2 Rename v2 CourseInformationSerializer to CourseInformationSerializerV2 --- .../instructor/tests/test_api_v2.py | 35 +++++++++++++++++++ lms/djangoapps/instructor/views/api.py | 7 ++-- lms/djangoapps/instructor/views/api_v2.py | 6 ++-- .../instructor/views/serializers_v2.py | 6 ++-- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 4747b447d4..7440dc1bfd 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -347,6 +347,41 @@ class CourseMetadataViewTest(SharedModuleStoreTestCase): tab_ids = [tab['tab_id'] for tab in tabs] self.assertIn('certificates', tab_ids) + @patch('lms.djangoapps.instructor.views.serializers_v2.is_bulk_email_feature_enabled') + @ddt.data('staff', 'instructor', 'admin') + def test_bulk_email_tab_when_enabled(self, user_attribute, mock_bulk_email_enabled): + """ + Test that the bulk_email tab appears for all staff-level users when is_bulk_email_feature_enabled is True. + """ + mock_bulk_email_enabled.return_value = True + + user = getattr(self, user_attribute) + tabs = self._get_tabs_from_response(user) + tab_ids = [tab['tab_id'] for tab in tabs] + + self.assertIn('bulk_email', tab_ids) + + @patch('lms.djangoapps.instructor.views.serializers_v2.is_bulk_email_feature_enabled') + @ddt.data( + (False, 'staff'), + (False, 'instructor'), + (False, 'admin'), + (True, 'data_researcher'), + ) + @ddt.unpack + def test_bulk_email_tab_not_visible(self, feature_enabled, user_attribute, mock_bulk_email_enabled): + """ + Test that the bulk_email tab does not appear when is_bulk_email_feature_enabled is False or the user is not + a user with staff permissions. + """ + mock_bulk_email_enabled.return_value = feature_enabled + + user = getattr(self, user_attribute) + tabs = self._get_tabs_from_response(user) + tab_ids = [tab['tab_id'] for tab in tabs] + + self.assertNotIn('bulk_email', tab_ids) + def test_tabs_have_sort_order(self): """ Test that all tabs include a sort_order field. diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 32f8d8e02a..946b9be000 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2448,7 +2448,7 @@ class ListEmailContent(APIView): return JsonResponse(response_payload) -class InstructorTaskSerializer(serializers.Serializer): # pylint: disable=abstract-method +class InstructorTaskSerializerV2(serializers.Serializer): # pylint: disable=abstract-method """ Serializer that describes the format of a single instructor task. """ @@ -2467,16 +2467,13 @@ class InstructorTaskSerializer(serializers.Serializer): # pylint: disable=abstr duration_sec = serializers.CharField(help_text=_("Task duration information, if known")) task_message = serializers.CharField(help_text=_("User-friendly task status information, if available.")) - class Meta: - ref_name = "instructor.InstructorTask.v1" - class InstructorTasksListSerializer(serializers.Serializer): # pylint: disable=abstract-method """ Serializer to describe the response of the instructor tasks list API. """ tasks = serializers.ListSerializer( - child=InstructorTaskSerializer(), + child=InstructorTaskSerializerV2(), help_text=_("List of instructor tasks.") ) diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index cc76cd80cd..5e4230b066 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -29,7 +29,7 @@ from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id from .serializers_v2 import ( InstructorTaskListSerializer, - CourseInformationSerializer, + CourseInformationSerializerV2, BlockDueDateSerializerV2, ) from .tools import ( @@ -125,7 +125,7 @@ class CourseMetadataView(DeveloperErrorViewMixin, APIView): ), ], responses={ - 200: CourseInformationSerializer, + 200: CourseInformationSerializerV2, 401: "The requesting user is not authenticated.", 403: "The requesting user lacks instructor access to the course.", 404: "The requested course does not exist.", @@ -216,7 +216,7 @@ class CourseMetadataView(DeveloperErrorViewMixin, APIView): 'user': request.user, 'request': request } - serializer = CourseInformationSerializer(context) + serializer = CourseInformationSerializerV2(context) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index bba3d95143..339c2c9e99 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -34,7 +34,7 @@ from xmodule.modulestore.django import modulestore from .tools import get_student_from_identifier, parse_datetime, DashboardError -class CourseInformationSerializer(serializers.Serializer): +class CourseInformationSerializerV2(serializers.Serializer): """ Serializer for comprehensive course information. @@ -208,6 +208,7 @@ class CourseInformationSerializer(serializers.Serializer): 'open_responses', 'certificates', 'cohorts', + 'bulk_email', 'special_exams', ] order_index = {tab: i for i, tab in enumerate(tabs_order)} @@ -370,9 +371,6 @@ class InstructorTaskSerializer(serializers.Serializer): task_input = serializers.CharField() task_output = serializers.CharField(allow_null=True) - class Meta: - ref_name = "instructor.InstructorTask.v2" - class InstructorTaskListSerializer(serializers.Serializer): tasks = InstructorTaskSerializer(many=True) From 11fd2225e675403e989a01b10815876e68f3adf6 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 3 Dec 2025 15:11:50 -0700 Subject: [PATCH 4/4] fix: Fix OpenAPI schema generation Fix OpenAPI schema generation for GET /api/instructor/v2/courses/{course_id} to properly show documentation. --- lms/djangoapps/instructor/views/api_v2.py | 65 ------------------- .../instructor/views/serializers_v2.py | 1 - 2 files changed, 66 deletions(-) diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 5e4230b066..3e4d70d7af 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -46,71 +46,6 @@ class CourseMetadataView(DeveloperErrorViewMixin, APIView): Retrieve comprehensive course metadata including enrollment counts, dashboard configuration, permissions, and navigation sections. - - **Example Requests** - - GET /api/instructor/v2/courses/{course_id} - - **Response Values** - - { - "course_id": "course-v1:edX+DemoX+Demo_Course", - "display_name": "Demonstration Course", - "org": "edX", - "course_number": "DemoX", - "enrollment_start": "2013-02-05T00:00:00Z", - "enrollment_end": null, - "start": "2013-02-05T05:00:00Z", - "end": "2024-12-31T23:59:59Z", - "pacing": "instructor", - "has_started": true, - "has_ended": false, - "total_enrollment": 150, - "enrollment_counts": { - "total": 150, - "audit": 100, - "verified": 40, - "honor": 10 - }, - "num_sections": 12, - "grade_cutoffs": "A is 0.9, B is 0.8, C is 0.7, D is 0.6", - "course_errors": [], - "studio_url": "https://studio.example.com/course/course-v1:edX+DemoX+2024", - "permissions": { - "admin": false, - "instructor": true, - "finance_admin": false, - "sales_admin": false, - "staff": true, - "forum_admin": true, - "data_researcher": false - }, - "tabs": [ - { - "tab_id": "courseware", - "title": "Course", - "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/courseware" - }, - { - "tab_id": "progress", - "title": "Progress", - "url": "INSTRUCTOR_MICROFRONTEND_URL/courses/course-v1:edX+DemoX+2024/progress" - }, - ], - "disable_buttons": false, - "analytics_dashboard_message": "To gain insights into student enrollment and participation..." - } - - **Parameters** - - course_key: Course key for the course. - - **Returns** - - * 200: OK - Returns course metadata - * 401: Unauthorized - User is not authenticated - * 403: Forbidden - User lacks instructor permissions - * 404: Not Found - Course does not exist """ permission_classes = (IsAuthenticated, permissions.InstructorPermission) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index 339c2c9e99..e504867d2a 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -357,7 +357,6 @@ class CourseInformationSerializerV2(serializers.Serializer): return get_analytics_dashboard_message(data['course'].id) - class InstructorTaskSerializer(serializers.Serializer): """Serializer for instructor task details.""" task_id = serializers.UUIDField()