Skip to content
Open
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
4 changes: 3 additions & 1 deletion cms/djangoapps/contentstore/rest_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
from .v0 import urls as v0_urls
from .v1 import urls as v1_urls
from .v2 import urls as v2_urls
from .v4 import urls as v4_urls

app_name = 'cms.djangoapps.contentstore'

urlpatterns = [
path('v0/', include(v0_urls)),
path('v1/', include(v1_urls)),
path('v2/', include(v2_urls))
path('v2/', include(v2_urls)),
path('v4/', include(v4_urls)),
]
Empty file.
Empty file.
16 changes: 16 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v4/serializers/home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""API serializers for course home V4. Re-exports V2 serializers under V4 names."""
from cms.djangoapps.contentstore.rest_api.v2.serializers.home import (
CourseCommonSerializerV2,
CourseHomeTabSerializerV2,
UnsucceededCourseSerializerV2,
)

CourseCommonSerializerV4 = CourseCommonSerializerV2
CourseHomeTabSerializerV4 = CourseHomeTabSerializerV2
UnsucceededCourseSerializerV4 = UnsucceededCourseSerializerV2

__all__ = [
"CourseCommonSerializerV4",
"CourseHomeTabSerializerV4",
"UnsucceededCourseSerializerV4",
]
Empty file.
55 changes: 55 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v4/tests/test_home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
ADR 0029 - Standardized error-response tests for HomeCoursesViewSet (v4).

Verifies that the central exception handler produces the correct ADR 0029
envelope for auth errors on the v4 home courses endpoint.
"""

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 TestHomeCoursesViewSetErrorShape(APITestCase):
"""
ADR 0029 - error response shape regression tests for HomeCoursesViewSet (v4).
"""

def setUp(self):
super().setUp()
self.client = APIClient()
self.list_url = reverse("cms.djangoapps.contentstore:v4:home-courses-list")

def test_unauthenticated_returns_standardized_401(self):
"""Unauthenticated GET must return 401 with the ADR 0029 envelope."""
response = self.client.get(self.list_url)

self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009
for field in _REQUIRED_ERROR_FIELDS:
self.assertIn( # noqa: PT009
field, response.data, f"ADR 0029: missing field '{field}'"
)

def test_unauthenticated_401_type_uri(self):
"""The ``type`` field for 401 must be the ADR 0029 authn URI."""
response = self.client.get(self.list_url)

self.assertEqual( # noqa: PT009
response.data.get("type"),
"https://docs.openedx.org/errors/authn",
)

def test_error_body_has_no_legacy_fields(self):
"""Error responses must NOT contain old DeveloperErrorViewMixin fields."""
response = self.client.get(self.list_url)

self.assertNotIn("developer_message", response.data) # noqa: PT009
self.assertNotIn("error_code", response.data) # noqa: PT009

def test_instance_field_is_request_path(self):
"""The ``instance`` field must equal the request path."""
response = self.client.get(self.list_url)

self.assertEqual(response.data.get("instance"), self.list_url) # noqa: PT009
14 changes: 14 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v4/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Contentstore API v4 URLs."""

from rest_framework.routers import DefaultRouter

from cms.djangoapps.contentstore.rest_api.v4.views import home

app_name = "v4"

# ADR 0028: HomeCoursesViewSet registered via DefaultRouter.
# Generates: GET home/courses/ → name: home-courses-list
router = DefaultRouter()
router.register(r'home/courses', home.HomeCoursesViewSet, basename='home-courses')

urlpatterns = router.urls
Empty file.
220 changes: 220 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v4/views/home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""HomeCoursesViewSet for getting courses available to the logged-in user (v4)."""

from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import (
SessionAuthenticationAllowInactiveUser,
)
from edx_rest_framework_extensions.paginators import DefaultPagination
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from cms.djangoapps.contentstore.rest_api.v4.serializers.home import (
CourseHomeTabSerializerV4,
)
from cms.djangoapps.contentstore.utils import get_course_context_v2


class HomePageCoursesPaginator(DefaultPagination):
"""
ADR 0032 - standard pagination for the Studio home courses list (v4).

Extends ``DefaultPagination`` (edx-rest-framework-extensions) which
provides the 7-field response envelope:
``count``, ``num_pages``, ``current_page``, ``start``,
``next``, ``previous``, ``results``.

Overrides ``paginate_queryset`` to handle ``filter`` objects returned
by ``get_course_context_v2``.
"""

page_size_query_param = "page_size"

def paginate_queryset(self, queryset, request, view=None):
"""
Paginate a queryset, converting ``filter`` objects to lists first.

``get_course_context_v2`` may return a ``filter`` object; the base
``PageNumberPagination`` cannot measure its length without materialising
it first, so we do that here.
"""
if isinstance(queryset, filter):
queryset = list(queryset)
return super().paginate_queryset(queryset, request, view)


def _query_param(
name: str, description: str, deprecated: bool = False
) -> OpenApiParameter:
"""Build a string-typed, optional query parameter for OpenAPI docs."""
return OpenApiParameter(
name=name,
description=description,
required=False,
type=str,
location=OpenApiParameter.QUERY,
deprecated=deprecated,
)


