diff --git a/RELEASE.rst b/RELEASE.rst index 021274867a..dcb456a09e 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,11 @@ Release Notes ============= +Version 1.160.3 +--------------- + +- Upgrade audit runs after program purchase (#3770) + Version 1.160.2 (Released July 20, 2026) --------------- diff --git a/courses/api.py b/courses/api.py index 21966296aa..8d3cfd946c 100644 --- a/courses/api.py +++ b/courses/api.py @@ -350,6 +350,52 @@ def create_program_enrollments( return successful_enrollments +def upgrade_audit_run_enrollments_for_program_purchase(user, program): + """ + When a user purchases a program, upgrade any eligible audit-track course run + enrollments in the program's courses to verified track. + + A run is eligible if: it is live, supports verified enrollment mode, the + upgrade deadline has not passed (or there is no deadline), and the user + has an active audit-track enrollment in it. + + Verified-track enrollments are left unchanged. If the user has a mix of + verified and audit enrollments, only the audit-track ones are upgraded. + + Args: + user (User): The user who purchased the program + program (Program): The program that was purchased + + Returns: + list of CourseRunEnrollment: Enrollments that were successfully upgraded + """ + now = now_in_utc() + + eligible_runs = list( + CourseRun.objects.filter( + course__in_programs__program=program, + live=True, + enrollment_modes__mode_slug=EDX_ENROLLMENT_VERIFIED_MODE, + enrollments__user=user, + enrollments__enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + enrollments__active=True, + ) + .filter(Q(upgrade_deadline__isnull=True) | Q(upgrade_deadline__gt=now)) + .distinct() + ) + + if not eligible_runs: + return [] + + upgraded_enrollments, _ = create_run_enrollments( + user, + eligible_runs, + mode=EDX_ENROLLMENT_VERIFIED_MODE, + keep_failed_enrollments=True, + ) + return upgraded_enrollments + + def downgrade_learner(enrollment): """ Downgrades given enrollment from verified to audit. diff --git a/courses/api_test.py b/courses/api_test.py index a62efcdbc5..bd1f977d07 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -57,6 +57,7 @@ pull_course_modes, sync_course_mode, sync_course_runs, + upgrade_audit_run_enrollments_for_program_purchase, upgrade_program_enrollment_if_eligible, ) from courses.constants import ( @@ -655,6 +656,209 @@ def test_create_program_enrollments_creation_fail(mocker, user): assert successful_enrollments == [enrollment] +class TestUpgradeAuditRunEnrollmentsForProgramPurchase: + """Tests for upgrade_audit_run_enrollments_for_program_purchase""" + + @pytest.fixture + def program_setup(self, user, program_with_empty_requirements): # noqa: F811 + """Set up a program with a course run the user is enrolled in.""" + program = program_with_empty_requirements + run = CourseRunFactory.create() + program.add_requirement(run.course) + return SimpleNamespace(program=program, run=run, user=user) + + def test_upgrades_audit_enrollment(self, mocker, program_setup): + """Eligible audit-track enrollments are upgraded to verified.""" + CourseRunEnrollmentFactory.create( + user=program_setup.user, + run=program_setup.run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert len(result) == 1 + enrollment = result[0] + enrollment.refresh_from_db() + assert enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE + + def test_skips_verified_enrollment(self, mocker, program_setup): + """Existing verified-track enrollments are left unchanged.""" + CourseRunEnrollmentFactory.create( + user=program_setup.user, + run=program_setup.run, + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert result == [] + + def test_skips_inactive_enrollment(self, mocker, program_setup): + """Inactive audit enrollments are not upgraded.""" + CourseRunEnrollmentFactory.create( + user=program_setup.user, + run=program_setup.run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=False, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert result == [] + + def test_skips_past_upgrade_deadline(self, mocker, program_setup, dates): + """Runs whose upgrade deadline has passed are skipped.""" + program_setup.run.upgrade_deadline = dates.past_10_days + program_setup.run.save() + CourseRunEnrollmentFactory.create( + user=program_setup.user, + run=program_setup.run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert result == [] + + def test_upgrades_when_no_upgrade_deadline(self, mocker, program_setup): + """Runs with no upgrade deadline are eligible.""" + program_setup.run.upgrade_deadline = None + program_setup.run.save() + CourseRunEnrollmentFactory.create( + user=program_setup.user, + run=program_setup.run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert len(result) == 1 + + def test_skips_non_live_run(self, mocker, program_setup): + """Runs that are not live are skipped.""" + program_setup.run.live = False + program_setup.run.save() + CourseRunEnrollmentFactory.create( + user=program_setup.user, + run=program_setup.run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert result == [] + + def test_skips_audit_only_run(self, mocker, program_setup): + """Runs without a verified enrollment mode are skipped.""" + from courses.factories import EnrollmentModeFactory # noqa: PLC0415 + + program_setup.run.enrollment_modes.set( + [EnrollmentModeFactory(mode_slug=EDX_ENROLLMENT_AUDIT_MODE)] + ) + CourseRunEnrollmentFactory.create( + user=program_setup.user, + run=program_setup.run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert result == [] + + def test_upgrades_only_program_courses(self, mocker, user, program_setup): + """Only runs belonging to the purchased program's courses are upgraded.""" + unrelated_run = CourseRunFactory.create() + CourseRunEnrollmentFactory.create( + user=user, + run=unrelated_run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + + result = upgrade_audit_run_enrollments_for_program_purchase( + user, program_setup.program + ) + + assert result == [] + + def test_returns_empty_when_no_enrollments(self, mocker, program_setup): + """Returns empty list when user has no course run enrollments in the program.""" + mocker.patch("courses.api.enroll_in_edx_course_runs") + + result = upgrade_audit_run_enrollments_for_program_purchase( + program_setup.user, program_setup.program + ) + + assert result == [] + + def test_mixed_enrollments_upgrades_only_audit( + self, + mocker, + user, + program_with_empty_requirements, # noqa: F811 + ): + """When the user has a mix of audit and verified enrollments, only audit ones are upgraded.""" + program = program_with_empty_requirements + audit_run = CourseRunFactory.create() + verified_run = CourseRunFactory.create() + program.add_requirement(audit_run.course) + program.add_requirement(verified_run.course) + + CourseRunEnrollmentFactory.create( + user=user, + run=audit_run, + enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE, + active=True, + ) + verified_enrollment = CourseRunEnrollmentFactory.create( + user=user, + run=verified_run, + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + active=True, + ) + mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + + result = upgrade_audit_run_enrollments_for_program_purchase(user, program) + + assert len(result) == 1 + assert result[0].run == audit_run + verified_enrollment.refresh_from_db() + assert verified_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE + + class TestDeactivateEnrollments: """Test cases for functions that deactivate enrollments""" diff --git a/ecommerce/hooks/process_transaction_line.py b/ecommerce/hooks/process_transaction_line.py index daf5e80450..ad4ff683d0 100644 --- a/ecommerce/hooks/process_transaction_line.py +++ b/ecommerce/hooks/process_transaction_line.py @@ -42,7 +42,10 @@ def _create_courserun_enrollment(line) -> str | None: def _create_program_enrollment(line) -> str | None: """Create a program enrollment for the line, if we need to.""" - from courses.api import create_program_enrollments # noqa: PLC0415 + from courses.api import ( # noqa: PLC0415 + create_program_enrollments, + upgrade_audit_run_enrollments_for_program_purchase, + ) if not line.order.is_fulfilled: return None @@ -63,6 +66,10 @@ def _create_program_enrollment(line) -> str | None: user=line.order.purchaser, program=purchased_program, order=line.order ) + upgrade_audit_run_enrollments_for_program_purchase( + line.order.purchaser, purchased_program + ) + log.debug("Created program enrollment for %s", purchased_program) diff --git a/main/settings.py b/main/settings.py index b74f46972b..b5a9e365d1 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.160.2" +VERSION = "1.160.3" log = logging.getLogger()