feat: add paginated HomePageCoursesV2 view with filtering & ordering (#34173)
This commit is contained in:
0
cms/djangoapps/contentstore/rest_api/v2/__init__.py
Normal file
0
cms/djangoapps/contentstore/rest_api/v2/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Module for v2 serializers."""
|
||||
|
||||
from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2
|
||||
68
cms/djangoapps/contentstore/rest_api/v2/serializers/home.py
Normal file
68
cms/djangoapps/contentstore/rest_api/v2/serializers/home.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
API Serializers for course home V2 API.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
from cms.djangoapps.contentstore.utils import get_lms_link_for_item, reverse_course_url
|
||||
from cms.djangoapps.contentstore.views.course import _get_rerun_link_for_item
|
||||
from openedx.core.lib.api.serializers import CourseKeyField
|
||||
|
||||
|
||||
class UnsucceededCourseSerializerV2(serializers.Serializer):
|
||||
"""Serializer for unsucceeded course."""
|
||||
|
||||
display_name = serializers.CharField()
|
||||
course_key = CourseKeyField()
|
||||
org = serializers.CharField()
|
||||
number = serializers.CharField()
|
||||
run = serializers.CharField()
|
||||
is_failed = serializers.BooleanField()
|
||||
is_in_progress = serializers.BooleanField()
|
||||
dismiss_link = serializers.CharField()
|
||||
|
||||
|
||||
class CourseCommonSerializerV2(serializers.Serializer):
|
||||
"""Serializer for course common fields V2."""
|
||||
|
||||
course_key = CourseKeyField(source='id')
|
||||
display_name = serializers.CharField()
|
||||
lms_link = serializers.SerializerMethodField()
|
||||
cms_link = serializers.SerializerMethodField()
|
||||
number = serializers.CharField()
|
||||
org = serializers.CharField()
|
||||
rerun_link = serializers.SerializerMethodField()
|
||||
run = serializers.CharField(source='id.run')
|
||||
url = serializers.SerializerMethodField()
|
||||
is_active = serializers.SerializerMethodField()
|
||||
|
||||
def get_lms_link(self, obj):
|
||||
"""Get LMS link for course."""
|
||||
return get_lms_link_for_item(obj.location)
|
||||
|
||||
def get_cms_link(self, obj):
|
||||
"""Get CMS link for course."""
|
||||
return f"//{settings.CMS_BASE}{reverse_course_url('course_handler', obj.id)}"
|
||||
|
||||
def get_rerun_link(self, obj):
|
||||
"""Get rerun link for course."""
|
||||
return _get_rerun_link_for_item(obj.id)
|
||||
|
||||
def get_url(self, obj):
|
||||
"""Get URL from the course handler."""
|
||||
return reverse_course_url('course_handler', obj.id)
|
||||
|
||||
def get_is_active(self, obj):
|
||||
"""Get whether the course is active or not."""
|
||||
return not obj.has_ended()
|
||||
|
||||
|
||||
class CourseHomeTabSerializerV2(serializers.Serializer):
|
||||
"""Serializer for course home tab V2 with unsucceeded courses and in process course actions."""
|
||||
|
||||
courses = CourseCommonSerializerV2(required=False, many=True)
|
||||
in_process_course_actions = UnsucceededCourseSerializerV2(
|
||||
many=True,
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
15
cms/djangoapps/contentstore/rest_api/v2/urls.py
Normal file
15
cms/djangoapps/contentstore/rest_api/v2/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Contenstore API v2 URLs."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from cms.djangoapps.contentstore.rest_api.v2.views import HomePageCoursesViewV2
|
||||
|
||||
app_name = "v2"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"home/courses",
|
||||
HomePageCoursesViewV2.as_view(),
|
||||
name="courses",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Module for v2 views."""
|
||||
|
||||
from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2
|
||||
147
cms/djangoapps/contentstore/rest_api/v2/views/home.py
Normal file
147
cms/djangoapps/contentstore/rest_api/v2/views/home.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""HomePageCoursesViewV2 APIView for getting content available to the logged in user."""
|
||||
import edx_api_doc_tools as apidocs
|
||||
from collections import OrderedDict
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseNotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
|
||||
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."""
|
||||
|
||||
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),
|
||||
]))
|
||||
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
"""
|
||||
Paginate a queryset if required, either returning a page object,
|
||||
or `None` if pagination is not configured for this view.
|
||||
|
||||
This method is a modified version of the original `paginate_queryset` method
|
||||
from the `PageNumberPagination` class. The original method was modified to
|
||||
handle the case where the `queryset` is a `filter` object.
|
||||
"""
|
||||
if isinstance(queryset, filter):
|
||||
queryset = list(queryset)
|
||||
|
||||
return super().paginate_queryset(queryset, request, view)
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class HomePageCoursesViewV2(APIView):
|
||||
"""View for getting all courses available to the logged in user."""
|
||||
|
||||
@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",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: CourseHomeTabSerializerV2,
|
||||
401: "The requester is not authenticated.",
|
||||
},
|
||||
)
|
||||
def get(self, request: Request):
|
||||
"""
|
||||
Get an object containing all courses.
|
||||
|
||||
**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?order=-org
|
||||
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
|
||||
|
||||
**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.
|
||||
|
||||
**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": [],
|
||||
}
|
||||
```
|
||||
|
||||
if the `ENABLE_HOME_PAGE_COURSE_API_V2` feature flag is not enabled, an HTTP 404 "Not Found" response
|
||||
is returned.
|
||||
"""
|
||||
if not settings.FEATURES.get('ENABLE_HOME_PAGE_COURSE_API_V2', False):
|
||||
return HttpResponseNotFound()
|
||||
|
||||
courses, in_process_course_actions = get_course_context_v2(request)
|
||||
paginator = HomePageCoursesPaginator()
|
||||
courses_page = paginator.paginate_queryset(
|
||||
courses,
|
||||
self.request,
|
||||
view=self
|
||||
)
|
||||
serializer = CourseHomeTabSerializerV2({
|
||||
'courses': courses_page,
|
||||
'in_process_course_actions': in_process_course_actions,
|
||||
})
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
241
cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py
Normal file
241
cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Unit tests for home page view.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from edx_toggles.toggles.testutils import override_waffle_switch
|
||||
from rest_framework import status
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||
from cms.djangoapps.contentstore.utils import reverse_course_url
|
||||
from cms.djangoapps.contentstore.views.course import ENABLE_GLOBAL_STAFF_OPTIMIZATION
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
|
||||
FEATURES_WITH_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy()
|
||||
FEATURES_WITH_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = True
|
||||
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
|
||||
@ddt.ddt
|
||||
class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
"""
|
||||
Tests for HomePageView view version 2.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api_v2_url = reverse("cms.djangoapps.contentstore:v2:courses")
|
||||
self.api_v1_url = reverse("cms.djangoapps.contentstore:v1:courses")
|
||||
self.active_course = CourseOverviewFactory.create(
|
||||
id=self.course.id,
|
||||
org=self.course.org,
|
||||
display_name=self.course.display_name,
|
||||
)
|
||||
archived_course_key = self.store.make_course_key('demo-org', 'demo-number', 'demo-run')
|
||||
self.archived_course = CourseOverviewFactory.create(
|
||||
display_name="Demo Course (Sample)",
|
||||
id=archived_course_key,
|
||||
org=archived_course_key.org,
|
||||
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
|
||||
)
|
||||
|
||||
def test_home_page_response(self):
|
||||
"""Get list of courses available to the logged in user.
|
||||
|
||||
Expected result:
|
||||
- A paginated response.
|
||||
- A list of courses available to the logged in user.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url)
|
||||
course_id = str(self.course.id)
|
||||
archived_course_id = str(self.archived_course.id)
|
||||
|
||||
expected_data = {
|
||||
"courses": [
|
||||
OrderedDict([
|
||||
("course_key", course_id),
|
||||
("display_name", self.course.display_name),
|
||||
("lms_link", f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}'),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
|
||||
("number", self.course.number),
|
||||
("org", self.course.org),
|
||||
("rerun_link", f'/course_rerun/{course_id}'),
|
||||
("run", self.course.id.run),
|
||||
("url", f'/course/{course_id}'),
|
||||
("is_active", True),
|
||||
]),
|
||||
OrderedDict([
|
||||
("course_key", str(self.archived_course.id)),
|
||||
("display_name", self.archived_course.display_name),
|
||||
(
|
||||
"lms_link",
|
||||
f'//{settings.LMS_BASE}/courses/{archived_course_id}/jump_to/{self.archived_course.location}'
|
||||
),
|
||||
(
|
||||
"cms_link",
|
||||
f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}',
|
||||
),
|
||||
("number", self.archived_course.number),
|
||||
("org", self.archived_course.org),
|
||||
("rerun_link", f'/course_rerun/{str(self.archived_course.id)}'),
|
||||
("run", self.archived_course.id.run),
|
||||
("url", f'/course/{str(self.archived_course.id)}'),
|
||||
("is_active", False),
|
||||
]),
|
||||
],
|
||||
"in_process_course_actions": [],
|
||||
}
|
||||
expected_response = OrderedDict([
|
||||
('count', 2),
|
||||
('num_pages', 1),
|
||||
('next', None),
|
||||
('previous', None),
|
||||
('results', expected_data),
|
||||
])
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(expected_response, response.data)
|
||||
|
||||
@override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True)
|
||||
def test_org_query_if_passed(self):
|
||||
"""Get list of courses when org filter passed as a query param.
|
||||
|
||||
Expected result:
|
||||
- A list of courses available to the logged in user for the specified org.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url, {"org": "demo-org"})
|
||||
|
||||
self.assertEqual(len(response.data['results']['courses']), 1)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True)
|
||||
def test_org_query_if_empty(self):
|
||||
"""Get home page with an empty org query param.
|
||||
|
||||
Expected result:
|
||||
- An empty list of courses available to the logged in user.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url)
|
||||
|
||||
self.assertEqual(len(response.data['results']['courses']), 0)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_active_only_query_if_passed(self):
|
||||
"""Get list of active courses only.
|
||||
|
||||
Expected result:
|
||||
- A list of active courses available to the logged in user.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url, {"active_only": "true"})
|
||||
|
||||
self.assertEqual(len(response.data["results"]["courses"]), 1)
|
||||
self.assertEqual(response.data["results"]["courses"], [OrderedDict([
|
||||
("course_key", str(self.course.id)),
|
||||
("display_name", self.course.display_name),
|
||||
("lms_link", f'//{settings.LMS_BASE}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
|
||||
("number", self.course.number),
|
||||
("org", self.course.org),
|
||||
("rerun_link", f'/course_rerun/{str(self.course.id)}'),
|
||||
("run", self.course.id.run),
|
||||
("url", f'/course/{str(self.course.id)}'),
|
||||
("is_active", True),
|
||||
])])
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_archived_only_query_if_passed(self):
|
||||
"""Get list of archived courses only.
|
||||
|
||||
Expected result:
|
||||
- A list of archived courses available to the logged in user.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url, {"archived_only": "true"})
|
||||
|
||||
self.assertEqual(len(response.data["results"]["courses"]), 1)
|
||||
self.assertEqual(response.data["results"]["courses"], [OrderedDict([
|
||||
("course_key", str(self.archived_course.id)),
|
||||
("display_name", self.archived_course.display_name),
|
||||
(
|
||||
"lms_link",
|
||||
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
|
||||
),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
|
||||
("number", self.archived_course.number),
|
||||
("org", self.archived_course.org),
|
||||
("rerun_link", f'/course_rerun/{str(self.archived_course.id)}'),
|
||||
("run", self.archived_course.id.run),
|
||||
("url", f'/course/{str(self.archived_course.id)}'),
|
||||
("is_active", False),
|
||||
])])
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_search_query_if_passed(self):
|
||||
"""Get list of courses when search filter passed as a query param.
|
||||
|
||||
Expected result:
|
||||
- A list of courses (active or inactive) available to the logged in user for the specified search.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url, {"search": "sample"})
|
||||
|
||||
self.assertEqual(len(response.data["results"]["courses"]), 1)
|
||||
self.assertEqual(response.data["results"]["courses"], [OrderedDict([
|
||||
("course_key", str(self.archived_course.id)),
|
||||
("display_name", self.archived_course.display_name),
|
||||
(
|
||||
"lms_link",
|
||||
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
|
||||
),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
|
||||
("number", self.archived_course.number),
|
||||
("org", self.archived_course.org),
|
||||
("rerun_link", f'/course_rerun/{str(self.archived_course.id)}'),
|
||||
("run", self.archived_course.id.run),
|
||||
("url", f'/course/{str(self.archived_course.id)}'),
|
||||
("is_active", False),
|
||||
])])
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_order_query_if_passed(self):
|
||||
"""Get list of courses when order filter passed as a query param.
|
||||
|
||||
Expected result:
|
||||
- A list of courses (active or inactive) available to the logged in user for the specified order.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url, {"order": "org"})
|
||||
|
||||
self.assertEqual(len(response.data["results"]["courses"]), 2)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["results"]["courses"][0]["org"], "demo-org")
|
||||
|
||||
def test_page_query_if_passed(self):
|
||||
"""Get list of courses when page filter passed as a query param.
|
||||
|
||||
Expected result:
|
||||
- A list of courses (active or inactive) available to the logged in user for the specified page.
|
||||
"""
|
||||
response = self.client.get(self.api_v2_url, {"page": 1})
|
||||
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@patch("cms.djangoapps.contentstore.views.course.CourseOverview")
|
||||
@patch("cms.djangoapps.contentstore.views.course.modulestore")
|
||||
def test_api_v2_is_disabled(self, mock_modulestore, mock_course_overview):
|
||||
"""Get list of courses when home page course v2 API is disabled.
|
||||
|
||||
Expected result:
|
||||
- Courses are read from the modulestore.
|
||||
"""
|
||||
with override_settings(FEATURES={'ENABLE_HOME_PAGE_COURSE_API_V2': False}):
|
||||
response = self.client.get(self.api_v1_url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mock_modulestore().get_course_summaries.assert_called_once()
|
||||
mock_course_overview.get_all_courses.assert_not_called()
|
||||
Reference in New Issue
Block a user