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:
@@ -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.
|
||||
|
||||
@@ -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.")
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user