diff --git a/cms/djangoapps/contentstore/rest_api/mixins.py b/cms/djangoapps/contentstore/rest_api/mixins.py new file mode 100644 index 000000000000..61f1ff82b4b6 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/mixins.py @@ -0,0 +1,29 @@ +""" +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 +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 the view + return the standardized envelope while other endpoints continue to use + whichever handler the project-wide ``EXCEPTION_HANDLER`` setting points at. + + Usage:: + + class MyViewSet(StandardizedErrorMixin, viewsets.ViewSet): + ... + """ + + def get_exception_handler(self): + return standardized_error_exception_handler 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..50f14bc361f6 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py @@ -0,0 +1,86 @@ +""" +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.mixins.StandardizedErrorMixin`, +which overrides DRF's per-view ``get_exception_handler`` to point at +``openedx.core.lib.api.exceptions.standardized_error_exception_handler``. + +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.test import APIClient, APITestCase + +_REQUIRED_ERROR_FIELDS = ("type", "title", "status", "detail", "instance") + + +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 + + 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/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..6f2bcfe63acb --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -0,0 +1,164 @@ +""" +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``) + * 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 +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.mixins import StandardizedErrorMixin +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(StandardizedErrorMixin, 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() 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)