Skip to content
Open
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
8 changes: 5 additions & 3 deletions cms/djangoapps/contentstore/rest_api/v1/views/xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
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.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from edx_rest_framework_extensions.mixins import StandardizedErrorMixin
from edx_rest_framework_extensions.routers import USAGE_KEY_LOOKUP_REGEX
from edx_rest_framework_extensions.shaping import project
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from rest_framework import viewsets
Expand All @@ -51,7 +54,6 @@
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 Expand Up @@ -151,7 +153,7 @@ def _apply_minimal_view(response):
# which returns the grader-type value directly), there's nothing to
# filter — return the response untouched.
return response
return JsonResponse({k: v for k, v in body.items() if k in _MINIMAL_VIEW_FIELDS})
return JsonResponse(project(body, _MINIMAL_VIEW_FIELDS))


@extend_schema(tags=["openedx-platform-sdk"])
Expand Down Expand Up @@ -180,7 +182,7 @@ class XblockViewSet(StandardizedErrorMixin, viewsets.ViewSet):
permission_classes = (IsAuthenticated, HasCourseAuthorAccess)
serializer_class = XblockSerializer
lookup_field = "usage_key_string"
lookup_value_regex = r'(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+)'
lookup_value_regex = USAGE_KEY_LOOKUP_REGEX

def __init__(self, **kwargs):
super().__init__(**kwargs)
Expand Down
34 changes: 1 addition & 33 deletions cms/djangoapps/contentstore/rest_api/v3/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@
* :data:`COMMON_ERROR_RESPONSES` – the shared ``@extend_schema(responses=...)``
fragment for the 401 / 403 / 404 cases every v3 course-scoped viewset
can raise.
* :func:`apply_field_selection` – ADR 0036 helper. Drops every top-level
key not listed in the caller's ``?fields=`` CSV. No-op when ``?fields=``
is absent. Use this when an action returns a wide flat object and clients
want to request a subset (e.g. ``?fields=id,display_name,courses``).
"""

from drf_spectacular.utils import OpenApiResponse
Expand All @@ -35,7 +31,7 @@ def resolve_course_key(course_key: str) -> CourseKey:
Raises:
rest_framework.exceptions.NotFound: if the string is unparseable
*or* the course does not exist. The ADR 0029 envelope (wired in
by :class:`openedx.core.lib.api.mixins.StandardizedErrorMixin`)
by :class:`edx_rest_framework_extensions.mixins.StandardizedErrorMixin`)
renders both as a structured 404.

OEP-68: the parameter name is ``course_key`` rather than the legacy
Expand All @@ -57,31 +53,3 @@ def resolve_course_key(course_key: str) -> CourseKey:
403: OpenApiResponse(description="The requester cannot access the specified course."),
404: OpenApiResponse(description="The requested course does not exist."),
}


def apply_field_selection(data, fields_csv):
"""
ADR 0036 — drop every top-level key not listed in ``fields_csv``.

Args:
data: a ``dict`` (typically ``serializer.data``). Anything else is
returned untouched.
fields_csv: the raw value of the ``?fields=`` query parameter. ``None``
or empty string → no filtering (the full ``data`` is returned).

Returns:
A new ``dict`` containing only the requested top-level keys, or the
original ``data`` if filtering is not applicable.

