From a289b0f7c989a1aa7e5a645d47863372c7a37994 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:48:46 +0500 Subject: [PATCH 1/7] feat: adopt the edx-drf-extensions 10.8.0 REST API building blocks Bump edx-drf-extensions to 10.8.0, which now ships the reusable core of the FC-0118 REST API conventions (standardized error envelope, response shaping, manual pagination, opaque-key lookup regexes, envelope test helper). openedx/core/lib/api/exceptions.py and the StandardizedErrorMixin in mixins.py become re-exports of the library implementation, keeping the 182 existing openedx.core.lib.api import paths working. The library handler delegates to a configurable base handler, so EDX_DRF_EXTENSIONS['STANDARDIZED_ERROR_BASE_HANDLER'] is pointed at ignored_error_exception_handler to preserve the platform's ignored-error logging and monitoring unchanged. --- openedx/core/lib/api/exceptions.py | 146 +++-------------------------- openedx/core/lib/api/mixins.py | 26 +---- openedx/envs/common.py | 10 ++ requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- uv.lock | 6 +- 6 files changed, 29 insertions(+), 163 deletions(-) diff --git a/openedx/core/lib/api/exceptions.py b/openedx/core/lib/api/exceptions.py index 13f6cb744c5c..6953d7294900 100644 --- a/openedx/core/lib/api/exceptions.py +++ b/openedx/core/lib/api/exceptions.py @@ -1,135 +1,11 @@ -"""ADR 0029 - Standardized error-response exception handler and helpers.""" - -from rest_framework.exceptions import APIException, ValidationError -from rest_framework.response import Response - - -class Conflict(APIException): - """HTTP 409 Conflict — ADR 0029.""" - - status_code = 409 - default_detail = "A conflict occurred." - default_code = "conflict" - - -def standardized_error_exception_handler(exc, context): - """ - ADR 0029 - platform-level DRF exception handler. - - Wraps the existing ``ignored_error_exception_handler`` and reformats its - response into the standardized JSON error envelope:: - - { - "type": "https://docs.openedx.org/errors/{category}", - "title": "", - "status": , - "detail": "", - "instance": "" - } - - For ``ValidationError``, an additional ``errors`` key is included with - per-field error details. - """ - from openedx.core.lib.request_utils import ( - ignored_error_exception_handler, - ) # avoid circular import - - response = ignored_error_exception_handler(exc, context) - - if response is None: - return Response( - { - "type": "https://docs.openedx.org/errors/internal", - "title": "Internal Server Error", - "status": 500, - "detail": "An unexpected error occurred. Please try again later.", - }, - status=500, - ) - - request = context.get("request") - body = { - "type": f"https://docs.openedx.org/errors/{_error_type(exc)}", - "title": _error_title(exc), - "status": response.status_code, - "detail": _flatten_detail(response.data), - } - if request: - body["instance"] = request.path - if hasattr(exc, "user_message") and exc.user_message: - body["user_message"] = exc.user_message - if isinstance(exc, ValidationError) and hasattr(exc, "detail"): - body["errors"] = _normalize_validation_errors(exc.detail) - - response.data = body - response["Content-Type"] = "application/json" - return response - - -def _error_type(exc): - """Map a DRF exception to an ADR 0029 error category slug.""" - from rest_framework.exceptions import ( # avoid circular import at module level - AuthenticationFailed, - NotAuthenticated, - NotFound, - PermissionDenied, - Throttled, - ) - - if isinstance(exc, (NotAuthenticated, AuthenticationFailed)): - return "authn" - if isinstance(exc, PermissionDenied): - return "authz" - if isinstance(exc, NotFound): - return "not-found" - if isinstance(exc, ValidationError): - return "validation" - if isinstance(exc, Throttled): - return "rate-limited" - if isinstance(exc, Conflict): - return "conflict" - return "internal" - - -def _error_title(exc): - """Return a human-readable title for the given DRF exception.""" - from rest_framework.exceptions import ( # avoid circular import at module level - AuthenticationFailed, - NotAuthenticated, - NotFound, - PermissionDenied, - Throttled, - ) - - return { - NotAuthenticated: "Authentication Required", - AuthenticationFailed: "Authentication Failed", - PermissionDenied: "Permission Denied", - NotFound: "Not Found", - ValidationError: "Validation Error", - Throttled: "Too Many Requests", - Conflict: "Conflict", - }.get(type(exc), "Internal Server Error") - - -def _flatten_detail(data): - """Extract a single string detail message from a DRF response data payload.""" - if isinstance(data, str): - return data - if isinstance(data, dict) and "detail" in data: - return str(data["detail"]) - if isinstance(data, list) and data: - return str(data[0]) - return str(data) - - -def _normalize_validation_errors(detail): - """Convert DRF validation error detail into a consistent per-field dict.""" - if isinstance(detail, dict): - return { - field: [str(e) for e in (errs if isinstance(errs, list) else [errs])] - for field, errs in detail.items() - } - if isinstance(detail, list): - return {"non_field_errors": [str(e) for e in detail]} - return {"non_field_errors": [str(detail)]} +""" +ADR 0029 - Standardized error-response exception handler and helpers. + +The implementation lives in ``edx_rest_framework_extensions.errors``; this +module re-exports it for existing import sites. New code should import from +``edx_rest_framework_extensions.errors`` directly. +""" +from edx_rest_framework_extensions.errors import ( # pylint: disable=unused-import + Conflict, + standardized_error_exception_handler, +) diff --git a/openedx/core/lib/api/mixins.py b/openedx/core/lib/api/mixins.py index 693a02ecf155..451bf63cf992 100644 --- a/openedx/core/lib/api/mixins.py +++ b/openedx/core/lib/api/mixins.py @@ -4,33 +4,13 @@ from django.core.exceptions import ValidationError from django.http import Http404 +# Re-exported for existing import sites; new code should import it from +# edx_rest_framework_extensions.mixins directly. +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin # pylint: disable=unused-import from rest_framework import status 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): """ diff --git a/openedx/envs/common.py b/openedx/envs/common.py index e4326a9fa81d..48c9eced9c66 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -972,6 +972,16 @@ def add_optional_apps(optional_apps, installed_apps): # Set this value to an empty dict in order to prevent automatically updating # user data from values in (possibly stale) JWTs. 'JWT_PAYLOAD_USER_ATTRIBUTE_MAPPING': {}, + + # .. setting_name: EDX_DRF_EXTENSIONS['STANDARDIZED_ERROR_BASE_HANDLER'] + # .. setting_default: openedx.core.lib.request_utils.ignored_error_exception_handler + # .. setting_description: The exception handler that the ADR 0029 standardized + # error-response handler (``edx_rest_framework_extensions.errors + # .standardized_error_exception_handler``) delegates to before shaping the + # error envelope. Pointing it at ``ignored_error_exception_handler`` + # preserves the platform's ignored-error logging and monitoring on every + # endpoint that opts into the standardized envelope. + 'STANDARDIZED_ERROR_BASE_HANDLER': 'openedx.core.lib.request_utils.ignored_error_exception_handler', } ################################# Features ################################# diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ba5965c7c749..aafbc395d366 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -467,7 +467,7 @@ edx-django-utils==8.0.2 # ora2 # super-csv # xblocks-contrib -edx-drf-extensions==10.7.0 +edx-drf-extensions==10.8.0 # via # edx-completion # edx-enterprise diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index b111cdde9964..9fb980ed12e9 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -520,7 +520,7 @@ edx-django-utils==8.0.2 # ora2 # super-csv # xblocks-contrib -edx-drf-extensions==10.7.0 +edx-drf-extensions==10.8.0 # via # edx-completion # edx-enterprise diff --git a/uv.lock b/uv.lock index e53aef313aca..d9e58461dc59 100644 --- a/uv.lock +++ b/uv.lock @@ -2023,7 +2023,7 @@ wheels = [ [[package]] name = "edx-drf-extensions" -version = "10.7.0" +version = "10.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "4.2.30", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-16-openedx-platform-django42'" }, @@ -2037,9 +2037,9 @@ dependencies = [ { name = "requests" }, { name = "semantic-version" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/06/b32d6d48415d9278c188a70da786796715ce2c4e73178f114fd498108052/edx_drf_extensions-10.7.0.tar.gz", hash = "sha256:784710bf9dc77e4234d201295963c20fd15b4e27595f1c1587b180a79e0914d4", size = 80429, upload-time = "2026-08-18T15:32:48.976Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/ce/7b348f25bb9a975171166904abe740a026ce7c6dca10e3827c927aab36cb/edx_drf_extensions-10.8.0.tar.gz", hash = "sha256:f7a6d1d0a4cfdec7c95635b0f3eb427cc473f28428bfabe3c4e3db0095feb6da", size = 99711, upload-time = "2026-09-02T20:47:28.28Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/e6/03aa9fc1de473702887657c0e165798c99dee32c77a6eca5a4b2f4d8809d/edx_drf_extensions-10.7.0-py2.py3-none-any.whl", hash = "sha256:c1931816a88ac60908051e28ecb6fd18ba97cc3d6f18d61360d8eb22d3203886", size = 79474, upload-time = "2026-08-18T15:32:47.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/7db09d26bb1c01ecef32d0762207930c900caf689d9bdc635c31406d4488/edx_drf_extensions-10.8.0-py2.py3-none-any.whl", hash = "sha256:853892aaba931315e82a3d3cf3cab57c5fe105f8d1760d70a45455b6e995e33b", size = 102718, upload-time = "2026-09-02T20:47:26.99Z" }, ] [[package]] From fe21b6cbc32dffeba6e9973922668f3eb9b6f949 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:49:01 +0500 Subject: [PATCH 2/7] feat: use edx-drf-extensions building blocks in Enrollments v2 API Import StandardizedErrorMixin and Conflict from the library, apply the ADR 0036 ?view=minimal preset through MinimalViewMixin (the enrollment preset overrides to_minimal_representation, since collapsing the embedded course_details sub-object to a course_id string is not a plain field projection), and paginate the list action through IterablePaginationMixin.paginate_iterable instead of the hand-written paginate/serialize/respond sequence. Response shapes are unchanged. --- .../enrollments/v2/view_services.py | 2 +- .../core/djangoapps/enrollments/v2/views.py | 44 ++++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/openedx/core/djangoapps/enrollments/v2/view_services.py b/openedx/core/djangoapps/enrollments/v2/view_services.py index 75c7de0dcec3..df310236d139 100644 --- a/openedx/core/djangoapps/enrollments/v2/view_services.py +++ b/openedx/core/djangoapps/enrollments/v2/view_services.py @@ -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, @@ -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 ( diff --git a/openedx/core/djangoapps/enrollments/v2/views.py b/openedx/core/djangoapps/enrollments/v2/views.py index 5979b0aa92f7..4b13e247d8f6 100644 --- a/openedx/core/djangoapps/enrollments/v2/views.py +++ b/openedx/core/djangoapps/enrollments/v2/views.py @@ -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 @@ -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__) @@ -187,9 +188,17 @@ 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) # =========================================================================== @@ -197,7 +206,10 @@ def _is_minimal_view_requested(request) -> bool: # =========================================================================== @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. @@ -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/ @@ -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 @@ -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)) # =========================================================================== From 68ece5644210550a078ff7bb269bfefeaf9c7236 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:49:01 +0500 Subject: [PATCH 3/7] feat: use edx-drf-extensions building blocks in Xblock v1 API Import StandardizedErrorMixin from the library, take the router lookup_value_regex from the shared USAGE_KEY_LOOKUP_REGEX constant (identical pattern), and filter the ?view=minimal payload with the shared project() helper. Response shapes are unchanged. --- cms/djangoapps/contentstore/rest_api/v1/views/xblock.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py index 2d8d94ceb1de..7549922dfdfa 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py @@ -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 @@ -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__) @@ -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"]) @@ -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) From 55aac37e2a9cbcb28574bc139eb62c0d08d689ba Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:49:01 +0500 Subject: [PATCH 4/7] feat: use edx-drf-extensions building blocks in Course Details v3 API Import StandardizedErrorMixin from the library, take the router lookup_value_regex from the shared COURSE_KEY_LOOKUP_REGEX constant, serve the ADR 0036 ?view=minimal preset through MinimalViewMixin with minimal_fields (replacing the local _apply_view_preset helper), and apply ?fields= selection with the shared project() helper. Response shapes are unchanged. --- .../rest_api/v3/views/course_details.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/course_details.py b/cms/djangoapps/contentstore/rest_api/v3/views/course_details.py index 0b0406421524..d89f9b8cc72a 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/course_details.py @@ -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, @@ -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( @@ -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``). @@ -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", @@ -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( From 14536278af64c9bee978e26c8f00e45bdb517dbe Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:49:01 +0500 Subject: [PATCH 5/7] feat: use edx-drf-extensions building blocks in Grading v3 API Import StandardizedErrorMixin from the library and take the router lookup_value_regex from the shared COURSE_KEY_LOOKUP_REGEX constant. Response shapes are unchanged. --- .../contentstore/rest_api/v3/views/authoring_grading.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py b/cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py index 15d2a2d50a94..196d8aebcc1b 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py @@ -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 @@ -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", @@ -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.""" From bb5e06197d7fb5b064bfd21997a14ee5299f7332 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:49:01 +0500 Subject: [PATCH 6/7] feat: use edx-drf-extensions building blocks in Home v3 and v4 APIs Import StandardizedErrorMixin from the library and apply ?fields= selection with the shared project() helper. With its last caller migrated, the local apply_field_selection helper is removed from the v3 utils module. Response shapes are unchanged. --- .../contentstore/rest_api/v3/utils.py | 34 +------------------ .../contentstore/rest_api/v3/views/home.py | 6 ++-- .../contentstore/rest_api/v4/views/home.py | 2 +- 3 files changed, 5 insertions(+), 37 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v3/utils.py b/cms/djangoapps/contentstore/rest_api/v3/utils.py index 79524acb8c53..07d2c10b800a 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/utils.py +++ b/cms/djangoapps/contentstore/rest_api/v3/utils.py @@ -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 @@ -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 @@ -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} diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/home.py b/cms/djangoapps/contentstore/rest_api/v3/views/home.py index e3628bdc36aa..6ae636504abc 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -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 @@ -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): @@ -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=[ diff --git a/cms/djangoapps/contentstore/rest_api/v4/views/home.py b/cms/djangoapps/contentstore/rest_api/v4/views/home.py index 6fd7fc2a01ef..60fee8ac0c24 100644 --- a/cms/djangoapps/contentstore/rest_api/v4/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v4/views/home.py @@ -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 @@ -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): From 4d3ef2efcc7dac1dd1f36cd442ebb36cad8a9937 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:28:41 +0500 Subject: [PATCH 7/7] fix: declare the api shim re-exports via __all__ to satisfy ruff --- openedx/core/lib/api/exceptions.py | 7 +++---- openedx/core/lib/api/mixins.py | 8 +++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/openedx/core/lib/api/exceptions.py b/openedx/core/lib/api/exceptions.py index 6953d7294900..8b3ce64b7c68 100644 --- a/openedx/core/lib/api/exceptions.py +++ b/openedx/core/lib/api/exceptions.py @@ -5,7 +5,6 @@ module re-exports it for existing import sites. New code should import from ``edx_rest_framework_extensions.errors`` directly. """ -from edx_rest_framework_extensions.errors import ( # pylint: disable=unused-import - Conflict, - standardized_error_exception_handler, -) +from edx_rest_framework_extensions.errors import Conflict, standardized_error_exception_handler + +__all__ = ["Conflict", "standardized_error_exception_handler"] diff --git a/openedx/core/lib/api/mixins.py b/openedx/core/lib/api/mixins.py index 451bf63cf992..4df4dfbb2704 100644 --- a/openedx/core/lib/api/mixins.py +++ b/openedx/core/lib/api/mixins.py @@ -4,13 +4,15 @@ from django.core.exceptions import ValidationError from django.http import Http404 -# Re-exported for existing import sites; new code should import it from -# edx_rest_framework_extensions.mixins directly. -from edx_rest_framework_extensions.mixins import StandardizedErrorMixin # pylint: disable=unused-import +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin from rest_framework import status from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response +# StandardizedErrorMixin is re-exported for existing import sites; new code +# should import it from edx_rest_framework_extensions.mixins directly. +__all__ = ["PutAsCreateMixin", "StandardizedErrorMixin"] + class PutAsCreateMixin(CreateModelMixin): """