diff --git a/lms/djangoapps/course_api/blocks/forms.py b/lms/djangoapps/course_api/blocks/forms.py index 17dee8491949565d9afc666e95f2a677a654f16c..96c7946e0b85fc439aecfa8d446713ac93aa42bd 100644 --- a/lms/djangoapps/course_api/blocks/forms.py +++ b/lms/djangoapps/course_api/blocks/forms.py @@ -3,8 +3,7 @@ Course API Forms """ -import six -from django.contrib.auth.models import User +from django.contrib.auth.models import AnonymousUser, User from django.core.exceptions import ValidationError from django.forms import CharField, ChoiceField, Form, IntegerField from django.http import Http404 @@ -49,7 +48,7 @@ class BlockListGetForm(Form): try: return int(value) except ValueError: - raise ValidationError(u"'{}' is not a valid depth value.".format(value)) + raise ValidationError("'{}' is not a valid depth value.".format(value)) def clean_requested_fields(self): """ @@ -76,7 +75,7 @@ class BlockListGetForm(Form): try: usage_key = UsageKey.from_string(usage_key) except InvalidKeyError: - raise ValidationError(u"'{}' is not a valid usage key.".format(six.text_type(usage_key))) + raise ValidationError("'{}' is not a valid usage key.".format(str(usage_key))) return usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) @@ -106,38 +105,86 @@ class BlockListGetForm(Form): cleaned_data['user'] = self._clean_requested_user(cleaned_data, usage_key.course_key) return cleaned_data + def clean_username(self): + """ + Return cleaned username. + + Overrides the default behaviour that maps an empty string to None. This + allows us to differentiate between no username being provided (None) vs + an empty username being provided (''). + """ + # In case all_blocks is specified, ignore the username. + if self.cleaned_data.get('all_blocks', False): + return None + + # See if 'username' was provided as a parameter in the raw data. + # If so, we return the already-cleaned version of that, otherwise we + # return None + if 'username' in self.data: + return self.cleaned_data['username'] + return None + def _clean_requested_user(self, cleaned_data, course_key): """ Validates and returns the requested_user, while checking permissions. """ requesting_user = self.initial['requesting_user'] requested_username = cleaned_data.get('username', None) + all_blocks = cleaned_data.get('all_blocks', False) + + if requested_username is None and not all_blocks: + raise ValidationError({'username': ["This field is required unless all_blocks is requested."]}) + + if requesting_user.is_anonymous: + return self._verify_anonymous_user(requested_username, course_key, all_blocks) - if not requested_username: - return self._verify_no_user(requesting_user, cleaned_data, course_key) + if all_blocks: + return self._verify_all_blocks(requesting_user, course_key) elif requesting_user.username.lower() == requested_username.lower(): return self._verify_requesting_user(requesting_user, course_key) else: return self._verify_other_user(requesting_user, requested_username, course_key) @staticmethod - def _verify_no_user(requesting_user, cleaned_data, course_key): + def _verify_anonymous_user(username, course_key, all_blocks): """ - Verifies form for when no username is specified, including permissions. + Verifies form for when the requesting user is anonymous. """ - # Verify that access to all blocks is requested - # (and not unintentionally requested). - if not cleaned_data.get('all_blocks', None): - raise ValidationError({'username': ['This field is required unless all_blocks is requested.']}) + if all_blocks: + raise PermissionDenied( + "Anonymous users do not have permission to access all blocks in '{course_key}'.".format( + course_key=str(course_key), + ) + ) + + # Check for '' and explicitly '' since the only valid option for anonymous users is + # an empty string that corresponds to an anonymous user. + if username != '': + raise PermissionDenied("Anonymous users cannot access another user's blocks.") + + if not permissions.is_course_public(course_key): + raise PermissionDenied( + "Course blocks for '{course_key}' cannot be accessed anonymously.".format( + course_key=course_key, + ) + ) + + return AnonymousUser() + @staticmethod + def _verify_all_blocks(requesting_user, course_key): # pylint: disable=useless-return + """ + Verifies form for when no username is specified, including permissions. + """ # Verify all blocks can be accessed for the course. if not permissions.can_access_all_blocks(requesting_user, course_key): raise PermissionDenied( - u"'{requesting_username}' does not have permission to access all blocks in '{course_key}'." - .format(requesting_username=requesting_user.username, course_key=six.text_type(course_key)) + "'{requesting_username}' does not have permission to access all blocks in '{course_key}'.".format( + requesting_username=requesting_user.username, + course_key=str(course_key), + ) ) - # return None for user return None @staticmethod @@ -147,8 +194,9 @@ class BlockListGetForm(Form): """ if not permissions.can_access_self_blocks(requesting_user, course_key): raise PermissionDenied( - u"Course blocks for '{requesting_username}' cannot be accessed." - .format(requesting_username=requesting_user.username) + "Course blocks for '{requesting_username}' cannot be accessed.".format( + requesting_username=requesting_user.username, + ) ) return requesting_user @@ -158,11 +206,18 @@ class BlockListGetForm(Form): Verifies whether the requesting user can access another user's view of the blocks in the course. """ + # If accessing a public course, and requesting only content available publicly, + # we can allow the request. + if requested_username == '' and permissions.is_course_public(course_key): + return AnonymousUser() + # Verify requesting user can access the user's blocks. if not permissions.can_access_others_blocks(requesting_user, course_key): raise PermissionDenied( - u"'{requesting_username}' does not have permission to access view for '{requested_username}'." - .format(requesting_username=requesting_user.username, requested_username=requested_username) + "'{requesting_username}' does not have permission to access view for '{requested_username}'.".format( + requesting_username=requesting_user.username, + requested_username=requested_username, + ) ) # Verify user exists. @@ -170,5 +225,7 @@ class BlockListGetForm(Form): return User.objects.get(username=requested_username) except User.DoesNotExist: raise Http404( - u"Requested user '{requested_username}' does not exist.".format(requested_username=requested_username) + "Requested user '{requested_username}' does not exist.".format( + requested_username=requested_username, + ) ) diff --git a/lms/djangoapps/course_api/blocks/permissions.py b/lms/djangoapps/course_api/blocks/permissions.py index e2acbea2bc097c96da9eb6fcad24e8958d849879..3d0e2c467037feeff0ec232b88209846a04d5f32 100644 --- a/lms/djangoapps/course_api/blocks/permissions.py +++ b/lms/djangoapps/course_api/blocks/permissions.py @@ -1,11 +1,16 @@ """ Encapsulates permissions checks for Course Blocks API """ - +from django.contrib.auth.models import User +from opaque_keys.edx.keys import CourseKey from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.access_response import AccessResponse +from lms.djangoapps.courseware.access_utils import ACCESS_DENIED, ACCESS_GRANTED, check_public_access +from lms.djangoapps.courseware.courses import get_course from student.models import CourseEnrollment from student.roles import CourseStaffRole +from xmodule.course_module import COURSE_VISIBILITY_PUBLIC def can_access_all_blocks(requesting_user, course_key): @@ -24,11 +29,25 @@ def can_access_others_blocks(requesting_user, course_key): return has_access(requesting_user, CourseStaffRole.ROLE, course_key) -def can_access_self_blocks(requesting_user, course_key): +def can_access_self_blocks(requesting_user: User, course_key: CourseKey) -> AccessResponse: """ Returns whether the requesting_user can access own blocks. """ - return ( + user_is_enrolled_or_staff = ( # pylint: disable=consider-using-ternary (requesting_user.id and CourseEnrollment.is_enrolled(requesting_user, course_key)) or has_access(requesting_user, CourseStaffRole.ROLE, course_key) ) + if user_is_enrolled_or_staff: + return ACCESS_GRANTED + try: + return is_course_public(course_key) + except ValueError: + return ACCESS_DENIED + + +def is_course_public(course_key: CourseKey) -> AccessResponse: + """ + This checks if a course is publicly accessible or not. + """ + course = get_course(course_key, depth=0) + return check_public_access(course, [COURSE_VISIBILITY_PUBLIC]) diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index 811cbc87c90b14e63105a14a27f79694f5751f90..cbb9325f62aad60f1b8f2d2f4f9ae8d644439c7e 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -4,9 +4,11 @@ Tests for Blocks Views from datetime import datetime +from unittest import mock +from unittest.mock import Mock +from urllib.parse import urlencode, urlunparse -import six -from six.moves.urllib.parse import urlencode, urlunparse +from django.conf import settings from django.urls import reverse from opaque_keys.edx.locator import CourseLocator @@ -38,7 +40,7 @@ class TestBlocksView(SharedModuleStoreTestCase): cls.course_usage_key = cls.store.make_course_usage_key(cls.course_key) cls.non_orphaned_block_usage_keys = set( - six.text_type(item.location) + str(item.location) for item in cls.store.get_items(cls.course_key) # remove all orphaned items in the course, except for the root 'course' block if cls.store.get_parent_location(item.location) or item.category == 'course' @@ -49,17 +51,18 @@ class TestBlocksView(SharedModuleStoreTestCase): # create and enroll user in the toy course self.user = UserFactory.create() + self.admin_user = AdminFactory.create() self.client.login(username=self.user.username, password='test') CourseEnrollmentFactory.create(user=self.user, course_id=self.course_key) # default values for url and query_params self.url = reverse( 'blocks_in_block_tree', - kwargs={'usage_key_string': six.text_type(self.course_usage_key)} + kwargs={'usage_key_string': str(self.course_usage_key)} ) self.query_params = {'depth': 'all', 'username': self.user.username} - def verify_response(self, expected_status_code=200, params=None, url=None): + def verify_response(self, expected_status_code=200, params=None, url=None, cacheable=False): """ Ensure that sending a GET request to the specified URL returns the expected status code. @@ -77,7 +80,13 @@ class TestBlocksView(SharedModuleStoreTestCase): if params: self.query_params.update(params) response = self.client.get(url or self.url, self.query_params) - self.assertEqual(response.status_code, expected_status_code) + self.assertEqual(response.status_code, expected_status_code, str(response.content)) + if cacheable: + assert response.get('Cache-Control', None) == 'max-age={}'.format( + settings.CACHE_MIDDLEWARE_SECONDS + ) + else: + assert response.get('Cache-Control', None) is None return response def verify_response_block_list(self, response): @@ -94,7 +103,7 @@ class TestBlocksView(SharedModuleStoreTestCase): Verify that the response contains the expected blocks """ self.assertSetEqual( - set(six.iterkeys(response.data['blocks'])), + set(response.data['blocks'].keys()), self.non_orphaned_block_usage_keys, ) @@ -103,7 +112,7 @@ class TestBlocksView(SharedModuleStoreTestCase): Verify the response has the expected structure """ self.verify_response_block_dict(response) - for block_key_string, block_data in six.iteritems(response.data['blocks']): + for block_key_string, block_data in response.data['blocks'].items(): block_key = deserialize_usage_key(block_key_string, self.course_key) xblock = self.store.get_item(block_key) @@ -116,7 +125,7 @@ class TestBlocksView(SharedModuleStoreTestCase): if xblock.has_children: self.assertSetEqual( - set(six.text_type(child.location) for child in xblock.get_children()), + set(str(child.location) for child in xblock.get_children()), set(block_data['children']), ) @@ -148,19 +157,97 @@ class TestBlocksView(SharedModuleStoreTestCase): else: self.assertFalse(expression) - def test_not_authenticated(self): + def test_not_authenticated_non_public_course_with_other_username(self): + """ + Verify behaviour when accessing course blocks of a non-public course for another user anonymously. + """ + self.client.logout() + self.verify_response(403) + + def test_not_authenticated_non_public_course_with_all_blocks(self): + """ + Verify behaviour when accessing all course blocks of a non-public course anonymously. + """ self.client.logout() - self.verify_response(401) + self.query_params.pop('username') + self.query_params['all_blocks'] = True + self.verify_response(403) - def test_not_enrolled(self): + def test_not_authenticated_non_public_course_with_blank_username(self): + """ + Verify behaviour when accessing course blocks of a non-public course for anonymous user anonymously. + """ + self.client.logout() + self.query_params['username'] = '' + self.verify_response(403) + + @mock.patch("course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) + def test_not_authenticated_public_course_with_other_username(self): + """ + Verify behaviour when accessing course blocks of a public course for another user anonymously. + """ + self.client.logout() + self.verify_response(403) + + @mock.patch("course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) + def test_not_authenticated_public_course_with_all_blocks(self): + """ + Verify behaviour when accessing all course blocks of a public course anonymously. + """ + self.client.logout() + self.query_params.pop('username') + self.query_params['all_blocks'] = True + self.verify_response(403) + + @mock.patch("course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) + def test_not_authenticated_public_course_with_blank_username(self): + """ + Verify behaviour when accessing course blocks of a public course for anonymous user anonymously. + """ + self.client.logout() + self.query_params['username'] = '' + self.verify_response(cacheable=True) + + def test_not_enrolled_non_public_course(self): + """ + Verify behaviour when accessing course blocks for a non-public course as a user not enrolled in course. + """ CourseEnrollment.unenroll(self.user, self.course_key) self.verify_response(403) + @mock.patch("course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) + def test_not_enrolled_public_course(self): + """ + Verify behaviour when accessing course blocks for a public course as a user not enrolled in course. + """ + self.query_params['username'] = '' + CourseEnrollment.unenroll(self.user, self.course_key) + self.verify_response(cacheable=True) + + @mock.patch("course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) + def test_public_course_all_blocks_and_empty_username(self): + """ + Verify behaviour when specifying both all_blocks and username='', and ensure the response is not cached. + """ + self.query_params['username'] = '' + self.query_params['all_blocks'] = True + # Verify response for a regular user. + self.verify_response(403, cacheable=False) + # Verify response for an unenrolled user. + CourseEnrollment.unenroll(self.user, self.course_key) + self.verify_response(403, cacheable=False) + # Verify response for an anonymous user. + self.client.logout() + self.verify_response(403, cacheable=False) + # Verify response for a staff user. + self.client.login(username=self.admin_user.username, password='test') + self.verify_response(cacheable=False) + def test_non_existent_course(self): usage_key = self.store.make_course_usage_key(CourseLocator('non', 'existent', 'course')) url = reverse( 'blocks_in_block_tree', - kwargs={'usage_key_string': six.text_type(usage_key)} + kwargs={'usage_key_string': str(usage_key)} ) self.verify_response(403, url=url) @@ -174,16 +261,16 @@ class TestBlocksView(SharedModuleStoreTestCase): self.verify_response(400) def test_no_user_staff_all_blocks(self): - self.client.login(username=AdminFactory.create().username, password='test') + self.client.login(username=self.admin_user.username, password='test') self.query_params.pop('username') self.query_params['all_blocks'] = True self.verify_response() def test_basic(self): response = self.verify_response() - self.assertEqual(response.data['root'], six.text_type(self.course_usage_key)) + self.assertEqual(response.data['root'], str(self.course_usage_key)) self.verify_response_block_dict(response) - for block_key_string, block_data in six.iteritems(response.data['blocks']): + for block_key_string, block_data in response.data['blocks'].items(): block_key = deserialize_usage_key(block_key_string, self.course_key) self.assertEqual(block_data['id'], block_key_string) self.assertEqual(block_data['type'], block_key.block_type) @@ -196,7 +283,7 @@ class TestBlocksView(SharedModuleStoreTestCase): def test_block_counts_param(self): response = self.verify_response(params={'block_counts': ['course', 'chapter']}) self.verify_response_block_dict(response) - for block_data in six.itervalues(response.data['blocks']): + for block_data in response.data['blocks'].values(): self.assertEqual( block_data['block_counts']['course'], 1 if block_data['type'] == 'course' else 0, @@ -215,7 +302,7 @@ class TestBlocksView(SharedModuleStoreTestCase): 'student_view_data': self.BLOCK_TYPES_WITH_STUDENT_VIEW_DATA + ['chapter'] }) self.verify_response_block_dict(response) - for block_data in six.itervalues(response.data['blocks']): + for block_data in response.data['blocks'].values(): self.assert_in_iff( 'student_view_data', block_data, @@ -233,12 +320,13 @@ class TestBlocksView(SharedModuleStoreTestCase): - other_course_settings - course_visibility """ + self.client.login(username=self.admin_user.username, password='test') response = self.verify_response(params={ 'all_blocks': True, 'requested_fields': ['other_course_settings', 'course_visibility'], }) self.verify_response_block_dict(response) - for block_data in six.itervalues(response.data['blocks']): + for block_data in response.data['blocks'].values(): self.assert_in_iff( 'other_course_settings', block_data, @@ -264,12 +352,13 @@ class TestBlocksView(SharedModuleStoreTestCase): - other_course_settings - course_visibility """ + self.client.login(username=self.admin_user.username, password='test') response = self.verify_response(params={ 'all_blocks': True, 'requested_fields': ['course_visibility'], }) self.verify_response_block_dict(response) - for block_data in six.itervalues(response.data['blocks']): + for block_data in response.data['blocks'].values(): self.assertNotIn( 'other_course_settings', block_data @@ -284,7 +373,7 @@ class TestBlocksView(SharedModuleStoreTestCase): def test_navigation_param(self): response = self.verify_response(params={'nav_depth': 10}) self.verify_response_block_dict(response) - for block_data in six.itervalues(response.data['blocks']): + for block_data in response.data['blocks'].values(): self.assertIn('descendants', block_data) def test_requested_fields_param(self): @@ -314,7 +403,7 @@ class TestBlocksInCourseView(TestBlocksView): def setUp(self): super(TestBlocksInCourseView, self).setUp() self.url = reverse('blocks_in_course') - self.query_params['course_id'] = six.text_type(self.course_key) + self.query_params['course_id'] = str(self.course_key) def test_no_course_id(self): self.query_params.pop('course_id') @@ -324,4 +413,4 @@ class TestBlocksInCourseView(TestBlocksView): self.verify_response(400, params={'course_id': 'invalid_course_id'}) def test_non_existent_course(self): - self.verify_response(403, params={'course_id': six.text_type(CourseLocator('non', 'existent', 'course'))}) + self.verify_response(403, params={'course_id': str(CourseLocator('non', 'existent', 'course'))}) diff --git a/lms/djangoapps/course_api/blocks/views.py b/lms/djangoapps/course_api/blocks/views.py index b9f1b9eb2e836bcc1ad97ff40a6f3133fe14c731..6f2421fcde94fb0b41f1c5ba393dc752df5e70da 100644 --- a/lms/djangoapps/course_api/blocks/views.py +++ b/lms/djangoapps/course_api/blocks/views.py @@ -7,6 +7,7 @@ import six from django.core.exceptions import ValidationError from django.db import transaction from django.http import Http404 +from django.utils.cache import patch_response_headers from django.utils.decorators import method_decorator from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -22,8 +23,8 @@ from .api import get_blocks from .forms import BlockListGetForm -@view_auth_classes() @method_decorator(transaction.non_atomic_requests, name='dispatch') +@view_auth_classes(is_authenticated=False) class BlocksView(DeveloperErrorViewMixin, ListAPIView): """ **Use Case** @@ -53,12 +54,16 @@ class BlocksView(DeveloperErrorViewMixin, ListAPIView): * username: (string) Required, unless ``all_blocks`` is specified. Specify the username for the user whose course blocks are requested. - Only users with course staff permissions can specify other users' - usernames. If a username is specified, results include blocks that - are visible to that user, including those based on group or cohort - membership or randomized content assigned to that user. + A blank/empty username can be used to request the blocks accessible + to anonymous users (for public courses). Only users with course staff + permissions can specify other users' usernames. If a username is + specified, results include blocks that are visible to that user, + including those based on group or cohort membership or randomized + content assigned to that user. Example: username=anjali + username='' + username * student_view_data: (list) Indicates for which block types to return student_view_data. @@ -211,7 +216,7 @@ class BlocksView(DeveloperErrorViewMixin, ListAPIView): raise ValidationError(params.errors) try: - return Response( + response = Response( get_blocks( request, params.cleaned_data['usage_key'], @@ -226,11 +231,17 @@ class BlocksView(DeveloperErrorViewMixin, ListAPIView): hide_access_denials=hide_access_denials, ) ) + # If the username is an empty string, and not None, then we are requesting + # data about the anonymous view of a course, which can be cached. In this + # case we add the usual caching headers to the response. + if params.cleaned_data.get('username', None) == '': + patch_response_headers(response) + return response except ItemNotFoundError as exception: raise Http404(u"Block not found: {}".format(text_type(exception))) -@view_auth_classes() +@view_auth_classes(is_authenticated=False) class BlocksInCourseView(BlocksView): """ **Use Case**