From 58ccf1c5aecbdd07fd3fc015fd0394165efd1dbc Mon Sep 17 00:00:00 2001 From: James Kachel Date: Wed, 12 Aug 2026 11:58:18 -0500 Subject: [PATCH] Recurse into the program if the program doesn't have a verified mode; update program factory to generate modes; update tests to work with modes --- conftest.py | 1 + courses/api.py | 16 ++++-- courses/api_test.py | 67 +++++++++++++++++++++---- courses/factories.py | 23 +++++++++ courses/serializers/v1/programs_test.py | 5 +- courses/serializers/v2/programs_test.py | 17 ++++--- courses/views/v1/views_test.py | 5 +- courses/views/v2/views_test.py | 7 ++- 8 files changed, 116 insertions(+), 25 deletions(-) diff --git a/conftest.py b/conftest.py index a748689056..bbf7fa8daa 100644 --- a/conftest.py +++ b/conftest.py @@ -53,6 +53,7 @@ def payment_gateway_settings(settings): settings.MITOL_PAYMENT_GATEWAY_CYBERSOURCE_REST_API_ENVIRONMENT = ( "apitest.cybersource.com" ) + settings.MITOL_PAYMENT_GATEWAY_STRIPE_API_KEY = uuid.uuid4() @pytest.fixture diff --git a/courses/api.py b/courses/api.py index eea610d2f8..5fb8d79278 100644 --- a/courses/api.py +++ b/courses/api.py @@ -1268,10 +1268,18 @@ def _has_earned(node): # has passed the referenced course return node.course in [*cert_courses, *grade_courses] elif node.is_program: - # has earned certificate for the required sub-program - return ProgramCertificate.all_objects.filter( - user=user, program=node.required_program, is_revoked=False - ).exists() + # If the program has a verified mode (requires_payment=True), then + # check for a certificate. If not, then recurse; if the learner would + # have earned a certificate, we should count that. + if ( + node.is_program + and node.program.enrollment_modes.filter(requires_payment=True).exists() + ): + return ProgramCertificate.all_objects.filter( + user=user, program=node.required_program, is_revoked=False + ).exists() + + return _has_earned_program_cert(user, node.program) return False return _has_earned(root) diff --git a/courses/api_test.py b/courses/api_test.py index b74eb29350..cd137fe09a 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -2367,15 +2367,56 @@ def test_generate_program_certificate_failure_not_all_passed_nested_elective_sti @pytest.mark.parametrize( - ("has_main_enroll", "has_sub_enroll"), + ( + "has_main_enroll", + "has_sub_enroll", + "sub_is_audit_only", + "sub_course_passed", + ), [ ( True, True, + False, + True, + ), + ( + True, + True, + False, + False, + ), + ( + True, + False, + False, + True, + ), + ( + True, + False, + False, + False, + ), + (False, True, False, True), + ( + False, + False, + False, + True, + ), + ( + True, + True, + True, + True, + ), + ( + True, + True, + True, + False, ), - (True, False), - (False, True), - (False, False), ], ) @patch("courses.signals.upsert_custom_properties") @@ -2386,10 +2427,12 @@ def test_generate_program_certificate_with_subprogram_requirement( # noqa: PLR0 default_mode_records, has_main_enroll, has_sub_enroll, + sub_is_audit_only, + sub_course_passed, ): """ - Test that generate_program_certificate considers sub-program (nested program) requirements - when determining if a user has earned a program certificate. + Test that generate_program_certificate considers sub-program (nested program) + requirements when determining if a user has earned a program certificate. """ patched_sync_hubspot_user = mocker.patch( "hubspot_sync.task_helpers.sync_hubspot_user", @@ -2399,7 +2442,12 @@ def test_generate_program_certificate_with_subprogram_requirement( # noqa: PLR0 ) # Create a sub-program that the user will complete - sub_program = ProgramFactory.create() + modes = ( + [EnrollmentModeFactory(mode_slug=EDX_ENROLLMENT_AUDIT_MODE)] + if sub_is_audit_only + else [] + ) + sub_program = ProgramFactory.create(enrollment_modes=modes) sub_course = CourseFactory.create() sub_program.add_requirement(sub_course) @@ -2446,8 +2494,9 @@ def test_generate_program_certificate_with_subprogram_requirement( # noqa: PLR0 main_certificate, main_created = generate_program_certificate( user=user, program=main_program ) - if has_main_enroll and has_sub_enroll: - # Should only get a certificate if we had a cert in the sub + if has_main_enroll and has_sub_enroll and sub_course_passed: + # Should only get a certificate if we had a cert (or a passing grade!) + # in the sub # So, we'd have to have been enrolled there, too assert main_created is True assert isinstance(main_certificate, ProgramCertificate) diff --git a/courses/factories.py b/courses/factories.py index 28e4b0a716..94bd20bd1a 100644 --- a/courses/factories.py +++ b/courses/factories.py @@ -87,6 +87,29 @@ def departments(self, create, extracted, **kwargs): # noqa: ARG002 return self.departments.set(extracted) + @factory.post_generation + def enrollment_modes(self, create, extracted, **kwargs): # noqa: ARG002 + """ + Post-generation method to add enrollment modes to the course run. + By default, adds the audit and verified modes if no modes are provided. + + Args: + create: Whether the instance is being created (as opposed to just built). + extracted: The enrollment modes to add, if any were provided when the factory was called. + **kwargs: Additional keyword arguments (not used here). + """ + if not create: + return + if extracted is not None and len(extracted) > 0: + self.enrollment_modes.set(extracted) + else: + self.enrollment_modes.add( + EnrollmentModeFactory(mode_slug=EDX_ENROLLMENT_AUDIT_MODE) + ) + self.enrollment_modes.add( + EnrollmentModeFactory(mode_slug=EDX_ENROLLMENT_VERIFIED_MODE) + ) + class Meta: model = Program diff --git a/courses/serializers/v1/programs_test.py b/courses/serializers/v1/programs_test.py index 55f8ff3ebb..02876ac9d3 100644 --- a/courses/serializers/v1/programs_test.py +++ b/courses/serializers/v1/programs_test.py @@ -24,6 +24,7 @@ program_with_requirements, # noqa: F401 ) from courses.models import Department, ProgramRequirement, ProgramRequirementNodeType +from courses.serializers.v1.base import EnrollmentModeSerializer from courses.serializers.v1.courses import CourseWithCourseRunsSerializer from courses.serializers.v1.programs import ( LearnerRecordSerializer, @@ -140,7 +141,9 @@ def sort_course_runs(course): "program_type": "Series", "departments": [], "live": True, - "enrollment_modes": [], + "enrollment_modes": EnrollmentModeSerializer( + program_with_empty_requirements.enrollment_modes, many=True + ).data, }, ) diff --git a/courses/serializers/v2/programs_test.py b/courses/serializers/v2/programs_test.py index ff04a5f91b..15c957b875 100644 --- a/courses/serializers/v2/programs_test.py +++ b/courses/serializers/v2/programs_test.py @@ -12,7 +12,6 @@ from courses.factories import ( # noqa: F401 CourseFactory, CourseRunFactory, - EnrollmentModeFactory, ProgramCollectionFactory, ProgramFactory, program_with_empty_requirements, @@ -25,6 +24,7 @@ ProgramCollectionItem, ProgramRequirement, ) +from courses.serializers.v1.base import EnrollmentModeSerializer from courses.serializers.v1.departments import DepartmentSerializer from courses.serializers.v2.programs import ( ProgramDetailSerializer, @@ -164,8 +164,10 @@ def test_serialize_program( "min_weekly_hours": program_with_empty_requirements.page.min_weekly_hours, "min_price": program_with_empty_requirements.page.min_price, "max_price": program_with_empty_requirements.page.max_price, - "certificate_available": False, - "enrollment_modes": [], + "certificate_available": True, + "enrollment_modes": EnrollmentModeSerializer( + program_with_empty_requirements.enrollment_modes, many=True + ).data, "display_mode": None, }, ) @@ -340,11 +342,10 @@ def test_serialize_program_certificate_available( expected, ): """Test that certificate_available reflects whether any enrollment mode requires payment.""" - mode = EnrollmentModeFactory.create( - mode_slug="verified" if has_paid_mode else "audit", - requires_payment=has_paid_mode, - ) - program_with_empty_requirements.enrollment_modes.add(mode) + if not has_paid_mode: + program_with_empty_requirements.enrollment_modes.filter( + requires_payment=True + ).delete() data = ProgramSerializer( instance=program_with_empty_requirements, context=mock_context diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 08c97fbb2e..9f693689b2 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -47,6 +47,7 @@ Program, ProgramEnrollment, ) +from courses.serializers.v1.base import EnrollmentModeSerializer from courses.serializers.v1.courses import ( CourseRunEnrollmentSerializer, CourseRunSerializer, @@ -836,7 +837,9 @@ def test_program_enrollments(user_drf_client, user_with_enrollments_and_certific "title": program_enrollment.program.title, "live": program_enrollment.program.live, "departments": [], - "enrollment_modes": [], + "enrollment_modes": EnrollmentModeSerializer( + program_enrollment.program.enrollment_modes, many=True + ).data, "readable_id": program_enrollment.program.readable_id, "req_tree": list( ProgramRequirementTreeSerializer( diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index b64fccb94b..2131e1a978 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -49,6 +49,7 @@ Program, ProgramEnrollment, ) +from courses.serializers.v1.base import EnrollmentModeSerializer from courses.serializers.v2.certificates import ( CourseRunCertificateSerializer, ProgramCertificateSerializer, @@ -1699,7 +1700,7 @@ def _get_page_prop(program_enrollment, prop, default=None): "collections": [], "availability": "anytime", "certificate_type": "Certificate of Completion", - "certificate_available": False, + "certificate_available": True, "required_prerequisites": _get_page_prop( program_enrollment, "prerequisites", "" ) @@ -1711,7 +1712,9 @@ def _get_page_prop(program_enrollment, prop, default=None): "end_date": None, "enrollment_end": None, "enrollment_start": None, - "enrollment_modes": [], + "enrollment_modes": EnrollmentModeSerializer( + program_enrollment.program.enrollment_modes, many=True + ).data, "duration": _get_page_prop(program_enrollment, "length"), "time_commitment": _get_page_prop(program_enrollment, "effort"), "min_price": _get_page_prop(program_enrollment, "min_price"),