Skip to content

Commit 1c835eb

Browse files
fix: return empty list when no courses are found for request (#35942)
This change addresses an issue reported while testing Sumac, where the API V2 is on by default in the authoring MFE: openedx/wg-build-test-release#428. It fails when retrieving an empty list of courses with the queryparams api/contentstore/v2/home/courses?page=1&order=display_name. When this was implemented, the course authoring MFE rendered the empty lists only with page=1 query param (didn't do any filtering/ordering by default), which was later changed to page=1&order=display_name which now ordered by default. This issue occurs because all the filtering and ordering are done under the assumption that course_overviews is always a query set. However, that's only true when there are courses available and CourseOverview.get_all_courses is used. When not, an empty list is returned instead, raising a 500 error in Studio.
1 parent 202d31b commit 1c835eb

File tree

3 files changed

+199
-6
lines changed

3 files changed

+199
-6
lines changed

cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
Unit tests for home page view.
33
"""
44
import ddt
5+
import pytz
56
from collections import OrderedDict
7+
from datetime import datetime, timedelta
68
from django.conf import settings
79
from django.test import override_settings
810
from django.urls import reverse
@@ -100,12 +102,13 @@ class HomePageCoursesViewTest(CourseTestCase):
100102
def setUp(self):
101103
super().setUp()
102104
self.url = reverse("cms.djangoapps.contentstore:v1:courses")
103-
CourseOverviewFactory.create(
105+
self.course_overview = CourseOverviewFactory.create(
104106
id=self.course.id,
105107
org=self.course.org,
106108
display_name=self.course.display_name,
107109
display_number_with_default=self.course.number,
108110
)
111+
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
109112

110113
def test_home_page_response(self):
111114
"""Check successful response content"""
@@ -131,6 +134,7 @@ def test_home_page_response(self):
131134
print(response.data)
132135
self.assertDictEqual(expected_response, response.data)
133136

137+
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
134138
def test_home_page_response_with_api_v2(self):
135139
"""Check successful response content with api v2 modifications.
136140
@@ -155,12 +159,88 @@ def test_home_page_response_with_api_v2(self):
155159
"in_process_course_actions": [],
156160
}
157161

158-
with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API):
159-
response = self.client.get(self.url)
162+
response = self.client.get(self.url)
160163

161164
self.assertEqual(response.status_code, status.HTTP_200_OK)
162165
self.assertDictEqual(expected_response, response.data)
163166

