diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index d969858b37..12df219ed3 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -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) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index c4c66655a0..f23701cc1d 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -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 = [ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 3cc8c32463..fce3042b69 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -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)