diff --git a/lms/djangoapps/instructor/ora.py b/lms/djangoapps/instructor/ora.py new file mode 100644 index 0000000000..b696cdb4d3 --- /dev/null +++ b/lms/djangoapps/instructor/ora.py @@ -0,0 +1,68 @@ +"""Utilities for retrieving Open Response Assessments (ORAs) data for instructor dashboards.""" + +from django.utils.translation import gettext as _ +from openassessment.data import OraAggregateData + +from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order + +DEFAULT_ORA_METRICS = { + 'total': 0, + 'training': 0, + 'peer': 0, + 'self': 0, + 'waiting': 0, + 'staff': 0, + 'final_grade_received': 0, +} + + +def get_open_response_assessment_list(course): + """ + Return a list of Open Response Assessments (ORAs) for a course. + + Uses OraAggregateData to collect response metrics, which transparently + supports both ORA1 and ORA2 data. + """ + course_key = course.id + store = modulestore() + + # Collect ORA2 response metrics keyed by block location + ora2_responses = OraAggregateData.collect_ora2_responses(str(course_key)) + + # Fetch all openassessment blocks in the course + openassessment_blocks = store.get_items( + course_key, + qualifiers={'category': 'openassessment'}, + ) + + parents_cache = {} + ora_items = [] + + for block in openassessment_blocks: + block_id = str(block.location) + parent_id = block.parent + + # Cache parent lookups to avoid repeated modulestore calls + if parent_id not in parents_cache: + parents_cache[parent_id] = store.get_item(parent_id) + + parent_block = parents_cache[parent_id] + + assessment_name = ( + _("Team") + " : " + block.display_name + if block.teams_enabled + else block.display_name + ) + + ora_assessment_data = { + 'id': block_id, + 'name': assessment_name, + 'parent_name': parent_block.display_name, + **DEFAULT_ORA_METRICS, + } + + # Merge collected metrics (if any) + ora_assessment_data.update(ora2_responses.get(block_id, {})) + ora_items.append(ora_assessment_data) + + return ora_items diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 510de37e37..63d9de0592 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -12,7 +12,7 @@ from django.urls import NoReverseMatch from django.urls import reverse from pytz import UTC from rest_framework import status -from rest_framework.test import APIClient +from rest_framework.test import APIClient, APITestCase from common.djangoapps.student.roles import CourseDataResearcherRole, CourseInstructorRole from common.djangoapps.student.tests.factories import ( @@ -22,9 +22,10 @@ from common.djangoapps.student.tests.factories import ( StaffFactory, UserFactory, ) +from common.djangoapps.student.models.course_enrollment import CourseEnrollment from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory @@ -890,3 +891,148 @@ class GradedSubsectionsViewTest(SharedModuleStoreTestCase): self.assertIn('subsection_id', item) self.assertIsInstance(item['display_name'], str) self.assertIsInstance(item['subsection_id'], str) + + +class ORABaseViewsTest(SharedModuleStoreTestCase, APITestCase): + """ + Base class for ORA view tests. + """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.course = CourseFactory.create() + cls.course_key = cls.course.location.course_key + + cls.ora_block = BlockFactory.create( + category="openassessment", + parent_location=cls.course.location, + display_name="test", + ) + cls.ora_usage_key = str(cls.ora_block.location) + + cls.password = "password" + cls.staff = StaffFactory(course_key=cls.course_key, password=cls.password) + + def log_in(self): + """Log in as staff by default.""" + self.client.login(username=self.staff.username, password=self.password) + + +class ORAViewTest(ORABaseViewsTest): + """ + Tests for the ORAAssessmentsView API endpoints. + """ + + view_name = "instructor_api_v2:ora_assessments" + + 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_assessment_list(self): + """Test retrieving the list of ORA assessments.""" + response = self.client.get( + self._get_url() + ) + + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 1 + ora_data = data[0] + assert ora_data['block_id'] == self.ora_usage_key + assert ora_data['unit_name'].startswith("Run") + assert ora_data['display_name'] == "test" + assert ora_data['total_responses'] == 0 + assert ora_data['training'] == 0 + assert ora_data['peer'] == 0 + assert ora_data['self'] == 0 + assert ora_data['waiting'] == 0 + assert ora_data['staff'] == 0 + assert ora_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 + + def test_pagination_of_assessments(self): + """Test pagination works correctly.""" + # Create additional ORA blocks to test pagination + for i in range(15): + BlockFactory.create( + category="openassessment", + parent_location=self.course.location, + display_name=f"test_{i}", + ) + + response = self.client.get(self._get_url(), {'page_size': 10}) + assert response.status_code == 200 + data = response.data + assert data['count'] == 16 # 1 original + 15 new + assert len(data['results']) == 10 # Page size + + # Get second page + response = self.client.get(self._get_url(), {'page_size': 10, 'page': 2}) + assert response.status_code == 200 + data = response.data + assert len(data['results']) == 6 # Remaining items + + def test_no_assessments(self): + """Test response when there are no ORA assessments.""" + # Create a new course with no ORA blocks + empty_course = CourseFactory.create() + empty_course_key = empty_course.location.course_key + empty_staff = StaffFactory(course_key=empty_course_key, password="password") + + # Log in as staff for the empty course + self.client.logout() + self.client.login(username=empty_staff.username, password="password") + + response = self.client.get( + reverse(self.view_name, kwargs={'course_id': str(empty_course_key)}) + ) + + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 0 diff --git a/lms/djangoapps/instructor/urls.py b/lms/djangoapps/instructor/urls.py index f559f5a09e..f312c9b55a 100644 --- a/lms/djangoapps/instructor/urls.py +++ b/lms/djangoapps/instructor/urls.py @@ -13,6 +13,12 @@ urlpatterns = [ 'api/instructor/v1/', include((api_urls.v1_api_urls, 'lms.djangoapps.instructor'), namespace='instructor_api_v1'), ), + # New v2 API endpoints are being introduced here + # They are intended to be used by MFEs and other API clients. + # For now, they are in in api_urls.v2_api_urls but the right place for them + # should be in a new module lms.djangoapps.instructor.api.v2.urls.py so they can be + # maintained separately. + # Saying that, it is likely the api_urls.v2_api_urls will be moved there in the near future. path( 'api/instructor/v2/', include((api_urls.v2_api_urls, 'lms.djangoapps.instructor'), namespace='instructor_api_v2'), diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index f23701cc1d..2653e03069 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -40,7 +40,12 @@ v2_api_urls = [ rf'^courses/{COURSE_ID_PATTERN}/graded_subsections$', api_v2.GradedSubsectionsView.as_view(), name='graded_subsections' - ) + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/ora$', + api_v2.ORAView.as_view(), + name='ora_assessments' + ), ] urlpatterns = [ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 709ad8f7e0..79c695fc55 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -14,6 +14,8 @@ from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.generics import GenericAPIView +from rest_framework.exceptions import NotFound from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control from django.utils.html import strip_tags @@ -25,12 +27,14 @@ 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 openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id from .serializers_v2 import ( InstructorTaskListSerializer, CourseInformationSerializerV2, BlockDueDateSerializerV2, + ORASerializer, ) from .tools import ( find_unit, @@ -349,3 +353,52 @@ class GradedSubsectionsView(APIView): } for unit in graded_subsections]} return Response(formated_subsections, status=status.HTTP_200_OK) + + +class ORAView(GenericAPIView): + """ + View to list all 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 = ORASerializer + + 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 list of all ORAs for the specified course. + """ + course = self.get_course() + + items = get_open_response_assessment_list(course) + + page = self.paginate_queryset(items) + if page is None: + # Pagination is required for this endpoint + return Response( + {"detail": "Pagination is required for this endpoint."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index e504867d2a..03e92a2e9a 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -416,3 +416,20 @@ class BlockDueDateSerializerV2(serializers.Serializer): raise serializers.ValidationError( _('The extension due date and time format is incorrect') ) from exc + + +class ORASerializer(serializers.Serializer): + """Serializer for Open Response Assessments (ORAs) in a course.""" + + block_id = serializers.CharField(source="id") + unit_name = serializers.CharField(source="parent_name") + display_name = serializers.CharField(source="name") + + # Metrics fields + total_responses = serializers.IntegerField(source="total") + training = serializers.IntegerField() + peer = serializers.IntegerField() + self = serializers.IntegerField() + waiting = serializers.IntegerField() + staff = serializers.IntegerField() + final_grade_received = serializers.IntegerField()