diff --git a/courses/api.py b/courses/api.py index 247d84623..7ef661172 100644 --- a/courses/api.py +++ b/courses/api.py @@ -35,6 +35,7 @@ ProgramCertificate, ProgramEnrollment, ProgramRequirement, + PaidCourseRun, ) from courses.tasks import subscribe_edx_course_emails from courses.utils import ( @@ -242,6 +243,7 @@ def create_run_enrollments( user, runs, *, + change_status=None, keep_failed_enrollments=False, mode=EDX_DEFAULT_ENROLLMENT_MODE, ): @@ -252,6 +254,7 @@ def create_run_enrollments( Args: user (User): The user to enroll runs (iterable of CourseRun): The course runs to enroll in + change_status (str): The status of the enrollment keep_failed_enrollments: (boolean): If True, keeps the local enrollment record in the database even if the enrollment fails in edX. mode (str): The course mode @@ -315,7 +318,7 @@ def _enroll_learner_into_associated_programs(): user=user, run=run, defaults=dict( - change_status=None, + change_status=change_status, edx_enrolled=edx_request_success, enrollment_mode=mode, ), @@ -499,6 +502,10 @@ def defer_enrollment( from_enrollment = CourseRunEnrollment.all_objects.get( user=user, run__courseware_id=from_courseware_id ) + downgraded_enrollments = [] + already_deferred_from = ( + from_enrollment.change_status == ENROLL_CHANGE_STATUS_DEFERRED + ) to_run = ( CourseRun.objects.get(courseware_id=to_courseware_id) if to_courseware_id @@ -509,7 +516,8 @@ def defer_enrollment( downgraded_enrollments, _ = create_run_enrollments( user=user, runs=[from_enrollment.run], - keep_failed_enrollments=True, + change_status=ENROLL_CHANGE_STATUS_DEFERRED, + keep_failed_enrollments=keep_failed_enrollments, mode=EDX_ENROLLMENT_AUDIT_MODE, ) return downgraded_enrollments, None @@ -538,19 +546,19 @@ def defer_enrollment( from_enrollment.run.course.title, to_run.course.title ) ) - already_unenrolled_from = ( - from_enrollment.change_status == ENROLL_CHANGE_STATUS_DEFERRED - ) - if already_unenrolled_from: + + if already_deferred_from: # check if user was already enrolled in verified track to_enrollments = CourseRunEnrollment.objects.filter( user=user, run=to_run, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE ).first() - return from_enrollment, to_enrollments + if to_enrollments: + return from_enrollment, to_enrollments to_enrollments, enroll_success = create_run_enrollments( - user, - [to_run], + user=user, + runs=[to_run], + change_status=None, keep_failed_enrollments=keep_failed_enrollments, mode=EDX_ENROLLMENT_VERIFIED_MODE, ) @@ -560,18 +568,23 @@ def defer_enrollment( to_run ) ) - if not already_unenrolled_from: - from_enrollment = deactivate_run_enrollment( - from_enrollment, - ENROLL_CHANGE_STATUS_DEFERRED, + if not already_deferred_from: + downgraded_enrollments, enroll_success = create_run_enrollments( + user=user, + runs=[from_enrollment.run], + change_status=ENROLL_CHANGE_STATUS_DEFERRED, keep_failed_enrollments=keep_failed_enrollments, + mode=EDX_ENROLLMENT_AUDIT_MODE, ) - if from_enrollment is None: + if not enroll_success and not keep_failed_enrollments: raise Exception( - "Api call to deactivate enrollment on edX " + "Api call to change enrollment mode to audit on edX " "was not successful for course run '{}'".format(from_courseware_id) ) - return from_enrollment, first_or_none(to_enrollments) + if PaidCourseRun.fulfilled_paid_course_run_exists(user, from_enrollment.run): + from_enrollment.change_payment_to_run(to_run) + + return first_or_none(downgraded_enrollments), first_or_none(to_enrollments) def ensure_course_run_grade(user, course_run, edx_grade, should_update=False): diff --git a/courses/api_test.py b/courses/api_test.py index c2384b768..df65af023 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -1,7 +1,7 @@ """Courses API tests""" from datetime import timedelta from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import Mock, call, patch import factory import pytest @@ -56,6 +56,7 @@ ProgramEnrollment, ProgramRequirement, ProgramRequirementNodeType, + PaidCourseRun, ) from ecommerce.factories import LineFactory, OrderFactory, ProductFactory from ecommerce.models import Order @@ -769,65 +770,81 @@ def test_deactivate_run_enrollment_line_does_not_exist( @pytest.mark.parametrize("keep_failed_enrollments", [True, False]) @pytest.mark.parametrize("edx_enroll_succeeds", [True, False]) -@pytest.mark.parametrize("edx_deactivate_succeeds", [True, False]) +@pytest.mark.parametrize("edx_downgrade_succeeds", [True, False]) def test_defer_enrollment( mocker, course, keep_failed_enrollments, edx_enroll_succeeds, - edx_deactivate_succeeds, + edx_downgrade_succeeds, ): """ - defer_enrollment should deactivate a user's existing enrollment and create an enrollment in another - course run + defer_enrollment should downgrade current enrollment to audit and create a verified enrollment in another + course run, and update PaidCourseRun to the new run """ course_runs = CourseRunFactory.create_batch(3, course=course) existing_enrollment = CourseRunEnrollmentFactory.create(run=course_runs[0]) - target_run = course_runs[1] - mock_new_enrollment = mocker.Mock() - patched_create_enrollments = mocker.patch( - "courses.api.create_run_enrollments", - autospec=True, - return_value=( - [mock_new_enrollment if edx_enroll_succeeds else None], - edx_enroll_succeeds, - ), + fulfilled_order = OrderFactory.create(state=Order.STATE.FULFILLED) + paid_course_run = PaidCourseRun.objects.create( + user=existing_enrollment.user, course_run=course_runs[0], order=fulfilled_order ) - patched_deactivate_enrollments = mocker.patch( - "courses.api.deactivate_run_enrollment", - autospec=True, - return_value=existing_enrollment - if (keep_failed_enrollments or edx_deactivate_succeeds) - else None, - ) - if keep_failed_enrollments or (edx_enroll_succeeds and edx_deactivate_succeeds): - returned_from_enrollment, returned_to_enrollment = defer_enrollment( - existing_enrollment.user, - existing_enrollment.run.courseware_id, - course_runs[1].courseware_id, - keep_failed_enrollments=keep_failed_enrollments, - ) - assert returned_from_enrollment == patched_deactivate_enrollments.return_value - assert returned_to_enrollment == patched_create_enrollments.return_value[0][0] - patched_create_enrollments.assert_called_once_with( - existing_enrollment.user, - [target_run], - keep_failed_enrollments=keep_failed_enrollments, - mode=EDX_ENROLLMENT_VERIFIED_MODE, - ) - patched_deactivate_enrollments.assert_called_once_with( - existing_enrollment, - ENROLL_CHANGE_STATUS_DEFERRED, - keep_failed_enrollments=keep_failed_enrollments, - ) - else: - with pytest.raises(Exception): - defer_enrollment( + + new_enrollment = CourseRunEnrollmentFactory.create(run=course_runs[1]) + return_values = [ + ([new_enrollment] if edx_enroll_succeeds else [], edx_enroll_succeeds), + ( + [existing_enrollment] if edx_downgrade_succeeds else [], + edx_downgrade_succeeds, + ), + ] + + with patch( + "courses.api.create_run_enrollments", autospec=True + ) as patched_create_enrollments: + patched_create_enrollments.side_effect = return_values + + if keep_failed_enrollments or (edx_enroll_succeeds and edx_downgrade_succeeds): + returned_from_enrollment, returned_to_enrollment = defer_enrollment( existing_enrollment.user, existing_enrollment.run.courseware_id, course_runs[1].courseware_id, keep_failed_enrollments=keep_failed_enrollments, ) + assert patched_create_enrollments.call_count == 2 + assert returned_from_enrollment == ( + existing_enrollment if edx_downgrade_succeeds else None + ) + assert returned_to_enrollment == ( + new_enrollment if edx_enroll_succeeds else None + ) + patched_create_enrollments.assert_has_calls( + [ + call( + user=existing_enrollment.user, + runs=[course_runs[1]], + change_status=None, + keep_failed_enrollments=keep_failed_enrollments, + mode=EDX_ENROLLMENT_VERIFIED_MODE, + ), + call( + user=existing_enrollment.user, + runs=[existing_enrollment.run], + change_status=ENROLL_CHANGE_STATUS_DEFERRED, + keep_failed_enrollments=keep_failed_enrollments, + mode=EDX_ENROLLMENT_AUDIT_MODE, + ), + ] + ) + paid_course_run.refresh_from_db() + assert paid_course_run.course_run == course_runs[1] + else: + with pytest.raises(Exception): + defer_enrollment( + existing_enrollment.user, + existing_enrollment.run.courseware_id, + course_runs[1].courseware_id, + keep_failed_enrollments=keep_failed_enrollments, + ) def test_defer_enrollment_validation(mocker, user): @@ -886,16 +903,36 @@ def test_defer_enrollment_validation(mocker, user): user, enrollments[0].run.courseware_id, enrollments[1].run.courseware_id, + keep_failed_enrollments=True, force=True, ) - assert patched_create_enrollments.call_count == 1 + assert patched_create_enrollments.call_count == 2 + patched_create_enrollments.assert_has_calls( + [ + call( + user=user, + runs=[enrollments[1].run], + change_status=None, + keep_failed_enrollments=True, + mode=EDX_ENROLLMENT_VERIFIED_MODE, + ), + call( + user=user, + runs=[enrollments[0].run], + change_status=ENROLL_CHANGE_STATUS_DEFERRED, + keep_failed_enrollments=True, + mode=EDX_ENROLLMENT_AUDIT_MODE, + ), + ] + ) + defer_enrollment( user, enrollments[1].run.courseware_id, enrollments[2].run.courseware_id, force=True, ) - assert patched_create_enrollments.call_count == 2 + assert patched_create_enrollments.call_count == 4 @pytest.mark.parametrize( diff --git a/courses/models.py b/courses/models.py index ec1538921..cabf2bb9f 100644 --- a/courses/models.py +++ b/courses/models.py @@ -1186,6 +1186,23 @@ def deactivate_and_save(self, change_status, no_user=False): return super().deactivate_and_save(change_status, no_user) + def change_payment_to_run(self, to_run): + """ + During a deferral process, if user has paid for this run + we can change the payment to another run + """ + # Due to circular dependancy importing locally + from ecommerce.models import Order + + paid_run = PaidCourseRun.objects.filter( + user=self.user, + course_run=self.run, + order__state=Order.STATE.FULFILLED, + ).first() + if paid_run: + paid_run.course_run = to_run + paid_run.save() + def to_dict(self): return {**super().to_dict(), "text_id": self.run.courseware_id} diff --git a/courses/models_test.py b/courses/models_test.py index d9a15624d..45fd2e92e 100644 --- a/courses/models_test.py +++ b/courses/models_test.py @@ -32,8 +32,10 @@ ProgramRequirement, ProgramRequirementNodeType, limit_to_certificate_pages, + PaidCourseRun, ) -from ecommerce.factories import ProductFactory +from ecommerce.factories import ProductFactory, OrderFactory +from ecommerce.models import Order from main.test_utils import format_as_iso8601 from users.factories import UserFactory @@ -351,6 +353,25 @@ def test_deactivate_and_save(): enrollment.change_status = ENROLL_CHANGE_STATUS_REFUNDED +def test_change_payment_to_run(): + """Test that the change_payment_to_run updates the obj to new run""" + user = UserFactory.create() + course_run_enrollment = CourseRunEnrollmentFactory.create( + user=user, active=True, change_status=None + ) + + fulfilled_order = OrderFactory.create(purchaser=user, state=Order.STATE.FULFILLED) + paid_course_run = PaidCourseRun.objects.create( + user=user, + course_run=course_run_enrollment.run, + order=fulfilled_order, + ) + new_run = CourseRunFactory.create() + course_run_enrollment.change_payment_to_run(new_run) + paid_course_run.refresh_from_db() + assert paid_course_run.course_run == new_run + + @pytest.mark.parametrize( "readable_id_value", ["somevalue", "some-value", "some_value", "some+value", "some:value"], diff --git a/sheets/deferrals_plugin.py b/sheets/deferrals_plugin.py index 337bfae83..973a35111 100644 --- a/sheets/deferrals_plugin.py +++ b/sheets/deferrals_plugin.py @@ -19,6 +19,7 @@ def deferrals_process_request( user, from_courseware_id, to_courseware_id, + keep_failed_enrollments=True, force=True, ) if to_courseware_id and not to_enrollment: