Skip to content

Commit 6c8a3af

Browse files
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
1 parent 70ea641 commit 6c8a3af

File tree

3 files changed

+224
-2
lines changed

3 files changed

+224
-2
lines changed

lms/djangoapps/instructor/tests/test_api_v2.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
Unit tests for instructor API v2 endpoints.
33
"""
44
import json
5+
from datetime import datetime
56
from unittest.mock import Mock, patch
67
from urllib.parse import urlencode
78
from uuid import uuid4
89

910
import ddt
1011
from django.urls import NoReverseMatch
1112
from django.urls import reverse
13+
from pytz import UTC
1214
from rest_framework import status
1315
from rest_framework.test import APIClient
1416

@@ -650,3 +652,195 @@ def test_task_data_structure(self):
650652
self.assertIn('task_type', task_data)
651653
self.assertIn('task_state', task_data)
652654
self.assertIn('created', task_data)
655+
656+
657+
@ddt.ddt
658+
class GradedSubsectionsViewTest(SharedModuleStoreTestCase):
659+
"""
660+
Tests for the GradedSubsectionsView API endpoint.
661+
"""
662+
663+
@classmethod
664+
def setUpClass(cls):
665+
super().setUpClass()
666+
cls.course = CourseFactory.create(
667+
org='edX',
668+
number='DemoX',
669+
run='Demo_Course',
670+
display_name='Demonstration Course',
671+
)
672+
cls.course_key = cls.course.id
673+
674+
def setUp(self):
675+
super().setUp()
676+
self.client = APIClient()
677+
self.instructor = InstructorFactory.create(course_key=self.course_key)
678+
self.staff = StaffFactory.create(course_key=self.course_key)
679+
self.student = UserFactory.create()
680+
CourseEnrollmentFactory.create(
681+
user=self.student,
682+
course_id=self.course_key,
683+
mode='audit',
684+
is_active=True
685+
)
686+
687+
# Create some subsections with due dates
688+
self.chapter = BlockFactory.create(
689+
parent=self.course,
690+
category='chapter',
691+
display_name='Test Chapter'
692+
)
693+
self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC)
694+
self.subsection_with_due_date = BlockFactory.create(
695+
parent=self.chapter,
696+
category='sequential',
697+
display_name='Homework 1',
698+
due=self.due_date
699+
)
700+
self.subsection_without_due_date = BlockFactory.create(
701+
parent=self.chapter,
702+
category='sequential',
703+
display_name='Reading Material'
704+
)
705+
self.problem = BlockFactory.create(
706+
parent=self.subsection_with_due_date,
707+
category='problem',
708+
display_name='Test Problem'
709+
)
710+
711+
def _get_url(self, course_id=None):
712+
"""Helper to get the API URL."""
713+
if course_id is None:
714+
course_id = str(self.course_key)
715+
return reverse('instructor_api_v2:graded_subsections', kwargs={'course_id': course_id})
716+
717+
def test_get_graded_subsections_success(self):
718+
"""
719+
Test that an instructor can retrieve graded subsections with due dates.
720+
"""
721+
self.client.force_authenticate(user=self.instructor)
722+
response = self.client.get(self._get_url())
723+
724+
self.assertEqual(response.status_code, status.HTTP_200_OK)
725+
response_data = json.loads(response.content)
726+
self.assertIn('items', response_data)
727+
self.assertIsInstance(response_data['items'], list)
728+
729+
# Should include subsection with due date
730+
items = response_data['items']
731+
if items: # Only test if there are items with due dates
732+
item = items[0]
733+
self.assertIn('display_name', item)
734+
self.assertIn('subsection_id', item)
735+
self.assertIsInstance(item['display_name'], str)
736+
self.assertIsInstance(item['subsection_id'], str)
737+
738+
def test_get_graded_subsections_as_staff(self):
739+
"""
740+
Test that staff can retrieve graded subsections.
741+
"""
742+
self.client.force_authenticate(user=self.staff)
743+
response = self.client.get(self._get_url())
744+
745+
self.assertEqual(response.status_code, status.HTTP_200_OK)
746+
response_data = json.loads(response.content)
747+
self.assertIn('items', response_data)
748+
749+
def test_get_graded_subsections_nonexistent_course(self):
750+
"""
751+
Test error handling for non-existent course.
752+
"""
753+
self.client.force_authenticate(user=self.instructor)
754+
nonexistent_course_id = 'course-v1:NonExistent+Course+2024'
755+
nonexistent_url = self._get_url(nonexistent_course_id)
756+
response = self.client.get(nonexistent_url)
757+
758+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
759+
760+
def test_get_graded_subsections_empty_course(self):
761+
"""
762+
Test graded subsections for course without due dates.
763+
"""
764+
# Create a completely separate course without any subsections with due dates
765+
empty_course = CourseFactory.create(
766+
org='EmptyTest',
767+
number='EmptyX',
768+
run='Empty2024',
769+
display_name='Empty Test Course'
770+
)
771+
# Don't add any subsections to this course
772+
empty_instructor = InstructorFactory.create(course_key=empty_course.id)
773+
774+
self.client.force_authenticate(user=empty_instructor)
775+
response = self.client.get(self._get_url(str(empty_course.id)))
776+
777+
self.assertEqual(response.status_code, status.HTTP_200_OK)
778+
response_data = json.loads(response.content)
779+
# An empty course should have no graded subsections with due dates
780+
self.assertEqual(response_data['items'], [])
781+
782+
@patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date')
783+
def test_get_graded_subsections_with_mocked_units(self, mock_get_units):
784+
"""
785+
Test graded subsections response format with mocked data.
786+
"""
787+
# Mock a unit with due date
788+
mock_unit = Mock()
789+
mock_unit.display_name = 'Mocked Assignment'
790+
mock_unit.location = Mock()
791+
mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@mock')
792+
mock_get_units.return_value = [mock_unit]
793+
794+
self.client.force_authenticate(user=self.instructor)
795+
response = self.client.get(self._get_url())
796+
797+
self.assertEqual(response.status_code, status.HTTP_200_OK)
798+
response_data = json.loads(response.content)
799+
items = response_data['items']
800+
self.assertEqual(len(items), 1)
801+
self.assertEqual(items[0]['display_name'], 'Mocked Assignment')
802+
self.assertEqual(items[0]['subsection_id'], 'block-v1:Test+Course+2024+type@sequential+block@mock')
803+
804+
@patch('lms.djangoapps.instructor.views.api_v2.title_or_url')
805+
@patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date')
806+
def test_get_graded_subsections_title_fallback(self, mock_get_units, mock_title_or_url):
807+
"""
808+
Test graded subsections when display_name is not available.
809+
"""
810+
# Mock a unit without display_name
811+
mock_unit = Mock()
812+
mock_unit.location = Mock()
813+
mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@fallback')
814+
mock_get_units.return_value = [mock_unit]
815+
mock_title_or_url.return_value = 'block-v1:Test+Course+2024+type@sequential+block@fallback'
816+
817+
self.client.force_authenticate(user=self.instructor)
818+
response = self.client.get(self._get_url())
819+
820+
self.assertEqual(response.status_code, status.HTTP_200_OK)
821+
response_data = json.loads(response.content)
822+
items = response_data['items']
823+
self.assertEqual(len(items), 1)
824+
self.assertEqual(items[0]['display_name'], 'block-v1:Test+Course+2024+type@sequential+block@fallback')
825+
self.assertEqual(items[0]['subsection_id'], 'block-v1:Test+Course+2024+type@sequential+block@fallback')
826+
827+
def test_get_graded_subsections_response_format(self):
828+
"""
829+
Test that the response has the correct format.
830+
"""
831+
self.client.force_authenticate(user=self.instructor)
832+
response = self.client.get(self._get_url())
833+
834+
self.assertEqual(response.status_code, status.HTTP_200_OK)
835+
836+
response_data = json.loads(response.content)
837+
# Verify top-level structure
838+
self.assertIn('items', response_data)
839+
self.assertIsInstance(response_data['items'], list)
840+
841+
# Verify each item has required fields
842+
for item in response_data['items']:
843+
self.assertIn('display_name', item)
844+
self.assertIn('subsection_id', item)
845+
self.assertIsInstance(item['display_name'], str)
846+
self.assertIsInstance(item['subsection_id'], str)

lms/djangoapps/instructor/views/api_urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
api_v2.ChangeDueDateView.as_view(),
3737
name='change_due_date'
3838
),
39+
re_path(
40+
rf'^courses/{COURSE_ID_PATTERN}/graded_subsections$',
41+
api_v2.GradedSubsectionsView.as_view(),
42+
name='graded_subsections'
43+
)
3944
]
4045

4146
urlpatterns = [

lms/djangoapps/instructor/views/api_v2.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from django.views.decorators.cache import cache_control
1919
from django.utils.html import strip_tags
2020
from django.utils.translation import gettext as _
21-
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest
21+
from common.djangoapps.util.json_request import JsonResponseBadRequest
2222

2323
from lms.djangoapps.courseware.tabs import get_course_tab_list
2424
from lms.djangoapps.instructor import permissions
@@ -34,7 +34,9 @@
3434
)
3535
from .tools import (
3636
find_unit,
37+
get_units_with_due_date,
3738
set_due_date_extension,
39+
title_or_url,
3840
)
3941

4042
log = logging.getLogger(__name__)
@@ -314,10 +316,31 @@ def post(self, request, course_id):
314316
except Exception as error: # pylint: disable=broad-except
315317
return JsonResponseBadRequest({'error': str(error)})
316318

317-
return JsonResponse(
319+
return Response(
318320
{
319321
'message': _(
320322
'Successfully changed due date for learner {0} for {1} '
321323
'to {2}').
322324
format(learner.profile.name, _display_unit(unit), due_date.strftime('%Y-%m-%d %H:%M')
323325
)})
326+
327+
328+
class GradedSubsectionsView(APIView):
329+
"""View to retrieve graded subsections with due dates"""
330+
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
331+
permission_name = permissions.VIEW_DASHBOARD
332+
333+
def get(self, request, course_id):
334+
"""
335+
Retrieves a list of graded subsections (units with due dates) within a specified course.
336+
"""
337+
course_key = CourseKey.from_string(course_id)
338+
course = get_course_by_id(course_key)
339+
graded_subsections = get_units_with_due_date(course)
340+
formated_subsections = {"items": [
341+
{
342+
"display_name": title_or_url(unit),
343+
"subsection_id": str(unit.location)
344+
} for unit in graded_subsections]}
345+
346+
return Response(formated_subsections, status=status.HTTP_200_OK)

0 commit comments

Comments
 (0)