From 81bdfdd8d2ef17d671a95c4e58a1acfe6bf686f4 Mon Sep 17 00:00:00 2001 From: Brian Buck Date: Wed, 3 Dec 2025 11:42:16 -0700 Subject: [PATCH] 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()