diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 4d9f6f83b9e8..898b64f3e9b3 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -2425,7 +2425,10 @@ def test_list_course_role_members_staff(self): 'first_name': self.other_staff.first_name, 'last_name': self.other_staff.last_name, } - ] + ], + 'count': 1, + 'num_pages': 1, + 'current_page': 1, } res_json = json.loads(response.content.decode('utf-8')) assert res_json == expected @@ -2440,7 +2443,10 @@ def test_list_course_role_members_beta(self): # check response content expected = { 'course_id': str(self.course.id), - 'beta': [] + 'beta': [], + 'count': 0, + 'num_pages': 0, + 'current_page': 1, } res_json = json.loads(response.content.decode('utf-8')) assert res_json == expected diff --git a/lms/djangoapps/instructor/tests/test_enrollment_list_api.py b/lms/djangoapps/instructor/tests/test_enrollment_list_api.py new file mode 100644 index 000000000000..722b06ea2c25 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_enrollment_list_api.py @@ -0,0 +1,468 @@ +""" +Unit tests for instructor API enrollment list endpoints with search and pagination. +""" +import json +from django.urls import reverse +from common.djangoapps.student.roles import CourseBetaTesterRole +from common.djangoapps.student.tests.factories import ( + CourseEnrollmentFactory, + InstructorFactory, + UserFactory +) +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase + + +class TestListCourseRoleMembersWithPagination(SharedModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test the list_course_role_members endpoint with search and pagination functionality. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super().setUp() + self.instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) + self.url = reverse('list_course_role_members', kwargs={'course_id': str(self.course.id)}) + + # Create beta testers for testing + self.beta_testers = [] + beta_role = CourseBetaTesterRole(self.course.id) + for i in range(25): + user = UserFactory( + username=f'beta_user_{i}', + email=f'beta{i}@example.com', + first_name=f'Beta{i}', + last_name=f'Tester{i}' + ) + beta_role.add_users(user) + self.beta_testers.append(user) + + def test_list_beta_testers_without_pagination(self): + """Test listing beta testers without pagination parameters (backward compatibility).""" + response = self.client.post(self.url, {'rolename': 'beta'}) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['course_id'] == str(self.course.id) + assert 'beta' in res_json + assert res_json['count'] == 25 + assert res_json['num_pages'] == 2 # 25 items with default page_size of 20 + assert res_json['current_page'] == 1 + assert len(res_json['beta']) == 20 # First page with default page_size + + def test_list_beta_testers_with_pagination(self): + """Test listing beta testers with pagination.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 25 + assert res_json['num_pages'] == 3 # 25 items / 10 per page + assert res_json['current_page'] == 1 + assert len(res_json['beta']) == 10 + + def test_list_beta_testers_second_page(self): + """Test listing beta testers on second page.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'page': 2, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['current_page'] == 2 + assert len(res_json['beta']) == 10 + + def test_list_beta_testers_last_page(self): + """Test listing beta testers on last page with partial results.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'page': 3, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['current_page'] == 3 + assert len(res_json['beta']) == 5 # Last page has 5 items + + def test_list_beta_testers_beyond_last_page(self): + """Test requesting a page beyond the last page returns empty results.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'page': 10, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['current_page'] == 10 + assert len(res_json['beta']) == 0 + + def test_list_beta_testers_search_by_username(self): + """Test searching beta testers by username.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'search': 'beta_user_1', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + # Should match beta_user_1, beta_user_10-19 (11 total) + assert res_json['count'] == 11 + assert len(res_json['beta']) == 10 # First page + for user in res_json['beta']: + assert 'beta_user_1' in user['username'] + + def test_list_beta_testers_search_by_email(self): + """Test searching beta testers by email.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'search': 'beta5@example.com', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 1 + assert len(res_json['beta']) == 1 + assert res_json['beta'][0]['email'] == 'beta5@example.com' + + def test_list_beta_testers_search_by_first_name(self): + """Test searching beta testers by first name.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'search': 'Beta2', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + # Should match Beta2, Beta20-24 (6 total) + assert res_json['count'] == 6 + for user in res_json['beta']: + assert 'Beta2' in user['first_name'] + + def test_list_beta_testers_search_case_insensitive(self): + """Test that search is case-insensitive.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'search': 'BETA_USER_3', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 1 + assert res_json['beta'][0]['username'] == 'beta_user_3' + + def test_list_beta_testers_search_no_results(self): + """Test searching with no matching results.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'search': 'nonexistent', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 0 + assert len(res_json['beta']) == 0 + + def test_list_beta_testers_empty_search(self): + """Test that empty search returns all results.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'search': '', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 25 + + def test_list_beta_testers_max_page_size(self): + """Test that page_size is capped at maximum.""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'page': 1, + 'page_size': 100 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert len(res_json['beta']) == 25 # All results fit in max page size + + def test_list_beta_testers_invalid_page_size(self): + """Test with invalid page_size (should fail validation).""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'page': 1, + 'page_size': 0 + }) + assert response.status_code == 400 + + def test_list_beta_testers_invalid_page(self): + """Test with invalid page number (should fail validation).""" + response = self.client.post(self.url, { + 'rolename': 'beta', + 'page': 0, + 'page_size': 10 + }) + assert response.status_code == 400 + + +class TestListCourseEnrollments(SharedModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test the list_course_enrollments endpoint with search and pagination functionality. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super().setUp() + self.instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) + self.url = reverse('list_course_enrollments', kwargs={'course_id': str(self.course.id)}) + + # Create enrollments for testing + self.enrolled_users = [] + for i in range(30): + user = UserFactory( + username=f'student_{i}', + email=f'student{i}@example.com', + first_name=f'Student{i}', + last_name=f'Learner{i}' + ) + CourseEnrollmentFactory( + user=user, + course_id=self.course.id, + is_active=True + ) + self.enrolled_users.append(user) + + # Create some inactive enrollments (should not be included) + for i in range(5): + user = UserFactory( + username=f'inactive_{i}', + email=f'inactive{i}@example.com' + ) + CourseEnrollmentFactory( + user=user, + course_id=self.course.id, + is_active=False + ) + + def test_list_enrollments_without_pagination(self): + """Test listing enrollments without pagination parameters.""" + response = self.client.post(self.url, {}) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['course_id'] == str(self.course.id) + assert 'enrollments' in res_json + # 30 active student enrollments (InstructorFactory does not create an enrollment) + assert res_json['count'] == 30 + assert res_json['num_pages'] == 2 # 30 items with default page_size of 20 + assert res_json['current_page'] == 1 + assert len(res_json['enrollments']) == 20 + + def test_list_enrollments_with_pagination(self): + """Test listing enrollments with pagination.""" + response = self.client.post(self.url, { + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 30 + assert res_json['num_pages'] == 3 # 30 items / 10 per page = 3 pages + assert res_json['current_page'] == 1 + assert len(res_json['enrollments']) == 10 + + def test_list_enrollments_second_page(self): + """Test listing enrollments on second page.""" + response = self.client.post(self.url, { + 'page': 2, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['current_page'] == 2 + assert len(res_json['enrollments']) == 10 + + def test_list_enrollments_last_page(self): + """Test listing enrollments on last page with partial results.""" + response = self.client.post(self.url, { + 'page': 3, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['current_page'] == 3 + assert len(res_json['enrollments']) == 10 # Last page has 10 items + + def test_list_enrollments_search_by_username(self): + """Test searching enrollments by username.""" + response = self.client.post(self.url, { + 'search': 'student_2', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + # Should match student_2, student_20-29 (11 total) + assert res_json['count'] == 11 + for user in res_json['enrollments']: + assert 'student_2' in user['username'] + + def test_list_enrollments_search_by_email(self): + """Test searching enrollments by email.""" + response = self.client.post(self.url, { + 'search': 'student7@example.com', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 1 + assert res_json['enrollments'][0]['email'] == 'student7@example.com' + + def test_list_enrollments_search_by_first_name(self): + """Test searching enrollments by first name.""" + response = self.client.post(self.url, { + 'search': 'Student1', + 'page': 1, + 'page_size': 20 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + # Should match Student1, Student10-19 (11 total) + assert res_json['count'] == 11 + + def test_list_enrollments_search_case_insensitive(self): + """Test that search is case-insensitive.""" + response = self.client.post(self.url, { + 'search': 'STUDENT_5', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 1 + assert res_json['enrollments'][0]['username'] == 'student_5' + + def test_list_enrollments_search_no_results(self): + """Test searching with no matching results.""" + response = self.client.post(self.url, { + 'search': 'nonexistent', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert res_json['count'] == 0 + assert len(res_json['enrollments']) == 0 + + def test_list_enrollments_excludes_inactive(self): + """Test that inactive enrollments are not included.""" + response = self.client.post(self.url, { + 'search': 'inactive', + 'page': 1, + 'page_size': 10 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + # Should not find any inactive enrollments + assert res_json['count'] == 0 + + def test_list_enrollments_empty_course(self): + """Test listing enrollments for a course with no enrollments.""" + empty_course = CourseFactory.create() + empty_instructor = InstructorFactory(course_key=empty_course.id) + self.client.login(username=empty_instructor.username, password=self.TEST_PASSWORD) + + url = reverse('list_course_enrollments', kwargs={'course_id': str(empty_course.id)}) + response = self.client.post(url, {}) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + # InstructorFactory does not create an enrollment, so empty course has 0 + assert res_json['count'] == 0 + assert len(res_json['enrollments']) == 0 + + def test_list_enrollments_max_page_size(self): + """Test that page_size is capped at maximum.""" + response = self.client.post(self.url, { + 'page': 1, + 'page_size': 100 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + assert len(res_json['enrollments']) == 30 # All results fit in max page size + + def test_list_enrollments_invalid_page_size(self): + """Test with invalid page_size (should fail validation).""" + response = self.client.post(self.url, { + 'page': 1, + 'page_size': 0 + }) + assert response.status_code == 400 + + def test_list_enrollments_invalid_page(self): + """Test with invalid page number (should fail validation).""" + response = self.client.post(self.url, { + 'page': -1, + 'page_size': 10 + }) + assert response.status_code == 400 + + def test_list_enrollments_permission_required(self): + """Test that non-instructor users cannot access the endpoint.""" + student = UserFactory() + self.client.login(username=student.username, password=self.TEST_PASSWORD) + + response = self.client.post(self.url, {}) + assert response.status_code == 403 + + def test_list_enrollments_ordered_by_username(self): + """Test that enrollments are ordered by username.""" + response = self.client.post(self.url, { + 'page': 1, + 'page_size': 5 + }) + assert response.status_code == 200 + + res_json = json.loads(response.content.decode('utf-8')) + usernames = [user['username'] for user in res_json['enrollments']] + # Check that usernames are in alphabetical order + assert usernames == sorted(usernames) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 09b91d362b28..b089e48fbc05 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -22,6 +22,7 @@ from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied, ValidationError from django.core.validators import validate_email from django.db import IntegrityError, transaction +from django.db.models import Q from django.http import QueryDict, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound from django.shortcuts import redirect from django.urls import reverse @@ -103,6 +104,7 @@ BlockDueDateSerializer, CertificateSerializer, CertificateStatusesSerializer, + EnrollmentListSerializer, ForumRoleNameSerializer, ListInstructorTaskInputSerializer, ModifyAccessSerializer, @@ -1050,6 +1052,11 @@ class ListCourseRoleMembersView(APIView): rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] + Supports optional search and pagination parameters: + - search: Filter users by username, email, first_name, or last_name + - page: Page number (default: 1) + - page_size: Number of results per page (default: 20, max: 100) + Returns JSON of the form { "course_id": "some/course/id", "staff": [ @@ -1059,7 +1066,10 @@ class ListCourseRoleMembersView(APIView): "first_name": "Joe", "last_name": "Shmoe", } - ] + ], + "count": 10, + "num_pages": 1, + "current_page": 1 } """ permission_classes = (IsAuthenticated, permissions.InstructorPermission) @@ -1087,13 +1097,128 @@ def post(self, request, course_id): role_serializer = RoleNameSerializer(data=request.data) role_serializer.is_valid(raise_exception=True) rolename = role_serializer.data['rolename'] + search = role_serializer.data.get('search', '').strip() + page = role_serializer.data.get('page', 1) + page_size = role_serializer.data.get('page_size', 20) users = list_with_level(course.id, rolename) - serializer = UserSerializer(users, many=True) + + # Apply search filter + if search: + users = [ + user for user in users + if search.lower() in user.username.lower() + or search.lower() in user.email.lower() + or search.lower() in (user.first_name or '').lower() + or search.lower() in (user.last_name or '').lower() + ] + + # Calculate pagination + total_count = len(users) + num_pages = (total_count + page_size - 1) // page_size if page_size > 0 else 1 + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + paginated_users = users[start_idx:end_idx] + + serializer = UserSerializer(paginated_users, many=True) response_payload = { 'course_id': str(course_id), rolename: serializer.data, + 'count': total_count, + 'num_pages': num_pages, + 'current_page': page, + } + + return Response(response_payload, status=status.HTTP_200_OK) + + +class ListCourseEnrollmentsView(APIView): + """ + View to list all enrollments (learners/students) for a specific course. + Requires the user to have instructor access. + + Supports optional search and pagination parameters: + - search: Filter users by username, email, first_name, or last_name + - page: Page number (default: 1) + - page_size: Number of results per page (default: 20, max: 100) + + Returns JSON of the form { + "course_id": "some/course/id", + "enrollments": [ + { + "username": "student1", + "email": "student1@example.org", + "first_name": "Jane", + "last_name": "Doe", + } + ], + "count": 100, + "num_pages": 5, + "current_page": 1 + } + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_ENROLLMENTS + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Handles POST request to list course enrollments. + + Args: + request (HttpRequest): The request object containing user data. + course_id (str): The ID of the course to list enrollments for. + + Returns: + Response: A Response object containing the list of enrollments or an error message. + + Raises: + Http404: If the course does not exist. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access( + request.user, 'instructor', course_key, depth=None + ) + + enrollment_serializer = EnrollmentListSerializer(data=request.data) + enrollment_serializer.is_valid(raise_exception=True) + search = enrollment_serializer.data.get('search', '').strip() + page = enrollment_serializer.data.get('page', 1) + page_size = enrollment_serializer.data.get('page_size', 20) + + # Get all active enrollments for the course + enrollments = CourseEnrollment.objects.filter( + course_id=course_key, + is_active=True + ).select_related('user').order_by('user__username') + + # Apply search filter + if search: + enrollments = enrollments.filter( + Q(user__username__icontains=search) | + Q(user__email__icontains=search) | + Q(user__first_name__icontains=search) | + Q(user__last_name__icontains=search) + ) + + # Calculate pagination + total_count = enrollments.count() + num_pages = (total_count + page_size - 1) // page_size if page_size > 0 else 1 + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + paginated_enrollments = enrollments[start_idx:end_idx] + + # Extract user data from enrollments + users = [enrollment.user for enrollment in paginated_enrollments] + serializer = UserSerializer(users, many=True) + + response_payload = { + 'course_id': str(course_key), + 'enrollments': serializer.data, + 'count': total_count, + 'num_pages': num_pages, + 'current_page': page, } return Response(response_payload, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index b61120e58c13..45089749d4d6 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -72,6 +72,7 @@ path('students_update_enrollment', api.StudentsUpdateEnrollmentView.as_view(), name='students_update_enrollment'), path('register_and_enroll_students', api.RegisterAndEnrollStudents.as_view(), name='register_and_enroll_students'), path('list_course_role_members', api.ListCourseRoleMembersView.as_view(), name='list_course_role_members'), + path('list_course_enrollments', api.ListCourseEnrollmentsView.as_view(), name='list_course_enrollments'), path('modify_access', api.ModifyAccess.as_view(), name='modify_access'), path('bulk_beta_modify_access', api.BulkBetaModifyAccess.as_view(), name='bulk_beta_modify_access'), path('get_problem_responses', api.GetProblemResponses.as_view(), name='get_problem_responses'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 9b83acdf6156..229a49dc9849 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -29,6 +29,9 @@ class RoleNameSerializer(serializers.Serializer): # pylint: disable=abstract-me """ rolename = serializers.CharField(help_text=_("Role name")) + search = serializers.CharField(required=False, allow_blank=True, help_text=_("Search term")) + page = serializers.IntegerField(required=False, min_value=1, help_text=_("Page number")) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, help_text=_("Page size")) def validate_rolename(self, value): """ @@ -45,6 +48,15 @@ class Meta: fields = ['username', 'email', 'first_name', 'last_name'] +class EnrollmentListSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer for enrollment list request parameters. + """ + search = serializers.CharField(required=False, allow_blank=True, help_text=_("Search term")) + page = serializers.IntegerField(required=False, min_value=1, help_text=_("Page number")) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, help_text=_("Page size")) + + class UniqueStudentIdentifierSerializer(serializers.Serializer): """ Serializer for identifying unique_student.