diff --git a/lms/djangoapps/instructor/constants.py b/lms/djangoapps/instructor/constants.py index 972946e2c0d1..1d1b43faf647 100644 --- a/lms/djangoapps/instructor/constants.py +++ b/lms/djangoapps/instructor/constants.py @@ -1,9 +1,29 @@ """ Constants used by Instructor. """ +from enum import StrEnum # this is the UserPreference key for the user's recipient invoice copy INVOICE_KEY = 'pref-invoice-copy' # external plugins (if any) will use this constant to return context to instructor dashboard INSTRUCTOR_DASHBOARD_PLUGIN_VIEW_NAME = 'instructor_dashboard' + + +class ReportType(StrEnum): + """ + Enum for report types used in the instructor dashboard downloads API. + These are the user-facing report type identifiers. + """ + ENROLLED_STUDENTS = "enrolled_students" + PENDING_ENROLLMENTS = "pending_enrollments" + PENDING_ACTIVATIONS = "pending_activations" + ANONYMIZED_STUDENT_IDS = "anonymized_student_ids" + GRADE = "grade" + PROBLEM_GRADE = "problem_grade" + PROBLEM_RESPONSES = "problem_responses" + ORA2_SUMMARY = "ora2_summary" + ORA2_DATA = "ora2_data" + ORA2_SUBMISSION_FILES = "ora2_submission_files" + ISSUED_CERTIFICATES = "issued_certificates" + UNKNOWN = "unknown" diff --git a/lms/djangoapps/instructor/tests/test_reports_api_v2.py b/lms/djangoapps/instructor/tests/test_reports_api_v2.py new file mode 100644 index 000000000000..ae7a5b9b3aa0 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_reports_api_v2.py @@ -0,0 +1,568 @@ +""" +Unit tests for instructor API v2 report endpoints. +""" +from unittest.mock import Mock, patch + +import ddt +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from common.djangoapps.student.roles import CourseDataResearcherRole +from common.djangoapps.student.tests.factories import ( + InstructorFactory, + StaffFactory, + UserFactory, +) +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@ddt.ddt +class ReportDownloadsViewTest(SharedModuleStoreTestCase): + """ + Tests for the ReportDownloadsView API endpoint. + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create( + org='edX', + number='ReportTestX', + run='Report_Test_Course', + display_name='Report Test Course', + ) + self.course_key = self.course.id + self.client = APIClient() + self.instructor = InstructorFactory.create(course_key=self.course_key) + self.staff = StaffFactory.create(course_key=self.course_key) + self.data_researcher = UserFactory.create() + CourseDataResearcherRole(self.course_key).add_users(self.data_researcher) + self.student = UserFactory.create() + + 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:report_downloads', kwargs={'course_id': course_id}) + + @patch('lms.djangoapps.instructor.views.api_v2.ReportStore.from_config') + def test_get_report_downloads_as_instructor(self, mock_report_store): + """ + Test that an instructor can retrieve report downloads. + """ + # Mock report store + mock_store = Mock() + mock_store.links_for.return_value = [ + ( + 'course-v1_edX_TestX_Test_Course_grade_report_2024-01-26-1030.csv', + '/grades/course-v1:edX+TestX+Test_Course/' + 'course-v1_edX_TestX_Test_Course_grade_report_2024-01-26-1030.csv' + ), + ( + 'course-v1_edX_TestX_Test_Course_enrolled_students_2024-01-25-0900.csv', + '/grades/course-v1:edX+TestX+Test_Course/' + 'course-v1_edX_TestX_Test_Course_enrolled_students_2024-01-25-0900.csv' + ), + ] + mock_report_store.return_value = mock_store + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('downloads', response.data) + downloads = response.data['downloads'] + self.assertEqual(len(downloads), 2) + + # Verify first report structure + report = downloads[0] + self.assertIn('report_name', report) + self.assertIn('report_url', report) + self.assertIn('date_generated', report) + self.assertIn('report_type', report) + + @patch('lms.djangoapps.instructor.views.api_v2.ReportStore.from_config') + def test_get_report_downloads_as_staff(self, mock_report_store): + """ + Test that staff can retrieve report downloads. + """ + mock_store = Mock() + mock_store.links_for.return_value = [] + mock_report_store.return_value = mock_store + + self.client.force_authenticate(user=self.staff) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('downloads', response.data) + + @patch('lms.djangoapps.instructor.views.api_v2.ReportStore.from_config') + def test_get_report_downloads_as_data_researcher(self, mock_report_store): + """ + Test that data researchers can retrieve report downloads. + """ + mock_store = Mock() + mock_store.links_for.return_value = [] + mock_report_store.return_value = mock_store + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('downloads', response.data) + + def test_get_report_downloads_unauthorized(self): + """ + Test that students cannot access report downloads endpoint. + """ + self.client.force_authenticate(user=self.student) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_report_downloads_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_report_downloads_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.ReportStore.from_config') + @ddt.data( + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_grade_report_2024-01-26-1030.csv', + 'grade', + '2024-01-26T10:30:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_enrolled_students_2024-01-25-0900.csv', + 'enrolled_students', + '2024-01-25T09:00:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_problem_grade_report_2024-02-15-1545.csv', + 'problem_grade', + '2024-02-15T15:45:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_ora2_summary_2024-03-10-2030.csv', + 'ora2_summary', + '2024-03-10T20:30:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_ora2_data_2024-03-11-1200.csv', + 'ora2_data', + '2024-03-11T12:00:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_ora2_submission_files_2024-03-12-0800.zip', + 'ora2_submission_files', + '2024-03-12T08:00:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_certificate_report_2024-04-01-1000.csv', + 'issued_certificates', + '2024-04-01T10:00:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_problem_responses_2024-05-20-1430.csv', + 'problem_responses', + '2024-05-20T14:30:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_may_enroll_2024-06-01-0930.csv', + 'pending_enrollments', + '2024-06-01T09:30:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_inactive_enrolled_2024-07-15-1115.csv', + 'pending_activations', + '2024-07-15T11:15:00Z' + ), + ( + 'course-v1_edX_ReportTestX_Report_Test_Course_anon_ids_2024-08-20-1600.csv', + 'anonymized_student_ids', + '2024-08-20T16:00:00Z' + ), + ) + @ddt.unpack + def test_report_type_detection(self, filename, expected_type, expected_date, mock_report_store): + """ + Test that report types are correctly detected from filenames. + """ + mock_store = Mock() + mock_store.links_for.return_value = [ + (filename, f'/grades/course-v1:edX+ReportTestX+Report_Test_Course/{filename}'), + ] + mock_report_store.return_value = mock_store + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + downloads = response.data['downloads'] + self.assertEqual(len(downloads), 1) + self.assertEqual(downloads[0]['report_type'], expected_type) + self.assertEqual(downloads[0]['date_generated'], expected_date) + + @patch('lms.djangoapps.instructor.views.api_v2.ReportStore.from_config') + def test_report_without_date(self, mock_report_store): + """ + Test handling of report files without date information. + """ + mock_store = Mock() + mock_store.links_for.return_value = [ + ('course_report.csv', '/grades/course-v1:edX+ReportTestX+Report_Test_Course/course_report.csv'), + ] + mock_report_store.return_value = mock_store + + self.client.force_authenticate(user=self.instructor) + response = self.client.get(self._get_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + downloads = response.data['downloads'] + self.assertEqual(len(downloads), 1) + self.assertIsNone(downloads[0]['date_generated']) + + @patch('lms.djangoapps.instructor.views.api_v2.ReportStore.from_config') + def test_empty_reports_list(self, mock_report_store): + """ + Test endpoint with no reports available. + """ + mock_store = Mock() + mock_store.links_for.return_value = [] + mock_report_store.return_value = mock_store + + 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.data['downloads'], []) + + +@ddt.ddt +class GenerateReportViewTest(SharedModuleStoreTestCase): + """ + Tests for the GenerateReportView API endpoint. + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create( + org='edX', + number='GenReportTestX', + run='GenReport_Test_Course', + display_name='Generate Report Test Course', + ) + self.course_key = self.course.id + self.client = APIClient() + self.instructor = InstructorFactory.create(course_key=self.course_key) + self.staff = StaffFactory.create(course_key=self.course_key) + self.data_researcher = UserFactory.create() + CourseDataResearcherRole(self.course_key).add_users(self.data_researcher) + self.student = UserFactory.create() + + def _get_url(self, course_id=None, report_type='grade'): + """Helper to get the API URL.""" + if course_id is None: + course_id = str(self.course_key) + return reverse('instructor_api_v2:generate_report', kwargs={ + 'course_id': course_id, + 'report_type': report_type + }) + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_grades_csv') + def test_generate_grade_report(self, mock_submit): + """ + Test generating a grade report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='grade')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.instructor_analytics_basic.get_available_features') + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_students_features_csv') + def test_generate_enrolled_students_report(self, mock_submit, mock_get_features): + """ + Test generating an enrolled students report. + Verifies that get_available_features is called to support custom attributes. + """ + mock_submit.return_value = None + mock_get_features.return_value = ('id', 'username', 'email', 'custom_field') + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='enrolled_students')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_get_features.assert_called_once_with(self.course.id) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_may_enroll_csv') + def test_generate_pending_enrollments_report(self, mock_submit): + """ + Test generating a pending enrollments report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='pending_enrollments')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_inactive_enrolled_students_csv') + def test_generate_pending_activations_report(self, mock_submit): + """ + Test generating a pending activations report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='pending_activations')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.generate_anonymous_ids') + def test_generate_anonymized_ids_report(self, mock_submit): + """ + Test generating an anonymized student IDs report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='anonymized_student_ids')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_problem_grade_report') + def test_generate_problem_grade_report(self, mock_submit): + """ + Test generating a problem grade report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='problem_grade')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_problem_responses_csv') + def test_generate_problem_responses_report(self, mock_submit): + """ + Test generating a problem responses report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='problem_responses')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_problem_responses_csv') + @patch('lms.djangoapps.instructor.views.api_v2.modulestore') + def test_generate_problem_responses_with_location(self, mock_modulestore, mock_submit): + """ + Test generating a problem responses report with specific problem location. + """ + # Mock a problem block instead of creating real ones + mock_problem = Mock() + mock_problem.location = Mock() + + mock_store = Mock() + mock_store.get_item.return_value = mock_problem + mock_store.make_course_usage_key.return_value = self.course.location + mock_modulestore.return_value = mock_store + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post( + self._get_url(report_type='problem_responses'), + {'problem_location': 'block-v1:edX+GenReportTestX+GenReport_Test_Course+type@problem+block@test'} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_export_ora2_summary') + def test_generate_ora2_summary_report(self, mock_submit): + """ + Test generating an ORA2 summary report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='ora2_summary')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_export_ora2_data') + def test_generate_ora2_data_report(self, mock_submit): + """ + Test generating an ORA2 data report. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='ora2_data')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_export_ora2_submission_files') + def test_generate_ora2_submission_files_report(self, mock_submit): + """ + Test generating an ORA2 submission files archive. + """ + mock_submit.return_value = None + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='ora2_submission_files')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_submit.assert_called_once() + + @patch('lms.djangoapps.instructor.views.api_v2.instructor_analytics_basic.issued_certificates') + @patch('lms.djangoapps.instructor.views.api_v2.instructor_analytics_csvs.format_dictlist') + @patch('lms.djangoapps.instructor.views.api_v2.upload_csv_file_to_report_store') + def test_generate_issued_certificates_report(self, mock_upload, mock_format, mock_issued_certs): + """ + Test generating an issued certificates report. + Note: This report uses staff permission instead of CAN_RESEARCH. + """ + mock_issued_certs.return_value = [] + mock_format.return_value = ([], []) + mock_upload.return_value = None + + # Use staff user since issued certificates requires staff permission + self.client.force_authenticate(user=self.staff) + response = self.client.post(self._get_url(report_type='issued_certificates')) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('status', response.data) + mock_issued_certs.assert_called_once() + mock_upload.assert_called_once() + + def test_generate_report_invalid_type(self): + """ + Test error handling for invalid report type. + """ + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='invalid_type')) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_grades_csv') + def test_generate_report_already_running(self, mock_submit): + """ + Test error handling when a report generation task is already running. + """ + from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError + mock_submit.side_effect = AlreadyRunningError('Task already running') + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='grade')) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_grades_csv') + def test_generate_report_queue_connection_error(self, mock_submit): + """ + Test error handling for queue connection errors. + """ + from lms.djangoapps.instructor_task.api_helper import QueueConnectionError + mock_submit.side_effect = QueueConnectionError('Cannot connect to queue') + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='grade')) + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertIn('error', response.data) + + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_problem_responses_csv') + def test_generate_report_value_error(self, mock_submit): + """ + Test error handling for ValueError exceptions. + """ + mock_submit.side_effect = ValueError('Invalid parameter') + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post(self._get_url(report_type='problem_responses')) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) + + def test_generate_report_unauthorized_student(self): + """ + Test that students cannot generate reports. + """ + self.client.force_authenticate(user=self.student) + response = self.client.post(self._get_url(report_type='grade')) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_generate_report_unauthenticated(self): + """ + Test that unauthenticated users cannot generate reports. + """ + response = self.client.post(self._get_url(report_type='grade')) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_generate_report_nonexistent_course(self): + """ + Test error handling for non-existent course. + """ + self.client.force_authenticate(user=self.data_researcher) + nonexistent_course_id = 'course-v1:edX+NonExistent+2024' + response = self.client.post(self._get_url(course_id=nonexistent_course_id, report_type='grade')) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @patch('lms.djangoapps.instructor.views.api_v2.modulestore') + @patch('lms.djangoapps.instructor.views.api_v2.task_api.submit_calculate_problem_responses_csv') + def test_problem_responses_with_invalid_location(self, mock_submit, mock_modulestore): + """ + Test generating problem responses report with invalid problem location. + """ + mock_store = Mock() + mock_store.get_item.side_effect = Exception('Not found') + mock_modulestore.return_value = mock_store + + self.client.force_authenticate(user=self.data_researcher) + response = self.client.post( + self._get_url(report_type='problem_responses'), + {'problem_location': 'invalid-location'} + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 68742fb56f0d..b61120e58c13 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -51,6 +51,16 @@ api_v2.ORAView.as_view(), name='ora_assessments' ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/reports$', + api_v2.ReportDownloadsView.as_view(), + name='report_downloads' + ), + re_path( + rf'^courses/{COURSE_ID_PATTERN}/reports/(?P[^/]+)/generate$', + api_v2.GenerateReportView.as_view(), + name='generate_report' + ), re_path( rf'^courses/{COURSE_ID_PATTERN}/ora_summary$', api_v2.ORASummaryView.as_view(), diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 9cb0b96fa34f..77384fb7ab48 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -5,25 +5,34 @@ These APIs are designed to be consumed by MFEs and other API clients. """ +import csv +import io import logging - +import re from dataclasses import dataclass +from datetime import datetime from typing import Optional, Tuple + + import edx_api_doc_tools as apidocs +from django.db import transaction +from django.utils.decorators import method_decorator +from django.utils.html import strip_tags +from django.utils.translation import gettext as _ +from django.views.decorators.cache import cache_control from edx_when import api as edx_when_api from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey +from pytz import UTC from rest_framework import status -from rest_framework.generics import ListAPIView +from rest_framework.exceptions import NotFound +from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.generics import GenericAPIView -from rest_framework.exceptions import NotFound -from django.utils.decorators import method_decorator -from django.views.decorators.cache import cache_control -from django.utils.html import strip_tags -from django.utils.translation import gettext as _ +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError + from common.djangoapps.util.json_request import JsonResponseBadRequest from lms.djangoapps.courseware.tabs import get_course_tab_list @@ -31,7 +40,13 @@ 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.api_helper import AlreadyRunningError, QueueConnectionError +from lms.djangoapps.instructor.constants import ReportType from lms.djangoapps.instructor.ora import get_open_response_assessment_list, get_ora_summary +from lms.djangoapps.instructor_analytics import basic as instructor_analytics_basic +from lms.djangoapps.instructor_analytics import csvs as instructor_analytics_csvs +from lms.djangoapps.instructor_task.models import ReportStore +from lms.djangoapps.instructor_task.tasks_helper.utils import upload_csv_file_to_report_store from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id from .serializers_v2 import ( @@ -568,6 +583,411 @@ def get(self, request, *args, **kwargs): return self.get_paginated_response(serializer.data) +class ReportDownloadsView(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + + List all available report downloads for a course. + + **Example Requests** + + GET /api/instructor/v2/courses/{course_key}/reports + + **Response Values** + + { + "downloads": [ + { + "report_name": "course-v1_edX_DemoX_Demo_Course_grade_report_2024-01-26-1030.csv", + "report_url": + "/grades/course-v1:edX+DemoX+Demo_Course/" + "course-v1_edX_DemoX_Demo_Course_grade_report_2024-01-26-1030.csv", + "date_generated": "2024-01-26T10:30:00Z", + "report_type": "grade" # Uses ReportType.GRADE.value + } + ] + } + + **Parameters** + + course_key: Course key for the course. + + **Returns** + + * 200: OK - Returns list of available reports + * 401: Unauthorized - User is not authenticated + * 403: Forbidden - User lacks staff access to the course + * 404: Not Found - Course does not exist + """ + + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + # Use ENROLLMENT_REPORT permission which allows course staff and data researchers + # to view generated reports, aligning with the intended audience of instructors/course staff + permission_name = permissions.ENROLLMENT_REPORT + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + ], + responses={ + 200: "Returns list of available report downloads.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks instructor access to the course.", + 404: "The requested course does not exist.", + }, + ) + def get(self, request, course_id): + """ + List all available report downloads for a course. + """ + course_key = CourseKey.from_string(course_id) + # Validate that the course exists + get_course_by_id(course_key) + + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') + + downloads = [] + for name, url in report_store.links_for(course_key): + # Determine report type from filename using helper method + report_type = self._detect_report_type_from_filename(name) + + # Extract date from filename if possible (format: YYYY-MM-DD-HHMM) + date_generated = self._extract_date_from_filename(name) + + downloads.append({ + 'report_name': name, + 'report_url': url, + 'date_generated': date_generated, + 'report_type': report_type, + }) + + return Response({'downloads': downloads}, status=status.HTTP_200_OK) + + def _detect_report_type_from_filename(self, filename): + """ + Detect report type from filename using pattern matching. + Check more specific patterns first to avoid false matches. + + Args: + filename: The name of the report file + + Returns: + str: The report type identifier + """ + name_lower = filename.lower() + + # Check more specific patterns first to avoid false matches + # Match exact report names from the filename format: {course_prefix}_{csv_name}_{timestamp}.csv + if 'inactive_enrolled' in name_lower: + return ReportType.PENDING_ACTIVATIONS.value + elif 'problem_grade_report' in name_lower: + return ReportType.PROBLEM_GRADE.value + elif 'ora2_submission' in name_lower or 'submission_files' in name_lower or 'ora_submission' in name_lower: + return ReportType.ORA2_SUBMISSION_FILES.value + elif 'ora2_summary' in name_lower or 'ora_summary' in name_lower: + return ReportType.ORA2_SUMMARY.value + elif 'ora2_data' in name_lower or 'ora_data' in name_lower: + return ReportType.ORA2_DATA.value + elif 'may_enroll' in name_lower: + return ReportType.PENDING_ENROLLMENTS.value + elif 'student_state' in name_lower or 'problem_responses' in name_lower: + return ReportType.PROBLEM_RESPONSES.value + elif 'anonymized_ids' in name_lower or 'anon' in name_lower: + return ReportType.ANONYMIZED_STUDENT_IDS.value + elif 'issued_certificates' in name_lower or 'certificate' in name_lower: + return ReportType.ISSUED_CERTIFICATES.value + elif 'grade_report' in name_lower: + return ReportType.GRADE.value + elif 'enrolled_students' in name_lower or 'profile' in name_lower: + return ReportType.ENROLLED_STUDENTS.value + + return ReportType.UNKNOWN.value + + def _extract_date_from_filename(self, filename): + """ + Extract date from filename (format: YYYY-MM-DD-HHMM). + + Args: + filename: The name of the report file + + Returns: + str: ISO formatted date string or None + """ + date_match = re.search(r'_(\d{4}-\d{2}-\d{2}-\d{4})', filename) + if date_match: + date_str = date_match.group(1) + try: + # Parse the date string (YYYY-MM-DD-HHMM) directly + dt = datetime.strptime(date_str, '%Y-%m-%d-%H%M') + # Format as ISO 8601 with UTC timezone + return dt.strftime('%Y-%m-%dT%H:%M:%SZ') + except ValueError: + pass + return None + + +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GenerateReportView(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + + Generate a specific type of report for a course. + + **Example Requests** + + POST /api/instructor/v2/courses/{course_key}/reports/enrolled_students/generate + POST /api/instructor/v2/courses/{course_key}/reports/grade/generate + POST /api/instructor/v2/courses/{course_key}/reports/problem_responses/generate + + **Response Values** + + { + "status": "The report is being created. Please check the data downloads section for the status." + } + + **Parameters** + + course_key: Course key for the course. + report_type: Type of report to generate. Valid values: + - enrolled_students: Enrolled Students Report + - pending_enrollments: Pending Enrollments Report + - pending_activations: Pending Activations Report (inactive users with enrollments) + - anonymized_student_ids: Anonymized Student IDs Report + - grade: Grade Report + - problem_grade: Problem Grade Report + - problem_responses: Problem Responses Report + - ora2_summary: ORA Summary Report + - ora2_data: ORA Data Report + - ora2_submission_files: ORA Submission Files Report + - issued_certificates: Issued Certificates Report + + **Returns** + + * 200: OK - Report generation task has been submitted + * 400: Bad Request - Task is already running or invalid report type + * 401: Unauthorized - User is not authenticated + * 403: Forbidden - User lacks instructor permissions + * 404: Not Found - Course does not exist + """ + + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + + @property + def permission_name(self): + """ + Return the appropriate permission name based on the requested report type. + For the issued certificates report, mirror the v1 behavior by using + VIEW_ISSUED_CERTIFICATES (course-level staff access). For all other reports, + require CAN_RESEARCH. + """ + report_type = self.kwargs.get('report_type') + if report_type == ReportType.ISSUED_CERTIFICATES.value: + return permissions.VIEW_ISSUED_CERTIFICATES + return permissions.CAN_RESEARCH + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.PATH, + description="Course key for the course.", + ), + apidocs.string_parameter( + 'report_type', + apidocs.ParameterLocation.PATH, + description=( + "Type of report to generate. Valid values: " + "enrolled_students, pending_enrollments, pending_activations, " + "anonymized_student_ids, grade, problem_grade, problem_responses, " + "ora2_summary, ora2_data, ora2_submission_files, issued_certificates" + ), + ), + ], + responses={ + 200: "Report generation task has been submitted successfully.", + 400: "The requested task is already running or invalid report type.", + 401: "The requesting user is not authenticated.", + 403: "The requesting user lacks instructor access to the course.", + 404: "The requested course does not exist.", + }, + ) + def post(self, request, course_id, report_type): + """ + Generate a specific type of report for a course. + """ + course_key = CourseKey.from_string(course_id) + + # Map report types to their submission functions + report_handlers = { + ReportType.ENROLLED_STUDENTS.value: self._generate_enrolled_students_report, + ReportType.PENDING_ENROLLMENTS.value: self._generate_pending_enrollments_report, + ReportType.PENDING_ACTIVATIONS.value: self._generate_pending_activations_report, + ReportType.ANONYMIZED_STUDENT_IDS.value: self._generate_anonymized_ids_report, + ReportType.GRADE.value: self._generate_grade_report, + ReportType.PROBLEM_GRADE.value: self._generate_problem_grade_report, + ReportType.PROBLEM_RESPONSES.value: self._generate_problem_responses_report, + ReportType.ORA2_SUMMARY.value: self._generate_ora2_summary_report, + ReportType.ORA2_DATA.value: self._generate_ora2_data_report, + ReportType.ORA2_SUBMISSION_FILES.value: self._generate_ora2_submission_files_report, + ReportType.ISSUED_CERTIFICATES.value: self._generate_issued_certificates_report, + } + + handler = report_handlers.get(report_type) + if not handler: + return Response( + {'error': f'Invalid report type: {report_type}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + success_message = handler(request, course_key) + except AlreadyRunningError as error: + log.warning("Task already running for %s report: %s", report_type, error) + return Response( + {'error': _('A report generation task is already running. Please wait for it to complete.')}, + status=status.HTTP_400_BAD_REQUEST + ) + except QueueConnectionError as error: + log.error("Queue connection error for %s report task: %s", report_type, error) + return Response( + {'error': _('Unable to connect to the task queue. Please try again later.')}, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + except ValueError as error: + log.error("Error submitting %s report task: %s", report_type, error) + return Response( + {'error': str(error)}, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response({'status': success_message}, status=status.HTTP_200_OK) + + def _generate_enrolled_students_report(self, request, course_key): + """Generate enrolled students report.""" + # Use get_available_features to include any custom attributes configured for the course + query_features = list(instructor_analytics_basic.get_available_features(course_key)) + task_api.submit_calculate_students_features_csv(request, course_key, query_features) + return _('The enrolled student report is being created.') + + def _generate_pending_enrollments_report(self, request, course_key): + """Generate pending enrollments report.""" + query_features = ['email'] + task_api.submit_calculate_may_enroll_csv(request, course_key, query_features) + return _('The pending enrollments report is being created.') + + def _generate_pending_activations_report(self, request, course_key): + """Generate pending activations report.""" + query_features = ['email'] + task_api.submit_calculate_inactive_enrolled_students_csv(request, course_key, query_features) + return _('The pending activations report is being created.') + + def _generate_anonymized_ids_report(self, request, course_key): + """Generate anonymized student IDs report.""" + task_api.generate_anonymous_ids(request, course_key) + return _('The anonymized student IDs report is being created.') + + def _generate_grade_report(self, request, course_key): + """Generate grade report.""" + task_api.submit_calculate_grades_csv(request, course_key) + return _('The grade report is being created.') + + def _generate_problem_grade_report(self, request, course_key): + """Generate problem grade report.""" + task_api.submit_problem_grade_report(request, course_key) + return _('The problem grade report is being created.') + + def _generate_problem_responses_report(self, request, course_key): + """Generate problem responses report.""" + problem_location = request.data.get('problem_location') + + # Validate problem location if provided + if problem_location: + try: + usage_key = UsageKey.from_string(problem_location).map_into_course(course_key) + except InvalidKeyError as exc: + raise ValueError(_('Invalid problem location format.')) from exc + + # Check if the problem actually exists in the modulestore + store = modulestore() + try: + store.get_item(usage_key) + except ItemNotFoundError as exc: + raise ValueError(_('The problem location does not exist in this course.')) from exc + + problem_locations_str = problem_location + else: + # When no problem location is provided, generate report for entire course + # Use the course root usage key to include all problems in the course + store = modulestore() + course_usage_key = store.make_course_usage_key(course_key) + problem_locations_str = str(course_usage_key) + + task_api.submit_calculate_problem_responses_csv(request, course_key, problem_locations_str) + return _('The problem responses report is being created.') + + def _generate_ora2_summary_report(self, request, course_key): + """Generate ORA2 summary report.""" + task_api.submit_export_ora2_summary(request, course_key) + return _('The ORA2 summary report is being created.') + + def _generate_ora2_data_report(self, request, course_key): + """Generate ORA2 data report.""" + task_api.submit_export_ora2_data(request, course_key) + return _('The ORA2 data report is being created.') + + def _generate_ora2_submission_files_report(self, request, course_key): + """Generate ORA2 submission files archive.""" + task_api.submit_export_ora2_submission_files(request, course_key) + return _('The ORA2 submission files archive is being created.') + + def _generate_issued_certificates_report(self, request, course_key): + """Generate issued certificates report.""" + # Query features for the report + query_features = ['course_id', 'mode', 'total_issued_certificate', 'report_run_date'] + query_features_names = [ + ('course_id', _('CourseID')), + ('mode', _('Certificate Type')), + ('total_issued_certificate', _('Total Certificates Issued')), + ('report_run_date', _('Date Report Run')) + ] + + # Get certificates data + certificates_data = instructor_analytics_basic.issued_certificates(course_key, query_features) + + # Format the data for CSV + __, data_rows = instructor_analytics_csvs.format_dictlist(certificates_data, query_features) + + # Generate CSV content as a file-like object + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([col_header for __, col_header in query_features_names]) + + # Write data rows + for row in data_rows: + writer.writerow(row) + + # Reset the buffer position to the beginning + output.seek(0) + + # Store the report using the standard helper function with UTC timestamp + timestamp = datetime.now(UTC) + upload_csv_file_to_report_store( + output, + 'issued_certificates', + course_key, + timestamp, + config_name='GRADES_DOWNLOAD' + ) + + return _('The issued certificates report has been created.') + + class ORASummaryView(GenericAPIView): """ View to get a summary of Open Response Assessments (ORAs) for a given course.