Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 38 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
97aeed8
feat: add CourseHomeCoursesV2 with filtering & ordering capabilities
mariajgrimaldi Jan 30, 2024
6d218c2
refactor: address PR reviews
mariajgrimaldi Feb 6, 2024
12cbacf
fix: add missing blank line
mariajgrimaldi Feb 6, 2024
5471f2e
fix: address PR quality issues
mariajgrimaldi Feb 6, 2024
8b02daa
fix: cast filter as sequence
mariajgrimaldi Feb 6, 2024
e9bef80
fix: address PR reviews
mariajgrimaldi Feb 6, 2024
eefd67c
refactor: remove list instantiation for course_filter
mariajgrimaldi Feb 16, 2024
108517f
refactor: address PR reviews
mariajgrimaldi Feb 19, 2024
37acb54
refactor!: remove checking for queryset emptiness
mariajgrimaldi Feb 19, 2024
fc47dab
refactor!: use existing functions for getting course objects
mariajgrimaldi Feb 19, 2024
2f31735
refactor: move filter section to its own method
mariajgrimaldi Feb 23, 2024
ed21fe1
fix: address testing errors
mariajgrimaldi Feb 23, 2024
4bf0a09
feat: add pagination to the HomePageCoursesV2 API (#34175)
mariajgrimaldi Mar 1, 2024
b662111
test: add same v1 test with adjustments for new output format
mariajgrimaldi Mar 1, 2024
2b54de7
test: add test suite for home page courses v2
mariajgrimaldi Mar 7, 2024
a298b3f
fix: address test failures
mariajgrimaldi Mar 8, 2024
06b6bf4
fix: go back to course overview queryset filtering
mariajgrimaldi Mar 8, 2024
f295535
fix: address test failures
mariajgrimaldi Mar 8, 2024
70c92e2
fix: address test failures
mariajgrimaldi Mar 8, 2024
a58b00a
fix: address test failures
mariajgrimaldi Mar 8, 2024
d7f2f2f
refactor: read from mysql when homepage courses API is on
mariajgrimaldi Mar 15, 2024
fbdeb85
Revert "fix: address test failures"
mariajgrimaldi Mar 15, 2024
8b306da
Revert "fix: address test failures"
mariajgrimaldi Mar 15, 2024
b5a5f36
fix: address test failures
mariajgrimaldi Mar 15, 2024
4aeee4e
fix: import missing check_mongo_calls
mariajgrimaldi Mar 15, 2024
46d9feb
refactor: modify tests to match latest changes in API helpers
mariajgrimaldi Mar 15, 2024
8fbd800
docs: add in-line comment explaining why read from mysql
mariajgrimaldi Mar 15, 2024
a9d13d5
refactor: register URL if and only if homepage course API v2 is enabled
mariajgrimaldi Mar 15, 2024
73a9586
fix: correct variable name according feature toggle
mariajgrimaldi Mar 15, 2024
8e6b1f5
refactor: return not found when feature not enabled
mariajgrimaldi Mar 15, 2024
fb848f9
docs: update method docstrings
mariajgrimaldi Mar 15, 2024
d1e5b99
refactor: address PR reviews
mariajgrimaldi Mar 18, 2024
f808777
Revert "Revert "fix: address test failures""
mariajgrimaldi Mar 18, 2024
084adfa
refactor: turn on feature as default
mariajgrimaldi Mar 18, 2024
9666b1d
Revert "Revert "fix: address test failures""
mariajgrimaldi Mar 18, 2024
3fb4b67
fix: address test failures
mariajgrimaldi Mar 18, 2024
7923987
fix: address test (quality) failures
mariajgrimaldi Mar 18, 2024
93e55c8
fix: address test failures
mariajgrimaldi Mar 18, 2024
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
2 changes: 2 additions & 0 deletions cms/djangoapps/contentstore/rest_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

from .v0 import urls as v0_urls
from .v1 import urls as v1_urls
from .v2 import urls as v2_urls

app_name = 'cms.djangoapps.contentstore'

urlpatterns = [
path('v0/', include(v0_urls)),
path('v1/', include(v1_urls)),
path('v2/', include(v2_urls))
]
45 changes: 45 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
Unit tests for home page view.
"""
import ddt
from collections import OrderedDict
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,
Expand All @@ -18,6 +20,12 @@
from xmodule.modulestore.tests.factories import CourseFactory


FEATURES_WITH_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy()
FEATURES_WITH_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = True
FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy()
FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = False


@ddt.ddt
class HomePageViewTest(CourseTestCase):
"""
Expand Down Expand Up @@ -74,6 +82,7 @@ def test_taxonomy_list_link(self):
)


@override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API)
@ddt.ddt
class HomePageCoursesViewTest(CourseTestCase):
"""
Expand All @@ -83,6 +92,12 @@ class HomePageCoursesViewTest(CourseTestCase):
def setUp(self):
super().setUp()
self.url = reverse("cms.djangoapps.contentstore:v1:courses")
CourseOverviewFactory.create(
id=self.course.id,
org=self.course.org,
display_name=self.course.display_name,
display_number_with_default=self.course.number,
)

def test_home_page_response(self):
"""Check successful response content"""
Expand All @@ -108,6 +123,36 @@ def test_home_page_response(self):
print(response.data)
self.assertDictEqual(expected_response, response.data)

def test_home_page_response_with_api_v2(self):
"""Check successful response content with api v2 modifications.

When the feature flag is enabled, the courses are exclusively fetched from the CourseOverview model, so
the values in the courses' list are OrderedDicts instead of the default dictionaries.
"""
course_id = str(self.course.id)
expected_response = {
"archived_courses": [],
"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}'),
("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}'),
]),
],
"in_process_course_actions": [],
}

with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API):
response = self.client.get(self.url)

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):
"""Test home page when org filter passed as a query param"""
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Module for v2 serializers."""

from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2
68 changes: 68 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/serializers/home.py
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/urls.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
3 changes: 3 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Module for v2 views."""

from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2
147 changes: 147 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/views/home.py
Original file line number Diff line number Diff line change
@@ -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",
mariajgrimaldi marked this conversation as resolved.
Show resolved Hide resolved
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(
mariajgrimaldi marked this conversation as resolved.
Show resolved Hide resolved
"order",
apidocs.ParameterLocation.QUERY,
description="Query param to order by course name, org, or number",
),
apidocs.string_parameter(
"active_only",
mariajgrimaldi marked this conversation as resolved.
Show resolved Hide resolved
apidocs.ParameterLocation.QUERY,
description="Query param to filter by active courses only",
),
apidocs.string_parameter(
"archived_only",
mariajgrimaldi marked this conversation as resolved.
Show resolved Hide resolved
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
felipemontoya marked this conversation as resolved.
Show resolved Hide resolved
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": [
{
mariajgrimaldi marked this conversation as resolved.
Show resolved Hide resolved
"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)
Empty file.
Loading
Loading