Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions cms/djangoapps/contentstore/rest_api/mixins.py

This file was deleted.

2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/rest_api/v1/views/xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/rest_api/v3/views/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/rest_api/v4/views/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
Empty file.
128 changes: 128 additions & 0 deletions openedx/core/djangoapps/enrollments/v2/forms.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions openedx/core/djangoapps/enrollments/v2/paginators.py
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions openedx/core/djangoapps/enrollments/v2/serializers.py
Original file line number Diff line number Diff line change
@@ -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()
Empty file.
Loading
Loading