diff --git a/lms/djangoapps/bulk_enroll/tests/test_views.py b/lms/djangoapps/bulk_enroll/tests/test_views.py index 780b89fff33d..7261e05a1a7d 100644 --- a/lms/djangoapps/bulk_enroll/tests/test_views.py +++ b/lms/djangoapps/bulk_enroll/tests/test_views.py @@ -14,7 +14,8 @@ from opaque_keys.edx.keys import CourseKey from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate -from common.djangoapps.student.models import ( # pylint: disable=line-too-long +from common.djangoapps.student.models import ( + ENROLLED_TO_ENROLLED, ENROLLED_TO_UNENROLLED, UNENROLLED_TO_ENROLLED, CourseEnrollment, @@ -150,6 +151,9 @@ def test_invalid_email(self): { "identifier": 'percivaloctavius@', "invalidIdentifier": True, + "success": False, + "error_type": "invalid_identifier", + "error_message": "Invalid email address", } ] } @@ -182,6 +186,9 @@ def test_invalid_username(self): { "identifier": 'percivaloctavius', "invalidIdentifier": True, + "success": False, + "error_type": "invalid_identifier", + "error_message": "Invalid email address", } ] } @@ -224,7 +231,9 @@ def test_enroll_with_username(self): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -274,7 +283,9 @@ def test_enroll_with_email(self, use_json): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -328,7 +339,9 @@ def test_unenroll(self, use_json): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": ENROLLED_TO_UNENROLLED, } ] } @@ -432,7 +445,9 @@ def test_add_to_valid_cohort(self): "user": True, "allowed": False, "cohort": 'cohort1', - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -446,7 +461,7 @@ def test_add_to_valid_cohort(self): assert res_json == expected - def test_readd_to_different_cohort(self): + def test_read_to_different_cohort(self): config_course_cohorts(self.course, is_cohorted=True, manual_cohorts=["cohort1", "cohort2"]) response = self.request_bulk_enroll({ 'identifiers': self.notenrolled_student.username, @@ -483,7 +498,9 @@ def test_readd_to_different_cohort(self): "user": True, "allowed": False, "cohort": 'cohort1', - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -531,7 +548,9 @@ def test_readd_to_different_cohort(self): "user": True, "allowed": False, "cohort": 'cohort2', - } + }, + "success": True, + "state_transition": ENROLLED_TO_ENROLLED, } ] } @@ -541,7 +560,7 @@ def test_readd_to_different_cohort(self): assert get_cohort_id(self.notenrolled_student, CourseKey.from_string(self.course_key)) is not None assert res2_json == expected2 - def test_readd_to_same_cohort(self): + def test_read_to_same_cohort(self): config_course_cohorts(self.course, is_cohorted=True, manual_cohorts=["cohort1", "cohort2"]) response = self.request_bulk_enroll({ 'identifiers': self.notenrolled_student.username, @@ -578,7 +597,9 @@ def test_readd_to_same_cohort(self): "user": True, "allowed": False, "cohort": 'cohort1', - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -627,7 +648,9 @@ def test_readd_to_same_cohort(self): "user": True, "allowed": False, "cohort": 'cohort1', - } + }, + "success": True, + "state_transition": ENROLLED_TO_ENROLLED, } ] } diff --git a/lms/djangoapps/bulk_enroll/views.py b/lms/djangoapps/bulk_enroll/views.py index 22f4650c3d08..7193e44ab9bf 100644 --- a/lms/djangoapps/bulk_enroll/views.py +++ b/lms/djangoapps/bulk_enroll/views.py @@ -90,7 +90,7 @@ def post(self, request): # pylint: disable=missing-function-docstring # Internal request to DRF view view = StudentsUpdateEnrollmentView() response_content = view._process_student_enrollment( # pylint: disable=protected-access - user=request.user, + request=request, course_id=course_id, data=request.data, secure=request.is_secure() diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 79fa2a7b1db8..565eef116913 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -10,8 +10,10 @@ from datetime import datetime import pytz +from crum import get_current_request from django.conf import settings from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.contrib.sites.models import Site from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import override as override_language @@ -50,7 +52,9 @@ ) from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming.helpers import get_current_site from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.lib.celery.task_utils import emulate_http_request from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order @@ -591,7 +595,37 @@ def send_mail_to_student(student, param_dict, language=None): language=language, user_context=param_dict, ) - ace.send(message) + + current_request = get_current_request() + + if current_request is None: + # We're in a Celery task context, need to emulate HTTP request + site = get_current_site() + if not site: + try: + site = Site.objects.get(id=settings.SITE_ID) + except Site.DoesNotExist: + try: + site = Site.objects.first() + except Exception: # pylint: disable=broad-except + site = None + + # Get the recipient user for tracking purposes + user = None + if lms_user_id and lms_user_id > 0: + try: + user = User.objects.get(id=lms_user_id) + except User.DoesNotExist: + pass + + # Use emulate_http_request to provide the necessary context for template tags + # that require a request object, such as google_analytics_tracking_pixel + with emulate_http_request(site=site, user=user): + ace.send(message) + else: + # We're in a web context, just send the message directly + # The current request already provides the necessary context + ace.send(message) def render_message_to_string(subject_template, message_template, param_dict, language=None): diff --git a/lms/djangoapps/instructor/message_types.py b/lms/djangoapps/instructor/message_types.py index 6354cfb54f63..cd56f478af94 100644 --- a/lms/djangoapps/instructor/message_types.py +++ b/lms/djangoapps/instructor/message_types.py @@ -83,3 +83,15 @@ class RemoveBetaTester(BaseMessageType): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.options['transactional'] = True + + +class BatchEnrollment(BaseMessageType): + """ + A message for instructors when they finish the batch enrollment async process. + """ + + APP_LABEL = "instructor" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.options["transactional"] = True diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 5210d35dd8cf..d68633c819c6 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -1104,6 +1104,9 @@ def test_invalid_email(self): { "identifier": 'percivaloctavius@', "invalidIdentifier": True, + "success": False, + "error_type": "invalid_identifier", + "error_message": "Invalid email address", } ] } @@ -1125,6 +1128,9 @@ def test_invalid_username(self): { "identifier": 'percivaloctavius', "invalidIdentifier": True, + "success": False, + "error_type": "invalid_identifier", + "error_message": "Invalid email address", } ] } @@ -1156,7 +1162,9 @@ def test_enroll_with_username(self): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -1195,7 +1203,9 @@ def test_enroll_without_email(self): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -1241,7 +1251,9 @@ def test_enroll_with_email(self, protocol): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": UNENROLLED_TO_ENROLLED, } ] } @@ -1428,7 +1440,9 @@ def test_unenroll_without_email(self): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": ENROLLED_TO_UNENROLLED, } ] } @@ -1471,7 +1485,9 @@ def test_unenroll_with_email(self): "auto_enroll": False, "user": True, "allowed": False, - } + }, + "success": True, + "state_transition": ENROLLED_TO_UNENROLLED, } ] } @@ -1525,7 +1541,9 @@ def test_unenroll_with_email_allowed_student(self): "auto_enroll": False, "user": False, "allowed": False, - } + }, + "success": True, + "state_transition": ALLOWEDTOENROLL_TO_UNENROLLED, } ] } @@ -1725,7 +1743,9 @@ def test_unenrolled_allowed_to_enroll_user(self): "auto_enroll": False, "user": True, "allowed": True, - } + }, + "success": True, + "state_transition": ALLOWEDTOENROLL_TO_ENROLLED, } ] } @@ -1767,7 +1787,9 @@ def test_unenrolled_already_not_enrolled_user(self): "auto_enroll": False, "user": False, "allowed": False, - } + }, + "success": True, + "state_transition": UNENROLLED_TO_UNENROLLED, } ] } @@ -1882,6 +1904,209 @@ def test_get_enrollment_status(self): res_json = json.loads(response.content.decode('utf-8')) assert res_json['enrollment_status'] == 'Enrollment status for nonotever@example.com: never enrolled' + @patch("lms.djangoapps.instructor_task.api.submit_student_enrollment_batch") + def test_enroll_async_processing_success(self, mock_submit_task): + """Test async enrollment with async_processing=True""" + mock_task = Mock() + mock_task.task_id = "test-task-id-123" + mock_task.task_state = "QUEUED" + mock_submit_task.return_value = mock_task + url = reverse("students_update_enrollment", kwargs={"course_id": str(self.course.id)}) + + response = self.client.post( + url, + { + "identifiers": self.notenrolled_student.email, + "action": "enroll", + "email_students": False, + "async_processing": True, + }, + ) + + assert response.status_code == 200 + res_json = json.loads(response.content.decode("utf-8")) + + # Verify async response structure + assert res_json == { + "action": "enroll", + "auto_enroll": False, + "async_processing": True, + "task_id": "test-task-id-123", + "task_state": "QUEUED", + "message": "Async enroll task submitted for 1 students", + "total_students": 1 + } + + # Verify the task was called with correct parameters + assert mock_submit_task.called + call_args = mock_submit_task.call_args + assert call_args[1]["course_key"] == self.course.id + assert call_args[1]["action"] == "enroll" + assert call_args[1]["identifiers"] == [self.notenrolled_student.email] + + @patch("lms.djangoapps.instructor_task.api.submit_student_enrollment_batch") + def test_unenroll_async_processing_success(self, mock_submit_task): + """Test async unenrollment with async_processing=True""" + mock_task = Mock() + mock_task.task_id = "test-unenroll-task-456" + mock_task.task_state = "QUEUED" + mock_submit_task.return_value = mock_task + url = reverse("students_update_enrollment", kwargs={"course_id": str(self.course.id)}) + + response = self.client.post( + url, + { + "identifiers": self.enrolled_student.email, + "action": "unenroll", + "email_students": True, + "async_processing": True, + }, + ) + + self.assertEqual(response.status_code, 200) # noqa: PT009 + res_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual( # noqa: PT009 + res_json, + { + "action": "unenroll", + "auto_enroll": False, + "async_processing": True, + "task_id": "test-unenroll-task-456", + "task_state": "QUEUED", + "message": "Async unenroll task submitted for 1 students", + "total_students": 1, + }, + ) + + @patch("lms.djangoapps.instructor_task.api.submit_student_enrollment_batch") + def test_async_enrollment_already_running_error(self, mock_submit_task): + """Test handling of AlreadyRunningError in async mode""" + mock_submit_task.side_effect = AlreadyRunningError("Task already running") + url = reverse("students_update_enrollment", kwargs={"course_id": str(self.course.id)}) + + response = self.client.post( + url, {"identifiers": self.notenrolled_student.email, "action": "enroll", "async_processing": True} + ) + + self.assertEqual(response.status_code, 200) # noqa: PT009 + res_json = json.loads(response.content.decode("utf-8")) + self.assertTrue(res_json["async_processing"]) # noqa: PT009 + self.assertIn("error", res_json) # noqa: PT009 + self.assertIn("already running", res_json["error"].lower()) # noqa: PT009 + self.assertEqual(res_json["action"], "enroll") # noqa: PT009 + + @patch("lms.djangoapps.instructor_task.api.submit_student_enrollment_batch") + def test_async_enrollment_multiple_identifiers(self, mock_submit_task): + """Test async enrollment with multiple student identifiers""" + student2 = UserFactory(username="Student2", email="student2@example.com") + student3 = UserFactory(username="Student3", email="student3@example.com") + mock_task = Mock() + mock_task.task_id = "test-bulk-task-789" + mock_task.task_state = "QUEUED" + mock_submit_task.return_value = mock_task + identifiers = f"{self.notenrolled_student.email},{student2.email},{student3.email}" + url = reverse("students_update_enrollment", kwargs={"course_id": str(self.course.id)}) + + response = self.client.post( + url, + { + "identifiers": identifiers, + "action": "enroll", + "email_students": False, + "async_processing": True, + "auto_enroll": True, + }, + ) + + self.assertEqual(response.status_code, 200) # noqa: PT009 + res_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(res_json["total_students"], 3) # noqa: PT009 + self.assertTrue(res_json["auto_enroll"]) # noqa: PT009 + + # Verify task was called with all identifiers + call_args = mock_submit_task.call_args + identifiers_list = call_args[1]["identifiers"] + self.assertEqual(len(identifiers_list), 3) # noqa: PT009 + self.assertIn(self.notenrolled_student.email, identifiers_list) # noqa: PT009 + self.assertIn(student2.email, identifiers_list) # noqa: PT009 + self.assertIn(student3.email, identifiers_list) # noqa: PT009 + + def test_async_processing_default_false(self): + """Test that async_processing defaults to False for backward compatibility""" + url = reverse("students_update_enrollment", kwargs={"course_id": str(self.course.id)}) + + response = self.client.post( + url, + { + "identifiers": self.notenrolled_student.email, + "action": "enroll", + "email_students": False, + # async_processing not provided + }, + ) + + self.assertEqual(response.status_code, 200) # noqa: PT009 + res_json = json.loads(response.content.decode("utf-8")) + + # Should have sync response structure (with 'results') + self.assertIn("results", res_json) # noqa: PT009 + self.assertNotIn("async_processing", res_json) # noqa: PT009 + self.assertEqual(res_json["action"], "enroll") # noqa: PT009 + + @patch("lms.djangoapps.instructor_task.api.submit_student_enrollment_batch") + def test_async_enrollment_with_reason(self, mock_submit_task): + """Test async enrollment with reason field""" + mock_task = Mock() + mock_task.task_id = "test-task-with-reason" + mock_task.task_state = "QUEUED" + mock_submit_task.return_value = mock_task + url = reverse("students_update_enrollment", kwargs={"course_id": str(self.course.id)}) + + response = self.client.post( + url, + { + "identifiers": self.notenrolled_student.email, + "action": "enroll", + "email_students": False, + "async_processing": True, + "reason": "Testing async enrollment", + }, + ) + + self.assertEqual(response.status_code, 200) # noqa: PT009 + res_json = json.loads(response.content.decode("utf-8")) + self.assertTrue(res_json["async_processing"]) # noqa: PT009 + + # Verify reason was passed to task + call_args = mock_submit_task.call_args + self.assertEqual(call_args[1]["reason"], "Testing async enrollment") # noqa: PT009 + + def test_sync_enrollment_still_works(self): + """Test that synchronous enrollment still works (async_processing=False)""" + url = reverse("students_update_enrollment", kwargs={"course_id": str(self.course.id)}) + + response = self.client.post( + url, + { + "identifiers": self.notenrolled_student.email, + "action": "enroll", + "email_students": False, + "async_processing": False, + }, + ) + + self.assertEqual(response.status_code, 200) # noqa: PT009 + res_json = json.loads(response.content.decode("utf-8")) # noqa: PT009 + + # Should have sync response structure + self.assertIn("results", res_json) # noqa: PT009 + self.assertEqual(len(res_json["results"]), 1) # noqa: PT009 + self.assertTrue(res_json["results"][0]["success"]) # noqa: PT009 + + # Verify actual enrollment happened + self.assertTrue(CourseEnrollment.is_enrolled(self.notenrolled_student, self.course.id)) # noqa: PT009 + @ddt.ddt class TestInstructorAPIBulkBetaEnrollment(SharedModuleStoreTestCase, LoginEnrollmentTestCase): diff --git a/lms/djangoapps/instructor/tests/test_utils.py b/lms/djangoapps/instructor/tests/test_utils.py new file mode 100644 index 000000000000..ae23f47d723c --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_utils.py @@ -0,0 +1,431 @@ +""" +Unit tests for instructor.utils module. + +Tests for student enrollment utility functions that can be used +in both synchronous and asynchronous contexts. +""" + +from unittest.mock import Mock, patch + +import ddt +from django.core.exceptions import ValidationError +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.student.models import ( + ALLOWEDTOENROLL_TO_ENROLLED, + ALLOWEDTOENROLL_TO_UNENROLLED, + DEFAULT_TRANSITION_STATE, + ENROLLED_TO_ENROLLED, + ENROLLED_TO_UNENROLLED, + UNENROLLED_TO_ALLOWEDTOENROLL, + UNENROLLED_TO_ENROLLED, + UNENROLLED_TO_UNENROLLED, + EnrollStatusChange, +) +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.instructor.enrollment import EmailEnrollmentState +from lms.djangoapps.instructor.utils import ( + _determine_enroll_state_transition, + _determine_unenroll_state_transition, + process_single_student_enrollment, + process_student_enrollment_batch, +) + + +@ddt.ddt +class TestDetermineEnrollStateTransition(TestCase): + """ + Test the _determine_enroll_state_transition function. + """ + + @ddt.data( + # User not registered, allowed to enroll + ( + {"user": False, "enrollment": False, "allowed": False}, + {"enrollment": False, "allowed": True}, + UNENROLLED_TO_ALLOWEDTOENROLL, + ), + # User not registered, not allowed + ( + {"user": False, "enrollment": False, "allowed": False}, + {"enrollment": False, "allowed": False}, + DEFAULT_TRANSITION_STATE, + ), + # User registered, was enrolled, still enrolled + ( + {"user": True, "enrollment": True, "allowed": False}, + {"enrollment": True, "allowed": False}, + ENROLLED_TO_ENROLLED, + ), + # User registered, was not enrolled, now enrolled + ( + {"user": True, "enrollment": False, "allowed": False}, + {"enrollment": True, "allowed": False}, + UNENROLLED_TO_ENROLLED, + ), + # User registered, was allowed, now enrolled + ( + {"user": True, "enrollment": False, "allowed": True}, + {"enrollment": True, "allowed": False}, + ALLOWEDTOENROLL_TO_ENROLLED, + ), + # User registered, not enrolled + ( + {"user": True, "enrollment": False, "allowed": False}, + {"enrollment": False, "allowed": False}, + DEFAULT_TRANSITION_STATE, + ), + ) + @ddt.unpack + def test_determine_enroll_state_transition(self, before_state: dict, after_state: dict, expected_transition: str): + """Test state transition determination for enrollment.""" + result = _determine_enroll_state_transition(before_state, after_state) + + self.assertEqual(result, expected_transition) # noqa: PT009 + + +@ddt.ddt +class TestDetermineUnenrollStateTransition(TestCase): + """ + Test the _determine_unenroll_state_transition function. + """ + + @ddt.data( + # User was enrolled + ( + {"enrollment": True, "allowed": False}, + ENROLLED_TO_UNENROLLED, + ), + # User was allowed to enroll + ( + {"enrollment": False, "allowed": True}, + ALLOWEDTOENROLL_TO_UNENROLLED, + ), + # User was neither enrolled nor allowed + ( + {"enrollment": False, "allowed": False}, + UNENROLLED_TO_UNENROLLED, + ), + ) + @ddt.unpack + def test_determine_unenroll_state_transition(self, before_state: dict, expected_transition: str): + """Test state transition determination for unenrollment.""" + result = _determine_unenroll_state_transition(before_state) + + self.assertEqual(result, expected_transition) # noqa: PT009 + + +class TestProcessSingleStudentEnrollment(TestCase): + """ + Test the process_single_student_enrollment function. + """ + + def setUp(self): + super().setUp() + self.course_key = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + self.user = UserFactory.create(username="testuser", email="test@example.com") + self.request_user = UserFactory.create(username="instructor", email="instructor@example.com") + self.email_params = {"course_name": "Test Course", "course_url": "http://example.com/course"} + + @patch("lms.djangoapps.instructor.utils.enroll_email") + def test_process_single_student_enrollment_success(self, mock_enroll_email: Mock): + """Test successful enrollment of a single student.""" + before_state = EmailEnrollmentState(self.course_key, self.user.email) + before_state.enrollment = False + before_state.user = True + before_state.allowed = False + after_state = EmailEnrollmentState(self.course_key, self.user.email) + after_state.enrollment = True + after_state.user = True + after_state.allowed = False + mock_enroll_email.return_value = (before_state, after_state, None) + + result = process_single_student_enrollment( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifier=self.user.email, + auto_enroll=False, + email_students=True, + reason="Test enrollment", + email_params=self.email_params, + ) + + self.assertTrue(result["success"]) # noqa: PT009 + self.assertEqual(result["identifier"], self.user.email) # noqa: PT009 + self.assertEqual(result["state_transition"], UNENROLLED_TO_ENROLLED) # noqa: PT009 + self.assertIn("before", result) # noqa: PT009 + self.assertIn("after", result) # noqa: PT009 + + @patch("lms.djangoapps.instructor.utils.unenroll_email") + def test_process_single_student_unenrollment_success(self, mock_unenroll_email: Mock): + """Test successful unenrollment of a single student.""" + before_state = EmailEnrollmentState(self.course_key, self.user.email) + before_state.enrollment = True + before_state.user = True + before_state.allowed = False + after_state = EmailEnrollmentState(self.course_key, self.user.email) + after_state.enrollment = False + after_state.user = True + after_state.allowed = False + mock_unenroll_email.return_value = (before_state, after_state) + + result = process_single_student_enrollment( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.unenroll, + identifier=self.user.email, + auto_enroll=False, + email_students=True, + reason="Test unenrollment", + email_params=self.email_params, + ) + + self.assertTrue(result["success"]) # noqa: PT009 + self.assertEqual(result["identifier"], self.user.email) # noqa: PT009 + self.assertEqual(result["state_transition"], ENROLLED_TO_UNENROLLED) # noqa: PT009 + + @patch("lms.djangoapps.instructor.utils.validate_email") + def test_process_single_student_enrollment_invalid_email(self, mock_validate_email: Mock): + """Test enrollment with invalid email address.""" + mock_validate_email.side_effect = ValidationError("Invalid email") + + result = process_single_student_enrollment( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifier="invalid-email", + auto_enroll=False, + email_students=True, + reason="Test enrollment", + email_params=self.email_params, + ) + + self.assertFalse(result["success"]) # noqa: PT009 + self.assertEqual(result["identifier"], "invalid-email") # noqa: PT009 + self.assertTrue(result["invalidIdentifier"]) # noqa: PT009 + self.assertEqual(result["error_type"], "invalid_identifier") # noqa: PT009 + self.assertEqual(result["error_message"], "Invalid email address") # noqa: PT009 + + @patch("lms.djangoapps.instructor.utils.enroll_email") + def test_process_single_student_enrollment_general_error( + self, + mock_enroll_email: Mock, + ): + """Test enrollment with general exception.""" + mock_enroll_email.side_effect = Exception() + + result = process_single_student_enrollment( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifier=self.user.email, + auto_enroll=False, + email_students=True, + reason="Test enrollment", + email_params=self.email_params, + ) + + self.assertFalse(result["success"]) # noqa: PT009 + self.assertEqual(result["identifier"], self.user.email) # noqa: PT009 + self.assertTrue(result["error"]) # noqa: PT009 + self.assertEqual(result["error_type"], "general_error") # noqa: PT009 + self.assertEqual( # noqa: PT009 + result["error_message"], + "Something went wrong while processing this learner. Please try again or contact support.", + ) + + +class TestProcessStudentEnrollmentBatch(TestCase): + """ + Test the process_student_enrollment_batch function. + """ + + def setUp(self): + super().setUp() + self.course_key = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + self.user1 = UserFactory.create(username="testuser1", email="test1@example.com") + self.user2 = UserFactory.create(username="testuser2", email="test2@example.com") + self.request_user = UserFactory.create(username="instructor", email="instructor@example.com") + + @patch("lms.djangoapps.instructor.utils.get_course_by_id") + @patch("lms.djangoapps.instructor.utils.get_email_params") + @patch("lms.djangoapps.instructor.utils.process_single_student_enrollment") + def test_process_student_enrollment_batch_success( + self, mock_process_single: Mock, mock_get_course: Mock, mock_get_email_params: Mock + ): + """Test batch processing with all successful enrollments.""" + mock_get_course.return_value = Mock(display_name_with_default="Test Course") + mock_get_email_params.return_value = {"course_name": "Test Course"} + mock_process_single.side_effect = [ + { + "identifier": self.user1.email, + "success": True, + "before": {}, + "after": {}, + "state_transition": UNENROLLED_TO_ENROLLED, + }, + { + "identifier": self.user2.email, + "success": True, + "before": {}, + "after": {}, + "state_transition": UNENROLLED_TO_ENROLLED, + }, + ] + identifiers = [self.user1.email, self.user2.email] + + result = process_student_enrollment_batch( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifiers=identifiers, + auto_enroll=False, + email_students=True, + reason="Batch enrollment test", + secure=True, + ) + + self.assertEqual(result["action"], EnrollStatusChange.enroll) # noqa: PT009 + self.assertFalse(result["auto_enroll"]) # noqa: PT009 + self.assertEqual(result["total_students"], 2) # noqa: PT009 + self.assertEqual(result["successful_operations"], 2) # noqa: PT009 + self.assertEqual(result["failed_operations"], 0) # noqa: PT009 + self.assertEqual(len(result["results"]), 2) # noqa: PT009 + self.assertEqual(mock_process_single.call_count, 2) # noqa: PT009 + + @patch("lms.djangoapps.instructor.utils.get_course_by_id") + @patch("lms.djangoapps.instructor.utils.get_email_params") + @patch("lms.djangoapps.instructor.utils.process_single_student_enrollment") + def test_process_student_enrollment_batch_mixed_results( + self, + mock_process_single: Mock, + mock_get_email_params: Mock, + mock_get_course: Mock, + ): + """Test batch processing with mixed success and failure.""" + mock_get_course.return_value = Mock(display_name_with_default="Test Course") + mock_get_email_params.return_value = {"course_name": "Test Course"} + mock_process_single.side_effect = [ + { + "identifier": self.user1.email, + "success": True, + "before": {}, + "after": {}, + "state_transition": UNENROLLED_TO_ENROLLED, + }, + { + "identifier": "invalid@", + "success": False, + "invalidIdentifier": True, + "error_type": "invalid_identifier", + "error_message": "Invalid email address", + }, + ] + identifiers = [self.user1.email, "invalid@"] + + result = process_student_enrollment_batch( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifiers=identifiers, + auto_enroll=False, + email_students=True, + reason="Batch enrollment test", + secure=True, + ) + + self.assertEqual(result["total_students"], 2) # noqa: PT009 + self.assertEqual(result["successful_operations"], 1) # noqa: PT009 + self.assertEqual(result["failed_operations"], 1) # noqa: PT009 + self.assertEqual(len(result["results"]), 2) # noqa: PT009 + + @patch("lms.djangoapps.instructor.utils.get_course_by_id") + @patch("lms.djangoapps.instructor.utils.get_email_params") + @patch("lms.djangoapps.instructor.utils.process_single_student_enrollment") + def test_process_student_enrollment_batch_with_progress_callback( + self, + mock_process_single: Mock, + mock_get_email_params: Mock, + mock_get_course: Mock, + ): + """Test batch processing with progress callback.""" + mock_get_course.return_value = Mock(display_name_with_default="Test Course") + mock_get_email_params.return_value = {"course_name": "Test Course"} + mock_process_single.return_value = { + "identifier": self.user1.email, + "success": True, + "before": {}, + "after": {}, + "state_transition": UNENROLLED_TO_ENROLLED, + } + identifiers = [self.user1.email, self.user2.email] + progress_calls = [] + + def progress_callback(current, total, results): + progress_calls.append((current, total, len(results))) + + result = process_student_enrollment_batch( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifiers=identifiers, + auto_enroll=False, + email_students=False, + reason=None, + secure=True, + progress_callback=progress_callback, + ) + + self.assertEqual(len(progress_calls), 2) # noqa: PT009 + self.assertEqual(progress_calls[0], (1, 2, 1)) # noqa: PT009 + self.assertEqual(progress_calls[1], (2, 2, 2)) # noqa: PT009 + self.assertEqual(result["successful_operations"], 2) # noqa: PT009 + + @patch("lms.djangoapps.instructor.utils.process_single_student_enrollment") + def test_process_student_enrollment_batch_no_email(self, mock_process_single: Mock): + """Test batch processing without sending emails.""" + mock_process_single.return_value = { + "identifier": self.user1.email, + "success": True, + "before": {}, + "after": {}, + "state_transition": UNENROLLED_TO_ENROLLED, + } + identifiers = [self.user1.email] + + result = process_student_enrollment_batch( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifiers=identifiers, + auto_enroll=False, + email_students=False, + reason=None, + secure=True, + ) + + self.assertEqual(result["successful_operations"], 1) # noqa: PT009 + call_kwargs = mock_process_single.call_args[1] + self.assertEqual(call_kwargs["email_params"], {}) # noqa: PT009 + + @patch("lms.djangoapps.instructor.utils.process_single_student_enrollment") + def test_process_student_enrollment_batch_empty_list(self, mock_process_single: Mock): + """Test batch processing with empty identifier list.""" + result = process_student_enrollment_batch( + request_user=self.request_user, + course_key=self.course_key, + action=EnrollStatusChange.enroll, + identifiers=[], + auto_enroll=False, + email_students=False, + reason=None, + secure=True, + ) + + self.assertEqual(result["total_students"], 0) # noqa: PT009 + self.assertEqual(result["successful_operations"], 0) # noqa: PT009 + self.assertEqual(result["failed_operations"], 0) # noqa: PT009 + self.assertEqual(len(result["results"]), 0) # noqa: PT009 + mock_process_single.assert_not_called() diff --git a/lms/djangoapps/instructor/utils.py b/lms/djangoapps/instructor/utils.py new file mode 100644 index 000000000000..c2ea750c4984 --- /dev/null +++ b/lms/djangoapps/instructor/utils.py @@ -0,0 +1,269 @@ +""" +Utility functions for student enrollment operations. + +This module contains reusable functions for processing student enrollments +that can be used in both synchronous and asynchronous contexts. +""" + +import logging +from collections.abc import Callable + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.db import transaction +from django.utils.translation import gettext as _ +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.student.models import ( + ALLOWEDTOENROLL_TO_ENROLLED, + ALLOWEDTOENROLL_TO_UNENROLLED, + DEFAULT_TRANSITION_STATE, + ENROLLED_TO_ENROLLED, + ENROLLED_TO_UNENROLLED, + UNENROLLED_TO_ALLOWEDTOENROLL, + UNENROLLED_TO_ENROLLED, + UNENROLLED_TO_UNENROLLED, + CourseEnrollment, + EnrollStatusChange, + ManualEnrollmentAudit, + get_user_by_username_or_email, +) +from lms.djangoapps.instructor.enrollment import ( + enroll_email, + get_email_params, + get_user_email_language, + unenroll_email, +) +from openedx.core.lib.courses import get_course_by_id + +log = logging.getLogger(__name__) + + +User = get_user_model() + + +def _determine_enroll_state_transition(before_state: dict, after_state: dict) -> str: + """ + Determine the state transition for an enrollment operation. + + Args: + before_state (dict): State before enrollment with keys 'enrollment', 'user', 'allowed' + after_state (dict): State after enrollment with keys 'enrollment', 'allowed' + + Returns: + str: The state transition constant + """ + # User was not registered before + if not before_state["user"]: + if after_state["allowed"]: + return UNENROLLED_TO_ALLOWEDTOENROLL + return DEFAULT_TRANSITION_STATE + + # User was registered and enrolled successfully + if after_state["enrollment"]: + if before_state["enrollment"]: + return ENROLLED_TO_ENROLLED + if before_state["allowed"]: + return ALLOWEDTOENROLL_TO_ENROLLED + return UNENROLLED_TO_ENROLLED + + return DEFAULT_TRANSITION_STATE + + +def _determine_unenroll_state_transition(before_state: dict) -> str: + """ + Determine the state transition for an unenrollment operation. + + Args: + before_state (dict): State before unenrollment with keys 'enrollment', 'allowed' + + Returns: + str: The state transition constant + """ + if before_state["enrollment"]: + return ENROLLED_TO_UNENROLLED + if before_state["allowed"]: + return ALLOWEDTOENROLL_TO_UNENROLLED + return UNENROLLED_TO_UNENROLLED + + +def process_single_student_enrollment( + request_user: User, + course_key: CourseKey, + action: str, + identifier: str, + auto_enroll: bool, + email_students: bool, + reason: str | None, + email_params: dict | None, +): + """ + Process enrollment/unenrollment for a single student. + + Args: + request_user (User): User who initiated the enrollment operation + course_key (CourseKey): CourseKey object for the course + action (str): 'enroll' or 'unenroll' + identifier (str): Student identifier (email or username) + auto_enroll (bool): Whether to auto-enroll in verified track if applicable + email_students (bool): Whether to send enrollment emails + reason (str | None): Optional reason for enrollment change + email_params (dict | None): Pre-computed email parameters (optional) + + Returns: + dict: Result of the enrollment operation with keys: + - identifier: The student identifier + - success: Boolean indicating if operation was successful + - before: State before operation (if successful) + - after: State after operation (if successful) + - error_type: Type of error ('invalid_identifier', 'validation_error', 'general_error') + - error_message: Error message (if failed) + """ + enrollment_obj = None + state_transition = DEFAULT_TRANSITION_STATE + identified_user = None + email = None + language = None + + try: + identified_user = get_user_by_username_or_email(identifier) + except User.DoesNotExist: + email = identifier + else: + email = identified_user.email + language = get_user_email_language(identified_user) + + try: + validate_email(email) # Raises ValidationError if invalid + + # Wrap enrollment and audit operations in an atomic transaction + # to ensure both succeed or both are rolled back + with transaction.atomic(): + if action == EnrollStatusChange.enroll: + before, after, enrollment_obj = enroll_email( + course_key, email, auto_enroll, email_students, {**email_params}, language=language + ) + before_state = before.to_dict() + after_state = after.to_dict() + state_transition = _determine_enroll_state_transition(before_state, after_state) + + elif action == EnrollStatusChange.unenroll: + before, after = unenroll_email( + course_key, email, email_students, {**email_params}, language=language + ) + before_state = before.to_dict() + after_state = after.to_dict() + state_transition = _determine_unenroll_state_transition(before_state) + enrollment_obj = ( + CourseEnrollment.get_enrollment(identified_user, course_key) if identified_user else None + ) + + # Create audit record + ManualEnrollmentAudit.create_manual_enrollment_audit( + request_user, email, state_transition, reason, enrollment_obj + ) + + return { + "identifier": identifier, + "before": before_state, + "after": after_state, + "success": True, + "state_transition": state_transition, + } + except ValidationError: + return { + "identifier": identifier, + "invalidIdentifier": True, + "success": False, + "error_type": "invalid_identifier", + "error_message": _("Invalid email address"), + } + except Exception as exc: # pylint: disable=broad-exception-caught + log.exception("Error while processing student %s: %s", identifier, exc) + return { + "identifier": identifier, + "error": True, + "success": False, + "error_type": "general_error", + "error_message": _( + "Something went wrong while processing this learner. Please try again or contact support." + ), + } + + +def process_student_enrollment_batch( + request_user: User, + course_key: CourseKey, + action: str, + identifiers: list[str], + auto_enroll: bool, + email_students: bool, + reason: str | None, + secure: bool, + progress_callback: Callable[..., None] | None = None, +): + """ + Process a batch of student enrollment/unenrollment operations. + + Args: + request_user (User): User who initiated the batch operation + course_key (CourseKey): CourseKey object for the course + action (str): 'enroll' or 'unenroll' + identifiers (list[str]): List of student identifiers (emails or usernames) + auto_enroll (bool): Whether to auto-enroll in verified track if applicable + email_students (bool): Whether to send enrollment emails + reason (str | None): Optional reason for enrollment change + secure (bool): Whether the request is secure (HTTPS) + progress_callback (Optional[Callable]): Optional callback function to report progress + Should accept (current, total, results) parameters + + Returns: + dict: Batch processing results with keys: + - action: The action performed + - auto_enroll: Auto-enrollment setting + - results: List of individual enrollment results + - successful_operations: Count of successful operations + - failed_operations: Count of failed operations + - total_students: Total number of students processed + """ + email_params = {} + if email_students: + course = get_course_by_id(course_key) + email_params = get_email_params(course, auto_enroll, secure=secure) + + results = [] + successful_operations = 0 + failed_operations = 0 + total_students = len(identifiers) + + for idx, identifier in enumerate(identifiers): + result = process_single_student_enrollment( + request_user=request_user, + course_key=course_key, + action=action, + identifier=identifier, + auto_enroll=auto_enroll, + email_students=email_students, + reason=reason, + email_params=email_params, + ) + + results.append(result) + + if result["success"]: + successful_operations += 1 + else: + failed_operations += 1 + + if progress_callback: + progress_callback(idx + 1, total_students, results) + + return { + "action": action, + "auto_enroll": auto_enroll, + "results": results, + "successful_operations": successful_operations, + "failed_operations": failed_operations, + "total_students": total_students, + } diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 033d1ef893c9..9b629fd807c7 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -54,14 +54,7 @@ from common.djangoapps.student import auth from common.djangoapps.student.api import is_user_enrolled_in_course from common.djangoapps.student.models import ( - ALLOWEDTOENROLL_TO_ENROLLED, - ALLOWEDTOENROLL_TO_UNENROLLED, - DEFAULT_TRANSITION_STATE, - ENROLLED_TO_ENROLLED, - ENROLLED_TO_UNENROLLED, - UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ENROLLED, - UNENROLLED_TO_UNENROLLED, CourseEnrollment, CourseEnrollmentAllowed, EntranceExamConfiguration, @@ -91,11 +84,10 @@ from lms.djangoapps.instructor.enrollment import ( enroll_email, get_email_params, - get_user_email_language, send_beta_role_email, send_mail_to_student, - unenroll_email, ) +from lms.djangoapps.instructor.utils import process_student_enrollment_batch from lms.djangoapps.instructor.views.instructor_task_helpers import extract_email_features, extract_task_features from lms.djangoapps.instructor.views.serializer import ( AccessSerializer, @@ -131,6 +123,7 @@ from openedx.core.djangoapps.course_groups.models import CourseUserGroup from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming.helpers import get_current_site from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser @@ -726,7 +719,8 @@ def create_and_enroll_user( @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') -class StudentsUpdateEnrollmentView(APIView): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class StudentsUpdateEnrollmentView(DeveloperErrorViewMixin, APIView): """ API view to enroll or unenroll students in a course. """ @@ -735,6 +729,7 @@ class StudentsUpdateEnrollmentView(APIView): permission_name = permissions.CAN_ENROLL @method_decorator(ensure_csrf_cookie) + @transaction.non_atomic_requests def post(self, request, course_id): """ Handle POST request to enroll or unenroll students. @@ -744,24 +739,25 @@ def post(self, request, course_id): - identifiers (str): comma/newline separated emails or usernames - auto_enroll (bool): auto-enroll in verified track if applicable - email_students (bool): whether to send enrollment emails + - async_processing (bool): whether to process asynchronously - reason (str, optional): reason for enrollment change Returns: - JSON response with action, auto_enroll flag, and enrollment results. """ response_payload = self._process_student_enrollment( - user=request.user, + request=request, course_id=course_id, data=request.data, secure=request.is_secure() ) return JsonResponse(response_payload) - def _process_student_enrollment(self, user, course_id, data, secure): # pylint: disable=too-many-statements + def _process_student_enrollment(self, request, course_id, data, secure): # pylint: disable=too-many-statements """ Core logic for enrolling or unenrolling students. - :param user: User making the request + :param request: The HTTP request object :param course_id: Course identifier :param data: Request data containing action, identifiers, etc. :param secure: Whether the request is secure (HTTPS) @@ -776,6 +772,7 @@ def _process_student_enrollment(self, user, course_id, data, secure): # pylint: identifiers_raw = serializer.validated_data['identifiers'] auto_enroll = serializer.validated_data['auto_enroll'] email_students = serializer.validated_data['email_students'] + async_processing = serializer.validated_data["async_processing"] reason = serializer.validated_data.get('reason') # Parse identifiers @@ -783,97 +780,97 @@ def _process_student_enrollment(self, user, course_id, data, secure): # pylint: course_key = CourseKey.from_string(course_id) - enrollment_obj = None - state_transition = DEFAULT_TRANSITION_STATE + site = get_current_site() + site_id = site.id if site else None - email_params = {} - if email_students: - course = get_course_by_id(course_key) - email_params = get_email_params(course, auto_enroll, secure=secure) + if async_processing: - results = [] + try: + instructor_task = task_api.submit_student_enrollment_batch( + request=request, + course_key=course_key, + action=action, + identifiers=identifiers, + auto_enroll=auto_enroll, + email_students=email_students, + reason=reason, + secure=secure, + site_id=site_id, + ) - for identifier in identifiers: # pylint: disable=too-many-nested-blocks - identified_user = None - email = None - language = None + return { + "action": action, + "auto_enroll": auto_enroll, + "async_processing": True, + "task_id": instructor_task.task_id, + "task_state": instructor_task.task_state, + "message": f"Async {action} task submitted for {len(identifiers)} students", + "total_students": len(identifiers), + } - try: - identified_user = get_student_from_identifier(identifier) - except User.DoesNotExist: - email = identifier - else: - email = identified_user.email - language = get_user_email_language(identified_user) + except AlreadyRunningError: + return { + "action": action, + "auto_enroll": auto_enroll, + "async_processing": True, + "error": "A similar enrollment task is already running. Please wait for it to complete.", + "total_students": len(identifiers), + } - try: - validate_email(email) # Raises ValidationError if invalid + return self._process_enrollment_sync( + request.user, course_key, action, identifiers, auto_enroll, email_students, reason, secure + ) - if action == 'enroll': - before, after, enrollment_obj = enroll_email( - course_key, email, auto_enroll, email_students, {**email_params}, language=language - ) - before_enrollment = before.to_dict()['enrollment'] - before_user_registered = before.to_dict()['user'] - before_allowed = before.to_dict()['allowed'] - after_enrollment = after.to_dict()['enrollment'] - after_allowed = after.to_dict()['allowed'] - - if before_user_registered: - if after_enrollment: - if before_enrollment: - state_transition = ENROLLED_TO_ENROLLED - elif before_allowed: - state_transition = ALLOWEDTOENROLL_TO_ENROLLED - else: - state_transition = UNENROLLED_TO_ENROLLED - elif after_allowed: - state_transition = UNENROLLED_TO_ALLOWEDTOENROLL - - elif action == 'unenroll': - before, after = unenroll_email( - course_key, email, email_students, {**email_params}, language=language - ) - before_enrollment = before.to_dict()['enrollment'] - before_allowed = before.to_dict()['allowed'] - enrollment_obj = ( - CourseEnrollment.get_enrollment(identified_user, course_key) - if identified_user else None - ) + def _process_enrollment_sync( + self, + request_user: User, + course_key: CourseKey, + action: str, + identifiers: list[str], + auto_enroll: bool, + email_students: bool, + reason: str | None, + secure: bool, + ): + """ + Process student enrollment/unenrollment operations synchronously. - if before_enrollment: - state_transition = ENROLLED_TO_UNENROLLED - elif before_allowed: - state_transition = ALLOWEDTOENROLL_TO_UNENROLLED - else: - state_transition = UNENROLLED_TO_UNENROLLED + This method handles batch enrollment operations by calling the + `process_student_enrollment_batch` utility function and returns a + simplified response containing the action, auto_enroll setting, + and enrollment results. - except ValidationError: - results.append({ - 'identifier': identifier, - 'invalidIdentifier': True, - }) - except Exception as exc: # pylint: disable=broad-except - log.exception("Error while processing student") - log.exception(exc) - results.append({ - 'identifier': identifier, - 'error': True, - }) - else: - ManualEnrollmentAudit.create_manual_enrollment_audit( - identified_user, email, state_transition, reason, enrollment_obj - ) - results.append({ - 'identifier': identifier, - 'before': before.to_dict(), - 'after': after.to_dict(), - }) + Args: + request_user (User): User who initiated the enrollment operation + course_key (CourseKey): CourseKey object for the target course + action (str): The enrollment action to perform ('enroll' or 'unenroll') + identifiers (list[str]): List of student identifiers (emails or usernames) + auto_enroll (bool): Whether to auto-enroll students in verified track if applicable + email_students (bool): Whether to send enrollment notification emails + reason (str | None): Optional reason for the enrollment change + secure (bool): Whether the request was made over HTTPS + + Returns: + dict: Enrollment operation results containing: + - action: The action that was performed + - auto_enroll: The auto-enrollment setting used + - results: List of individual enrollment results for each student + """ + batch_result = process_student_enrollment_batch( + request_user=request_user, + course_key=course_key, + action=action, + identifiers=identifiers, + auto_enroll=auto_enroll, + email_students=email_students, + reason=reason, + secure=secure, + ) return { - 'action': action, - 'auto_enroll': auto_enroll, - 'results': results, + "action": batch_result["action"], + "auto_enroll": batch_result["auto_enroll"], + "results": batch_result["results"], } diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 081081d52ecd..b2817d5f2032 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -565,6 +565,7 @@ class StudentsUpdateEnrollmentSerializer(serializers.Serializer): identifiers = serializers.CharField() auto_enroll = serializers.BooleanField(default=False) email_students = serializers.BooleanField(default=False) + async_processing = serializers.BooleanField(default=False) reason = serializers.CharField(required=False, allow_blank=True) diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 558be9df8377..4416a8ce99bf 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -8,11 +8,14 @@ """ import datetime import hashlib +import json import logging from collections import Counter import pytz from celery.states import READY_STATES +from django.http import HttpRequest +from opaque_keys.edx.keys import CourseKey from common.djangoapps.util import milestones_helpers from lms.djangoapps.bulk_email.api import get_course_email @@ -50,6 +53,7 @@ rescore_problem, reset_problem_attempts, send_bulk_course_email, + student_enrollment_batch, ) from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order @@ -607,3 +611,56 @@ def process_scheduled_instructor_tasks(): submit_scheduled_task(schedule) except QueueConnectionError as exc: log.error(f"Error processing scheduled task with task id '{schedule.task.id}': {exc}") + + +def submit_student_enrollment_batch( + request: HttpRequest, + course_key: CourseKey, + action: str, + identifiers: list[str], + auto_enroll: bool, + email_students: bool, + reason: str | None, + secure: bool, + site_id: int | None = None, +): + """ + Request to have student enrollment operations processed as a background task. + + The task will process a batch of enrollment/unenrollment operations for the specified + students in the given course. + + Args: + request (HttpRequest): The HTTP request object + course_key (CourseKey): Course identifier + action (str): 'enroll' or 'unenroll' + identifiers (list[str]): List of student identifiers (emails or usernames) + auto_enroll (bool): Whether to auto-enroll in verified track if applicable + email_students (bool): Whether to send enrollment emails + reason (str | None): Optional reason for enrollment change + secure (bool): Whether the request is secure (HTTPS) + site_id (int | None): Optional site ID for notification emails + + Returns: + InstructorTask object representing the submitted background task + + Raises: + AlreadyRunningError: If the same task is already running + """ + task_type = InstructorTaskTypes.STUDENT_ENROLLMENT_BATCH + task_class = student_enrollment_batch + + task_input = { + "action": action, + "identifiers": identifiers, + "auto_enroll": auto_enroll, + "email_students": email_students, + "reason": reason, + "secure": secure, + "site_id": site_id, + } + + task_key_stub = f"{course_key}_{action}_{json.dumps(sorted(identifiers))}" + task_key = hashlib.md5(task_key_stub.encode("utf-8")).hexdigest() + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) diff --git a/lms/djangoapps/instructor_task/api_helper.py b/lms/djangoapps/instructor_task/api_helper.py index eb0f6a11fbe7..3a2cba1be1a9 100644 --- a/lms/djangoapps/instructor_task/api_helper.py +++ b/lms/djangoapps/instructor_task/api_helper.py @@ -113,7 +113,8 @@ def generate_already_running_error_message(task_type): 'proctored_exam_results_report': _('proctored exam results'), 'export_ora2_data': _('ORA data'), 'grade_course': _('grade'), - 'inactive_enrolled_students_info_csv': _('inactive enrollment') + 'inactive_enrolled_students_info_csv': _('inactive enrollment'), + 'student_enrollment_batch': _('student enrollment batch'), } if report_types.get(task_type): diff --git a/lms/djangoapps/instructor_task/data.py b/lms/djangoapps/instructor_task/data.py index ad0fe5764135..4b77468159ca 100644 --- a/lms/djangoapps/instructor_task/data.py +++ b/lms/djangoapps/instructor_task/data.py @@ -33,3 +33,4 @@ class InstructorTaskTypes(str, Enum): # noqa: UP042 RESCORE_PROBLEM = "rescore_problem" RESCORE_PROBLEM_IF_HIGHER = "rescore_problem_if_higher" RESET_PROBLEM_ATTEMPTS = "reset_problem_attempts" + STUDENT_ENROLLMENT_BATCH = "student_enrollment_batch" diff --git a/lms/djangoapps/instructor_task/notifications.py b/lms/djangoapps/instructor_task/notifications.py new file mode 100644 index 000000000000..5ade4f51edc3 --- /dev/null +++ b/lms/djangoapps/instructor_task/notifications.py @@ -0,0 +1,190 @@ +""" +Notification utilities for instructor tasks. + +This module contains functions for sending email notifications about completed +instructor tasks (enrollments, grades, certificates, etc.). +""" + +import json +import logging + +from django.conf import settings +from django.contrib.sites.models import Site +from edx_ace import ace +from edx_ace.recipient import Recipient +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.student.models import EnrollStatusChange +from lms.djangoapps.instructor.message_types import BatchEnrollment +from lms.djangoapps.instructor_task.models import InstructorTask +from openedx.core.djangoapps.ace_common.template_context import get_base_template_context +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_api.preferences.api import get_user_preference +from openedx.core.lib.celery.task_utils import emulate_http_request +from openedx.core.lib.courses import get_course_by_id + +TASK_LOG = logging.getLogger("edx.celery.task") + + +def _get_current_site(site_id: int | None = None) -> Site | None: + """ + Get the current Django Site instance with fallback logic. + + Args: + site_id (int | None): Optional site ID to retrieve. If provided, attempts + to get this specific site first before falling back. + + Returns: + Site | None: The current Site object or None if unavailable + """ + if site_id: + try: + return Site.objects.get(id=site_id) + except Site.DoesNotExist: + pass + + # Try to get current site if method exists + if hasattr(Site.objects, "get_current"): + site = Site.objects.get_current() + if site: + return site + + # Fallback to SITE_ID from settings + try: + return Site.objects.get(id=settings.SITE_ID) + except Site.DoesNotExist: + pass + + try: + return Site.objects.first() + except Exception: # pylint: disable=broad-except + return None + + +def _parse_task_input(instructor_task: InstructorTask) -> dict: + """ + Parse and return the task input JSON from InstructorTask. + + Args: + instructor_task (InstructorTask): The InstructorTask instance + + Returns: + dict: Parsed task input or empty dict if parsing fails + """ + try: + return json.loads(instructor_task.task_input) + except (json.JSONDecodeError, ValueError): + return {} + + +def _get_action_display_name(action: str) -> str: + """ + Get the localized display name for an enrollment action. + + Args: + action (str): The enrollment action ('enroll' or 'unenroll') + + Returns: + str: Localized action name + """ + from django.utils.translation import gettext_lazy as _ + + return _("enrollment") if action == EnrollStatusChange.enroll else _("unenrollment") + + +def _build_enrollment_email_context( + course_key: CourseKey, + requester, + action: str, + task_result: dict, + site: Site | None, + task_input: dict, +) -> dict: + """ + Build the email context dictionary for enrollment completion email. + + Args: + course_key (CourseKey): The course key + requester (User): The user who initiated the task + action (str): The enrollment action + task_result (dict): Dictionary with task results + site (Site | None): The current site object + task_input (dict): Parsed task input dictionary + + Returns: + dict: Complete context for email template + """ + course = get_course_by_id(course_key) + site_name = configuration_helpers.get_value("SITE_NAME", settings.SITE_NAME) + secure = task_input.get("secure", True) + protocol = "https" if secure else "http" + + context = { + "action_name": _get_action_display_name(action), + "course_name": course.display_name_with_default, + "total_processed": task_result.get("total_processed", 0), + "successful": task_result.get("successful", 0), + "failed": task_result.get("failed", 0), + "user_name": requester.username, + "platform_name": settings.PLATFORM_NAME, + "course_url": f"{protocol}://{site_name}/courses/{course_key}/", + } + + # Add base template context + context.update(get_base_template_context(site)) + + return context + + +def send_enrollment_task_completion_email( + course_key: CourseKey, instructor_task: InstructorTask, action: str, task_result: dict +) -> None: + """ + Send a completion email to the user who initiated the enrollment batch task. + + Args: + course_key (CourseKey): The course key + instructor_task (InstructorTask): The InstructorTask object + action (str): The action (e.g., 'enroll', 'unenroll') + task_result (dict): Dictionary containing task completion results with keys: + - total_processed: Total number of students processed + - successful: Number of successful operations + - failed: Number of failed operations + """ + requester = instructor_task.requester + task_input = _parse_task_input(instructor_task) + + site_id = task_input.get("site_id") + site = _get_current_site(site_id) + user_language = get_user_preference(requester, LANGUAGE_KEY) + + user_context = _build_enrollment_email_context( + course_key=course_key, + requester=requester, + action=action, + task_result=task_result, + site=site, + task_input=task_input, + ) + + message = BatchEnrollment().personalize( + recipient=Recipient(lms_user_id=requester.id, email_address=requester.email), + language=user_language, + user_context=user_context, + ) + + with emulate_http_request(site=site, user=requester): + ace.send(message) + + TASK_LOG.info( + "Enrollment task completion email sent via ACE to user %s (%s) for course %s. " + "Action: %s, Results: %d successful, %d failed out of %d total", + requester.username, + requester.email, + course_key, + action, + user_context["successful"], + user_context["failed"], + user_context["total_processed"], + ) diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index a17551115a59..5baee74adbbc 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -31,6 +31,7 @@ from lms.djangoapps.instructor_task.tasks_base import BaseInstructorTask from lms.djangoapps.instructor_task.tasks_helper.certs import generate_students_certificates from lms.djangoapps.instructor_task.tasks_helper.enrollments import ( + process_student_enrollment_batch, upload_inactive_enrolled_students_info_csv, upload_may_enroll_csv, upload_students_csv, @@ -359,3 +360,14 @@ def export_ora2_summary(entry_id, xblock_instance_args): action_name = gettext_noop('generated') task_fn = partial(upload_ora2_summary, xblock_instance_args) return run_main_task(entry_id, task_fn, action_name) + + +@shared_task(base=BaseInstructorTask) +@set_code_owner_attribute +def student_enrollment_batch(entry_id, xblock_instance_args): + """ + Process student enrollment/unenrollment operations in batch asynchronously. + """ + action_name = gettext_noop('processed') + task_fn = partial(process_student_enrollment_batch, xblock_instance_args) + return run_main_task(entry_id, task_fn, action_name) diff --git a/lms/djangoapps/instructor_task/tasks_helper/enrollments.py b/lms/djangoapps/instructor_task/tasks_helper/enrollments.py index 889f52a3061b..b043994a84e9 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/enrollments.py +++ b/lms/djangoapps/instructor_task/tasks_helper/enrollments.py @@ -2,23 +2,27 @@ Instructor tasks related to enrollments. """ - import logging from datetime import datetime from time import time +from typing import Any +from opaque_keys.edx.keys import CourseKey from pytz import UTC -from common.djangoapps.student.models import CourseEnrollment # pylint: disable=unused-import +from common.djangoapps.student.models import CourseEnrollment +from lms.djangoapps.instructor.utils import process_student_enrollment_batch as process_batch from lms.djangoapps.instructor_analytics.basic import ( enrolled_students_features, list_inactive_enrolled_students, list_may_enroll, ) from lms.djangoapps.instructor_analytics.csvs import format_dictlist +from lms.djangoapps.instructor_task.models import InstructorTask +from lms.djangoapps.instructor_task.notifications import send_enrollment_task_completion_email from .runner import TaskProgress -from .utils import upload_csv_to_report_store # pylint: disable=unused-import +from .utils import upload_csv_to_report_store TASK_LOG = logging.getLogger('edx.celery.task') FILTERED_OUT_ROLES = ['staff', 'instructor', 'finance_admin', 'sales_admin'] @@ -121,3 +125,96 @@ def upload_students_csv(_xblock_instance_args, _entry_id, course_id, task_input, upload_csv_to_report_store(rows, upload_filename, course_id, start_date, parent_dir=upload_parent_dir) return task_progress.update_task_state(extra_meta=current_step) + + +def process_student_enrollment_batch( + _xblock_instance_args: Any, + _entry_id: int, + course_id: str | CourseKey, + task_input: dict, + action_name: str, +) -> dict: + """ + Process a batch of student enrollment/unenrollment operations asynchronously. + + Args: + _xblock_instance_args: XBlock instance arguments (unused) + _entry_id: The primary key for the InstructorTask entry + course_id: The course identifier (string or CourseKey) + task_input: Dictionary containing: + - action: 'enroll' or 'unenroll' + - identifiers: list of student identifiers (emails or usernames) + - auto_enroll: boolean for auto-enrollment + - email_students: boolean to send enrollment emails + - reason: optional reason for enrollment change + - secure: boolean indicating if request was secure (HTTPS) + - site_id: optional site ID for notification emails + action_name: Name of the action being performed + + Returns: + dict: Task progress dictionary with results of enrollment operations + """ + instructor_task = InstructorTask.objects.get(pk=_entry_id) + start_time = time() + start_date = datetime.now(UTC) + + action = task_input.get("action") + identifiers = task_input.get("identifiers", []) + course_key = CourseKey.from_string(course_id) if isinstance(course_id, str) else course_id + total_students = len(identifiers) + task_progress = TaskProgress(action_name, total_students, start_time) + + current_step = {"step": f"Processing {action} operations for {total_students} students"} + task_progress.update_task_state(extra_meta=current_step) + + def progress_callback(current: int, total: int, results: list[dict]) -> None: + """Update task progress for enrollment batch operations.""" + task_progress.attempted = current + task_progress.succeeded = sum(1 for r in results if r.get("success", False)) + task_progress.failed = current - task_progress.succeeded + + # Update progress every 10 operations or at the end + if current % 10 == 0 or current == total: + current_step = { + "step": f"Processed {current}/{total} {action} operations", + "succeeded": task_progress.succeeded, + "failed": task_progress.failed, + } + task_progress.update_task_state(extra_meta=current_step) + + batch_result = process_batch( + request_user=instructor_task.requester, + course_key=course_key, + action=action, + identifiers=identifiers, + auto_enroll=task_input.get("auto_enroll", False), + email_students=task_input.get("email_students", False), + reason=task_input.get("reason"), + secure=task_input.get("secure", False), + progress_callback=progress_callback, + ) + + task_progress.attempted = batch_result["total_students"] + task_progress.succeeded = batch_result["successful_operations"] + task_progress.failed = batch_result["failed_operations"] + task_progress.skipped = 0 + + final_step = { + "step": f"Completed {action} batch processing", + "total_processed": batch_result["total_students"], + "successful": batch_result["successful_operations"], + "failed": batch_result["failed_operations"], + } + + CSV_FIELDS = ["identifier", "success", "state_transition", "error_type", "error_message"] + CSV_DEFAULTS = {"identifier": "", "success": False, "state_transition": "", "error_type": "", "error_message": ""} + + def extract_csv_row(result: dict) -> list[str]: + """Extract CSV row data from result dictionary.""" + return [result.get(field, CSV_DEFAULTS[field]) for field in CSV_FIELDS] + + rows = [CSV_FIELDS] + [extract_csv_row(result) for result in batch_result["results"]] + upload_csv_to_report_store(rows, "enrollment_batch_results", course_id, start_date) + send_enrollment_task_completion_email(course_key, instructor_task, action, final_step) + + return task_progress.update_task_state(extra_meta=final_step) diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 321414041a89..cbe3085077dd 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -3,7 +3,9 @@ """ import datetime +import hashlib import json +from unittest import mock from unittest.mock import MagicMock, Mock, patch from uuid import uuid4 @@ -11,6 +13,8 @@ import pytest import pytz from celery.states import FAILURE, SUCCESS +from django.http import HttpRequest +from opaque_keys.edx.keys import CourseKey from testfixtures import LogCapture from common.djangoapps.student.tests.factories import UserFactory @@ -43,6 +47,7 @@ submit_rescore_problem_for_student, submit_reset_problem_attempts_for_all_students, submit_reset_problem_attempts_in_entrance_exam, + submit_student_enrollment_batch, ) from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError from lms.djangoapps.instructor_task.data import InstructorTaskTypes @@ -51,6 +56,7 @@ export_ora2_data, export_ora2_submission_files, generate_anonymous_ids_for_course, + student_enrollment_batch, ) from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory, InstructorTaskScheduleFactory from lms.djangoapps.instructor_task.tests.test_base import ( @@ -530,3 +536,116 @@ def test_process_scheduled_tasks_expect_error(self, mock_scheduled_task): process_scheduled_instructor_tasks() log.check_present((LOG_PATH, "ERROR", expected_messages[0]),) + + +class SubmitStudentEnrollmentBatchTests(InstructorTaskCourseTestCase): + """ + Tests for the submit_student_enrollment_batch API function. + """ + + def setUp(self): + self.request = HttpRequest() + self.course_key = CourseKey.from_string("course-v1:edX+DemoX+2025") + + @mock.patch("lms.djangoapps.instructor_task.api.submit_task") + def test_basic_submission(self, mock_submit_task): + """ + Basic test with <= 5 identifiers. + Verifies: task_input, task_type, task_class, task_key. + """ + identifiers = ["u1", "u2", "username3@example.com"] + action = "enroll" + mock_submit_task.return_value = "task-result" + expected_input = { + "action": action, + "identifiers": identifiers, + "auto_enroll": True, + "email_students": False, + "reason": "test", + "secure": True, + "site_id": None, + } + + result = submit_student_enrollment_batch( + request=self.request, + course_key=self.course_key, + action=action, + identifiers=identifiers, + auto_enroll=True, + email_students=False, + reason="test", + secure=True, + ) + + self.assertEqual(result, "task-result") # noqa: PT009 + key_stub = f'{self.course_key}_{action}_{json.dumps(identifiers)}' + expected_key = hashlib.md5(key_stub.encode("utf-8")).hexdigest() + mock_submit_task.assert_called_once_with( + self.request, + InstructorTaskTypes.STUDENT_ENROLLMENT_BATCH, + student_enrollment_batch, + self.course_key, + expected_input, + expected_key, + ) + + @mock.patch("lms.djangoapps.instructor_task.api.submit_task") + def test_task_key_differs_when_tail_identifiers_differ(self, mock_submit_task): + """ + Task key must incorporate the full list so batches that share the same first IDs + are not incorrectly treated as duplicates of different work. + """ + prefix = ["u1", "u2", "u3", "u4", "u5"] + batch_a = prefix + ["tail-a"] + batch_b = prefix + ["tail-b"] + + submit_student_enrollment_batch( + request=self.request, + course_key=self.course_key, + action="unenroll", + identifiers=batch_a, + auto_enroll=False, + email_students=True, + reason=None, + secure=False, + ) + key_a = mock_submit_task.call_args[0][5] + + submit_student_enrollment_batch( + request=self.request, + course_key=self.course_key, + action="unenroll", + identifiers=batch_b, + auto_enroll=False, + email_students=True, + reason=None, + secure=False, + ) + key_b = mock_submit_task.call_args[0][5] + + key_stub_a = f"{self.course_key}_unenroll_{json.dumps(sorted(batch_a))}" + key_stub_b = f"{self.course_key}_unenroll_{json.dumps(sorted(batch_b))}" + expected_a = hashlib.md5(key_stub_a.encode("utf-8")).hexdigest() + expected_b = hashlib.md5(key_stub_b.encode("utf-8")).hexdigest() + self.assertEqual(key_a, expected_a) # noqa: PT009 + self.assertEqual(key_b, expected_b) # noqa: PT009 + self.assertNotEqual(key_a, key_b) # noqa: PT009 + + @mock.patch("lms.djangoapps.instructor_task.api.submit_task") + def test_already_running_error_is_propagated(self, mock_submit_task): + """ + submit_task may raise AlreadyRunningError; our function should not swallow it. + """ + mock_submit_task.side_effect = AlreadyRunningError("Task already running") + + with pytest.raises(AlreadyRunningError): + submit_student_enrollment_batch( + request=self.request, + course_key=self.course_key, + action="enroll", + identifiers=["john"], + auto_enroll=False, + email_students=False, + reason=None, + secure=False, + ) diff --git a/lms/static/js/instructor_dashboard/membership.js b/lms/static/js/instructor_dashboard/membership.js index ac5d63fbdc4c..0d6147c26bce 100644 --- a/lms/static/js/instructor_dashboard/membership.js +++ b/lms/static/js/instructor_dashboard/membership.js @@ -608,7 +608,9 @@ such that the value can be defined later than this assignment (file load order). this.$reason_field = this.$container.find("textarea[name='reason-field']"); this.$checkbox_autoenroll = this.$container.find("input[name='auto-enroll']"); this.$checkbox_emailstudents = this.$container.find("input[name='email-students']"); + this.$checkbox_asyncprocessing = this.$container.find("input[name='async-processing']"); this.checkbox_emailstudents_initialstate = this.$checkbox_emailstudents.is(':checked'); + this.checkbox_asyncprocessing_initialstate = this.$checkbox_asyncprocessing.is(':checked'); this.$task_response = this.$container.find('.request-response'); this.$request_response_error = this.$container.find('.request-response-error'); this.$enrollment_button.click(function(event) { @@ -624,6 +626,7 @@ such that the value can be defined later than this assignment (file load order). identifiers: batchEnroll.$identifier_input.val(), auto_enroll: batchEnroll.$checkbox_autoenroll.is(':checked'), email_students: emailStudents, + async_processing: batchEnroll.$checkbox_asyncprocessing.is(':checked'), reason: batchEnroll.$reason_field.val() }; return $.ajax({ @@ -645,6 +648,7 @@ such that the value can be defined later than this assignment (file load order). this.$identifier_input.val(''); this.$reason_field.val(''); this.$checkbox_emailstudents.attr('checked', this.checkbox_emailstudents_initialstate); + this.$checkbox_asyncprocessing.attr('checked', this.checkbox_asyncprocessing_initialstate); return this.$checkbox_autoenroll.attr('checked', true); }; @@ -655,6 +659,43 @@ such that the value can be defined later than this assignment (file load order). return this.$request_response_error.text(msg); }; + batchEnrollment.prototype.show_async_processing_message = function (dataFromServer) { + const { action: rawAction = 'process' } = dataFromServer; + + const actionsMap = { + enroll: { + text: gettext('enrollment'), + title: gettext('Enrollment Request Submitted') + }, + unenroll: { + text: gettext('unenrollment'), + title: gettext('Unenrollment Request Submitted') + }, + process: { + text: gettext('processing'), + title: gettext('Request Submitted') + } + }; + + const { text: actionText, title } = actionsMap[rawAction] || actionsMap.process; + + const message = interpolate( + gettext( + 'Your %(action)s request is being processed in the background. ' + + 'This may take several minutes to complete. You will receive an email ' + + 'notification when the process is finished.' + ), + { action: actionText }, + true + ); + + const $taskResSection = $('
', { class: 'request-res-section' }) + .append($('', { text: title })) + .append($('
+ + {% autoescape off %} + {# xss-lint: disable=django-blocktrans-missing-escape-filter #} + {% blocktrans %}Batch {{ action_name }} completed for {{ course_name }}{% endblocktrans %} + {% endautoescape %} ++ +
+ {% autoescape off %}
+ {# xss-lint: disable=django-blocktrans-missing-escape-filter #}
+ {% blocktrans %}The batch {{ action_name }} process for {{ course_name }} is complete.{% endblocktrans %}
+ {% endautoescape %}
+
+ {% autoescape off %}
+ {# xss-lint: disable=django-blocktrans-missing-escape-filter #}
+ {% blocktrans %}Out of {{ total_processed }} users:{% endblocktrans %}
+ {% endautoescape %}
+
+
+
+ {% filter force_escape %}
+ {% blocktrans asvar course_cta_text %}Download the CSV report{% endblocktrans %}
+ {% endfilter %}
+ {% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=course_url|add:"instructor#view-data_download" %}
+
|
+