167+
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
168+
@ddt.data(
169+
("active_only", "true", 2, 0),
170+
("archived_only", "true", 0, 1),
171+
("search", "sample", 1, 0),
172+
("search", "demo", 0, 1),
173+
("order", "org", 2, 1),
174+
("order", "display_name", 2, 1),
175+
("order", "number", 2, 1),
176+
("order", "run", 2, 1)
177+
)
178+
@ddt.unpack
179+
def test_filter_and_ordering_courses(
180+
self,
181+
filter_key,
182+
filter_value,
183+
expected_active_length,
184+
expected_archived_length
185+
):
186+
"""Test home page with org filter and ordering for a staff user.
187+
188+
The test creates an active/archived course, and then filters/orders them using the query parameters.
189+
"""
190+
archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run")
191+
CourseOverviewFactory.create(
192+
display_name="Course (Demo)",
193+
id=archived_course_key,
194+
org=archived_course_key.org,
195+
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
196+
)
197+
active_course_key = self.store.make_course_key("sample-org", "sample-number", "sample-run")
198+
CourseOverviewFactory.create(
199+
display_name="Course (Sample)",
200+
id=active_course_key,
201+
org=active_course_key.org,
202+
)
203+
204+
response = self.client.get(self.url, {filter_key: filter_value})
205+
206+
self.assertEqual(response.status_code, status.HTTP_200_OK)
207+
self.assertEqual(len(response.data["archived_courses"]), expected_archived_length)
208+
self.assertEqual(len(response.data["courses"]), expected_active_length)
209+
210+
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
211+
@ddt.data(
212+
("active_only", "true"),
213+
("archived_only", "true"),
214+
("search", "sample"),
215+
("order", "org"),
216+
)
217+
@ddt.unpack
218+
def test_filter_and_ordering_no_courses_staff(self, filter_key, filter_value):
219+
"""Test home page with org filter and ordering when there are no courses for a staff user."""
220+
self.course_overview.delete()
221+
222+
response = self.client.get(self.url, {filter_key: filter_value})
223+
224+
self.assertEqual(len(response.data["courses"]), 0)
225+
self.assertEqual(response.status_code, status.HTTP_200_OK)
226+
227+
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
228+
@ddt.data(
229+
("active_only", "true"),
230+
("archived_only", "true"),
231+
("search", "sample"),
232+
("order", "org"),
233+
)
234+
@ddt.unpack
235+
def test_home_page_response_no_courses_non_staff(self, filter_key, filter_value):
236+
"""Test home page with org filter and ordering when there are no courses for a non-staff user."""
237+
self.course_overview.delete()
238+
239+
response = self.non_staff_client.get(self.url)
240+
241+
self.assertEqual(len(response.data["courses"]), 0)
242+
self.assertEqual(response.status_code, status.HTTP_200_OK)
243+
164244
@override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True)
165245
def test_org_query_if_passed(self):
166246
"""Test home page when org filter passed as a query param"""

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def setUp(self):
4545
org=archived_course_key.org,
4646
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
4747
)
48+
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
4849

