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
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/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
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/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_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"}
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
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)
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)
# ===========================================================================