From f65d423c77d8aac885d7f83dc819925cf0ca45a6 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Tue, 20 Jan 2026 14:32:06 -0600 Subject: [PATCH] feat: add Instructor Dashboard ORA summary API v2 (#37858) --- lms/djangoapps/instructor/ora.py | 29 ++++++ .../instructor/tests/test_api_v2.py | 90 +++++++++++++++++++ lms/djangoapps/instructor/views/api_urls.py | 5 ++ lms/djangoapps/instructor/views/api_v2.py | 44 ++++++++- .../instructor/views/serializers_v2.py | 15 ++++ 5 files changed, 182 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/ora.py b/lms/djangoapps/instructor/ora.py index b696cdb4d3..9798176838 100644 --- a/lms/djangoapps/instructor/ora.py +++ b/lms/djangoapps/instructor/ora.py @@ -66,3 +66,32 @@ def get_open_response_assessment_list(course): ora_items.append(ora_assessment_data) return ora_items + + +def get_ora_summary(course): + """ + Return aggregated ORA statistics for a course. + """ + ora_items = get_open_response_assessment_list(course) + summary = { + 'total_units': 0, + 'total_assessments': 0, + 'total_responses': 0, + 'training': 0, + 'peer': 0, + 'self': 0, + 'waiting': 0, + 'staff': 0, + 'final_grade_received': 0, + } + for item in ora_items: + summary['total_assessments'] += 1 + summary['total_units'] += 1 # Assuming one assessment per unit + summary['total_responses'] += item['total'] + summary['training'] += item['training'] + summary['peer'] += item['peer'] + summary['self'] += item['self'] + summary['waiting'] += item['waiting'] + summary['staff'] += item['staff'] + summary['final_grade_received'] += item['final_grade_received'] + return summary diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 63d9de0592..b2ce1bacc1 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -1036,3 +1036,93 @@ class ORAViewTest(ORABaseViewsTest): assert response.status_code == 200 data = response.data['results'] assert len(data) == 0 + + +class ORASummaryViewTest(ORABaseViewsTest): + """ + Tests for the ORASummaryView API endpoints. + """ + + view_name = "instructor_api_v2:ora_summary" + + def setUp(self): + super().setUp() + self.log_in() + + def _get_url(self, course_id=None): + """Helper to get the API URL.""" + if course_id is None: + course_id = str(self.course_key) + return reverse(self.view_name, kwargs={'course_id': course_id}) + + def test_get_ora_summary(self): + """Test retrieving the ORA summary.""" + + BlockFactory.create( + category="openassessment", + parent_location=self.course.location, + display_name="test2", + ) + + response = self.client.get( + self._get_url() + ) + + assert response.status_code == 200 + data = response.data + assert 'total_units' in data + assert 'total_assessments' in data + assert 'total_responses' in data + assert 'training' in data + assert 'peer' in data + assert 'self' in data + assert 'waiting' in data + assert 'staff' in data + assert 'final_grade_received' in data + + assert data['total_units'] == 2 + assert data['total_assessments'] == 2 + assert data['total_responses'] == 0 + assert data['training'] == 0 + assert data['peer'] == 0 + assert data['self'] == 0 + assert data['waiting'] == 0 + assert data['staff'] == 0 + assert data['final_grade_received'] == 0 + + def test_invalid_course_id(self): + """Test error handling for invalid course ID.""" + invalid_course_id = 'invalid-course-id' + url = self._get_url() + response = self.client.get(url.replace(str(self.course_key), invalid_course_id)) + assert response.status_code == 404 + + def test_permission_denied_for_non_staff(self): + """Test that non-staff users cannot access the endpoint.""" + # Log out staff + self.client.logout() + + # Create a non-staff user and enroll them in the course + user = UserFactory(password="password") + CourseEnrollment.enroll(user, self.course_key) + + # Log in as the non-staff user + self.client.login(username=user.username, password="password") + + response = self.client.get(self._get_url()) + assert response.status_code == 403 + + def test_permission_allowed_for_instructor(self): + """Test that instructor users can access the endpoint.""" + # Log out staff user + self.client.logout() + + # Create instructor for this course + instructor = InstructorFactory(course_key=self.course_key, password="password") + + # Log in as instructor + self.client.login(username=instructor.username, password="password") + + # Access the endpoint + response = self.client.get(self._get_url()) + assert response.status_code == 200 diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 2653e03069..5a10a826fc 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -46,6 +46,11 @@ v2_api_urls = [ api_v2.ORAView.as_view(), name='ora_assessments' ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/ora_summary$', + api_v2.ORASummaryView.as_view(), + name='ora_summary' + ), ] urlpatterns = [ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 79c695fc55..ff6d7c04aa 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -27,7 +27,7 @@ from lms.djangoapps.instructor import permissions from lms.djangoapps.instructor.views.api import _display_unit, get_student_from_identifier from lms.djangoapps.instructor.views.instructor_task_helpers import extract_task_features from lms.djangoapps.instructor_task import api as task_api -from lms.djangoapps.instructor.ora import get_open_response_assessment_list +from lms.djangoapps.instructor.ora import get_open_response_assessment_list, get_ora_summary from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id from .serializers_v2 import ( @@ -35,6 +35,7 @@ from .serializers_v2 import ( CourseInformationSerializerV2, BlockDueDateSerializerV2, ORASerializer, + ORASummarySerializer, ) from .tools import ( find_unit, @@ -402,3 +403,44 @@ class ORAView(GenericAPIView): serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) + + +class ORASummaryView(GenericAPIView): + """ + View to get a summary of Open Response Assessments (ORAs) for a given course. + + * Requires token authentication. + * Only instructors or staff for the course are able to access this view. + """ + permission_classes = [IsAuthenticated, permissions.InstructorPermission] + permission_name = permissions.VIEW_DASHBOARD + serializer_class = ORASummarySerializer + + def get_course(self): + """ + Retrieve the course object based on the course_id URL parameter. + + Validates that the course exists and is not deprecated. + Raises NotFound if the course does not exist. + """ + course_id = self.kwargs.get("course_id") + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError as exc: + log.error("Unable to find course with course key %s while loading the Instructor Dashboard.", course_id) + raise NotFound("Course not found") from exc + if course_key.deprecated: + raise NotFound("Course not found") + course = get_course_by_id(course_key, depth=None) + return course + + def get(self, request, *args, **kwargs): + """ + Return a summary of ORAs for the specified course. + """ + course = self.get_course() + + items = get_ora_summary(course) + + serializer = self.get_serializer(items) + return Response(serializer.data) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index 03e92a2e9a..9f0996a747 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -433,3 +433,18 @@ class ORASerializer(serializers.Serializer): waiting = serializers.IntegerField() staff = serializers.IntegerField() final_grade_received = serializers.IntegerField() + + +class ORASummarySerializer(serializers.Serializer): + """ + Aggregated ORA statistics for a course + """ + total_units = serializers.IntegerField() + total_assessments = serializers.IntegerField() + total_responses = serializers.IntegerField() + training = serializers.IntegerField() + peer = serializers.IntegerField() + self = serializers.IntegerField() + waiting = serializers.IntegerField() + staff = serializers.IntegerField() + final_grade_received = serializers.IntegerField()