From 6c87142629f139c038621bad8a4a8777013eafd9 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Wed, 17 Dec 2025 14:39:29 -0600 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 From d31fd520e0fde8ccbd4e166d8bed2275bb3ad9e2 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Tue, 20 Jan 2026 14:40:33 -0600 Subject: [PATCH 09/13] chore: fixed lint issue --- lms/djangoapps/instructor/views/serializers_v2.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index 1b459cffa229..ad54cd2c08f9 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -443,6 +443,8 @@ class UnitExtensionSerializer(serializers.Serializer): extended_due_date = serializers.DateTimeField( help_text="The extended due date for the learner" ) + + class ORASerializer(serializers.Serializer): """Serializer for Open Response Assessments (ORAs) in a course.""" From c94aa75e3daa35c59a417514896c338daf1ee63a Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 21 Jan 2026 18:24:06 -0600 Subject: [PATCH 10/13] fix: apply review feedback to unit extensions tests --- .../instructor/tests/test_api_v2.py | 76 +++---------------- 1 file changed, 9 insertions(+), 67 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index ebccf3facf4b..e6120b96f217 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -1219,23 +1219,6 @@ def _get_url(self, course_id=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. @@ -1271,9 +1254,9 @@ 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): + def test_get_unit_extensions(self): """ - Test unit extensions with mocked data. + Test retrieving unit extensions. """ self.client.force_authenticate(user=self.instructor) @@ -1294,48 +1277,6 @@ def test_get_unit_extensions_with_no_mockups(self): 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), @@ -1377,12 +1318,13 @@ def test_filter_by_email_or_username(self, filter_value, is_username, mock_get_u 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()) + self.assertEqual(len(results), 1) + + # Check that the filter value is in the appropriate field + if is_username: + self.assertIn(filter_value.lower(), results[0]['username'].lower()) + else: + self.assertIn(filter_value.lower(), results[0]['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') From 41cb3faede9cb0e4781ba3e9538e56d1663209b3 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 26 Jan 2026 11:47:29 -0600 Subject: [PATCH 11/13] refactor: simplify result filtering --- lms/djangoapps/instructor/views/api_v2.py | 26 +++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index ece3b3ab666a..8056958f1b57 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -447,6 +447,15 @@ class UnitExtensionsView(ListAPIView): permission_name = permissions.VIEW_DASHBOARD serializer_class = UnitExtensionSerializer + def _matches_email_or_username(self, unit_extension, filter_value): + """ + Check if the unit extension matches the email or username filter. + """ + return ( + filter_value in unit_extension.username.lower() + or filter_value in unit_extension.email.lower() + ) + def get_queryset(self): """ Returns the queryset of unit extensions for the specified course. @@ -489,19 +498,14 @@ def get_queryset(self): if str(row[3]) in units_dict # Ensure unit has due date ] - # Apply filters - results = [] + # Apply filters if any 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) + results = [ + extension + for extension in unit_due_date_extensions + if self._matches_email_or_username(extension, filter_value) + ] if filter_value else unit_due_date_extensions # if no filter, use all # Sort for consistent ordering results.sort( From a20c2061f18a680835a56c46b23bae53d3727a42 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 26 Jan 2026 17:08:01 -0600 Subject: [PATCH 12/13] test: update test_get_unit_extensions --- .../instructor/tests/test_api_v2.py | 70 +++++++++++-------- lms/djangoapps/instructor/views/api_v2.py | 1 + 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index c204cc5afbe7..94b2fd7b43ef 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -10,7 +10,6 @@ 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 @@ -1174,18 +1173,16 @@ def setUpClass(cls): ) cls.course_key = cls.course.id - # Create course structure with due dates + # Create course structure 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 + display_name='Homework 1' ) cls.vertical = BlockFactory.create( parent=cls.subsection, @@ -1218,27 +1215,6 @@ 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: @@ -1285,7 +1261,30 @@ def test_get_unit_extensions(self): Test retrieving unit extensions. """ - self.client.force_authenticate(user=self.instructor) + # Set up due dates + date1 = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC) + date2 = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC) + date3 = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC) + + items = [ + (self.subsection.location, {'due': date1}), # Homework 1 + (self.vertical.location, {'due': date2}), # Test Vertical (Should be ignored) + (self.problem.location, {'due': date3}), # Test Problem (Should be ignored) + ] + set_dates_for_course(self.course_key, items) + + # Set up overrides + override1 = datetime(2025, 10, 31, 23, 59, 59, tzinfo=UTC) + override2 = datetime(2025, 11, 30, 23, 59, 59, tzinfo=UTC) + override3 = datetime(2025, 12, 31, 23, 59, 59, tzinfo=UTC) + # Single override per user + # Only return the top-level override per user, in this case the subsection level + set_date_for_block(self.course_key, self.subsection.location, 'due', override1, user=self.student1) + set_date_for_block(self.course_key, self.subsection.location, 'due', override2, user=self.student2) + # Multiple overrides per user + set_date_for_block(self.course_key, self.subsection.location, 'due', override3, user=self.student2) + + self.client.force_authenticate(user=self.staff) response = self.client.get(self._get_url()) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1294,14 +1293,23 @@ def test_get_unit_extensions(self): self.assertEqual(len(results), 2) - # If there are results, verify the structure - extension = results[1] + # Student 1's extension + extension = results[0] 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_title'], 'Homework 1') # Should be the top-level unit + self.assertEqual(extension['unit_location'], 'block-v1:edX+TestX+Test_Course+type@sequential+block@Homework_1') + self.assertEqual(extension['extended_due_date'], '2025-10-31T23:59:59Z') + + # Student 2's extension + extension = results[1] + self.assertEqual(extension['username'], 'student2') + self.assertIn('Robot', extension['full_name']) + self.assertEqual(extension['email'], 'student2@example.com') + self.assertEqual(extension['unit_title'], 'Homework 1') # Should be the top-level unit 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') + self.assertEqual(extension['extended_due_date'], '2025-12-31T23:59:59Z') @ddt.data( ('student1', True), diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 8056958f1b57..9cb0b96fa34f 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -446,6 +446,7 @@ class UnitExtensionsView(ListAPIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD serializer_class = UnitExtensionSerializer + filter_backends = [] def _matches_email_or_username(self, unit_extension, filter_value): """ From 90896c493152e03c80e0f539f4315f773382b2e0 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 26 Jan 2026 12:54:58 -0500 Subject: [PATCH 13/13] build: Update README, CODEOWNERS, catalog-info with new repo name (#37939) Referenced GH team names have been updated accordingly as well. Closes: https://github.com/openedx/openedx-platform/issues/37904 --- .github/CODEOWNERS | 40 ++++++++++++++++++++-------------------- README.rst | 19 ++++++++++--------- catalog-info.yaml | 4 ++-- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 038f49cd4470..c32fed81b2ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,8 +2,8 @@ # Core common/djangoapps/student/ -common/djangoapps/student/models/__init__.py @openedx/committers-edx-platform-2u-tnl -common/djangoapps/student/models/course_enrollment.py @openedx/committers-edx-platform-2u-tnl +common/djangoapps/student/models/__init__.py @openedx/committers-openedx-platform-2u-tnl +common/djangoapps/student/models/course_enrollment.py @openedx/committers-openedx-platform-2u-tnl common/djangoapps/third_party_auth/ lms/djangoapps/course_api/blocks lms/djangoapps/courseware/ @@ -11,15 +11,15 @@ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_task/ lms/djangoapps/mobile_api/ -openedx/core/djangoapps/commerce/ @openedx/committers-edx-platform-2u-infinity -openedx/core/djangoapps/credentials @openedx/committers-edx-platform-2u-aperture -openedx/core/djangoapps/credit @openedx/committers-edx-platform-2u-aperture -openedx/core/djangoapps/enrollments/ @openedx/committers-edx-platform-2u-aperture +openedx/core/djangoapps/commerce/ @openedx/committers-openedx-platform-2u-infinity +openedx/core/djangoapps/credentials @openedx/committers-openedx-platform-2u-aperture +openedx/core/djangoapps/credit @openedx/committers-openedx-platform-2u-aperture +openedx/core/djangoapps/enrollments/ @openedx/committers-openedx-platform-2u-aperture openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/oauth_dispatch -openedx/core/djangoapps/user_api/ @openedx/committers-edx-platform-2u-aperture -openedx/core/djangoapps/user_authn/ @openedx/committers-edx-platform-2u-infinity -openedx/core/djangoapps/verified_track_content/ @openedx/committers-edx-platform-2u-infinity +openedx/core/djangoapps/user_api/ @openedx/committers-openedx-platform-2u-aperture +openedx/core/djangoapps/user_authn/ @openedx/committers-openedx-platform-2u-infinity +openedx/core/djangoapps/verified_track_content/ @openedx/committers-openedx-platform-2u-infinity openedx/features/course_experience/ # The Aximprovements team is working on extracting all built-in XBlocks # to the external repository (xblocks-contrib). They need to be notified @@ -35,21 +35,21 @@ lms/djangoapps/edxnotes common/djangoapps/track/ # Credentials -lms/djangoapps/certificates/ @openedx/committers-edx-platform-2u-aperture +lms/djangoapps/certificates/ @openedx/committers-openedx-platform-2u-aperture # Discovery -common/djangoapps/course_modes/ @openedx/committers-edx-platform-2u-aperture +common/djangoapps/course_modes/ @openedx/committers-openedx-platform-2u-aperture common/djangoapps/enrollment/ -common/djangoapps/entitlements/ @openedx/committers-edx-platform-2u-aperture -lms/djangoapps/branding/ @openedx/committers-edx-platform-2u-aperture -lms/djangoapps/commerce/ @openedx/committers-edx-platform-2u-infinity -lms/djangoapps/experiments/ @openedx/committers-edx-platform-2u-infinity -lms/djangoapps/gating/ @openedx/committers-edx-platform-2u-infinity -lms/djangoapps/learner_dashboard/ @openedx/committers-edx-platform-2u-aperture -lms/djangoapps/learner_home/ @openedx/committers-edx-platform-2u-aperture -openedx/features/content_type_gating/ @openedx/committers-edx-platform-2u-infinity +common/djangoapps/entitlements/ @openedx/committers-openedx-platform-2u-aperture +lms/djangoapps/branding/ @openedx/committers-openedx-platform-2u-aperture +lms/djangoapps/commerce/ @openedx/committers-openedx-platform-2u-infinity +lms/djangoapps/experiments/ @openedx/committers-openedx-platform-2u-infinity +lms/djangoapps/gating/ @openedx/committers-openedx-platform-2u-infinity +lms/djangoapps/learner_dashboard/ @openedx/committers-openedx-platform-2u-aperture +lms/djangoapps/learner_home/ @openedx/committers-openedx-platform-2u-aperture +openedx/features/content_type_gating/ @openedx/committers-openedx-platform-2u-infinity openedx/features/course_duration_limits/ -openedx/features/discounts/ @openedx/committers-edx-platform-2u-infinity +openedx/features/discounts/ @openedx/committers-openedx-platform-2u-infinity # Ping Axim On-call if someone uses the QuickStart # https://docs.openedx.org/en/latest/developers/quickstarts/first_openedx_pr.html diff --git a/README.rst b/README.rst index bac76e2efc62..b315cdcbf2b6 100644 --- a/README.rst +++ b/README.rst @@ -5,11 +5,13 @@ Open edX Platform .. |License: AGPL v3| image:: https://img.shields.io/badge/License-AGPL_v3-blue.svg :target: https://www.gnu.org/licenses/agpl-3.0 -.. |Python CI| image:: https://github.com/openedx/edx-platform/actions/workflows/unit-tests.yml/badge.svg - :target: https://github.com/openedx/edx-platform/actions/workflows/unit-tests.yml +.. |Python CI| image:: https://github.com/openedx/openedx-platform/actions/workflows/unit-tests.yml/badge.svg + :target: https://github.com/openedx/openedx-platform/actions/workflows/unit-tests.yml .. |Status| image:: https://img.shields.io/badge/status-maintained-31c653 +*Formerly known as "edx-platform"* + Purpose ******* The `Open edX Platform `_ enables the authoring and @@ -20,7 +22,7 @@ modular monolith, some independently deployable applications (IDAs), and micro-frontends (MFEs) based on the ReactJS. This repository hosts the monolith at the center of the Open edX -platform. Functionally, the edx-platform repository provides two services: +platform. Functionally, the openedx-platform repository provides two services: * CMS (Content Management Service), which powers Open edX Studio, the platform's learning content authoring environment; and * LMS (Learning Management Service), which delivers learning content. @@ -53,7 +55,7 @@ For Development =============== Tutor also features a `development mode`_ which will also help you modify, -test, and extend edx-platform. We recommend this method for all Open edX +test, and extend openedx-platform. We recommend this method for all Open edX developers. Bare Metal (Advanced) @@ -234,7 +236,7 @@ License The code in this repository is licensed under version 3 of the AGPL unless otherwise noted. Please see the `LICENSE`_ file for details. -.. _LICENSE: https://github.com/openedx/edx-platform/blob/master/LICENSE +.. _LICENSE: https://github.com/openedx/openedx-platform/blob/master/LICENSE More about Open edX @@ -271,8 +273,8 @@ We use Github Issues for our issue tracker. You can search `previously reported issues`_. If you need to report a bug, or want to discuss a new feature before you implement it, please `create a new issue`_. -.. _previously reported issues: https://github.com/openedx/edx-platform/issues -.. _create a new issue: https://github.com/openedx/edx-platform/issues/new/choose +.. _previously reported issues: https://github.com/openedx/openedx-platform/issues +.. _create a new issue: https://github.com/openedx/openedx-platform/issues/new/choose How to Contribute @@ -306,5 +308,4 @@ People The current maintainers of this repository can be found on `Backstage`_. -.. _Backstage: https://backstage.openedx.org/catalog/default/component/edx-platform - +.. _Backstage: https://backstage.openedx.org/catalog/default/component/openedx-platform diff --git a/catalog-info.yaml b/catalog-info.yaml index c6b87b08db82..ba10f006a332 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -4,7 +4,7 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: 'edx-platform' + name: 'openedx-platform' description: "The monolith at the center of the Open edX platform" links: - url: "https://docs.openedx.org" @@ -13,6 +13,6 @@ metadata: annotations: openedx.org/release: "master" spec: - owner: group:wg-maintenance-edx-platform + owner: group:wg-maintenance-openedx-platform type: 'service' lifecycle: 'production'