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
This commit is contained in:
Brian Buck
2025-12-03 11:42:16 -07:00
parent 70ea641c99
commit 81bdfdd8d2
3 changed files with 125 additions and 10 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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()