From 4323f37556bcc74dda3dc4f5f3c6e5f35e0f5e3b Mon Sep 17 00:00:00 2001 From: James Kachel Date: Wed, 25 Mar 2026 08:04:22 -0500 Subject: [PATCH 1/2] Update verified program course enrollments API to allow for intermediate programs (#3418) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Chris Chudzicki --- courses/admin.py | 10 +- courses/exceptions.py | 5 + courses/models.py | 20 +++ courses/urls/v2/urls.py | 2 +- courses/views/v2/__init__.py | 258 ++++++++++++++++++++++++--------- courses/views/v2/views_test.py | 184 ++++++++++++++++++++++- openapi/specs/v0.yaml | 28 +++- openapi/specs/v1.yaml | 28 +++- openapi/specs/v2.yaml | 28 +++- 9 files changed, 468 insertions(+), 95 deletions(-) create mode 100644 courses/exceptions.py diff --git a/courses/admin.py b/courses/admin.py index 40a901bdd1..8ea342f1a6 100644 --- a/courses/admin.py +++ b/courses/admin.py @@ -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", diff --git a/courses/exceptions.py b/courses/exceptions.py new file mode 100644 index 0000000000..0b77c390d2 --- /dev/null +++ b/courses/exceptions.py @@ -0,0 +1,5 @@ +"""Exceptions for the courses API.""" + + +class EnrollmentCreationFailedError(Exception): + """Error when the create_run_enrollments fails.""" diff --git a/courses/models.py b/courses/models.py index 3629b35957..6b74d89ffa 100644 --- a/courses/models.py +++ b/courses/models.py @@ -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): """ diff --git a/courses/urls/v2/urls.py b/courses/urls/v2/urls.py index 55c58bf7c7..2a4c6c8e97 100644 --- a/courses/urls/v2/urls.py +++ b/courses/urls/v2/urls.py @@ -27,7 +27,7 @@ urlpatterns = [ *router.urls, path( - r"verified_program_enrollments///", + r"verified_program_enrollments//", v2.add_verified_program_course_enrollment, name="add_verified_program_course_enrollment", ), diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index efe39a5de3..aee52f306c 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -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, @@ -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): @@ -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() + + # 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 + 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, }, ) @@ -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. @@ -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 ) diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index 5036f791d7..10e928ccaa 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -1754,9 +1754,11 @@ def test_add_verified_program_course_enrollment( "v2:add_verified_program_course_enrollment", kwargs={ "courserun_id": course_run.courseware_id, - "program_id": program.readable_id, }, - ) + ), + data=[ + program.readable_id, + ], ) if program_enrollment_type: @@ -1780,6 +1782,184 @@ def test_add_verified_program_course_enrollment( assert resp.json()["enrollment_mode"] == EDX_ENROLLMENT_AUDIT_MODE +@pytest.mark.skip_nplusone_check +@responses.activate +@pytest.mark.parametrize( + "crogram_unrelated", + [ + True, + False, + ], +) +@pytest.mark.parametrize( + "base_program_enrollment_type", + [ + None, + EDX_ENROLLMENT_AUDIT_MODE, + EDX_ENROLLMENT_VERIFIED_MODE, + ], +) +@pytest.mark.parametrize( + "crogram_enrollment_type", + [ + None, + EDX_ENROLLMENT_AUDIT_MODE, + EDX_ENROLLMENT_VERIFIED_MODE, + ], +) +def test_add_nested_verified_program_course_enrollment( + user, + user_drf_client, + base_program_enrollment_type, + crogram_enrollment_type, + crogram_unrelated, +): + """ + Test that the endpoint works as expected when we're enrolling in a nested + program. + + This specifically is for testing when the course is part of a "crogram" that + is part of a program, and the user has a verified enrollment in the program + but no enrollment in the "crogram" (or they don't match). In this case, we + should figure out what the upstream enrollment is and make sure the + intermediary has the right enrollments too. + + This test doesn't care too much about the resulting course run enrollment + since that gets tested in the above test (and elsewhere). + """ + + expected_enrollment = ( + EDX_ENROLLMENT_VERIFIED_MODE + if EDX_ENROLLMENT_VERIFIED_MODE + in [base_program_enrollment_type, crogram_enrollment_type] + else EDX_ENROLLMENT_AUDIT_MODE + ) + + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/enrollment/v1/enrollments", + json={ + "results": [ + {"mode": expected_enrollment, "is_active": True}, + ], + }, + status=status.HTTP_200_OK, + ) + + # Set up base models + # We need a "crogram" that has a course as a requirement, and a base program + # that has the crogram as a requirement. Then, we need products for all of + # these. + + base_program = ProgramFactory.create(display_mode=None) + crogram = ProgramFactory.create(display_mode="course") + course_run = CourseRunFactory.create() + + course_run_content_type = ContentType.objects.get_for_model(course_run) + program_content_type = ContentType.objects.get_for_model(base_program) + + with reversion.create_revision(): + Product.objects.create( + price=10, + is_active=True, + object_id=course_run.id, + content_type=course_run_content_type, + ) + + with reversion.create_revision(): + Product.objects.create( + price=10, + is_active=True, + object_id=base_program.id, + content_type=program_content_type, + ) + + with reversion.create_revision(): + Product.objects.create( + price=10, + is_active=True, + object_id=crogram.id, + content_type=program_content_type, + ) + + crogram.add_requirement(course_run.course) + if not crogram_unrelated: + base_program.add_requirement(crogram) + + assert base_program not in crogram.program_nodes + assert crogram in base_program.program_nodes + + # Get enrollments set up. + + if base_program_enrollment_type: + ProgramEnrollmentFactory.create( + program=base_program, + user=user, + enrollment_mode=base_program_enrollment_type, + ) + + if crogram_enrollment_type: + ProgramEnrollmentFactory.create( + program=crogram, + user=user, + enrollment_mode=crogram_enrollment_type, + ) + + resp = user_drf_client.post( + reverse( + "v2:add_verified_program_course_enrollment", + kwargs={ + "courserun_id": course_run.courseware_id, + }, + ), + data=[base_program.readable_id, crogram.readable_id], + ) + + if not base_program_enrollment_type and not crogram_enrollment_type: + # If we don't have any program enrollments, then it should bail. + assert resp.status_code == status.HTTP_404_NOT_FOUND + assert not ProgramEnrollment.objects.filter( + user=user, program__in=[base_program, crogram] + ).exists() + return + + if crogram_unrelated: + # If the two programs specified are unrelated, it should bail. + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + if not crogram_enrollment_type: + assert not ProgramEnrollment.objects.filter( + user=user, program=crogram + ).exists() + + return + + if ( + crogram_enrollment_type == EDX_ENROLLMENT_VERIFIED_MODE + and base_program_enrollment_type != EDX_ENROLLMENT_VERIFIED_MODE + ): + assert resp.status_code == status.HTTP_400_BAD_REQUEST + return + + assert resp.status_code == status.HTTP_201_CREATED + + if [base_program_enrollment_type, crogram_enrollment_type] == [ + EDX_ENROLLMENT_VERIFIED_MODE, + EDX_ENROLLMENT_VERIFIED_MODE, + ]: + # If we were making verified enrollments, then we should have verified + # enrollments for both programs too. + + assert ( + ProgramEnrollment.objects.filter( + user=user, + program__in=[base_program, crogram], + enrollment_mode=expected_enrollment, + ).count() + == 2 + ) + + @pytest.mark.skip_nplusone_check @pytest.mark.parametrize( "sync_on_load,flag_enabled,sync_raises", # noqa: PT006 diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 1a3cb0bcda..40e9fc5ca0 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -3036,7 +3036,7 @@ paths: responses: '200': description: No response body - /api/v2/verified_program_enrollments/{program_id}/{courserun_id}/: + /api/v2/verified_program_enrollments/{courserun_id}/: post: operationId: verified_program_enrollments_create description: |- @@ -3055,16 +3055,26 @@ paths: name: courserun_id schema: type: string - description: Readable ID for the course run to enroll in. - required: true - - in: path - name: program_id - schema: - type: string - description: Readable ID for the program. required: true tags: - verified_program_enrollments + requestBody: + content: + application/json: + schema: + type: array + items: + type: string + application/x-www-form-urlencoded: + schema: + type: array + items: + type: string + multipart/form-data: + schema: + type: array + items: + type: string responses: '201': content: @@ -3074,6 +3084,8 @@ paths: description: '' '204': description: No response body + '400': + description: No response body '404': description: No response body /api/v3/enrollments/: diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index f2dd2c32f0..6457d9a807 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -3036,7 +3036,7 @@ paths: responses: '200': description: No response body - /api/v2/verified_program_enrollments/{program_id}/{courserun_id}/: + /api/v2/verified_program_enrollments/{courserun_id}/: post: operationId: verified_program_enrollments_create description: |- @@ -3055,16 +3055,26 @@ paths: name: courserun_id schema: type: string - description: Readable ID for the course run to enroll in. - required: true - - in: path - name: program_id - schema: - type: string - description: Readable ID for the program. required: true tags: - verified_program_enrollments + requestBody: + content: + application/json: + schema: + type: array + items: + type: string + application/x-www-form-urlencoded: + schema: + type: array + items: + type: string + multipart/form-data: + schema: + type: array + items: + type: string responses: '201': content: @@ -3074,6 +3084,8 @@ paths: description: '' '204': description: No response body + '400': + description: No response body '404': description: No response body /api/v3/enrollments/: diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 241dac80ed..cbfe267897 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -3036,7 +3036,7 @@ paths: responses: '200': description: No response body - /api/v2/verified_program_enrollments/{program_id}/{courserun_id}/: + /api/v2/verified_program_enrollments/{courserun_id}/: post: operationId: verified_program_enrollments_create description: |- @@ -3055,16 +3055,26 @@ paths: name: courserun_id schema: type: string - description: Readable ID for the course run to enroll in. - required: true - - in: path - name: program_id - schema: - type: string - description: Readable ID for the program. required: true tags: - verified_program_enrollments + requestBody: + content: + application/json: + schema: + type: array + items: + type: string + application/x-www-form-urlencoded: + schema: + type: array + items: + type: string + multipart/form-data: + schema: + type: array + items: + type: string responses: '201': content: @@ -3074,6 +3084,8 @@ paths: description: '' '204': description: No response body + '400': + description: No response body '404': description: No response body /api/v3/enrollments/: From d6788bbbb25f2e38f93abb9de983309afed85750 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 25 Mar 2026 13:19:06 +0000 Subject: [PATCH 2/2] Release 1.143.2 --- RELEASE.rst | 5 +++++ main/settings.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 6a57574635..956600fffb 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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) --------------- diff --git a/main/settings.py b/main/settings.py index 17e0b1963a..fdb3ee3104 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.143.1" +VERSION = "1.143.2" log = logging.getLogger()