From 6c87142629f139c038621bad8a4a8777013eafd9 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Wed, 17 Dec 2025 14:39:29 -0600 Subject: [PATCH 1/8] chore: base work for unit extensions v2 api --- .../instructor/tests/test_api_v2.py | 407 ++++++++++++++++++ lms/djangoapps/instructor/views/api_urls.py | 5 + lms/djangoapps/instructor/views/api_v2.py | 180 ++++++++ .../instructor/views/serializers_v2.py | 27 ++ 4 files changed, 619 insertions(+) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 510de37e37b5..5a901fb7e1b0 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -890,3 +890,410 @@ 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 + ) + + 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) + + @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), 0) + + # If there are results, verify the structure + if results: + extension = results[0] + expected_fields = [ + 'username', 'full_name', 'email', + 'unit_title', 'unit_location', 'extended_due_date' + ] + for field in expected_fields: + self.assertIn(field, extension) + + @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, 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': 'student1'} + url = f"{self._get_url()}?{urlencode(params)}" + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # The filter logic should be called + mock_get_overrides.assert_called() + + # Test filter by email + params = {'email_or_username': 'jane@example.com'} + url = f"{self._get_url()}?{urlencode(params)}" + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @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, due_date) + extended_date = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC) + mock_get_overrides_block.return_value = [ + ('student1', 'John Doe', extended_date), + ] + + self.client.force_authenticate(user=self.instructor) + params = {'block_id': 'block-v1:Test+Course+2024+type@sequential+block@hw1'} + url = f"{self._get_url()}?{urlencode(params)}" + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify the block-specific API was called + mock_get_overrides_block.assert_called_once() + mock_find_unit.assert_called_once() + + @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 = Exception('Invalid block') + + self.client.force_authenticate(user=self.instructor) + params = {'block_id': 'invalid-block-id'} + url = f"{self._get_url()}?{urlencode(params)}" + response = self.client.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_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), + ('student2', 'Jane Smith', extended_date), + ] + + self.client.force_authenticate(user=self.instructor) + params = { + 'block_id': 'block-v1:Test+Course+2024+type@sequential+block@hw1', + 'email_or_username': 'student1' + } + url = f"{self._get_url()}?{urlencode(params)}" + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Both filters should be applied + mock_get_overrides_block.assert_called_once() + mock_find_unit.assert_called_once() + + def test_pagination_parameters(self): + """ + Test that pagination parameters work correctly. + """ + self.client.force_authenticate(user=self.instructor) + + # Test page parameter + params = {'page': '1', 'page_size': '10'} + url = f"{self._get_url()}?{urlencode(params)}" + response = self.client.get(url) + + 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) + + @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 + + if data['results']: + 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) + + def test_response_content_type(self): + """ + Test that the response has the correct content type. + """ + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response['content-type'], 'application/json') + + @ddt.data( + ('student1',), + ('john@example.com',), + ('STUDENT1',), # Test case insensitive + ('JOHN@EXAMPLE.COM',), # Test case insensitive + ) + @ddt.unpack + def test_email_or_username_filter_variations(self, filter_value): + """ + Test various formats for email_or_username filter. + """ + self.client.force_authenticate(user=self.instructor) + params = {'email_or_username': filter_value} + url = f"{self._get_url()}?{urlencode(params)}" + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Verify the response structure is correct regardless of filter + data = response.data + self.assertIn('count', data) + self.assertIn('results', data) 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..43664337e0b4 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -8,9 +8,11 @@ import logging 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 +33,7 @@ InstructorTaskListSerializer, CourseInformationSerializerV2, BlockDueDateSerializerV2, + UnitExtensionSerializer, ) from .tools import ( find_unit, @@ -349,3 +352,180 @@ def get(self, request, course_id): } for unit in graded_subsections]} return Response(formated_subsections, status=status.HTTP_200_OK) + + +class UnitExtensionsView(ListAPIView): + """ + **Use Cases** + + 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+course+run+type@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 + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'email_or_username', + apidocs.ParameterLocation.QUERY, + description="Optional: Filter by username or email address.", + ), + apidocs.string_parameter( + 'block_id', + apidocs.ParameterLocation.QUERY, + description="Optional: Filter by specific unit/subsection location.", + ), + ], + responses={ + 200: UnitExtensionSerializer(many=True), + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks instructor permissions.", + 404: "The requested course does not exist.", + }, + ) + + 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 dictionaries + 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) + + # Get query parameters for filtering + email_or_username_filter = self.request.query_params.get('email_or_username', None) + block_id_filter = self.request.query_params.get('block_id', None) + + # Get units with due dates for filtering + units = get_units_with_due_date(course) + units_dict = {u.location: u for u in units} + + # If filtering by specific unit, use the block-specific logic + if block_id_filter: + try: + # Parse the block_id and find the unit + unit = find_unit(course, block_id_filter) + + # Use the block-specific API call for better performance + query_data = edx_when_api.get_overrides_for_block(course.id, unit.location, True) + + # Transform the tuple data (username, full_name, email, location, due_date) for block-specific query + extension_data = [] + for username, fullname, email, location, due_date in query_data: + # Apply email_or_username filter if specified + if email_or_username_filter and email_or_username_filter.lower() not in username.lower(): + continue + + unit_title = title_or_url(unit) + extension_data.append({ + 'username': username, + 'full_name': fullname, + 'email': email, + 'unit_title': unit_title, + 'unit_location': str(location), + 'extended_due_date': due_date, + }) + + except (InvalidKeyError, Exception): + # If block_id is invalid, return empty list + return [] + else: + # Use the course-wide logic from get_overrides_for_course + query_data = edx_when_api.get_overrides_for_course(course.id) + + # Transform the tuple data into dictionaries for serialization + extension_data = [] + for username, fullname, email, location, due_date in query_data: + # Filter by unit if location not in units with due dates + if location not in units_dict: + continue + + # Apply email_or_username filter if specified + if email_or_username_filter: + email_or_username_filter_lower = email_or_username_filter.lower() + if (email_or_username_filter_lower not in username.lower() and + email_or_username_filter_lower != email.lower()): + continue + + unit_title = title_or_url(units_dict[location]) + extension_data.append({ + 'username': username, + 'full_name': fullname, + 'email': email, + 'unit_title': unit_title, + 'unit_location': str(location), + 'extended_due_date': due_date, + }) + + # Sort by username and unit title for consistent ordering + extension_data.sort(key=lambda x: (x['username'], x['unit_title'])) + + return extension_data + + def list(self, request, *args, **kwargs): + """ + Override list to work with our dictionary-based data structure. + """ + queryset = self.get_queryset() + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) 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" + ) From a2e3566ff727c1db7a373f58e9bc1f7b3d383b55 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Wed, 17 Dec 2025 15:36:59 -0600 Subject: [PATCH 2/8] chore: fix pylint things --- lms/djangoapps/instructor/views/api_v2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 43664337e0b4..48284b0b072e 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -366,7 +366,7 @@ class UnitExtensionsView(ListAPIView): 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+course+run+type@problem+block@unit1 + GET /api/instructor/v2/courses/{course_id}/unit_extensions?block_id=block-v1:org@problem+block@unit1 **Response Values** @@ -480,7 +480,7 @@ def get_queryset(self): 'extended_due_date': due_date, }) - except (InvalidKeyError, Exception): + except (InvalidKeyError): # If block_id is invalid, return empty list return [] else: From dd4b7eb893e7c94d6a78c01320fbb5666a4c120d Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 8 Jan 2026 14:25:30 -0600 Subject: [PATCH 3/8] chore: update to match edx-when comments --- lms/djangoapps/instructor/tests/test_api_v2.py | 11 ++++++----- lms/djangoapps/instructor/views/api_v2.py | 9 ++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 5a901fb7e1b0..619388cb53f4 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -10,6 +10,7 @@ import ddt from django.urls import NoReverseMatch from django.urls import reverse +from opaque_keys import InvalidKeyError from pytz import UTC from rest_framework import status from rest_framework.test import APIClient @@ -1112,10 +1113,10 @@ def test_filter_by_block_id(self, mock_get_units, mock_find_unit, mock_get_overr mock_find_unit.return_value = mock_unit mock_get_units.return_value = [mock_unit] - # Mock block-specific overrides data (username, full_name, due_date) + # 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), + ('student1', 'John Doe', 'john@example.com', mock_unit.location, extended_date), ] self.client.force_authenticate(user=self.instructor) @@ -1135,7 +1136,7 @@ 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 = Exception('Invalid block') + mock_find_unit.side_effect = InvalidKeyError('Invalid block', 'invalid-block-id') self.client.force_authenticate(user=self.instructor) params = {'block_id': 'invalid-block-id'} @@ -1164,8 +1165,8 @@ def test_combined_filters(self, mock_find_unit, mock_get_overrides_block): # 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), - ('student2', 'Jane Smith', extended_date), + ('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) diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 48284b0b072e..651067e8107c 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -429,7 +429,6 @@ class UnitExtensionsView(ListAPIView): 404: "The requested course does not exist.", }, ) - def get_queryset(self): """ Returns the queryset of unit extensions for the specified course. @@ -461,11 +460,11 @@ def get_queryset(self): unit = find_unit(course, block_id_filter) # Use the block-specific API call for better performance - query_data = edx_when_api.get_overrides_for_block(course.id, unit.location, True) + query_data = edx_when_api.get_overrides_for_block(course.id, unit.location) - # Transform the tuple data (username, full_name, email, location, due_date) for block-specific query + # Transform the tuple data (username, full_name, due_date, email, location) for block-specific query extension_data = [] - for username, fullname, email, location, due_date in query_data: + for username, fullname, due_date, email, location in query_data: # Apply email_or_username filter if specified if email_or_username_filter and email_or_username_filter.lower() not in username.lower(): continue @@ -498,7 +497,7 @@ def get_queryset(self): if email_or_username_filter: email_or_username_filter_lower = email_or_username_filter.lower() if (email_or_username_filter_lower not in username.lower() and - email_or_username_filter_lower != email.lower()): + email_or_username_filter_lower != email.lower()): continue unit_title = title_or_url(units_dict[location]) From 43deb2ab33580dddcb26eacc54598df5e12a9590 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 9 Jan 2026 15:26:11 -0600 Subject: [PATCH 4/8] chore: update edx_when version --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 13d36c644595..565e7503a29d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -546,7 +546,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 1b23b5b14bb1..a34c499a7ca4 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -852,7 +852,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 d6dc34a342f2..0d26eab55fcb 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -636,7 +636,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 From 7a98a89ab08217959aed40e254a7b6f9b56800bf Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 9 Jan 2026 15:59:25 -0600 Subject: [PATCH 5/8] chore: restore old version before upgrade with make --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 565e7503a29d..13d36c644595 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -546,7 +546,7 @@ edx-toggles==5.4.1 # edxval # event-tracking # ora2 -edx-when==3.1.0 +edx-when==3.0.0 # via # -r requirements/edx/kernel.in # edx-proctoring diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index a34c499a7ca4..1b23b5b14bb1 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -852,7 +852,7 @@ edx-toggles==5.4.1 # edxval # event-tracking # ora2 -edx-when==3.1.0 +edx-when==3.0.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 0d26eab55fcb..d6dc34a342f2 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -636,7 +636,7 @@ edx-toggles==5.4.1 # edxval # event-tracking # ora2 -edx-when==3.1.0 +edx-when==3.0.0 # via # -r requirements/edx/base.txt # edx-proctoring From 4913b986e05c2f70c760c4114ac1cda8155efd0f Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 9 Jan 2026 16:08:18 -0600 Subject: [PATCH 6/8] chore: update with make upgrade-package package=edx-when --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 13d36c644595..565e7503a29d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -546,7 +546,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 1b23b5b14bb1..a34c499a7ca4 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -852,7 +852,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 d6dc34a342f2..0d26eab55fcb 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -636,7 +636,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 9ce5c2944f79..b8887f659d92 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -660,7 +660,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 From 55eef0e333c2fefef6f1d30586502536c538467f Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 12 Jan 2026 09:24:15 -0600 Subject: [PATCH 7/8] chore: exclude extras from get_overrides_for_block to prevent tuple from breaking --- lms/djangoapps/instructor/views/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"))) From 9c95a16296324fd9f05998ed2beda1cb068e5812 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Fri, 16 Jan 2026 19:05:16 -0600 Subject: [PATCH 8/8] feat: address review comments - Adjust tests for more consistent behavior - Add UnitDueDateExtension as a data interface - Reduce code duplication --- .../instructor/tests/test_api_v2.py | 242 +++++++++++------- lms/djangoapps/instructor/views/api_v2.py | 177 ++++++------- 2 files changed, 234 insertions(+), 185 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index 619388cb53f4..0bcd9a5404d0 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -10,11 +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, @@ -954,6 +956,27 @@ def setUp(self): 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: @@ -1012,6 +1035,29 @@ def test_get_unit_extensions_nonexistent_course(self): 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): @@ -1043,21 +1089,27 @@ def test_get_unit_extensions_with_data(self, mock_get_units, mock_get_overrides) data = response.data results = data['results'] - self.assertGreaterEqual(len(results), 0) + self.assertGreaterEqual(len(results), 1) # If there are results, verify the structure - if results: - extension = results[0] - expected_fields = [ - 'username', 'full_name', 'email', - 'unit_title', 'unit_location', 'extended_due_date' - ] - for field in expected_fields: - self.assertIn(field, extension) + 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, mock_get_units, mock_get_overrides): + 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. """ @@ -1082,20 +1134,19 @@ def test_filter_by_email_or_username(self, mock_get_units, mock_get_overrides): self.client.force_authenticate(user=self.instructor) # Test filter by username - params = {'email_or_username': 'student1'} - url = f"{self._get_url()}?{urlencode(params)}" - response = self.client.get(url) + params = {'email_or_username': filter_value} + response = self.client.get(self._get_url(), params) self.assertEqual(response.status_code, status.HTTP_200_OK) - # The filter logic should be called - mock_get_overrides.assert_called() - - # Test filter by email - params = {'email_or_username': 'jane@example.com'} - url = f"{self._get_url()}?{urlencode(params)}" - response = self.client.get(url) + data = response.data + results = data['results'] - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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') @@ -1116,19 +1167,28 @@ def test_filter_by_block_id(self, mock_get_units, mock_find_unit, mock_get_overr # 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', 'john@example.com', mock_unit.location, extended_date), + ('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'} - url = f"{self._get_url()}?{urlencode(params)}" - response = self.client.get(url) + response = self.client.get(self._get_url(), params) self.assertEqual(response.status_code, status.HTTP_200_OK) - # Verify the block-specific API was called - mock_get_overrides_block.assert_called_once() - mock_find_unit.assert_called_once() + 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): @@ -1140,8 +1200,7 @@ def test_filter_by_invalid_block_id(self, mock_find_unit): self.client.force_authenticate(user=self.instructor) params = {'block_id': 'invalid-block-id'} - url = f"{self._get_url()}?{urlencode(params)}" - response = self.client.get(url) + response = self.client.get(self._get_url(), params) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.data @@ -1174,24 +1233,53 @@ def test_combined_filters(self, mock_find_unit, mock_get_overrides_block): 'block_id': 'block-v1:Test+Course+2024+type@sequential+block@hw1', 'email_or_username': 'student1' } - url = f"{self._get_url()}?{urlencode(params)}" - response = self.client.get(url) + response = self.client.get(self._get_url(), params) self.assertEqual(response.status_code, status.HTTP_200_OK) - # Both filters should be applied - mock_get_overrides_block.assert_called_once() - mock_find_unit.assert_called_once() - def test_pagination_parameters(self): + 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': '10'} - url = f"{self._get_url()}?{urlencode(params)}" - response = self.client.get(url) + 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 @@ -1200,6 +1288,20 @@ def test_pagination_parameters(self): 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): @@ -1248,53 +1350,21 @@ def test_extension_data_structure(self, mock_title_or_url, mock_get_units, mock_ self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.data + self.assertEqual(data['count'], 1) - if data['results']: - extension = data['results'][0] + 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) - - def test_response_content_type(self): - """ - Test that the response has the correct content type. - """ - self.client.force_authenticate(user=self.instructor) - response = self.client.get(self._get_url()) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response['content-type'], 'application/json') - - @ddt.data( - ('student1',), - ('john@example.com',), - ('STUDENT1',), # Test case insensitive - ('JOHN@EXAMPLE.COM',), # Test case insensitive - ) - @ddt.unpack - def test_email_or_username_filter_variations(self, filter_value): - """ - Test various formats for email_or_username filter. - """ - self.client.force_authenticate(user=self.instructor) - params = {'email_or_username': filter_value} - url = f"{self._get_url()}?{urlencode(params)}" - response = self.client.get(url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - # Verify the response structure is correct regardless of filter - data = response.data - self.assertIn('count', data) - self.assertIn('results', data) + # 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_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 651067e8107c..ec177c755b05 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -7,6 +7,8 @@ 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 @@ -354,11 +356,47 @@ def get(self, request, course_id): 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): """ - **Use Cases** - - Retrieve a paginated list of due date extensions for units in a course. + Retrieve a paginated list of due date extensions for units in a course. **Example Requests** @@ -404,127 +442,68 @@ class UnitExtensionsView(ListAPIView): permission_name = permissions.VIEW_DASHBOARD serializer_class = UnitExtensionSerializer - @apidocs.schema( - parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'email_or_username', - apidocs.ParameterLocation.QUERY, - description="Optional: Filter by username or email address.", - ), - apidocs.string_parameter( - 'block_id', - apidocs.ParameterLocation.QUERY, - description="Optional: Filter by specific unit/subsection location.", - ), - ], - responses={ - 200: UnitExtensionSerializer(many=True), - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor permissions.", - 404: "The requested course does not exist.", - }, - ) 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 dictionaries + 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_id = self.kwargs["course_id"] course_key = CourseKey.from_string(course_id) course = get_course_by_id(course_key) - # Get query parameters for filtering - email_or_username_filter = self.request.query_params.get('email_or_username', None) - block_id_filter = self.request.query_params.get('block_id', None) + email_or_username_filter = self.request.query_params.get("email_or_username") + block_id_filter = self.request.query_params.get("block_id") - # Get units with due dates for filtering units = get_units_with_due_date(course) - units_dict = {u.location: u for u in units} + units_dict = {str(u.location): u for u in units} - # If filtering by specific unit, use the block-specific logic + # Fetch and normalize overrides if block_id_filter: try: - # Parse the block_id and find the unit unit = find_unit(course, block_id_filter) - - # Use the block-specific API call for better performance query_data = edx_when_api.get_overrides_for_block(course.id, unit.location) - - # Transform the tuple data (username, full_name, due_date, email, location) for block-specific query - extension_data = [] - for username, fullname, due_date, email, location in query_data: - # Apply email_or_username filter if specified - if email_or_username_filter and email_or_username_filter.lower() not in username.lower(): - continue - - unit_title = title_or_url(unit) - extension_data.append({ - 'username': username, - 'full_name': fullname, - 'email': email, - 'unit_title': unit_title, - 'unit_location': str(location), - 'extended_due_date': due_date, - }) - - except (InvalidKeyError): + 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 - return [] + unit_due_date_extensions = [] else: - # Use the course-wide logic from get_overrides_for_course 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 + ] - # Transform the tuple data into dictionaries for serialization - extension_data = [] - for username, fullname, email, location, due_date in query_data: - # Filter by unit if location not in units with due dates - if location not in units_dict: + # 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) - # Apply email_or_username filter if specified - if email_or_username_filter: - email_or_username_filter_lower = email_or_username_filter.lower() - if (email_or_username_filter_lower not in username.lower() and - email_or_username_filter_lower != email.lower()): - continue - - unit_title = title_or_url(units_dict[location]) - extension_data.append({ - 'username': username, - 'full_name': fullname, - 'email': email, - 'unit_title': unit_title, - 'unit_location': str(location), - 'extended_due_date': due_date, - }) - - # Sort by username and unit title for consistent ordering - extension_data.sort(key=lambda x: (x['username'], x['unit_title'])) - - return extension_data - - def list(self, request, *args, **kwargs): - """ - Override list to work with our dictionary-based data structure. - """ - queryset = self.get_queryset() - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) + # Sort for consistent ordering + results.sort( + key=lambda o: ( + o.username, + o.unit_title, + ) + ) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) + return results