diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 12df219ed3..510de37e37 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -112,6 +112,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 @@ -348,6 +349,51 @@ 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. + """ + 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.py b/lms/djangoapps/instructor/views/api.py index 7778f6a2a7..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. """ @@ -2473,7 +2473,7 @@ class InstructorTasksListSerializer(serializers.Serializer): # pylint: disable= 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 fce3042b69..709ad8f7e0 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 ( @@ -48,71 +48,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) @@ -127,7 +62,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.", @@ -137,6 +72,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) @@ -148,7 +153,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 e651cdb4fc..e504867d2a 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 ) @@ -33,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. @@ -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 @@ -180,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)} @@ -202,6 +231,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