Skip to content
Merged
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
5 changes: 5 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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)
---------------

Expand Down
12 changes: 9 additions & 3 deletions courses/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down
22 changes: 19 additions & 3 deletions courses/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
21 changes: 14 additions & 7 deletions courses/models_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -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

Expand Down
9 changes: 8 additions & 1 deletion courses/serializers/v1/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
4 changes: 2 additions & 2 deletions courses/serializers/v1/courses_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 == {
Expand Down
43 changes: 35 additions & 8 deletions courses/serializers/v1/programs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CourseRunEnrollmentFactory,
CourseRunFactory,
CourseRunGradeFactory,
EnrollmentModeFactory,
ProgramFactory,
program_with_empty_requirements, # noqa: F401
program_with_requirements, # noqa: F401
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions courses/serializers/v2/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions courses/serializers/v2/courses_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 28 additions & 7 deletions courses/views/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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()
)
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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")
)

Expand Down
7 changes: 6 additions & 1 deletion courses/views/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
)
Expand Down
5 changes: 4 additions & 1 deletion courses/views/v3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading