Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,5 @@ docker-compose.*.yml


certs/*

.claude/
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Claude Instructions

@AGENTS.md
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 0.138.6
---------------

- Adds enrollment modes to course runs and programs (#3314)
- Expose programs on product API results, display in UI (#3322)
- Update ecommerce post-checkout code to handle program purchasing (#3304)
- Add unit tests for AdminRefundOrder view (#3313)
- Support Claude Code better (gitignore, CLAUDE.md) (#3323)

Version 0.138.5 (Released February 25, 2026)
---------------

Expand Down
84 changes: 75 additions & 9 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
CourseRunEnrollment,
CourseRunGrade,
Department,
EnrollmentMode,
PaidCourseRun,
Program,
ProgramCertificate,
Expand All @@ -71,7 +72,6 @@
enroll_in_edx_course_runs,
get_edx_api_course_detail_client,
get_edx_api_course_list_client,
get_edx_api_course_mode_client,
get_edx_course_modes,
get_edx_grades_with_users,
unenroll_edx_course_run,
Expand All @@ -90,6 +90,7 @@

if TYPE_CHECKING:
from django.db.models.query import QuerySet
from edx_api.course_detail.models import CourseMode


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -282,14 +283,19 @@ def _enroll_learner_into_associated_programs():
return successful_enrollments, edx_request_success


def create_program_enrollments(user, programs):
def create_program_enrollments(
user, programs, *, enrollment_mode=EDX_DEFAULT_ENROLLMENT_MODE
):
"""
Creates local records of a user's enrollment in programs

Args:
user (User): The user to enroll
programs (iterable of Program): The course runs to enroll in

Kwargs:
enrollment_mode (str): The mode the enrollment should be in

Returns:
list of ProgramEnrollment: A list of enrollment objects that were successfully created
"""
Expand All @@ -299,9 +305,15 @@ def create_program_enrollments(user, programs):
enrollment, created = ProgramEnrollment.all_objects.get_or_create(
user=user,
program=program,
defaults={
"enrollment_mode": enrollment_mode,
},
)
if not created and not enrollment.active:
enrollment.reactivate_and_save()

if not created and enrollment.enrollment_mode != enrollment_mode:
enrollment.update_mode_and_save(enrollment_mode)
except: # pylint: disable=bare-except # noqa: PERF203, E722
mail_api.send_enrollment_failure_message(
user, program, details=format_exc()
Expand Down Expand Up @@ -671,24 +683,76 @@ def sync_course_runs(runs):
return success_count, failure_count


def pull_course_modes(run: CourseRun) -> tuple[list[CourseMode], int]:
"""
Pull the course modes for the given run and store them locally.

We only generally care about "audit" and "verified", but this will store any
others that happen to be configured as well.

Args:
run (CourseRun): CourseRun object to retrieve modes for
Returns:
tuple of edX modes retrieved and count of modes created
"""

modes = get_edx_course_modes(course_id=run.courseware_id)
new_mode_count = 0

for edx_mode in modes:
mxo_mode, created = EnrollmentMode.objects.get_or_create(
mode_slug=edx_mode.mode_slug,
defaults={
"mode_display_name": edx_mode.mode_display_name,
"requires_payment": edx_mode.mode_slug == EDX_ENROLLMENT_VERIFIED_MODE,
},
)

run.enrollment_modes.add(mxo_mode)

if created:
new_mode_count += 1

run.save()

return (modes, new_mode_count)


def check_course_modes(run: CourseRun) -> tuple[bool, bool]:
"""
Check that the course has the course modes we expect.

We expect an `audit` and a `verified` mode in our course runs. If these don't
exist for the given course, this will create them.
exist for the given course, this will create them on both the edX side and
on the MITx Online side. (If the default audit and verified modes don't exist,
then we'll make those too.)

Args:
runs ([CourseRun]): list of CourseRun objects.
runs (CourseRun): CourseRun object to check

Returns:
(audit_created: bool, verified_created: bool): Tuple of mode status - true for created, false for found
"""

modes = get_edx_course_modes(course_id=run.courseware_id)
modes, _ = pull_course_modes(run)

found_audit, found_verified = (False, False)

mxo_audit_mode, _ = EnrollmentMode.objects.get_or_create(
mode_slug=EDX_ENROLLMENT_AUDIT_MODE,
defaults={
"mode_display_name": EDX_ENROLLMENT_AUDIT_MODE,
"requires_payment": False,
},
)
mxo_verified_mode, _ = EnrollmentMode.objects.get_or_create(
mode_slug=EDX_ENROLLMENT_VERIFIED_MODE,
defaults={
"mode_display_name": EDX_ENROLLMENT_VERIFIED_MODE,
"requires_payment": True,
},
)

for mode in modes:
if mode.mode_slug == EDX_ENROLLMENT_AUDIT_MODE:
found_audit = True
Expand All @@ -705,6 +769,7 @@ def check_course_modes(run: CourseRun) -> tuple[bool, bool]:
expiration_datetime=None,
currency="USD",
)
run.enrollment_modes.add(mxo_audit_mode)

if not found_verified:
create_edx_course_mode(
Expand All @@ -716,6 +781,10 @@ def check_course_modes(run: CourseRun) -> tuple[bool, bool]:
min_price=10,
expiration_datetime=run.upgrade_deadline if run.upgrade_deadline else None,
)
run.enrollment_modes.add(mxo_verified_mode)

if not (found_audit or found_verified):
run.save()

# these are created flags, not found flags
return (not found_audit, not found_verified)
Expand All @@ -731,17 +800,14 @@ def sync_course_mode(runs: list[CourseRun]) -> list[int]:
Returns:
[int, int]: Count of successful and failed operations
"""
api_client = get_edx_api_course_mode_client()

success_count = 0
failure_count = 0

# Iterate all eligible runs and sync if possible
for run in runs:
try:
course_modes = api_client.get_course_modes(
course_id=run.courseware_id,
)
course_modes, _ = pull_course_modes(run)
except HTTPError as e: # noqa: PERF203
failure_count += 1
if e.response.status_code == HTTP_404_NOT_FOUND:
Expand Down
85 changes: 82 additions & 3 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
manage_program_certificate_access,
override_user_grade,
process_course_run_grade_certificate,
pull_course_modes,
sync_course_mode,
sync_course_runs,
)
Expand All @@ -73,6 +74,7 @@
# pylint: disable=redefined-outer-name
from courses.models import (
CourseRunEnrollment,
EnrollmentMode,
PaidCourseRun,
ProgramCertificate,
ProgramEnrollment,
Expand Down Expand Up @@ -428,7 +430,14 @@ def test_create_run_enrollments_creation_fail(mocker, user):
assert edx_request_success is True


def test_create_program_enrollments(user):
@pytest.mark.parametrize(
"mode",
[
EDX_ENROLLMENT_AUDIT_MODE,
EDX_ENROLLMENT_VERIFIED_MODE,
],
)
def test_create_program_enrollments(user, mode):
"""
create_program_enrollments should create or reactivate local enrollment records
"""
Expand All @@ -443,8 +452,7 @@ def test_create_program_enrollments(user):
)

successful_enrollments = create_program_enrollments(
user,
programs,
user, programs, enrollment_mode=mode
)
assert len(successful_enrollments) == num_programs
enrollments = ProgramEnrollment.objects.order_by("program__id").all()
Expand All @@ -453,6 +461,7 @@ def test_create_program_enrollments(user):
assert enrollment.change_status is None
assert enrollment.active is True
assert enrollment.program == program
assert enrollment.enrollment_mode == mode


def test_create_program_enrollments_creation_fail(mocker, user):
Expand Down Expand Up @@ -1959,6 +1968,9 @@ def test_check_course_modes(mocker, audit_exists, verified_exists):

run = CourseRunFactory.create()

# clear all of these - the appropriate ones should be created later
EnrollmentMode.objects.all().delete()

return_modes = []
audit_mode = CourseMode(
{
Expand Down Expand Up @@ -1998,6 +2010,13 @@ def test_check_course_modes(mocker, audit_exists, verified_exists):
assert audit_created != audit_exists
assert verified_created != verified_exists

assert (
EnrollmentMode.objects.filter(
mode_slug__in=[EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE]
).count()
== 2
)

if not audit_exists:
mocked_create_mode.assert_any_call(
course_id=run.courseware_id,
Expand All @@ -2008,6 +2027,7 @@ def test_check_course_modes(mocker, audit_exists, verified_exists):
expiration_datetime=None,
min_price=0,
)
assert run.enrollment_modes.filter(mode_slug=EDX_ENROLLMENT_AUDIT_MODE).exists()

if not verified_exists:
mocked_create_mode.assert_any_call(
Expand All @@ -2019,6 +2039,9 @@ def test_check_course_modes(mocker, audit_exists, verified_exists):
expiration_datetime=str(run.upgrade_deadline),
min_price=10,
)
assert run.enrollment_modes.filter(
mode_slug=EDX_ENROLLMENT_VERIFIED_MODE
).exists()


@pytest.mark.parametrize(
Expand Down Expand Up @@ -2750,3 +2773,59 @@ def test_deactivate_run_enrollment_feature_flag(
assert result.change_status == ENROLL_CHANGE_STATUS_UNENROLLED
else:
assert result is None


@pytest.mark.parametrize(
"no_initial_modes",
[
True,
False,
],
)
def test_pull_course_modes(mocker, no_initial_modes):
"""Test that we can pull the course modes from edX and store them."""

run = CourseRunFactory.create()

if no_initial_modes:
EnrollmentMode.objects.all().delete()

return_modes = [
CourseMode(
{
"course_id": run.courseware_id,
"mode_slug": "audit",
"mode_display_name": "Audit",
}
),
CourseMode(
{
"course_id": run.courseware_id,
"mode_slug": "verified",
"mode_display_name": "Verified",
}
),
CourseMode(
{
"course_id": run.courseware_id,
"mode_slug": "bonus",
"mode_display_name": "Bonus Mode!",
}
),
]

mocked_get_modes = mocker.patch(
"edx_api.course_detail.CourseModes.get_course_modes", return_value=return_modes
)

returned_modes, created_count = pull_course_modes(run)

assert created_count == 3 if no_initial_modes else 1
mocked_get_modes.assert_called()

expected_modes = [
returned_mode
for returned_mode in returned_modes
if returned_mode.mode_slug in ["audit", "verified", "bonus"]
]
assert len(expected_modes) == 3
Loading
Loading