feat: add Instructor Dashboard ORA list API v2 (#37853)

* feat: add Instructor Dashboard ORA list API v2
This commit is contained in:
Daniel Wong
2026-01-20 08:27:04 -06:00
committed by GitHub
parent d06ff7d2ca
commit 761739020b
6 changed files with 298 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@@ -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 = [

View File

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

View File

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