feat: instructor dashboard - added graded subsections endpoint (#37708)
* feat: added extensions v2 endpoint * chore: move new api related things to new v2 files * chore: cleanup * chore: codestyle fixes * chore: manual codestyle fix * chore: better class naming * chore: fixed trailing new lins * chore: better response for bad learner id * chore: fixed comments * chore: fixed linting issues * chore: commit fixes * feat: add GET graded subsections endpoint * chore: fixed lint issue * chore: pylint fixes * chore: lint fix * chore: lint fixes * chore: lint fix * chore: lint fix * chore: updated JsonResponse to Response for consitency * chore: syntax fix after rebase * chore: re-added url after master rebase
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
Unit tests for instructor API v2 endpoints.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
@@ -9,6 +10,7 @@ from uuid import uuid4
|
||||
import ddt
|
||||
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
|
||||
|
||||
@@ -650,3 +652,195 @@ class InstructorTaskListViewTest(SharedModuleStoreTestCase):
|
||||
self.assertIn('task_type', task_data)
|
||||
self.assertIn('task_state', task_data)
|
||||
self.assertIn('created', task_data)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class GradedSubsectionsViewTest(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the GradedSubsectionsView API endpoint.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.course = CourseFactory.create(
|
||||
org='edX',
|
||||
number='DemoX',
|
||||
run='Demo_Course',
|
||||
display_name='Demonstration Course',
|
||||
)
|
||||
cls.course_key = cls.course.id
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.instructor = InstructorFactory.create(course_key=self.course_key)
|
||||
self.staff = StaffFactory.create(course_key=self.course_key)
|
||||
self.student = UserFactory.create()
|
||||
CourseEnrollmentFactory.create(
|
||||
user=self.student,
|
||||
course_id=self.course_key,
|
||||
mode='audit',
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Create some subsections with due dates
|
||||
self.chapter = BlockFactory.create(
|
||||
parent=self.course,
|
||||
category='chapter',
|
||||
display_name='Test Chapter'
|
||||
)
|
||||
self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC)
|
||||
self.subsection_with_due_date = BlockFactory.create(
|
||||
parent=self.chapter,
|
||||
category='sequential',
|
||||
display_name='Homework 1',
|
||||
due=self.due_date
|
||||
)
|
||||
self.subsection_without_due_date = BlockFactory.create(
|
||||
parent=self.chapter,
|
||||
category='sequential',
|
||||
display_name='Reading Material'
|
||||
)
|
||||
self.problem = BlockFactory.create(
|
||||
parent=self.subsection_with_due_date,
|
||||
category='problem',
|
||||
display_name='Test Problem'
|
||||
)
|
||||
|
||||
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('instructor_api_v2:graded_subsections', kwargs={'course_id': course_id})
|
||||
|
||||
def test_get_graded_subsections_success(self):
|
||||
"""
|
||||
Test that an instructor can retrieve graded subsections with due dates.
|
||||
"""
|
||||
self.client.force_authenticate(user=self.instructor)
|
||||
response = self.client.get(self._get_url())
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
response_data = json.loads(response.content)
|
||||
self.assertIn('items', response_data)
|
||||
self.assertIsInstance(response_data['items'], list)
|
||||
|
||||
# Should include subsection with due date
|
||||
items = response_data['items']
|
||||
if items: # Only test if there are items with due dates
|
||||
item = items[0]
|
||||
self.assertIn('display_name', item)
|
||||
self.assertIn('subsection_id', item)
|
||||
self.assertIsInstance(item['display_name'], str)
|
||||
self.assertIsInstance(item['subsection_id'], str)
|
||||
|
||||
def test_get_graded_subsections_as_staff(self):
|
||||
"""
|
||||
Test that staff can retrieve graded subsections.
|
||||
"""
|
||||
self.client.force_authenticate(user=self.staff)
|
||||
response = self.client.get(self._get_url())
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
response_data = json.loads(response.content)
|
||||
self.assertIn('items', response_data)
|
||||
|
||||
def test_get_graded_subsections_nonexistent_course(self):
|
||||
"""
|
||||
Test error handling for non-existent course.
|
||||
"""
|
||||
self.client.force_authenticate(user=self.instructor)
|
||||
nonexistent_course_id = 'course-v1:NonExistent+Course+2024'
|
||||
nonexistent_url = self._get_url(nonexistent_course_id)
|
||||
response = self.client.get(nonexistent_url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_get_graded_subsections_empty_course(self):
|
||||
"""
|
||||
Test graded subsections for course without due dates.
|
||||
"""
|
||||
# Create a completely separate course without any subsections with due dates
|
||||
empty_course = CourseFactory.create(
|
||||
org='EmptyTest',
|
||||
number='EmptyX',
|
||||
run='Empty2024',
|
||||
display_name='Empty Test Course'
|
||||
)
|
||||
# Don't add any subsections to this course
|
||||
empty_instructor = InstructorFactory.create(course_key=empty_course.id)
|
||||
|
||||
self.client.force_authenticate(user=empty_instructor)
|
||||
response = self.client.get(self._get_url(str(empty_course.id)))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
response_data = json.loads(response.content)
|
||||
# An empty course should have no graded subsections with due dates
|
||||
self.assertEqual(response_data['items'], [])
|
||||
|
||||
@patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date')
|
||||
def test_get_graded_subsections_with_mocked_units(self, mock_get_units):
|
||||
"""
|
||||
Test graded subsections response format with mocked data.
|
||||
"""
|
||||
# Mock a unit with due date
|
||||
mock_unit = Mock()
|
||||
mock_unit.display_name = 'Mocked Assignment'
|
||||
mock_unit.location = Mock()
|
||||
mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@mock')
|
||||
mock_get_units.return_value = [mock_unit]
|
||||
|
||||
self.client.force_authenticate(user=self.instructor)
|
||||
response = self.client.get(self._get_url())
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
response_data = json.loads(response.content)
|
||||
items = response_data['items']
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]['display_name'], 'Mocked Assignment')
|
||||
self.assertEqual(items[0]['subsection_id'], 'block-v1:Test+Course+2024+type@sequential+block@mock')
|
||||
|
||||
@patch('lms.djangoapps.instructor.views.api_v2.title_or_url')
|
||||
@patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date')
|
||||
def test_get_graded_subsections_title_fallback(self, mock_get_units, mock_title_or_url):
|
||||
"""
|
||||
Test graded subsections when display_name is not available.
|
||||
"""
|
||||
# Mock a unit without display_name
|
||||
mock_unit = Mock()
|
||||
mock_unit.location = Mock()
|
||||
mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@fallback')
|
||||
mock_get_units.return_value = [mock_unit]
|
||||
mock_title_or_url.return_value = 'block-v1:Test+Course+2024+type@sequential+block@fallback'
|
||||
|
||||
self.client.force_authenticate(user=self.instructor)
|
||||
response = self.client.get(self._get_url())
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
response_data = json.loads(response.content)
|
||||
items = response_data['items']
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]['display_name'], 'block-v1:Test+Course+2024+type@sequential+block@fallback')
|
||||
self.assertEqual(items[0]['subsection_id'], 'block-v1:Test+Course+2024+type@sequential+block@fallback')
|
||||
|
||||
def test_get_graded_subsections_response_format(self):
|
||||
"""
|
||||
Test that the response has the correct format.
|
||||
"""
|
||||
self.client.force_authenticate(user=self.instructor)
|
||||
response = self.client.get(self._get_url())
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
response_data = json.loads(response.content)
|
||||
# Verify top-level structure
|
||||
self.assertIn('items', response_data)
|
||||
self.assertIsInstance(response_data['items'], list)
|
||||
|
||||
# Verify each item has required fields
|
||||
for item in response_data['items']:
|
||||
self.assertIn('display_name', item)
|
||||
self.assertIn('subsection_id', item)
|
||||
self.assertIsInstance(item['display_name'], str)
|
||||
self.assertIsInstance(item['subsection_id'], str)
|
||||
|
||||
@@ -36,6 +36,11 @@ v2_api_urls = [
|
||||
api_v2.ChangeDueDateView.as_view(),
|
||||
name='change_due_date'
|
||||
),
|
||||
re_path(
|
||||
rf'^courses/{COURSE_ID_PATTERN}/graded_subsections$',
|
||||
api_v2.GradedSubsectionsView.as_view(),
|
||||
name='graded_subsections'
|
||||
)
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.translation import gettext as _
|
||||
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest
|
||||
from common.djangoapps.util.json_request import JsonResponseBadRequest
|
||||
|
||||
from lms.djangoapps.courseware.tabs import get_course_tab_list
|
||||
from lms.djangoapps.instructor import permissions
|
||||
@@ -34,7 +34,9 @@ from .serializers_v2 import (
|
||||
)
|
||||
from .tools import (
|
||||
find_unit,
|
||||
get_units_with_due_date,
|
||||
set_due_date_extension,
|
||||
title_or_url,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -314,10 +316,31 @@ class ChangeDueDateView(APIView):
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
return JsonResponseBadRequest({'error': str(error)})
|
||||
|
||||
return JsonResponse(
|
||||
return Response(
|
||||
{
|
||||
'message': _(
|
||||
'Successfully changed due date for learner {0} for {1} '
|
||||
'to {2}').
|
||||
format(learner.profile.name, _display_unit(unit), due_date.strftime('%Y-%m-%d %H:%M')
|
||||
)})
|
||||
|
||||
|
||||
class GradedSubsectionsView(APIView):
|
||||
"""View to retrieve graded subsections with due dates"""
|
||||
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
|
||||
permission_name = permissions.VIEW_DASHBOARD
|
||||
|
||||
def get(self, request, course_id):
|
||||
"""
|
||||
Retrieves a list of graded subsections (units with due dates) within a specified course.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_by_id(course_key)
|
||||
graded_subsections = get_units_with_due_date(course)
|
||||
formated_subsections = {"items": [
|
||||
{
|
||||
"display_name": title_or_url(unit),
|
||||
"subsection_id": str(unit.location)
|
||||
} for unit in graded_subsections]}
|
||||
|
||||
return Response(formated_subsections, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user