Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cms/djangoapps/contentstore/rest_api/mixins.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions cms/djangoapps/contentstore/rest_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)),
]
Empty file.
Empty file.
86 changes: 86 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v3/urls.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v3/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Views for v3 contentstore API."""

from .home import HomeViewSet # noqa: F401
164 changes: 164 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v3/views/home.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
Loading
Loading