diff --git a/RELEASE.rst b/RELEASE.rst index ad75ab89a1..caa27b6dc0 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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) --------------- diff --git a/courses/conftest.py b/courses/conftest.py index e7a9d97707..57f72198b8 100644 --- a/courses/conftest.py +++ b/courses/conftest.py @@ -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(): @@ -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. @@ -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): @@ -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, + ) diff --git a/courses/serializers/v1/base.py b/courses/serializers/v1/base.py index 12e73d9adc..a8742329dc 100644 --- a/courses/serializers/v1/base.py +++ b/courses/serializers/v1/base.py @@ -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""" diff --git a/courses/serializers/v1/courses.py b/courses/serializers/v1/courses.py index c608e523ae..9525249306 100644 --- a/courses/serializers/v1/courses.py +++ b/courses/serializers/v1/courses.py @@ -13,7 +13,7 @@ from courses import models from courses.api import create_run_enrollments from courses.serializers.v1.base import ( - BaseCourseRunEnrollmentSerializer, + BaseCourseRunEnrollmentWithFlexiblePriceSerializer, BaseCourseRunSerializer, BaseCourseSerializer, ProductFlexiblePriceRelatedField, @@ -21,7 +21,6 @@ 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): @@ -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"] @@ -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", ] diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index 284e0a8b3d..7d6ea481c0 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -14,7 +14,7 @@ from courses.api import create_run_enrollments from courses.serializers.utils import get_topics_from_page from courses.serializers.v1.base import ( - BaseCourseRunEnrollmentSerializer, + BaseCourseRunEnrollmentWithFlexiblePriceSerializer, BaseCourseRunSerializer, BaseCourseSerializer, BaseProgramSerializer, @@ -23,7 +23,6 @@ from courses.utils import get_approved_flexible_price_exists, get_dated_courseruns from ecommerce.serializers.v0 import BaseProductSerializer from main import features -from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE log = logging.getLogger(__name__) @@ -321,15 +320,11 @@ class Meta: @extend_schema_serializer(component_name="CourseRunEnrollmentRequestV2") -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() b2b_organization_id = serializers.SerializerMethodField() b2b_contract_id = serializers.SerializerMethodField() @@ -367,8 +362,10 @@ def get_b2b_contract_id(self, enrollment): return enrollment.run.b2b_contract.id return None - class Meta(BaseCourseRunEnrollmentSerializer.Meta): - fields = BaseCourseRunEnrollmentSerializer.Meta.fields + [ # noqa: RUF005 + class Meta(BaseCourseRunEnrollmentWithFlexiblePriceSerializer.Meta): + fields = [ + *BaseCourseRunEnrollmentWithFlexiblePriceSerializer.Meta.fields, + "run", "run_id", "b2b_organization_id", "b2b_contract_id", diff --git a/courses/serializers/v3/certificates.py b/courses/serializers/v3/certificates.py new file mode 100644 index 0000000000..1a178f77ca --- /dev/null +++ b/courses/serializers/v3/certificates.py @@ -0,0 +1,22 @@ +from drf_spectacular.utils import extend_schema_serializer +from rest_framework import serializers + +from courses.models import CourseRunCertificate, ProgramCertificate + + +@extend_schema_serializer(component_name="V3ProgramCertificate") +class ProgramCertificateSerializer(serializers.ModelSerializer): + """ProgramCertificate model serializer""" + + class Meta: + model = ProgramCertificate + fields = ["uuid", "link"] + + +@extend_schema_serializer(component_name="V3CourseRunCertificate") +class CourseRunCertificateSerializer(serializers.ModelSerializer): + """CourseRunCertificate model serializer""" + + class Meta: + model = CourseRunCertificate + fields = ["uuid", "link"] diff --git a/courses/serializers/v3/courses.py b/courses/serializers/v3/courses.py new file mode 100644 index 0000000000..25b9d7f9d7 --- /dev/null +++ b/courses/serializers/v3/courses.py @@ -0,0 +1,95 @@ +"""Courses v3 serializers""" + +from __future__ import annotations + +import logging + +from django.conf import settings +from drf_spectacular.utils import extend_schema_field, extend_schema_serializer +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from courses import models +from courses.api import create_run_enrollments +from courses.serializers.v1.base import ( + BaseCourseRunEnrollmentSerializer, + BaseCourseRunSerializer, + BaseCourseSerializer, +) +from courses.serializers.v3.certificates import CourseRunCertificateSerializer +from main import features + +log = logging.getLogger(__name__) + + +@extend_schema_serializer(component_name="CourseV3") +class CourseSerializer(BaseCourseSerializer): + """Course serializer""" + + +@extend_schema_serializer(component_name="CourseRunWithCourseV3") +class CourseRunWithCourseSerializer(BaseCourseRunSerializer): + """CourseRun serializer""" + + course = CourseSerializer(read_only=True) + + class Meta(BaseCourseRunSerializer.Meta): + fields = [ + *BaseCourseRunSerializer.Meta.fields, + "course", + ] + + +@extend_schema_serializer(component_name="CourseRunEnrollmentV3") +class CourseRunEnrollmentSerializer(BaseCourseRunEnrollmentSerializer): + """CourseRunEnrollment model serializer""" + + run = CourseRunWithCourseSerializer(read_only=True) + certificate = CourseRunCertificateSerializer(read_only=True, allow_null=True) + + b2b_organization_id = serializers.SerializerMethodField(read_only=True) + b2b_contract_id = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_b2b_organization_id(self, enrollment): + """Get the B2B organization ID if this enrollment is associated with a B2B contract.""" + if enrollment.run.b2b_contract: + return enrollment.run.b2b_contract.organization_id + return None + + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_b2b_contract_id(self, enrollment): + """Get the B2B contract ID if this enrollment is associated with a B2B contract.""" + return enrollment.run.b2b_contract_id + + def create(self, validated_data): + """Create a new course run enrollment.""" + user = self.context["user"] + run_id = validated_data["run_id"] + run = models.CourseRun.objects.filter(id=run_id).first() + + 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 + ), + ) + if not successful_enrollments: + msg = "Unable to create course run enrollment" + raise ValueError(msg) + + return successful_enrollments[0] + + class Meta(BaseCourseRunEnrollmentSerializer.Meta): + model = models.CourseRunEnrollment + fields = [ + *BaseCourseRunEnrollmentSerializer.Meta.fields, + "run", + "b2b_organization_id", + "b2b_contract_id", + "certificate", + ] diff --git a/courses/serializers/v3/courses_test.py b/courses/serializers/v3/courses_test.py new file mode 100644 index 0000000000..40b0c8a4e2 --- /dev/null +++ b/courses/serializers/v3/courses_test.py @@ -0,0 +1,59 @@ +import pytest + +from b2b.factories import ( + ContractPageFactory, + OrganizationPageFactory, +) +from courses.factories import ( + CourseRunEnrollmentFactory, +) +from courses.serializers.v3.courses import ( + CourseRunEnrollmentSerializer, +) + +pytestmark = [pytest.mark.django_db] + + +class TestCourseRunEnrollmentSerializerV3: + """Test the v3 CourseRunEnrollmentSerializer.""" + + def test_serializer_without_b2b_contract(self): + """Test serialization without B2B contract.""" + enrollment = CourseRunEnrollmentFactory.create() + serialized_data = CourseRunEnrollmentSerializer(enrollment).data + + assert "b2b_organization_id" in serialized_data + assert "b2b_contract_id" in serialized_data + assert serialized_data["b2b_organization_id"] is None + assert serialized_data["b2b_contract_id"] is None + + def test_serializer_with_b2b_contract(self): + """Test serialization with B2B contract.""" + org = OrganizationPageFactory.create() + contract = ContractPageFactory.create(organization=org) + + enrollment = CourseRunEnrollmentFactory.create() + enrollment.run.b2b_contract = contract + enrollment.run.save() + + serialized_data = CourseRunEnrollmentSerializer(enrollment).data + assert serialized_data["b2b_organization_id"] == org.id + assert serialized_data["b2b_contract_id"] == contract.id + + def test_serializer_fields(self): + """Test that all expected fields are present.""" + enrollment = CourseRunEnrollmentFactory.create() + serialized_data = CourseRunEnrollmentSerializer(enrollment).data + + expected_fields = { + "run", + "id", + "edx_emails_subscription", + "enrollment_mode", + "certificate", + "grades", + "b2b_organization_id", + "b2b_contract_id", + } + + assert set(serialized_data.keys()) == expected_fields diff --git a/courses/serializers/v3/programs.py b/courses/serializers/v3/programs.py index ef3c62b659..023231e788 100644 --- a/courses/serializers/v3/programs.py +++ b/courses/serializers/v3/programs.py @@ -3,9 +3,9 @@ from courses.models import ( Program, - ProgramCertificate, ProgramEnrollment, ) +from courses.serializers.v3.certificates import ProgramCertificateSerializer @extend_schema_serializer( @@ -26,15 +26,6 @@ class Meta: ] -@extend_schema_serializer(component_name="V3ProgramCertificate") -class ProgramCertificateSerializer(serializers.ModelSerializer): - """ProgramCertificate model serializer""" - - class Meta: - model = ProgramCertificate - fields = ["uuid", "link"] - - @extend_schema_serializer(component_name="V3UserProgramEnrollment") class ProgramEnrollmentSerializer(serializers.ModelSerializer): """ diff --git a/courses/urls/v3/urls.py b/courses/urls/v3/urls.py index bd8fa8ab40..b5b140a0e3 100644 --- a/courses/urls/v3/urls.py +++ b/courses/urls/v3/urls.py @@ -1,4 +1,4 @@ -"""Course API v2 URL configuration.""" +"""Course API v3 URL configuration.""" from rest_framework import routers @@ -7,6 +7,11 @@ app_name = "courses" router = routers.SimpleRouter() +router.register( + r"enrollments", + v3.UserEnrollmentsApiViewSet, + basename="user_enrollments_api", +) router.register( r"program_enrollments", v3.UserProgramEnrollmentsViewSet, diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index d513e68e84..c9e57e3e4d 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -784,7 +784,7 @@ def test_program_enrollments(user_drf_client, user_with_enrollments_and_certific Tests the program enrollments API, which should show the user's enrollment in programs with the course runs that apply. """ - user = user_with_enrollments_and_certificates + user = user_with_enrollments_and_certificates.user program_enrollments = ( ProgramEnrollment.objects.filter(user=user) diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index f6c52de00e..cdb67fb782 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -1407,7 +1407,7 @@ def test_program_enrollments(user_drf_client, user_with_enrollments_and_certific Tests the program enrollments API, which should show the user's enrollment in programs with the course runs that apply. """ - user = user_with_enrollments_and_certificates + user = user_with_enrollments_and_certificates.user program_enrollments = ( ProgramEnrollment.objects.filter(user=user) diff --git a/courses/views/v3/__init__.py b/courses/views/v3/__init__.py index 61c511f524..2a1a1a1869 100644 --- a/courses/views/v3/__init__.py +++ b/courses/views/v3/__init__.py @@ -2,8 +2,11 @@ Course API Views version 3 """ +import django_filters +from django.conf import settings from django.db.models import Q from django.shortcuts import get_object_or_404 +from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import mixins, status, viewsets @@ -12,16 +15,112 @@ ) from rest_framework.response import Response -from courses.api import create_program_enrollments +from courses.api import create_program_enrollments, deactivate_run_enrollment from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED from courses.models import ( + CourseRunEnrollment, Program, ProgramEnrollment, ) +from courses.serializers.v3.courses import CourseRunEnrollmentSerializer from courses.serializers.v3.programs import ( ProgramEnrollmentCreateSerializer, ProgramEnrollmentSerializer, ) +from main import features + + +class UserEnrollmentFilterSet(django_filters.FilterSet): + """Filter set for user enrollments with B2B organization filtering.""" + + org_id = django_filters.NumberFilter( + method="filter_org_id", + label="Filter by B2B organization ID", + ) + exclude_b2b = django_filters.BooleanFilter( + method="filter_exclude_b2b", + label="Exclude B2B enrollments (enrollments linked to course runs with B2B contracts)", + ) + + class Meta: + model = CourseRunEnrollment + fields = ["org_id", "exclude_b2b"] + + def filter_exclude_b2b(self, queryset, name, value): # noqa: ARG002 + """Filter out B2B enrollments if exclude_b2b is True.""" + if value: + return queryset.filter(run__b2b_contract__isnull=True) + return queryset + + def filter_org_id(self, queryset, name, value): # noqa: ARG002 + """Filter enrollments by B2B organization ID.""" + if value: + return queryset.filter(run__b2b_contract__organization_id=value) + return queryset + + +@extend_schema_view( + list=extend_schema( + operation_id="user_enrollments_list_v3", + description="List user enrollments with B2B organization and contract information - API v3. " + "Use ?exclude_b2b=true to filter out enrollments linked to course runs with B2B contracts. " + "Use ?org_id= to filter enrollments by specific B2B organization.", + ), + create=extend_schema( + operation_id="user_enrollments_create_v3", + description="Create a new user enrollment - API v3", + ), +) +class UserEnrollmentsApiViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """API view set for user enrollments - v3""" + + serializer_class = CourseRunEnrollmentSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend] + filterset_class = UserEnrollmentFilterSet + + queryset = ( + CourseRunEnrollment.objects.select_related( + # these possibly get joined anyway via filer, so select over prefetch + "run", + "run__b2b_contract", + ) + .prefetch_related("run__course", "run__enrollment_modes") + .prefetch("certificate", "grades") + .order_by("id") + ) + + def get_queryset(self): + """Get the queryset for user enrollments.""" + return super().get_queryset().filter(user=self.request.user) + + def get_serializer_context(self): + """Get the serializer context.""" + return {"user": self.request.user} + + @extend_schema( + operation_id="user_enrollments_destroy_v3", + description="Unenroll from a course - API v3", + ) + def destroy(self, request, *args, **kwargs): # noqa: ARG002 + """Unenroll from a course.""" + enrollment = self.get_object() + deactivated_enrollment = deactivate_run_enrollment( + enrollment, + change_status=ENROLL_CHANGE_STATUS_UNENROLLED, + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + if deactivated_enrollment is None: + return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) @extend_schema_view( diff --git a/courses/views/v3/views_test.py b/courses/views/v3/views_test.py index 95f568df8c..ca615b2085 100644 --- a/courses/views/v3/views_test.py +++ b/courses/views/v3/views_test.py @@ -8,20 +8,353 @@ from rest_framework import status from rest_framework.test import APIClient +from courses.conftest import B2BCourses, UserWithEnrollmentsAndCerts from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED from courses.factories import ProgramEnrollmentFactory, ProgramFactory from courses.models import ( ProgramEnrollment, ) from courses.serializers.v3.programs import SimpleProgramSerializer -from courses.test_utils import maybe_serialize_program_cert +from courses.test_utils import maybe_serialize_course_cert, maybe_serialize_program_cert +from main.test_utils import drf_datetime -pytestmark = [pytest.mark.django_db] +pytestmark = [ + pytest.mark.django_db, + pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True), + pytest.mark.parametrize("course_catalog_program_count", [20], indirect=True), + pytest.mark.usefixtures("b2b_courses", "course_catalog_data"), +] + + +def test_user_enrollments_detail( + user_drf_client, + user_with_enrollments_and_certificates: UserWithEnrollmentsAndCerts, +): + """Test that user enrollments can be filtered by B2B organization ID""" + enrollment = user_with_enrollments_and_certificates.run_enrollments[0] + resp = user_drf_client.get( + reverse("v3:user_enrollments_api-detail", kwargs={"pk": enrollment.id}) + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == { + "id": enrollment.id, + "run": { + "id": enrollment.run.id, + "is_archived": enrollment.run.is_enrollable and enrollment.run.is_past, + "is_enrollable": enrollment.run.is_enrollable, + "is_self_paced": enrollment.run.is_self_paced, + "is_upgradable": enrollment.run.is_upgradable, + "live": enrollment.run.live, + "run_tag": enrollment.run.run_tag, + "start_date": drf_datetime(enrollment.run.start_date), + "title": enrollment.run.title, + "upgrade_deadline": drf_datetime(enrollment.run.upgrade_deadline), + "certificate_available_date": drf_datetime( + enrollment.run.certificate_available_date + ), + "course_number": enrollment.run.course_number, + "courseware_id": enrollment.run.courseware_id, + "courseware_url": enrollment.run.courseware_url, + "end_date": drf_datetime(enrollment.run.end_date) + if enrollment.run.end_date + else None, + "enrollment_end": drf_datetime(enrollment.run.enrollment_end), + "enrollment_modes": [], + "enrollment_start": drf_datetime(enrollment.run.enrollment_start), + "expiration_date": drf_datetime(enrollment.run.expiration_date), + "course": { + "id": enrollment.run.course_id, + "readable_id": enrollment.run.course.readable_id, + "title": "Test page", + "type": "course", + }, + }, + "edx_emails_subscription": enrollment.edx_emails_subscription, + "grades": [ + { + "grade": grade.grade, + "letter_grade": grade.letter_grade, + "passed": grade.passed, + "set_by_admin": grade.set_by_admin, + "grade_percent": grade.grade_percent, + } + for grade in enrollment.grades + ], + "b2b_contract_id": enrollment.run.b2b_contract_id, + "b2b_organization_id": enrollment.run.b2b_contract.organization_id + if enrollment.run.b2b_contract + else None, + "enrollment_mode": enrollment.enrollment_mode, + "certificate": maybe_serialize_course_cert(enrollment.run, enrollment.user), + } + + +def test_user_enrollments_list( + user_drf_client, + user_with_enrollments_and_certificates: UserWithEnrollmentsAndCerts, +): + """Test that user enrollments can be filtered by B2B organization ID""" + resp = user_drf_client.get(reverse("v3:user_enrollments_api-list")) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == [ + { + "id": enrollment.id, + "run": { + "id": enrollment.run.id, + "is_archived": enrollment.run.is_enrollable and enrollment.run.is_past, + "is_enrollable": enrollment.run.is_enrollable, + "is_self_paced": enrollment.run.is_self_paced, + "is_upgradable": enrollment.run.is_upgradable, + "live": enrollment.run.live, + "run_tag": enrollment.run.run_tag, + "start_date": drf_datetime(enrollment.run.start_date), + "title": enrollment.run.title, + "upgrade_deadline": drf_datetime(enrollment.run.upgrade_deadline), + "certificate_available_date": drf_datetime( + enrollment.run.certificate_available_date + ), + "course_number": enrollment.run.course_number, + "courseware_id": enrollment.run.courseware_id, + "courseware_url": enrollment.run.courseware_url, + "end_date": drf_datetime(enrollment.run.end_date) + if enrollment.run.end_date + else None, + "enrollment_end": drf_datetime(enrollment.run.enrollment_end), + "enrollment_modes": [], + "enrollment_start": drf_datetime(enrollment.run.enrollment_start), + "expiration_date": drf_datetime(enrollment.run.expiration_date), + "course": { + "id": enrollment.run.course_id, + "readable_id": enrollment.run.course.readable_id, + "title": "Test page", + "type": "course", + }, + }, + "edx_emails_subscription": enrollment.edx_emails_subscription, + "grades": [ + { + "grade": grade.grade, + "letter_grade": grade.letter_grade, + "passed": grade.passed, + "set_by_admin": grade.set_by_admin, + "grade_percent": grade.grade_percent, + } + for grade in enrollment.grades + ], + "b2b_contract_id": enrollment.run.b2b_contract_id, + "b2b_organization_id": enrollment.run.b2b_contract.organization_id + if enrollment.run.b2b_contract + else None, + "enrollment_mode": enrollment.enrollment_mode, + "certificate": maybe_serialize_course_cert(enrollment.run, enrollment.user), + } + for enrollment in user_with_enrollments_and_certificates.run_enrollments + ] + + +def test_user_enrollments_list_filter_org_id( + user_drf_client, + b2b_courses: B2BCourses, + user_with_enrollments_and_certificates: UserWithEnrollmentsAndCerts, +): + """Test that user enrollments can be filtered by B2B organization ID""" + org = b2b_courses.organizations[0] + + for org in b2b_courses.organizations: + resp = user_drf_client.get( + reverse("v3:user_enrollments_api-list"), {"org_id": org.id} + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == [ + { + "id": enrollment.id, + "run": { + "id": enrollment.run.id, + "is_archived": enrollment.run.is_enrollable + and enrollment.run.is_past, + "is_enrollable": enrollment.run.is_enrollable, + "is_self_paced": enrollment.run.is_self_paced, + "is_upgradable": enrollment.run.is_upgradable, + "live": enrollment.run.live, + "run_tag": enrollment.run.run_tag, + "start_date": drf_datetime(enrollment.run.start_date), + "title": enrollment.run.title, + "upgrade_deadline": drf_datetime(enrollment.run.upgrade_deadline), + "certificate_available_date": drf_datetime( + enrollment.run.certificate_available_date + ), + "course_number": enrollment.run.course_number, + "courseware_id": enrollment.run.courseware_id, + "courseware_url": enrollment.run.courseware_url, + "end_date": drf_datetime(enrollment.run.end_date) + if enrollment.run.end_date + else None, + "enrollment_end": drf_datetime(enrollment.run.enrollment_end), + "enrollment_modes": [], + "enrollment_start": drf_datetime(enrollment.run.enrollment_start), + "expiration_date": drf_datetime(enrollment.run.expiration_date), + "course": { + "id": enrollment.run.course_id, + "readable_id": enrollment.run.course.readable_id, + "title": "Test page", + "type": "course", + }, + }, + "edx_emails_subscription": enrollment.edx_emails_subscription, + "grades": [ + { + "grade": grade.grade, + "letter_grade": grade.letter_grade, + "passed": grade.passed, + "set_by_admin": grade.set_by_admin, + "grade_percent": grade.grade_percent, + } + for grade in enrollment.grades + ], + "b2b_contract_id": enrollment.run.b2b_contract_id, + "b2b_organization_id": enrollment.run.b2b_contract.organization_id + if enrollment.run.b2b_contract + else None, + "enrollment_mode": enrollment.enrollment_mode, + "certificate": maybe_serialize_course_cert( + enrollment.run, enrollment.user + ), + } + for enrollment in user_with_enrollments_and_certificates.run_enrollments + if enrollment.run in b2b_courses.course_runs_by_org_id[org.id] + ] + + resp = user_drf_client.get( + reverse("v3:user_enrollments_api-list"), {"org_id": 99999} + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == [] + + +def test_user_enrollments_list_filter_exclude_b2b( + user_drf_client, + b2b_courses: B2BCourses, + user_with_enrollments_and_certificates: UserWithEnrollmentsAndCerts, +): + """Test that user enrollments can be filtered by B2B organization ID""" + resp = user_drf_client.get( + reverse("v3:user_enrollments_api-list"), {"exclude_b2b": True} + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == [ + { + "id": enrollment.id, + "run": { + "id": enrollment.run.id, + "is_archived": enrollment.run.is_enrollable and enrollment.run.is_past, + "is_enrollable": enrollment.run.is_enrollable, + "is_self_paced": enrollment.run.is_self_paced, + "is_upgradable": enrollment.run.is_upgradable, + "live": enrollment.run.live, + "run_tag": enrollment.run.run_tag, + "start_date": drf_datetime(enrollment.run.start_date), + "title": enrollment.run.title, + "upgrade_deadline": drf_datetime(enrollment.run.upgrade_deadline), + "certificate_available_date": drf_datetime( + enrollment.run.certificate_available_date + ), + "course_number": enrollment.run.course_number, + "courseware_id": enrollment.run.courseware_id, + "courseware_url": enrollment.run.courseware_url, + "end_date": drf_datetime(enrollment.run.end_date) + if enrollment.run.end_date + else None, + "enrollment_end": drf_datetime(enrollment.run.enrollment_end), + "enrollment_modes": [], + "enrollment_start": drf_datetime(enrollment.run.enrollment_start), + "expiration_date": drf_datetime(enrollment.run.expiration_date), + "course": { + "id": enrollment.run.course_id, + "readable_id": enrollment.run.course.readable_id, + "title": "Test page", + "type": "course", + }, + }, + "edx_emails_subscription": enrollment.edx_emails_subscription, + "grades": [ + { + "grade": grade.grade, + "letter_grade": grade.letter_grade, + "passed": grade.passed, + "set_by_admin": grade.set_by_admin, + "grade_percent": grade.grade_percent, + } + for grade in enrollment.grades + ], + "b2b_contract_id": None, + "b2b_organization_id": None, + "enrollment_mode": enrollment.enrollment_mode, + "certificate": maybe_serialize_course_cert(enrollment.run, enrollment.user), + } + for enrollment in user_with_enrollments_and_certificates.run_enrollments + if enrollment.run not in b2b_courses.course_runs + ] + + resp = user_drf_client.get( + reverse("v3:user_enrollments_api-list"), {"exclude_b2b": False} + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == [ + { + "id": enrollment.id, + "run": { + "id": enrollment.run.id, + "is_archived": enrollment.run.is_enrollable and enrollment.run.is_past, + "is_enrollable": enrollment.run.is_enrollable, + "is_self_paced": enrollment.run.is_self_paced, + "is_upgradable": enrollment.run.is_upgradable, + "live": enrollment.run.live, + "run_tag": enrollment.run.run_tag, + "start_date": drf_datetime(enrollment.run.start_date), + "title": enrollment.run.title, + "upgrade_deadline": drf_datetime(enrollment.run.upgrade_deadline), + "certificate_available_date": drf_datetime( + enrollment.run.certificate_available_date + ), + "course_number": enrollment.run.course_number, + "courseware_id": enrollment.run.courseware_id, + "courseware_url": enrollment.run.courseware_url, + "end_date": drf_datetime(enrollment.run.end_date) + if enrollment.run.end_date + else None, + "enrollment_end": drf_datetime(enrollment.run.enrollment_end), + "enrollment_modes": [], + "enrollment_start": drf_datetime(enrollment.run.enrollment_start), + "expiration_date": drf_datetime(enrollment.run.expiration_date), + "course": { + "id": enrollment.run.course_id, + "readable_id": enrollment.run.course.readable_id, + "title": "Test page", + "type": "course", + }, + }, + "edx_emails_subscription": enrollment.edx_emails_subscription, + "grades": [ + { + "grade": grade.grade, + "letter_grade": grade.letter_grade, + "passed": grade.passed, + "set_by_admin": grade.set_by_admin, + "grade_percent": grade.grade_percent, + } + for grade in enrollment.grades + ], + "b2b_contract_id": enrollment.run.b2b_contract_id, + "b2b_organization_id": enrollment.run.b2b_contract.organization_id + if enrollment.run.b2b_contract + else None, + "enrollment_mode": enrollment.enrollment_mode, + "certificate": maybe_serialize_course_cert(enrollment.run, enrollment.user), + } + for enrollment in user_with_enrollments_and_certificates.run_enrollments + ] -@pytest.mark.usefixtures("b2b_courses") -@pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True) -@pytest.mark.parametrize("course_catalog_program_count", [20], indirect=True) def test_program_enrollments( user_drf_client, user_with_enrollments_and_certificates, @@ -31,7 +364,7 @@ def test_program_enrollments( Tests the program enrollments API, which should show the user's enrollment in programs with the course runs that apply. """ - user = user_with_enrollments_and_certificates + user = user_with_enrollments_and_certificates.user program_enrollments = ( ProgramEnrollment.objects.filter(user=user) diff --git a/hubspot_sync/api.py b/hubspot_sync/api.py index bbe9dd6418..eab7a156ee 100644 --- a/hubspot_sync/api.py +++ b/hubspot_sync/api.py @@ -2,7 +2,6 @@ import logging import re -import sys from decimal import Decimal from typing import List # noqa: UP035 @@ -723,10 +722,14 @@ def upsert_custom_properties(): """Create or update all custom properties and groups""" for ecommerce_object_type, ecommerce_object in CUSTOM_ECOMMERCE_PROPERTIES.items(): for group in ecommerce_object["groups"]: - sys.stdout.write(f"Adding group {group}\n") + log.debug("Adding group %s", group["name"]) sync_property_group(ecommerce_object_type, group["name"], group["label"]) for obj_property in ecommerce_object["properties"]: - sys.stdout.write(f"Adding property {obj_property}\n") + log.debug( + "Adding property %s for %s", + obj_property.get("name"), + ecommerce_object_type, + ) sync_object_property(ecommerce_object_type, obj_property) sync_object_property("contacts", _get_course_run_certificate_hubspot_property()) sync_object_property("contacts", _get_program_certificate_hubspot_property()) diff --git a/main/settings.py b/main/settings.py index b4a7f795cf..718824451c 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 = "0.141.0" +VERSION = "0.141.1" log = logging.getLogger() diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 1e470f065e..772446edff 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -3076,6 +3076,94 @@ paths: description: No response body '404': description: No response body + /api/v3/enrollments/: + get: + operationId: user_enrollments_list_v3 + description: List user enrollments with B2B organization and contract information + - API v3. Use ?exclude_b2b=true to filter out enrollments linked to course + runs with B2B contracts. Use ?org_id= to filter enrollments by specific + B2B organization. + parameters: + - in: query + name: exclude_b2b + schema: + type: boolean + description: Exclude B2B enrollments (enrollments linked to course runs with + B2B contracts) + - in: query + name: org_id + schema: + type: number + description: Filter by B2B organization ID + tags: + - enrollments + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + post: + operationId: user_enrollments_create_v3 + description: Create a new user enrollment - API v3 + tags: + - enrollments + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + multipart/form-data: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + /api/v3/enrollments/{id}/: + get: + operationId: enrollments_retrieve + description: API view set for user enrollments - v3 + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this course run enrollment. + required: true + tags: + - enrollments + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + delete: + operationId: user_enrollments_destroy_v3 + description: Unenroll from a course - API v3 + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this course run enrollment. + required: true + tags: + - enrollments + responses: + '204': + description: No response body /api/v3/program_enrollments/: get: operationId: v3_program_enrollments_list @@ -3942,10 +4030,6 @@ components: type: object description: CourseRunEnrollment model serializer properties: - run: - allOf: - - $ref: '#/components/schemas/V1CourseRunWithCourse' - readOnly: true id: type: integer readOnly: true @@ -3960,14 +4044,18 @@ components: allOf: - $ref: '#/components/schemas/EnrollmentModeEnum' readOnly: true - approved_flexible_price_exists: - type: boolean - readOnly: true grades: type: array items: $ref: '#/components/schemas/CourseRunGrade' readOnly: true + approved_flexible_price_exists: + type: boolean + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/V1CourseRunWithCourse' + readOnly: true required: - approved_flexible_price_exists - certificate @@ -3990,10 +4078,6 @@ components: type: object description: CourseRunEnrollment model serializer properties: - run: - allOf: - - $ref: '#/components/schemas/V2CourseRunWithCourse' - readOnly: true id: type: integer readOnly: true @@ -4008,14 +4092,18 @@ components: allOf: - $ref: '#/components/schemas/EnrollmentModeEnum' readOnly: true - approved_flexible_price_exists: - type: boolean - readOnly: true grades: type: array items: $ref: '#/components/schemas/CourseRunGrade' readOnly: true + approved_flexible_price_exists: + type: boolean + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/V2CourseRunWithCourse' + readOnly: true b2b_organization_id: type: integer nullable: true @@ -4044,6 +4132,55 @@ components: writeOnly: true required: - run_id + CourseRunEnrollmentV3: + type: object + description: CourseRunEnrollment model serializer + properties: + id: + type: integer + readOnly: true + edx_emails_subscription: + type: boolean + certificate: + allOf: + - $ref: '#/components/schemas/V3CourseRunCertificate' + readOnly: true + nullable: true + enrollment_mode: + allOf: + - $ref: '#/components/schemas/EnrollmentModeEnum' + readOnly: true + grades: + type: array + items: + $ref: '#/components/schemas/CourseRunGrade' + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/CourseRunWithCourseV3' + readOnly: true + b2b_organization_id: + type: integer + nullable: true + readOnly: true + b2b_contract_id: + type: integer + nullable: true + readOnly: true + required: + - b2b_contract_id + - b2b_organization_id + - certificate + - enrollment_mode + - grades + - id + - run + CourseRunEnrollmentV3Request: + type: object + description: CourseRunEnrollment model serializer + properties: + edx_emails_subscription: + type: boolean CourseRunGrade: type: object description: CourseRunGrade serializer @@ -4192,6 +4329,223 @@ components: - products - run_tag - title + CourseRunWithCourseV3: + type: object + description: CourseRun serializer + properties: + title: + type: string + description: The title of the course. This value is synced automatically + with edX studio. + maxLength: 255 + start_date: + type: string + format: date-time + nullable: true + description: The day the course begins. This value is synced automatically + with edX studio. + end_date: + type: string + format: date-time + nullable: true + description: The last day the course is active. This value is synced automatically + with edX studio. + enrollment_start: + type: string + format: date-time + nullable: true + description: The first day students can enroll. This value is synced automatically + with edX studio. + enrollment_end: + type: string + format: date-time + nullable: true + description: The last day students can enroll. This value is synced automatically + with edX studio. + expiration_date: + type: string + format: date-time + nullable: true + description: The date beyond which the learner should not see link to this + course run on their dashboard. + courseware_url: + type: string + nullable: true + description: Get the courseware URL + readOnly: true + courseware_id: + type: string + maxLength: 255 + certificate_available_date: + type: string + format: date-time + nullable: true + description: The day certificates should be available to users. This value + is synced automatically with edX studio. + upgrade_deadline: + type: string + format: date-time + nullable: true + description: The date beyond which the learner can not enroll in paid course + mode. + is_upgradable: + type: boolean + description: Check if the course run is upgradable + readOnly: true + is_enrollable: + type: boolean + description: Check if the course run is enrollable + readOnly: true + is_archived: + type: boolean + description: Check if the course run is archived + readOnly: true + is_self_paced: + type: boolean + run_tag: + type: string + description: 'A string that identifies the set of runs that this run belongs + to (example: ''R2'')' + maxLength: 100 + id: + type: integer + readOnly: true + live: + type: boolean + course_number: + type: string + description: Get the course number + readOnly: true + enrollment_modes: + type: array + items: + $ref: '#/components/schemas/EnrollmentMode' + readOnly: true + course: + allOf: + - $ref: '#/components/schemas/CourseV3' + readOnly: true + required: + - course + - course_number + - courseware_id + - courseware_url + - enrollment_modes + - id + - is_archived + - is_enrollable + - is_upgradable + - run_tag + - title + CourseRunWithCourseV3Request: + type: object + description: CourseRun serializer + properties: + title: + type: string + minLength: 1 + description: The title of the course. This value is synced automatically + with edX studio. + maxLength: 255 + start_date: + type: string + format: date-time + nullable: true + description: The day the course begins. This value is synced automatically + with edX studio. + end_date: + type: string + format: date-time + nullable: true + description: The last day the course is active. This value is synced automatically + with edX studio. + enrollment_start: + type: string + format: date-time + nullable: true + description: The first day students can enroll. This value is synced automatically + with edX studio. + enrollment_end: + type: string + format: date-time + nullable: true + description: The last day students can enroll. This value is synced automatically + with edX studio. + expiration_date: + type: string + format: date-time + nullable: true + description: The date beyond which the learner should not see link to this + course run on their dashboard. + courseware_id: + type: string + minLength: 1 + maxLength: 255 + certificate_available_date: + type: string + format: date-time + nullable: true + description: The day certificates should be available to users. This value + is synced automatically with edX studio. + upgrade_deadline: + type: string + format: date-time + nullable: true + description: The date beyond which the learner can not enroll in paid course + mode. + is_self_paced: + type: boolean + run_tag: + type: string + minLength: 1 + description: 'A string that identifies the set of runs that this run belongs + to (example: ''R2'')' + maxLength: 100 + live: + type: boolean + required: + - courseware_id + - run_tag + - title + CourseV3: + type: object + description: Course serializer + properties: + id: + type: integer + readOnly: true + title: + type: string + maxLength: 255 + readable_id: + type: string + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + type: + type: string + description: Returns the type of object this is serializing. + readOnly: true + required: + - id + - readable_id + - title + - type + CourseV3Request: + type: object + description: Course serializer + properties: + title: + type: string + minLength: 1 + maxLength: 255 + readable_id: + type: string + minLength: 1 + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + required: + - readable_id + - title CourseWithCourseRunsSerializerV2: type: object description: Course model serializer - also serializes child course runs @@ -8331,6 +8685,24 @@ components: - certificate - enrollments - program + V3CourseRunCertificate: + type: object + description: CourseRunCertificate model serializer + properties: + uuid: + type: string + format: uuid + readOnly: true + link: + type: string + description: |- + Get the link at which this certificate will be served + Format: /certificate// + Example: /certificate/93ebd74e-5f88-4b47-bb09-30a6d575328f/ + readOnly: true + required: + - link + - uuid V3ProgramCertificate: type: object description: ProgramCertificate model serializer diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 2f6481c87f..f42c9ad59d 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -3076,6 +3076,94 @@ paths: description: No response body '404': description: No response body + /api/v3/enrollments/: + get: + operationId: user_enrollments_list_v3 + description: List user enrollments with B2B organization and contract information + - API v3. Use ?exclude_b2b=true to filter out enrollments linked to course + runs with B2B contracts. Use ?org_id= to filter enrollments by specific + B2B organization. + parameters: + - in: query + name: exclude_b2b + schema: + type: boolean + description: Exclude B2B enrollments (enrollments linked to course runs with + B2B contracts) + - in: query + name: org_id + schema: + type: number + description: Filter by B2B organization ID + tags: + - enrollments + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + post: + operationId: user_enrollments_create_v3 + description: Create a new user enrollment - API v3 + tags: + - enrollments + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + multipart/form-data: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + /api/v3/enrollments/{id}/: + get: + operationId: enrollments_retrieve + description: API view set for user enrollments - v3 + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this course run enrollment. + required: true + tags: + - enrollments + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + delete: + operationId: user_enrollments_destroy_v3 + description: Unenroll from a course - API v3 + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this course run enrollment. + required: true + tags: + - enrollments + responses: + '204': + description: No response body /api/v3/program_enrollments/: get: operationId: v3_program_enrollments_list @@ -3942,10 +4030,6 @@ components: type: object description: CourseRunEnrollment model serializer properties: - run: - allOf: - - $ref: '#/components/schemas/V1CourseRunWithCourse' - readOnly: true id: type: integer readOnly: true @@ -3960,14 +4044,18 @@ components: allOf: - $ref: '#/components/schemas/EnrollmentModeEnum' readOnly: true - approved_flexible_price_exists: - type: boolean - readOnly: true grades: type: array items: $ref: '#/components/schemas/CourseRunGrade' readOnly: true + approved_flexible_price_exists: + type: boolean + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/V1CourseRunWithCourse' + readOnly: true required: - approved_flexible_price_exists - certificate @@ -3990,10 +4078,6 @@ components: type: object description: CourseRunEnrollment model serializer properties: - run: - allOf: - - $ref: '#/components/schemas/V2CourseRunWithCourse' - readOnly: true id: type: integer readOnly: true @@ -4008,14 +4092,18 @@ components: allOf: - $ref: '#/components/schemas/EnrollmentModeEnum' readOnly: true - approved_flexible_price_exists: - type: boolean - readOnly: true grades: type: array items: $ref: '#/components/schemas/CourseRunGrade' readOnly: true + approved_flexible_price_exists: + type: boolean + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/V2CourseRunWithCourse' + readOnly: true b2b_organization_id: type: integer nullable: true @@ -4044,6 +4132,55 @@ components: writeOnly: true required: - run_id + CourseRunEnrollmentV3: + type: object + description: CourseRunEnrollment model serializer + properties: + id: + type: integer + readOnly: true + edx_emails_subscription: + type: boolean + certificate: + allOf: + - $ref: '#/components/schemas/V3CourseRunCertificate' + readOnly: true + nullable: true + enrollment_mode: + allOf: + - $ref: '#/components/schemas/EnrollmentModeEnum' + readOnly: true + grades: + type: array + items: + $ref: '#/components/schemas/CourseRunGrade' + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/CourseRunWithCourseV3' + readOnly: true + b2b_organization_id: + type: integer + nullable: true + readOnly: true + b2b_contract_id: + type: integer + nullable: true + readOnly: true + required: + - b2b_contract_id + - b2b_organization_id + - certificate + - enrollment_mode + - grades + - id + - run + CourseRunEnrollmentV3Request: + type: object + description: CourseRunEnrollment model serializer + properties: + edx_emails_subscription: + type: boolean CourseRunGrade: type: object description: CourseRunGrade serializer @@ -4192,6 +4329,223 @@ components: - products - run_tag - title + CourseRunWithCourseV3: + type: object + description: CourseRun serializer + properties: + title: + type: string + description: The title of the course. This value is synced automatically + with edX studio. + maxLength: 255 + start_date: + type: string + format: date-time + nullable: true + description: The day the course begins. This value is synced automatically + with edX studio. + end_date: + type: string + format: date-time + nullable: true + description: The last day the course is active. This value is synced automatically + with edX studio. + enrollment_start: + type: string + format: date-time + nullable: true + description: The first day students can enroll. This value is synced automatically + with edX studio. + enrollment_end: + type: string + format: date-time + nullable: true + description: The last day students can enroll. This value is synced automatically + with edX studio. + expiration_date: + type: string + format: date-time + nullable: true + description: The date beyond which the learner should not see link to this + course run on their dashboard. + courseware_url: + type: string + nullable: true + description: Get the courseware URL + readOnly: true + courseware_id: + type: string + maxLength: 255 + certificate_available_date: + type: string + format: date-time + nullable: true + description: The day certificates should be available to users. This value + is synced automatically with edX studio. + upgrade_deadline: + type: string + format: date-time + nullable: true + description: The date beyond which the learner can not enroll in paid course + mode. + is_upgradable: + type: boolean + description: Check if the course run is upgradable + readOnly: true + is_enrollable: + type: boolean + description: Check if the course run is enrollable + readOnly: true + is_archived: + type: boolean + description: Check if the course run is archived + readOnly: true + is_self_paced: + type: boolean + run_tag: + type: string + description: 'A string that identifies the set of runs that this run belongs + to (example: ''R2'')' + maxLength: 100 + id: + type: integer + readOnly: true + live: + type: boolean + course_number: + type: string + description: Get the course number + readOnly: true + enrollment_modes: + type: array + items: + $ref: '#/components/schemas/EnrollmentMode' + readOnly: true + course: + allOf: + - $ref: '#/components/schemas/CourseV3' + readOnly: true + required: + - course + - course_number + - courseware_id + - courseware_url + - enrollment_modes + - id + - is_archived + - is_enrollable + - is_upgradable + - run_tag + - title + CourseRunWithCourseV3Request: + type: object + description: CourseRun serializer + properties: + title: + type: string + minLength: 1 + description: The title of the course. This value is synced automatically + with edX studio. + maxLength: 255 + start_date: + type: string + format: date-time + nullable: true + description: The day the course begins. This value is synced automatically + with edX studio. + end_date: + type: string + format: date-time + nullable: true + description: The last day the course is active. This value is synced automatically + with edX studio. + enrollment_start: + type: string + format: date-time + nullable: true + description: The first day students can enroll. This value is synced automatically + with edX studio. + enrollment_end: + type: string + format: date-time + nullable: true + description: The last day students can enroll. This value is synced automatically + with edX studio. + expiration_date: + type: string + format: date-time + nullable: true + description: The date beyond which the learner should not see link to this + course run on their dashboard. + courseware_id: + type: string + minLength: 1 + maxLength: 255 + certificate_available_date: + type: string + format: date-time + nullable: true + description: The day certificates should be available to users. This value + is synced automatically with edX studio. + upgrade_deadline: + type: string + format: date-time + nullable: true + description: The date beyond which the learner can not enroll in paid course + mode. + is_self_paced: + type: boolean + run_tag: + type: string + minLength: 1 + description: 'A string that identifies the set of runs that this run belongs + to (example: ''R2'')' + maxLength: 100 + live: + type: boolean + required: + - courseware_id + - run_tag + - title + CourseV3: + type: object + description: Course serializer + properties: + id: + type: integer + readOnly: true + title: + type: string + maxLength: 255 + readable_id: + type: string + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + type: + type: string + description: Returns the type of object this is serializing. + readOnly: true + required: + - id + - readable_id + - title + - type + CourseV3Request: + type: object + description: Course serializer + properties: + title: + type: string + minLength: 1 + maxLength: 255 + readable_id: + type: string + minLength: 1 + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + required: + - readable_id + - title CourseWithCourseRunsSerializerV2: type: object description: Course model serializer - also serializes child course runs @@ -8331,6 +8685,24 @@ components: - certificate - enrollments - program + V3CourseRunCertificate: + type: object + description: CourseRunCertificate model serializer + properties: + uuid: + type: string + format: uuid + readOnly: true + link: + type: string + description: |- + Get the link at which this certificate will be served + Format: /certificate// + Example: /certificate/93ebd74e-5f88-4b47-bb09-30a6d575328f/ + readOnly: true + required: + - link + - uuid V3ProgramCertificate: type: object description: ProgramCertificate model serializer diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index a7e9f6448d..f43140b622 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -3076,6 +3076,94 @@ paths: description: No response body '404': description: No response body + /api/v3/enrollments/: + get: + operationId: user_enrollments_list_v3 + description: List user enrollments with B2B organization and contract information + - API v3. Use ?exclude_b2b=true to filter out enrollments linked to course + runs with B2B contracts. Use ?org_id= to filter enrollments by specific + B2B organization. + parameters: + - in: query + name: exclude_b2b + schema: + type: boolean + description: Exclude B2B enrollments (enrollments linked to course runs with + B2B contracts) + - in: query + name: org_id + schema: + type: number + description: Filter by B2B organization ID + tags: + - enrollments + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + post: + operationId: user_enrollments_create_v3 + description: Create a new user enrollment - API v3 + tags: + - enrollments + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + multipart/form-data: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3Request' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + /api/v3/enrollments/{id}/: + get: + operationId: enrollments_retrieve + description: API view set for user enrollments - v3 + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this course run enrollment. + required: true + tags: + - enrollments + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CourseRunEnrollmentV3' + description: '' + delete: + operationId: user_enrollments_destroy_v3 + description: Unenroll from a course - API v3 + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this course run enrollment. + required: true + tags: + - enrollments + responses: + '204': + description: No response body /api/v3/program_enrollments/: get: operationId: v3_program_enrollments_list @@ -3942,10 +4030,6 @@ components: type: object description: CourseRunEnrollment model serializer properties: - run: - allOf: - - $ref: '#/components/schemas/V1CourseRunWithCourse' - readOnly: true id: type: integer readOnly: true @@ -3960,14 +4044,18 @@ components: allOf: - $ref: '#/components/schemas/EnrollmentModeEnum' readOnly: true - approved_flexible_price_exists: - type: boolean - readOnly: true grades: type: array items: $ref: '#/components/schemas/CourseRunGrade' readOnly: true + approved_flexible_price_exists: + type: boolean + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/V1CourseRunWithCourse' + readOnly: true required: - approved_flexible_price_exists - certificate @@ -3990,10 +4078,6 @@ components: type: object description: CourseRunEnrollment model serializer properties: - run: - allOf: - - $ref: '#/components/schemas/V2CourseRunWithCourse' - readOnly: true id: type: integer readOnly: true @@ -4008,14 +4092,18 @@ components: allOf: - $ref: '#/components/schemas/EnrollmentModeEnum' readOnly: true - approved_flexible_price_exists: - type: boolean - readOnly: true grades: type: array items: $ref: '#/components/schemas/CourseRunGrade' readOnly: true + approved_flexible_price_exists: + type: boolean + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/V2CourseRunWithCourse' + readOnly: true b2b_organization_id: type: integer nullable: true @@ -4044,6 +4132,55 @@ components: writeOnly: true required: - run_id + CourseRunEnrollmentV3: + type: object + description: CourseRunEnrollment model serializer + properties: + id: + type: integer + readOnly: true + edx_emails_subscription: + type: boolean + certificate: + allOf: + - $ref: '#/components/schemas/V3CourseRunCertificate' + readOnly: true + nullable: true + enrollment_mode: + allOf: + - $ref: '#/components/schemas/EnrollmentModeEnum' + readOnly: true + grades: + type: array + items: + $ref: '#/components/schemas/CourseRunGrade' + readOnly: true + run: + allOf: + - $ref: '#/components/schemas/CourseRunWithCourseV3' + readOnly: true + b2b_organization_id: + type: integer + nullable: true + readOnly: true + b2b_contract_id: + type: integer + nullable: true + readOnly: true + required: + - b2b_contract_id + - b2b_organization_id + - certificate + - enrollment_mode + - grades + - id + - run + CourseRunEnrollmentV3Request: + type: object + description: CourseRunEnrollment model serializer + properties: + edx_emails_subscription: + type: boolean CourseRunGrade: type: object description: CourseRunGrade serializer @@ -4192,6 +4329,223 @@ components: - products - run_tag - title + CourseRunWithCourseV3: + type: object + description: CourseRun serializer + properties: + title: + type: string + description: The title of the course. This value is synced automatically + with edX studio. + maxLength: 255 + start_date: + type: string + format: date-time + nullable: true + description: The day the course begins. This value is synced automatically + with edX studio. + end_date: + type: string + format: date-time + nullable: true + description: The last day the course is active. This value is synced automatically + with edX studio. + enrollment_start: + type: string + format: date-time + nullable: true + description: The first day students can enroll. This value is synced automatically + with edX studio. + enrollment_end: + type: string + format: date-time + nullable: true + description: The last day students can enroll. This value is synced automatically + with edX studio. + expiration_date: + type: string + format: date-time + nullable: true + description: The date beyond which the learner should not see link to this + course run on their dashboard. + courseware_url: + type: string + nullable: true + description: Get the courseware URL + readOnly: true + courseware_id: + type: string + maxLength: 255 + certificate_available_date: + type: string + format: date-time + nullable: true + description: The day certificates should be available to users. This value + is synced automatically with edX studio. + upgrade_deadline: + type: string + format: date-time + nullable: true + description: The date beyond which the learner can not enroll in paid course + mode. + is_upgradable: + type: boolean + description: Check if the course run is upgradable + readOnly: true + is_enrollable: + type: boolean + description: Check if the course run is enrollable + readOnly: true + is_archived: + type: boolean + description: Check if the course run is archived + readOnly: true + is_self_paced: + type: boolean + run_tag: + type: string + description: 'A string that identifies the set of runs that this run belongs + to (example: ''R2'')' + maxLength: 100 + id: + type: integer + readOnly: true + live: + type: boolean + course_number: + type: string + description: Get the course number + readOnly: true + enrollment_modes: + type: array + items: + $ref: '#/components/schemas/EnrollmentMode' + readOnly: true + course: + allOf: + - $ref: '#/components/schemas/CourseV3' + readOnly: true + required: + - course + - course_number + - courseware_id + - courseware_url + - enrollment_modes + - id + - is_archived + - is_enrollable + - is_upgradable + - run_tag + - title + CourseRunWithCourseV3Request: + type: object + description: CourseRun serializer + properties: + title: + type: string + minLength: 1 + description: The title of the course. This value is synced automatically + with edX studio. + maxLength: 255 + start_date: + type: string + format: date-time + nullable: true + description: The day the course begins. This value is synced automatically + with edX studio. + end_date: + type: string + format: date-time + nullable: true + description: The last day the course is active. This value is synced automatically + with edX studio. + enrollment_start: + type: string + format: date-time + nullable: true + description: The first day students can enroll. This value is synced automatically + with edX studio. + enrollment_end: + type: string + format: date-time + nullable: true + description: The last day students can enroll. This value is synced automatically + with edX studio. + expiration_date: + type: string + format: date-time + nullable: true + description: The date beyond which the learner should not see link to this + course run on their dashboard. + courseware_id: + type: string + minLength: 1 + maxLength: 255 + certificate_available_date: + type: string + format: date-time + nullable: true + description: The day certificates should be available to users. This value + is synced automatically with edX studio. + upgrade_deadline: + type: string + format: date-time + nullable: true + description: The date beyond which the learner can not enroll in paid course + mode. + is_self_paced: + type: boolean + run_tag: + type: string + minLength: 1 + description: 'A string that identifies the set of runs that this run belongs + to (example: ''R2'')' + maxLength: 100 + live: + type: boolean + required: + - courseware_id + - run_tag + - title + CourseV3: + type: object + description: Course serializer + properties: + id: + type: integer + readOnly: true + title: + type: string + maxLength: 255 + readable_id: + type: string + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + type: + type: string + description: Returns the type of object this is serializing. + readOnly: true + required: + - id + - readable_id + - title + - type + CourseV3Request: + type: object + description: Course serializer + properties: + title: + type: string + minLength: 1 + maxLength: 255 + readable_id: + type: string + minLength: 1 + pattern: ^[\w\-+:\.]+$ + maxLength: 255 + required: + - readable_id + - title CourseWithCourseRunsSerializerV2: type: object description: Course model serializer - also serializes child course runs @@ -8331,6 +8685,24 @@ components: - certificate - enrollments - program + V3CourseRunCertificate: + type: object + description: CourseRunCertificate model serializer + properties: + uuid: + type: string + format: uuid + readOnly: true + link: + type: string + description: |- + Get the link at which this certificate will be served + Format: /certificate// + Example: /certificate/93ebd74e-5f88-4b47-bb09-30a6d575328f/ + readOnly: true + required: + - link + - uuid V3ProgramCertificate: type: object description: ProgramCertificate model serializer