From 325548d79ee7c419374d26e80c3df4fe39f63b7d Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 17 Jun 2026 12:43:13 +0500 Subject: [PATCH 1/6] feat: apply ADR 0036 to Xblock v1 (?view=minimal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a ``?view=minimal`` query parameter on ``XblockViewSet.retrieve`` that filters the (tree-shaped) xblock response down to a small set of structural fields — id, display_name, category, children, has_children, studio_url — dropping heavy/contextual fields such as data, metadata, fields, student_view_data, edited_on, published. Default response shape is unchanged (full xblock payload) to avoid breaking the existing Studio frontend. The pre-existing ``?fields=...`` query parameter retains its legacy "type of response" semantics (?fields=graderType, ?fields=ancestorInfo, ?fields=customReadToken); ADR 0036's CSV-subset interpretation is deferred to a future API version to avoid breaking those callers. Adds 4 regression tests covering: default response untouched, minimal strips heavy fields, minimal keeps structural fields, minimal is a no-op for non-dict response bodies (legacy ?fields=graderType path). --- .../v1/views/tests/test_xblock_viewset.py | 74 +++++++++++++++++++ .../contentstore/rest_api/v1/views/xblock.py | 66 ++++++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock_viewset.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock_viewset.py index 8694f7023683..638b0ce2eb35 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock_viewset.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock_viewset.py @@ -142,3 +142,77 @@ def test_error_body_has_no_developer_message(self): def test_instance_field_is_request_path(self): response = self.client.get(_detail_url()) assert response.json()["instance"] == _detail_url() + + +# --------------------------------------------------------------------------- +# ADR 0036 — minimal-view regression tests +# --------------------------------------------------------------------------- +class TestXblockViewSetMinimalView(ModuleStoreTestCase, APITestCase): + """ + ADR 0036 — verify ``?view=minimal`` strips the xblock response down to + the structural fields enumerated in ``_MINIMAL_VIEW_FIELDS`` and leaves + the default (full) response untouched. + """ + + _FULL_PAYLOAD = { + "id": TEST_LOCATOR, + "display_name": "Problem 1", + "category": "problem", + "children": [], + "has_children": False, + "studio_url": "/studio/...", + # Heavy / contextual fields that ``?view=minimal`` MUST drop: + "data": "...", + "metadata": {"weight": 1.0}, + "fields": {"showanswer": "always"}, + "student_view_data": {"...": "..."}, + "edited_on": "2026-06-17T00:00:00Z", + "published": True, + } + + def setUp(self): + super().setUp() + self.author = GlobalStaffFactory.create() + self.client.force_authenticate(user=self.author) + + @patch(f"{_VIEW_MODULE}.retrieve_xblock_response") + def test_default_response_is_unchanged(self, mock_retrieve): + """Without ``?view=minimal`` the response is the full handler payload.""" + mock_retrieve.return_value = JsonResponse(self._FULL_PAYLOAD) + response = self.client.get(_detail_url()) + assert response.status_code == status.HTTP_200_OK + body = response.json() + # Heavy fields must still be present in the default response. + assert "data" in body + assert "metadata" in body + assert "student_view_data" in body + + @patch(f"{_VIEW_MODULE}.retrieve_xblock_response") + def test_minimal_view_strips_heavy_fields(self, mock_retrieve): + """``?view=minimal`` drops data, metadata, fields, student_view_data, edited_on, published.""" + mock_retrieve.return_value = JsonResponse(self._FULL_PAYLOAD) + response = self.client.get(_detail_url(), {"view": "minimal"}) + assert response.status_code == status.HTTP_200_OK + body = response.json() + # Heavy fields MUST be dropped. + for dropped in ("data", "metadata", "fields", "student_view_data", "edited_on", "published"): + assert dropped not in body, f"ADR 0036: ?view=minimal must drop '{dropped}'" + + @patch(f"{_VIEW_MODULE}.retrieve_xblock_response") + def test_minimal_view_keeps_structural_fields(self, mock_retrieve): + """``?view=minimal`` keeps id, display_name, category, children, has_children, studio_url.""" + mock_retrieve.return_value = JsonResponse(self._FULL_PAYLOAD) + response = self.client.get(_detail_url(), {"view": "minimal"}) + body = response.json() + for kept in ("id", "display_name", "category", "children", "has_children", "studio_url"): + assert kept in body, f"ADR 0036: ?view=minimal must keep '{kept}'" + assert body["id"] == TEST_LOCATOR + assert body["category"] == "problem" + + @patch(f"{_VIEW_MODULE}.retrieve_xblock_response") + def test_minimal_view_is_noop_for_non_json_payload(self, mock_retrieve): + """Legacy ``?fields=graderType`` returns a non-dict body — minimal must be a no-op.""" + mock_retrieve.return_value = JsonResponse("notgraded", safe=False) + response = self.client.get(_detail_url(), {"view": "minimal", "fields": "graderType"}) + assert response.status_code == status.HTTP_200_OK + assert response.json() == "notgraded" diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py index acd4a8192ad9..025f2c4c4ca9 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py @@ -8,10 +8,24 @@ * ADR 0026 - explicit authentication_classes + permission_classes * ADR 0028 - consolidated into XblockViewSet via DefaultRouter * ADR 0029 - standardized error envelope via StandardizedErrorMixin + * ADR 0036 - minimal/flattened views. ``retrieve`` accepts a ``?view=minimal`` + query parameter that strips the (tree-shaped) xblock response to a small + set of structural fields. The full xblock response is kept as the default + for backwards compatibility with the existing Studio frontend; new clients + SHOULD opt into ``?view=minimal`` whenever the full nested payload is not + required. + + Note on ``?fields=`` — the underlying ``retrieve_xblock_response`` already + interprets ``?fields=`` with **legacy semantics** as a "type of response" + selector (``?fields=graderType``, ``?fields=ancestorInfo``, + ``?fields=customReadToken``). To avoid breaking existing callers, v1 does + NOT repurpose ``?fields=`` as the ADR 0036 CSV-subset selector — use + ``?view=minimal`` instead. A future v2 may reconcile these names. """ import json import logging +from django.http import JsonResponse from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys import InvalidKeyError @@ -33,6 +47,39 @@ log = logging.getLogger(__name__) +# ADR 0036 — top-level keys kept when ``?view=minimal`` is requested. Chosen so +# the response is structurally complete (callers can navigate the tree by id +# and fetch full nodes on demand) without any heavy/contextual fields +# (student_view_data, completion, OLX metadata, etc.). +_MINIMAL_VIEW_FIELDS = frozenset({ + "id", + "display_name", + "category", + "children", + "has_children", + "studio_url", +}) + + +def _apply_minimal_view(response): + """ + ADR 0036 — when ``?view=minimal`` was requested, drop every top-level key + not in :data:`_MINIMAL_VIEW_FIELDS` from ``response``. No-op for non-JSON + or non-2xx responses. + """ + if not isinstance(response, JsonResponse) or response.status_code >= 300: + return response + try: + body = json.loads(response.content.decode("utf-8") or "{}") + except (ValueError, AttributeError): + return response + if not isinstance(body, dict): + # If the handler returned a non-object payload (e.g. `?fields=graderType` + # 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}) + class XblockViewSet(StandardizedErrorMixin, viewsets.ViewSet): """ @@ -44,6 +91,12 @@ class XblockViewSet(StandardizedErrorMixin, viewsets.ViewSet): PUT /api/contentstore/v1/xblock/{usage_key_string}/ → update PATCH /api/contentstore/v1/xblock/{usage_key_string}/ → partial_update DELETE /api/contentstore/v1/xblock/{usage_key_string}/ → destroy + + Query parameters (ADR 0036, GET only): + ?view=minimal Drop heavy / contextual fields from the response, + keeping only structural fields (id, display_name, + category, children, has_children, studio_url). + Default response is the full xblock payload. """ authentication_classes = ( @@ -98,8 +151,17 @@ def create(self, request): @expect_json_in_class_view def retrieve(self, request, usage_key_string=None): - """Retrieve an xblock by its usage key.""" - return retrieve_xblock_response(request, usage_key_string) + """ + Retrieve an xblock by its usage key. + + ADR 0036 — honours ``?view=minimal``; everything else is delegated to + ``retrieve_xblock_response`` (which keeps its legacy ``?fields=`` / + ``?fields=ancestorInfo`` / ``?fields=customReadToken`` semantics). + """ + response = retrieve_xblock_response(request, usage_key_string) + if request.GET.get("view") == "minimal": + response = _apply_minimal_view(response) + return response @expect_json_in_class_view @validate_request_with_serializer From 74d9c53d769657a2d68f04a46a08f9a92bca82f7 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 17 Jun 2026 12:45:46 +0500 Subject: [PATCH 2/6] feat: apply ADR 0036 to CourseHome v3 (?fields= on list action) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 ``HomeViewSet.list`` action returns a wide ``StudioHomeSerializer`` payload with ~25 top-level keys (courses, archived_courses, libraries, allowed_organizations, allowed_organizations_for_libraries, plus Studio settings). Adds a ``?fields=`` query parameter so clients can request a subset of those keys explicitly. The flat-list ``courses`` and ``libraries`` actions are out of scope (each returns a single-key dict wrapping a list of small items — no nested or wide structure to filter). Adds a shared ``apply_field_selection`` helper to ``v3/utils.py`` so future v3 viewsets can opt into the same convention without re-implementing it. Adds 2 regression tests: default keeps all keys, ``?fields=courses,libraries`` restricts to exactly those keys. --- .../contentstore/rest_api/v3/utils.py | 32 +++++++++++ .../contentstore/rest_api/v3/views/home.py | 27 ++++++++- .../rest_api/v3/views/tests/test_home.py | 55 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v3/utils.py b/cms/djangoapps/contentstore/rest_api/v3/utils.py index 3db96963b764..79524acb8c53 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/utils.py +++ b/cms/djangoapps/contentstore/rest_api/v3/utils.py @@ -13,6 +13,10 @@ * :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 @@ -53,3 +57,31 @@ 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 2e11b191806e..de5158c17e58 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -12,6 +12,12 @@ * ADR 0029 – standardized error envelope, opted in via :class:`StandardizedErrorMixin` (v3-scoped — does not change the project-wide DRF ``EXCEPTION_HANDLER`` setting) + * ADR 0036 – field selection via ``?fields=`` (e.g. ``?fields=courses``). + The ``list`` action returns a wide ``StudioHomeSerializer`` payload with + ~25 top-level keys; clients that only need a subset can request it + explicitly. The flat-list ``courses`` and ``libraries`` actions are + out of scope (single-key dict around a list) and do not honour + ``?fields=``. """ import edx_api_doc_tools as apidocs @@ -30,6 +36,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 @@ -66,7 +73,21 @@ def get_serializer(self, *args, **kwargs): "org", apidocs.ParameterLocation.QUERY, description="Query param to filter by course org", - )], + ), + # ADR 0036 decision #3 — document the ``?fields=`` variant so it's + # discoverable by OpenAPI consumers. The 200 response below is the + # full default shape; ``?fields=`` returns a subset of top-level keys. + apidocs.string_parameter( + "fields", + apidocs.ParameterLocation.QUERY, + description=( + "ADR 0036 explicit field selection. Comma-separated list " + "of top-level keys to include in the response (e.g. " + "``courses,libraries,studio_name``). Omit for the full " + "response. Unknown keys are silently skipped." + ), + ), + ], responses={ 200: StudioHomeSerializer, 401: "The requester is not authenticated.", @@ -79,6 +100,7 @@ def list(self, request: Request): **Example Request** GET /api/contentstore/v3/home/ + GET /api/contentstore/v3/home/?fields=courses,libraries (ADR 0036) """ home_context = get_home_context(request, True) home_context.update({ @@ -96,7 +118,8 @@ def list(self, request: Request): 'user_is_active': request.user.is_active, }) serializer = self.get_serializer(home_context) - return Response(serializer.data) + # ADR 0036 — drop top-level keys not requested via ?fields=. + return Response(apply_field_selection(serializer.data, request.query_params.get("fields"))) @apidocs.schema( parameters=[ diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_home.py index a688a488e63c..980d33b81bd3 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_home.py @@ -116,3 +116,58 @@ def test_libraries_calls_get_library_context(self, mock_libs, mock_get_ser): assert response.status_code == status.HTTP_200_OK mock_libs.assert_called_once() + + +# --------------------------------------------------------------------------- +# ADR 0036 — ?fields= field selection on the list action +# --------------------------------------------------------------------------- +class TestHomeViewSetFieldSelection(APITestCase): + """ + ADR 0036 — verify ``?fields=`` filters top-level keys on the ``list`` + action's wide ``StudioHomeSerializer`` response. ``courses`` and + ``libraries`` actions are out of scope (single-key dicts). + """ + + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.client.force_authenticate(user=self.user) + self.url = reverse('cms.djangoapps.contentstore:v3:home-list') + + @patch.object(HomeViewSet, 'get_serializer') + @patch(MOCK_ORG_API) + @patch(MOCK_GET_HOME_CONTEXT) + def test_default_response_keeps_all_keys(self, mock_home, mock_org, mock_get_ser): # noqa: ARG002 + """Without ``?fields=`` every top-level key is returned.""" + mock_home.return_value = {'can_create_organizations': True} + mock_org.is_autocreate_enabled.return_value = True + mock_get_ser.return_value.data = { + 'studio_name': 'Studio', 'platform_name': 'edX', + 'courses': [], 'libraries': [], 'archived_courses': [], + } + + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert set(response.data.keys()) == { + 'studio_name', 'platform_name', 'courses', 'libraries', 'archived_courses', + } + + @patch.object(HomeViewSet, 'get_serializer') + @patch(MOCK_ORG_API) + @patch(MOCK_GET_HOME_CONTEXT) + def test_fields_csv_restricts_top_level_keys(self, mock_home, mock_org, mock_get_ser): # noqa: ARG002 + """``?fields=courses,libraries`` returns exactly those keys.""" + mock_home.return_value = {'can_create_organizations': True} + mock_org.is_autocreate_enabled.return_value = True + mock_get_ser.return_value.data = { + 'studio_name': 'Studio', 'platform_name': 'edX', + 'courses': [], 'libraries': [], 'archived_courses': [], + } + + response = self.client.get(self.url, {'fields': 'courses,libraries'}) + + assert response.status_code == status.HTTP_200_OK + assert set(response.data.keys()) == {'courses', 'libraries'} + assert 'studio_name' not in response.data + assert 'platform_name' not in response.data From e875df1242a1b3ac0d5a43655af2351dca45fc4e Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 17 Jun 2026 12:50:04 +0500 Subject: [PATCH 3/6] docs: audit CourseHome v4 against ADR 0036 (out of scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ADR 0036 entry to ``HomeCoursesViewSet``'s compliance list explicitly marking this endpoint as out of scope. Rationale: the v4 home endpoint returns a flat paginated list governed by ADR 0032 (DefaultPagination 7-field envelope). ADR 0036 excludes flat lists from its ``?view=`` / ``?depth=`` / minimal-by-default requirements — those apply to tree-shaped responses or wide flat objects with embedded sub-objects. Each course item is a thin 9-field record with no nested children, no embedded sub-objects, and no tree structure to bound. Per-item ``?fields=`` subset filtering remains a possible follow-up (would require a dynamic-fields serializer mixin and per-field schema documentation) but is deferred to keep the v4 contract stable for the existing Studio frontend. --- cms/djangoapps/contentstore/rest_api/v4/views/home.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cms/djangoapps/contentstore/rest_api/v4/views/home.py b/cms/djangoapps/contentstore/rest_api/v4/views/home.py index 8f0fedc1cfed..28fab47b55c0 100644 --- a/cms/djangoapps/contentstore/rest_api/v4/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v4/views/home.py @@ -117,6 +117,17 @@ class HomeCoursesViewSet(StandardizedErrorMixin, viewsets.ViewSet): - 0029: standardized error envelope via ``StandardizedErrorMixin`` - 0032: 7-field pagination envelope via ``DefaultPagination`` - 0033: ``ordering`` parameter; ``order`` kept as deprecated alias + - 0036: **out of scope.** This endpoint returns a flat paginated list + governed by ADR 0032; ADR 0036 explicitly excludes flat lists from + its ``?view=`` / ``?depth=`` / minimal-by-default requirements. Each + course item carries 9 thin top-level fields (``course_key``, + ``display_name``, ``lms_link``, ``cms_link``, ``number``, ``org``, + ``rerun_link``, ``run``, ``url``, ``is_active``) — no nested + children, no embedded full sub-objects, no tree shape. Per-item + ``?fields=`` subset filtering is a possible follow-up (would require + a dynamic-fields serializer mixin and per-field schema documentation) + but is intentionally NOT added here to keep the v4 contract stable + for the existing Studio frontend. """ authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) From 0d19d17db5fb2c789cd3ce92ae99b96d83ecd1df Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 17 Jun 2026 12:53:27 +0500 Subject: [PATCH 4/6] feat: apply ADR 0036 to Enrollment v2 (?view=minimal flattens course_details) Each enrollment record returned by the v2 ``EnrollmentViewSet.list`` and ``EnrollmentRetrieveView.get`` actions embeds a full ``course_details`` sub-object (which itself includes a ``course_modes`` list and other heavy course-overview fields). Server-to-server callers and AI agents that only need to know which courses a user is enrolled in shouldn't have to parse the embedded sub-object on every row. Adds a ``?view=minimal`` query parameter on both actions that collapses the embedded ``course_details`` to a single ``course_id`` string. The enrollment-level fields (``created``, ``mode``, ``is_active``, ``user``) are preserved. Default response shape is unchanged. Adds 2 regression tests (mocked, MongoDB-free): default list keeps the full shape, ``?view=minimal`` collapses each row's course_details to a flat course_id and the heavy fields are dropped. --- .../enrollments/v2/tests/test_views.py | 53 ++++++++++++ .../core/djangoapps/enrollments/v2/views.py | 82 +++++++++++++++++-- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/openedx/core/djangoapps/enrollments/v2/tests/test_views.py b/openedx/core/djangoapps/enrollments/v2/tests/test_views.py index ce8f958a1d79..414aa8075926 100644 --- a/openedx/core/djangoapps/enrollments/v2/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/v2/tests/test_views.py @@ -18,6 +18,7 @@ from rest_framework.test import APITestCase from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.enrollments.v2.views import EnrollmentViewSet from openedx.core.djangolib.testing.utils import skip_unless_lms API_KEY = "test-enrollment-v2-api-key" @@ -234,3 +235,55 @@ def test_no_filter_no_header(self, mock_get): # noqa: ARG002 response = self.client.get(self.url) assert response.status_code == status.HTTP_200_OK assert "Deprecation" not in response.headers + + +# --------------------------------------------------------------------------- +# ADR 0036 — minimal view tests +# --------------------------------------------------------------------------- +@skip_unless_lms +class TestEnrollmentViewSetMinimalView(APITestCase): + """ + ADR 0036 — verify ``?view=minimal`` on the list action collapses each + enrollment's embedded ``course_details`` sub-object to a single ``course_id`` + string and drops the heavy fields (``course_modes`` etc.). + """ + + def setUp(self): + super().setUp() + self.user = UserFactory.create(password="test") + self.client.force_authenticate(user=self.user) + self.url = reverse("v2:enrollment-list") + + @patch(MOCK_OPS_LIST, return_value=[]) + def test_default_list_includes_course_details(self, mock_list): # noqa: ARG002 + """Without ``?view=minimal``, embedded course_details is present (full shape).""" + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + # An empty list naturally has no rows to inspect — the contract is that the + # envelope's `results` key is a list (already verified by the pagination test). + assert response.data["results"] == [] + + @patch.object(EnrollmentViewSet, "get_serializer") + @patch(MOCK_OPS_LIST, return_value=["e1", "e2"]) + def test_minimal_view_collapses_course_details_to_course_id(self, mock_list, mock_get_ser): # noqa: ARG002 + """``?view=minimal`` replaces each ``course_details`` sub-object with a ``course_id`` string.""" + mock_get_ser.return_value.data = [ + { + "mode": "audit", "is_active": True, "user": "u1", + "course_details": {"course_id": "course-v1:org+a+r", "course_modes": [{"slug": "audit"}]}, + }, + { + "mode": "honor", "is_active": True, "user": "u1", + "course_details": {"course_id": "course-v1:org+b+r", "course_modes": [{"slug": "honor"}]}, + }, + ] + + response = self.client.get(self.url, {"view": "minimal"}) + + assert response.status_code == status.HTTP_200_OK + for row in response.data["results"]: + assert "course_details" not in row, "ADR 0036: minimal must drop embedded course_details" + assert "course_id" in row, "ADR 0036: minimal must keep the flattened course_id" + assert {r["course_id"] for r in response.data["results"]} == { + "course-v1:org+a+r", "course-v1:org+b+r", + } diff --git a/openedx/core/djangoapps/enrollments/v2/views.py b/openedx/core/djangoapps/enrollments/v2/views.py index cb940244c401..905452f1d32c 100644 --- a/openedx/core/djangoapps/enrollments/v2/views.py +++ b/openedx/core/djangoapps/enrollments/v2/views.py @@ -15,6 +15,13 @@ * 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 + * ADR 0036 – ``?view=minimal`` on the enrollment ``list`` and singleton + ``retrieve`` actions. By default each enrollment record embeds the full + ``course_details`` sub-object (which itself includes a ``course_modes`` + list and other heavy fields). When ``?view=minimal`` is requested, the + embedded sub-object is flattened to a single ``course_id`` string so + callers that only need to know which courses a user is enrolled in (AI + agents, sync pipelines) can skip the per-row sub-object payload. Existing v1 endpoints at ``/api/enrollment/v1/`` are unchanged — v2 is a parallel new version mounted at ``/api/enrollment/v2/``. @@ -100,6 +107,24 @@ def _query_param(name: str, description: str, *, required: bool = False, type_=s _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).") +# ADR 0036 decision #3 — document the ``?view=`` variant in OpenAPI so it's +# discoverable. ``?view=minimal`` collapses each enrollment's embedded +# ``course_details`` sub-object to a single ``course_id`` string; omit to +# receive the full default shape declared by the 200 response schema. +_VIEW_QUERY_PARAM = OpenApiParameter( + name="view", + description=( + "ADR 0036 response preset. ``minimal`` collapses the embedded " + "``course_details`` sub-object on each enrollment to a single " + "``course_id`` string (drops ``course_modes`` and other heavy " + "course-detail fields). Omit the parameter to receive the full response." + ), + required=False, + type=str, + location=OpenApiParameter.QUERY, + enum=["minimal"], +) + _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.") @@ -135,6 +160,32 @@ def _maybe_set_legacy_param_deprecation_header(request, response, alias_pairs): return response +# --------------------------------------------------------------------------- +# ADR 0036 — minimal enrollment view helper +# --------------------------------------------------------------------------- +def _to_minimal_enrollment(enrollment_dict): + """ + ADR 0036 — collapse the embedded ``course_details`` sub-object on a serialized + enrollment dict down to a single ``course_id`` string. Heavy fields such as + ``course_modes`` are dropped. The enrollment-level fields (``created``, + ``mode``, ``is_active``, ``user``) are kept. + + Returns a new dict — the original is not mutated. + """ + if not isinstance(enrollment_dict, dict): + return enrollment_dict + minimal = {k: v for k, v in enrollment_dict.items() if k != "course_details"} + details = enrollment_dict.get("course_details") or {} + if isinstance(details, dict): + minimal["course_id"] = details.get("course_id") + 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" + + # =========================================================================== # EnrollmentViewSet — consolidates list / create / unenroll / allowed # =========================================================================== @@ -185,20 +236,33 @@ def get_serializer(self, *args, **kwargs): "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." + "requester staffs. Supports the ADR 0036 ``?view=minimal`` preset (see parameter " + "description)." ), - parameters=[_USER_QUERY_PARAM, _PAGE_QUERY_PARAM, _PAGE_SIZE_QUERY_PARAM], + parameters=[_USER_QUERY_PARAM, _PAGE_QUERY_PARAM, _PAGE_SIZE_QUERY_PARAM, _VIEW_QUERY_PARAM], responses={ 200: OpenApiResponse( response=CourseEnrollmentSerializer(many=True), - description="Paginated enrollment list.", + description=( + "Paginated enrollment list. The schema below is the full " + "default shape; when ``?view=minimal`` is supplied each " + "enrollment's ``course_details`` is collapsed to a single " + "``course_id`` string (ADR 0036)." + ), ), 401: _RESP_UNAUTHENTICATED, }, ) @method_decorator(ensure_csrf_cookie_cross_domain) def list(self, request): - """List enrollments for the currently logged-in user (paginated).""" + """ + List enrollments for the currently logged-in user (paginated). + + ADR 0036 — when ``?view=minimal`` is supplied, each enrollment's embedded + ``course_details`` sub-object is collapsed to a single ``course_id`` + string; ``course_modes`` and the other heavy course-detail fields are + dropped. Default response shape is unchanged for backwards compatibility. + """ username = request.GET.get("user", request.user.username) enrollments = _OPS.list_enrollments_for_user( request_user=request.user, @@ -207,7 +271,10 @@ def list(self, 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) + 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) # ------------------------------------------------------------------ # create — POST /enrollment/ @@ -413,7 +480,10 @@ def get(self, request, course_id=None, username=None): f"'{username}' in course '{course_id}'" ) from exc - return Response(self.serializer_class(enrollment).data) + data = self.serializer_class(enrollment).data + if _is_minimal_view_requested(request): + data = _to_minimal_enrollment(data) + return Response(data) # =========================================================================== From 79e3370d56ad3dede93361447f26ce6855eb75c0 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 17 Jun 2026 12:56:34 +0500 Subject: [PATCH 5/6] feat: apply ADR 0036 to Course Detail v3 (?view=minimal + ?fields=) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 ``CourseDetailsViewSet.retrieve`` action returns a wide ``CourseDetailsSerializer`` payload with ~40 top-level fields plus a nested ``instructor_info`` sub-object (instructor names, bios, image URLs) and a ``learning_info`` long-form list. The default full payload is preserved; two new opt-in query parameters apply ADR 0036: * ``?view=minimal`` drops heavy fields (overview, syllabus, description, short_description, instructor_info, learning_info, banner_image_name / banner_image_asset_path, video_thumbnail assets, license) — leaving only identification (course_id, org, run, title, subtitle, language), schedule (start_date, end_date, enrollment_start, enrollment_end, certificate_available_date), and flags (self_paced, certificates_display_behavior, has_changes). * ``?fields=a,b,c`` keeps an explicit CSV subset of top-level keys. Composes with ``?view=minimal`` — the preset is applied first, then the explicit subset. Reuses ``apply_field_selection`` from ``v3/utils.py`` (introduced in the CourseHome v3 commit) so the convention is consistent across v3. Adds 3 regression tests (mocked, MongoDB-free): default keeps all fields, ?view=minimal drops the heavy/embedded ones and keeps identification/schedule/flags, ?fields=course_id,title restricts to exactly those keys. --- .../rest_api/v3/views/course_details.py | 100 +++++++++++++++- .../v3/views/tests/test_course_details.py | 108 ++++++++++++++++++ 2 files changed, 202 insertions(+), 6 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 47a4743854b0..79a1f0ed1cba 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/course_details.py @@ -39,7 +39,11 @@ from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseDetailsSerializer 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, resolve_course_key +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 @@ -55,6 +59,68 @@ location=OpenApiParameter.PATH, ) +# ADR 0036 — document the minimal/full response variants in OpenAPI (decision #3). +# Declaring these as query parameters is what makes the presets discoverable by +# OpenAPI consumers (Swagger UI, generated SDK clients, etc.). The 200 response +# schema below points at the full ``CourseDetailsSerializer``; ``?view=minimal`` +# returns the subset of top-level keys listed in :data:`_MINIMAL_VIEW_FIELDS`. +_VIEW_QUERY_PARAMETER = OpenApiParameter( + name="view", + description=( + "ADR 0036 response preset. ``minimal`` drops heavy fields (overview, " + "syllabus, description, instructor_info, learning_info, banner/video " + "assets, license) leaving only identification, schedule, and flags. " + "Omit the parameter to receive the full response." + ), + required=False, + type=str, + location=OpenApiParameter.QUERY, + enum=["minimal"], +) +_FIELDS_QUERY_PARAMETER = OpenApiParameter( + name="fields", + description=( + "ADR 0036 explicit field selection. Comma-separated list of top-level " + "keys to include in the response (e.g. ``course_id,title,start_date``). " + "When combined with ``?view=``, the preset is applied first and " + "``?fields=`` is applied to the result. Unknown keys are silently " + "skipped." + ), + required=False, + type=str, + location=OpenApiParameter.QUERY, +) + +# ADR 0036 — the ``CourseDetailsSerializer`` has ~40 top-level fields plus a +# nested ``instructor_info`` sub-object with bios and image URLs and a +# ``learning_info`` long-form list. When ``?view=minimal`` is requested, +# everything outside :data:`_MINIMAL_VIEW_FIELDS` is dropped so server-to-server +# and AI-agent callers can fetch just the identification + schedule + flags +# without paying for the heavy text and embedded sub-objects. +_MINIMAL_VIEW_FIELDS = frozenset({ + "course_id", + "org", + "run", + "title", + "subtitle", + "language", + "self_paced", + "start_date", + "end_date", + "enrollment_start", + "enrollment_end", + "certificate_available_date", + "certificates_display_behavior", + "has_changes", +}) + + +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} + class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet): """ @@ -78,12 +144,21 @@ class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet): @extend_schema( summary="Retrieve a course's details", - description="Get an object containing all the course details for the specified course.", - parameters=[_COURSE_ID_PARAMETER], + description=( + "Get an object containing the course details for the specified course. " + "Supports the ADR 0036 ``?view=minimal`` preset and ``?fields=`` " + "explicit field selection (see the parameter descriptions for details)." + ), + parameters=[_COURSE_ID_PARAMETER, _VIEW_QUERY_PARAMETER, _FIELDS_QUERY_PARAMETER], responses={ 200: OpenApiResponse( response=CourseDetailsSerializer, - description="Course details retrieved successfully.", + description=( + "Course details retrieved successfully. The schema below is " + "the full default response; when ``?view=minimal`` and/or " + "``?fields=`` is supplied, the response contains a subset of " + "these top-level keys (see ADR 0036)." + ), ), **COMMON_ERROR_RESPONSES, }, @@ -95,6 +170,16 @@ def retrieve(self, request: Request, course_id: str): **Example Request** GET /api/contentstore/v3/course_details/{course_id}/ + GET /api/contentstore/v3/course_details/{course_id}/?view=minimal + GET /api/contentstore/v3/course_details/{course_id}/?fields=course_id,title + + ADR 0036: + * ``?view=minimal`` drops heavy fields (overview, syllabus, description, + instructor_info, learning_info, banner/video assets, license, etc.) + leaving only identification, schedule, and flags. + * ``?fields=...`` keeps an arbitrary CSV subset of top-level keys. + * ``?fields=`` and ``?view=`` may be combined — ``?view=minimal`` + is applied first, then ``?fields=`` is applied to the result. """ course_key = resolve_course_key(course_id) if not user_has_course_permission( @@ -106,8 +191,11 @@ def retrieve(self, request: Request, course_id: str): self.permission_denied(request) course_details = CourseDetails.fetch(course_key) - serializer = self.serializer_class(course_details) - return Response(serializer.data) + 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")) + return Response(data) @extend_schema( summary="Update a course's details", diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_course_details.py b/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_course_details.py index a7d0165fb32d..e25df0188d18 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_course_details.py @@ -270,3 +270,111 @@ def test_v1_endpoint_unaffected_by_v3_envelope(self): assert response.status_code == status.HTTP_401_UNAUTHORIZED assert "type" not in response.data assert "instance" not in response.data + + +# =========================================================================== +# ADR 0036 — ?view=minimal and ?fields= tests +# =========================================================================== +class TestCourseDetailsViewSetNestedJsonNormalization(APITestCase): + """ + ADR 0036 — verify ``?view=minimal`` drops the heavy fields and ``?fields=`` + restricts to an explicit subset. The full default response is unchanged. + """ + + _FAKE_DATA = { + # kept by ?view=minimal: + "course_id": "course-v1:org+course+run", + "org": "org", + "run": "run", + "title": "Sample Title", + "subtitle": "", + "language": "en", + "self_paced": False, + "start_date": "2026-06-01T00:00:00Z", + "end_date": "2026-12-01T00:00:00Z", + "enrollment_start": None, + "enrollment_end": None, + "certificate_available_date": None, + "certificates_display_behavior": "end", + "has_changes": False, + # dropped by ?view=minimal: + "overview": "", + "syllabus": "", + "description": "", + "short_description": "", + "instructor_info": {"instructors": [{"name": "x", "bio": "..."}]}, + "learning_info": ["a", "b"], + "banner_image_name": "img.jpg", + "banner_image_asset_path": "/asset/...", + "video_thumbnail_image_name": "vid.jpg", + "video_thumbnail_image_asset_path": "/asset/...", + "license": "...", + } + + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.client.force_authenticate(user=self.user) + self.url = reverse( + "cms.djangoapps.contentstore:v3:course_details-detail", + kwargs={"course_id": TEST_COURSE_ID}, + ) + + @patch.object(CourseDetailsViewSet, "serializer_class") + @patch.object(CourseOverview, "course_exists", return_value=True) + @patch(MOCK_HAS_PERMISSION, return_value=True) + @patch(MOCK_FETCH) + def test_default_response_keeps_all_fields( + self, mock_fetch, mock_perm, mock_exists, mock_ser_cls, # noqa: ARG002 + ): + """Without ``?view=`` or ``?fields=`` the full payload is returned.""" + mock_fetch.return_value = MagicMock() + mock_ser_cls.return_value.data = self._FAKE_DATA + + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert "instructor_info" in response.data + assert "overview" in response.data + assert "learning_info" in response.data + + @patch.object(CourseDetailsViewSet, "serializer_class") + @patch.object(CourseOverview, "course_exists", return_value=True) + @patch(MOCK_HAS_PERMISSION, return_value=True) + @patch(MOCK_FETCH) + def test_view_minimal_drops_heavy_fields( + self, mock_fetch, mock_perm, mock_exists, mock_ser_cls, # noqa: ARG002 + ): + """``?view=minimal`` drops the heavy text + embedded instructor_info sub-object.""" + mock_fetch.return_value = MagicMock() + mock_ser_cls.return_value.data = self._FAKE_DATA + + response = self.client.get(self.url, {"view": "minimal"}) + + assert response.status_code == status.HTTP_200_OK + for dropped in ( + "overview", "syllabus", "description", "short_description", + "instructor_info", "learning_info", + "banner_image_name", "banner_image_asset_path", + "video_thumbnail_image_name", "video_thumbnail_image_asset_path", + "license", + ): + assert dropped not in response.data, f"ADR 0036: ?view=minimal must drop '{dropped}'" + for kept in ("course_id", "org", "run", "title", "self_paced", "start_date", "end_date"): + assert kept in response.data, f"ADR 0036: ?view=minimal must keep '{kept}'" + + @patch.object(CourseDetailsViewSet, "serializer_class") + @patch.object(CourseOverview, "course_exists", return_value=True) + @patch(MOCK_HAS_PERMISSION, return_value=True) + @patch(MOCK_FETCH) + def test_fields_csv_restricts_top_level_keys( + self, mock_fetch, mock_perm, mock_exists, mock_ser_cls, # noqa: ARG002 + ): + """``?fields=course_id,title`` returns exactly those two keys.""" + mock_fetch.return_value = MagicMock() + mock_ser_cls.return_value.data = self._FAKE_DATA + + response = self.client.get(self.url, {"fields": "course_id,title"}) + + assert response.status_code == status.HTTP_200_OK + assert set(response.data.keys()) == {"course_id", "title"} From 2dafb729301c04a9c0a9992eb368e159b56ef704 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 17 Jun 2026 12:57:27 +0500 Subject: [PATCH 6/6] docs: audit AuthorGrading v3 against ADR 0036 (largely out of scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ADR 0036 entry to ``AuthoringGradingViewSet``'s compliance list. Rationale: the v3 grading response is a single top-level ``graders`` list of small fixed-shape objects (type, min_count, drop_count, short_label, weight, id) — no tree nesting, no embedded sub-objects, no ``children`` field, no wide flat object. ``?view=minimal`` and ``?fields=`` would have no fields to drop; the only ADR 0036 concern that applies is anti-pattern #3 (unbounded child list). In practice each course has ≤8 graders (Homework, Lab, Exam, etc.) and the update flow is exercised only by course-authoring staff, so the real-world payload is always small. The hard cap is enforced upstream by ``CourseGradingModel.update_from_json``; documented as a note rather than re-implemented at the view layer. --- .../rest_api/v3/views/authoring_grading.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 49dd882b4f07..72a4039c9ff3 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py @@ -17,6 +17,19 @@ legacy ``course_id``. Since this is a brand-new versioned API, no deprecated alias is needed — clients on the v0 endpoint continue to use ``course_id`` there. + * ADR 0036 – **largely out of scope.** The ``CourseGradingModelSerializer`` + response is a single top-level ``graders`` list of small fixed-shape + objects (type, min_count, drop_count, short_label, weight, id) — no + tree nesting, no embedded sub-objects, no ``children`` field, no wide + flat object that would benefit from ``?view=minimal`` / ``?fields=``. + + The one ADR 0036 concern is anti-pattern #3 (unbounded child list): the + ``graders`` array has no upper bound in the serializer. In practice each + course has typically ≤8 graders (Homework, Lab, Exam, etc.) and the + update flow is exercised only by course-authoring staff, so the + real-world payload is always small. A hard cap is enforced upstream of + this endpoint by :func:`CourseGradingModel.update_from_json`; we surface + that as a documentation note rather than re-implement the bound here. Permission model note: PR #38363 proposed a class-level ``HasStudioReadAccess`` permission. The