From ebe83c788d488d86802584a74386dab6c670e9cf Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Thu, 2 Apr 2026 00:08:33 +0500 Subject: [PATCH 1/2] feat: consider verified mode when checking is_upgradable (#3450) --- courses/conftest.py | 12 +++++-- courses/factories.py | 22 +++++++++++-- courses/models.py | 24 ++++++++++++-- courses/models_test.py | 21 ++++++++---- courses/serializers/v1/base.py | 9 +++++- courses/serializers/v1/courses_test.py | 4 +-- courses/serializers/v1/programs_test.py | 43 ++++++++++++++++++++----- courses/serializers/v2/courses.py | 1 + courses/serializers/v2/courses_test.py | 2 ++ courses/views/v1/__init__.py | 35 ++++++++++++++++---- courses/views/v2/__init__.py | 7 +++- courses/views/v3/__init__.py | 5 ++- openapi/specs/v0.yaml | 32 +++++++++--------- openapi/specs/v1.yaml | 32 +++++++++--------- openapi/specs/v2.yaml | 32 +++++++++--------- 15 files changed, 194 insertions(+), 87 deletions(-) diff --git a/courses/conftest.py b/courses/conftest.py index 57f72198b8..962d835557 100644 --- a/courses/conftest.py +++ b/courses/conftest.py @@ -101,9 +101,15 @@ def course_catalog_data( def _create_course(idx): test_course = CourseFactory.create(title=f"Test Course {idx}") - cr1 = CourseRunFactory.create(course=test_course, past_start=True) - cr2 = CourseRunFactory.create(course=test_course, in_progress=True) - cr3 = CourseRunFactory.create(course=test_course, in_future=True) + cr1 = CourseRunFactory.create( + course=test_course, past_start=True, enrollment_modes=[] + ) + cr2 = CourseRunFactory.create( + course=test_course, in_progress=True, enrollment_modes=[] + ) + cr3 = CourseRunFactory.create( + course=test_course, in_future=True, enrollment_modes=[] + ) return test_course, [cr1, cr2, cr3] diff --git a/courses/factories.py b/courses/factories.py index 52f7244aa5..031734d808 100644 --- a/courses/factories.py +++ b/courses/factories.py @@ -155,9 +155,25 @@ class CourseRunFactory(DjangoModelFactory): b2b_contract = None is_source_run = False - enrollment_modes = factory.RelatedFactoryList( - EnrollmentModeFactory, size=1, mode_slug=EDX_ENROLLMENT_AUDIT_MODE - ) + @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 verified mode 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: + self.enrollment_modes.set(extracted) + else: + self.enrollment_modes.add( + EnrollmentModeFactory(mode_slug=EDX_ENROLLMENT_VERIFIED_MODE) + ) class Meta: model = CourseRun diff --git a/courses/models.py b/courses/models.py index 0abfe50a86..bf8da8445f 100644 --- a/courses/models.py +++ b/courses/models.py @@ -38,7 +38,11 @@ ) from main.models import AuditableModel, AuditModel, ValidateOnSaveMixin from main.utils import serialize_model_object -from openedx.constants import EDX_DEFAULT_ENROLLMENT_MODE, EDX_ENROLLMENTS_PAID_MODES +from openedx.constants import ( + EDX_DEFAULT_ENROLLMENT_MODE, + EDX_ENROLLMENT_VERIFIED_MODE, + EDX_ENROLLMENTS_PAID_MODES, +) User = get_user_model() @@ -1240,19 +1244,33 @@ def is_in_progress(self) -> bool: @property def is_upgradable(self): """ - Checks if the course can be upgraded - A null value means that the upgrade window is always open + Checks if the course can be upgraded. + Requires the run to be live, the upgrade deadline to not have passed, + a product to exist, and a verified enrollment mode to be available. + A null upgrade_deadline means that the upgrade window is always open. """ if hasattr(self, "prefetched_products"): has_product = bool(self.prefetched_products) else: has_product = self.products.exists() + + if hasattr(self, "prefetched_enrollment_modes"): + has_verified_mode = any( + mode.mode_slug == EDX_ENROLLMENT_VERIFIED_MODE + for mode in self.prefetched_enrollment_modes + ) + else: + has_verified_mode = self.enrollment_modes.filter( + mode_slug=EDX_ENROLLMENT_VERIFIED_MODE + ).exists() + return ( self.live is True and ( self.upgrade_deadline is None or (self.upgrade_deadline > now_in_utc()) ) and has_product + and has_verified_mode ) @cached_property diff --git a/courses/models_test.py b/courses/models_test.py index 23f35a388e..3b58bfafb8 100644 --- a/courses/models_test.py +++ b/courses/models_test.py @@ -152,16 +152,21 @@ def test_course_run_past(end_days, expected): @pytest.mark.parametrize( - "upgrade_deadline_days,has_product, expected", # noqa: PT006 + "upgrade_deadline_days,has_product,has_verified_mode,expected", # noqa: PT006 [ - [-1, True, False], # noqa: PT007 - [1, True, True], # noqa: PT007 - [None, True, True], # noqa: PT007 - [None, False, False], # noqa: PT007 - [1, False, False], # noqa: PT007 + [-1, True, True, False], # noqa: PT007 + [1, True, True, True], # noqa: PT007 + [None, True, True, True], # noqa: PT007 + [None, False, True, False], # noqa: PT007 + [1, False, True, False], # noqa: PT007 + [1, True, False, False], # noqa: PT007 + [None, True, False, False], # noqa: PT007 + [None, False, False, False], # noqa: PT007 ], ) -def test_course_run_upgradeable(upgrade_deadline_days, has_product, expected): +def test_course_run_upgradeable( + upgrade_deadline_days, has_product, has_verified_mode, expected +): """ Test that CourseRun.is_upgradable returns the expected boolean value """ @@ -174,6 +179,8 @@ def test_course_run_upgradeable(upgrade_deadline_days, has_product, expected): course_run = CourseRunFactory.create(upgrade_deadline=upgrade_deadline) if has_product: ProductFactory.create(purchasable_object=course_run) + if not has_verified_mode: + course_run.enrollment_modes.clear() assert course_run.is_upgradable is expected diff --git a/courses/serializers/v1/base.py b/courses/serializers/v1/base.py index 17383f25cc..e76f5d5a63 100644 --- a/courses/serializers/v1/base.py +++ b/courses/serializers/v1/base.py @@ -60,7 +60,14 @@ class BaseCourseRunSerializer(serializers.ModelSerializer): is_enrollable = serializers.SerializerMethodField() course_number = serializers.SerializerMethodField() courseware_url = serializers.SerializerMethodField() - enrollment_modes = EnrollmentModeSerializer(many=True, read_only=True) + enrollment_modes = serializers.SerializerMethodField() + + def get_enrollment_modes(self, instance) -> list[dict]: + """Get the enrollment modes for the course run""" + modes = getattr(instance, "prefetched_enrollment_modes", None) + if modes is None: + modes = instance.enrollment_modes.all() + return EnrollmentModeSerializer(modes, many=True).data def get_courseware_url(self, instance) -> str | None: """Get the courseware URL""" diff --git a/courses/serializers/v1/courses_test.py b/courses/serializers/v1/courses_test.py index 596ec76dd3..6928efba84 100644 --- a/courses/serializers/v1/courses_test.py +++ b/courses/serializers/v1/courses_test.py @@ -129,7 +129,7 @@ def test_serialize_course_with_page_fields( def test_serialize_course_run(): """Test CourseRun serialization""" - course_run = CourseRunFactory.create(course__page=None) + course_run = CourseRunFactory.create(course__page=None, enrollment_modes=[]) course_run.refresh_from_db() data = CourseRunSerializer(course_run).data @@ -165,7 +165,7 @@ def test_serialize_course_run(): def test_serialize_course_run_with_course(): """Test CoursePageDepartmentsSerializer serialization""" - course_run = CourseRunFactory.create(course__page=None) + course_run = CourseRunFactory.create(course__page=None, enrollment_modes=[]) data = CourseRunWithCourseSerializer(course_run).data assert data == { diff --git a/courses/serializers/v1/programs_test.py b/courses/serializers/v1/programs_test.py index e906116ac7..0f5c9d576d 100644 --- a/courses/serializers/v1/programs_test.py +++ b/courses/serializers/v1/programs_test.py @@ -12,6 +12,7 @@ CourseRunEnrollmentFactory, CourseRunFactory, CourseRunGradeFactory, + EnrollmentModeFactory, ProgramFactory, program_with_empty_requirements, # noqa: F401 program_with_requirements, # noqa: F401 @@ -36,15 +37,30 @@ ) def test_serialize_program(mock_context, remove_tree, program_with_empty_requirements): # noqa: F811 """Test Program serialization""" + + def sort_course_runs(course): + """ + Sort course runs and enrollment modes in place to ensure consistent ordering for test assertions + """ + course["courseruns"].sort(key=lambda cr: cr["id"]) + for course_run in course["courseruns"]: + course_run["enrollment_modes"].sort(key=lambda em: em["mode_slug"]) + run1 = CourseRunFactory.create( course__page=None, start_date=now() + timedelta(hours=1), ) + run1.enrollment_modes.add( + EnrollmentModeFactory.create(mode=EDX_ENROLLMENT_VERIFIED_MODE) + ) course1 = run1.course run2 = CourseRunFactory.create( course__page=None, start_date=now() + timedelta(hours=2), ) + run2.enrollment_modes.add( + EnrollmentModeFactory.create(mode=EDX_ENROLLMENT_VERIFIED_MODE) + ) course2 = run2.course runs = ( # noqa: F841 [run1, run2] @@ -83,20 +99,31 @@ def test_serialize_program(mock_context, remove_tree, program_with_empty_require instance=program_with_empty_requirements, context=mock_context ).data + expected_courses = ( + [ + CourseWithCourseRunsSerializer( + instance=course, context={**mock_context} + ).data + for course in [course1, course2] + ] + if not remove_tree + else [] + ) + + # Sort course runs and enrollment modes in expected data and actual data to ensure consistent ordering for assertions + for course in expected_courses: + sort_course_runs(course) + + for course in data["courses"]: + sort_course_runs(course) + assert_drf_json_equal( data, { "title": program_with_empty_requirements.title, "readable_id": program_with_empty_requirements.readable_id, "id": program_with_empty_requirements.id, - "courses": [ - CourseWithCourseRunsSerializer( - instance=course, context={**mock_context} - ).data - for course in [course1, course2] - ] - if not remove_tree - else [], + "courses": expected_courses, "requirements": formatted_reqs, "req_tree": ProgramRequirementTreeSerializer( program_with_empty_requirements.requirements_root diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index 31ee5adb24..24b18f65da 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -298,6 +298,7 @@ class CourseWithCourseRunsSerializer(CourseSerializer): def get_courseruns(self, instance): # Use prefetched course runs to preserve prefetched products courseruns = instance.courseruns.all() + courseruns = sorted(courseruns, key=lambda run: run.id) if hasattr(instance, "prefetched_courseruns"): courseruns = instance.prefetched_courseruns # Filter by enrollable status if context parameter is present diff --git a/courses/serializers/v2/courses_test.py b/courses/serializers/v2/courses_test.py index 72e3c767fa..f0fb49a0e5 100644 --- a/courses/serializers/v2/courses_test.py +++ b/courses/serializers/v2/courses_test.py @@ -56,6 +56,8 @@ def test_serialize_course( # noqa: PLR0913 courseRun2 = CourseRunFactory.create( certificate_available_date=None, course=courseRun1.course ) + courseRun1.enrollment_modes.clear() + courseRun2.enrollment_modes.clear() else: courseRun1 = CourseRunFactory.create() courseRun1.enrollment_modes.add( diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index 08ef2f391b..1834d51b94 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -8,7 +8,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db import transaction -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse @@ -183,7 +183,13 @@ def get_queryset(self): queryset = ( Course.objects.filter() .select_related("page") - .prefetch_related("departments") + .prefetch_related( + "departments", + Prefetch( + "courseruns__enrollment_modes", + to_attr="prefetched_enrollment_modes", + ), + ) .all() ) else: @@ -193,7 +199,10 @@ def get_queryset(self): .prefetch_related( "courseruns", "departments", - "courseruns__enrollment_modes", + Prefetch( + "courseruns__enrollment_modes", + to_attr="prefetched_enrollment_modes", + ), ) .all() ) @@ -247,21 +256,27 @@ def get_queryset(self): if relevant_to: course = Course.objects.filter(readable_id=relevant_to).first() if course: - return get_relevant_course_run_qset(course) + runs_qset = get_relevant_course_run_qset(course) else: program = Program.objects.filter(readable_id=relevant_to).first() - return ( + runs_qset = ( get_user_relevant_program_course_run_qset(program) if program - else Program.objects.none() + else CourseRun.objects.none() + ) + return runs_qset.prefetch_related( + Prefetch( + "enrollment_modes", + to_attr="prefetched_enrollment_modes", ) + ) else: return ( CourseRun.objects.select_related("course") .prefetch_related( "course__departments", "course__page", - "enrollment_modes", + Prefetch("enrollment_modes", to_attr="prefetched_enrollment_modes"), ) .filter(live=True) ) @@ -433,6 +448,12 @@ def get_queryset(self): return ( CourseRunEnrollment.objects.filter(user=self.request.user) .select_related("run__course__page", "user", "run") + .prefetch_related( + Prefetch( + "run__enrollment_modes", + to_attr="prefetched_enrollment_modes", + ) + ) .prefetch("certificate", "grades") ) diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index 772ba8daee..fd887b5db4 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -400,11 +400,15 @@ def get_queryset(self): queryset=courserun_product_queryset, to_attr="prefetched_products", ) + modes_prefetch = Prefetch( + "enrollment_modes", + to_attr="prefetched_enrollment_modes", + ) course_runs_prefetch = Prefetch( "courseruns", queryset=CourseRun.objects.order_by("id") .select_related("b2b_contract") - .prefetch_related("enrollment_modes", products_prefetch), + .prefetch_related(modes_prefetch, products_prefetch), ) queryset = queryset.prefetch_related( "departments", "in_programs", course_runs_prefetch @@ -608,6 +612,7 @@ class UserEnrollmentsApiViewSet( .prefetch_related( "run__b2b_contract__organization", "run__course__page", + Prefetch("run__enrollment_modes", to_attr="prefetched_enrollment_modes"), ) .prefetch("certificate", "grades") ) diff --git a/courses/views/v3/__init__.py b/courses/views/v3/__init__.py index 6e430f8daa..e51df17f09 100644 --- a/courses/views/v3/__init__.py +++ b/courses/views/v3/__init__.py @@ -95,7 +95,10 @@ class UserEnrollmentsApiViewSet( .prefetch_related( "run__course", "run__course__page", - "run__enrollment_modes", + Prefetch( + "run__enrollment_modes", + to_attr="prefetched_enrollment_modes", + ), Prefetch( "run__products", queryset=Product.objects.only("id", "price", "is_active"), diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index ccbc94a078..0ea3718602 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -4312,7 +4312,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -4428,7 +4430,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true upgrade_product_id: type: integer @@ -5000,18 +5004,6 @@ components: x-enum-descriptions: - audit - verified - EnrollmentModeRequest: - type: object - description: Enrollment mode serializer. - properties: - mode_slug: - type: string - maxLength: 255 - mode_display_name: - type: string - maxLength: 255 - requires_payment: - type: boolean ErrorEnum: enum: - enroll-blocked @@ -7402,7 +7394,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -7516,7 +7510,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -8067,7 +8063,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 9e9ef080fd..d111124152 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -4312,7 +4312,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -4428,7 +4430,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true upgrade_product_id: type: integer @@ -5000,18 +5004,6 @@ components: x-enum-descriptions: - audit - verified - EnrollmentModeRequest: - type: object - description: Enrollment mode serializer. - properties: - mode_slug: - type: string - maxLength: 255 - mode_display_name: - type: string - maxLength: 255 - requires_payment: - type: boolean ErrorEnum: enum: - enroll-blocked @@ -7402,7 +7394,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -7516,7 +7510,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -8067,7 +8063,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 357f68b9e6..973858ebfa 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -4312,7 +4312,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -4428,7 +4430,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true upgrade_product_id: type: integer @@ -5000,18 +5004,6 @@ components: x-enum-descriptions: - audit - verified - EnrollmentModeRequest: - type: object - description: Enrollment mode serializer. - properties: - mode_slug: - type: string - maxLength: 255 - mode_display_name: - type: string - maxLength: 255 - requires_payment: - type: boolean ErrorEnum: enum: - enroll-blocked @@ -7402,7 +7394,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -7516,7 +7510,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array @@ -8067,7 +8063,9 @@ components: enrollment_modes: type: array items: - $ref: '#/components/schemas/EnrollmentMode' + type: object + additionalProperties: {} + description: Get the enrollment modes for the course run readOnly: true products: type: array From 237bf5a502e58892b30fdfba1ebf88cb84107104 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 1 Apr 2026 19:14:36 +0000 Subject: [PATCH 2/2] Release 1.144.4 --- RELEASE.rst | 5 +++++ main/settings.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 85f18f0abd..0e938448d7 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,11 @@ Release Notes ============= +Version 1.144.4 +--------------- + +- feat: consider verified mode when checking is_upgradable (#3450) + Version 1.144.3 (Released April 01, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index cfa2f04d40..c65a8c1ffe 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.3" +VERSION = "1.144.4" log = logging.getLogger()