Files
edx-platform/lms/djangoapps/course_api/tests/test_views.py
Robert Raposa 9fa5443379 add course_ids api
Adds a course_ids api that can filter by user role, since the courses
api could not perform well enough for this, and returned much more
data than we need.

Additionally, adds a LazyPageNumberPagination to provide more accurate
counts in the pagination response when using LazySequence with the
queryset.

BOM-897
2020-01-31 18:02:16 -05:00

530 lines
22 KiB
Python

"""
Tests for Course API views.
"""
from datetime import datetime
from hashlib import md5
from unittest import TestCase
import ddt
import six
from six.moves import range
from django.core.exceptions import ImproperlyConfigured
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from edx_django_utils.cache import RequestCache
from search.tests.test_course_discovery import DemoCourse
from search.tests.tests import TEST_INDEX_NAME
from search.tests.utils import SearcherMixin
from waffle.testutils import override_switch
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from openedx.core.lib.api.view_utils import LazySequence
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from student.auth import add_users
from student.roles import CourseInstructorRole, CourseStaffRole
from student.tests.factories import AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..views import CourseDetailView, CourseListUserThrottle, LazyPageNumberPagination
from .mixins import TEST_PASSWORD, CourseApiFactoryMixin
class CourseApiTestViewMixin(CourseApiFactoryMixin):
"""
Mixin class for test helpers for Course API views
"""
def setup_user(self, requesting_user, make_inactive=False):
"""
log in the specified user and set its is_active field
"""
self.assertTrue(self.client.login(username=requesting_user.username, password=TEST_PASSWORD))
if make_inactive:
requesting_user.is_active = False
requesting_user.save()
def verify_response(self, expected_status_code=200, params=None, url=None):
"""
Ensure that sending a GET request to self.url returns the expected
status code (200 by default).
Arguments:
expected_status_code: (default 200)
params:
query parameters to include in the request. Can include
`username`.
Returns:
response: (HttpResponse) The response returned by the request
"""
query_params = {}
query_params.update(params or {})
response = self.client.get(url or self.url, data=query_params)
self.assertEqual(response.status_code, expected_status_code)
return response
@ddt.ddt
class CourseListViewTestCase(CourseApiTestViewMixin, SharedModuleStoreTestCase):
"""
Test responses returned from CourseListView.
"""
@classmethod
def setUpClass(cls):
super(CourseListViewTestCase, cls).setUpClass()
cls.course = cls.create_course()
cls.url = reverse('course-list')
cls.staff_user = cls.create_user(username='staff', is_staff=True)
cls.honor_user = cls.create_user(username='honor', is_staff=False)
def test_as_staff(self):
self.setup_user(self.staff_user)
self.verify_response(params={'username': self.staff_user.username})
def test_as_staff_for_honor(self):
self.setup_user(self.staff_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_honor(self):
self.setup_user(self.honor_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_honor_for_explicit_self(self):
self.setup_user(self.honor_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_honor_for_staff(self):
self.setup_user(self.honor_user)
self.verify_response(expected_status_code=403, params={'username': self.staff_user.username})
def test_as_inactive_user(self):
inactive_user = self.create_user(username='inactive', is_staff=False)
self.setup_user(inactive_user, make_inactive=True)
self.verify_response(params={'username': inactive_user.username})
def test_missing_username(self):
self.setup_user(self.honor_user)
response_to_missing_username = self.verify_response(expected_status_code=200)
self.assertIsNotNone(response_to_missing_username.data)
def test_not_logged_in(self):
self.client.logout()
self.verify_response()
def assert_throttle_configured_correctly(self, user_scope, throws_exception, expected_rate):
"""Helper to determine throttle configuration is correctly set."""
throttle = CourseListUserThrottle()
throttle.check_for_switches()
throttle.scope = user_scope
try:
rate_limit, __ = throttle.parse_rate(throttle.get_rate())
self.assertEqual(rate_limit, expected_rate)
self.assertFalse(throws_exception)
except ImproperlyConfigured:
self.assertTrue(throws_exception)
@ddt.data(('staff', False, 40), ('user', False, 20), ('unknown', True, None))
@ddt.unpack
def test_throttle_rate_default(self, user_scope, throws_exception, expected_rate):
""" Make sure throttle rate default is set correctly for different user scopes. """
self.assert_throttle_configured_correctly(user_scope, throws_exception, expected_rate)
@ddt.data(('staff', False, 10), ('user', False, 2), ('unknown', True, None))
@ddt.unpack
@override_switch('course_list_api_rate_limit.rate_limit_2', active=True)
def test_throttle_rate_2(self, user_scope, throws_exception, expected_rate):
""" Make sure throttle rate 2 is set correctly for different user scopes. """
self.assert_throttle_configured_correctly(user_scope, throws_exception, expected_rate)
@ddt.data(('staff', False, 20), ('user', False, 10), ('unknown', True, None))
@ddt.unpack
@override_switch('course_list_api_rate_limit.rate_limit_10', active=True)
def test_throttle_rate_20(self, user_scope, throws_exception, expected_rate):
""" Make sure throttle rate 20 is set correctly for different user scopes. """
self.assert_throttle_configured_correctly(user_scope, throws_exception, expected_rate)
class CourseListViewTestCaseMultipleCourses(CourseApiTestViewMixin, ModuleStoreTestCase):
"""
Test responses returned from CourseListView (with tests that modify the
courseware).
"""
ENABLED_SIGNALS = ['course_published']
def setUp(self):
super(CourseListViewTestCaseMultipleCourses, self).setUp()
self.course = self.create_course(mobile_available=False)
self.url = reverse('course-list')
self.staff_user = self.create_user(username='staff', is_staff=True)
self.honor_user = self.create_user(username='honor', is_staff=False)
def test_filter_by_org(self):
"""Verify that CourseOverviews are filtered by the provided org key."""
self.setup_user(self.staff_user)
# Create a second course to be filtered out of queries.
alternate_course = self.create_course(
org=md5(self.course.org.encode('utf-8')).hexdigest()
)
self.assertNotEqual(alternate_course.org, self.course.org)
# No filtering.
unfiltered_response = self.verify_response(params={'username': self.staff_user.username})
for org in [self.course.org, alternate_course.org]:
self.assertTrue(
any(course['org'] == org for course in unfiltered_response.data['results'])
)
# With filtering.
filtered_response = self.verify_response(params={'org': self.course.org, 'username': self.staff_user.username})
self.assertTrue(
all(course['org'] == self.course.org for course in filtered_response.data['results'])
)
def test_filter(self):
self.setup_user(self.staff_user)
# Create a second course to be filtered out of queries.
alternate_course = self.create_course(course='mobile')
test_cases = [
(None, [alternate_course, self.course]),
(dict(mobile=True), [alternate_course]),
(dict(mobile=False), [self.course]),
]
for filter_, expected_courses in test_cases:
params = {'username': self.staff_user.username}
if filter_:
params.update(filter_)
response = self.verify_response(params=params)
self.assertEqual(
{course['course_id'] for course in response.data['results']},
{six.text_type(course.id) for course in expected_courses},
u"testing course_api.views.CourseListView with filter_={}".format(filter_),
)
class CourseDetailViewTestCase(CourseApiTestViewMixin, SharedModuleStoreTestCase):
"""
Test responses returned from CourseDetailView.
"""
@classmethod
def setUpClass(cls):
super(CourseDetailViewTestCase, cls).setUpClass()
cls.course = cls.create_course()
cls.hidden_course = cls.create_course(course=u'hidden', visible_to_staff_only=True)
cls.url = reverse('course-detail', kwargs={'course_key_string': cls.course.id})
cls.hidden_url = reverse('course-detail', kwargs={'course_key_string': cls.hidden_course.id})
cls.nonexistent_url = reverse('course-detail', kwargs={'course_key_string': 'edX/nope/Fall_2014'})
cls.staff_user = cls.create_user(username='staff', is_staff=True)
cls.honor_user = cls.create_user(username='honor', is_staff=False)
def test_as_honor(self):
self.setup_user(self.honor_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_honor_for_staff(self):
self.setup_user(self.honor_user)
self.verify_response(expected_status_code=403, params={'username': self.staff_user.username})
def test_as_staff(self):
self.setup_user(self.staff_user)
self.verify_response(params={'username': self.staff_user.username})
def test_as_staff_for_honor(self):
self.setup_user(self.staff_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_anonymous_user(self):
self.verify_response(expected_status_code=200)
def test_as_inactive_user(self):
inactive_user = self.create_user(username='inactive', is_staff=False)
self.setup_user(inactive_user, make_inactive=True)
self.verify_response(params={'username': inactive_user.username})
def test_hidden_course_as_honor(self):
self.setup_user(self.honor_user)
self.verify_response(
expected_status_code=404, url=self.hidden_url, params={'username': self.honor_user.username}
)
def test_hidden_course_as_staff(self):
self.setup_user(self.staff_user)
self.verify_response(url=self.hidden_url, params={'username': self.staff_user.username})
def test_nonexistent_course(self):
self.setup_user(self.staff_user)
self.verify_response(
expected_status_code=404, url=self.nonexistent_url, params={'username': self.staff_user.username}
)
def test_invalid_course_key(self):
# Our URL patterns try to block invalid course keys. If one got
# through, this is how the view would respond.
request_factory = RequestFactory()
request = request_factory.get('/')
request.query_params = {}
request.user = self.staff_user
response = CourseDetailView().dispatch(request, course_key_string='a:b:c')
self.assertEqual(response.status_code, 400)
@override_settings(ELASTIC_FIELD_MAPPINGS={
'start_date': {'type': 'date'},
'enrollment_start': {'type': 'date'},
'enrollment_end': {'type': 'date'}
})
@override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine")
@override_settings(COURSEWARE_INDEX_NAME=TEST_INDEX_NAME)
class CourseListSearchViewTest(CourseApiTestViewMixin, ModuleStoreTestCase, SearcherMixin):
"""
Tests the search functionality of the courses API.
Similar to search.tests.test_course_discovery_views but with the course API integration.
"""
ENABLED_SIGNALS = ['course_published']
ENABLED_CACHES = ModuleStoreTestCase.ENABLED_CACHES + ['configuration']
def setUp(self):
super(CourseListSearchViewTest, self).setUp()
DemoCourse.reset_count()
self.searcher.destroy()
self.courses = [
self.create_and_index_course('OrgA', 'Find this one with the right parameter'),
self.create_and_index_course('OrgB', 'Find this one with another parameter'),
self.create_and_index_course('OrgC', 'This course has a unique search term'),
]
self.url = reverse('course-list')
self.staff_user = self.create_user(username='staff', is_staff=True)
self.honor_user = self.create_user(username='honor', is_staff=False)
self.audit_user = self.create_user(username='audit', is_staff=False)
def create_and_index_course(self, org_code, short_description):
"""
Add a course to both database and search.
Warning: A ton of gluing here! If this fails, double check both CourseListViewTestCase and MockSearchUrlTest.
"""
search_course = DemoCourse.get({
'org': org_code,
'run': '2010',
'number': 'DemoZ',
# Using the slash separated course ID bcuz `DemoCourse` isn't updated yet to new locator.
'id': '{org_code}/DemoZ/2010'.format(org_code=org_code),
'content': {
'short_description': short_description,
},
})
DemoCourse.index(self.searcher, [search_course])
org, course, run = search_course['id'].split('/')
db_course = self.create_course(
mobile_available=False,
org=org,
course=course,
run=run,
short_description=short_description,
)
return db_course
def test_list_all(self):
"""
Test without search, should list all the courses.
"""
res = self.verify_response()
self.assertIn('results', res.data)
self.assertNotEqual(res.data['results'], [])
self.assertEqual(res.data['pagination']['count'], 3) # Should list all of the 3 courses
def test_list_all_with_search_term(self):
"""
Test with search, should list only the course that matches the search term.
"""
res = self.verify_response(params={'search_term': 'unique search term'})
self.assertIn('results', res.data)
self.assertNotEqual(res.data['results'], [])
self.assertEqual(res.data['pagination']['count'], 1)
self.assertEqual(len(res.data['results']), 1) # Should return a single course
def test_too_many_courses(self):
"""
Test that search results are limited to 100 courses, and that they don't
blow up the database.
"""
ContentTypeGatingConfig.objects.create(
enabled=True,
enabled_as_of=datetime(2018, 1, 1),
)
CourseDurationLimitConfig.objects.create(
enabled=True,
enabled_as_of=datetime(2018, 1, 1),
)
course_ids = []
# Create 300 courses across 30 organizations
for org_num in range(10):
org_id = 'org{}'.format(org_num)
for course_num in range(30):
course_name = 'course{}.{}'.format(org_num, course_num)
course_run_name = 'run{}.{}'.format(org_num, course_num)
course = CourseFactory.create(org=org_id, number=course_name, run=course_run_name, emit_signals=True)
CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.VERIFIED)
course_ids.append(course.id)
self.setup_user(self.audit_user)
# These query counts were found empirically
query_counts = [63, 45, 45, 45, 45, 45, 45, 45, 45, 45, 15]
ordered_course_ids = sorted([str(cid) for cid in (course_ids + [c.id for c in self.courses])])
self.clear_caches()
for page in range(1, 12):
RequestCache.clear_all_namespaces()
with self.assertNumQueries(query_counts[page - 1]):
response = self.verify_response(params={'page': page, 'page_size': 30})
self.assertIn('results', response.data)
self.assertEqual(response.data['pagination']['count'], 303)
self.assertEqual(len(response.data['results']), 30 if page < 11 else 3)
self.assertEqual(
[c['id'] for c in response.data['results']],
ordered_course_ids[(page - 1) * 30:page * 30]
)
class CourseIdListViewTestCase(CourseApiTestViewMixin, ModuleStoreTestCase):
"""
Test responses returned from CourseIdListView (with tests that modify the courseware).
"""
ENABLED_SIGNALS = ['course_published']
def setUp(self):
super(CourseIdListViewTestCase, self).setUp()
self.course = self.create_course()
self.url = reverse('course-id-list')
self.staff_user = self.create_user(username='staff', is_staff=True)
self.honor_user = self.create_user(username='honor', is_staff=False)
self.global_admin = AdminFactory()
def test_filter_by_roles_global_staff(self):
"""
Verify that global staff are always returned all courses irregardless of role filter.
"""
self.setup_user(self.staff_user)
# Request the courses as the staff user with the different roles specified.
for role in ('staff', 'instructor'):
filtered_response = self.verify_response(params={'username': self.staff_user.username, 'role': role})
self.assertEqual(len(filtered_response.data['results']), 1)
def test_filter_by_roles_non_staff(self):
"""
Verify that a non-staff user can't access course_ids by role.
"""
self.setup_user(self.honor_user)
# Request the courses as the non-staff user with the different roles should *not* be allowed.
for role in ('staff', 'instructor'):
filtered_response = self.verify_response(params={'username': self.honor_user.username, 'role': role})
self.assertEqual(len(filtered_response.data['results']), 0)
def test_filter_by_roles_course_staff(self):
"""
Verify that course_ids are filtered by the provided roles.
"""
# Make this user a course staff user for the course.
course_staff_user = self.create_user(username='course_staff', is_staff=False)
add_users(self.global_admin, CourseStaffRole(self.course.id), course_staff_user)
# Create a second course, along with an instructor user for it.
alternate_course = self.create_course(org='test')
course_instructor_user = self.create_user(username='course_instructor', is_staff=False)
add_users(self.global_admin, CourseInstructorRole(alternate_course.id), course_instructor_user)
# Requesting the courses for which the course staff user is staff should return *only* the single course.
self.setup_user(self.staff_user)
filtered_response = self.verify_response(params={
'username': course_staff_user.username,
'role': 'staff'
})
self.assertEqual(len(filtered_response.data['results']), 1)
self.assertTrue(filtered_response.data['results'][0].startswith(self.course.org))
# The course staff user does *not* have the course instructor role on any courses.
filtered_response = self.verify_response(params={
'username': course_staff_user.username,
'role': 'instructor'
})
self.assertEqual(len(filtered_response.data['results']), 0)
# The course instructor user only has the course instructor role on one course.
filtered_response = self.verify_response(params={
'username': course_instructor_user.username,
'role': 'instructor'
})
self.assertEqual(len(filtered_response.data['results']), 1)
self.assertTrue(filtered_response.data['results'][0].startswith(alternate_course.org))
# The course instructor user has the inferred course staff role on one course.
self.setup_user(course_instructor_user)
filtered_response = self.verify_response(params={
'username': course_instructor_user.username,
'role': 'staff'
})
self.assertEqual(len(filtered_response.data['results']), 1)
self.assertTrue(filtered_response.data['results'][0].startswith(alternate_course.org))
class LazyPageNumberPaginationTestCase(TestCase):
def test_lazy_page_number_pagination(self):
number_sequence = range(20)
even_numbers_lazy_sequence = LazySequence(
(
number for number in number_sequence
if (number % 2) == 0
),
est_len=len(number_sequence)
)
expected_response = {
'results': [10, 12, 14, 16, 18],
'pagination': {
'previous': 'http://testserver/endpoint?page_size=5',
'num_pages': 2,
'next': None,
'count': 10}
}
request = RequestFactory().get('/endpoint', data={'page': 2, 'page_size': 5})
request.query_params = {'page': 2, 'page_size': 5}
pagination = LazyPageNumberPagination()
pagination.max_page_size = 5
pagination.page_size = 5
paginated_queryset = pagination.paginate_queryset(even_numbers_lazy_sequence, request)
paginated_response = pagination.get_paginated_response(paginated_queryset)
self.assertDictEqual(expected_response, paginated_response.data)