Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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)
---------------

Expand Down
46 changes: 46 additions & 0 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
204 changes: 204 additions & 0 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"""

Expand Down
9 changes: 8 additions & 1 deletion ecommerce/hooks/process_transaction_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down