feat: add paginated HomePageCoursesV2 view with filtering & ordering (#34173)

This commit is contained in:
Maria Grimaldi
2024-03-20 11:31:59 -04:00
committed by GitHub
parent 8571eeca61
commit 45178e0ced
16 changed files with 809 additions and 13 deletions

View File

@@ -0,0 +1,3 @@
"""Module for v2 serializers."""
from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2

View 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
)

View 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",
),
]

View File

@@ -0,0 +1,3 @@
"""Module for v2 views."""
from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2

View 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)

View 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()