diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 510de37e37b5..0bcd9a5404d0 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -10,10 +10,13 @@ import ddt from django.urls import NoReverseMatch from django.urls import reverse +from django.utils import timezone +from opaque_keys import InvalidKeyError from pytz import UTC from rest_framework import status from rest_framework.test import APIClient +from edx_when.api import set_dates_for_course, set_date_for_block from common.djangoapps.student.roles import CourseDataResearcherRole, CourseInstructorRole from common.djangoapps.student.tests.factories import ( AdminFactory, @@ -890,3 +893,478 @@ def test_get_graded_subsections_response_format(self): self.assertIn('subsection_id', item) self.assertIsInstance(item['display_name'], str) self.assertIsInstance(item['subsection_id'], str) + + +@ddt.ddt +class UnitExtensionsViewTest(SharedModuleStoreTestCase): + """ + Tests for the UnitExtensionsView API endpoint. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create( + org='edX', + number='TestX', + run='Test_Course', + display_name='Test Course', + ) + cls.course_key = cls.course.id + + # Create course structure with due dates + cls.chapter = BlockFactory.create( + parent=cls.course, + category='chapter', + display_name='Test Chapter' + ) + cls.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC) + cls.subsection = BlockFactory.create( + parent=cls.chapter, + category='sequential', + display_name='Homework 1', + due=cls.due_date + ) + cls.vertical = BlockFactory.create( + parent=cls.subsection, + category='vertical', + display_name='Test Vertical' + ) + cls.problem = BlockFactory.create( + parent=cls.vertical, + category='problem', + display_name='Test Problem' + ) + + 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.student1 = UserFactory.create(username='student1', email='student1@example.com') + self.student2 = UserFactory.create(username='student2', email='student2@example.com') + + # Enroll students + CourseEnrollmentFactory.create( + user=self.student1, + course_id=self.course_key, + is_active=True + ) + CourseEnrollmentFactory.create( + user=self.student2, + course_id=self.course_key, + is_active=True + ) + + date1 = timezone.make_aware(datetime(2019, 3, 22)) + date2 = timezone.make_aware(datetime(2019, 3, 23)) + date3 = timezone.make_aware(datetime(2019, 3, 24)) + + override1 = timezone.make_aware(datetime(2019, 4, 1)) + override2 = timezone.make_aware(datetime(2019, 4, 2)) + override3 = timezone.make_aware(datetime(2019, 4, 3)) + + items = [ + (self.subsection.location, {'due': date1}), + (self.vertical.location, {'due': date2}), + (self.problem.location, {'due': date3}), + ] + set_dates_for_course(self.course_key, items) + + set_date_for_block(self.course_key, self.subsection.location, 'due', override1, user=self.instructor) + set_date_for_block(self.course_key, self.vertical.location, 'due', override2, user=self.student1) + set_date_for_block(self.course_key, self.problem.location, 'due', override3, user=self.student2) + # Multiple overrides per user + set_date_for_block(self.course_key, self.subsection.location, 'due', override2, user=self.student1) + + 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:unit_extensions', kwargs={'course_id': course_id}) + + def test_get_unit_extensions_success(self): + """ + Test that an instructor can retrieve paginated unit extensions. + """ + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + + # Verify pagination structure + self.assertIn('count', data) + self.assertIn('next', data) + self.assertIn('previous', data) + self.assertIn('results', data) + self.assertIsInstance(data['results'], list) + + def test_get_unit_extensions_as_staff(self): + """ + Test that staff can retrieve unit extensions. + """ + self.client.force_authenticate(user=self.staff) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_get_unit_extensions_unauthorized(self): + """ + Test that students cannot access unit extensions endpoint. + """ + self.client.force_authenticate(user=self.student1) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_unit_extensions_unauthenticated(self): + """ + Test that unauthenticated users cannot access the endpoint. + """ + response = self.client.get(self._get_url()) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_get_unit_extensions_nonexistent_course(self): + """ + Test error handling for non-existent course. + """ + self.client.force_authenticate(user=self.instructor) + nonexistent_course_id = 'course-v1:edX+NonExistent+2024' + response = self.client.get(self._get_url(course_id=nonexistent_course_id)) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_get_unit_extensions_with_no_mockups(self): + """ + Test unit extensions with mocked data. + """ + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + results = data['results'] + + self.assertEqual(len(results), 2) + + # If there are results, verify the structure + extension = results[1] + self.assertEqual(extension['username'], 'student1') + self.assertIn('Robot', extension['full_name']) + self.assertEqual(extension['email'], 'student1@example.com') + self.assertEqual(extension['unit_title'], 'Homework 1') + self.assertEqual(extension['unit_location'], 'block-v1:edX+TestX+Test_Course+type@sequential+block@Homework_1') + self.assertEqual(extension['extended_due_date'], '2019-04-02T00:00:00Z') + + @patch('lms.djangoapps.instructor.views.api_v2.edx_when_api.get_overrides_for_course') + @patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date') + def test_get_unit_extensions_with_data(self, mock_get_units, mock_get_overrides): + """ + Test unit extensions with mocked data. + """ + # Mock units with due dates + mock_unit = Mock() + mock_unit.display_name = 'Homework 1' + mock_unit.location = Mock() + mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + mock_get_units.return_value = [mock_unit] + + # Mock location for dictionary lookup + mock_location = Mock() + mock_location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + + # Mock course overrides data (username, full_name, email, location, due_date) + extended_date = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC) + mock_get_overrides.return_value = [ + ('student1', 'John Doe', 'john@example.com', mock_location, extended_date), + ('student2', 'Jane Smith', 'jane@example.com', mock_location, extended_date), + ] + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + results = data['results'] + + self.assertGreaterEqual(len(results), 1) + + # If there are results, verify the structure + extension = results[0] + self.assertEqual(extension['username'], 'student1') + self.assertEqual(extension['full_name'], 'John Doe') + self.assertEqual(extension['email'], 'john@example.com') + self.assertEqual(extension['unit_title'], 'Homework 1') + self.assertEqual(extension['unit_location'], 'block-v1:Test+Course+2024+type@sequential+block@hw1') + self.assertEqual(extension['extended_due_date'], extended_date.strftime("%Y-%m-%dT%H:%M:%SZ")) + + @ddt.data( + ('student1', True), + ('jane@example.com', False), + ('STUDENT1', True), # Test case insensitive + ('JANE@EXAMPLE.COM', False), # Test case insensitive + ) + @ddt.unpack + @patch('lms.djangoapps.instructor.views.api_v2.edx_when_api.get_overrides_for_course') + @patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date') + def test_filter_by_email_or_username(self, filter_value, is_username, mock_get_units, mock_get_overrides): + """ + Test filtering unit extensions by email or username. + """ + # Mock units with due dates + mock_unit = Mock() + mock_unit.display_name = 'Homework 1' + mock_unit.location = Mock() + mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + mock_get_units.return_value = [mock_unit] + + # Mock location for dictionary lookup + mock_location = Mock() + mock_location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + + # Mock course overrides data + extended_date = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC) + mock_get_overrides.return_value = [ + ('student1', 'John Doe', 'john@example.com', mock_location, extended_date), + ('student2', 'Jane Smith', 'jane@example.com', mock_location, extended_date), + ] + + self.client.force_authenticate(user=self.instructor) + + # Test filter by username + params = {'email_or_username': filter_value} + response = self.client.get(self._get_url(), params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + results = data['results'] + + for r in results: + # Check that the filter value is in the appropriate field + if is_username: + self.assertIn(filter_value.lower(), r['username'].lower()) + else: + self.assertIn(filter_value.lower(), r['email'].lower()) + + @patch('lms.djangoapps.instructor.views.api_v2.edx_when_api.get_overrides_for_block') + @patch('lms.djangoapps.instructor.views.api_v2.find_unit') + @patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date') + def test_filter_by_block_id(self, mock_get_units, mock_find_unit, mock_get_overrides_block): + """ + Test filtering unit extensions by specific block_id. + """ + # Mock unit + mock_unit = Mock() + mock_unit.display_name = 'Homework 1' + mock_unit.location = Mock() + mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + + mock_find_unit.return_value = mock_unit + mock_get_units.return_value = [mock_unit] + + # Mock block-specific overrides data (username, full_name, email, location, due_date) + extended_date = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC) + mock_get_overrides_block.return_value = [ + ('student1', 'John Doe', extended_date, 'john@example.com', mock_unit.location), + ] + + self.client.force_authenticate(user=self.instructor) + params = {'block_id': 'block-v1:Test+Course+2024+type@sequential+block@hw1'} + response = self.client.get(self._get_url(), params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.data + results = data['results'] + + self.assertEqual(data['count'], 1) + self.assertEqual(len(results), 1) + + data = results[0] + self.assertEqual(data['username'], 'student1') + self.assertEqual(data['full_name'], 'John Doe') + self.assertEqual(data['email'], 'john@example.com') + self.assertEqual(data['unit_title'], 'Homework 1') + self.assertEqual(data['unit_location'], 'block-v1:Test+Course+2024+type@sequential+block@hw1') + self.assertEqual(data['extended_due_date'], extended_date.strftime("%Y-%m-%dT%H:%M:%SZ")) + + @patch('lms.djangoapps.instructor.views.api_v2.find_unit') + def test_filter_by_invalid_block_id(self, mock_find_unit): + """ + Test filtering by invalid block_id returns empty list. + """ + # Make find_unit raise an exception + mock_find_unit.side_effect = InvalidKeyError('Invalid block', 'invalid-block-id') + + self.client.force_authenticate(user=self.instructor) + params = {'block_id': 'invalid-block-id'} + response = self.client.get(self._get_url(), params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(data['count'], 0) + self.assertEqual(data['results'], []) + + @patch('lms.djangoapps.instructor.views.api_v2.edx_when_api.get_overrides_for_block') + @patch('lms.djangoapps.instructor.views.api_v2.find_unit') + def test_combined_filters(self, mock_find_unit, mock_get_overrides_block): + """ + Test combining block_id and email_or_username filters. + """ + # Mock unit + mock_unit = Mock() + mock_unit.display_name = 'Homework 1' + mock_unit.location = Mock() + mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + + mock_find_unit.return_value = mock_unit + + # Mock block-specific overrides data + extended_date = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC) + mock_get_overrides_block.return_value = [ + ('student1', 'John Doe', extended_date, 'john@example.com', mock_unit.location), + ('student2', 'Jane Smith', extended_date, 'jane@example.com', mock_unit.location), + ] + + self.client.force_authenticate(user=self.instructor) + params = { + 'block_id': 'block-v1:Test+Course+2024+type@sequential+block@hw1', + 'email_or_username': 'student1' + } + response = self.client.get(self._get_url(), params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.data + results = data['results'] + + self.assertEqual(data['count'], 1) + self.assertEqual(len(results), 1) + + data = results[0] + # Match only the filtered student1 + self.assertEqual(data['username'], 'student1') + self.assertEqual(data['full_name'], 'John Doe') + self.assertEqual(data['email'], 'john@example.com') + self.assertEqual(data['unit_title'], 'Homework 1') + self.assertEqual(data['unit_location'], 'block-v1:Test+Course+2024+type@sequential+block@hw1') + self.assertEqual(data['extended_due_date'], extended_date.strftime("%Y-%m-%dT%H:%M:%SZ")) + + @patch('lms.djangoapps.instructor.views.api_v2.edx_when_api.get_overrides_for_course') + @patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date') + def test_pagination_parameters(self, mock_get_units, mock_get_overrides): + """ + Test that pagination parameters work correctly. + """ + # Mock units with due dates + mock_unit = Mock() + mock_unit.display_name = 'Homework 1' + mock_unit.location = Mock() + mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + mock_get_units.return_value = [mock_unit] + + # Mock location for dictionary lookup + mock_location = Mock() + mock_location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + + # Mock course overrides data + extended_date = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC) + mock_get_overrides.return_value = [ + ('student1', 'John Doe', 'john@example.com', mock_location, extended_date), + ('student2', 'Jane Smith', 'jane@example.com', mock_location, extended_date), + ] + self.client.force_authenticate(user=self.instructor) + + # Test page parameter + params = {'page': '1', 'page_size': '1'} + response = self.client.get(self._get_url(), params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertIn('count', data) + self.assertIn('next', data) + self.assertIn('previous', data) + self.assertIn('results', data) + + self.assertEqual(data['count'], 2) + self.assertIsNotNone(data['next']) + self.assertIsNone(data['previous']) + self.assertEqual(len(data['results']), 1) + + # Test second page + params = {'page': '2', 'page_size': '1'} + response = self.client.get(self._get_url(), params) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertIsNone(data['next']) + self.assertIsNotNone(data['previous']) + self.assertEqual(len(data['results']), 1) + + @patch('lms.djangoapps.instructor.views.api_v2.edx_when_api.get_overrides_for_course') + @patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date') + def test_empty_results(self, mock_get_units, mock_get_overrides): + """ + Test endpoint with no extension data. + """ + # Mock empty data + mock_get_units.return_value = [] + mock_get_overrides.return_value = [] + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(data['count'], 0) + self.assertEqual(data['results'], []) + + @patch('lms.djangoapps.instructor.views.api_v2.edx_when_api.get_overrides_for_course') + @patch('lms.djangoapps.instructor.views.api_v2.get_units_with_due_date') + @patch('lms.djangoapps.instructor.views.api_v2.title_or_url') + def test_extension_data_structure(self, mock_title_or_url, mock_get_units, mock_get_overrides): + """ + Test that extension data has the correct structure. + """ + # Mock units with due dates + mock_unit = Mock() + mock_unit.display_name = 'Homework 1' + mock_unit.location = Mock() + mock_unit.location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + mock_get_units.return_value = [mock_unit] + mock_title_or_url.return_value = 'Homework 1' + + # Mock location for dictionary lookup + mock_location = Mock() + mock_location.__str__ = Mock(return_value='block-v1:Test+Course+2024+type@sequential+block@hw1') + + # Mock course overrides data + extended_date = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC) + mock_get_overrides.return_value = [ + ('student1', 'John Doe', 'john@example.com', mock_location, extended_date), + ] + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(data['count'], 1) + + extension = data['results'][0] + + # Verify all required fields are present + required_fields = [ + 'username', 'full_name', 'email', + 'unit_title', 'unit_location', 'extended_due_date' + ] + for field in required_fields: + self.assertIn(field, extension) + + # Verify data types + self.assertIsInstance(extension['username'], str) + self.assertIsInstance(extension['full_name'], str) + self.assertIsInstance(extension['email'], str) + self.assertIsInstance(extension['unit_title'], str) + self.assertIsInstance(extension['unit_location'], str) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index f23701cc1de7..083a3dc66204 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -40,6 +40,11 @@ rf'^courses/{COURSE_ID_PATTERN}/graded_subsections$', api_v2.GradedSubsectionsView.as_view(), name='graded_subsections' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/unit_extensions$', + api_v2.UnitExtensionsView.as_view(), + name='unit_extensions' ) ] diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 709ad8f7e0f0..ec177c755b05 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -7,10 +7,14 @@ import logging +from dataclasses import dataclass +from typing import Optional, Tuple import edx_api_doc_tools as apidocs +from edx_when import api as edx_when_api from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework import status +from rest_framework.generics import ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -31,6 +35,7 @@ InstructorTaskListSerializer, CourseInformationSerializerV2, BlockDueDateSerializerV2, + UnitExtensionSerializer, ) from .tools import ( find_unit, @@ -349,3 +354,156 @@ def get(self, request, course_id): } for unit in graded_subsections]} return Response(formated_subsections, status=status.HTTP_200_OK) + + +@dataclass(frozen=True) +class UnitDueDateExtension: + """Dataclass representing a unit due date extension for a student.""" + + username: str + full_name: str + email: str + unit_title: str + unit_location: str + extended_due_date: Optional[str] + + @classmethod + def from_block_tuple(cls, row: Tuple, unit): + username, full_name, due_date, email, location = row + unit_title = title_or_url(unit) + return cls( + username=username, + full_name=full_name, + email=email, + unit_title=unit_title, + unit_location=location, + extended_due_date=due_date, + ) + + @classmethod + def from_course_tuple(cls, row: Tuple, units_dict: dict): + username, full_name, email, location, due_date = row + unit_title = title_or_url(units_dict[str(location)]) + return cls( + username=username, + full_name=full_name, + email=email, + unit_title=unit_title, + unit_location=location, + extended_due_date=due_date, + ) + + +class UnitExtensionsView(ListAPIView): + """ + Retrieve a paginated list of due date extensions for units in a course. + + **Example Requests** + + GET /api/instructor/v2/courses/{course_id}/unit_extensions + GET /api/instructor/v2/courses/{course_id}/unit_extensions?page=2 + GET /api/instructor/v2/courses/{course_id}/unit_extensions?page_size=50 + GET /api/instructor/v2/courses/{course_id}/unit_extensions?email_or_username=john + GET /api/instructor/v2/courses/{course_id}/unit_extensions?block_id=block-v1:org@problem+block@unit1 + + **Response Values** + + { + "count": 150, + "next": "http://example.com/api/instructor/v2/courses/course-v1:org+course+run/unit_extensions?page=2", + "previous": null, + "results": [ + { + "username": "student1", + "full_name": "John Doe", + "email": "john.doe@example.com", + "unit_title": "Unit 1: Introduction", + "unit_location": "block-v1:org+course+run+type@problem+block@unit1", + "extended_due_date": "2023-12-25T23:59:59Z" + }, + ... + ] + } + + **Parameters** + + course_id: Course key for the course. + page (optional): Page number for pagination. + page_size (optional): Number of results per page. + + **Returns** + + * 200: OK - Returns paginated list of unit extensions + * 401: Unauthorized - User is not authenticated + * 403: Forbidden - User lacks instructor permissions + * 404: Not Found - Course does not exist + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_DASHBOARD + serializer_class = UnitExtensionSerializer + + def get_queryset(self): + """ + Returns the queryset of unit extensions for the specified course. + + This method uses the core logic from get_overrides_for_course to retrieve + due date extension data and transforms it into a list of normalized objects + that can be paginated and serialized. + + Supports filtering by: + - email_or_username: Filter by username or email address + - block_id: Filter by specific unit/subsection location + """ + course_id = self.kwargs["course_id"] + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + + email_or_username_filter = self.request.query_params.get("email_or_username") + block_id_filter = self.request.query_params.get("block_id") + + units = get_units_with_due_date(course) + units_dict = {str(u.location): u for u in units} + + # Fetch and normalize overrides + if block_id_filter: + try: + unit = find_unit(course, block_id_filter) + query_data = edx_when_api.get_overrides_for_block(course.id, unit.location) + unit_due_date_extensions = [ + UnitDueDateExtension.from_block_tuple(row, unit) + for row in query_data + ] + except InvalidKeyError: + # If block_id is invalid, return empty list + unit_due_date_extensions = [] + else: + query_data = edx_when_api.get_overrides_for_course(course.id) + unit_due_date_extensions = [ + UnitDueDateExtension.from_course_tuple(row, units_dict) + for row in query_data + if str(row[3]) in units_dict # Ensure unit has due date + ] + + # Apply filters + results = [] + filter_value = email_or_username_filter.lower() if email_or_username_filter else None + + for unit_due_date_extension in unit_due_date_extensions: + # Optional username/email filter + if filter_value: + if ( + filter_value not in unit_due_date_extension.username.lower() + and filter_value not in unit_due_date_extension.email.lower() + ): + continue + results.append(unit_due_date_extension) + + # Sort for consistent ordering + results.sort( + key=lambda o: ( + o.username, + o.unit_title, + ) + ) + + return results diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index e504867d2af9..9e06dd0cb01f 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -416,3 +416,30 @@ def validate_due_datetime(self, value): raise serializers.ValidationError( _('The extension due date and time format is incorrect') ) from exc + + +class UnitExtensionSerializer(serializers.Serializer): + """ + Serializer for unit extension data. + + This serializer formats the data returned by get_overrides_for_course + for the paginated list API endpoint. + """ + username = serializers.CharField( + help_text="Username of the learner who has the extension" + ) + full_name = serializers.CharField( + help_text="Full name of the learner" + ) + email = serializers.EmailField( + help_text="Email address of the learner" + ) + unit_title = serializers.CharField( + help_text="Display name or URL of the unit" + ) + unit_location = serializers.CharField( + help_text="Block location/ID of the unit" + ) + extended_due_date = serializers.DateTimeField( + help_text="The extended due date for the learner" + ) diff --git a/lms/djangoapps/instructor/views/tools.py b/lms/djangoapps/instructor/views/tools.py index 5a62f705f530..8b32e019db02 100644 --- a/lms/djangoapps/instructor/views/tools.py +++ b/lms/djangoapps/instructor/views/tools.py @@ -220,7 +220,7 @@ def dump_block_extensions(course, unit): """ header = [_("Username"), _("Full Name"), _("Extended Due Date")] data = [] - for username, fullname, due_date in api.get_overrides_for_block(course.id, unit.location): + for username, fullname, due_date, *unused in api.get_overrides_for_block(course.id, unit.location): due_date = due_date.strftime('%Y-%m-%d %H:%M') data.append(dict(list(zip(header, (username, fullname, due_date))))) data.sort(key=operator.itemgetter(_("Username"))) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 65ae2148c4ce..457772af55af 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -544,7 +544,7 @@ edx-toggles==5.4.1 # edxval # event-tracking # ora2 -edx-when==3.0.0 +edx-when==3.1.0 # via # -r requirements/edx/kernel.in # edx-proctoring diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5697a3b1bc3d..cfd832cb9fa1 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -851,7 +851,7 @@ edx-toggles==5.4.1 # edxval # event-tracking # ora2 -edx-when==3.0.0 +edx-when==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 900c75f22c2a..14cf7abc7c0a 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -635,7 +635,7 @@ edx-toggles==5.4.1 # edxval # event-tracking # ora2 -edx-when==3.0.0 +edx-when==3.1.0 # via # -r requirements/edx/base.txt # edx-proctoring diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 3d8325779829..3c8a902dbafb 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -659,7 +659,7 @@ edx-toggles==5.4.1 # edxval # event-tracking # ora2 -edx-when==3.0.0 +edx-when==3.1.0 # via # -r requirements/edx/base.txt # edx-proctoring