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 0.141.1
---------------

- Add a /api/v3/enrollments/ API with reduced footprint (#3303)

Version 0.141.0 (Released March 11, 2026)
---------------

Expand Down
213 changes: 173 additions & 40 deletions courses/conftest.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
"""Shared pytest configuration for courses application"""

from collections import defaultdict
from math import ceil
from typing import NamedTuple

import pytest
from django.contrib.auth import get_user_model

from b2b.factories import ContractPageFactory
from b2b.factories import ContractPageFactory, OrganizationPageFactory
from b2b.models import ContractPage, OrganizationPage
from courses.factories import (
CourseFactory,
CourseRunCertificateFactory,
CourseRunEnrollmentFactory,
CourseRunFactory,
ProgramCertificateFactory,
ProgramEnrollmentFactory,
ProgramFactory,
)
from courses.models import (
Course,
CourseRun,
CourseRunCertificate,
CourseRunEnrollment,
Program,
ProgramCertificate,
ProgramEnrollment,
ProgramRequirement,
ProgramRequirementNodeType,
)

User = get_user_model()


@pytest.fixture
def programs():
Expand Down Expand Up @@ -46,10 +61,16 @@ def course_catalog_course_count(request):
return getattr(request, "param", 10)


class CourseCatalogData(NamedTuple):
courses: list[Course]
programs: list[Program]
course_runs: list[CourseRun]


@pytest.fixture
def course_catalog_data(
fake, course_catalog_program_count, course_catalog_course_count
):
) -> CourseCatalogData:
"""
Current production data is around 85 courses and 150 course runs. I opted to create 3 of each to allow
the best course run logic to play out as well as to push the endpoint a little harder in testing.
Expand All @@ -71,11 +92,11 @@ def course_catalog_data(
for idx in range(course_catalog_course_count):
course, course_runs_for_course = _create_course(idx)
courses.append(course)
course_runs.append(course_runs_for_course)
course_runs.extend(course_runs_for_course)
for _ in range(course_catalog_program_count):
program = _create_program(programs, courses, fake)
programs.append(program)
return courses, programs, course_runs
return CourseCatalogData(courses, programs, course_runs)


def _create_course(idx):
Expand Down Expand Up @@ -133,55 +154,167 @@ def _create_program(programs, courses, fake):
return program


class B2BCourses(NamedTuple):
organizations: list[OrganizationPage]
contracts_by_org_id: dict[int, ContractPage]
course_runs: list[CourseRun]
course_runs_by_contract_id: dict[int, list[CourseRun]]
course_runs_by_org_id: dict[int, list[CourseRun]]


@pytest.fixture
def b2b_courses(fake, course_catalog_data):
"""Configure some of the courses as b2b"""
courses, _, _ = course_catalog_data
contract = ContractPageFactory.create()
b2b_courses = []
_, _, runs = course_catalog_data
organizations = OrganizationPageFactory.create_batch(3)
contracts = []
contracts_by_org_id = {}
course_runs = []
course_runs_by_contract_id = defaultdict(list)
course_runs_by_org_id = defaultdict(list)

for org in organizations:
org_contracts = ContractPageFactory.create_batch(3)
contracts_by_org_id[org.id] = org_contracts
contracts.extend(org_contracts)

for run in fake.random_sample(runs, length=ceil(len(runs) * 0.5)):
contract = fake.random_element(elements=contracts)

run.b2b_contract = contract
run.save()

course_runs.append(run)
course_runs_by_contract_id[contract.id].append(run)
course_runs_by_org_id[contract.organization_id].append(run)

return B2BCourses(
organizations=organizations,
contracts_by_org_id=contracts_by_org_id,
course_runs=course_runs,
course_runs_by_contract_id=course_runs_by_contract_id,
course_runs_by_org_id=course_runs_by_org_id,
)

for course in courses:
if fake.boolean(chance_of_getting_true=50):
for run in course.courseruns.all():
run.b2b_contract = contract
run.save()
b2b_courses.append(course)

return b2b_courses
@pytest.fixture
def user_run_enrollment_count(request, course_catalog_data: CourseCatalogData):
course_count = len(course_catalog_data.courses)
count = getattr(request, "param", ceil(course_count * 0.7))
if count > course_count:
return pytest.fail("Cannot have more certificates than courses")
return count


@pytest.fixture
def user_with_enrollments_and_certificates(fake, user, course_catalog_data):
"""
Tests the program enrollments API, which should show the user's enrollment
in programs with the course runs that apply.
"""
courses, programs, _ = course_catalog_data
def user_run_certificate_count(request, user_run_enrollment_count):
count = getattr(request, "param", ceil(user_run_enrollment_count * 0.7))
if count > user_run_enrollment_count:
return pytest.fail("Cannot have more run certificates than run enrollments")
return count

certificate_runs = []

programs_to_enroll_in = fake.random_sample(programs)
programs_with_certificate = fake.random_sample(programs_to_enroll_in)
@pytest.fixture
def user_program_enrollment_count(request, course_catalog_data):
program_count = len(course_catalog_data.programs)
count = getattr(request, "param", ceil(program_count * 0.7))
if count > program_count:
return pytest.fail("Cannot have more certificates than programs")
return count

for program in programs_to_enroll_in:
ProgramEnrollmentFactory.create(user=user, program=program)
courses = [
req.course for req in program.all_requirements.filter(course__isnull=False)
]

if program in programs_with_certificate:
ProgramCertificateFactory.create(user=user, program=program)
@pytest.fixture
def user_program_certificate_count(request, user_program_enrollment_count):
count = getattr(request, "param", ceil(user_program_enrollment_count * 0.7))
if count > user_program_enrollment_count:
return pytest.fail(
"Cannot have more program certificates than program enrollments"
)
return count

for course in courses:
runs = list(course.courseruns.all())
runs_to_enroll_in = fake.random_sample(runs)
runs_with_certificate = fake.random_sample(runs_to_enroll_in)

for run in runs_to_enroll_in:
CourseRunEnrollment.objects.get_or_create(run=run, user=user)
class UserEnrollmentConfig(NamedTuple):
run_enrollment_count: int
run_certificate_count: int
program_enrollment_count: int
program_certificate_count: int

if run in runs_with_certificate and run not in certificate_runs:
CourseRunCertificateFactory.create(user=user, course_run=run)
certificate_runs.append(run)

return user
@pytest.fixture
def user_enrollment_config(
request,
user_run_enrollment_count,
user_run_certificate_count,
user_program_enrollment_count,
user_program_certificate_count,
) -> UserEnrollmentConfig:
return getattr(
request,
"param",
UserEnrollmentConfig(
user_run_enrollment_count,
user_run_certificate_count,
user_program_enrollment_count,
user_program_certificate_count,
),
)


class UserWithEnrollmentsAndCerts(NamedTuple):
user: User
run_enrollments: list[CourseRunEnrollment]
run_certificates: list[CourseRunCertificate]
program_enrollments: list[ProgramEnrollment]
program_certificates: list[ProgramCertificate]


@pytest.fixture
def user_with_enrollments_and_certificates(
fake,
user,
course_catalog_data: CourseCatalogData,
user_enrollment_config: UserEnrollmentConfig,
):
"""
Fixture for a user with enrollments and certificates
"""
_, programs, course_runs = course_catalog_data

programs_to_enroll_in = fake.random_sample(
programs, length=user_enrollment_config.program_enrollment_count
)
programs_with_certificate = fake.random_sample(
programs_to_enroll_in, length=user_enrollment_config.program_enrollment_count
)

runs_to_enroll_in = fake.random_sample(
course_runs, length=user_enrollment_config.run_enrollment_count
)
runs_with_certificate = fake.random_sample(
runs_to_enroll_in, length=user_enrollment_config.run_certificate_count
)

program_enrollments = [
ProgramEnrollmentFactory.create(user=user, program=program)
for program in programs_to_enroll_in
]
program_certificates = [
ProgramCertificateFactory.create(user=user, program=program)
for program in programs_with_certificate
]
run_enrollments = [
CourseRunEnrollmentFactory.create(run=run, user=user)
for run in runs_to_enroll_in
]
run_certificates = [
CourseRunCertificateFactory.create(user=user, course_run=run)
for run in runs_with_certificate
]

return UserWithEnrollmentsAndCerts(
user=user,
run_enrollments=run_enrollments,
run_certificates=run_certificates,
program_enrollments=program_enrollments,
program_certificates=program_certificates,
)
24 changes: 17 additions & 7 deletions courses/serializers/v1/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,26 +151,36 @@ class BaseCourseRunEnrollmentSerializer(serializers.ModelSerializer):
enrollment_mode = serializers.ChoiceField(
(EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE), read_only=True
)
approved_flexible_price_exists = serializers.SerializerMethodField()
grades = CourseRunGradeSerializer(many=True, read_only=True)

@extend_schema_field(bool)
def get_approved_flexible_price_exists(self, instance):
return get_approved_flexible_price_exists(instance, self.context)

class Meta:
model = models.CourseRunEnrollment
fields = [
"run",
"id",
"edx_emails_subscription",
"certificate",
"enrollment_mode",
"approved_flexible_price_exists",
"grades",
]


class BaseCourseRunEnrollmentWithFlexiblePriceSerializer(
BaseCourseRunEnrollmentSerializer
):
approved_flexible_price_exists = serializers.SerializerMethodField()

@extend_schema_field(bool)
def get_approved_flexible_price_exists(self, instance):
return get_approved_flexible_price_exists(instance, self.context)

class Meta(BaseCourseRunEnrollmentSerializer.Meta):
model = models.CourseRunEnrollment
fields = [
*BaseCourseRunEnrollmentSerializer.Meta.fields,
"approved_flexible_price_exists",
]


@extend_schema_field(BaseProductSerializer)
class ProductRelatedField(serializers.RelatedField):
"""Simple serializer for the Product generic field"""
Expand Down
15 changes: 6 additions & 9 deletions courses/serializers/v1/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@
from courses import models
from courses.api import create_run_enrollments
from courses.serializers.v1.base import (
BaseCourseRunEnrollmentSerializer,
BaseCourseRunEnrollmentWithFlexiblePriceSerializer,
BaseCourseRunSerializer,
BaseCourseSerializer,
ProductFlexiblePriceRelatedField,
)
from courses.serializers.v1.departments import DepartmentSerializer
from courses.utils import get_approved_flexible_price_exists
from main import features
from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE


class CourseSerializer(BaseCourseSerializer):
Expand Down Expand Up @@ -159,15 +158,11 @@ class Meta:
]


class CourseRunEnrollmentSerializer(BaseCourseRunEnrollmentSerializer):
class CourseRunEnrollmentSerializer(BaseCourseRunEnrollmentWithFlexiblePriceSerializer):
"""CourseRunEnrollment model serializer"""

run = CourseRunWithCourseSerializer(read_only=True)
run_id = serializers.IntegerField(write_only=True)
enrollment_mode = serializers.ChoiceField(
(EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE), read_only=True
)
approved_flexible_price_exists = serializers.SerializerMethodField()

def create(self, validated_data):
user = self.context["user"]
Expand All @@ -189,7 +184,9 @@ def create(self, validated_data):

return successful_enrollments[0] if successful_enrollments else None

class Meta(BaseCourseRunEnrollmentSerializer.Meta):
fields = BaseCourseRunEnrollmentSerializer.Meta.fields + [ # noqa: RUF005
class Meta(BaseCourseRunEnrollmentWithFlexiblePriceSerializer.Meta):
fields = [
*BaseCourseRunEnrollmentWithFlexiblePriceSerializer.Meta.fields,
"run",
"run_id",
]
Loading
Loading