From a277e3719f66e56e8227c8b4ad32322ba1aea747 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Mon, 8 Jun 2026 11:41:00 +0500 Subject: [PATCH] feat: apply ADRs standardization to enrollment apis --- .../contentstore/rest_api/mixins.py | 29 - .../contentstore/rest_api/v1/views/xblock.py | 2 +- .../rest_api/v3/tests/test_home.py | 2 +- .../contentstore/rest_api/v3/views/home.py | 2 +- .../contentstore/rest_api/v4/views/home.py | 2 +- lms/urls.py | 1 + .../djangoapps/enrollments/v2/__init__.py | 0 .../core/djangoapps/enrollments/v2/forms.py | 128 ++++ .../djangoapps/enrollments/v2/paginators.py | 29 + .../djangoapps/enrollments/v2/serializers.py | 34 + .../enrollments/v2/tests/__init__.py | 0 .../enrollments/v2/tests/test_envelope.py | 129 ++++ .../v2/tests/test_view_services.py | 87 +++ .../enrollments/v2/tests/test_views.py | 236 +++++++ .../core/djangoapps/enrollments/v2/urls.py | 73 ++ .../enrollments/v2/view_services.py | 376 +++++++++++ .../core/djangoapps/enrollments/v2/views.py | 626 ++++++++++++++++++ openedx/core/lib/api/mixins.py | 23 + 18 files changed, 1746 insertions(+), 33 deletions(-) delete mode 100644 cms/djangoapps/contentstore/rest_api/mixins.py create mode 100644 openedx/core/djangoapps/enrollments/v2/__init__.py create mode 100644 openedx/core/djangoapps/enrollments/v2/forms.py create mode 100644 openedx/core/djangoapps/enrollments/v2/paginators.py create mode 100644 openedx/core/djangoapps/enrollments/v2/serializers.py create mode 100644 openedx/core/djangoapps/enrollments/v2/tests/__init__.py create mode 100644 openedx/core/djangoapps/enrollments/v2/tests/test_envelope.py create mode 100644 openedx/core/djangoapps/enrollments/v2/tests/test_view_services.py create mode 100644 openedx/core/djangoapps/enrollments/v2/tests/test_views.py create mode 100644 openedx/core/djangoapps/enrollments/v2/urls.py create mode 100644 openedx/core/djangoapps/enrollments/v2/view_services.py create mode 100644 openedx/core/djangoapps/enrollments/v2/views.py diff --git a/cms/djangoapps/contentstore/rest_api/mixins.py b/cms/djangoapps/contentstore/rest_api/mixins.py deleted file mode 100644 index 61f1ff82b4b6..000000000000 --- a/cms/djangoapps/contentstore/rest_api/mixins.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Shared mixins for the contentstore REST API (used across versions). - -Currently provides :class:`StandardizedErrorMixin`, which opts a single -view/viewset into the ADR 0029 error envelope without changing the -project-wide DRF ``EXCEPTION_HANDLER`` setting. -""" -from openedx.core.lib.api.exceptions import standardized_error_exception_handler - - -class StandardizedErrorMixin: - """ - Opt-in mixin that routes DRF exceptions on this view through the ADR 0029 - standardized error-response handler (see - ``openedx.core.lib.api.exceptions.standardized_error_exception_handler``). - - DRF's :class:`rest_framework.views.APIView` calls ``self.get_exception_handler`` - inside ``handle_exception``; overriding that method here lets the view - return the standardized envelope while other endpoints continue to use - whichever handler the project-wide ``EXCEPTION_HANDLER`` setting points at. - - Usage:: - - class MyViewSet(StandardizedErrorMixin, viewsets.ViewSet): - ... - """ - - def get_exception_handler(self): - return standardized_error_exception_handler diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py index a3d2e38a3ba7..acd4a8192ad9 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py @@ -19,7 +19,6 @@ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from cms.djangoapps.contentstore.rest_api.mixins import StandardizedErrorMixin from cms.djangoapps.contentstore.rest_api.v0.serializers import XblockSerializer from cms.djangoapps.contentstore.rest_api.v0.views.utils import validate_request_with_serializer from cms.djangoapps.contentstore.rest_api.v1.views.permissions import HasCourseAuthorAccess @@ -30,6 +29,7 @@ update_xblock_response, ) from common.djangoapps.util.json_request import expect_json_in_class_view +from openedx.core.lib.api.mixins import StandardizedErrorMixin log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py index 50f14bc361f6..54c08b0a3894 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py @@ -2,7 +2,7 @@ ADR 0029 – Standardized error-response regression tests for HomeViewSet (v3). The ADR 0029 envelope is wired into the v3 viewset via -:class:`cms.djangoapps.contentstore.rest_api.mixins.StandardizedErrorMixin`, +:class:`openedx.core.lib.api.mixins.StandardizedErrorMixin`, which overrides DRF's per-view ``get_exception_handler`` to point at ``openedx.core.lib.api.exceptions.standardized_error_exception_handler``. diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/home.py b/cms/djangoapps/contentstore/rest_api/v3/views/home.py index 6f2bcfe63acb..2e11b191806e 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -25,13 +25,13 @@ from rest_framework.request import Request from rest_framework.response import Response -from cms.djangoapps.contentstore.rest_api.mixins import StandardizedErrorMixin from cms.djangoapps.contentstore.rest_api.v1.serializers import ( CourseHomeTabSerializer, LibraryTabSerializer, StudioHomeSerializer, ) from cms.djangoapps.contentstore.utils import get_course_context, get_home_context, get_library_context +from openedx.core.lib.api.mixins import StandardizedErrorMixin class HomeViewSet(StandardizedErrorMixin, viewsets.ViewSet): diff --git a/cms/djangoapps/contentstore/rest_api/v4/views/home.py b/cms/djangoapps/contentstore/rest_api/v4/views/home.py index 3013f05e9b28..8f0fedc1cfed 100644 --- a/cms/djangoapps/contentstore/rest_api/v4/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v4/views/home.py @@ -11,11 +11,11 @@ from rest_framework.request import Request from rest_framework.response import Response -from cms.djangoapps.contentstore.rest_api.mixins import StandardizedErrorMixin from cms.djangoapps.contentstore.rest_api.v4.serializers.home import ( CourseHomeTabSerializerV4, ) from cms.djangoapps.contentstore.utils import get_course_context_v2 +from openedx.core.lib.api.mixins import StandardizedErrorMixin class HomePageCoursesPaginator(DefaultPagination): diff --git a/lms/urls.py b/lms/urls.py index caa95569f3e0..280c739c9bce 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -117,6 +117,7 @@ # Enrollment API RESTful endpoints path('api/enrollment/v1/', include('openedx.core.djangoapps.enrollments.urls')), + path('api/enrollment/v2/', include('openedx.core.djangoapps.enrollments.v2.urls')), # Agreements API RESTful endpoints path('api/agreements/v1/', include('openedx.core.djangoapps.agreements.urls')), diff --git a/openedx/core/djangoapps/enrollments/v2/__init__.py b/openedx/core/djangoapps/enrollments/v2/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/enrollments/v2/forms.py b/openedx/core/djangoapps/enrollments/v2/forms.py new file mode 100644 index 000000000000..413bb870bb87 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/forms.py @@ -0,0 +1,128 @@ +""" +Forms for validating user input to the Course Enrollment v2 views. + +ADR 0033 (OEP-68 parameter naming standardization) — accepts both the +preferred parameter names (``course_key``, ``course_keys``) and the legacy +aliases (``course_id``, ``course_ids``). When both are present, the +preferred name wins. Use :meth:`legacy_param_aliases_used` from the view +layer to emit the ADR 0033 ``Deprecation`` HTTP header when a legacy alias +was sent. + +Internally the cleaned_data continues to expose ``course_id`` / +``course_ids`` (the names the queryset code reads) — the form coalesces +the preferred values onto those fields before the rest of validation runs. +""" + +from django.core.exceptions import ValidationError +from django.forms import CharField, Form +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.user_authn.views.registration_form import validate_username + + +class EnrollmentsAdminListForm(Form): + """ + Validates the query string parameters for the v2 admin enrollments list + endpoint (``GET /api/enrollment/v2/enrollments/``). + """ + + MAX_INPUT_COUNT = 100 + # Legacy / OEP-68 alias pairs: (legacy, preferred). + _LEGACY_PARAM_ALIASES = ( + ("course_id", "course_key"), + ("course_ids", "course_keys"), + ) + + username = CharField(required=False) + course_id = CharField(required=False) + course_key = CharField(required=False) + course_ids = CharField(required=False) + course_keys = CharField(required=False) + email = CharField(required=False) + + def __init__(self, query_params, *args, **kwargs): + # Capture the raw param names supplied on the wire (before Django's + # form layer resolves aliases) so :meth:`legacy_param_aliases_used` + # can later report exactly which legacy names were used. + try: + raw_keys = set(query_params.keys()) + except AttributeError: + raw_keys = set() + self._raw_param_names = raw_keys + + # Coalesce OEP-68 preferred names onto the legacy field names so the + # downstream queryset code keeps reading ``course_id`` / ``course_ids`` + # without changes. The preferred name wins when both are sent. + if hasattr(query_params, "copy"): + data = query_params.copy() + else: + data = dict(query_params) + for legacy_name, preferred_name in self._LEGACY_PARAM_ALIASES: + preferred_value = data.get(preferred_name) + if preferred_value: + data[legacy_name] = preferred_value + + super().__init__(data, *args, **kwargs) + + def legacy_param_aliases_used(self): + """ + Return the list of legacy parameter names that were actually present + in the request, in declaration order. The view layer uses this to + emit the ADR 0033 ``Deprecation`` header. + """ + return [ + legacy for legacy, _preferred in self._LEGACY_PARAM_ALIASES + if legacy in self._raw_param_names + ] + + def clean_course_id(self): + """Parse and validate the ``course_id`` (or aliased ``course_key``) parameter.""" + course_id = self.cleaned_data.get("course_id") + if course_id: + try: + return CourseKey.from_string(course_id) + except InvalidKeyError as exc: + raise ValidationError(f"'{course_id}' is not a valid course id.") from exc + return course_id + + def clean_course_ids(self): + """Split the ``course_ids`` CSV (or aliased ``course_keys``) and enforce MAX_INPUT_COUNT.""" + course_ids_csv = self.cleaned_data.get("course_ids") + if course_ids_csv: + course_ids = course_ids_csv.split(",") + if len(course_ids) > self.MAX_INPUT_COUNT: + raise ValidationError( + f"Too many course_ids in a single request - {len(course_ids)}. " + f"A maximum of {self.MAX_INPUT_COUNT} is allowed" + ) + return course_ids + return course_ids_csv + + def clean_username(self): + """Split the ``username`` CSV, validate each entry, and enforce MAX_INPUT_COUNT.""" + usernames_csv = self.cleaned_data.get("username") + if usernames_csv: + usernames = usernames_csv.split(",") + if len(usernames) > self.MAX_INPUT_COUNT: + raise ValidationError( + f"Too many usernames in a single request - {len(usernames)}. " + f"A maximum of {self.MAX_INPUT_COUNT} is allowed" + ) + for username in usernames: + validate_username(username) + return usernames + return usernames_csv + + def clean_email(self): + """Split the ``email`` CSV and enforce MAX_INPUT_COUNT.""" + emails_csv = self.cleaned_data.get("email") + if emails_csv: + emails = emails_csv.split(",") + if len(emails) > self.MAX_INPUT_COUNT: + raise ValidationError( + f"Too many emails in a single request - {len(emails)}. " + f"A maximum of {self.MAX_INPUT_COUNT} is allowed" + ) + return emails + return emails_csv diff --git a/openedx/core/djangoapps/enrollments/v2/paginators.py b/openedx/core/djangoapps/enrollments/v2/paginators.py new file mode 100644 index 000000000000..d8d9eed96c77 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/paginators.py @@ -0,0 +1,29 @@ +""" +Pagination for the Enrollment API — v2. + +ADR 0032 — uses :class:`DefaultPagination` from +``edx-rest-framework-extensions``, which provides the standard 7-field +envelope: ``count``, ``num_pages``, ``current_page``, ``start``, ``next``, +``previous``, ``results``. + +Distinct from v1's :class:`openedx.core.djangoapps.enrollments.paginators.CourseEnrollmentsApiListPagination` +(which is a :class:`CursorPagination` subclass with a 3-field envelope). +v2 introduces the new shape — clients that need the legacy shape stay on +``/api/enrollment/v1/`` until they migrate. +""" + +from edx_rest_framework_extensions.paginators import DefaultPagination + + +class EnrollmentsAdminListPagination(DefaultPagination): + """ + ADR 0032 — standard pagination for the admin enrollments list API + (GET /api/enrollment/v2/enrollments/). + + Defaults sized for an admin-facing bulk-query endpoint: + page_size 100, max 100. + """ + + page_size = 100 + page_size_query_param = "page_size" + max_page_size = 100 diff --git a/openedx/core/djangoapps/enrollments/v2/serializers.py b/openedx/core/djangoapps/enrollments/v2/serializers.py new file mode 100644 index 000000000000..2c7220b398f1 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/serializers.py @@ -0,0 +1,34 @@ +""" +Serializers for the Enrollment API — v2. + +Only contains the serializers introduced by ADR 0025 (replacing inline +dict construction in role-listing endpoints). The other v1 serializers +(:class:`CourseEnrollmentSerializer`, :class:`CourseSerializer`, +:class:`CourseEnrollmentAllowedSerializer`, :class:`CourseEnrollmentsApiListSerializer`) +are unchanged in shape between v1 and v2 — v2 view code imports them +directly from :mod:`openedx.core.djangoapps.enrollments.serializers`. + +If a future v3 needs to break any of those response shapes, fork them +into a new v3/serializers.py at that time. +""" + +from rest_framework import serializers + + +class UserRoleSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializes a single course-level role entry for a user (ADR 0025).""" + + org = serializers.CharField() + course_id = serializers.SerializerMethodField() + role = serializers.CharField() + + def get_course_id(self, obj): + """Return course_id as a string.""" + return str(obj.course_id) + + +class UserRolesResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializes the full response payload for UserRolesViewSet (ADR 0025).""" + + roles = UserRoleSerializer(many=True) + is_staff = serializers.BooleanField() diff --git a/openedx/core/djangoapps/enrollments/v2/tests/__init__.py b/openedx/core/djangoapps/enrollments/v2/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/enrollments/v2/tests/test_envelope.py b/openedx/core/djangoapps/enrollments/v2/tests/test_envelope.py new file mode 100644 index 000000000000..3c87e8c8008d --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/tests/test_envelope.py @@ -0,0 +1,129 @@ +""" +ADR 0029 — Standardized error-response envelope regression tests for the +v2 Enrollment API. + +The envelope is wired into every v2 viewset via +:class:`openedx.core.lib.api.mixins.StandardizedErrorMixin`, which overrides +DRF's per-view ``get_exception_handler`` to point at the project-wide +``standardized_error_exception_handler``. + +The envelope shape is:: + + { + "type": "https://docs.openedx.org/errors/", + "title": "", + "status": , + "detail": "", + "instance": "", + } + +These tests confirm the envelope reaches every v2 endpoint that can produce +a 401 / 403 / 404 / 400. The last test (``test_v1_endpoint_unaffected``) +locks in the scoping — v1 must NOT carry the envelope. +""" +from unittest.mock import patch + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +_REQUIRED_ERROR_FIELDS = ("type", "title", "status", "detail", "instance") + + +@skip_unless_lms +class TestEnrollmentViewSetEnvelope(APITestCase): + """ADR 0029 — envelope on the EnrollmentViewSet 401s.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + self.list_url = reverse("v2:enrollment-list") + self.unenroll_url = reverse("v2:enrollment-unenroll") + self.allowed_url = reverse("v2:enrollment-allowed") + + def test_list_unauthenticated_envelope(self): + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + + def test_list_unauthenticated_type_uri(self): + response = self.client.get(self.list_url) + assert response.data.get("type") == "https://docs.openedx.org/errors/authn" + + def test_unenroll_unauthenticated_envelope(self): + response = self.client.post(self.unenroll_url, data={}, content_type="application/json") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + + def test_allowed_unauthenticated_envelope(self): + response = self.client.get(self.allowed_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + + def test_non_admin_get_allowed_envelope(self): + """ADR 0029 — 403 also carries the envelope.""" + self.client.force_authenticate(user=UserFactory.create()) + response = self.client.get(self.allowed_url) + assert response.status_code == status.HTTP_403_FORBIDDEN + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + assert response.data.get("type") == "https://docs.openedx.org/errors/authz" + + def test_create_missing_course_id_envelope(self): + """ADR 0029 — inline ValidationError surfaces with the envelope.""" + self.client.force_authenticate(user=UserFactory.create()) + response = self.client.post(self.list_url, data={}, content_type="application/json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + assert response.data.get("type") == "https://docs.openedx.org/errors/validation" + + def test_instance_field_is_request_path(self): + response = self.client.get(self.list_url) + assert response.data.get("instance") == self.list_url + + def test_error_body_has_no_developer_message(self): + """Legacy DeveloperErrorViewMixin fields must not leak through.""" + response = self.client.get(self.list_url) + assert "developer_message" not in response.data + assert "error_code" not in response.data + + +@skip_unless_lms +class TestCourseEnrollmentDetailViewEnvelope(APITestCase): + """ADR 0029 — envelope on the public course-detail endpoint.""" + + def test_invalid_course_key_envelope(self): + url = reverse("v2:enrollment-v2-course-detail", kwargs={"course_id": "course-v1:org+course+run"}) + with patch( + "openedx.core.djangoapps.enrollments.v2.views.CourseOverview.get_from_id", + ) as mock_get: + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + mock_get.side_effect = CourseOverview.DoesNotExist() + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + assert response.data.get("type") == "https://docs.openedx.org/errors/not-found" + + +@skip_unless_lms +class TestV1EndpointUnaffected(APITestCase): + """ + The ADR 0029 envelope must be scoped to v2 — v1 endpoints continue to + use whichever handler the project-wide ``EXCEPTION_HANDLER`` setting + points at. Hitting v1 unauthenticated must NOT return the v2 envelope. + """ + + def test_v1_enrollment_list_does_not_carry_envelope(self): + response = self.client.get(reverse("courseenrollments")) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + # v1 still uses the project-default handler; ADR 0029 fields absent. + assert "type" not in response.data + assert "instance" not in response.data diff --git a/openedx/core/djangoapps/enrollments/v2/tests/test_view_services.py b/openedx/core/djangoapps/enrollments/v2/tests/test_view_services.py new file mode 100644 index 000000000000..0bb8fd1ecbfe --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/tests/test_view_services.py @@ -0,0 +1,87 @@ +""" +Unit tests for EnrollmentOperationsService (v2). + +Exercises the service methods directly — no HTTP layer involved. Tests the +two-layer authorization model (ADR 0031) and the modern ADR 0029 raise-DRF- +exceptions pattern. +""" +from unittest.mock import MagicMock, patch + +import pytest +from django.test import TestCase +from rest_framework.exceptions import NotFound, ValidationError + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.enrollments.v2.view_services import EnrollmentOperationsService + + +class TestUnenrollUserForRetirement(TestCase): + """ADR 0029 — error handling for the retirement unenroll flow.""" + + def setUp(self): + super().setUp() + self.service = EnrollmentOperationsService() + + def test_missing_username_raises_validation_error(self): + with pytest.raises(ValidationError): + self.service.unenroll_user_for_retirement(None) + + def test_blank_username_raises_validation_error(self): + with pytest.raises(ValidationError): + self.service.unenroll_user_for_retirement("") + + @patch( + "openedx.core.djangoapps.enrollments.v2.view_services.UserRetirementStatus.get_retirement_for_retirement_action" + ) + def test_unknown_retirement_status_raises_not_found(self, mock_get): + from openedx.core.djangoapps.user_api.models import UserRetirementStatus + mock_get.side_effect = UserRetirementStatus.DoesNotExist() + with pytest.raises(NotFound): + self.service.unenroll_user_for_retirement("ghost-user") + + +class TestListEnrollmentsForUser(TestCase): + """ADR 0031 — per-operation permission filter in the listing helper.""" + + def setUp(self): + super().setUp() + self.service = EnrollmentOperationsService() + self.user = UserFactory.create() + self.other = UserFactory.create() + + @patch("openedx.core.djangoapps.enrollments.v2.view_services.CourseEnrollment.objects") + def test_self_lookup_returns_full_list_unfiltered(self, mock_objects): + """Requesting your own enrollments bypasses the course-staff filter.""" + mock_qs = MagicMock() + mock_qs.__iter__ = lambda self: iter([]) + mock_objects.filter.return_value.select_related.return_value = mock_qs + result = self.service.list_enrollments_for_user( + request_user=self.user, target_username=self.user.username, has_api_key=False, + ) + assert isinstance(result, list) + + @patch("openedx.core.djangoapps.enrollments.v2.view_services.CourseEnrollment.objects") + def test_api_key_bypasses_per_course_filter(self, mock_objects): + """has_api_key=True returns the full list even across user boundaries.""" + mock_qs = MagicMock() + mock_qs.__iter__ = lambda self: iter([]) + mock_objects.filter.return_value.select_related.return_value = mock_qs + result = self.service.list_enrollments_for_user( + request_user=self.user, target_username=self.other.username, has_api_key=True, + ) + assert isinstance(result, list) + + +class TestDeleteAllowedEnrollment(TestCase): + """ADR 0029 — delete raises NotFound when the row is missing.""" + + def setUp(self): + super().setUp() + self.service = EnrollmentOperationsService() + + @patch("openedx.core.djangoapps.enrollments.v2.view_services.CourseEnrollmentAllowed.objects") + def test_delete_missing_row_raises_not_found(self, mock_objects): + from django.core.exceptions import ObjectDoesNotExist + mock_objects.get.side_effect = ObjectDoesNotExist() + with pytest.raises(NotFound): + self.service.delete_allowed_enrollment("ghost@example.com", "course-v1:org+course+run") diff --git a/openedx/core/djangoapps/enrollments/v2/tests/test_views.py b/openedx/core/djangoapps/enrollments/v2/tests/test_views.py new file mode 100644 index 000000000000..ce8f958a1d79 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/tests/test_views.py @@ -0,0 +1,236 @@ +""" +Action + permission regression tests for the v2 Enrollment ViewSet. + +MongoDB-free: every service-layer call is mocked, so these tests run +without a live modulestore or course-overview row. + +Covers: + - ADR 0026: permission enforcement on every action (list/create/unenroll/allowed) + - ADR 0028: router-generated URL reverse names work + - ADR 0032: list action returns the 7-field DefaultPagination envelope + - ADR 0033: ordering whitelist + Deprecation header on the admin list +""" +from unittest.mock import patch + +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +API_KEY = "test-enrollment-v2-api-key" + +# Mock targets — all keyed off the v2 module to avoid leaking into v1. +MOCK_OPS_LIST = "openedx.core.djangoapps.enrollments.v2.views._OPS.list_enrollments_for_user" +MOCK_OPS_CREATE = "openedx.core.djangoapps.enrollments.v2.views._OPS.create_or_update_enrollment" +MOCK_OPS_UNENROLL = "openedx.core.djangoapps.enrollments.v2.views._OPS.unenroll_user_for_retirement" +MOCK_OPS_LIST_ALLOWED = "openedx.core.djangoapps.enrollments.v2.views._OPS.list_allowed_for_email" +MOCK_OPS_CREATE_ALLOWED = "openedx.core.djangoapps.enrollments.v2.views._OPS.create_allowed_enrollment" +MOCK_OPS_DELETE_ALLOWED = "openedx.core.djangoapps.enrollments.v2.views._OPS.delete_allowed_enrollment" + + +# --------------------------------------------------------------------------- +# EnrollmentViewSet.list (GET /enrollment/) +# --------------------------------------------------------------------------- + +@skip_unless_lms +class TestEnrollmentViewSetList(APITestCase): + """ADR 0026 + 0028 — permission + reverse-name tests for the list action.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(password="test") + self.url = reverse("v2:enrollment-list") + + def test_unauthenticated_gets_401(self): + response = self.client.get(self.url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @patch(MOCK_OPS_LIST, return_value=[]) + def test_authenticated_user_gets_200(self, mock_list): # noqa: ARG002 + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + + @patch(MOCK_OPS_LIST, return_value=[]) + def test_valid_api_key_gets_200(self, mock_list): # noqa: ARG002 + with override_settings(EDX_API_KEY=API_KEY): + response = self.client.get(self.url, HTTP_X_EDX_API_KEY=API_KEY) + assert response.status_code == status.HTTP_200_OK + + def test_invalid_api_key_without_session_gets_401(self): + response = self.client.get(self.url, HTTP_X_EDX_API_KEY="wrong-key") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @patch(MOCK_OPS_LIST, return_value=[]) + def test_list_returns_pagination_envelope(self, mock_list): # noqa: ARG002 + """ADR 0032 — every response carries the 7-field DefaultPagination envelope.""" + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + for field in ("count", "num_pages", "current_page", "start", "next", "previous", "results"): + assert field in response.data, f"ADR 0032: missing envelope field '{field}'" + + +# --------------------------------------------------------------------------- +# EnrollmentViewSet.create (POST /enrollment/) +# --------------------------------------------------------------------------- + +@skip_unless_lms +class TestEnrollmentViewSetCreate(APITestCase): + """ADR 0026 + 0028 — permission tests for the create action.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(password="test") + self.url = reverse("v2:enrollment-list") + + def test_unauthenticated_post_gets_401(self): + response = self.client.post(self.url, data={}, content_type="application/json") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_authenticated_post_missing_course_id_gets_400(self): + """ADR 0029 — missing course_id raises ValidationError → 400.""" + self.client.force_authenticate(user=self.user) + response = self.client.post(self.url, data={}, content_type="application/json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_authenticated_post_invalid_course_id_gets_400(self): + """ADR 0029 — unparseable course_id raises ValidationError → 400.""" + self.client.force_authenticate(user=self.user) + response = self.client.post( + self.url, + data={"course_details": {"course_id": "not-a-course-key"}}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch(MOCK_OPS_CREATE, return_value={"mode": "audit", "is_active": True}) + def test_authenticated_post_valid_returns_200(self, mock_create): # noqa: ARG002 + self.client.force_authenticate(user=self.user) + response = self.client.post( + self.url, + data={"course_details": {"course_id": "course-v1:org+course+run"}}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_200_OK + + +# --------------------------------------------------------------------------- +# EnrollmentViewSet.unenroll (POST /enrollment/unenroll/) +# --------------------------------------------------------------------------- + +@skip_unless_lms +class TestEnrollmentViewSetUnenroll(APITestCase): + """ADR 0026 — IsAuthenticated + CanRetireUser permission.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(password="test") + self.url = reverse("v2:enrollment-unenroll") + + def test_unauthenticated_gets_401(self): + response = self.client.post( + self.url, data={"username": self.user.username}, content_type="application/json", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_authenticated_non_retirement_user_gets_403(self): + """A plain authenticated user lacks CanRetireUser → 403.""" + self.client.force_authenticate(user=self.user) + response = self.client.post( + self.url, data={"username": self.user.username}, content_type="application/json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# --------------------------------------------------------------------------- +# EnrollmentViewSet.allowed (GET/POST/DELETE /enrollment/enrollment_allowed/) +# --------------------------------------------------------------------------- + +@skip_unless_lms +class TestEnrollmentViewSetAllowed(APITestCase): + """ADR 0026 — IsAdminUser permission on the allowed action.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(password="test") + self.admin = AdminFactory.create(password="test") + self.url = reverse("v2:enrollment-allowed") + + def test_unauthenticated_get_gets_401(self): + response = self.client.get(self.url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_non_admin_get_gets_403(self): + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch(MOCK_OPS_LIST_ALLOWED, return_value=[]) + def test_admin_get_gets_200(self, mock_list): # noqa: ARG002 + self.client.force_authenticate(user=self.admin) + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + def test_non_admin_post_gets_403(self): + self.client.force_authenticate(user=self.user) + response = self.client.post( + self.url, + data={"email": "test@example.com", "course_id": "course-v1:edX+DemoX+Demo_Course"}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_non_admin_delete_gets_403(self): + self.client.force_authenticate(user=self.user) + response = self.client.delete( + self.url, + data={"email": "test@example.com", "course_id": "course-v1:edX+DemoX+Demo_Course"}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# --------------------------------------------------------------------------- +# UserRolesView (GET /roles/) — ADR 0033 OEP-68 aliasing +# --------------------------------------------------------------------------- + +_ADR_0033_HEADER_COURSE_ID = ( + "Parameter 'course_id' is deprecated. Use 'course_key' instead. " + "Support will be removed in release ''." +) + + +@skip_unless_lms +class TestUserRolesViewAliases(APITestCase): + """ADR 0033 — OEP-68 parameter alias + Deprecation header tests.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(password="test") + self.url = reverse("v2:enrollment-v2-roles") + + @patch("openedx.core.djangoapps.enrollments.v2.views.api.get_user_roles", return_value=[]) + def test_new_course_key_param_no_header(self, mock_get): # noqa: ARG002 + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url, {"course_key": "course-v1:org+course+run"}) + assert response.status_code == status.HTTP_200_OK + assert "Deprecation" not in response.headers + + @patch("openedx.core.djangoapps.enrollments.v2.views.api.get_user_roles", return_value=[]) + def test_legacy_course_id_param_emits_header(self, mock_get): # noqa: ARG002 + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url, {"course_id": "course-v1:org+course+run"}) + assert response.status_code == status.HTTP_200_OK + assert response.headers.get("Deprecation") == _ADR_0033_HEADER_COURSE_ID + + @patch("openedx.core.djangoapps.enrollments.v2.views.api.get_user_roles", return_value=[]) + def test_no_filter_no_header(self, mock_get): # noqa: ARG002 + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert "Deprecation" not in response.headers diff --git a/openedx/core/djangoapps/enrollments/v2/urls.py b/openedx/core/djangoapps/enrollments/v2/urls.py new file mode 100644 index 000000000000..cda839fd4319 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/urls.py @@ -0,0 +1,73 @@ +""" +URLs for the Enrollment API — v2. + +Mounted at ``/api/enrollment/v2/`` (see ``lms/urls.py``). + +ADR 0028 — :class:`EnrollmentViewSet` is registered via ``DefaultRouter`` +(actions: ``list``, ``create``, ``unenroll``, ``allowed``). The other v2 +endpoints (singleton retrieve by URL form, roles, course-detail-by-id, +admin enrollments list) cannot be expressed as router-generated URLs, so +they remain as standalone ``APIView`` classes routed via ``path()`` / +``re_path()``. + +URL surface +----------- + +Router-generated (basename ``enrollment``): + GET /enrollment/ + POST /enrollment/ + POST /enrollment/unenroll/ + GET /enrollment/enrollment_allowed/ + POST /enrollment/enrollment_allowed/ + DELETE /enrollment/enrollment_allowed/ + +Explicit paths: + GET /enrollment/{username},{course_key} (name: enrollment-v2-retrieve) + GET /enrollment/{course_key} (name: enrollment-v2-retrieve) + GET /enrollments/ (name: enrollment-v2-admin-list) + GET /course/{course_key} (name: enrollment-v2-course-detail) + GET /roles/ (name: enrollment-v2-roles) +""" + +from django.conf import settings +from django.urls import path, re_path +from rest_framework.routers import DefaultRouter + +from .views import ( + CourseEnrollmentDetailView, + EnrollmentRetrieveView, + EnrollmentsAdminListView, + EnrollmentViewSet, + UserRolesView, +) + +app_name = "v2" + +router = DefaultRouter() +router.register(r"enrollment", EnrollmentViewSet, basename="enrollment") + +urlpatterns = router.urls + [ + re_path( + r"^enrollment/{username},{course_key}$".format( # noqa: UP032 + username=settings.USERNAME_PATTERN, course_key=settings.COURSE_ID_PATTERN, + ), + EnrollmentRetrieveView.as_view(), + name="enrollment-v2-retrieve", + ), + re_path( + rf"^enrollment/{settings.COURSE_ID_PATTERN}$", + EnrollmentRetrieveView.as_view(), + name="enrollment-v2-retrieve", + ), + re_path( + r"^enrollments/?$", + EnrollmentsAdminListView.as_view(), + name="enrollment-v2-admin-list", + ), + re_path( + rf"^course/{settings.COURSE_ID_PATTERN}$", + CourseEnrollmentDetailView.as_view(), + name="enrollment-v2-course-detail", + ), + path("roles/", UserRolesView.as_view(), name="enrollment-v2-roles"), +] diff --git a/openedx/core/djangoapps/enrollments/v2/view_services.py b/openedx/core/djangoapps/enrollments/v2/view_services.py new file mode 100644 index 000000000000..75c7de0dcec3 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/view_services.py @@ -0,0 +1,376 @@ +""" +Shared service layer for enrollment v2 HTTP operations. + +ADR 0031 (Merge Similar Endpoints) — consolidates the business logic +behind the three v2 viewset actions that previously had partially duplicated +implementations in v1's ``EnrollmentListView`` / ``UnenrollmentView`` / +``EnrollmentAllowedView``. + +Authorization model +------------------- +Each operation is enforced in two layers: + +1. The viewset declares a coarse permission class (``IsAuthenticated``, + ``IsAdminUser``, ``CanRetireUser``, ``ApiKeyHeaderPermissionIsAuthenticated``) + on the action. +2. The service method enforces the per-operation rules — e.g. only API-key + callers or global staff may deactivate enrollments, downgrade modes, or + force-enroll a user. + +ADR 0029 — service methods raise DRF exceptions (``NotFound``, +``ValidationError``, ``PermissionDenied``, ``Conflict``) instead of returning +``Response`` objects with non-2xx status. The exceptions flow through the +viewset's :class:`StandardizedErrorMixin` to produce the standardized +envelope. +""" + +import logging + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.exceptions import ( + APIException, + NotFound, + PermissionDenied, + ValidationError, +) + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.auth import user_has_role +from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, EnrollmentNotAllowed +from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff +from openedx.core.djangoapps.course_groups.cohorts import CourseUserGroup, add_user_to_cohort, get_cohort_by_name +from openedx.core.djangoapps.embargo import api as embargo_api +from openedx.core.djangoapps.enrollments import api +from openedx.core.djangoapps.enrollments.errors import ( + CourseEnrollmentError, + CourseEnrollmentExistsError, + CourseModeNotFoundError, + InvalidEnrollmentAttribute, +) +from openedx.core.djangoapps.user_api.models import UserRetirementStatus +from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in +from openedx.core.lib.api.exceptions import Conflict +from openedx.core.lib.exceptions import CourseNotFoundError +from openedx.core.lib.log_utils import audit_log +from openedx.features.enterprise_support.api import ( + ConsentApiServiceClient, + EnterpriseApiException, + EnterpriseApiServiceClient, + enterprise_enabled, +) + +log = logging.getLogger(__name__) + +User = get_user_model() + +REQUIRED_ATTRIBUTES = { + "credit": ["credit:provider_id"], +} + + +class EnrollmentOperationsService: + """ + Operation handlers for the v2 EnrollmentViewSet. + + All methods raise DRF exceptions on error paths so the viewset's + :class:`StandardizedErrorMixin` can produce the ADR 0029 envelope. + """ + + # ------------------------------------------------------------------ + # Listing + # ------------------------------------------------------------------ + def list_enrollments_for_user(self, request_user, target_username, has_api_key): + """ + Return enrollments visible to ``request_user`` for ``target_username``. + + - Self / global staff / api-key requests → full list. + - Otherwise filtered to courses ``request_user`` staffs. + """ + enrollments = CourseEnrollment.objects.filter( + user__username=target_username + ).select_related("user", "course") + if ( + target_username == request_user.username + or GlobalStaff().has_user(request_user) + or has_api_key + ): + return list(enrollments) + return [ + enrollment for enrollment in enrollments + if user_has_role(request_user, CourseStaffRole(enrollment.course_id)) + ] + + # ------------------------------------------------------------------ + # Create / update + # ------------------------------------------------------------------ + def create_or_update_enrollment(self, request, has_api_key, course_id): + """ + Handle the POST /enrollment/ create-or-update flow. + + ``course_id`` is a parsed :class:`CourseKey`. The viewset is + responsible for the up-front ``InvalidKeyError → ValidationError`` + translation before calling this method. + + Returns the enrollment dict on success. Raises DRF exceptions on + any error path. + """ + # pylint: disable=too-many-statements,too-many-branches + username = request.data.get("user") + mode = request.data.get("mode") + is_active = None + user = None + cohort_name = None + + # Per-operation authz layer 1: only admin/api-key callers may enroll + # other users. Non-staff callers can only enroll themselves. + if ( + username + and username != request.user.username + and not has_api_key + and not GlobalStaff().has_user(request.user) + ): + raise NotFound() + + if not username: + email = request.data.get("email") + if email: + if not has_api_key and not GlobalStaff().has_user(request.user): + raise NotFound() + try: + username = User.objects.get(email=email).username + except ObjectDoesNotExist as exc: + raise NotFound( + f"The user with the email address {email} does not exist." + ) from exc + else: + username = request.user.username + + # Per-operation authz layer 2: non-default modes require api-key or + # global-staff privileges. + if ( + mode not in (CourseMode.AUDIT, CourseMode.HONOR, None) + and not has_api_key + and not GlobalStaff().has_user(request.user) + ): + raise PermissionDenied( + f"User does not have permission to create enrollment with mode [{mode}]." + ) + + try: + user = User.objects.get(username=username) + except ObjectDoesNotExist as exc: + raise NotFound(f"The user {username} does not exist.") from exc + + embargo_response = embargo_api.get_embargo_response(request, course_id, user) + if embargo_response: + # Embargo returns a fully-formed Response; surface its body as a + # PermissionDenied so the standardized envelope wraps it. + raise PermissionDenied(detail=getattr(embargo_response, "data", "Embargoed.")) + + try: + is_active = request.data.get("is_active") + if is_active is not None and not isinstance(is_active, bool): + raise ValidationError(f"'{is_active}' is an invalid enrollment activation status.") + + explicit_linked_enterprise = request.data.get("linked_enterprise_customer") + if explicit_linked_enterprise and has_api_key and enterprise_enabled(): + enterprise_api_client = EnterpriseApiServiceClient() + consent_client = ConsentApiServiceClient() + try: + enterprise_api_client.post_enterprise_course_enrollment(username, str(course_id)) + except EnterpriseApiException as error: + log.exception( + "An unexpected error occurred while creating the new EnterpriseCourseEnrollment " + "for user [%s] in course run [%s]", username, course_id, + ) + raise CourseEnrollmentError(str(error)) from error + consent_client.provide_consent( + username=username, + course_id=str(course_id), + enterprise_customer_uuid=explicit_linked_enterprise, + ) + + enrollment_attributes = request.data.get("enrollment_attributes") + force_enrollment = request.data.get("force_enrollment") + if force_enrollment is not None and not isinstance(force_enrollment, bool): + raise ValidationError(f"'{force_enrollment}' is an invalid force enrollment status.") + force_enrollment = force_enrollment and GlobalStaff().has_user(request.user) + + enrollment = api.get_enrollment(username, str(course_id)) + mode_changed = enrollment and mode is not None and enrollment["mode"] != mode + active_changed = enrollment and is_active is not None and enrollment["is_active"] != is_active + missing_attrs = [] + if enrollment_attributes: + actual_attrs = ["{namespace}:{name}".format(**attr) for attr in enrollment_attributes] + missing_attrs = set(REQUIRED_ATTRIBUTES.get(mode, [])) - set(actual_attrs) + + if (GlobalStaff().has_user(request.user) or has_api_key) and (mode_changed or active_changed): + if mode_changed and active_changed and not is_active: + msg = ( + f"Enrollment mode mismatch: active mode={enrollment['mode']}, " + f"requested mode={mode}. Won't deactivate." + ) + log.warning(msg) + raise ValidationError(msg) + + if missing_attrs: + msg = ( + f"Missing enrollment attributes: requested mode={mode} " + f"required attributes={REQUIRED_ATTRIBUTES.get(mode)}" + ) + log.warning(msg) + raise ValidationError(msg) + + response_data = api.update_enrollment( + username, + str(course_id), + mode=mode, + is_active=is_active, + enrollment_attributes=enrollment_attributes, + include_expired=has_api_key, + ) + else: + response_data = api.add_enrollment( + username, + str(course_id), + mode=mode, + is_active=is_active, + enrollment_attributes=enrollment_attributes, + enterprise_uuid=request.data.get("enterprise_uuid"), + force_enrollment=force_enrollment, + include_expired=force_enrollment, + ) + + cohort_name = request.data.get("cohort") + if cohort_name is not None: + cohort = get_cohort_by_name(course_id, cohort_name) + try: + add_user_to_cohort(cohort, user) + except ValueError: + log.exception("Cohort re-addition") + + email_opt_in = request.data.get("email_opt_in", None) + if email_opt_in is not None: + update_email_opt_in(request.user, course_id.org, email_opt_in) + + log.info("The user [%s] has already been enrolled in course run [%s].", username, course_id) + return response_data + + except InvalidEnrollmentAttribute as error: + raise ValidationError(str(error)) from error + except EnrollmentNotAllowed as error: + raise PermissionDenied(str(error)) from error + except CourseModeNotFoundError as error: + raise ValidationError( + f"The [{mode}] course mode is expired or otherwise unavailable for course run [{course_id}]." + ) from error + except CourseNotFoundError as error: + raise ValidationError(f"No course '{course_id}' found for enrollment") from error + except CourseEnrollmentExistsError as error: + log.warning("An enrollment already exists for user [%s] in course run [%s].", username, course_id) + # Caller-visible signal that the enrollment already exists. Use 200 + existing enrollment body + # (matches v1 semantics) — surface as a successful return, not an exception. + return error.enrollment + except CourseEnrollmentError as error: + log.exception( + "An error occurred while creating the new course enrollment for user [%s] in course run [%s]", + username, course_id, + ) + raise ValidationError( + f"An error occurred while creating the new course enrollment " + f"for user '{username}' in course '{course_id}'" + ) from error + except CourseUserGroup.DoesNotExist as error: + log.exception("Missing cohort [%s] in course run [%s]", cohort_name, course_id) + raise ValidationError( + f"An error occured while adding to cohort [{cohort_name}]" + ) from error + finally: + # Audit-log every API-key-driven enrollment change. + if has_api_key and user is not None: + try: + current = CourseEnrollment.objects.get(user__username=username, course_id=course_id) + actual_mode = current.mode + actual_activation = current.is_active + except CourseEnrollment.DoesNotExist: + actual_mode = None + actual_activation = None + audit_log( + "enrollment_change_requested", + course_id=str(course_id), + requested_mode=mode, + actual_mode=actual_mode, + requested_activation=is_active, + actual_activation=actual_activation, + user_id=user.id, + ) + + # ------------------------------------------------------------------ + # Unenroll (retirement pipeline) + # ------------------------------------------------------------------ + def unenroll_user_for_retirement(self, username): + """ + Handle the retirement-pipeline /enrollment/unenroll/ flow. + + Returns: + - ``None`` if the user has no active enrollments (caller should + return 204 No Content). + - A dict (the unenroll-result payload) on success (caller returns 200). + + Raises: + ValidationError: if ``username`` is missing. + NotFound: if no retirement-status row exists for the user. + APIException: on any other unexpected error (mapped to 500). + """ + if not username: + raise ValidationError("Username not specified.") + try: + UserRetirementStatus.get_retirement_for_retirement_action(username) + except UserRetirementStatus.DoesNotExist as exc: + raise NotFound("No retirement request status for username.") from exc + try: + if not CourseEnrollment.objects.filter(user__username=username, is_active=True).exists(): + return None + return api.unenroll_user_from_all_courses(username) + except Exception as exc: # pylint: disable=broad-except + log.exception("Unexpected error during unenrollment for user %s", username) + raise APIException("An unexpected error occurred during unenrollment.") from exc + + # ------------------------------------------------------------------ + # Allowed enrollments + # ------------------------------------------------------------------ + def list_allowed_for_email(self, email): + """Return the ``CourseEnrollmentAllowed`` queryset for ``email``.""" + return CourseEnrollmentAllowed.objects.filter(email=email) + + def create_allowed_enrollment(self, serializer): + """ + Persist the allowed-enrollment described by ``serializer``. + + Raises: + Conflict: if a row already exists for the (email, course_id) pair. + """ + from django.db import IntegrityError # local import — avoid heavy startup cost + try: + return serializer.save() + except IntegrityError as exc: + email = serializer.validated_data.get("email") + course_id = serializer.validated_data.get("course_id") + raise Conflict( + f"An enrollment allowed with email {email} and course {course_id} already exists." + ) from exc + + def delete_allowed_enrollment(self, email, course_id): + """ + Delete the allowed-enrollment row identified by (email, course_id). + + Raises: + NotFound: if no such row exists. + """ + try: + CourseEnrollmentAllowed.objects.get(email=email, course_id=course_id).delete() + except ObjectDoesNotExist as exc: + raise NotFound( + f"An enrollment allowed with email {email} and course {course_id} doesn't exists." + ) from exc diff --git a/openedx/core/djangoapps/enrollments/v2/views.py b/openedx/core/djangoapps/enrollments/v2/views.py new file mode 100644 index 000000000000..cb940244c401 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/v2/views.py @@ -0,0 +1,626 @@ +""" +API Views for the Enrollment API — v2. + +This module is the v2 incarnation of the v1 enrollment views, restructured +to apply the FC-0118 ADRs from the start: + + * ADR 0025 – ``serializer_class`` on every viewset/view + * ADR 0026 – explicit ``authentication_classes`` + ``permission_classes`` + * ADR 0027 – ``drf_spectacular`` for OpenAPI schema generation + * ADR 0028 – consolidated into ``ViewSet`` classes registered via + ``DefaultRouter`` where the URL shape allows it + * ADR 0029 – standardized error envelope via :class:`StandardizedErrorMixin` + * ADR 0031 – business logic centralized in + :class:`EnrollmentOperationsService` (``v2.view_services``) + * ADR 0032 – ``DefaultPagination`` 7-field envelope on list endpoints + * ADR 0033 – OEP-68 parameter naming (``course_key`` preferred, + ``course_id`` as deprecated alias) plus standard ``ordering`` whitelist + +Existing v1 endpoints at ``/api/enrollment/v1/`` are unchanged — v2 is a +parallel new version mounted at ``/api/enrollment/v2/``. +""" + +import logging + +from django.contrib.auth import get_user_model +from django.utils.decorators import method_decorator +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiRequest, + OpenApiResponse, + extend_schema, +) +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.paginators import DefaultPagination +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.generics import ListAPIView +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain +from openedx.core.djangoapps.enrollments import api +from openedx.core.djangoapps.enrollments.errors import CourseEnrollmentError +from openedx.core.djangoapps.enrollments.serializers import ( + CourseEnrollmentAllowedSerializer, + CourseEnrollmentsApiListSerializer, + CourseEnrollmentSerializer, + CourseSerializer, +) +from openedx.core.djangoapps.enrollments.v2.forms import EnrollmentsAdminListForm +from openedx.core.djangoapps.enrollments.v2.paginators import EnrollmentsAdminListPagination +from openedx.core.djangoapps.enrollments.v2.serializers import UserRolesResponseSerializer +from openedx.core.djangoapps.enrollments.v2.view_services import EnrollmentOperationsService +from openedx.core.djangoapps.enrollments.views import ( + ApiKeyPermissionMixIn, + EnrollmentCrossDomainSessionAuth, + EnrollmentUserThrottle, +) +from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +from openedx.core.lib.api.mixins import StandardizedErrorMixin +from openedx.core.lib.api.permissions import ApiKeyHeaderPermissionIsAuthenticated + +log = logging.getLogger(__name__) +User = get_user_model() + +# ADR 0031 — single shared service instance for the v2 enrollment operations. +_OPS = EnrollmentOperationsService() + + +# --------------------------------------------------------------------------- +# ADR 0027 — shared OpenAPI parameter and response building blocks +# --------------------------------------------------------------------------- +def _path_param(name: str, description: str) -> OpenApiParameter: + return OpenApiParameter( + name=name, description=description, required=True, type=str, location=OpenApiParameter.PATH, + ) + + +def _query_param(name: str, description: str, *, required: bool = False, type_=str, + deprecated: bool = False) -> OpenApiParameter: + return OpenApiParameter( + name=name, description=description, required=required, type=type_, + location=OpenApiParameter.QUERY, deprecated=deprecated, + ) + + +_COURSE_ID_PATH_PARAM = _path_param("course_id", "Course ID (e.g. course-v1:org+course+run).") +_USERNAME_PATH_PARAM = _path_param("username", "Username of the user.") +_USER_QUERY_PARAM = _query_param("user", "Username of the user whose enrollments to list.") +_INCLUDE_EXPIRED_QUERY_PARAM = _query_param( + "include_expired", "If '1', include expired enrollment modes in the response.", +) +_PAGE_QUERY_PARAM = _query_param("page", "Page number to retrieve. Default 1.") +_PAGE_SIZE_QUERY_PARAM = _query_param("page_size", "Items per page (default 10, max 100).") + +_RESP_UNAUTHENTICATED = OpenApiResponse(description="The requester is not authenticated.") +_RESP_FORBIDDEN = OpenApiResponse(description="The requester does not have permission for this operation.") +_RESP_NOT_FOUND = OpenApiResponse(description="The requested resource does not exist.") +_RESP_BAD_REQUEST = OpenApiResponse(description="Invalid request data or parameters.") + + +# --------------------------------------------------------------------------- +# ADR 0033 — Deprecation-header helpers (OEP-68 parameter naming) +# --------------------------------------------------------------------------- +def _build_legacy_param_deprecation_header(legacy_to_preferred): + """ + Build the ADR 0033 ``Deprecation`` HTTP header value for one or more + legacy parameter names, each paired with its OEP-68-compliant + replacement. + + Example: ``[('course_id', 'course_key')]`` → + ``"Parameter 'course_id' is deprecated. Use 'course_key' instead. ..."`` + """ + parts = [ + f"Parameter '{legacy}' is deprecated. Use '{preferred}' instead." + for legacy, preferred in legacy_to_preferred + ] + parts.append("Support will be removed in release ''.") + return " ".join(parts) + + +def _maybe_set_legacy_param_deprecation_header(request, response, alias_pairs): + """Set the ADR 0033 ``Deprecation`` HTTP header on the response when any + legacy parameter name from ``alias_pairs`` is present in the request.""" + used = [(legacy, preferred) for legacy, preferred in alias_pairs if legacy in request.query_params] + if used: + response["Deprecation"] = _build_legacy_param_deprecation_header(used) + return response + + +# =========================================================================== +# EnrollmentViewSet — consolidates list / create / unenroll / allowed +# =========================================================================== +@can_disable_rate_limit +class EnrollmentViewSet(StandardizedErrorMixin, viewsets.ViewSet, ApiKeyPermissionMixIn): + """ + Canonical ViewSet for the v2 Enrollment API. + + Consolidates the v1 ``EnrollmentListView`` + ``UnenrollmentView`` + + ``EnrollmentAllowedView`` into a single router-registered ViewSet + (ADR 0028). Per-action permissions are declared via the ``@action`` + decorator's ``permission_classes`` kwarg. + + Router URLs (registered at ``basename="enrollment"``):: + + GET /api/enrollment/v2/enrollment/ → list + POST /api/enrollment/v2/enrollment/ → create + POST /api/enrollment/v2/enrollment/unenroll/ → unenroll + GET /api/enrollment/v2/enrollment/enrollment_allowed/ → allowed (GET) + POST /api/enrollment/v2/enrollment/enrollment_allowed/ → allowed (POST) + DELETE /api/enrollment/v2/enrollment/enrollment_allowed/ → allowed (DELETE) + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + EnrollmentCrossDomainSessionAuth, + ) + permission_classes = (ApiKeyHeaderPermissionIsAuthenticated,) + throttle_classes = (EnrollmentUserThrottle,) + serializer_class = CourseEnrollmentSerializer + pagination_class = DefaultPagination # ADR 0032 + + def get_serializer_class(self): + if self.action == "allowed": + return CourseEnrollmentAllowedSerializer + return self.serializer_class + + def get_serializer(self, *args, **kwargs): + return self.get_serializer_class()(*args, **kwargs) + + # ------------------------------------------------------------------ + # list — GET /enrollment/ + # ------------------------------------------------------------------ + @extend_schema( + summary="List enrollments for a user (paginated)", + description=( + "Returns a paginated list of enrollments for the currently logged-in user, or for " + "the user named by the 'user' query parameter. Staff/admin/api-key access is required " + "to view another user's enrollments — otherwise the list is filtered to courses the " + "requester staffs." + ), + parameters=[_USER_QUERY_PARAM, _PAGE_QUERY_PARAM, _PAGE_SIZE_QUERY_PARAM], + responses={ + 200: OpenApiResponse( + response=CourseEnrollmentSerializer(many=True), + description="Paginated enrollment list.", + ), + 401: _RESP_UNAUTHENTICATED, + }, + ) + @method_decorator(ensure_csrf_cookie_cross_domain) + def list(self, request): + """List enrollments for the currently logged-in user (paginated).""" + username = request.GET.get("user", request.user.username) + enrollments = _OPS.list_enrollments_for_user( + request_user=request.user, + target_username=username, + has_api_key=self.has_api_key_permissions(request), + ) + paginator = self.pagination_class() + page = paginator.paginate_queryset(enrollments, request, view=self) + return paginator.get_paginated_response(self.get_serializer(page, many=True).data) + + # ------------------------------------------------------------------ + # create — POST /enrollment/ + # ------------------------------------------------------------------ + @extend_schema( + summary="Create or update an enrollment", + description=( + "Enrolls a user in a course. Server-to-server calls may deactivate or modify the " + "mode of existing enrollments; all other requests create or reactivate enrollments. " + "The request body must include course_details.course_id." + ), + request=OpenApiRequest(request=CourseEnrollmentSerializer), + responses={ + 200: OpenApiResponse( + response=CourseEnrollmentSerializer, + description="Enrollment created, reactivated, or updated successfully.", + ), + 400: _RESP_BAD_REQUEST, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + }, + ) + @method_decorator(ensure_csrf_cookie_cross_domain) + def create(self, request): + """Enroll a user in a course (or update an existing enrollment).""" + course_id = request.data.get("course_details", {}).get("course_id") + if not course_id: + raise ValidationError("Course ID must be specified to create a new enrollment.") + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError as exc: + raise ValidationError(f"No course '{course_id}' found for enrollment") from exc + return Response(_OPS.create_or_update_enrollment( + request=request, + has_api_key=self.has_api_key_permissions(request), + course_id=course_key, + )) + + # ------------------------------------------------------------------ + # unenroll — @action POST /enrollment/unenroll/ + # ------------------------------------------------------------------ + @extend_schema( + summary="Unenroll a user from all courses (retirement)", + description=( + "Privileged retirement-pipeline use only. Unenrolls the named user from every active " + "enrollment. The request must be made by a service user with CanRetireUser permission, " + "not the user being unenrolled." + ), + request=OpenApiRequest( + request={"type": "object", "properties": {"username": {"type": "string"}}, "required": ["username"]}, + ), + responses={ + 200: OpenApiResponse(description="List of courses from which the user was unenrolled."), + 204: OpenApiResponse(description="User has no active enrollments."), + 400: _RESP_BAD_REQUEST, + 404: _RESP_NOT_FOUND, + }, + ) + @action( + detail=False, + methods=["post"], + url_path="unenroll", + permission_classes=[permissions.IsAuthenticated, CanRetireUser], + ) + def unenroll(self, request): + """Unenroll the specified user from all courses (retirement pipeline).""" + result = _OPS.unenroll_user_for_retirement(request.data.get("username")) + if result is None: + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(result) + + # ------------------------------------------------------------------ + # allowed — @action GET/POST/DELETE /enrollment/enrollment_allowed/ + # ------------------------------------------------------------------ + @extend_schema( + summary="Manage CourseEnrollmentAllowed records (admin-only)", + description=( + "GET lists allowed enrollments for an email; POST creates a new one; DELETE removes " + "an existing one by email + course_id. Admin-only." + ), + request=OpenApiRequest(request=CourseEnrollmentAllowedSerializer), + parameters=[_query_param("email", "Email to query (GET only). Defaults to the requester's email.")], + responses={ + 200: OpenApiResponse( + response=CourseEnrollmentAllowedSerializer(many=True), + description="GET success — list of allowed enrollments for the email.", + ), + 201: OpenApiResponse( + response=CourseEnrollmentAllowedSerializer, + description="POST success — allowed enrollment created.", + ), + 204: OpenApiResponse(description="DELETE success — allowed enrollment deleted."), + 400: _RESP_BAD_REQUEST, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse(description="DELETE: allowed enrollment not found."), + 409: OpenApiResponse(description="POST: allowed enrollment already exists."), + }, + ) + @action( + detail=False, + methods=["get", "post", "delete"], + url_path="enrollment_allowed", + permission_classes=[permissions.IsAdminUser], + throttle_classes=[EnrollmentUserThrottle], + ) + def allowed(self, request): + """Retrieve, create, or delete CourseEnrollmentAllowed records. Admin-only.""" + if request.method == "GET": + user_email = request.query_params.get("email") or request.user.email + enrollments_allowed = _OPS.list_allowed_for_email(user_email) + return Response( + status=status.HTTP_200_OK, + data=self.get_serializer(enrollments_allowed, many=True).data, + ) + + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + raise ValidationError(serializer.errors) + + if request.method == "POST": + enrollment_allowed = _OPS.create_allowed_enrollment(serializer) + return Response( + status=status.HTTP_201_CREATED, + data=self.get_serializer(enrollment_allowed).data, + ) + + # DELETE + _OPS.delete_allowed_enrollment( + email=serializer.validated_data.get("email"), + course_id=serializer.validated_data.get("course_id"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +# =========================================================================== +# EnrollmentRetrieveView — singleton GET /enrollment/{course_id} +# =========================================================================== +# Kept as a standalone APIView because the {username},{course_id} URL form +# (comma-separated, both optional) is not expressible via DefaultRouter. +class EnrollmentRetrieveView(StandardizedErrorMixin, ApiKeyPermissionMixIn, APIView): + """GET enrollment for a course (and optionally a named user).""" + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + EnrollmentCrossDomainSessionAuth, + ) + permission_classes = (ApiKeyHeaderPermissionIsAuthenticated,) + throttle_classes = (EnrollmentUserThrottle,) + serializer_class = CourseEnrollmentSerializer + + @extend_schema( + summary="Retrieve a user's enrollment in a course", + description=( + "Returns the current user's enrollment for the specified course, or the named user's " + "enrollment when invoked with the {username},{course_id} URL form (server-to-server or " + "staff only)." + ), + parameters=[_USERNAME_PATH_PARAM, _COURSE_ID_PATH_PARAM], + responses={ + 200: OpenApiResponse( + response=CourseEnrollmentSerializer, + description="Enrollment retrieved successfully (or empty body if no enrollment).", + ), + 400: _RESP_BAD_REQUEST, + 404: _RESP_NOT_FOUND, + }, + ) + @method_decorator(ensure_csrf_cookie_cross_domain) + def get(self, request, course_id=None, username=None): + """ + Return the enrollment for ``(username, course_id)``. + + When ``username`` is omitted (the ``GET /enrollment/{course_id}`` + URL form), the request user is used. Non-staff callers may only + look up their own enrollment; any cross-user lookup without + ``has_api_key`` or staff privileges raises ``NotFound`` (so the + caller cannot probe for the existence of other users' enrollments). + """ + if username is None: + username = request.user.username + + if ( + username != request.user.username + and not self.has_api_key_permissions(request) + and not request.user.is_staff + ): + # Hide existence of other users' enrollments. + raise NotFound() + + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError as exc: + raise ValidationError(f"No course '{course_id}' found for enrollment") from exc + + try: + enrollment = CourseEnrollment.objects.get(user__username=username, course_id=course_key) + except CourseEnrollment.DoesNotExist: + return Response(None) + except CourseEnrollmentError as exc: + raise ValidationError( + f"An error occurred while retrieving enrollments for user " + f"'{username}' in course '{course_id}'" + ) from exc + + return Response(self.serializer_class(enrollment).data) + + +# =========================================================================== +# UserRolesView — GET /roles/ (singleton list endpoint for the current user) +# =========================================================================== +class UserRolesView(StandardizedErrorMixin, APIView): + """List the current user's course-level roles.""" + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + EnrollmentCrossDomainSessionAuth, + ) + permission_classes = (ApiKeyHeaderPermissionIsAuthenticated,) + throttle_classes = (EnrollmentUserThrottle,) + serializer_class = UserRolesResponseSerializer + + # ADR 0033: ``course_key`` is the preferred filter name (OEP-68); + # ``course_id`` is retained as a deprecated alias. + _LEGACY_PARAM_ALIASES = (("course_id", "course_key"),) + + @extend_schema( + summary="List the current user's course roles", + description=( + "Returns the list of course-level roles held by the currently logged-in user, plus " + "an is_staff flag. Optionally filters by course_key (or course_id, deprecated)." + ), + parameters=[ + _query_param("course_key", "If provided, only roles for this course are returned (OEP-68)."), + _query_param( + "course_id", "Deprecated alias for 'course_key' (ADR 0033). Use 'course_key' instead.", + deprecated=True, + ), + ], + responses={ + 200: OpenApiResponse( + response=UserRolesResponseSerializer, + description="Roles retrieved successfully.", + ), + 400: _RESP_BAD_REQUEST, + }, + ) + @method_decorator(ensure_csrf_cookie_cross_domain) + def get(self, request): + """ + List the current user's course-level roles. + + Optionally filtered by ``course_key`` (preferred, OEP-68) or + ``course_id`` (deprecated alias). When both are present, + ``course_key`` wins and the response carries the ADR 0033 + ``Deprecation`` HTTP header. + """ + try: + course_key = request.GET.get("course_key") or request.GET.get("course_id") + roles_data = api.get_user_roles(request.user.username) + if course_key: + roles_data = [role for role in roles_data if str(role.course_id) == course_key] + except Exception as exc: # pylint: disable=broad-except + raise ValidationError( + f"An error occurred while retrieving roles for user '{request.user.username}'" + ) from exc + + serializer = self.serializer_class({ + "roles": list(roles_data), + "is_staff": request.user.is_staff, + }) + response = Response(serializer.data) + return _maybe_set_legacy_param_deprecation_header( + request, response, self._LEGACY_PARAM_ALIASES, + ) + + +# =========================================================================== +# CourseEnrollmentDetailView — GET /course/{course_id} (public, no auth) +# =========================================================================== +class CourseEnrollmentDetailView(StandardizedErrorMixin, APIView): + """Get enrollment information about a particular course.""" + + authentication_classes = () + permission_classes = () + throttle_classes = (EnrollmentUserThrottle,) + serializer_class = CourseSerializer + + @extend_schema( + summary="Get enrollment details for a course", + description=( + "Returns the course schedule and supported enrollment modes. No authentication " + "required. Use ?include_expired=1 to include expired enrollment modes." + ), + parameters=[_COURSE_ID_PATH_PARAM, _INCLUDE_EXPIRED_QUERY_PARAM], + responses={ + 200: OpenApiResponse( + response=CourseSerializer, + description="Course enrollment details retrieved successfully.", + ), + 400: _RESP_BAD_REQUEST, + 404: _RESP_NOT_FOUND, + }, + ) + def get(self, request, course_id=None): + """ + Return enrollment-related details for the specified course. + + Public (no authentication required). The response includes the + course schedule and supported enrollment modes; pass + ``?include_expired=1`` to include expired enrollment modes. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError as exc: + raise ValidationError(f"No course found for course ID '{course_id}'") from exc + try: + course_overview = CourseOverview.get_from_id(course_key) + except CourseOverview.DoesNotExist as exc: + raise NotFound(f"No course found for course ID '{course_id}'") from exc + + include_expired = bool(request.GET.get("include_expired", "")) + serializer = self.serializer_class(course_overview, include_expired=include_expired) + return Response(serializer.data) + + +# =========================================================================== +# EnrollmentsAdminListView — GET /enrollments/ (admin paginated list) +# =========================================================================== +@extend_schema( + summary="List all course enrollments (admin-only, paginated)", + description=( + "Admin-only paginated list of CourseEnrollment records, optionally filtered by " + "course_key, course_keys, username, or email, and optionally ordered." + ), + parameters=[ + _query_param("course_key", "Filter to enrollments for this course (OEP-68)."), + _query_param("course_keys", "Comma-separated list of course keys (OEP-68)."), + _query_param( + "course_id", "Deprecated alias for 'course_key' (ADR 0033). Use 'course_key' instead.", + deprecated=True, + ), + _query_param( + "course_ids", "Deprecated alias for 'course_keys' (ADR 0033). Use 'course_keys' instead.", + deprecated=True, + ), + _query_param("username", "Comma-separated list of usernames."), + _query_param("email", "Comma-separated list of emails."), + _query_param("ordering", "Order results by one of: created, -created, id, -id (ADR 0033 §3)."), + _PAGE_QUERY_PARAM, + _PAGE_SIZE_QUERY_PARAM, + ], + responses={ + 200: OpenApiResponse( + response=CourseEnrollmentsApiListSerializer(many=True), + description="Paginated list of course enrollments.", + ), + 400: _RESP_BAD_REQUEST, + 401: _RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + }, +) +class EnrollmentsAdminListView(StandardizedErrorMixin, ListAPIView): + """Admin-only paginated enrollment list with OEP-68 filter aliases.""" + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + EnrollmentCrossDomainSessionAuth, + ) + permission_classes = (permissions.IsAdminUser,) + throttle_classes = (EnrollmentUserThrottle,) + serializer_class = CourseEnrollmentsApiListSerializer + pagination_class = EnrollmentsAdminListPagination + + # ADR 0033 §3 — whitelist of allowed values for the ``ordering`` param. + ALLOWED_ORDERING_FIELDS = frozenset({"created", "-created", "id", "-id"}) + + # ADR 0033 §2 / OEP-68 alias pairs accepted by this endpoint. + _LEGACY_PARAM_ALIASES = ( + ("course_id", "course_key"), + ("course_ids", "course_keys"), + ) + + def get_queryset(self): + form = EnrollmentsAdminListForm(self.request.query_params) + if not form.is_valid(): + raise ValidationError(form.errors) + + queryset = CourseEnrollment.objects.all().select_related("user", "course") + course_id = form.cleaned_data.get("course_id") + course_ids = form.cleaned_data.get("course_ids") + usernames = form.cleaned_data.get("username") + emails = form.cleaned_data.get("email") + + if course_id: + queryset = queryset.filter(course_id=course_id) + if course_ids: + queryset = queryset.filter(course_id__in=course_ids) + if usernames: + queryset = queryset.filter(user__username__in=usernames) + if emails: + queryset = queryset.filter(user__email__in=emails) + + ordering = self.request.query_params.get("ordering") + if ordering in self.ALLOWED_ORDERING_FIELDS: + queryset = queryset.order_by(ordering) + return queryset + + def list(self, request, *args, **kwargs): + """Override to emit the ADR 0033 Deprecation header when legacy params used.""" + response = super().list(request, *args, **kwargs) + return _maybe_set_legacy_param_deprecation_header( + request, response, self._LEGACY_PARAM_ALIASES, + ) diff --git a/openedx/core/lib/api/mixins.py b/openedx/core/lib/api/mixins.py index f0af9072e101..693a02ecf155 100644 --- a/openedx/core/lib/api/mixins.py +++ b/openedx/core/lib/api/mixins.py @@ -8,6 +8,29 @@ from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response +from openedx.core.lib.api.exceptions import standardized_error_exception_handler + + +class StandardizedErrorMixin: + """ + Opt-in mixin that routes DRF exceptions on this view through the ADR 0029 + standardized error-response handler (see + ``openedx.core.lib.api.exceptions.standardized_error_exception_handler``). + + DRF's :class:`rest_framework.views.APIView` calls ``self.get_exception_handler`` + inside ``handle_exception``; overriding that method here lets the view + return the standardized envelope while other endpoints continue to use + whichever handler the project-wide ``EXCEPTION_HANDLER`` setting points at. + + Usage:: + + class MyViewSet(StandardizedErrorMixin, viewsets.ViewSet): + ... + """ + + def get_exception_handler(self): + return standardized_error_exception_handler + class PutAsCreateMixin(CreateModelMixin): """