diff --git a/lms/djangoapps/instructor/docs/references/instructor-v2-grading-api-spec.yaml b/lms/djangoapps/instructor/docs/references/instructor-v2-grading-api-spec.yaml index 4fea37ebb5ec..1a28ea92de72 100644 --- a/lms/djangoapps/instructor/docs/references/instructor-v2-grading-api-spec.yaml +++ b/lms/djangoapps/instructor/docs/references/instructor-v2-grading-api-spec.yaml @@ -32,6 +32,8 @@ security: - JWTAuth: [] tags: + - name: Course + description: Course-level configuration and metadata - name: Learners description: Learner information and enrollment data - name: Problems @@ -83,6 +85,9 @@ paths: Retrieve problem metadata including display name, location in course hierarchy, and usage key. + When a `learner` query parameter is provided, the response also includes the + learner's current score and attempt count for the problem. + **Note:** Requires exact problem location - no search or partial matching. operationId: getProblem produces: @@ -95,6 +100,7 @@ paths: required: true type: string x-example: "block-v1:edX+DemoX+Demo_Course+type@problem+block@sample_problem" + - $ref: '#/parameters/EmailOrUsernameQuery' responses: 200: description: Problem information retrieved successfully @@ -109,6 +115,33 @@ paths: 404: $ref: '#/responses/NotFound' + # ==================== COURSE CONFIG ENDPOINTS ==================== + + /api/instructor/v2/courses/{course_key}/grading-config: + get: + tags: + - Course + summary: Get course grading configuration + description: | + Retrieve the grading policy for a course, including assignment type weights + and letter grade cutoff thresholds. + operationId: getGradingConfig + produces: + - application/json + parameters: + - $ref: '#/parameters/CourseKey' + responses: + 200: + description: Grading configuration retrieved successfully + schema: + $ref: '#/definitions/GradingConfig' + 400: + $ref: '#/responses/BadRequest' + 401: + $ref: '#/responses/Unauthorized' + 403: + $ref: '#/responses/Forbidden' + # ==================== GRADING ENDPOINTS ==================== /api/instructor/v2/courses/{course_key}/{problem}/grading/attempts/reset: @@ -378,6 +411,14 @@ parameters: type: string x-example: "john_harvard" + EmailOrUsernameQuery: + name: email_or_username + in: query + required: false + description: Learner's username or email address. + type: string + x-example: "john_harvard" + ProblemLocationPath: name: problem in: path @@ -548,8 +589,7 @@ definitions: required: - username - email - - first_name - - last_name + - full_name properties: username: type: string @@ -558,44 +598,64 @@ definitions: type: string format: email example: "john@example.com" - first_name: - type: string - example: "John" - last_name: + full_name: type: string - example: "Harvard" + description: Learner's full name from their Open edX profile + example: "John Harvard" progress_url: type: string format: uri description: URL to learner's progress page x-nullable: true - gradebook_url: - type: string - format: uri - description: URL to learner's gradebook view - x-nullable: true - current_score: - type: object - x-nullable: true - properties: - score: - type: number - format: float - minimum: 0 - total: - type: number - format: float - minimum: 0 - attempts: + + GradingConfig: + type: object + description: Course grading policy configuration + required: + - graders + - grade_cutoffs + properties: + graders: + type: array + description: List of grader configurations by assignment type + items: + type: object + required: + - type + - min_count + - drop_count + - weight + properties: + type: + type: string + description: Assignment type name + example: "Homework" + short_label: + type: string + x-nullable: true + description: Short label used when displaying assignment names + example: "HW" + min_count: + type: integer + minimum: 0 + description: Minimum number of assignments counted in this category + drop_count: + type: integer + minimum: 0 + description: Number of lowest scores dropped from this category + weight: + type: number + format: float + minimum: 0 + maximum: 1 + description: Weight of this assignment type in the final grade (0.0 to 1.0) + grade_cutoffs: type: object - x-nullable: true - properties: - current: - type: integer - minimum: 0 - total: - type: integer - minimum: 0 + description: Grade cutoffs mapping letter grades to minimum score thresholds (0.0 to 1.0) + example: + A: 0.9 + B: 0.8 + C: 0.7 Problem: type: object @@ -626,6 +686,37 @@ definitions: usage_key: type: string description: Block usage key (omitted for course level) + current_score: + type: object + x-nullable: true + description: Learner's current score (present when learner query param is provided and has a submission) + properties: + score: + type: number + format: float + x-nullable: true + minimum: 0 + total: + type: number + format: float + x-nullable: true + minimum: 0 + attempts: + type: object + x-nullable: true + description: Learner's attempt data (present when learner query param is provided and has a submission) + properties: + current: + type: integer + minimum: 0 + description: Number of times the learner has attempted this problem + total: + type: integer + x-nullable: true + minimum: 0 + description: > + Maximum number of attempts allowed for this problem. + A null value means the problem allows unlimited attempts. Error: type: object 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..70edb2bf0084 --- /dev/null +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -0,0 +1,400 @@ +""" +Tests for Instructor API v2 GET endpoints. +""" +import json +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 InstructorFactory, UserFactory +from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.instructor_task.models import InstructorTask +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + + +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.course = CourseFactory.create() + self.instructor = InstructorFactory.create(course_key=self.course.id) + self.student = UserFactory( + username='john_harvard', + email='john@example.com', + ) + self.student.profile.name = 'John Harvard' + self.student.profile.save() + self.client.force_authenticate(user=self.instructor) + + def test_get_learner_by_username(self): + """Test retrieving learner info by username""" + 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) + + expected_progress_url = reverse('student_progress', kwargs={ + 'course_id': str(self.course.id), + 'student_id': self.student.id, + }) + + 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['full_name'], 'John Harvard') + self.assertEqual(data['progress_url'], expected_progress_url) + + def test_get_learner_by_email(self): + """Test retrieving learner info by email""" + 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) + + expected_progress_url = reverse('student_progress', kwargs={ + 'course_id': str(self.course.id), + 'student_id': self.student.id, + }) + + 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['progress_url'], expected_progress_url) + + 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.course = CourseFactory.create(display_name='Test Course') + self.instructor = InstructorFactory.create(course_key=self.course.id) + 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) + + def test_get_problem_metadata(self): + """Test retrieving problem metadata""" + 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) + + def test_get_problem_with_breadcrumbs(self): + """Test that breadcrumbs are included in response""" + 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) + + def test_get_problem_invalid_location(self): + """Test 400 with invalid problem location""" + 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_without_learner_has_null_score_and_attempts(self): + """Test that current_score and attempts are null when no learner is specified""" + 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.assertIsNone(data['current_score']) + self.assertIsNone(data['attempts']) + + def test_get_problem_with_learner_returns_score_and_attempts(self): + """Test that current_score and attempts are returned when learner has a StudentModule""" + student = UserFactory() + StudentModule.objects.create( + student=student, + course_id=self.course.id, + module_state_key=self.problem.location, + module_type='problem', + grade=7.0, + max_grade=10.0, + state=json.dumps({'attempts': 3}), + ) + + url = reverse('instructor_api_v2:problem_detail', kwargs={ + 'course_id': str(self.course.id), + 'location': str(self.problem.location) + }) + response = self.client.get(url, {'email_or_username': student.username}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['current_score']['score'], 7.0) + self.assertEqual(data['current_score']['total'], 10.0) + self.assertEqual(data['attempts']['current'], 3) + + def test_get_problem_with_learner_no_submission_returns_404(self): + """Test that 404 is returned when learner has no StudentModule for the problem""" + student = UserFactory() + url = reverse('instructor_api_v2:problem_detail', kwargs={ + 'course_id': str(self.course.id), + 'location': str(self.problem.location) + }) + response = self.client.get(url, {'email_or_username': student.username}) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('error', response.json()) + + def test_get_problem_with_unknown_learner_returns_404(self): + """Test that a 404 is returned when learner does not exist""" + url = reverse('instructor_api_v2:problem_detail', kwargs={ + 'course_id': str(self.course.id), + 'location': str(self.problem.location) + }) + response = self.client.get(url, {'email_or_username': 'nonexistent_user'}) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + 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.course = CourseFactory.create() + self.instructor = InstructorFactory.create(course_key=self.course.id) + self.client.force_authenticate(user=self.instructor) + + def test_get_task_status_completed(self): + """Test retrieving completed task status""" + # Create a completed task + task_id = str(uuid4()) + task_output = json.dumps({ + 'current': 150, + 'total': 150, + 'message': 'Reset attempts for 150 learners' + }) + 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']) + + def test_get_task_status_running(self): + """Test retrieving running task status""" + # 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) + + def test_get_task_status_failed(self): + """Test retrieving failed task status""" + # 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]) + + +class GradingConfigViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/instructor/v2/courses/{course_key}/grading-config + """ + + def setUp(self): + super().setUp() + self.client = APIClient() + self.course = CourseFactory.create() + self.instructor = InstructorFactory.create(course_key=self.course.id) + self.client.force_authenticate(user=self.instructor) + + def test_get_grading_config(self): + """Test retrieving grading configuration returns graders and grade cutoffs""" + url = reverse('instructor_api_v2:grading_config', kwargs={ + 'course_id': str(self.course.id), + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn('graders', data) + self.assertIn('grade_cutoffs', data) + self.assertIsInstance(data['graders'], list) + self.assertIsInstance(data['grade_cutoffs'], dict) + + def test_get_grading_config_grader_fields(self): + """Test that each grader entry has the expected fields""" + url = reverse('instructor_api_v2:grading_config', kwargs={ + 'course_id': str(self.course.id), + }) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + for grader in data['graders']: + self.assertIn('type', grader) + self.assertIn('min_count', grader) + self.assertIn('drop_count', grader) + self.assertIn('weight', grader) + + def test_get_grading_config_requires_authentication(self): + """Test that endpoint requires authentication""" + self.client.force_authenticate(user=None) + + url = reverse('instructor_api_v2:grading_config', kwargs={ + 'course_id': str(self.course.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 12472f7c3f88..abe629a67629 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -71,6 +71,26 @@ api_v2.CourseEnrollmentsView.as_view(), name='course_enrollments' ), + 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' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/grading-config$', + api_v2.GradingConfigView.as_view(), + name='grading_config' + ), ] urlpatterns = [ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 6be95187beca..e8f5fdc2a87e 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -7,6 +7,7 @@ import csv import io +import json import logging import re from dataclasses import dataclass @@ -15,7 +16,9 @@ import edx_api_doc_tools as apidocs from django.conf import settings +from django.contrib.auth import get_user_model from django.db import transaction +from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.html import strip_tags from django.utils.translation import gettext as _ @@ -33,8 +36,11 @@ from rest_framework.views import APIView from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.models.user import get_user_by_username_or_email from common.djangoapps.student.roles import CourseBetaTesterRole from common.djangoapps.util.json_request import JsonResponseBadRequest +from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active +from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.tabs import get_course_tab_list from lms.djangoapps.instructor import permissions from lms.djangoapps.instructor.constants import ReportType @@ -45,12 +51,13 @@ from lms.djangoapps.instructor_analytics import csvs as instructor_analytics_csvs from lms.djangoapps.instructor_task import api as task_api from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError -from lms.djangoapps.instructor_task.models import ReportStore +from lms.djangoapps.instructor_task.models import InstructorTask, ReportStore from lms.djangoapps.instructor_task.tasks_helper.utils import upload_csv_file_to_report_store from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id +from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError @@ -59,9 +66,13 @@ BlockDueDateSerializerV2, CourseEnrollmentSerializerV2, CourseInformationSerializerV2, + GradingConfigSerializer, InstructorTaskListSerializer, + LearnerSerializer, ORASerializer, ORASummarySerializer, + ProblemSerializer, + TaskStatusSerializer, UnitExtensionSerializer, ) from .tools import find_unit, get_units_with_due_date, keep_field_private, set_due_date_extension, title_or_url @@ -1169,3 +1180,429 @@ def list(self, request, *args, **kwargs): response = super().list(request, *args, **kwargs) response.data['course_id'] = self.kwargs['course_id'] return response + + + +class LearnerView(DeveloperErrorViewMixin, APIView): + """ + API view for retrieving learner information. + + **GET Example Response:** + ```json + { + "username": "john_harvard", + "email": "john@example.com", + "full_name": "John Harvard", + "progress_url": "https://example.com/courses/course-v1:edX+DemoX+Demo_Course/progress/john_harvard/" + } + ``` + """ + 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 + ) + + UserModel = get_user_model() + try: + student = get_user_by_username_or_email(email_or_username) + except UserModel.DoesNotExist: + return Response( + {'error': 'Learner not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except UserModel.MultipleObjectsReturned: + return Response( + {'error': 'Multiple learners found for the given identifier'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Build progress URL (MFE or legacy depending on feature flag) + if course_home_mfe_progress_tab_is_active(course_key): + progress_url = get_learning_mfe_home_url(course_key, url_fragment='progress') + progress_url += f'/{student.id}/' + else: + progress_url = reverse( + 'student_progress', + kwargs={'course_id': str(course_key), 'student_id': student.id} + ) + + learner_data = { + 'username': student.username, + 'email': student.email, + 'full_name': student.profile.name, + 'progress_url': progress_url, + } + + 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" + } + ], + "current_score": { + "score": 7.0, + "total": 10.0 + }, + "attempts": { + "current": 3, + "total": 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( + '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 + ) + + 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 not parent_location: + break + try: + current = store.get_item(parent_location) + except Exception: # pylint: disable=broad-except + 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], + 'current_score': None, + 'attempts': None, + } + + learner_identifier = request.query_params.get('email_or_username') + if learner_identifier: + UserModel = get_user_model() + try: + student = get_user_by_username_or_email(learner_identifier) + except UserModel.DoesNotExist: + return Response( + {'error': 'Learner not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except UserModel.MultipleObjectsReturned: + return Response( + {'error': 'Multiple learners found for the given identifier'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + student_module = StudentModule.objects.get( + course_id=course_key, + module_state_key=problem_key, + student=student, + ) + problem_data['current_score'] = { + 'score': student_module.grade, + 'total': student_module.max_grade, + } + state = json.loads(student_module.state) if student_module.state else {} + problem_data['attempts'] = { + 'current': state.get('attempts', 0), + 'total': problem.max_attempts, + } + except StudentModule.DoesNotExist: + return Response( + {'error': 'Learner has not attempted this problem'}, + status=status.HTTP_404_NOT_FOUND + ) + + 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: + 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' + } + + serializer = TaskStatusSerializer(task_data) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class GradingConfigView(DeveloperErrorViewMixin, APIView): + """ + API view for retrieving course grading configuration. + + **GET Example Response:** + ```json + { + "graders": [ + { + "type": "Homework", + "short_label": "HW", + "min_count": 12, + "drop_count": 2, + "weight": 0.15 + }, + { + "type": "Final Exam", + "short_label": "Final", + "min_count": 1, + "drop_count": 0, + "weight": 0.40 + } + ], + "grade_cutoffs": { + "A": 0.9, + "B": 0.8, + "C": 0.7 + } + } + ``` + """ + 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.", + ), + ], + responses={ + 200: 'Grading configuration retrieved successfully', + 400: "Invalid parameters provided.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks instructor access to the course.", + 404: "Course does not exist.", + }, + ) + def get(self, request, course_id): + """ + Retrieve the grading configuration for a course, including assignment type + weights and grade cutoff thresholds. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + {'error': 'Invalid course key'}, + status=status.HTTP_400_BAD_REQUEST + ) + + course = get_course_by_id(course_key) + grading_policy = course.grading_policy + config_data = { + 'graders': grading_policy.get('GRADER', []), + 'grade_cutoffs': grading_policy.get('GRADE_CUTOFFS', {}), + } + serializer = GradingConfigSerializer(config_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 9e53bb4d7fba..4bc922b95e04 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -65,6 +65,12 @@ class CourseInformationSerializerV2(serializers.Serializer): grade_cutoffs = serializers.SerializerMethodField(help_text="Formatted string of grade cutoffs") course_errors = serializers.SerializerMethodField(help_text="List of course validation errors from modulestore") studio_url = serializers.SerializerMethodField(help_text="URL to view/edit course in Studio") + gradebook_url = serializers.SerializerMethodField( + help_text="URL to the MFE gradebook for the course (null if not configured)" + ) + studio_grading_url = serializers.SerializerMethodField( + help_text="URL to the Studio grading settings page for the course (null if not configured)" + ) permissions = serializers.SerializerMethodField(help_text="User permissions for instructor dashboard features") tabs = serializers.SerializerMethodField(help_text="List of course tabs with configuration and display information") disable_buttons = serializers.SerializerMethodField( @@ -433,6 +439,21 @@ def get_studio_url(self, data): """Get Studio URL for the course.""" return get_studio_url(data['course'], 'course') + def get_gradebook_url(self, data): + """Get MFE gradebook URL for the course.""" + course_key = data['course'].id + if settings.WRITABLE_GRADEBOOK_URL: + return f'{settings.WRITABLE_GRADEBOOK_URL}/gradebook/{course_key}' + return None + + def get_studio_grading_url(self, data): + """Get Studio MFE grading settings URL for the course.""" + course_key = data['course'].id + mfe_base_url = getattr(settings, 'COURSE_AUTHORING_MICROFRONTEND_URL', None) + if mfe_base_url: + return f'{mfe_base_url}/course/{course_key}/settings/grading' + return None + def get_disable_buttons(self, data): """Check if buttons should be disabled for large courses.""" return not CourseEnrollment.objects.is_small_course(data['course'].id) @@ -586,3 +607,130 @@ def get_is_beta_tester(self, enrollment): """Check if the user is a beta tester for this course.""" beta_tester_ids = self.context.get('beta_tester_ids', set()) return enrollment.user_id in beta_tester_ids + + +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" + ) + full_name = serializers.CharField( + help_text="Learner's full name from their Open edX profile" + ) + progress_url = serializers.URLField( + allow_null=True, + required=False, + help_text="URL to learner's progress page" + ) + + +class GraderSerializer(serializers.Serializer): + """Serializer for a single grader configuration entry.""" + type = serializers.CharField( + help_text="Assignment type (e.g. Homework, Lab, Midterm Exam)" + ) + short_label = serializers.CharField( + required=False, + allow_null=True, + help_text="Short label used when displaying assignment names" + ) + min_count = serializers.IntegerField( + help_text="Minimum number of assignments counted in this category" + ) + drop_count = serializers.IntegerField( + help_text="Number of lowest scores dropped from this category" + ) + weight = serializers.FloatField( + help_text="Weight of this assignment type in the final grade (0.0 to 1.0)" + ) + + +class GradingConfigSerializer(serializers.Serializer): + """ + Serializer for course grading configuration. + + Returns structured grading policy data including assignment type weights + and grade cutoff thresholds. + """ + graders = GraderSerializer( + many=True, + help_text="List of grader configurations by assignment type" + ) + grade_cutoffs = serializers.DictField( + child=serializers.FloatField(), + help_text="Grade cutoffs mapping letter grades to minimum score thresholds (0.0 to 1.0)" + ) + + +class ProblemSerializer(serializers.Serializer): + """ + Serializer for problem metadata and location. + + Provides problem information including display name and course hierarchy. + Optionally includes learner-specific score and attempt data when a learner + query parameter is provided. + """ + 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" + ) + current_score = serializers.DictField( + allow_null=True, + required=False, + help_text="Learner's current score with 'score' and 'total' fields. Null if no learner specified." + ) + attempts = serializers.DictField( + allow_null=True, + required=False, + help_text="Learner's attempt data with 'current' and 'total' (max) fields. Null if no learner specified." + ) + + +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" + )