Skip to content
Open
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
1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 58 additions & 9 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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",
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions courses/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion courses/serializers/v1/programs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
)

Expand Down
17 changes: 9 additions & 8 deletions courses/serializers/v2/programs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from courses.factories import ( # noqa: F401
CourseFactory,
CourseRunFactory,
EnrollmentModeFactory,
ProgramCollectionFactory,
ProgramFactory,
program_with_empty_requirements,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
},
)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion courses/views/v1/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
Program,
ProgramEnrollment,
)
from courses.serializers.v1.base import EnrollmentModeSerializer
from courses.serializers.v1.courses import (
CourseRunEnrollmentSerializer,
CourseRunSerializer,
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions courses/views/v2/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
Program,
ProgramEnrollment,
)
from courses.serializers.v1.base import EnrollmentModeSerializer
from courses.serializers.v2.certificates import (
CourseRunCertificateSerializer,
ProgramCertificateSerializer,
Expand Down Expand Up @@ -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", ""
)
Expand All @@ -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"),
Expand Down