feat: add v2 REST API endpoints for instructor dashboard data downloads (#37984)
This commit is contained in:
committed by
GitHub
parent
8dd99defac
commit
b98e41e339
@@ -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"
|
||||
|
||||
563
lms/djangoapps/instructor/tests/test_reports_api_v2.py
Normal file
563
lms/djangoapps/instructor/tests/test_reports_api_v2.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
def test_generate_problem_responses_report_missing_location(self):
|
||||
"""
|
||||
Test that generating a problem responses report without a problem_location returns 400.
|
||||
"""
|
||||
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)
|
||||
|
||||
@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)
|
||||
@@ -51,6 +51,16 @@ v2_api_urls = [
|
||||
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<report_type>[^/]+)/generate$',
|
||||
api_v2.GenerateReportView.as_view(),
|
||||
name='generate_report'
|
||||
),
|
||||
re_path(
|
||||
rf'^courses/{COURSE_ID_PATTERN}/ora_summary$',
|
||||
api_v2.ORASummaryView.as_view(),
|
||||
|
||||
@@ -5,33 +5,51 @@ This module contains the v2 API endpoints for instructor functionality.
|
||||
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.conf import settings
|
||||
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 openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
|
||||
from lms.djangoapps.courseware.tabs import get_course_tab_list
|
||||
from lms.djangoapps.instructor import permissions
|
||||
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 (
|
||||
@@ -45,6 +63,7 @@ from .serializers_v2 import (
|
||||
from .tools import (
|
||||
find_unit,
|
||||
get_units_with_due_date,
|
||||
keep_field_private,
|
||||
set_due_date_extension,
|
||||
title_or_url,
|
||||
)
|
||||
@@ -568,6 +587,477 @@ class ORAView(GenericAPIView):
|
||||
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."""
|
||||
course = get_course_by_id(course_key)
|
||||
available_features = instructor_analytics_basic.get_available_features(course_key)
|
||||
|
||||
# Allow for sites to be able to define additional columns.
|
||||
# Note that adding additional columns has the potential to break
|
||||
# the student profile report due to a character limit on the
|
||||
# asynchronous job input which in this case is a JSON string
|
||||
# containing the list of columns to include in the report.
|
||||
# TODO: Refactor the student profile report code to remove the list of columns
|
||||
# that should be included in the report from the asynchronous job input.
|
||||
# We need to clone the list because we modify it below
|
||||
query_features = list(configuration_helpers.get_value('student_profile_download_fields', []))
|
||||
|
||||
if not query_features:
|
||||
query_features = [
|
||||
'id', 'username', 'name', 'email', 'language', 'location',
|
||||
'year_of_birth', 'gender', 'level_of_education', 'mailing_address',
|
||||
'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key',
|
||||
'enrollment_date',
|
||||
]
|
||||
|
||||
additional_attributes = configuration_helpers.get_value_for_org(
|
||||
course_key.org,
|
||||
"additional_student_profile_attributes"
|
||||
)
|
||||
if additional_attributes:
|
||||
# Fail fast: must be list/tuple of strings.
|
||||
if not isinstance(additional_attributes, (list, tuple)):
|
||||
raise ValueError(
|
||||
_('Invalid additional student attribute configuration: expected list of strings, got {type}.')
|
||||
.format(type=type(additional_attributes).__name__)
|
||||
)
|
||||
if not all(isinstance(v, str) for v in additional_attributes):
|
||||
raise ValueError(
|
||||
_('Invalid additional student attribute configuration: all entries must be strings.')
|
||||
)
|
||||
# Reject empty string entries explicitly.
|
||||
if any(v == '' for v in additional_attributes):
|
||||
raise ValueError(
|
||||
_('Invalid additional student attribute configuration: empty attribute names are not allowed.')
|
||||
)
|
||||
# Validate each attribute is in available_features; allow duplicates as provided.
|
||||
invalid = [v for v in additional_attributes if v not in available_features]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
_('Invalid additional student attributes: {attrs}').format(
|
||||
attrs=', '.join(invalid)
|
||||
)
|
||||
)
|
||||
query_features.extend(additional_attributes)
|
||||
|
||||
for field in settings.PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS:
|
||||
keep_field_private(query_features, field)
|
||||
|
||||
if is_course_cohorted(course.id):
|
||||
query_features.append('cohort')
|
||||
|
||||
if course.teams_enabled:
|
||||
query_features.append('team')
|
||||
|
||||
# For compatibility reasons, city and country should always appear last.
|
||||
query_features.append('city')
|
||||
query_features.append('country')
|
||||
|
||||
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.
|
||||
|
||||
Requires a problem_location (section or problem block id).
|
||||
Supports optional filtering by problem types.
|
||||
"""
|
||||
problem_location = request.data.get('problem_location', '').strip()
|
||||
problem_types_filter = request.data.get('problem_types_filter')
|
||||
|
||||
if not problem_location:
|
||||
raise ValueError(_('Specify Section or Problem block id is required.'))
|
||||
|
||||
# Validate 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
|
||||
|
||||
task_api.submit_calculate_problem_responses_csv(
|
||||
request, course_key, problem_locations_str, problem_types_filter
|
||||
)
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user