From d68df4d9514e0b13c64d7e422eedcb12fd87ac8e Mon Sep 17 00:00:00 2001 From: Anna Date: Thu, 23 Jul 2026 15:10:14 -0400 Subject: [PATCH 1/7] Enforce export compliance verification on course/program enrollment Wires the compliance app's CyberSource export check into create_run_enrollments/create_program_enrollments behind the EXPORT_COMPLIANCE_CHECK_ENABLED feature flag, and surfaces ExportComplianceCheckError as a user-facing validation error from the v1-v3 enrollment serializers/views. --- conftest.py | 16 +++ courses/api.py | 25 +++++ courses/api_test.py | 179 ++++++++++++++++++++++++++++++ courses/serializers/v1/courses.py | 18 +-- courses/serializers/v2/courses.py | 20 ++-- courses/serializers/v3/courses.py | 19 ++-- courses/views/v1/__init__.py | 21 ++-- courses/views/v2/__init__.py | 95 ++++++++++------ courses/views/v3/__init__.py | 7 +- drf_lint_baseline.json | 4 +- main/features.py | 1 + 11 files changed, 339 insertions(+), 66 deletions(-) diff --git a/conftest.py b/conftest.py index a748689056..1d51b7705a 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,7 @@ import uuid from pathlib import Path +from types import SimpleNamespace import pytest from faker import Faker @@ -27,6 +28,7 @@ def default_settings(monkeypatch, settings): settings.FEATURES[features.IGNORE_EDX_FAILURES] = False settings.FEATURES[features.SYNC_ON_DASHBOARD_LOAD] = False settings.FEATURES[features.ENABLE_PROGRAM_SPECIFIC_PATHWAY_SCHOOLS] = False + settings.FEATURES[features.EXPORT_COMPLIANCE_CHECK_ENABLED] = True @pytest.fixture(autouse=True) @@ -65,6 +67,20 @@ def export_compliance_keypair(settings): return private_key +@pytest.fixture(autouse=True) +def mocked_export_compliance(mocker): + """Mock export compliance checks in shared enrollment helpers by default.""" + return mocker.patch( + "courses.api.verify_user_with_exports", + return_value=SimpleNamespace( + accepted=True, + decision="ACCEPT", + reason_code=100, + request_id="test-request-id", + ), + ) + + @pytest.fixture(autouse=True) def mock_hubspot_api(mocker): """Mock the Hubspot CRM API""" diff --git a/courses/api.py b/courses/api.py index eea610d2f8..e4be40b05a 100644 --- a/courses/api.py +++ b/courses/api.py @@ -32,6 +32,8 @@ from b2b.api import process_add_org_membership from cms.api import create_default_courseware_page +from compliance.api import verify_user_with_exports +from compliance.exceptions import ExportComplianceError from courses import mail_api from courses.constants import ( COURSE_KEY_PATTERN, @@ -205,6 +207,8 @@ def create_run_enrollments( # noqa: C901 created in mitxonline, paired with a boolean indicating whether or not the edX enrollment API call was successful for all of the given course runs """ + _verify_exports_compliance_for_enrollment(user) + if keep_failed_enrollments is None: keep_failed_enrollments = settings.FEATURES.get( features.IGNORE_EDX_FAILURES, False @@ -321,6 +325,8 @@ def create_program_enrollments( Returns: list of ProgramEnrollment: A list of enrollment objects that were successfully created """ + _verify_exports_compliance_for_enrollment(user) + successful_enrollments = [] for program in programs: try: @@ -399,6 +405,25 @@ def upgrade_audit_run_enrollments_for_program_purchase(user, program): return upgraded_enrollments +def _verify_exports_compliance_for_enrollment(user) -> None: + """Verify users with CyberSource before creating enrollments.""" + if not settings.FEATURES.get(features.EXPORT_COMPLIANCE_CHECK_ENABLED, False): + return + + result = verify_user_with_exports(user) + if result.accepted: + return + + log.warning( + "Export compliance check did not accept enrollment for user=%s: " + "decision=%r, reason_code=%r", + user.id, + result.decision, + result.reason_code, + ) + raise ExportComplianceError(user, result.decision, result.reason_code) + + def downgrade_learner(enrollment): """ Downgrades given enrollment from verified to audit. diff --git a/courses/api_test.py b/courses/api_test.py index b74eb29350..cfe5dcdecd 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -35,6 +35,8 @@ OrganizationPageFactory, ) from cms.factories import CourseIndexPageFactory +from compliance.api import ExportComplianceResult +from compliance.exceptions import ExportComplianceError from courses.api import ( check_course_modes, create_local_enrollment, @@ -862,6 +864,183 @@ def test_mixed_enrollments_upgrades_only_audit( verified_enrollment.refresh_from_db() assert verified_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE +def test_create_run_enrollments_verifies_exports_for_verified_mode( + mocker, user, django_capture_on_commit_callbacks +): + """Verified course enrollments should require an accepted export check.""" + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="ACCEPT", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + mocker.patch("courses.tasks.subscribe_edx_course_emails.delay") + + with django_capture_on_commit_callbacks(execute=True): + successful_enrollments, edx_request_success = create_run_enrollments( + user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE + ) + + patched_verify.assert_called_once_with(user) + patched_edx_enroll.assert_called_once_with( + user, + [run], + mode=EDX_ENROLLMENT_VERIFIED_MODE, + ) + assert edx_request_success is True + assert len(successful_enrollments) == 1 + + +def test_create_run_enrollments_verifies_exports_for_audit_mode(mocker, user): + """Audit course enrollments should also require an accepted export check.""" + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="ACCEPT", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + mocker.patch("courses.tasks.subscribe_edx_course_emails.delay") + + create_run_enrollments(user, [run], mode=EDX_ENROLLMENT_AUDIT_MODE) + + patched_verify.assert_called_once_with(user) + patched_edx_enroll.assert_called_once() + + +def test_create_run_enrollments_rejects_nonaccepted_exports(mocker, user): + """Verified course enrollments should fail closed when exports are not accepted.""" + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="REJECT", + reason_code=102, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + + with pytest.raises( + ExportComplianceError, match="Export compliance check did not accept" + ): + create_run_enrollments(user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE) + + patched_verify.assert_called_once_with(user) + patched_edx_enroll.assert_not_called() + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + +def test_create_run_enrollments_skips_exports_check_when_feature_disabled( + settings, mocker, user +): + """The export compliance check should be skipped entirely when the feature flag is off.""" + settings.FEATURES[features.EXPORT_COMPLIANCE_CHECK_ENABLED] = False + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="REJECT", + reason_code=102, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + + successful_enrollments, _ = create_run_enrollments( + user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE + ) + + patched_verify.assert_not_called() + patched_edx_enroll.assert_called_once() + assert len(successful_enrollments) == 1 + + +def test_create_program_enrollments_verifies_exports_for_verified_mode(mocker, user): + """Verified program enrollments should require an accepted export check.""" + program = ProgramFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="ACCEPT", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + + successful_enrollments = create_program_enrollments( + user, + [program], + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + ) + + patched_verify.assert_called_once_with(user) + assert len(successful_enrollments) == 1 + assert successful_enrollments[0].program == program + + +def test_create_program_enrollments_verifies_exports_for_default_mode(mocker, user): + """Default program enrollments should also require an accepted export check.""" + program = ProgramFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="ACCEPT", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + + successful_enrollments = create_program_enrollments( + user, + [program], + ) + + patched_verify.assert_called_once_with(user) + assert len(successful_enrollments) == 1 + assert successful_enrollments[0].program == program + + +def test_create_program_enrollments_rejects_nonaccepted_exports(mocker, user): + """Verified program enrollments should fail closed when exports are not accepted.""" + program = ProgramFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="REVIEW", + reason_code=480, + request_id="req-123", + raw={}, + ), + ) + + with pytest.raises( + ExportComplianceError, match="Export compliance check did not accept" + ): + create_program_enrollments( + user, + [program], + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + ) + + patched_verify.assert_called_once_with(user) + assert not ProgramEnrollment.objects.filter(user=user, program=program).exists() + class TestDeactivateEnrollments: """Test cases for functions that deactivate enrollments""" diff --git a/courses/serializers/v1/courses.py b/courses/serializers/v1/courses.py index b07b8305fc..3ea513a98c 100644 --- a/courses/serializers/v1/courses.py +++ b/courses/serializers/v1/courses.py @@ -10,6 +10,7 @@ from rest_framework.exceptions import ValidationError from cms.serializers import CoursePageSerializer +from compliance.exceptions import ExportComplianceCheckError from courses import models from courses.api import create_run_enrollments from courses.serializers.v1.base import ( @@ -175,13 +176,16 @@ def create(self, validated_data): if run.b2b_contract is not None: raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) - successful_enrollments, _ = create_run_enrollments( - user, - [run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + try: + successful_enrollments, _ = create_run_enrollments( + user, + [run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + raise ValidationError(exc.to_error_detail()) from exc return successful_enrollments[0] if successful_enrollments else None diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index c6bf688fcf..e43a465a69 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -12,6 +12,7 @@ from rest_framework.exceptions import ValidationError from cms.serializers import CoursePageSerializer +from compliance.exceptions import ExportComplianceCheckError from courses import models from courses.api import create_run_enrollments from courses.serializers.utils import get_topics_from_page @@ -340,13 +341,18 @@ def create(self, validated_data): if run.b2b_contract is not None: raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) - successful_enrollments, _ = create_run_enrollments( - user, - [run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + + try: + successful_enrollments, _ = create_run_enrollments( + user, + [run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + raise ValidationError(exc.to_error_detail()) from exc + return successful_enrollments[0] if successful_enrollments else None @extend_schema_field(serializers.IntegerField(allow_null=True)) diff --git a/courses/serializers/v3/courses.py b/courses/serializers/v3/courses.py index cf6a32e761..ff38929fb7 100644 --- a/courses/serializers/v3/courses.py +++ b/courses/serializers/v3/courses.py @@ -9,6 +9,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from compliance.exceptions import ExportComplianceCheckError from courses import models from courses.api import create_run_enrollments from courses.serializers.v1.base import ( @@ -113,13 +114,17 @@ def create(self, validated_data): if run is None or run.b2b_contract_id is not None: raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) - successful_enrollments, _ = create_run_enrollments( - user, - [run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + try: + successful_enrollments, _ = create_run_enrollments( + user, + [run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + raise ValidationError(exc.to_error_detail()) from exc + if not successful_enrollments: msg = "Unable to create course run enrollment" raise ValueError(msg) diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index 20148b91a3..6fc8ac9ee7 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -28,6 +28,7 @@ from rest_framework.views import APIView from reversion.models import Version +from compliance.exceptions import ExportComplianceCheckError from courses.api import ( create_run_enrollments, deactivate_run_enrollment, @@ -368,13 +369,19 @@ def post(self, request): create_user(user) user.refresh_from_db() - _, edx_request_success = create_run_enrollments( - user=user, - runs=[run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + try: + _, edx_request_success = create_run_enrollments( + user=user, + runs=[run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + return redirect_with_user_message( + reverse("user-dashboard"), + {"type": USER_MSG_TYPE_ENROLL_BLOCKED, **exc.to_error_detail()}, + ) def respond(data, status=True): # noqa: FBT002 """ diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index 24b494c953..37963c9b86 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -32,6 +32,7 @@ from rest_framework.response import Response from cms.models import CoursePage +from compliance.exceptions import ExportComplianceCheckError from courses.api import ( create_program_enrollments, create_run_enrollments, @@ -784,12 +785,15 @@ def _create_course_enrollment_from_program(request, courserun_id, program_enroll if should_create_audit_enrollment: # 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, - ) + try: + enrollments, _ = create_run_enrollments( + request.user, + [run], + mode=EDX_ENROLLMENT_AUDIT_MODE, + keep_failed_enrollments=True, + ) + except ExportComplianceCheckError as exc: + return Response(exc.to_error_detail(), status=status.HTTP_400_BAD_REQUEST) if len(enrollments) == 0: raise EnrollmentCreationFailedError return Response( @@ -810,6 +814,51 @@ def _create_course_enrollment_from_program(request, courserun_id, program_enroll ) +def _reconcile_verified_program_enrollments( + request, courserun_id, root_program, verified_program_enrollments, programs +): + """ + Create audit/verified program enrollments as needed to reconcile the + learner's verified enrollment state before enrolling them in the course run. + + Returns: + Response: an error response if reconciliation failed, otherwise None + """ + try: + 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) + except ExportComplianceCheckError as exc: + return Response(exc.to_error_detail(), status=status.HTTP_400_BAD_REQUEST) + return None + + @extend_schema( request=list[str], responses={ @@ -910,35 +959,11 @@ def add_verified_program_course_enrollment(request, courserun_id: str): 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) + error_response = _reconcile_verified_program_enrollments( + request, courserun_id, root_program, verified_program_enrollments, programs + ) + if error_response is not None: + return error_response # If we fell out the bottom, we have all verified enrollments, or we've made # sufficient enrollments to fill the gaps. diff --git a/courses/views/v3/__init__.py b/courses/views/v3/__init__.py index e72ae9eacd..d01d6ee3ba 100644 --- a/courses/views/v3/__init__.py +++ b/courses/views/v3/__init__.py @@ -25,6 +25,7 @@ from rest_framework.response import Response from b2b.models import ContractPage +from compliance.exceptions import ExportComplianceCheckError from courses.api import create_program_enrollments, deactivate_run_enrollment from courses.constants import COURSE_KEY_PATTERN, ENROLL_CHANGE_STATUS_UNENROLLED from courses.models import ( @@ -235,7 +236,11 @@ def create(self, request, *args, **kwargs): # noqa: ARG002 return Response(response_serializer.data, status=status.HTTP_200_OK) # Create the enrollment using default enrollment mode (audit) - enrollments = create_program_enrollments(request.user, [program]) + try: + enrollments = create_program_enrollments(request.user, [program]) + except ExportComplianceCheckError as exc: + raise serializers.ValidationError(exc.to_error_detail()) from exc + if not enrollments: raise ValueError("Failed to create program enrollment.") # noqa: EM101 response_serializer = ProgramEnrollmentSerializer(enrollments[0]) diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 03b4fdf46d..445c6530f0 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -23,8 +23,8 @@ "courses/serializers/v2/departments.py:49:42:ORM002", "courses/serializers/v2/programs.py:385:53:ORM002", "courses/serializers/v2/programs.py:495:12:ORM002", - "courses/serializers/v3/courses.py:111:14:ORM001", - "courses/serializers/v3/courses.py:55:12:ORM002", + "courses/serializers/v3/courses.py:112:14:ORM001", + "courses/serializers/v3/courses.py:56:12:ORM002", "courses/serializers/v3/programs.py:55:22:ORM001", "ecommerce/serializers/__init__.py:204:17:ORM001", "ecommerce/serializers/__init__.py:206:18:ORM001", diff --git a/main/features.py b/main/features.py index 708af7397f..ed2707fd9c 100644 --- a/main/features.py +++ b/main/features.py @@ -13,3 +13,4 @@ ENABLE_PROGRAM_SPECIFIC_PATHWAY_SCHOOLS = ( "mitxonline-12321-program-specific-pathway-schools" ) +EXPORT_COMPLIANCE_CHECK_ENABLED = "EXPORT_COMPLIANCE_CHECK_ENABLED" From 52714fd383171c7499e7f174d0c243e10c6ebf71 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:21:14 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- courses/api_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/courses/api_test.py b/courses/api_test.py index cfe5dcdecd..f32cc3c4f3 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -864,6 +864,7 @@ def test_mixed_enrollments_upgrades_only_audit( verified_enrollment.refresh_from_db() assert verified_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE + def test_create_run_enrollments_verifies_exports_for_verified_mode( mocker, user, django_capture_on_commit_callbacks ): From f707a9d82abf27adaf90ecab6ff0f4d54bd95ef3 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 29 Jul 2026 12:04:55 -0400 Subject: [PATCH 3/7] Fix verify_user_with_exports call sites after cache/models rebase verify_user_with_exports now takes (user, courseware_object). Pass the representative course run for run enrollments, and check each program individually for program enrollments (Program is now the accepted courseware object type, and a batch may contain multiple programs). --- courses/api.py | 12 ++++++------ courses/api_test.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/courses/api.py b/courses/api.py index e4be40b05a..950544344f 100644 --- a/courses/api.py +++ b/courses/api.py @@ -207,7 +207,7 @@ def create_run_enrollments( # noqa: C901 created in mitxonline, paired with a boolean indicating whether or not the edX enrollment API call was successful for all of the given course runs """ - _verify_exports_compliance_for_enrollment(user) + _verify_exports_compliance_for_enrollment(user, runs[0]) if keep_failed_enrollments is None: keep_failed_enrollments = settings.FEATURES.get( @@ -325,10 +325,10 @@ def create_program_enrollments( Returns: list of ProgramEnrollment: A list of enrollment objects that were successfully created """ - _verify_exports_compliance_for_enrollment(user) - successful_enrollments = [] for program in programs: + _verify_exports_compliance_for_enrollment(user, program) + try: enrollment, created = ProgramEnrollment.all_objects.get_or_create( user=user, @@ -345,7 +345,7 @@ def create_program_enrollments( if not created and enrollment.enrollment_mode != enrollment_mode: enrollment.update_mode_and_save(enrollment_mode) - except: # pylint: disable=bare-except # noqa: PERF203, E722 + except: # pylint: disable=bare-except # noqa: E722 mail_api.send_enrollment_failure_message( user, program, details=format_exc() ) @@ -405,12 +405,12 @@ def upgrade_audit_run_enrollments_for_program_purchase(user, program): return upgraded_enrollments -def _verify_exports_compliance_for_enrollment(user) -> None: +def _verify_exports_compliance_for_enrollment(user, courseware_object) -> None: """Verify users with CyberSource before creating enrollments.""" if not settings.FEATURES.get(features.EXPORT_COMPLIANCE_CHECK_ENABLED, False): return - result = verify_user_with_exports(user) + result = verify_user_with_exports(user, courseware_object) if result.accepted: return diff --git a/courses/api_test.py b/courses/api_test.py index f32cc3c4f3..77d31676c3 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -888,7 +888,7 @@ def test_create_run_enrollments_verifies_exports_for_verified_mode( user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE ) - patched_verify.assert_called_once_with(user) + patched_verify.assert_called_once_with(user, run) patched_edx_enroll.assert_called_once_with( user, [run], @@ -916,7 +916,7 @@ def test_create_run_enrollments_verifies_exports_for_audit_mode(mocker, user): create_run_enrollments(user, [run], mode=EDX_ENROLLMENT_AUDIT_MODE) - patched_verify.assert_called_once_with(user) + patched_verify.assert_called_once_with(user, run) patched_edx_enroll.assert_called_once() @@ -939,7 +939,7 @@ def test_create_run_enrollments_rejects_nonaccepted_exports(mocker, user): ): create_run_enrollments(user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE) - patched_verify.assert_called_once_with(user) + patched_verify.assert_called_once_with(user, run) patched_edx_enroll.assert_not_called() assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() @@ -989,7 +989,7 @@ def test_create_program_enrollments_verifies_exports_for_verified_mode(mocker, u enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, ) - patched_verify.assert_called_once_with(user) + patched_verify.assert_called_once_with(user, program) assert len(successful_enrollments) == 1 assert successful_enrollments[0].program == program @@ -1012,7 +1012,7 @@ def test_create_program_enrollments_verifies_exports_for_default_mode(mocker, us [program], ) - patched_verify.assert_called_once_with(user) + patched_verify.assert_called_once_with(user, program) assert len(successful_enrollments) == 1 assert successful_enrollments[0].program == program @@ -1039,7 +1039,7 @@ def test_create_program_enrollments_rejects_nonaccepted_exports(mocker, user): enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, ) - patched_verify.assert_called_once_with(user) + patched_verify.assert_called_once_with(user, program) assert not ProgramEnrollment.objects.filter(user=user, program=program).exists() From 84c8a9883f64d64cc8522dfeb1d3e0a846e87552 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 29 Jul 2026 12:09:44 -0400 Subject: [PATCH 4/7] Fix export compliance test fixtures to use a real accepted decision ExportComplianceResult.accepted now checks against ExportComplianceLog.ACCEPTED_DECISIONS ({COMPLETED, MANUALLY_APPROVED}), so tests asserting an accepted outcome need decision="COMPLETED" rather than the no-longer-recognized "ACCEPT". --- courses/api_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/courses/api_test.py b/courses/api_test.py index 77d31676c3..8b5ed4e0a3 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -873,7 +873,7 @@ def test_create_run_enrollments_verifies_exports_for_verified_mode( patched_verify = mocker.patch( "courses.api.verify_user_with_exports", return_value=ExportComplianceResult( - decision="ACCEPT", + decision="COMPLETED", reason_code=100, request_id="req-123", raw={}, @@ -904,7 +904,7 @@ def test_create_run_enrollments_verifies_exports_for_audit_mode(mocker, user): patched_verify = mocker.patch( "courses.api.verify_user_with_exports", return_value=ExportComplianceResult( - decision="ACCEPT", + decision="COMPLETED", reason_code=100, request_id="req-123", raw={}, @@ -976,7 +976,7 @@ def test_create_program_enrollments_verifies_exports_for_verified_mode(mocker, u patched_verify = mocker.patch( "courses.api.verify_user_with_exports", return_value=ExportComplianceResult( - decision="ACCEPT", + decision="COMPLETED", reason_code=100, request_id="req-123", raw={}, @@ -1000,7 +1000,7 @@ def test_create_program_enrollments_verifies_exports_for_default_mode(mocker, us patched_verify = mocker.patch( "courses.api.verify_user_with_exports", return_value=ExportComplianceResult( - decision="ACCEPT", + decision="COMPLETED", reason_code=100, request_id="req-123", raw={}, From 74b9ca48020a9648673c84bbb2a055baff574a9a Mon Sep 17 00:00:00 2001 From: Anna Date: Thu, 6 Aug 2026 17:44:43 -0400 Subject: [PATCH 5/7] drf_lint --- drf_lint_baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 445c6530f0..587718c412 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -8,8 +8,8 @@ "cms/serializers.py:97:12:ORM001", "courses/serializers/base.py:53:16:ORM001", "courses/serializers/v1/base.py:75:20:ORM002", - "courses/serializers/v1/courses.py:171:18:ORM001", - "courses/serializers/v1/courses.py:57:16:ORM001", + "courses/serializers/v1/courses.py:172:18:ORM001", + "courses/serializers/v1/courses.py:58:16:ORM001", "courses/serializers/v1/programs.py:181:12:ORM001", "courses/serializers/v1/programs.py:196:12:ORM001", "courses/serializers/v1/programs.py:208:12:ORM001", @@ -17,8 +17,8 @@ "courses/serializers/v1/programs.py:303:27:ORM001", "courses/serializers/v1/programs.py:318:16:ORM001", "courses/serializers/v1/programs.py:335:17:ORM001", - "courses/serializers/v2/courses.py:272:17:ORM002", - "courses/serializers/v2/courses.py:337:18:ORM001", + "courses/serializers/v2/courses.py:273:17:ORM002", + "courses/serializers/v2/courses.py:338:18:ORM001", "courses/serializers/v2/departments.py:35:40:ORM002", "courses/serializers/v2/departments.py:49:42:ORM002", "courses/serializers/v2/programs.py:385:53:ORM002", From 2cc05e891b677b200aca725d01581f32ac014a8f Mon Sep 17 00:00:00 2001 From: Anna Date: Fri, 7 Aug 2026 13:36:20 -0400 Subject: [PATCH 6/7] tests --- courses/views/v1/views_test.py | 67 +++++++++++++++++++++ courses/views/v2/views_test.py | 107 +++++++++++++++++++++++++++++++++ courses/views/v3/views_test.py | 43 +++++++++++++ 3 files changed, 217 insertions(+) diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 08c97fbb2e..ac5e9ef7d8 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -24,6 +24,7 @@ from b2b.factories import ContractPageFactory from cms.factories import ProgramPageFactory from cms.serializers import CoursePageSerializer, ProgramPageSerializer +from compliance.exceptions import ExportComplianceDataError, ExportComplianceError from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED from courses.factories import ( BlockedCountryFactory, @@ -541,6 +542,24 @@ def test_user_enrollments_create_invalid(user_drf_client, user): assert resp.json() == {"errors": {"run_id": "Invalid course run id: 1234"}} +def test_user_enrollments_create_export_compliance_blocked( + mocker, user_drf_client, user +): + """The user enrollments view should fail closed when the export compliance check rejects the user.""" + run = CourseRunFactory.create() + exc = ExportComplianceError(user, "REJECT", 102) + mocker.patch( + "courses.serializers.v1.courses.create_run_enrollments", + side_effect=exc, + ) + resp = user_drf_client.post( + reverse("v1:user-enrollments-api-list"), data={"run_id": run.id} + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == {"errors": exc.to_error_detail()} + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + def test_user_enrollments_create_b2b_run_invalid(user_drf_client, user): """Creating an enrollment for a B2B course run via the public API should be rejected.""" contract = ContractPageFactory.create() @@ -737,6 +756,54 @@ def test_create_enrollments_blocked_country(user_client, user): ) +def test_create_enrollments_export_compliance_blocked(mocker, user_client, user): + """ + Create enrollment view should redirect with a user message in a cookie + if the export compliance check does not accept the enrollment. + """ + run = CourseRunFactory.create() + exc = ExportComplianceError(user, "REJECT", 102) + mocker.patch( + "courses.views.v1.create_run_enrollments", + side_effect=exc, + ) + resp = user_client.post( + reverse("create-enrollment-via-form"), + data={"run": str(run.id)}, + HTTP_REFERER=EXAMPLE_URL, + ) + assert resp.status_code == status.HTTP_302_FOUND + assert resp.url == reverse("user-dashboard") + assert USER_MSG_COOKIE_NAME in resp.cookies + assert resp.cookies[USER_MSG_COOKIE_NAME].value == encode_json_cookie_value( + {"type": USER_MSG_TYPE_ENROLL_BLOCKED, **exc.to_error_detail()} + ) + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + +def test_create_enrollments_export_compliance_missing_data(mocker, user_client, user): + """ + Create enrollment view should redirect with the missing profile fields + when the export compliance check can't run due to incomplete profile data. + """ + run = CourseRunFactory.create() + exc = ExportComplianceDataError(user, ["postal_code", "state"]) + mocker.patch( + "courses.views.v1.create_run_enrollments", + side_effect=exc, + ) + resp = user_client.post( + reverse("create-enrollment-via-form"), + data={"run": str(run.id)}, + HTTP_REFERER=EXAMPLE_URL, + ) + assert resp.status_code == status.HTTP_302_FOUND + assert resp.url == reverse("user-dashboard") + assert resp.cookies[USER_MSG_COOKIE_NAME].value == encode_json_cookie_value( + {"type": USER_MSG_TYPE_ENROLL_BLOCKED, **exc.to_error_detail()} + ) + + @pytest.mark.parametrize("receive_emails", [True, False]) def test_update_user_enrollment(mocker, user_drf_client, user, receive_emails): """The enrollment should update the course email subscriptions""" diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index b64fccb94b..7df7acde0f 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -30,6 +30,7 @@ from b2b.models import ContractProgramItem from cms.factories import CoursePageFactory, ProgramPageFactory from cms.serializers import ProgramPageSerializer +from compliance.exceptions import ExportComplianceError from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED from courses.factories import ( CourseFactory, @@ -1156,6 +1157,24 @@ def test_user_enrollments_create_b2b_run_invalid_v2(user_drf_client, user): assert resp.json() == {"errors": {"run_id": f"Invalid course run id: {run.id}"}} +def test_user_enrollments_create_export_compliance_blocked_v2( + mocker, user_drf_client, user +): + """v2 enrollments API should fail closed when the export compliance check rejects the user.""" + run = CourseRunFactory.create() + exc = ExportComplianceError(user, "REJECT", 102) + mocker.patch( + "courses.serializers.v2.courses.create_run_enrollments", + side_effect=exc, + ) + resp = user_drf_client.post( + reverse("v2:user-enrollments-api-list"), data={"run_id": run.id} + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == {"errors": exc.to_error_detail()} + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + def test_program_filter_for_b2b_org(user, mock_course_run_clone): """Test that filtering programs by org works as expected.""" @@ -1982,6 +2001,49 @@ def test_add_verified_program_course_enrollment_audit_only_run_falls_back_to_aud assert user.orders.count() == initial_order_count +@responses.activate +def test_add_verified_program_course_enrollment_export_compliance_blocked( + mocker, user, user_drf_client +): + """ + A single-program audit enrollment should fail closed (400) when the export + compliance check rejects the user, instead of creating an enrollment. + """ + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/enrollment/v1/enrollments", + json={ + "results": [ + {"mode": EDX_ENROLLMENT_AUDIT_MODE, "is_active": True}, + ], + }, + status=status.HTTP_200_OK, + ) + + prog_enrollment = ProgramEnrollmentFactory.create( + user=user, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE + ) + program = prog_enrollment.program + + course_run = CourseRunFactory.create() + program.add_requirement(course_run.course) + + exc = ExportComplianceError(user, "REJECT", 102) + mocker.patch("courses.views.v2.create_run_enrollments", side_effect=exc) + + resp = user_drf_client.post( + reverse( + "v2:add_verified_program_course_enrollment", + kwargs={"courserun_id": course_run.courseware_id}, + ), + data=[program.readable_id], + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == exc.to_error_detail() + assert not CourseRunEnrollment.objects.filter(user=user, run=course_run).exists() + + @pytest.mark.skip_nplusone_check @responses.activate @pytest.mark.parametrize( @@ -2160,6 +2222,51 @@ def test_add_nested_verified_program_course_enrollment( ) +@responses.activate +def test_add_nested_verified_program_course_enrollment_export_compliance_blocked( + mocker, user, user_drf_client +): + """ + Reconciling program enrollments across a multi-program request should fail + closed (400) when the export compliance check rejects the user. + """ + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/enrollment/v1/enrollments", + json={"results": [{"mode": EDX_ENROLLMENT_AUDIT_MODE, "is_active": True}]}, + status=status.HTTP_200_OK, + ) + + base_program = ProgramFactory.create(display_mode=None) + crogram = ProgramFactory.create(display_mode="course") + course_run = CourseRunFactory.create() + + crogram.add_requirement(course_run.course) + base_program.add_requirement(crogram) + + # Neither program has a verified enrollment, so reconciliation will try to + # create audit program enrollments via create_program_enrollments - which + # is where the export compliance check happens. + ProgramEnrollmentFactory.create( + program=base_program, user=user, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE + ) + + exc = ExportComplianceError(user, "REJECT", 102) + mocker.patch("courses.views.v2.create_program_enrollments", side_effect=exc) + + 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], + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == exc.to_error_detail() + assert not ProgramEnrollment.objects.filter(user=user, program=crogram).exists() + + @pytest.mark.skip_nplusone_check @pytest.mark.parametrize( "sync_on_load,flag_enabled,sync_raises", # noqa: PT006 diff --git a/courses/views/v3/views_test.py b/courses/views/v3/views_test.py index cbbace7451..408a2e7097 100644 --- a/courses/views/v3/views_test.py +++ b/courses/views/v3/views_test.py @@ -12,6 +12,7 @@ from rest_framework import status from rest_framework.test import APIClient +from compliance.exceptions import ExportComplianceError from courses.conftest import B2BCourses, UserWithEnrollmentsAndCerts from courses.constants import ( ENROLL_CHANGE_STATUS_UNENROLLED, @@ -25,6 +26,7 @@ ProgramFactory, ) from courses.models import ( + CourseRunEnrollment, PaidProgram, ProgramEnrollment, ) @@ -644,6 +646,47 @@ def test_create_program_enrollment_not_found(user_drf_client): assert resp.status_code == status.HTTP_400_BAD_REQUEST +def test_create_program_enrollment_export_compliance_blocked( + mocker, user_drf_client, user +): + """POST should fail closed when the export compliance check rejects the user.""" + program = ProgramFactory.create(live=True) + exc = ExportComplianceError(user, "REJECT", 102) + mocker.patch("courses.views.v3.create_program_enrollments", side_effect=exc) + + resp = user_drf_client.post( + reverse("v3:user_program_enrollments_api-list"), + data={"program_id": program.id}, + format="json", + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == {"errors": exc.to_error_detail()} + assert not ProgramEnrollment.objects.filter(user=user, program=program).exists() + + +def test_user_enrollments_create_export_compliance_blocked_v3( + mocker, user_drf_client, user +): + """v3 enrollments API should fail closed when the export compliance check rejects the user.""" + run = CourseRunFactory.create() + exc = ExportComplianceError(user, "REJECT", 102) + mocker.patch( + "courses.serializers.v3.courses.create_run_enrollments", + side_effect=exc, + ) + + resp = user_drf_client.post( + reverse("v3:user_enrollments_api-list"), + data={"run_id": run.id}, + format="json", + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == {"errors": exc.to_error_detail()} + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + def test_create_program_enrollment_unauthenticated(): """POST without authentication returns 401 or 403.""" program = ProgramFactory.create(live=True) From 6b5b2bfedb279ed028d091adc92f3711bab66017 Mon Sep 17 00:00:00 2001 From: Anna Date: Fri, 7 Aug 2026 14:52:16 -0400 Subject: [PATCH 7/7] fixing tests --- courses/views/v1/views_test.py | 4 ++-- courses/views/v2/views_test.py | 6 +++--- courses/views/v3/views_test.py | 25 +------------------------ 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index ac5e9ef7d8..8593c5789e 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -547,7 +547,7 @@ def test_user_enrollments_create_export_compliance_blocked( ): """The user enrollments view should fail closed when the export compliance check rejects the user.""" run = CourseRunFactory.create() - exc = ExportComplianceError(user, "REJECT", 102) + exc = ExportComplianceError(user, "REJECT", "102") mocker.patch( "courses.serializers.v1.courses.create_run_enrollments", side_effect=exc, @@ -762,7 +762,7 @@ def test_create_enrollments_export_compliance_blocked(mocker, user_client, user) if the export compliance check does not accept the enrollment. """ run = CourseRunFactory.create() - exc = ExportComplianceError(user, "REJECT", 102) + exc = ExportComplianceError(user, "REJECT", "102") mocker.patch( "courses.views.v1.create_run_enrollments", side_effect=exc, diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index 7df7acde0f..8b2b564d74 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -1162,7 +1162,7 @@ def test_user_enrollments_create_export_compliance_blocked_v2( ): """v2 enrollments API should fail closed when the export compliance check rejects the user.""" run = CourseRunFactory.create() - exc = ExportComplianceError(user, "REJECT", 102) + exc = ExportComplianceError(user, "REJECT", "102") mocker.patch( "courses.serializers.v2.courses.create_run_enrollments", side_effect=exc, @@ -2028,7 +2028,7 @@ def test_add_verified_program_course_enrollment_export_compliance_blocked( course_run = CourseRunFactory.create() program.add_requirement(course_run.course) - exc = ExportComplianceError(user, "REJECT", 102) + exc = ExportComplianceError(user, "REJECT", "102") mocker.patch("courses.views.v2.create_run_enrollments", side_effect=exc) resp = user_drf_client.post( @@ -2251,7 +2251,7 @@ def test_add_nested_verified_program_course_enrollment_export_compliance_blocked program=base_program, user=user, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE ) - exc = ExportComplianceError(user, "REJECT", 102) + exc = ExportComplianceError(user, "REJECT", "102") mocker.patch("courses.views.v2.create_program_enrollments", side_effect=exc) resp = user_drf_client.post( diff --git a/courses/views/v3/views_test.py b/courses/views/v3/views_test.py index 408a2e7097..475631c99f 100644 --- a/courses/views/v3/views_test.py +++ b/courses/views/v3/views_test.py @@ -26,7 +26,6 @@ ProgramFactory, ) from courses.models import ( - CourseRunEnrollment, PaidProgram, ProgramEnrollment, ) @@ -651,7 +650,7 @@ def test_create_program_enrollment_export_compliance_blocked( ): """POST should fail closed when the export compliance check rejects the user.""" program = ProgramFactory.create(live=True) - exc = ExportComplianceError(user, "REJECT", 102) + exc = ExportComplianceError(user, "REJECT", "102") mocker.patch("courses.views.v3.create_program_enrollments", side_effect=exc) resp = user_drf_client.post( @@ -665,28 +664,6 @@ def test_create_program_enrollment_export_compliance_blocked( assert not ProgramEnrollment.objects.filter(user=user, program=program).exists() -def test_user_enrollments_create_export_compliance_blocked_v3( - mocker, user_drf_client, user -): - """v3 enrollments API should fail closed when the export compliance check rejects the user.""" - run = CourseRunFactory.create() - exc = ExportComplianceError(user, "REJECT", 102) - mocker.patch( - "courses.serializers.v3.courses.create_run_enrollments", - side_effect=exc, - ) - - resp = user_drf_client.post( - reverse("v3:user_enrollments_api-list"), - data={"run_id": run.id}, - format="json", - ) - - assert resp.status_code == status.HTTP_400_BAD_REQUEST - assert resp.json() == {"errors": exc.to_error_detail()} - assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() - - def test_create_program_enrollment_unauthenticated(): """POST without authentication returns 401 or 403.""" program = ProgramFactory.create(live=True)