diff --git a/RELEASE.rst b/RELEASE.rst index 1a6bee843b..88236fadc3 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 1.144.3 +--------------- + +- Update get_certificate_available to use the enrollment modes for determination (#3446) +- fix: don't send receipts for modules of a program (#3442) +- Don't sync edX course modes back from edX (#3443) + Version 1.144.2 (Released March 31, 2026) --------------- diff --git a/courses/factories.py b/courses/factories.py index 8cedd8dd79..52f7244aa5 100644 --- a/courses/factories.py +++ b/courses/factories.py @@ -155,6 +155,10 @@ class CourseRunFactory(DjangoModelFactory): b2b_contract = None is_source_run = False + enrollment_modes = factory.RelatedFactoryList( + EnrollmentModeFactory, size=1, mode_slug=EDX_ENROLLMENT_AUDIT_MODE + ) + class Meta: model = CourseRun diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index cc3220fb9a..31ee5adb24 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -173,7 +173,8 @@ def get_certificate_available(self, instance) -> bool: return ( instance.first_unexpired_run is not None - and instance.first_unexpired_run.certificate_available_date is not None + and hasattr(instance, "verified_courserun_count") + and instance.verified_courserun_count > 0 ) @extend_schema_field(str) diff --git a/courses/serializers/v2/courses_test.py b/courses/serializers/v2/courses_test.py index 78fd3c629f..72e3c767fa 100644 --- a/courses/serializers/v2/courses_test.py +++ b/courses/serializers/v2/courses_test.py @@ -12,6 +12,7 @@ CourseFactory, CourseRunEnrollmentFactory, CourseRunFactory, + EnrollmentModeFactory, ProgramFactory, ) from courses.models import CourseRunEnrollment, CoursesTopic, Department @@ -23,6 +24,7 @@ ) from courses.views.v2 import UserEnrollmentFilterSet from main.test_utils import assert_drf_json_equal +from openedx.constants import EDX_ENROLLMENT_VERIFIED_MODE pytestmark = [pytest.mark.django_db] @@ -56,7 +58,13 @@ def test_serialize_course( # noqa: PLR0913 ) else: courseRun1 = CourseRunFactory.create() + courseRun1.enrollment_modes.add( + EnrollmentModeFactory.create(mode_slug=EDX_ENROLLMENT_VERIFIED_MODE) + ) courseRun2 = CourseRunFactory.create(course=courseRun1.course) + courseRun2.enrollment_modes.add( + EnrollmentModeFactory.create(mode_slug=EDX_ENROLLMENT_VERIFIED_MODE) + ) if is_anonymous: mock_context["request"].user = AnonymousUser() @@ -78,6 +86,13 @@ def test_serialize_course( # noqa: PLR0913 run=courseRun1, **({} if is_anonymous else {"user": user}) ) + # Fake out a "verified_courserun_count" attribute - this is an annotation that the + # viewset adds, but won't be here because we're just passing it in a straight + # Course object. + course.verified_courserun_count = course.courseruns.filter( + enrollment_modes__mode_slug=EDX_ENROLLMENT_VERIFIED_MODE + ).count() + data = CourseWithCourseRunsSerializer(instance=course, context=mock_context).data assert_drf_json_equal( diff --git a/courses/tasks.py b/courses/tasks.py index 2058a7bb69..45d8932e9e 100644 --- a/courses/tasks.py +++ b/courses/tasks.py @@ -23,7 +23,7 @@ def sync_courseruns_data(): """ Task to sync titles and dates for course runs from edX. """ - from courses.api import sync_course_mode, sync_course_runs + from courses.api import sync_course_runs now = now_in_utc() runs = ( @@ -33,7 +33,6 @@ def sync_courseruns_data(): ) # `sync_course_runs` logs internally so no need to capture/output the returned values - sync_course_mode(runs) sync_course_runs(runs) diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index 4144f46c80..772ba8daee 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -413,6 +413,15 @@ def get_queryset(self): count_b2b_courseruns=Count("courseruns__b2b_contract__id") ) queryset = queryset.annotate(count_courseruns=Count("courseruns")) + queryset = queryset.annotate( + verified_courserun_count=Count( + "courseruns__enrollment_modes", + filter=Q( + courseruns__enrollment_modes__mode_slug=EDX_ENROLLMENT_VERIFIED_MODE + ), + ) + ) + return queryset.order_by("title").distinct() def get_serializer_context(self): diff --git a/ecommerce/api.py b/ecommerce/api.py index acc6c3c1b8..e028b10e1d 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -78,7 +78,9 @@ log = logging.getLogger(__name__) -def generate_checkout_payload(request, *, skip_discount_check=False): # noqa: PLR0911 +def generate_checkout_payload( # noqa: PLR0911 + request, *, skip_discount_check=False, skip_receipt=False +): """ Generate the checkout payload for the current basket. @@ -90,6 +92,7 @@ def generate_checkout_payload(request, *, skip_discount_check=False): # noqa: P - request: the incoming http request Kwargs: - skip_discount_check: skip checking discounts for validity (default False) + - skip_receipt: skip sending order receipt email (default False) """ from b2b.api import validate_basket_for_b2b_purchase # noqa: PLC0415 @@ -200,7 +203,10 @@ def generate_checkout_payload(request, *, skip_discount_check=False): # noqa: P if total_price == 0: with transaction.atomic(): fulfill_completed_order( - order, payment_data=ZERO_PAYMENT_DATA, basket=basket + order, + payment_data=ZERO_PAYMENT_DATA, + basket=basket, + skip_receipt=skip_receipt, ) order.refresh_from_db() @@ -329,9 +335,17 @@ def apply_user_discounts(request): return -def fulfill_completed_order(order, payment_data, basket=None, already_enrolled=False): # noqa: FBT002 +def fulfill_completed_order( + order, + payment_data, + basket=None, + already_enrolled=False, # noqa: FBT002 + skip_receipt=False, # noqa: FBT002 +): order_flow = order.get_object_flow() - order_flow.fulfill(payment_data, already_enrolled=already_enrolled) + order_flow.fulfill( + payment_data, already_enrolled=already_enrolled, skip_receipt=skip_receipt + ) sync_hubspot_deal(order) if basket and basket.compare_to_order(order): @@ -1118,7 +1132,9 @@ def create_verified_program_course_run_enrollment(request, courserun, program): msg = f"Basket for {request.user} is not zero-value" raise VerifiedProgramInvalidBasketError(msg) - processed_order = generate_checkout_payload(request, skip_discount_check=True) + processed_order = generate_checkout_payload( + request, skip_discount_check=True, skip_receipt=True + ) if "no_checkout" not in processed_order: # It didn't just clear the order so something went wrong diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py index 36e9ff4cc6..2cfae1b1d4 100644 --- a/ecommerce/api_test.py +++ b/ecommerce/api_test.py @@ -735,11 +735,20 @@ def test_create_verified_program_discount(): def test_create_verified_program_course_run_enrollment( - mock_create_run_enrollments, mock_hubspot_order, bootstrapped_verified_program, user + mocker, + mock_create_run_enrollments, + mock_hubspot_order, + bootstrapped_verified_program, + user, ): - """Test that creating a verified course run enrollment for a program works.""" + """Test that creating a verified course run enrollment for a program works + and does not send a receipt email. + """ mock_cre_side_effect = mock_create_run_enrollments.side_effect + mock_send_receipt = mocker.patch( + "ecommerce.tasks.send_ecommerce_order_receipt.delay" + ) (program, _, _, courserun, _) = bootstrapped_verified_program @@ -766,6 +775,7 @@ def test_create_verified_program_course_run_enrollment( ) assert cr_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE + mock_send_receipt.assert_not_called() mock_create_run_enrollments.side_effect = mock_cre_side_effect diff --git a/ecommerce/models.py b/ecommerce/models.py index adfebf69d0..2e4d6038fa 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -702,7 +702,7 @@ def create_enrollments(self): source=OrderStatus.PENDING, target=OrderStatus.FULFILLED, ) - def fulfill(self, payment_data, already_enrolled=False): # noqa: FBT002 + def fulfill(self, payment_data, already_enrolled=False, skip_receipt=False): # noqa: FBT002 # record the transaction self.create_transaction(payment_data) @@ -710,9 +710,10 @@ def fulfill(self, payment_data, already_enrolled=False): # noqa: FBT002 self.create_enrollments() # No email is required as this order is generated from management command - # Skip receipt emails for UAI orders + # Skip receipt emails for UAI orders and program-derived course run orders if ( not already_enrolled + and not skip_receipt and not is_uai_order(self.order) and not is_contract_order(self.order) ): diff --git a/ecommerce/models_test.py b/ecommerce/models_test.py index 53dd887840..37b412e8a3 100644 --- a/ecommerce/models_test.py +++ b/ecommerce/models_test.py @@ -849,3 +849,32 @@ def test_process_transaction_line_hooks(mocker, user, user_drf_client): assert mocked_create_run_enrollment.called assert not mocked_create_program_enrollment.called + + +@pytest.mark.skip_nplusone_check +@pytest.mark.parametrize( + ("skip_receipt", "email_sent"), + [(True, False), (False, True)], +) +def test_fulfill_skip_receipt( + mocker, django_capture_on_commit_callbacks, skip_receipt, email_sent +): + """Test that fulfill respects the skip_receipt flag for sending receipt email.""" + mocker.patch("courses.api.create_run_enrollments", autospec=True) + mock_send_receipt = mocker.patch( + "ecommerce.tasks.send_ecommerce_order_receipt.delay" + ) + + pending_order = OrderFactory.create(state=OrderStatus.PENDING) + order_flow = pending_order.get_object_flow() + + with django_capture_on_commit_callbacks(execute=True): + order_flow.fulfill( + {"amount": 0, "data": {"reason": "No payment required"}}, + skip_receipt=skip_receipt, + ) + + if email_sent: + mock_send_receipt.assert_called_once_with(pending_order.id) + else: + mock_send_receipt.assert_not_called() diff --git a/main/settings.py b/main/settings.py index bb4fb1a9a6..cfa2f04d40 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.144.2" +VERSION = "1.144.3" log = logging.getLogger()