diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py new file mode 100644 index 000000000000..dfcf87d9e84c --- /dev/null +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -0,0 +1,299 @@ +""" +Tests for Instructor API v2 GET endpoints. +""" +import json +from datetime import datetime, timezone +from unittest.mock import patch +from uuid import uuid4 +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.instructor_task.models import InstructorTask +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory + + +class LearnerViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/instructor/v2/courses/{course_key}/learners/{email_or_username} + """ + + def setUp(self): + super().setUp() + self.client = APIClient() + self.instructor = UserFactory(is_staff=False) + self.student = UserFactory( + username='john_harvard', + email='john@example.com', + first_name='John', + last_name='Harvard' + ) + self.course = CourseFactory.create() + self.client.force_authenticate(user=self.instructor) + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_learner_by_username(self, mock_perm): + """Test retrieving learner info by username""" + mock_perm.return_value = True + + url = reverse('instructor_api_v2:learner_detail', kwargs={ + 'course_id': str(self.course.id), + 'email_or_username': self.student.username + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['username'], 'john_harvard') + self.assertEqual(data['email'], 'john@example.com') + self.assertEqual(data['first_name'], 'John') + self.assertEqual(data['last_name'], 'Harvard') + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_learner_by_email(self, mock_perm): + """Test retrieving learner info by email""" + mock_perm.return_value = True + + url = reverse('instructor_api_v2:learner_detail', kwargs={ + 'course_id': str(self.course.id), + 'email_or_username': self.student.email + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['username'], 'john_harvard') + self.assertEqual(data['email'], 'john@example.com') + + def test_get_learner_requires_authentication(self): + """Test that endpoint requires authentication""" + self.client.force_authenticate(user=None) + + url = reverse('instructor_api_v2:learner_detail', kwargs={ + 'course_id': str(self.course.id), + 'email_or_username': self.student.username + }) + response = self.client.get(url) + + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + +class ProblemViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/instructor/v2/courses/{course_key}/problems/{location} + """ + + def setUp(self): + super().setUp() + self.client = APIClient() + self.instructor = UserFactory(is_staff=False) + self.course = CourseFactory.create(display_name='Test Course') + self.chapter = BlockFactory.create( + parent=self.course, + category='chapter', + display_name='Week 1' + ) + self.sequential = BlockFactory.create( + parent=self.chapter, + category='sequential', + display_name='Homework 1' + ) + self.problem = BlockFactory.create( + parent=self.sequential, + category='problem', + display_name='Sample Problem' + ) + self.client.force_authenticate(user=self.instructor) + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_problem_metadata(self, mock_perm): + """Test retrieving problem metadata""" + mock_perm.return_value = True + + url = reverse('instructor_api_v2:problem_detail', kwargs={ + 'course_id': str(self.course.id), + 'location': str(self.problem.location) + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['id'], str(self.problem.location)) + self.assertEqual(data['name'], 'Sample Problem') + self.assertIn('breadcrumbs', data) + self.assertIsInstance(data['breadcrumbs'], list) + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_problem_with_breadcrumbs(self, mock_perm): + """Test that breadcrumbs are included in response""" + mock_perm.return_value = True + + url = reverse('instructor_api_v2:problem_detail', kwargs={ + 'course_id': str(self.course.id), + 'location': str(self.problem.location) + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + breadcrumbs = data['breadcrumbs'] + + # Should have at least the problem itself + self.assertGreater(len(breadcrumbs), 0) + # Check that breadcrumb items have required fields + for crumb in breadcrumbs: + self.assertIn('display_name', crumb) + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_problem_invalid_location(self, mock_perm): + """Test 400 with invalid problem location""" + mock_perm.return_value = True + + url = reverse('instructor_api_v2:problem_detail', kwargs={ + 'course_id': str(self.course.id), + 'location': 'invalid-location' + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.json()) + + def test_get_problem_requires_authentication(self): + """Test that endpoint requires authentication""" + self.client.force_authenticate(user=None) + + url = reverse('instructor_api_v2:problem_detail', kwargs={ + 'course_id': str(self.course.id), + 'location': str(self.problem.location) + }) + response = self.client.get(url) + + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + +class TaskStatusViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/instructor/v2/courses/{course_key}/tasks/{task_id} + """ + + def setUp(self): + super().setUp() + self.client = APIClient() + self.instructor = UserFactory(is_staff=False) + self.course = CourseFactory.create() + self.client.force_authenticate(user=self.instructor) + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_task_status_completed(self, mock_perm): + """Test retrieving completed task status""" + mock_perm.return_value = True + + # Create a completed task + task_id = str(uuid4()) + task_output = json.dumps({ + 'current': 150, + 'total': 150, + 'message': 'Reset attempts for 150 learners' + }) + task = InstructorTask.objects.create( + course_id=self.course.id, + task_type='rescore_problem', + task_key='', + task_input='{}', + task_id=task_id, + task_state='SUCCESS', + task_output=task_output, + requester=self.instructor + ) + + url = reverse('instructor_api_v2:task_status', kwargs={ + 'course_id': str(self.course.id), + 'task_id': task_id + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['task_id'], task_id) + self.assertEqual(data['state'], 'completed') + self.assertIn('progress', data) + self.assertEqual(data['progress']['current'], 150) + self.assertEqual(data['progress']['total'], 150) + self.assertIn('result', data) + self.assertTrue(data['result']['success']) + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_task_status_running(self, mock_perm): + """Test retrieving running task status""" + mock_perm.return_value = True + + # Create a running task + task_id = str(uuid4()) + task_output = json.dumps({'current': 75, 'total': 150}) + InstructorTask.objects.create( + course_id=self.course.id, + task_type='rescore_problem', + task_key='', + task_input='{}', + task_id=task_id, + task_state='PROGRESS', + task_output=task_output, + requester=self.instructor + ) + + url = reverse('instructor_api_v2:task_status', kwargs={ + 'course_id': str(self.course.id), + 'task_id': task_id + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['state'], 'running') + self.assertIn('progress', data) + self.assertEqual(data['progress']['current'], 75) + self.assertEqual(data['progress']['total'], 150) + + @patch('lms.djangoapps.instructor.views.api_v2.permissions.InstructorPermission.has_permission') + def test_get_task_status_failed(self, mock_perm): + """Test retrieving failed task status""" + mock_perm.return_value = True + + # Create a failed task + task_id = str(uuid4()) + InstructorTask.objects.create( + course_id=self.course.id, + task_type='rescore_problem', + task_key='', + task_input='{}', + task_id=task_id, + task_state='FAILURE', + task_output='Task execution failed', + requester=self.instructor + ) + + url = reverse('instructor_api_v2:task_status', kwargs={ + 'course_id': str(self.course.id), + 'task_id': task_id + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['state'], 'failed') + self.assertIn('error', data) + self.assertIn('code', data['error']) + self.assertIn('message', data['error']) + + def test_get_task_requires_authentication(self): + """Test that endpoint requires authentication""" + self.client.force_authenticate(user=None) + + url = reverse('instructor_api_v2:task_status', kwargs={ + 'course_id': str(self.course.id), + 'task_id': 'some-task-id' + }) + response = self.client.get(url) + + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index f23701cc1de7..dc2804e56bf9 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -40,6 +40,21 @@ rf'^courses/{COURSE_ID_PATTERN}/graded_subsections$', api_v2.GradedSubsectionsView.as_view(), name='graded_subsections' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/learners/(?P[^/]+)$', + api_v2.LearnerView.as_view(), + name='learner_detail' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/problems/(?P.+)$', + api_v2.ProblemView.as_view(), + name='problem_detail' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/tasks/(?P[^/]+)$', + api_v2.TaskStatusView.as_view(), + name='task_status' ) ] diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 709ad8f7e0f0..94bd3f63c3e1 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -25,6 +25,7 @@ from lms.djangoapps.instructor.views.api import _display_unit, get_student_from_identifier from lms.djangoapps.instructor.views.instructor_task_helpers import extract_task_features from lms.djangoapps.instructor_task import api as task_api +from lms.djangoapps.instructor_task.models import InstructorTask from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id from .serializers_v2 import ( @@ -349,3 +350,301 @@ def get(self, request, course_id): } for unit in graded_subsections]} return Response(formated_subsections, status=status.HTTP_200_OK) + + +class LearnerView(DeveloperErrorViewMixin, APIView): + """ + API view for retrieving learner information. + + **GET Example Response:** + ```json + { + "username": "john_harvard", + "email": "john@example.com", + "first_name": "John", + "last_name": "Harvard", + "progress_url": "https://courses.example.com/courses/course-v1:edX+DemoX+Demo_Course/progress/john_harvard/", + "gradebook_url": "https://courses.example.com/courses/course-v1:edX+DemoX+Demo_Course/instructor#view-gradebook", + "current_score": { + "score": 85.5, + "total": 100.0 + }, + "attempts": null + } + ``` + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_DASHBOARD + + @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.PATH, + description="Learner's username or email address", + ), + ], + responses={ + 200: 'Learner information retrieved successfully', + 400: "Invalid parameters provided.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks instructor access to the course.", + 404: "Learner not found or course does not exist.", + }, + ) + def get(self, request, course_id, email_or_username): + """ + Retrieve comprehensive learner information including profile, enrollment status, + progress URLs, and current grading data. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {'error': 'Invalid course key'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + student = get_student_from_identifier(email_or_username) + except Exception: # pylint: disable=broad-except + return Response( + {'error': 'Learner not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Build learner data + learner_data = { + 'username': student.username, + 'email': student.email, + 'first_name': student.first_name, + 'last_name': student.last_name, + 'progress_url': None, + 'gradebook_url': None, + 'current_score': None, + 'attempts': None, + } + + from .serializers_v2 import LearnerSerializer + serializer = LearnerSerializer(learner_data) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ProblemView(DeveloperErrorViewMixin, APIView): + """ + API view for retrieving problem metadata. + + **GET Example Response:** + ```json + { + "id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@sample_problem", + "name": "Sample Problem", + "breadcrumbs": [ + {"display_name": "Demonstration Course"}, + {"display_name": "Week 1", "usage_key": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@week1"}, + {"display_name": "Homework", "usage_key": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@hw1"}, + {"display_name": "Sample Problem", "usage_key": "block-v1:edX+DemoX+Demo_Course+type@problem+block@sample_problem"} + ] + } + ``` + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_DASHBOARD + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'location', + apidocs.ParameterLocation.PATH, + description="Problem block usage key", + ), + ], + responses={ + 200: 'Problem information retrieved successfully', + 400: "Invalid parameters provided.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks instructor access to the course.", + 404: "Problem not found or course does not exist.", + }, + ) + def get(self, request, course_id, location): + """ + Retrieve problem metadata including display name, location in course hierarchy, + and usage key. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {'error': 'Invalid course key'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + problem_key = UsageKey.from_string(location) + except InvalidKeyError: + return Response( + {'error': 'Invalid problem location'}, + status=status.HTTP_400_BAD_REQUEST + ) + + from xmodule.modulestore.django import modulestore + store = modulestore() + + try: + problem = store.get_item(problem_key) + except Exception: # pylint: disable=broad-except + return Response( + {'error': 'Problem not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Build breadcrumbs + breadcrumbs = [] + current = problem + while current: + breadcrumbs.insert(0, { + 'display_name': current.display_name, + 'usage_key': str(current.location) if current.location != course_key else None + }) + parent_location = current.get_parent() if hasattr(current, 'get_parent') else None + if parent_location: + try: + current = store.get_item(parent_location) + except Exception: # pylint: disable=broad-except + break + else: + break + + problem_data = { + 'id': str(problem.location), + 'name': problem.display_name, + 'breadcrumbs': [b for b in breadcrumbs if b.get('usage_key') is not None or breadcrumbs.index(b) == 0] + } + + from .serializers_v2 import ProblemSerializer + serializer = ProblemSerializer(problem_data) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class TaskStatusView(DeveloperErrorViewMixin, APIView): + """ + API view for checking background task status. + + **GET Example Response:** + ```json + { + "task_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "state": "completed", + "progress": { + "current": 150, + "total": 150 + }, + "result": { + "success": true, + "message": "Reset attempts for 150 learners" + }, + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:35:23Z" + } + ``` + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.SHOW_TASKS + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'task_id', + apidocs.ParameterLocation.PATH, + description="Task identifier returned from async operation", + ), + ], + responses={ + 200: 'Task status retrieved successfully', + 400: "Invalid parameters provided.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks instructor access to the course.", + 404: "Task not found.", + }, + ) + def get(self, request, course_id, task_id): + """ + Check the status of a background task. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {'error': 'Invalid course key'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get task from InstructorTask model + try: + task = InstructorTask.objects.get(task_id=task_id, course_id=course_key) + except InstructorTask.DoesNotExist: + return Response( + {'error': 'Task not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Map task state + state_map = { + 'PENDING': 'pending', + 'PROGRESS': 'running', + 'SUCCESS': 'completed', + 'FAILURE': 'failed', + 'REVOKED': 'failed' + } + + task_data = { + 'task_id': str(task.task_id), + 'state': state_map.get(task.task_state, 'pending'), + 'created_at': task.created, + 'updated_at': task.updated, + } + + # Add progress if available + if hasattr(task, 'task_output') and task.task_output: + import json + try: + output = json.loads(task.task_output) + if 'current' in output and 'total' in output: + task_data['progress'] = { + 'current': output['current'], + 'total': output['total'] + } + if task.task_state == 'SUCCESS' and 'message' in output: + task_data['result'] = { + 'success': True, + 'message': output['message'] + } + except (json.JSONDecodeError, KeyError): + pass + + # Add error if failed + if task.task_state in ['FAILURE', 'REVOKED']: + task_data['error'] = { + 'code': 'TASK_FAILED', + 'message': str(task.task_output) if task.task_output else 'Task failed' + } + + from .serializers_v2 import TaskStatusSerializer + serializer = TaskStatusSerializer(task_data) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index e504867d2af9..f1017126462b 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -416,3 +416,98 @@ def validate_due_datetime(self, value): raise serializers.ValidationError( _('The extension due date and time format is incorrect') ) from exc + + +class LearnerSerializer(serializers.Serializer): + """ + Serializer for learner information. + + Provides comprehensive learner data including profile, enrollment status, + and current progress in a course. + """ + username = serializers.CharField( + help_text="Learner's username" + ) + email = serializers.EmailField( + help_text="Learner's email address" + ) + first_name = serializers.CharField( + help_text="Learner's first name" + ) + last_name = serializers.CharField( + help_text="Learner's last name" + ) + progress_url = serializers.URLField( + allow_null=True, + required=False, + help_text="URL to learner's progress page" + ) + gradebook_url = serializers.URLField( + allow_null=True, + required=False, + help_text="URL to learner's gradebook view" + ) + current_score = serializers.DictField( + allow_null=True, + required=False, + help_text="Current score information with 'score' and 'total' fields" + ) + attempts = serializers.DictField( + allow_null=True, + required=False, + help_text="Attempts information with 'current' and 'total' fields" + ) + + +class ProblemSerializer(serializers.Serializer): + """ + Serializer for problem metadata and location. + + Provides problem information including display name and course hierarchy. + """ + id = serializers.CharField( + help_text="Problem usage key" + ) + name = serializers.CharField( + help_text="Problem display name" + ) + breadcrumbs = serializers.ListField( + child=serializers.DictField(), + help_text="Course hierarchy breadcrumbs showing problem location" + ) + + +class TaskStatusSerializer(serializers.Serializer): + """ + Serializer for background task status. + + Provides status and progress information for asynchronous operations. + """ + task_id = serializers.CharField( + help_text="Task identifier" + ) + state = serializers.ChoiceField( + choices=['pending', 'running', 'completed', 'failed'], + help_text="Current state of the task" + ) + progress = serializers.DictField( + allow_null=True, + required=False, + help_text="Progress information with 'current' and 'total' fields" + ) + result = serializers.DictField( + allow_null=True, + required=False, + help_text="Task result (present when state is 'completed')" + ) + error = serializers.DictField( + allow_null=True, + required=False, + help_text="Error information (present when state is 'failed')" + ) + created_at = serializers.DateTimeField( + help_text="Task creation timestamp" + ) + updated_at = serializers.DateTimeField( + help_text="Last update timestamp" + )