Skip to content

Commit 06b230b

Browse files
authored
feat: standardize home v2 API into v4 (#38684)
1 parent b4d8143 commit 06b230b

13 files changed

Lines changed: 725 additions & 4 deletions

File tree

cms/djangoapps/contentstore/rest_api/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
from .v0 import urls as v0_urls
88
from .v1 import urls as v1_urls
99
from .v2 import urls as v2_urls
10+
from .v4 import urls as v4_urls
1011

1112
app_name = 'cms.djangoapps.contentstore'
1213

1314
urlpatterns = [
1415
path('v0/', include(v0_urls)),
1516
path('v1/', include(v1_urls)),
16-
path('v2/', include(v2_urls))
17+
path('v2/', include(v2_urls)),
18+
path('v4/', include(v4_urls)),
1719
]

cms/djangoapps/contentstore/rest_api/v4/__init__.py

Whitespace-only changes.

cms/djangoapps/contentstore/rest_api/v4/serializers/__init__.py

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""API serializers for course home V4. Re-exports V2 serializers under V4 names."""
2+
from cms.djangoapps.contentstore.rest_api.v2.serializers.home import (
3+
CourseCommonSerializerV2,
4+
CourseHomeTabSerializerV2,
5+
UnsucceededCourseSerializerV2,
6+
)
7+
8+
CourseCommonSerializerV4 = CourseCommonSerializerV2
9+
CourseHomeTabSerializerV4 = CourseHomeTabSerializerV2
10+
UnsucceededCourseSerializerV4 = UnsucceededCourseSerializerV2
11+
12+
__all__ = [
13+
"CourseCommonSerializerV4",
14+
"CourseHomeTabSerializerV4",
15+
"UnsucceededCourseSerializerV4",
16+
]

cms/djangoapps/contentstore/rest_api/v4/tests/__init__.py

Whitespace-only changes.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
ADR 0029 - Standardized error-response tests for HomeCoursesViewSet (v4).
3+
4+
Verifies that the central exception handler produces the correct ADR 0029
5+
envelope for auth errors on the v4 home courses endpoint.
6+
"""
7+
8+
from django.urls import reverse
9+
from rest_framework import status
10+
from rest_framework.test import APIClient, APITestCase
11+
12+
_REQUIRED_ERROR_FIELDS = ("type", "title", "status", "detail", "instance")
13+
14+
15+
class TestHomeCoursesViewSetErrorShape(APITestCase):
16+
"""
17+
ADR 0029 - error response shape regression tests for HomeCoursesViewSet (v4).
18+
"""
19+
20+
def setUp(self):
21+
super().setUp()
22+
self.client = APIClient()
23+
self.list_url = reverse("cms.djangoapps.contentstore:v4:home-courses-list")
24+
25+
def test_unauthenticated_returns_standardized_401(self):
26+
"""Unauthenticated GET must return 401 with the ADR 0029 envelope."""
27+
response = self.client.get(self.list_url)
28+
29+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009
30+
for field in _REQUIRED_ERROR_FIELDS:
31+
self.assertIn( # noqa: PT009
32+
field, response.data, f"ADR 0029: missing field '{field}'"
33+
)
34+
35+
def test_unauthenticated_401_type_uri(self):
36+
"""The ``type`` field for 401 must be the ADR 0029 authn URI."""
37+
response = self.client.get(self.list_url)
38+
39+
self.assertEqual( # noqa: PT009
40+
response.data.get("type"),
41+
"https://docs.openedx.org/errors/authn",
42+
)
43+
44+
def test_error_body_has_no_legacy_fields(self):
45+
"""Error responses must NOT contain old DeveloperErrorViewMixin fields."""
46+
response = self.client.get(self.list_url)
47+
48+
self.assertNotIn("developer_message", response.data) # noqa: PT009
49+
self.assertNotIn("error_code", response.data) # noqa: PT009
50+
51+
def test_instance_field_is_request_path(self):
52+
"""The ``instance`` field must equal the request path."""
53+
response = self.client.get(self.list_url)
54+
55+
self.assertEqual(response.data.get("instance"), self.list_url) # noqa: PT009
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Contentstore API v4 URLs."""
2+
3+
from rest_framework.routers import DefaultRouter
4+
5+
from cms.djangoapps.contentstore.rest_api.v4.views import home
6+
7+
app_name = "v4"
8+
9+
# ADR 0028: HomeCoursesViewSet registered via DefaultRouter.
10+
# Generates: GET home/courses/ → name: home-courses-list
11+
router = DefaultRouter()
12+
router.register(r'home/courses', home.HomeCoursesViewSet, basename='home-courses')
13+
14+
urlpatterns = router.urls

cms/djangoapps/contentstore/rest_api/v4/views/__init__.py

Whitespace-only changes.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""HomeCoursesViewSet for getting courses available to the logged-in user (v4)."""
2+
3+
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
4+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
5+
from edx_rest_framework_extensions.auth.session.authentication import (
6+
SessionAuthenticationAllowInactiveUser,
7+
)
8+
from edx_rest_framework_extensions.paginators import DefaultPagination
9+
from rest_framework import viewsets
10+
from rest_framework.permissions import IsAuthenticated
11+
from rest_framework.request import Request
12+
from rest_framework.response import Response
13+
14+
from cms.djangoapps.contentstore.rest_api.v4.serializers.home import (
15+
CourseHomeTabSerializerV4,
16+
)
17+
from cms.djangoapps.contentstore.utils import get_course_context_v2
18+
19+
20+
class HomePageCoursesPaginator(DefaultPagination):
21+
"""
22+
ADR 0032 - standard pagination for the Studio home courses list (v4).
23+
24+
Extends ``DefaultPagination`` (edx-rest-framework-extensions) which
25+
provides the 7-field response envelope:
26+
``count``, ``num_pages``, ``current_page``, ``start``,
27+
``next``, ``previous``, ``results``.
28+
29+
Overrides ``paginate_queryset`` to handle ``filter`` objects returned
30+
by ``get_course_context_v2``.
31+
"""
32+
33+
page_size_query_param = "page_size"
34+
35+
def paginate_queryset(self, queryset, request, view=None):
36+
"""
37+
Paginate a queryset, converting ``filter`` objects to lists first.
38+
39+
``get_course_context_v2`` may return a ``filter`` object; the base
40+
``PageNumberPagination`` cannot measure its length without materialising
41+
it first, so we do that here.
42+
"""
43+
if isinstance(queryset, filter):
44+
queryset = list(queryset)
45+
return super().paginate_queryset(queryset, request, view)
46+
47+
48+
def _query_param(
49+
name: str, description: str, deprecated: bool = False
50+
) -> OpenApiParameter:
51+
"""Build a string-typed, optional query parameter for OpenAPI docs."""
52+
return OpenApiParameter(
53+
name=name,
54+
description=description,
55+
required=False,
56+
type=str,
57+
location=OpenApiParameter.QUERY,
58+
deprecated=deprecated,
59+
)
60+
61+
62+
_HOME_COURSES_QUERY_PARAMETERS = [
63+
_query_param("org", "Filter by course org"),
64+
_query_param("search", "Filter by course name, org, or number"),
65+
_query_param(
66+
"ordering",
67+
"Order by course field: display_name, org, number, or run (ADR 0033 standard parameter).",
68+
),
69+
_query_param(
70+
"order",
71+
"Deprecated alias for 'ordering' (ADR 0033). Use 'ordering' instead.",
72+
deprecated=True,
73+
),
74+
_query_param("active_only", "Filter to active courses only"),
75+
_query_param("archived_only", "Filter to archived courses only"),
76+
_query_param("page", "Page number for pagination"),
77+
_query_param("page_size", "Number of courses per page (default 10, max 100)"),
78+
]
79+
80+
_UNAUTHENTICATED_RESPONSE = OpenApiResponse(
81+
description="The requester is not authenticated."
82+
)
83+
84+
# ADR 0033: emitted as an HTTP ``Deprecation`` header when the legacy ``order``
85+
# parameter is used instead of the DRF-standard ``ordering``.
86+
_LEGACY_ORDER_DEPRECATION_HEADER = (
87+
"Parameter 'order' is deprecated. Use 'ordering' instead. "
88+
"Support will be removed in release '<release_name>'."
89+
)
90+
91+
92+
def _maybe_set_legacy_order_deprecation_header(
93+
request: Request, response: Response
94+
) -> Response:
95+
"""Set the ADR 0033 Deprecation header when the legacy ``order`` parameter is used."""
96+
if "order" in request.query_params:
97+
response["Deprecation"] = _LEGACY_ORDER_DEPRECATION_HEADER
98+
return response
99+
100+
101+
class HomeCoursesViewSet(viewsets.ViewSet):
102+
"""
103+
ViewSet for course listing (v4). Registered via DefaultRouter (basename ``home-courses``).
104+
105+
Router-generated URLs::
106+
107+
GET /api/contentstore/v4/home/courses/ → list
108+
109+
Supersedes ``HomePageCoursesViewV2`` at ``/api/contentstore/v2/home/courses``.
110+
111+
ADR compliance:
112+
- 0025: ``serializer_class`` attribute for schema generation
113+
- 0026: explicit ``authentication_classes`` and ``permission_classes``
114+
- 0027: ``drf_spectacular`` for OpenAPI documentation
115+
- 0028: ViewSet with DefaultRouter registration
116+
- 0032: 7-field pagination envelope via ``DefaultPagination``
117+
- 0033: ``ordering`` parameter; ``order`` kept as deprecated alias
118+
"""
119+
120+
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)
121+
permission_classes = (IsAuthenticated,)
122+
serializer_class = CourseHomeTabSerializerV4
123+
124+
def get_exception_handler(self):
125+
"""Return the ADR 0029 standardized error handler for this viewset."""
126+
from openedx.core.lib.api.exceptions import standardized_error_exception_handler
127+
return standardized_error_exception_handler
128+
129+
def get_serializer(self, *args, **kwargs):
130+
"""Instantiate and return the configured serializer class."""
131+
return self.serializer_class(*args, **kwargs)
132+
133+
@extend_schema(
134+
summary="List courses for the Studio home page (paginated)",
135+
description=(
136+
"Returns a paginated list of all courses available to the logged-in user, "
137+
"with optional filtering and ordering. "
138+
"Supersedes ``GET /api/contentstore/v2/home/courses``."
139+
),
140+
parameters=_HOME_COURSES_QUERY_PARAMETERS,
141+
responses={
142+
200: OpenApiResponse(
143+
response=CourseHomeTabSerializerV4,
144+
description="Paginated course list retrieved successfully.",
145+
),
146+
401: _UNAUTHENTICATED_RESPONSE,
147+
},
148+
)
149+
def list(self, request: Request):
150+
"""
151+
Get a paginated list of all courses available to the logged-in user.
152+
153+
**Example Request**
154+
155+
GET /api/contentstore/v4/home/courses/
156+
GET /api/contentstore/v4/home/courses/?org=edX
157+
GET /api/contentstore/v4/home/courses/?search=E2E
158+
GET /api/contentstore/v4/home/courses/?ordering=-org
159+
GET /api/contentstore/v4/home/courses/?order=-org
160+
GET /api/contentstore/v4/home/courses/?active_only=true
161+
GET /api/contentstore/v4/home/courses/?archived_only=true
162+
GET /api/contentstore/v4/home/courses/?page=2
163+
GET /api/contentstore/v4/home/courses/?page_size=20
164+
165+
**Pagination Parameters**
166+
167+
- ``page`` (int): Page number to retrieve. Default is 1.
168+
- ``page_size`` (int): Items per page. Default is 10, max is 100.
169+
170+
**Response Envelope (ADR 0032)**
171+
172+
- ``count`` (int): Total number of courses matching the filters.
173+
- ``num_pages`` (int): Total number of pages.
174+
- ``current_page`` (int): The current page number.
175+
- ``start`` (int): The 0-based index of the first course on this page.
176+
- ``next`` (str|null): URL for the next page, or null on the last page.
177+
- ``previous`` (str|null): URL for the previous page, or null on the first page.
178+
- ``results`` (dict): Course data for the current page.
179+
180+
**Example Response**
181+
182+
```json
183+
{
184+
"count": 1,
185+
"num_pages": 1,
186+
"current_page": 1,
187+
"start": 0,
188+
"next": null,
189+
"previous": null,
190+
"results": {
191+
"courses": [
192+
{
193+
"course_key": "course-v1:edX+E2E-101+course",
194+
"display_name": "E2E Test Course",
195+
"lms_link": "//localhost:18000/courses/course-v1:edX+E2E-101+course",
196+
"cms_link": "//localhost:18010/course/course-v1:edX+E2E-101+course",
197+
"number": "E2E-101",
198+
"org": "edX",
199+
"rerun_link": "/course_rerun/course-v1:edX+E2E-101+course",
200+
"run": "course",
201+
"url": "/course/course-v1:edX+E2E-101+course",
202+
"is_active": true
203+
}
204+
],
205+
"in_process_course_actions": []
206+
}
207+
}
208+
```
209+
"""
210+
courses, in_process_course_actions = get_course_context_v2(request)
211+
paginator = HomePageCoursesPaginator()
212+
courses_page = paginator.paginate_queryset(courses, request, view=self)
213+
serializer = self.get_serializer(
214+
{
215+
"courses": courses_page,
216+
"in_process_course_actions": in_process_course_actions,
217+
}
218+
)
219+
response = paginator.get_paginated_response(serializer.data)
220+
return _maybe_set_legacy_order_deprecation_header(request, response)

cms/djangoapps/contentstore/rest_api/v4/views/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)