feat: add Instructor Dashboard ORA list API v2 (#37853)
* feat: add Instructor Dashboard ORA list API v2
This commit is contained in:
68
lms/djangoapps/instructor/ora.py
Normal file
68
lms/djangoapps/instructor/ora.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user