4950
def test_home_page_response(self):
5051
"""Get list of courses available to the logged in user.
@@ -247,3 +248,100 @@ def test_api_v2_is_disabled(self, mock_modulestore, mock_course_overview):
247248
self.assertEqual(response.status_code, status.HTTP_200_OK)
248249
mock_modulestore().get_course_summaries.assert_called_once()
249250
mock_course_overview.get_all_courses.assert_not_called()
251+
252+
@ddt.data(
253+
("active_only", "true"),
254+
("archived_only", "true"),
255+
("search", "sample"),
256+
("order", "org"),
257+
("page", 1),
258+
)
259+
@ddt.unpack
260+
def test_if_empty_list_of_courses(self, query_param, value):
261+
"""Get list of courses when no courses are available.
262+
263+
Expected result:
264+
- An empty list of courses available to the logged in user.
265+
"""
266+
self.active_course.delete()
267+
self.archived_course.delete()
268+
269+
response = self.client.get(self.api_v2_url, {query_param: value})
270+
271+
self.assertEqual(len(response.data['results']['courses']), 0)
272+
self.assertEqual(response.status_code, status.HTTP_200_OK)
273+
274+
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
275+
@ddt.data(
276+
("active_only", "true", 2, 0),
277+
("archived_only", "true", 0, 1),
278+
("search", "foo", 1, 0),
279+
("search", "demo", 0, 1),
280+
("order", "org", 2, 1),
281+
("order", "display_name", 2, 1),
282+
("order", "number", 2, 1),
283+
("order", "run", 2, 1)
284+
)
285+
@ddt.unpack
286+
def test_filter_and_ordering_courses(
287+
self,
288+
filter_key,
289+
filter_value,
290+
expected_active_length,
291+
expected_archived_length
292+
):
293+
"""Get list of courses when filter and ordering are applied.
294+
295+
This test creates two courses besides the default courses created in the setUp method.
296+
Then filters and orders them based on the filter_key and filter_value passed as query parameters.
297+
298+
Expected result:
299+
- A list of courses available to the logged in user for the specified filter and order.
300+
"""
301+
archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run")
302+
CourseOverviewFactory.create(
303+
display_name="Course (Demo)",
304+
id=archived_course_key,
305+
org=archived_course_key.org,
306+
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
307+
)
308+
active_course_key = self.store.make_course_key("foo-org", "foo-number", "foo-run")
309+
CourseOverviewFactory.create(
310+
display_name="Course (Foo)",
311+
id=active_course_key,
312+
org=active_course_key.org,
313+
)
314+
315+
response = self.client.get(self.api_v2_url, {filter_key: filter_value})
316+
317+
self.assertEqual(response.status_code, status.HTTP_200_OK)
318+
self.assertEqual(
319+
len([course for course in response.data["results"]["courses"] if course["is_active"]]),
320+
expected_active_length
321+
)
322+
self.assertEqual(
323+
len([course for course in response.data["results"]["courses"] if not course["is_active"]]),
324+
expected_archived_length
325+
)
326+
327+
@ddt.data(
328+
("active_only", "true"),
329+
("archived_only", "true"),
330+
("search", "sample"),
331+
("order", "org"),
332+
("page", 1),
333+
)
334+
@ddt.unpack
335+
def test_if_empty_list_of_courses_non_staff(self, query_param, value):
336+
"""Get list of courses when no courses are available for non-staff users.
337+
338+
Expected result:
339+
- An empty list of courses available to the logged in user.
340+
"""
341+
self.active_course.delete()
342+
self.archived_course.delete()
343+
344+
response = self.non_staff_client.get(self.api_v2_url, {query_param: value})
345+
346+
self.assertEqual(len(response.data["results"]["courses"]), 0)
347+
self.assertEqual(response.status_code, status.HTTP_200_OK)

cms/djangoapps/contentstore/views/course.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from django.contrib.auth import get_user_model
1616
from django.contrib.auth.decorators import login_required
1717
from django.core.exceptions import FieldError, PermissionDenied, ValidationError as DjangoValidationError
18+
from django.db.models import QuerySet
1819
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
1920
from django.shortcuts import redirect
2021
from django.urls import reverse
@@ -575,6 +576,10 @@ def filter_ccx(course_access):
575576

576577
if course_keys:
577578
courses_list = CourseOverview.get_all_courses(filter_={'id__in': course_keys})
579+
else:
580+
# If no course keys are found for the current user, then return without filtering
581+
# or ordering the courses list.
582+
return courses_list, []
578583

579584
search_query, order, active_only, archived_only = get_query_params_if_present(request)
580585
courses_list = get_filtered_and_ordered_courses(
@@ -588,7 +593,11 @@ def filter_ccx(course_access):
588593
return courses_list, []
589594

590595

591-
def get_courses_by_status(active_only, archived_only, course_overviews):
596+
def get_courses_by_status(
597+
active_only: bool,
598+
archived_only: bool,
599+
course_overviews: QuerySet[CourseOverview]
600+
) -> QuerySet[CourseOverview]:
592601
"""
593602
Return course overviews based on a base queryset filtered by a status.
594603
@@ -602,7 +611,10 @@ def get_courses_by_status(active_only, archived_only, course_overviews):
602611
return CourseOverview.get_courses_by_status(active_only, archived_only, course_overviews)
603612

604613

605-
def get_courses_by_search_query(search_query, course_overviews):
614+
def get_courses_by_search_query(
615+
search_query: str | None,
616+
course_overviews: QuerySet[CourseOverview]
617+
) -> QuerySet[CourseOverview]:
606618
"""Return course overviews based on a base queryset filtered by a search query.
607619
608620
Args:
@@ -614,7 +626,10 @@ def get_courses_by_search_query(search_query, course_overviews):
614626
return CourseOverview.get_courses_matching_query(search_query, course_overviews=course_overviews)
615627

616628

617-
def get_courses_order_by(order_query, course_overviews):
629+
def get_courses_order_by(
630+
order_query: str | None,
631+
course_overviews: QuerySet[CourseOverview]
632+
) -> QuerySet[CourseOverview]:
618633
"""Return course overviews based on a base queryset ordered by a query.
619634
620635
Args:

0 commit comments

Comments
 (0)