From deb0a43b9514b2bd6e1723ba384f652ef5f2eed9 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 3 Jun 2026 11:15:50 +0500 Subject: [PATCH 1/3] feat: apply ADRs to home v1 apis --- cms/djangoapps/contentstore/rest_api/urls.py | 2 + .../contentstore/rest_api/v3/__init__.py | 0 .../rest_api/v3/tests/__init__.py | 0 .../rest_api/v3/tests/test_home.py | 92 ++++++++++ .../contentstore/rest_api/v3/urls.py | 12 ++ .../rest_api/v3/views/__init__.py | 3 + .../contentstore/rest_api/v3/views/home.py | 160 ++++++++++++++++++ .../rest_api/v3/views/tests/__init__.py | 0 .../rest_api/v3/views/tests/test_home.py | 118 +++++++++++++ 9 files changed, 387 insertions(+) create mode 100644 cms/djangoapps/contentstore/rest_api/v3/__init__.py create mode 100644 cms/djangoapps/contentstore/rest_api/v3/tests/__init__.py create mode 100644 cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py create mode 100644 cms/djangoapps/contentstore/rest_api/v3/urls.py create mode 100644 cms/djangoapps/contentstore/rest_api/v3/views/__init__.py create mode 100644 cms/djangoapps/contentstore/rest_api/v3/views/home.py create mode 100644 cms/djangoapps/contentstore/rest_api/v3/views/tests/__init__.py create mode 100644 cms/djangoapps/contentstore/rest_api/v3/views/tests/test_home.py diff --git a/cms/djangoapps/contentstore/rest_api/urls.py b/cms/djangoapps/contentstore/rest_api/urls.py index e6337cb11b7c..9696cdf9b73c 100644 --- a/cms/djangoapps/contentstore/rest_api/urls.py +++ b/cms/djangoapps/contentstore/rest_api/urls.py @@ -7,6 +7,7 @@ from .v0 import urls as v0_urls from .v1 import urls as v1_urls from .v2 import urls as v2_urls +from .v3 import urls as v3_urls from .v4 import urls as v4_urls app_name = 'cms.djangoapps.contentstore' @@ -15,5 +16,6 @@ path('v0/', include(v0_urls)), path('v1/', include(v1_urls)), path('v2/', include(v2_urls)), + path('v3/', include(v3_urls)), path('v4/', include(v4_urls)), ] diff --git a/cms/djangoapps/contentstore/rest_api/v3/__init__.py b/cms/djangoapps/contentstore/rest_api/v3/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/contentstore/rest_api/v3/tests/__init__.py b/cms/djangoapps/contentstore/rest_api/v3/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py new file mode 100644 index 000000000000..48ea5616f9a4 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py @@ -0,0 +1,92 @@ +""" +ADR 0029 – Standardized error-response regression tests for HomeViewSet (v3). + +The ADR 0029 envelope requires the global DRF ``EXCEPTION_HANDLER`` to be +``openedx.core.lib.api.exceptions.standardized_error_exception_handler``. +That global swap is *not* part of this v3 port (see PR scoping decision), +so these tests are skipped at module load time when the handler is not +wired up. Once the platform-wide handler lands, removing the ``skipUnless`` +guard will activate the assertions automatically. +""" +import unittest + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.settings import api_settings +from rest_framework.test import APIClient, APITestCase + +_REQUIRED_ERROR_FIELDS = ("type", "title", "status", "detail", "instance") +_EXPECTED_HANDLER = "openedx.core.lib.api.exceptions.standardized_error_exception_handler" + + +def _envelope_handler_active() -> bool: + """Return True iff the project-wide DRF EXCEPTION_HANDLER is the ADR 0029 one.""" + handler = api_settings.EXCEPTION_HANDLER + return getattr(handler, "__module__", "") + "." + getattr(handler, "__name__", "") == _EXPECTED_HANDLER + + +pytestmark = pytest.mark.skipif( + not _envelope_handler_active(), + reason=( + "ADR 0029 standardized exception handler not wired into DRF settings. " + f"Expected EXCEPTION_HANDLER = '{_EXPECTED_HANDLER}'." + ), +) + + +@unittest.skipUnless(_envelope_handler_active(), "ADR 0029 handler not installed") +class TestHomeViewSetErrorShape(APITestCase): + """ + ADR 0029 – error response shape regression tests for HomeViewSet (v3). + + Verifies that 401 responses on all three actions conform to the + standardized JSON envelope. + """ + + def setUp(self): + super().setUp() + self.client = APIClient() + self.list_url = reverse("cms.djangoapps.contentstore:v3:home-list") + self.courses_url = reverse("cms.djangoapps.contentstore:v3:home-courses") + self.libraries_url = reverse("cms.djangoapps.contentstore:v3:home-libraries") + + def test_unauthenticated_list_returns_standardized_401(self): + """Unauthenticated GET /home/ must return 401 with the ADR 0029 envelope.""" + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + + def test_unauthenticated_list_401_type_uri(self): + """The ``type`` field for 401 must be the ADR 0029 authn URI.""" + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data.get("type") == "https://docs.openedx.org/errors/authn" + + def test_unauthenticated_courses_returns_standardized_401(self): + """Unauthenticated GET /home/courses/ must return 401 with the ADR 0029 envelope.""" + response = self.client.get(self.courses_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + + def test_unauthenticated_libraries_returns_standardized_401(self): + """Unauthenticated GET /home/libraries/ must return 401 with the ADR 0029 envelope.""" + response = self.client.get(self.libraries_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + for field in _REQUIRED_ERROR_FIELDS: + assert field in response.data, f"ADR 0029: missing field '{field}'" + + def test_error_body_has_no_developer_message(self): + """Error responses must NOT contain old DeveloperErrorViewMixin fields.""" + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert "developer_message" not in response.data + assert "error_code" not in response.data + + def test_instance_field_is_request_path(self): + """The ``instance`` field must equal the request path.""" + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data.get("instance") == self.list_url diff --git a/cms/djangoapps/contentstore/rest_api/v3/urls.py b/cms/djangoapps/contentstore/rest_api/v3/urls.py new file mode 100644 index 000000000000..9d8f93a7ee7c --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/urls.py @@ -0,0 +1,12 @@ +"""Contentstore API v3 URLs.""" + +from rest_framework.routers import DefaultRouter + +from cms.djangoapps.contentstore.rest_api.v3.views import HomeViewSet + +app_name = "v3" + +router = DefaultRouter() +router.register(r'home', HomeViewSet, basename='home') + +urlpatterns = router.urls diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v3/views/__init__.py new file mode 100644 index 000000000000..573556d80b39 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/views/__init__.py @@ -0,0 +1,3 @@ +"""Views for v3 contentstore API.""" + +from .home import HomeViewSet # noqa: F401 diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/home.py b/cms/djangoapps/contentstore/rest_api/v3/views/home.py new file mode 100644 index 000000000000..d2fd6b914b32 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -0,0 +1,160 @@ +""" +API Views for Studio course home — v3. + +This module is the v3 incarnation of the v1 ``home`` endpoints, restructured +to apply the FC-0118 ADRs: + + * ADR 0025 – ``serializer_class`` (with per-action ``get_serializer_class``) + * ADR 0026 – explicit ``authentication_classes`` + ``permission_classes`` + * ADR 0028 – consolidated into a single DRF ``ViewSet`` registered via + ``DefaultRouter`` (replaces the three legacy ``APIView`` classes + ``HomePageView`` / ``HomePageCoursesView`` / ``HomePageLibrariesView``) +""" + +import edx_api_doc_tools as apidocs +from django.conf import settings +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from organizations import api as org_api +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response + +from cms.djangoapps.contentstore.rest_api.v1.serializers import ( + CourseHomeTabSerializer, + LibraryTabSerializer, + StudioHomeSerializer, +) +from cms.djangoapps.contentstore.utils import get_course_context, get_home_context, get_library_context + + +class HomeViewSet(viewsets.ViewSet): + """ + ViewSet for the Studio home page. Registered via DefaultRouter (basename ``home``). + + Router-generated URLs: + GET /api/contentstore/v3/home/ → list (aggregated home context) + GET /api/contentstore/v3/home/courses/ → courses (course list only) + GET /api/contentstore/v3/home/libraries/ → libraries (library list only) + """ + + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (IsAuthenticated,) + serializer_class = StudioHomeSerializer + + def get_serializer_class(self): + """Return the appropriate serializer class for the current action.""" + if self.action == 'courses': + return CourseHomeTabSerializer + if self.action == 'libraries': + return LibraryTabSerializer + return StudioHomeSerializer + + def get_serializer(self, *args, **kwargs): + """Return a serializer instance using the action-appropriate class.""" + return self.get_serializer_class()(*args, **kwargs) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "org", + apidocs.ParameterLocation.QUERY, + description="Query param to filter by course org", + )], + responses={ + 200: StudioHomeSerializer, + 401: "The requester is not authenticated.", + }, + ) + def list(self, request: Request): + """ + Get an object containing all courses and libraries on home page. + + **Example Request** + + GET /api/contentstore/v3/home/ + """ + home_context = get_home_context(request, True) + home_context.update({ + # 'allow_to_create_new_org' is actually about auto-creating organizations + # (e.g. when creating a course or library), so we add an additional test. + 'allow_to_create_new_org': ( + home_context['can_create_organizations'] and + org_api.is_autocreate_enabled() + ), + 'studio_name': settings.STUDIO_NAME, + 'studio_short_name': settings.STUDIO_SHORT_NAME, + 'studio_request_email': settings.FEATURES.get('STUDIO_REQUEST_EMAIL', ''), + 'tech_support_email': settings.TECH_SUPPORT_EMAIL, + 'platform_name': settings.PLATFORM_NAME, + 'user_is_active': request.user.is_active, + }) + serializer = self.get_serializer(home_context) + return Response(serializer.data) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "org", + apidocs.ParameterLocation.QUERY, + description="Query param to filter by course org", + )], + responses={ + 200: CourseHomeTabSerializer, + 401: "The requester is not authenticated.", + }, + ) + @action(detail=False, methods=['get'], url_path='courses', url_name='courses') + def courses(self, request: Request): + """ + Get an object containing all courses. + + **Example Request** + + GET /api/contentstore/v3/home/courses/ + """ + active_courses, archived_courses, in_process_course_actions = get_course_context(request) + courses_context = { + "courses": active_courses, + "archived_courses": archived_courses, + "in_process_course_actions": in_process_course_actions, + } + serializer = self.get_serializer(courses_context) + return Response(serializer.data) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "org", + apidocs.ParameterLocation.QUERY, + description="Query param to filter by course org", + ), + apidocs.query_parameter( + "is_migrated", + bool, + description=( + "Query param to filter by migrated status of library." + " If present (true or false), it will filter by migration status" + " else it will return all legacy libraries." + ), + ) + ], + responses={ + 200: LibraryTabSerializer, + 401: "The requester is not authenticated.", + }, + ) + @action(detail=False, methods=['get'], url_path='libraries', url_name='libraries') + def libraries(self, request: Request): + """ + Get an object containing all libraries on home page. + + **Example Request** + + GET /api/contentstore/v3/home/libraries/ + """ + library_context = get_library_context(request) + serializer = self.get_serializer(library_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/tests/__init__.py b/cms/djangoapps/contentstore/rest_api/v3/views/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 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 new file mode 100644 index 000000000000..a688a488e63c --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/views/tests/test_home.py @@ -0,0 +1,118 @@ +""" +Unit tests for HomeViewSet — v3 (ADR 0025 / 0026 / 0028). + +MongoDB-free: all service-layer calls are mocked. + +patch.object is used for the ViewSet's get_serializer() method because: + - get_serializer_class() returns a *different* serializer per action. + - Each serializer (StudioHomeSerializer, CourseHomeTabSerializer, + LibraryTabSerializer) has many required fields that would be painful to + satisfy with synthetic data. + - Patching get_serializer() lets us focus on routing + service-call + assertions without re-testing serializer logic (covered in serializer + unit tests). +""" +from unittest.mock import patch + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from cms.djangoapps.contentstore.rest_api.v3.views.home import HomeViewSet +from common.djangoapps.student.tests.factories import UserFactory + +MOCK_GET_HOME_CONTEXT = ( + 'cms.djangoapps.contentstore.rest_api.v3.views.home.get_home_context' +) +MOCK_GET_COURSE_CONTEXT = ( + 'cms.djangoapps.contentstore.rest_api.v3.views.home.get_course_context' +) +MOCK_GET_LIBRARY_CONTEXT = ( + 'cms.djangoapps.contentstore.rest_api.v3.views.home.get_library_context' +) +MOCK_ORG_API = ( + 'cms.djangoapps.contentstore.rest_api.v3.views.home.org_api' +) + + +class TestHomeViewSetPermissions(APITestCase): + """ + ADR 0026 – permission regression tests for HomeViewSet (v3). + + Verifies that ``permission_classes = (IsAuthenticated,)`` enforces the + access rules expected of the consolidated viewset. + """ + + def test_unauthenticated_list_returns_401(self): + """Unauthenticated GET /home/ must return 401.""" + url = reverse('cms.djangoapps.contentstore:v3:home-list') + response = self.client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_unauthenticated_courses_returns_401(self): + """Unauthenticated GET /home/courses/ must return 401.""" + url = reverse('cms.djangoapps.contentstore:v3:home-courses') + response = self.client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_unauthenticated_libraries_returns_401(self): + """Unauthenticated GET /home/libraries/ must return 401.""" + url = reverse('cms.djangoapps.contentstore:v3:home-libraries') + response = self.client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestHomeViewSetActions(APITestCase): + """ + Action tests for HomeViewSet (list, courses, libraries). + + Any authenticated user can access these endpoints — no course-staff role + is required — so a plain (non-staff) factory user is sufficient. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.client.force_authenticate(user=self.user) + + @patch.object(HomeViewSet, 'get_serializer') + @patch(MOCK_ORG_API) + @patch(MOCK_GET_HOME_CONTEXT) + def test_list_calls_get_home_context(self, mock_home, mock_org, mock_get_ser): + """GET /home/ calls get_home_context() and returns 200.""" + 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'} + + response = self.client.get(reverse('cms.djangoapps.contentstore:v3:home-list')) + + assert response.status_code == status.HTTP_200_OK + mock_home.assert_called_once() + + @patch.object(HomeViewSet, 'get_serializer') + @patch(MOCK_GET_COURSE_CONTEXT) + def test_courses_calls_get_course_context(self, mock_courses, mock_get_ser): + """GET /home/courses/ calls get_course_context() and returns 200.""" + mock_courses.return_value = ([], [], []) + mock_get_ser.return_value.data = { + 'courses': [], + 'archived_courses': [], + 'in_process_course_actions': [], + } + + response = self.client.get(reverse('cms.djangoapps.contentstore:v3:home-courses')) + + assert response.status_code == status.HTTP_200_OK + mock_courses.assert_called_once() + + @patch.object(HomeViewSet, 'get_serializer') + @patch(MOCK_GET_LIBRARY_CONTEXT) + def test_libraries_calls_get_library_context(self, mock_libs, mock_get_ser): + """GET /home/libraries/ calls get_library_context() and returns 200.""" + mock_libs.return_value = {'libraries': []} + mock_get_ser.return_value.data = {'libraries': []} + + response = self.client.get(reverse('cms.djangoapps.contentstore:v3:home-libraries')) + + assert response.status_code == status.HTTP_200_OK + mock_libs.assert_called_once() From 9f922f7081b779a9eaf5b389ecb389785f281e88 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 3 Jun 2026 13:24:38 +0500 Subject: [PATCH 2/3] feat: apply ADR 0029 error envelope to home v3 viewset Wires HomeViewSet (v3) into openedx/core/lib/api/exceptions's standardized_error_exception_handler via a v3-local StandardizedErrorMixin that overrides DRF's per-view get_exception_handler. Project-wide EXCEPTION_HANDLER setting is intentionally left unchanged so v0/v1/v2 endpoints are unaffected. --- .../contentstore/rest_api/v3/mixins.py | 29 ++++++++++++ .../rest_api/v3/tests/test_home.py | 46 ++++++++----------- .../contentstore/rest_api/v3/views/home.py | 6 ++- 3 files changed, 54 insertions(+), 27 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v3/mixins.py diff --git a/cms/djangoapps/contentstore/rest_api/v3/mixins.py b/cms/djangoapps/contentstore/rest_api/v3/mixins.py new file mode 100644 index 000000000000..4ff1e687fc4e --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/mixins.py @@ -0,0 +1,29 @@ +""" +v3-scoped mixins for the contentstore REST API. + +Currently provides :class:`StandardizedErrorMixin`, which opts a single +view/viewset into the ADR 0029 error envelope without changing the +project-wide DRF ``EXCEPTION_HANDLER`` setting. +""" +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 v3 endpoints + return the standardized envelope while v0/v1/v2 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 diff --git a/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py index 48ea5616f9a4..e8f79069106f 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py @@ -1,41 +1,22 @@ """ ADR 0029 – Standardized error-response regression tests for HomeViewSet (v3). -The ADR 0029 envelope requires the global DRF ``EXCEPTION_HANDLER`` to be +The ADR 0029 envelope is wired into the v3 viewset via +:class:`cms.djangoapps.contentstore.rest_api.v3.mixins.StandardizedErrorMixin`, +which overrides DRF's per-view ``get_exception_handler`` to point at ``openedx.core.lib.api.exceptions.standardized_error_exception_handler``. -That global swap is *not* part of this v3 port (see PR scoping decision), -so these tests are skipped at module load time when the handler is not -wired up. Once the platform-wide handler lands, removing the ``skipUnless`` -guard will activate the assertions automatically. -""" -import unittest -import pytest +This is intentionally *scoped to v3* — the project-wide DRF +``EXCEPTION_HANDLER`` setting is unchanged, so v0/v1/v2 endpoints continue +to return the legacy error shape. +""" from django.urls import reverse from rest_framework import status -from rest_framework.settings import api_settings from rest_framework.test import APIClient, APITestCase _REQUIRED_ERROR_FIELDS = ("type", "title", "status", "detail", "instance") -_EXPECTED_HANDLER = "openedx.core.lib.api.exceptions.standardized_error_exception_handler" - - -def _envelope_handler_active() -> bool: - """Return True iff the project-wide DRF EXCEPTION_HANDLER is the ADR 0029 one.""" - handler = api_settings.EXCEPTION_HANDLER - return getattr(handler, "__module__", "") + "." + getattr(handler, "__name__", "") == _EXPECTED_HANDLER -pytestmark = pytest.mark.skipif( - not _envelope_handler_active(), - reason=( - "ADR 0029 standardized exception handler not wired into DRF settings. " - f"Expected EXCEPTION_HANDLER = '{_EXPECTED_HANDLER}'." - ), -) - - -@unittest.skipUnless(_envelope_handler_active(), "ADR 0029 handler not installed") class TestHomeViewSetErrorShape(APITestCase): """ ADR 0029 – error response shape regression tests for HomeViewSet (v3). @@ -90,3 +71,16 @@ def test_instance_field_is_request_path(self): response = self.client.get(self.list_url) assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.data.get("instance") == self.list_url + + def test_v1_endpoint_unaffected_by_v3_envelope(self): + """ + The ADR 0029 envelope must be scoped to v3 — hitting the legacy v1 + ``home/courses`` endpoint unauthenticated must NOT return the v3 envelope + (it has no ``type`` / ``instance`` keys). + """ + v1_url = reverse("cms.djangoapps.contentstore:v1:courses") + response = self.client.get(v1_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + # v1 still uses the project-default handler → ADR 0029 fields absent. + assert "type" not in response.data + assert "instance" not in response.data diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/home.py b/cms/djangoapps/contentstore/rest_api/v3/views/home.py index d2fd6b914b32..9252c2c80b18 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -9,6 +9,9 @@ * ADR 0028 – consolidated into a single DRF ``ViewSet`` registered via ``DefaultRouter`` (replaces the three legacy ``APIView`` classes ``HomePageView`` / ``HomePageCoursesView`` / ``HomePageLibrariesView``) + * ADR 0029 – standardized error envelope, opted in via + :class:`StandardizedErrorMixin` (v3-scoped — does not change the + project-wide DRF ``EXCEPTION_HANDLER`` setting) """ import edx_api_doc_tools as apidocs @@ -27,10 +30,11 @@ LibraryTabSerializer, StudioHomeSerializer, ) +from cms.djangoapps.contentstore.rest_api.v3.mixins import StandardizedErrorMixin from cms.djangoapps.contentstore.utils import get_course_context, get_home_context, get_library_context -class HomeViewSet(viewsets.ViewSet): +class HomeViewSet(StandardizedErrorMixin, viewsets.ViewSet): """ ViewSet for the Studio home page. Registered via DefaultRouter (basename ``home``). From 16169369740f93b03836a0d4e44065acb55514d6 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 3 Jun 2026 14:06:07 +0500 Subject: [PATCH 3/3] feat: refactor mixin so that both v3 and v4 can use it --- cms/djangoapps/contentstore/rest_api/{v3 => }/mixins.py | 6 +++--- .../contentstore/rest_api/v3/tests/test_home.py | 2 +- cms/djangoapps/contentstore/rest_api/v3/views/home.py | 2 +- cms/djangoapps/contentstore/rest_api/v4/views/home.py | 9 +++------ 4 files changed, 8 insertions(+), 11 deletions(-) rename cms/djangoapps/contentstore/rest_api/{v3 => }/mixins.py (85%) diff --git a/cms/djangoapps/contentstore/rest_api/v3/mixins.py b/cms/djangoapps/contentstore/rest_api/mixins.py similarity index 85% rename from cms/djangoapps/contentstore/rest_api/v3/mixins.py rename to cms/djangoapps/contentstore/rest_api/mixins.py index 4ff1e687fc4e..61f1ff82b4b6 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/mixins.py +++ b/cms/djangoapps/contentstore/rest_api/mixins.py @@ -1,5 +1,5 @@ """ -v3-scoped mixins for the contentstore REST API. +Shared mixins for the contentstore REST API (used across versions). Currently provides :class:`StandardizedErrorMixin`, which opts a single view/viewset into the ADR 0029 error envelope without changing the @@ -15,8 +15,8 @@ class StandardizedErrorMixin: ``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 v3 endpoints - return the standardized envelope while v0/v1/v2 endpoints continue to use + 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:: diff --git a/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py index e8f79069106f..50f14bc361f6 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py @@ -2,7 +2,7 @@ ADR 0029 – Standardized error-response regression tests for HomeViewSet (v3). The ADR 0029 envelope is wired into the v3 viewset via -:class:`cms.djangoapps.contentstore.rest_api.v3.mixins.StandardizedErrorMixin`, +:class:`cms.djangoapps.contentstore.rest_api.mixins.StandardizedErrorMixin`, which overrides DRF's per-view ``get_exception_handler`` to point at ``openedx.core.lib.api.exceptions.standardized_error_exception_handler``. diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/home.py b/cms/djangoapps/contentstore/rest_api/v3/views/home.py index 9252c2c80b18..6f2bcfe63acb 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -25,12 +25,12 @@ from rest_framework.request import Request from rest_framework.response import Response +from cms.djangoapps.contentstore.rest_api.mixins import StandardizedErrorMixin from cms.djangoapps.contentstore.rest_api.v1.serializers import ( CourseHomeTabSerializer, LibraryTabSerializer, StudioHomeSerializer, ) -from cms.djangoapps.contentstore.rest_api.v3.mixins import StandardizedErrorMixin from cms.djangoapps.contentstore.utils import get_course_context, get_home_context, get_library_context diff --git a/cms/djangoapps/contentstore/rest_api/v4/views/home.py b/cms/djangoapps/contentstore/rest_api/v4/views/home.py index d6c927b328c7..3013f05e9b28 100644 --- a/cms/djangoapps/contentstore/rest_api/v4/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v4/views/home.py @@ -11,6 +11,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from cms.djangoapps.contentstore.rest_api.mixins import StandardizedErrorMixin from cms.djangoapps.contentstore.rest_api.v4.serializers.home import ( CourseHomeTabSerializerV4, ) @@ -98,7 +99,7 @@ def _maybe_set_legacy_order_deprecation_header( return response -class HomeCoursesViewSet(viewsets.ViewSet): +class HomeCoursesViewSet(StandardizedErrorMixin, viewsets.ViewSet): """ ViewSet for course listing (v4). Registered via DefaultRouter (basename ``home-courses``). @@ -113,6 +114,7 @@ class HomeCoursesViewSet(viewsets.ViewSet): - 0026: explicit ``authentication_classes`` and ``permission_classes`` - 0027: ``drf_spectacular`` for OpenAPI documentation - 0028: ViewSet with DefaultRouter registration + - 0029: standardized error envelope via ``StandardizedErrorMixin`` - 0032: 7-field pagination envelope via ``DefaultPagination`` - 0033: ``ordering`` parameter; ``order`` kept as deprecated alias """ @@ -121,11 +123,6 @@ class HomeCoursesViewSet(viewsets.ViewSet): permission_classes = (IsAuthenticated,) serializer_class = CourseHomeTabSerializerV4 - def get_exception_handler(self): - """Return the ADR 0029 standardized error handler for this viewset.""" - from openedx.core.lib.api.exceptions import standardized_error_exception_handler - return standardized_error_exception_handler - def get_serializer(self, *args, **kwargs): """Instantiate and return the configured serializer class.""" return self.serializer_class(*args, **kwargs)