_HOME_COURSES_QUERY_PARAMETERS = [
_query_param("org", "Filter by course org"),
_query_param("search", "Filter by course name, org, or number"),
_query_param(
"ordering",
"Order by course field: display_name, org, number, or run (ADR 0033 standard parameter).",
),
_query_param(
"order",
"Deprecated alias for 'ordering' (ADR 0033). Use 'ordering' instead.",
deprecated=True,
),
_query_param("active_only", "Filter to active courses only"),
_query_param("archived_only", "Filter to archived courses only"),
_query_param("page", "Page number for pagination"),
_query_param("page_size", "Number of courses per page (default 10, max 100)"),
]

_UNAUTHENTICATED_RESPONSE = OpenApiResponse(
description="The requester is not authenticated."
)

# ADR 0033: emitted as an HTTP ``Deprecation`` header when the legacy ``order``
# parameter is used instead of the DRF-standard ``ordering``.
_LEGACY_ORDER_DEPRECATION_HEADER = (
"Parameter 'order' is deprecated. Use 'ordering' instead. "
"Support will be removed in release '<release_name>'."
)


def _maybe_set_legacy_order_deprecation_header(
request: Request, response: Response
) -> Response:
"""Set the ADR 0033 Deprecation header when the legacy ``order`` parameter is used."""
if "order" in request.query_params:
response["Deprecation"] = _LEGACY_ORDER_DEPRECATION_HEADER
return response


class HomeCoursesViewSet(viewsets.ViewSet):
"""
ViewSet for course listing (v4). Registered via DefaultRouter (basename ``home-courses``).

Router-generated URLs::

GET /api/contentstore/v4/home/courses/ → list

Supersedes ``HomePageCoursesViewV2`` at ``/api/contentstore/v2/home/courses``.

ADR compliance:
- 0025: ``serializer_class`` attribute for schema generation
- 0026: explicit ``authentication_classes`` and ``permission_classes``
- 0027: ``drf_spectacular`` for OpenAPI documentation
- 0028: ViewSet with DefaultRouter registration
- 0032: 7-field pagination envelope via ``DefaultPagination``
- 0033: ``ordering`` parameter; ``order`` kept as deprecated alias
"""

authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)
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)

@extend_schema(
summary="List courses for the Studio home page (paginated)",
description=(
"Returns a paginated list of all courses available to the logged-in user, "
"with optional filtering and ordering. "
"Supersedes ``GET /api/contentstore/v2/home/courses``."
),
parameters=_HOME_COURSES_QUERY_PARAMETERS,
responses={
200: OpenApiResponse(
response=CourseHomeTabSerializerV4,
description="Paginated course list retrieved successfully.",
),
401: _UNAUTHENTICATED_RESPONSE,
},
)
def list(self, request: Request):
"""
Get a paginated list of all courses available to the logged-in user.

**Example Request**

GET /api/contentstore/v4/home/courses/
GET /api/contentstore/v4/home/courses/?org=edX
GET /api/contentstore/v4/home/courses/?search=E2E
GET /api/contentstore/v4/home/courses/?ordering=-org
GET /api/contentstore/v4/home/courses/?order=-org
GET /api/contentstore/v4/home/courses/?active_only=true
GET /api/contentstore/v4/home/courses/?archived_only=true
GET /api/contentstore/v4/home/courses/?page=2
GET /api/contentstore/v4/home/courses/?page_size=20

**Pagination Parameters**

- ``page`` (int): Page number to retrieve. Default is 1.
- ``page_size`` (int): Items per page. Default is 10, max is 100.

**Response Envelope (ADR 0032)**

- ``count`` (int): Total number of courses matching the filters.
- ``num_pages`` (int): Total number of pages.
- ``current_page`` (int): The current page number.
- ``start`` (int): The 0-based index of the first course on this page.
- ``next`` (str|null): URL for the next page, or null on the last page.
- ``previous`` (str|null): URL for the previous page, or null on the first page.
- ``results`` (dict): Course data for the current page.

**Example Response**

```json
{
"count": 1,
"num_pages": 1,
"current_page": 1,
"start": 0,
"next": null,
"previous": null,
"results": {
"courses": [
{
"course_key": "course-v1:edX+E2E-101+course",
"display_name": "E2E Test Course",
"lms_link": "//localhost:18000/courses/course-v1:edX+E2E-101+course",
"cms_link": "//localhost:18010/course/course-v1:edX+E2E-101+course",
"number": "E2E-101",
"org": "edX",
"rerun_link": "/course_rerun/course-v1:edX+E2E-101+course",
"run": "course",
"url": "/course/course-v1:edX+E2E-101+course",
"is_active": true
}
],
"in_process_course_actions": []
}
}
```
"""
courses, in_process_course_actions = get_course_context_v2(request)
paginator = HomePageCoursesPaginator()
courses_page = paginator.paginate_queryset(courses, request, view=self)
serializer = self.get_serializer(
{
"courses": courses_page,
"in_process_course_actions": in_process_course_actions,
}
)
response = paginator.get_paginated_response(serializer.data)
return _maybe_set_legacy_order_deprecation_header(request, response)
Empty file.
Loading
Loading