Note:
Only top-level keys are honoured. Dotted paths (``?fields=children.x``)
are stripped to their first segment (``children``) — full dotted-path
traversal is intentionally left to a future implementation per the
ADR 0036 guidance to "reject silent over-fetching" via that syntax.
"""
if not fields_csv or not isinstance(data, dict):
return data
wanted = {name.strip().split(".", 1)[0] for name in fields_csv.split(",") if name.strip()}
if not wanted:
return data
return {key: value for key, value in data.items() if key in wanted}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
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.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from edx_rest_framework_extensions.mixins import StandardizedErrorMixin
from edx_rest_framework_extensions.routers import COURSE_KEY_LOOKUP_REGEX
from openedx_authz.constants.permissions import COURSES_EDIT_GRADING_SETTINGS
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
Expand All @@ -70,7 +72,6 @@
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.lib.api.mixins import StandardizedErrorMixin

_COURSE_KEY_PARAMETER = OpenApiParameter(
name="course_key",
Expand Down Expand Up @@ -108,7 +109,7 @@ class AuthoringGradingViewSet(StandardizedErrorMixin, viewsets.ViewSet):
# DefaultRouter lookup: matches course-v1:org+course+run (+ or / separators).
# OEP-68: the kwarg name is ``course_key`` (not the legacy ``course_id``).
lookup_field = "course_key"
lookup_value_regex = r"[^/+]+(?:/|\+)[^/+]+(?:/|\+)[^/?]+"
lookup_value_regex = COURSE_KEY_LOOKUP_REGEX

def get_serializer(self, *args, **kwargs):
"""Instantiate and return the configured serializer class."""
Expand Down
25 changes: 11 additions & 14 deletions cms/djangoapps/contentstore/rest_api/v3/views/course_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
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.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from edx_rest_framework_extensions.mixins import StandardizedErrorMixin
from edx_rest_framework_extensions.routers import COURSE_KEY_LOOKUP_REGEX
from edx_rest_framework_extensions.shaping import MinimalViewMixin, project
from openedx_authz.constants.permissions import (
COURSES_EDIT_DETAILS,
COURSES_EDIT_SCHEDULE,
Expand All @@ -47,14 +50,12 @@
from cms.djangoapps.contentstore.rest_api.v1.views.course_details import _classify_update
from cms.djangoapps.contentstore.rest_api.v3.utils import (
COMMON_ERROR_RESPONSES,
apply_field_selection,
resolve_course_key,
)
from cms.djangoapps.contentstore.utils import update_course_details
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.lib.api.mixins import StandardizedErrorMixin
from xmodule.modulestore.django import modulestore

_COURSE_ID_PARAMETER = OpenApiParameter(
Expand Down Expand Up @@ -121,15 +122,8 @@
})


def _apply_view_preset(data, view_preset):
"""ADR 0036 — drop everything outside ``_MINIMAL_VIEW_FIELDS`` when ``?view=minimal``."""
if view_preset != "minimal" or not isinstance(data, dict):
return data
return {key: value for key, value in data.items() if key in _MINIMAL_VIEW_FIELDS}


@extend_schema(tags=["openedx-platform-sdk"])
class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):
class CourseDetailsViewSet(StandardizedErrorMixin, MinimalViewMixin, viewsets.ViewSet):
"""
ViewSet for course details (v3). Registered via DefaultRouter (basename ``course_details``).

Expand All @@ -147,7 +141,10 @@ class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):

# Matches both slash-separated (org/course/run) and plus-separated (course-v1:org+course+run) IDs
lookup_field = "course_id"
lookup_value_regex = r"[^/+]+(?:/|\+)[^/+]+(?:/|\+)[^/?]+"
lookup_value_regex = COURSE_KEY_LOOKUP_REGEX

# ADR 0036 — the ``?view=minimal`` preset (MinimalViewMixin) keeps these keys.
minimal_fields = _MINIMAL_VIEW_FIELDS

@extend_schema(
summary="Retrieve a course's details",
Expand Down Expand Up @@ -199,9 +196,9 @@ def retrieve(self, request: Request, course_id: str):

course_details = CourseDetails.fetch(course_key)
data = self.serializer_class(course_details).data
# ADR 0036 — preset first, then explicit CSV subset.
data = _apply_view_preset(data, request.query_params.get("view"))
data = apply_field_selection(data, request.query_params.get("fields"))
# ADR 0036 — preset first (MinimalViewMixin), then explicit CSV subset.
data = self.shape_minimal(data, request)
data = project(data, request.query_params.get("fields"))
return Response(data)

@extend_schema(
Expand Down
6 changes: 3 additions & 3 deletions cms/djangoapps/contentstore/rest_api/v3/views/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
from drf_spectacular.utils import OpenApiParameter, extend_schema
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from edx_rest_framework_extensions.mixins import StandardizedErrorMixin
from edx_rest_framework_extensions.shaping import project
from organizations import api as org_api
from rest_framework import viewsets
from rest_framework.decorators import action
Expand All @@ -44,9 +46,7 @@
LibraryTabSerializer,
StudioHomeSerializer,
)
from cms.djangoapps.contentstore.rest_api.v3.utils import apply_field_selection
from cms.djangoapps.contentstore.utils import get_course_context, get_home_context, get_library_context
from openedx.core.lib.api.mixins import StandardizedErrorMixin


class _HomeAutoSchema(AutoSchema):
Expand Down Expand Up @@ -129,7 +129,7 @@ def list(self, request: Request):
})
serializer = self.get_serializer(home_context)
# ADR 0036 — drop top-level keys not requested via ?fields=.
return Response(apply_field_selection(serializer.data, request.query_params.get("fields")))
return Response(project(serializer.data, request.query_params.get("fields")))

