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:
Javier Ontiveros
2025-12-15 11:26:35 -06:00
committed by GitHub
parent 70ea641c99
commit 6c8a3af6b9
3 changed files with 224 additions and 2 deletions

View File

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

View File

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

View File

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