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.143.2
---------------

- Update verified program course enrollments API to allow for intermediate programs (#3418)

Version 1.143.1 (Released March 24, 2026)
---------------

Expand Down
10 changes: 8 additions & 2 deletions courses/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,14 @@ class ProgramEnrollmentAdmin(AuditableModelAdmin):
"program__readable_id",
"program__title",
]
list_filter = ["active", "change_status"]
list_display = ("id", "get_user_email", "get_program_readable_id", "change_status")
list_filter = ["active", "enrollment_mode", "change_status"]
list_display = (
"id",
"get_user_email",
"get_program_readable_id",
"enrollment_mode",
"change_status",
)
raw_id_fields = (
"user",
"program",
Expand Down
5 changes: 5 additions & 0 deletions courses/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Exceptions for the courses API."""


class EnrollmentCreationFailedError(Exception):
"""Error when the create_run_enrollments fails."""
20 changes: 20 additions & 0 deletions courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,26 @@ def elective_courses(self) -> list:
"""
return self._courses_with_requirements_data["elective_courses"]

@cached_property
def program_nodes(self):
"""
Returns the programs that are associated with this program via the
requirements tree.

Returns:
- list of Program: programs that are electives
"""
return [
req.required_program
for req in ProgramRequirement.objects.filter(
node_type=ProgramRequirementNodeType.PROGRAM,
program=self,
required_program__isnull=False,
)
.select_related("required_program")
.all()
]

@property
def required_programs(self):
"""
Expand Down
2 changes: 1 addition & 1 deletion courses/urls/v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
urlpatterns = [
*router.urls,
path(
r"verified_program_enrollments/<str:program_id>/<str:courserun_id>/",
r"verified_program_enrollments/<str:courserun_id>/",
v2.add_verified_program_course_enrollment,
name="add_verified_program_course_enrollment",
),
Expand Down
258 changes: 192 additions & 66 deletions courses/views/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@
)
from rest_framework.response import Response

from courses.api import create_run_enrollments, deactivate_run_enrollment
from courses.api import (
create_program_enrollments,
create_run_enrollments,
deactivate_run_enrollment,
)
from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED
from courses.exceptions import EnrollmentCreationFailedError
from courses.models import (
Course,
CourseRun,
Expand Down Expand Up @@ -74,6 +79,7 @@
from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE

log = logging.getLogger(__name__)
VPE_MAX_PROGRAMS = 2


class Pagination(PageNumberPagination):
Expand Down Expand Up @@ -659,25 +665,83 @@ def destroy(self, request, *args, **kwargs): # noqa: ARG002
return Response(status=status.HTTP_204_NO_CONTENT)


def _create_course_enrollment_from_program(request, courserun_id, program_enrollment):
"""Create the course enrollment based on the specified program enrollment."""

if CourseRunEnrollment.objects.filter(
run__courseware_id=courserun_id,
user=request.user,
enrollment_mode=program_enrollment.enrollment_mode,
).exists():
# Learner already has a matching enrollment, so nothing to do.
return Response(status=status.HTTP_204_NO_CONTENT)

run = CourseRun.objects.filter(courseware_id=courserun_id).get()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A CourseRun.DoesNotExist exception is not caught when querying for a course run, leading to an unhandled exception and a 500 error.
Severity: HIGH

Suggested Fix

Wrap the CourseRun.objects.get() call in a try...except CourseRun.DoesNotExist block. In the except block, raise a rest_framework.exceptions.NotFound exception with a descriptive message to ensure a proper 404 response is returned to the client.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: courses/views/v2/__init__.py#L679

Potential issue: A call to `CourseRun.objects.get()` is made without wrapping it in a
`try...except` block to handle the `CourseRun.DoesNotExist` exception. If an API request
is made with a `courserun_id` that does not exist in the database, this line will raise
an unhandled `DoesNotExist` exception. Neither the custom exception handler nor the
default DRF handler is configured to convert this specific exception into a 404
response. Consequently, a request with an invalid ID will cause an HTTP 500 Internal
Server Error instead of the expected 404 Not Found.

Did we get this right? 👍 / 👎 to inform future reviews.


# Check if:
# .. the program enrollment is audit,
# .. if the run isn't in the program,
# .. if the run is an elective, and if the learner already has enough verified elective enrollments
# and create an audit enrollment if any of these are true.

if (
(program_enrollment.enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE)
or (
run.course
not in [
*program_enrollment.program.required_courses,
*program_enrollment.program.elective_courses,
]
)
or (
run.course not in program_enrollment.program.required_courses
and (
CourseRunEnrollment.objects.filter(
run__course__in=program_enrollment.program.elective_courses,
user=request.user,
active=True,
enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE,
).count()
>= (
program_enrollment.program.minimum_elective_courses_requirement or 1
)
)
)
):
# Audit enrollments just get created, regardless of whether or not
# the course is an elective.
enrollments, _ = create_run_enrollments(
request.user,
[run],
mode=EDX_ENROLLMENT_AUDIT_MODE,
keep_failed_enrollments=True,
)
if len(enrollments) == 0:
raise EnrollmentCreationFailedError
Comment on lines +719 to +720
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The custom EnrollmentCreationFailedError is not handled, causing an unhandled exception and a 500 error instead of a graceful API response.
Severity: HIGH

Suggested Fix

Modify EnrollmentCreationFailedError to inherit from rest_framework.exceptions.APIException. Set its status_code to an appropriate value, such as 400_BAD_REQUEST or 404_NOT_FOUND, to ensure DRF's exception handler automatically converts it into a proper HTTP response.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: courses/views/v2/__init__.py#L719-L720

Potential issue: The custom exception `EnrollmentCreationFailedError` inherits from
Python's base `Exception` rather than a DRF `APIException`. When this error is raised,
for instance when an audit enrollment creation fails, it is not caught by any
`try...except` blocks. The custom DRF exception handler delegates to the default
handler, which does not process non-`APIException` types. This results in an unhandled
exception, causing the API to return an HTTP 500 Internal Server Error to the client
instead of a more appropriate 4xx error with a meaningful message, violating the API's
error handling contract.

Did we get this right? 👍 / 👎 to inform future reviews.

return Response(
CourseRunEnrollmentSerializer(enrollments[0]).data,
status=status.HTTP_201_CREATED,
)

# Everything checks out for a verified enrollment, so generate one.
# This requires generating an order.

enrollment = create_verified_program_course_run_enrollment(
request, run, program_enrollment.program
)

return Response(
CourseRunEnrollmentSerializer(enrollment).data,
status=status.HTTP_201_CREATED,
)


@extend_schema(
parameters=[
OpenApiParameter(
"program_id",
str,
OpenApiParameter.PATH,
description="Readable ID for the program.",
),
OpenApiParameter(
"courserun_id",
str,
OpenApiParameter.PATH,
description="Readable ID for the course run to enroll in.",
),
],
request=None,
request=list[str],
responses={
status.HTTP_201_CREATED: CourseRunEnrollmentSerializer,
status.HTTP_204_NO_CONTENT: None,
status.HTTP_400_BAD_REQUEST: None,
status.HTTP_404_NOT_FOUND: None,
},
)
Expand All @@ -689,7 +753,7 @@ def destroy(self, request, *args, **kwargs): # noqa: ARG002
IsAuthenticated,
]
)
def add_verified_program_course_enrollment(request, program_id: str, courserun_id: str):
def add_verified_program_course_enrollment(request, courserun_id: str):
"""
Create a program-related course enrollment for the learner.

Expand All @@ -703,69 +767,131 @@ def add_verified_program_course_enrollment(request, program_id: str, courserun_i
the upgrade separately.)
"""

try:
program_enrollment = ProgramEnrollment.objects.filter(
program__readable_id=program_id, user=request.user
).get()
except ProgramEnrollment.DoesNotExist:
# Learner isn't in the program so abort.
return Response(status=status.HTTP_404_NOT_FOUND)
program_ids = request.data

if CourseRunEnrollment.objects.filter(
run__courseware_id=courserun_id,
user=request.user,
enrollment_mode=program_enrollment.enrollment_mode,
).exists():
# Learner already has a matching enrollment, so nothing to do.
return Response(status=status.HTTP_204_NO_CONTENT)
if (
not isinstance(program_ids, list)
or len(program_ids) == 0
or len(program_ids) > VPE_MAX_PROGRAMS
):
return Response(status=status.HTTP_400_BAD_REQUEST)

run = CourseRun.objects.filter(courseware_id=courserun_id).get()
programs = Program.objects.filter(readable_id__in=program_ids).all()

if program_enrollment.enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE:
# Audit enrollments just get created, regardless of whether or not
# the course is an elective.
enrollments, _ = create_run_enrollments(
request.user,
[run],
mode=EDX_ENROLLMENT_AUDIT_MODE,
keep_failed_enrollments=True,
program_enrollments = (
ProgramEnrollment.objects.prefetch_related("program")
.filter(
program__in=programs,
user=request.user,
)
return Response(
CourseRunEnrollmentSerializer(enrollments[0]).data,
status=status.HTTP_201_CREATED,
.all()
)

# Early short circuiting for some simpler cases.

if (
len(program_enrollments) == 0
or len(programs) == 0
or len(programs) != len(program_ids)
):
# Insufficient program enrollments, so abort.
return Response(status=status.HTTP_404_NOT_FOUND)
elif len(programs) == 1 and len(program_enrollments) == 1:
# Just one program specified, so stop further processing.
return _create_course_enrollment_from_program(
request, courserun_id, program_enrollments[0]
)

if run not in program_enrollment.program.required_courses and (
CourseRunEnrollment.objects.filter(
run__course__in=program_enrollment.program.elective_courses,
user=request.user,
active=True,
enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE,
).count()
>= (program_enrollment.program.minimum_elective_courses_requirement or 1)
# Make sure the programs are related to each other before continuing.
# Also check for circular reference, which will break the verified enrollment
# checks later.

if (
programs[0] not in programs[1].program_nodes
and programs[1] not in programs[0].program_nodes
) or (
programs[0] in programs[1].program_nodes
and programs[1] in programs[0].program_nodes
):
# Too many verified elective enrollments, so make this as an audit one.
enrollments, _ = create_run_enrollments(
log.error(
"add_verified_program_course_enrollment: user %s enrolling in %s but programs specified (%s) have bad interdependencies",
request.user,
[run],
mode=EDX_ENROLLMENT_AUDIT_MODE,
keep_failed_enrollments=True,
courserun_id,
",".join(programs.values_list("readable_id", flat=True)),
stack_info=True,
extra={
"program_1": programs[0],
"program_1_nodes": programs[0].program_nodes,
"program_2": programs[1],
"program_2_nodes": programs[1].program_nodes,
},
)
return Response(
CourseRunEnrollmentSerializer(enrollments[0]).data,
status=status.HTTP_201_CREATED,
return Response(status=status.HTTP_400_BAD_REQUEST)

# Figure out which is the most upstream program and check enrollments there.

root_program = (
programs[0] if programs[0] not in programs[1].program_nodes else programs[1]
)

verified_program_enrollments = [
enrollment
for enrollment in program_enrollments
if enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE
]

if len(verified_program_enrollments) == 0:
# No verified enrollments, so it doesn't matter - the user will get an
# audit one. (But make the audit enrollment to not confuse the course run
# process later.)
create_program_enrollments(
request.user, programs, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE
)
elif (
len(verified_program_enrollments) == 1
and verified_program_enrollments[0].program == root_program
):
# The verified enrollment that's here is for the root program, so we can
# create a verified enrollment for the other program.
create_program_enrollments(
request.user, programs, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
)
elif (
len(verified_program_enrollments) == 1
and verified_program_enrollments[0].program != root_program
):
# The verified enrollment that's here is _not_ for the root program, so
# we should stop.
log.error(
"add_verified_program_course_enrollment: user %s enrolling in %s has no verified enrollment in %s",
request.user,
courserun_id,
root_program,
)
return Response(status=status.HTTP_400_BAD_REQUEST)

# Everything checks out for a verified enrollment, so generate one.
# This requires generating an order.
# If we fell out the bottom, we have all verified enrollments, or we've made
# sufficient enrollments to fill the gaps.

enrollment = create_verified_program_course_run_enrollment(
request, run, program_enrollment.program
# If we have >1 program, we need to pass in the enrollment for the program
# the course belongs to, or the call to create the course run enrollment will fail.

course_program = (
programs[0]
if programs[0]
.courses_qset.filter(courseruns__courseware_id=courserun_id)
.exists()
else programs[1]
)
try:
program_enrollment = ProgramEnrollment.objects.get(
user=request.user, program=course_program
)
except ProgramEnrollment.DoesNotExist as exc:
raise EnrollmentCreationFailedError from exc

return Response(
CourseRunEnrollmentSerializer(enrollment).data,
status=status.HTTP_201_CREATED,
return _create_course_enrollment_from_program(
request, courserun_id, program_enrollment
)


Expand Down
Loading
Loading