@apidocs.schema(
parameters=[
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 @@ -6,6 +6,7 @@
from edx_rest_framework_extensions.auth.session.authentication import (
SessionAuthenticationAllowInactiveUser,
)
from edx_rest_framework_extensions.mixins import StandardizedErrorMixin
from edx_rest_framework_extensions.paginators import DefaultPagination
from rest_framework import serializers as _serializers
from rest_framework import viewsets
Expand All @@ -17,7 +18,6 @@
CourseHomeTabSerializerV4,
)
from cms.djangoapps.contentstore.utils import get_course_context_v2
from openedx.core.lib.api.mixins import StandardizedErrorMixin


class _HomeCoursesAutoSchema(AutoSchema):
Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/enrollments/v2/view_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from edx_rest_framework_extensions.errors import Conflict
from rest_framework.exceptions import (
APIException,
NotFound,
Expand All @@ -50,7 +51,6 @@
)
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 (
Expand Down
44 changes: 27 additions & 17 deletions openedx/core/djangoapps/enrollments/v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
extend_schema,
)
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.paginators import DefaultPagination
from edx_rest_framework_extensions.mixins import StandardizedErrorMixin
from edx_rest_framework_extensions.paginators import DefaultPagination, IterablePaginationMixin
from edx_rest_framework_extensions.shaping import MinimalViewMixin
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions, status, viewsets
Expand Down Expand Up @@ -77,7 +79,6 @@
EnrollmentUserThrottle,
)
from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser
from openedx.core.lib.api.mixins import StandardizedErrorMixin
from openedx.core.lib.api.permissions import ApiKeyHeaderPermissionIsAuthenticated

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -187,17 +188,28 @@ def _to_minimal_enrollment(enrollment_dict):
return minimal


def _is_minimal_view_requested(request) -> bool:
"""Return True when the caller asked for the ADR 0036 minimal preset."""
return request.query_params.get("view") == "minimal"
class _EnrollmentMinimalViewMixin(MinimalViewMixin):
"""
ADR 0036 ``?view=minimal`` for enrollment payloads, on the shared
:class:`~edx_rest_framework_extensions.shaping.MinimalViewMixin`. The
enrollment preset is not a plain field projection — it collapses the
embedded ``course_details`` sub-object to a ``course_id`` string — so the
representation hook is overridden instead of setting ``minimal_fields``.
"""

def to_minimal_representation(self, item):
return _to_minimal_enrollment(item)


# ===========================================================================
# EnrollmentViewSet — consolidates list / create / unenroll / allowed
# ===========================================================================
@can_disable_rate_limit
@extend_schema(tags=["openedx-platform-sdk"])
class EnrollmentViewSet(StandardizedErrorMixin, viewsets.ViewSet, ApiKeyPermissionMixIn):
class EnrollmentViewSet(
StandardizedErrorMixin, _EnrollmentMinimalViewMixin, IterablePaginationMixin,
viewsets.ViewSet, ApiKeyPermissionMixIn,
):
"""
Canonical ViewSet for the v2 Enrollment API.

Expand Down Expand Up @@ -279,12 +291,13 @@ def list(self, request):
target_username=username,
has_api_key=self.has_api_key_permissions(request),
)
paginator = self.pagination_class()
page = paginator.paginate_queryset(enrollments, request, view=self)
data = self.get_serializer(page, many=True).data
if _is_minimal_view_requested(request):
data = [_to_minimal_enrollment(item) for item in data]
return paginator.get_paginated_response(data)
# ADR 0032 pagination envelope; the ADR 0036 minimal preset is applied
# to the serialized page.
return self.paginate_iterable(
request,
enrollments,
serialize=lambda page: self.shape_minimal(self.get_serializer(page, many=True).data),
)

# ------------------------------------------------------------------
# create — POST /enrollment/
Expand Down Expand Up @@ -425,7 +438,7 @@ def allowed(self, request):
# Kept as a standalone APIView because the {username},{course_id} URL form
# (comma-separated, both optional) is not expressible via DefaultRouter.
@extend_schema(tags=["openedx-platform-sdk"])
class EnrollmentRetrieveView(StandardizedErrorMixin, ApiKeyPermissionMixIn, APIView):
class EnrollmentRetrieveView(StandardizedErrorMixin, _EnrollmentMinimalViewMixin, ApiKeyPermissionMixIn, APIView):
"""GET enrollment for a course (and optionally a named user)."""

# ADR 0034 — JWT + cross-domain session (BearerAuthenticationAllowInactiveUser
Expand Down Expand Up @@ -494,10 +507,7 @@ def get(self, request, course_id=None, username=None):
f"'{username}' in course '{course_id}'"
) from exc

data = self.serializer_class(enrollment).data
if _is_minimal_view_requested(request):
data = _to_minimal_enrollment(data)
return Response(data)
return Response(self.shape_minimal(self.serializer_class(enrollment).data))


# ===========================================================================
Expand Down
Loading
Loading