Merge pull request #37721 from WGU-Open-edX/feature/37713-instructor-dashboard-course-info-api

FEAT: Instructor Dashboard - Course Info - API - Add missing content
This commit is contained in:
Feanil Patel
2025-12-16 11:54:59 -05:00
committed by GitHub
4 changed files with 166 additions and 81 deletions

View File

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

View File

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

View File

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

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