Skip to content
11 changes: 10 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@

from django.conf import settings
from django.urls import path, re_path
from rest_framework.routers import DefaultRouter

from cms.djangoapps.contentstore.rest_api.v2.views import downstreams, home, utils

app_name = "v2"

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

urlpatterns = router.urls + [
# DEPRECATED (ADR 0028): kept for backward compatibility.
# Will be removed after one named release.
# Use GET home/courses/ (router URL name: home-courses-list) instead.
path(
"home/courses",
home.HomePageCoursesViewV2.as_view(),
Expand Down
289 changes: 211 additions & 78 deletions cms/djangoapps/contentstore/rest_api/v2/views/home.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,89 @@
"""HomePageCoursesViewV2 APIView for getting content available to the logged in user."""

import edx_api_doc_tools as apidocs
from collections import OrderedDict
from rest_framework.response import Response

from drf_spectacular.utils import (
extend_schema,
OpenApiParameter,
OpenApiResponse,
)
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.pagination import PageNumberPagination

from openedx.core.lib.api.view_utils import view_auth_classes
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 cms.djangoapps.contentstore.utils import get_course_context_v2
from cms.djangoapps.contentstore.rest_api.v2.serializers import CourseHomeTabSerializerV2


class HomePageCoursesPaginator(PageNumberPagination):
"""Custom paginator for the home page courses view version 2."""
page_size_query_param = 'page_size'
def _query_param(name: str, description: str, deprecated: bool = False) -> OpenApiParameter:
"""Build a string-typed, optional query parameter (preserves api-doc-tools behavior)."""
return OpenApiParameter(
name=name,
description=description,
required=False,
type=str,
location=OpenApiParameter.QUERY,
deprecated=deprecated,
)


# ADR 0033 – sorting standardization:
# ``ordering`` is the DRF-standard parameter name and is the preferred form.
# ``order`` is kept as a deprecated alias; requests using it receive a
# ``Deprecation`` HTTP header. Removal is scheduled for the named release
# tracked in ADR 0033's deprecation window (see _LEGACY_ORDER_DEPRECATION_HEADER).
_HOME_COURSES_QUERY_PARAMETERS = [
_query_param("org", "Query param to filter by course org"),
_query_param("search", "Query param to filter by course name, org, or number"),
_query_param("ordering", "Query param to order by course name, org, or number (DRF standard, ADR 0033)"),
_query_param(
"order",
"Deprecated alias for 'ordering' (ADR 0033). Use 'ordering' instead.",
deprecated=True,
),
_query_param("active_only", "Query param to filter by active courses only"),
_query_param("archived_only", "Query param to filter by archived courses only"),
_query_param("page", "Query param to paginate the courses"),
_query_param("page_size", "Query param to set page size"),
]
_UNAUTHENTICATED_RESPONSE = OpenApiResponse(description="The requester is not authenticated.")

# ADR 0033 BC strategy §2: deprecation warning header emitted when the legacy
# ``order`` query parameter is used in place of the standardized ``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 on ``response`` when the request
used the legacy ``order`` query parameter.

The header is emitted whenever ``order`` appears in the query string, even
if ``ordering`` was also supplied (in which case ``ordering`` wins, but the
caller should still be told that ``order`` is deprecated).
"""
if 'order' in request.query_params:
response['Deprecation'] = _LEGACY_ORDER_DEPRECATION_HEADER
return response


def get_paginated_response(self, data):
"""Return a paginated style `Response` object for the given output data."""
return Response(OrderedDict([
('count', self.page.paginator.count),
('num_pages', self.page.paginator.num_pages),
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data),
]))
class HomePageCoursesPaginator(DefaultPagination):
"""
ADR 0032 – standard pagination for the Studio home courses list (v2).

Extends DefaultPagination with the full 7-field response envelope:
count, num_pages, current_page, start, next, previous, results.
Handles Python ``filter`` objects returned by get_course_context_v2.
"""
page_size_query_param = 'page_size'

def paginate_queryset(self, queryset, request, view=None):
"""
Expand All @@ -42,52 +100,103 @@ def paginate_queryset(self, queryset, request, view=None):
return super().paginate_queryset(queryset, request, view)


@view_auth_classes(is_authenticated=True)
class HomeCoursesViewSetV2(viewsets.ViewSet):
"""
ViewSet for course listing (v2). Registered via DefaultRouter (basename ``home-courses``).

Router-generated URLs:
GET /api/contentstore/v2/home/courses/ → list

ADR 0033 compliance notes:
- Sorting uses the DRF-standard ``ordering`` parameter; ``order`` is
retained as a deprecated alias (Phase 1 of the BC strategy) and
requests using it receive a ``Deprecation`` HTTP header.
- Full migration to ``django-filter``/``DjangoFilterBackend`` is tracked
as a follow-up: the underlying data source returned by
``get_courses_accessible_to_user`` is a Python ``filter()`` wrapper
around a hybrid queryset, so attaching a ``FilterSet`` requires
reshaping ``_accessible_courses_summary_iter`` /
``_accessible_courses_list_from_groups`` first.
"""
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)
permission_classes = (IsAuthenticated,)
serializer_class = CourseHomeTabSerializerV2

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."
),
parameters=_HOME_COURSES_QUERY_PARAMETERS,
responses={
200: OpenApiResponse(
response=CourseHomeTabSerializerV2,
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/v2/home/courses/
GET /api/contentstore/v2/home/courses/?org=edX
GET /api/contentstore/v2/home/courses/?search=E2E
GET /api/contentstore/v2/home/courses/?ordering=-org
GET /api/contentstore/v2/home/courses/?order=-org # deprecated, use ?ordering=
GET /api/contentstore/v2/home/courses/?active_only=true
GET /api/contentstore/v2/home/courses/?archived_only=true
GET /api/contentstore/v2/home/courses/?page=2
GET /api/contentstore/v2/home/courses/?page_size=20

**Response Values**

If the request is successful, an HTTP 200 \"OK\" response is returned.

The HTTP 200 response is paginated and contains ``count``, ``num_pages``,
``next``, ``previous`` and ``results`` keys. ``results`` contains the
serialized course data.
"""
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)


class HomePageCoursesViewV2(APIView):
"""View for getting all courses available to the logged in user."""
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)
permission_classes = (IsAuthenticated,)
serializer_class = CourseHomeTabSerializerV2

@apidocs.schema(
parameters=[
apidocs.string_parameter(
"org",
apidocs.ParameterLocation.QUERY,
description="Query param to filter by course org",
),
apidocs.string_parameter(
"search",
apidocs.ParameterLocation.QUERY,
description="Query param to filter by course name, org, or number",
),
apidocs.string_parameter(
"order",
apidocs.ParameterLocation.QUERY,
description="Query param to order by course name, org, or number",
),
apidocs.string_parameter(
"active_only",
apidocs.ParameterLocation.QUERY,
description="Query param to filter by active courses only",
),
apidocs.string_parameter(
"archived_only",
apidocs.ParameterLocation.QUERY,
description="Query param to filter by archived courses only",
),
apidocs.string_parameter(
"page",
apidocs.ParameterLocation.QUERY,
description="Query param to paginate the courses",
),
apidocs.string_parameter(
"page_size",
apidocs.ParameterLocation.QUERY,
description="Query param to set page size",
),
],
@extend_schema(
operation_id="v2_home_courses_retrieve_deprecated",
summary="List courses for the Studio home page (deprecated)",
description=(
"Deprecated. Use GET /api/contentstore/v2/home/courses/ instead."
),
parameters=_HOME_COURSES_QUERY_PARAMETERS,
responses={
200: CourseHomeTabSerializerV2,
401: "The requester is not authenticated.",
200: OpenApiResponse(
response=CourseHomeTabSerializerV2,
description="Paginated course list retrieved successfully.",
),
401: _UNAUTHENTICATED_RESPONSE,
},
deprecated=True,
)
def get(self, request: Request):
"""
Expand All @@ -98,38 +207,61 @@ def get(self, request: Request):
GET /api/contentstore/v2/home/courses
GET /api/contentstore/v2/home/courses?org=edX
GET /api/contentstore/v2/home/courses?search=E2E
GET /api/contentstore/v2/home/courses?order=-org
GET /api/contentstore/v2/home/courses?ordering=-org
GET /api/contentstore/v2/home/courses?order=-org # deprecated, use ?ordering=
GET /api/contentstore/v2/home/courses?active_only=true
GET /api/contentstore/v2/home/courses?archived_only=true
GET /api/contentstore/v2/home/courses?page=2
GET /api/contentstore/v2/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 Values**

If the request is successful, an HTTP 200 "OK" response is returned.

The HTTP 200 response contains a single dict that contains keys that
are the course's home.
The HTTP 200 response contains the ADR 0032 standard pagination envelope.

**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 if this is the last page.
- ``previous`` (str|null): URL for the previous page, or null if this is the first page.
- ``results`` (dict): Course data for the current page.

**Example Response**

```json
{
"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": [],
"count": 2,
"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": []
}
}
```
"""
Expand All @@ -140,8 +272,9 @@ def get(self, request: Request):
self.request,
view=self
)
serializer = CourseHomeTabSerializerV2({
serializer = self.serializer_class({
'courses': courses_page,
'in_process_course_actions': in_process_course_actions,
})
return paginator.get_paginated_response(serializer.data)
response = paginator.get_paginated_response(serializer.data)
return _maybe_set_legacy_order_deprecation_header(request, response)
Loading
Loading