diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 4dae9f3161..fe4159fefe 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -19,6 +19,7 @@ from xblock.core import XBlock from external_auth.models import ExternalAuthMap from courseware.masquerade import is_masquerading_as_student from django.utils.timezone import UTC +from student import auth from student.roles import ( GlobalStaff, CourseStaffRole, CourseInstructorRole, OrgStaffRole, OrgInstructorRole, CourseBetaTesterRole @@ -46,6 +47,7 @@ def has_access(user, action, obj, course_key=None): - visible_to_staff_only for modules - DISABLE_START_DATES - different access for instructor, staff, course staff, and students. + - mobile_available flag for course modules user: a Django user object. May be anonymous. If none is passed, anonymous is assumed @@ -108,6 +110,8 @@ def _has_access_course_desc(user, action, course): 'load' -- load the courseware, see inside the course 'load_forum' -- can load and contribute to the forums (one access level for now) + 'load_mobile' -- can load from a mobile context + 'load_mobile_no_enrollment_check' -- can load from a mobile context without checking for enrollment 'enroll' -- enroll. Checks for enrollment window, ACCESS_REQUIRE_STAFF_FOR_COURSE, 'see_exists' -- can see that the course exists. @@ -136,6 +140,36 @@ def _has_access_course_desc(user, action, course): ) ) + def can_load_mobile(): + """ + Can this user access this course from a mobile device? + """ + return ( + # check mobile requirements + can_load_mobile_no_enroll_check() and + # check enrollment + ( + CourseEnrollment.is_enrolled(user, course.id) or + _has_staff_access_to_descriptor(user, course, course.id) + ) + ) + + def can_load_mobile_no_enroll_check(): + """ + Can this enrolled user access this course from a mobile device? + Note: does not check for enrollment since it is assumed the caller has done so. + """ + return ( + # check start date + can_load() and + # check mobile_available flag + ( + course.mobile_available or + auth.has_access(user, CourseBetaTesterRole(course.id)) or + _has_staff_access_to_descriptor(user, course, course.id) + ) + ) + def can_enroll(): """ First check if restriction of enrollment by login method is enabled, both @@ -234,6 +268,8 @@ def _has_access_course_desc(user, action, course): checkers = { 'load': can_load, 'load_forum': can_load_forum, + 'load_mobile': can_load_mobile, + 'load_mobile_no_enrollment_check': can_load_mobile_no_enroll_check, 'enroll': can_enroll, 'see_exists': see_exists, 'staff': lambda: _has_staff_access_to_descriptor(user, course, course.id), diff --git a/lms/djangoapps/mobile_api/course_info/tests.py b/lms/djangoapps/mobile_api/course_info/tests.py index 68d4f11d87..2c8b989b5a 100644 --- a/lms/djangoapps/mobile_api/course_info/tests.py +++ b/lms/djangoapps/mobile_api/course_info/tests.py @@ -1,43 +1,36 @@ """ Tests for course_info """ -import json from django.conf import settings -from django.core.urlresolvers import reverse -from rest_framework.test import APITestCase -from courseware.tests.factories import UserFactory from xmodule.html_module import CourseInfoModule +from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.xml_importer import import_from_xml +from ..testutils import ( + MobileAPITestCase, MobileCourseAccessTestMixin, MobileEnrolledCourseAccessTestMixin, MobileAuthTestMixin +) -class TestCourseInfo(APITestCase): + +class TestAbout(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin): """ - Tests for /api/mobile/v0.5/course_info/... + Tests for /api/mobile/v0.5/course_info/{course_id}/about """ - def setUp(self): - super(TestCourseInfo, self).setUp() - self.user = UserFactory.create() - self.course = CourseFactory.create(mobile_available=True) - self.client.login(username=self.user.username, password='test') + REVERSE_INFO = {'name': 'course-about-detail', 'params': ['course_id']} - def test_about(self): - url = reverse('course-about-detail', kwargs={'course_id': unicode(self.course.id)}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertTrue('overview' in response.data) # pylint: disable=maybe-no-member + def verify_success(self, response): + super(TestAbout, self).verify_success(response) + self.assertTrue('overview' in response.data) - def test_updates(self): - url = reverse('course-updates-list', kwargs={'course_id': unicode(self.course.id)}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, []) # pylint: disable=maybe-no-member + def init_course_access(self, course_id=None): + # override this method since enrollment is not required for the About endpoint. + self.login() + + def test_about_static_rewrite(self): + self.login() - def test_about_static_rewrites(self): about_usage_key = self.course.id.make_usage_key('about', 'overview') about_module = modulestore().get_item(about_usage_key) underlying_about_html = about_module.data @@ -45,16 +38,24 @@ class TestCourseInfo(APITestCase): # check that we start with relative static assets self.assertIn('\"/static/', underlying_about_html) - url = reverse('course-about-detail', kwargs={'course_id': unicode(self.course.id)}) - response = self.client.get(url) - json_data = json.loads(response.content) - about_html = json_data['overview'] - # but shouldn't finish with any - self.assertEqual(response.status_code, 200) - self.assertNotIn('\"/static/', about_html) + response = self.api_response() + self.assertNotIn('\"/static/', response.data['overview']) + + +class TestUpdates(MobileAPITestCase, MobileAuthTestMixin, MobileEnrolledCourseAccessTestMixin): + """ + Tests for /api/mobile/v0.5/course_info/{course_id}/updates + """ + REVERSE_INFO = {'name': 'course-updates-list', 'params': ['course_id']} + + def verify_success(self, response): + super(TestUpdates, self).verify_success(response) + self.assertEqual(response.data, []) + + def test_updates_static_rewrite(self): + self.login_and_enroll() - def test_updates_rewrite(self): updates_usage_key = self.course.id.make_usage_key('course_info', 'updates') course_updates = modulestore().create_item( self.user.id, @@ -72,50 +73,51 @@ class TestCourseInfo(APITestCase): course_updates.items = [course_update_data] modulestore().update_item(course_updates, self.user.id) - url = reverse('course-updates-list', kwargs={'course_id': unicode(self.course.id)}) - response = self.client.get(url) + response = self.api_response() content = response.data[0]["content"] # pylint: disable=maybe-no-member - self.assertEqual(response.status_code, 200) self.assertNotIn("\"/static/", content) underlying_updates_module = modulestore().get_item(updates_usage_key) self.assertIn("\"/static/", underlying_updates_module.items[0]['content']) -class TestHandoutInfo(ModuleStoreTestCase, APITestCase): +class TestHandouts(MobileAPITestCase, MobileAuthTestMixin, MobileEnrolledCourseAccessTestMixin): """ Tests for /api/mobile/v0.5/course_info/{course_id}/handouts """ + REVERSE_INFO = {'name': 'course-handouts-list', 'params': ['course_id']} + def setUp(self): - super(TestHandoutInfo, self).setUp() - self.user = UserFactory.create() - self.client.login(username=self.user.username, password='test') + super(TestHandouts, self).setUp() + + # use toy course with handouts, and make it mobile_available course_items = import_from_xml(self.store, self.user.id, settings.COMMON_TEST_DATA_ROOT, ['toy']) self.course = course_items[0] + self.course.mobile_available = True + self.store.update_item(self.course, self.user.id) + + def verify_success(self, response): + super(TestHandouts, self).verify_success(response) + self.assertIn('Sample', response.data['handouts_html']) def test_no_handouts(self): - empty_course = CourseFactory.create(mobile_available=True) - url = reverse('course-handouts-list', kwargs={'course_id': unicode(empty_course.id)}) - response = self.client.get(url) - self.assertEqual(response.status_code, 404) + self.login_and_enroll() - def test_handout_exists(self): - url = reverse('course-handouts-list', kwargs={'course_id': unicode(self.course.id)}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) + # delete handouts in course + handouts_usage_key = self.course.id.make_usage_key('course_info', 'handouts') + with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id): + self.store.delete_item(handouts_usage_key, self.user.id) + + self.api_response(expected_response_code=404) + + def test_handouts_static_rewrites(self): + self.login_and_enroll() - def test_handout_static_rewrites(self): # check that we start with relative static assets handouts_usage_key = self.course.id.make_usage_key('course_info', 'handouts') underlying_handouts = self.store.get_item(handouts_usage_key) self.assertIn('\'/static/', underlying_handouts.data) - url = reverse('course-handouts-list', kwargs={'course_id': unicode(self.course.id)}) - response = self.client.get(url) - - json_data = json.loads(response.content) - handouts_html = json_data['handouts_html'] - # but shouldn't finish with any - self.assertNotIn('\'/static/', handouts_html) - self.assertEqual(response.status_code, 200) + response = self.api_response() + self.assertNotIn('\'/static/', response.data['handouts_html']) diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 85f90d1225..0e6a9f7861 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -2,17 +2,16 @@ Views for course info API """ from django.http import Http404 -from rest_framework import generics, permissions -from rest_framework.authentication import OAuth2Authentication, SessionAuthentication +from rest_framework import generics from rest_framework.response import Response from courseware.courses import get_course_about_section, get_course_info_section_module -from opaque_keys.edx.keys import CourseKey - -from xmodule.modulestore.django import modulestore from static_replace import make_static_urls_absolute, replace_static_urls +from ..utils import MobileView, mobile_course_access + +@MobileView() class CourseUpdatesList(generics.ListAPIView): """ **Use Case** @@ -35,12 +34,9 @@ class CourseUpdatesList(generics.ListAPIView): * id: The unique identifier of the update. """ - authentication_classes = (OAuth2Authentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated,) - def list(self, request, *args, **kwargs): - course_id = CourseKey.from_string(kwargs['course_id']) - course = modulestore().get_course(course_id) + @mobile_course_access() + def list(self, request, course, *args, **kwargs): course_updates_module = get_course_info_section_module(request, course, 'updates') update_items = reversed(getattr(course_updates_module, 'items', [])) @@ -53,13 +49,14 @@ class CourseUpdatesList(generics.ListAPIView): content = item['content'] content = replace_static_urls( content, - course_id=course_id, + course_id=course.id, static_asset_path=course.static_asset_path) item['content'] = make_static_urls_absolute(request, content) return Response(updates_to_show) +@MobileView() class CourseHandoutsList(generics.ListAPIView): """ **Use Case** @@ -74,27 +71,24 @@ class CourseHandoutsList(generics.ListAPIView): * handouts_html: The HTML for course handouts. """ - authentication_classes = (OAuth2Authentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated,) - def list(self, request, *args, **kwargs): - course_id = CourseKey.from_string(kwargs['course_id']) - course = modulestore().get_course(course_id) + @mobile_course_access() + def list(self, request, course, *args, **kwargs): course_handouts_module = get_course_info_section_module(request, course, 'handouts') if course_handouts_module: handouts_html = course_handouts_module.data handouts_html = replace_static_urls( handouts_html, - course_id=course_id, + course_id=course.id, static_asset_path=course.static_asset_path) handouts_html = make_static_urls_absolute(self.request, handouts_html) return Response({'handouts_html': handouts_html}) else: # course_handouts_module could be None if there are no handouts - # (such as while running tests) - raise Http404(u"No handouts for {}".format(unicode(course_id))) + raise Http404(u"No handouts for {}".format(unicode(course.id))) +@MobileView() class CourseAboutDetail(generics.RetrieveAPIView): """ **Use Case** @@ -109,13 +103,9 @@ class CourseAboutDetail(generics.RetrieveAPIView): * overview: The HTML for the course About page. """ - authentication_classes = (OAuth2Authentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated,) - - def get(self, request, *args, **kwargs): - course_id = CourseKey.from_string(kwargs['course_id']) - course = modulestore().get_course(course_id) + @mobile_course_access(verify_enrolled=False) + def get(self, request, course, *args, **kwargs): # There are other fields, but they don't seem to be in use. # see courses.py:get_course_about_section. # diff --git a/lms/djangoapps/mobile_api/tests.py b/lms/djangoapps/mobile_api/tests.py index ff02dc4dbb..8065be07be 100644 --- a/lms/djangoapps/mobile_api/tests.py +++ b/lms/djangoapps/mobile_api/tests.py @@ -1,55 +1,47 @@ """ -Tests for mobile API utilities +Tests for mobile API utilities. """ import ddt -from rest_framework.test import APITestCase +from mock import patch -from courseware.tests.factories import UserFactory -from student import auth - -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from .utils import mobile_available_when_enrolled - -ROLE_CASES = ( - (auth.CourseBetaTesterRole, True), - (auth.CourseStaffRole, True), - (auth.CourseInstructorRole, True), - (None, False) -) +from .utils import mobile_access_when_enrolled +from .testutils import MobileAPITestCase, ROLE_CASES @ddt.ddt -class TestMobileApiUtils(ModuleStoreTestCase, APITestCase): +class TestMobileApiUtils(MobileAPITestCase): """ Tests for mobile API utilities """ - - def setUp(self): - self.user = UserFactory.create() - @ddt.data(*ROLE_CASES) @ddt.unpack def test_mobile_role_access(self, role, should_have_access): """ Verifies that our mobile access function properly handles using roles to grant access """ - course = CourseFactory.create(mobile_available=False) + non_mobile_course = CourseFactory.create(mobile_available=False) if role: - role(course.id).add_users(self.user) - self.assertEqual(should_have_access, mobile_available_when_enrolled(course, self.user)) + role(non_mobile_course.id).add_users(self.user) + self.assertEqual(should_have_access, mobile_access_when_enrolled(non_mobile_course, self.user)) def test_mobile_explicit_access(self): """ Verifies that our mobile access function listens to the mobile_available flag as it should """ - course = CourseFactory.create(mobile_available=True) - self.assertTrue(mobile_available_when_enrolled(course, self.user)) + self.assertTrue(mobile_access_when_enrolled(self.course, self.user)) def test_missing_course(self): """ Verifies that we handle the case where a course doesn't exist """ - self.assertFalse(mobile_available_when_enrolled(None, self.user)) + self.assertFalse(mobile_access_when_enrolled(None, self.user)) + + @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) + def test_unreleased_course(self): + """ + Verifies that we handle the case where a course hasn't started + """ + self.assertFalse(mobile_access_when_enrolled(self.course, self.user)) diff --git a/lms/djangoapps/mobile_api/testutils.py b/lms/djangoapps/mobile_api/testutils.py new file mode 100644 index 0000000000..c4dbaa01ee --- /dev/null +++ b/lms/djangoapps/mobile_api/testutils.py @@ -0,0 +1,204 @@ +""" +Test utilities for mobile API tests: + + MobileAPITestCase - Common base class with helper methods and common functionality. + No tests are implemented in this base class. + + Test Mixins to be included by concrete test classes and provide implementation of common test methods: + MobileAuthTestMixin - tests for APIs with MobileView/mobile_view and is_user=False. + MobileAuthUserTestMixin - tests for APIs with MobileView/mobile_view and is_user=True. + MobileCourseAccessTestMixin - tests for APIs with mobile_course_access and verify_enrolled=False. + MobileEnrolledCourseAccessTestMixin - tests for APIs with mobile_course_access and verify_enrolled=True. +""" +# pylint: disable=no-member +import ddt +from mock import patch +from rest_framework.test import APITestCase +from django.core.urlresolvers import reverse + +from opaque_keys.edx.keys import CourseKey +from courseware.tests.factories import UserFactory + +from student import auth +from student.models import CourseEnrollment + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +# A tuple of Role Types and Boolean values that indicate whether access should be given to that role. +ROLE_CASES = ( + (auth.CourseBetaTesterRole, True), + (auth.CourseStaffRole, True), + (auth.CourseInstructorRole, True), + (None, False) +) + + +class MobileAPITestCase(ModuleStoreTestCase, APITestCase): + """ + Base class for testing Mobile APIs. + Subclasses are expected to define REVERSE_INFO to be used for django reverse URL, of the form: + REVERSE_INFO = {'name': , 'params': []} + They may also override any of the methods defined in this class to control the behavior of the TestMixins. + """ + def setUp(self): + super(MobileAPITestCase, self).setUp() + self.course = CourseFactory.create(mobile_available=True) + self.user = UserFactory.create() + self.password = 'test' + self.username = self.user.username + + def tearDown(self): + super(MobileAPITestCase, self).tearDown() + self.logout() + + def login(self): + """Login test user.""" + self.client.login(username=self.username, password=self.password) + + def logout(self): + """Logout test user.""" + self.client.logout() + + def enroll(self, course_id=None): + """Enroll test user in test course.""" + CourseEnrollment.enroll(self.user, course_id or self.course.id) + + def unenroll(self, course_id=None): + """Unenroll test user in test course.""" + CourseEnrollment.unenroll(self.user, course_id or self.course.id) + + def login_and_enroll(self, course_id=None): + """Shortcut for both login and enrollment of the user.""" + self.login() + self.enroll(course_id) + + def api_response(self, reverse_args=None, expected_response_code=200, **kwargs): + """ + Helper method for calling endpoint, verifying and returning response. + If expected_response_code is None, doesn't verify the response' status_code. + """ + url = self.reverse_url(reverse_args, **kwargs) + response = self.url_method(url, **kwargs) + if expected_response_code is not None: + self.assertEqual(response.status_code, expected_response_code) + return response + + def reverse_url(self, reverse_args=None, **kwargs): # pylint: disable=unused-argument + """Base implementation that returns URL for endpoint that's being tested.""" + reverse_args = reverse_args or {} + if 'course_id' in self.REVERSE_INFO['params']: + reverse_args.update({'course_id': unicode(kwargs.get('course_id', self.course.id))}) + if 'username' in self.REVERSE_INFO['params']: + reverse_args.update({'username': kwargs.get('username', self.user.username)}) + return reverse(self.REVERSE_INFO['name'], kwargs=reverse_args) + + def url_method(self, url, **kwargs): # pylint: disable=unused-argument + """Base implementation that returns response from the GET method of the URL.""" + return self.client.get(url) + + +class MobileAuthTestMixin(object): + """ + Test Mixin for testing APIs decorated with MobileView or mobile_view. + """ + def test_no_auth(self): + self.logout() + self.api_response(expected_response_code=401) + + +class MobileAuthUserTestMixin(MobileAuthTestMixin): + """ + Test Mixin for testing APIs related to users: mobile_view or MobileView with is_user=True. + """ + def test_invalid_user(self): + self.login_and_enroll() + self.api_response(expected_response_code=403, username='no_user') + + def test_other_user(self): + # login and enroll as the test user + self.login_and_enroll() + self.logout() + + # login and enroll as another user + other = UserFactory.create() + self.client.login(username=other.username, password='test') + self.enroll() + self.logout() + + # now login and call the API as the test user + self.login() + self.api_response(expected_response_code=403, username=other.username) + + +@ddt.ddt +class MobileCourseAccessTestMixin(object): + """ + Test Mixin for testing APIs marked with mobile_course_access. + (Use MobileEnrolledCourseAccessTestMixin when verify_enrolled is set to True.) + Subclasses are expected to inherit from MobileAPITestCase. + Subclasses can override verify_success, verify_failure, and init_course_access methods. + """ + def verify_success(self, response): + """Base implementation of verifying a successful response.""" + self.assertEqual(response.status_code, 200) + + def verify_failure(self, response): + """Base implementation of verifying a failed response.""" + self.assertEqual(response.status_code, 404) + + def init_course_access(self, course_id=None): + """Base implementation of initializing the user for each test.""" + self.login_and_enroll(course_id) + + def test_success(self): + self.init_course_access() + + response = self.api_response(expected_response_code=None) + self.verify_success(response) # allow subclasses to override verification + + def test_course_not_found(self): + non_existent_course_id = CourseKey.from_string('a/b/c') + self.init_course_access(course_id=non_existent_course_id) + + response = self.api_response(expected_response_code=None, course_id=non_existent_course_id) + self.verify_failure(response) # allow subclasses to override verification + + @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) + def test_unreleased_course(self): + self.init_course_access() + + response = self.api_response(expected_response_code=None) + self.verify_failure(response) # allow subclasses to override verification + + @ddt.data(*ROLE_CASES) + @ddt.unpack + def test_non_mobile_available(self, role, should_succeed): + self.init_course_access() + + # set mobile_available to False for the test course + self.course.mobile_available = False + self.store.update_item(self.course, self.user.id) + + # set user's role in the course + if role: + role(self.course.id).add_users(self.user) + + # call API and verify response + response = self.api_response(expected_response_code=None) + if should_succeed: + self.verify_success(response) + else: + self.verify_failure(response) + + +class MobileEnrolledCourseAccessTestMixin(MobileCourseAccessTestMixin): + """ + Test Mixin for testing APIs marked with mobile_course_access with verify_enrolled=True. + """ + def test_unenrolled_user(self): + self.login() + self.unenroll() + response = self.api_response(expected_response_code=None) + self.verify_failure(response) diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 62d1d1f677..2b28a20d4e 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -3,149 +3,74 @@ Tests for users API """ import datetime -import ddt -import json - -from rest_framework.test import APITestCase -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.django import modulestore -from courseware.tests.factories import UserFactory -from django.core.urlresolvers import reverse from django.utils import timezone -from mobile_api.users.serializers import CourseEnrollmentSerializer -from mobile_api import errors + +from xmodule.modulestore.tests.factories import ItemFactory +from xmodule.modulestore.django import modulestore from student.models import CourseEnrollment -from mobile_api.tests import ROLE_CASES + +from .. import errors +from ..testutils import MobileAPITestCase, MobileAuthTestMixin, MobileAuthUserTestMixin, MobileEnrolledCourseAccessTestMixin +from .serializers import CourseEnrollmentSerializer -@ddt.ddt -class TestUserApi(ModuleStoreTestCase, APITestCase): +class TestUserDetailApi(MobileAPITestCase, MobileAuthUserTestMixin): """ - Test the user info API + Tests for /api/mobile/v0.5/users/... """ - def setUp(self): - super(TestUserApi, self).setUp() - self.course = CourseFactory.create(mobile_available=True) - self.user = UserFactory.create() - self.password = 'test' - self.username = self.user.username + REVERSE_INFO = {'name': 'user-detail', 'params': ['username']} - def tearDown(self): - super(TestUserApi, self).tearDown() - self.client.logout() + def test_success(self): + self.login() - def _enrollment_url(self): - """ - api url that gets the current user's course enrollments - """ - return reverse('courseenrollment-detail', kwargs={'username': self.user.username}) + response = self.api_response() + self.assertEqual(response.data['username'], self.user.username) + self.assertEqual(response.data['email'], self.user.email) - def _enroll(self, course): - """ - enroll test user in test course - """ - resp = self.client.post(reverse('change_enrollment'), { - 'enrollment_action': 'enroll', - 'course_id': course.id.to_deprecated_string(), - 'check_access': True, - }) - self.assertEqual(resp.status_code, 200) - def _verify_single_course_enrollment(self, course, should_succeed): - """ - check that enrolling in course adds us to it - """ +class TestUserInfoApi(MobileAPITestCase, MobileAuthTestMixin): + """ + Tests for /api/mobile/v0.5/my_user_info + """ + def reverse_url(self, reverse_args=None, **kwargs): + return '/api/mobile/v0.5/my_user_info' - url = self._enrollment_url() - self.client.login(username=self.username, password=self.password) - self._enroll(course) - response = self.client.get(url) + def test_success(self): + """Verify the endpoint redirects to the user detail endpoint""" + self.login() - courses = response.data # pylint: disable=maybe-no-member - - self.assertEqual(response.status_code, 200) - - if should_succeed: - self.assertEqual(len(courses), 1) - found_course = courses[0]['course'] - self.assertTrue('video_outline' in found_course) - self.assertTrue('course_handouts' in found_course) - self.assertEqual(found_course['id'], unicode(course.id)) - self.assertEqual(courses[0]['mode'], 'honor') - else: - self.assertEqual(len(courses), 0) - - @ddt.data(*ROLE_CASES) - @ddt.unpack - def test_non_mobile_enrollments(self, role, should_succeed): - non_mobile_course = CourseFactory.create(mobile_available=False) - - if role: - role(non_mobile_course.id).add_users(self.user) - - self._verify_single_course_enrollment(non_mobile_course, should_succeed) - - def test_mobile_enrollments(self): - self._verify_single_course_enrollment(self.course, True) - - def test_user_overview(self): - self.client.login(username=self.username, password=self.password) - url = reverse('user-detail', kwargs={'username': self.user.username}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - data = response.data # pylint: disable=maybe-no-member - self.assertEqual(data['username'], self.user.username) - self.assertEqual(data['email'], self.user.email) - - def test_overview_anon(self): - # anonymous disallowed - url = reverse('user-detail', kwargs={'username': self.user.username}) - response = self.client.get(url) - self.assertEqual(response.status_code, 401) - # can't get info on someone else - other = UserFactory.create() - self.client.login(username=self.username, password=self.password) - response = self.client.get(reverse('user-detail', kwargs={'username': other.username})) - self.assertEqual(response.status_code, 403) - - def test_redirect_userinfo(self): - url = '/api/mobile/v0.5/my_user_info' - response = self.client.get(url) - self.assertEqual(response.status_code, 401) - - self.client.login(username=self.username, password=self.password) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) + response = self.api_response(expected_response_code=302) self.assertTrue(self.username in response['location']) - def test_course_serializer(self): - self.client.login(username=self.username, password=self.password) - self._enroll(self.course) - serialized = CourseEnrollmentSerializer(CourseEnrollment.enrollments_for_user(self.user)[0]).data # pylint: disable=no-member - self.assertEqual(serialized['course']['video_outline'], None) - self.assertEqual(serialized['course']['name'], self.course.display_name) - self.assertEqual(serialized['course']['number'], self.course.id.course) - self.assertEqual(serialized['course']['org'], self.course.id.org) - def test_course_serializer_with_display_overrides(self): - self.course.display_coursenumber = "overridden_number" - self.course.display_organization = "overridden_org" - modulestore().update_item(self.course, self.user.id) +class TestUserEnrollmentApi(MobileAPITestCase, MobileAuthUserTestMixin, MobileEnrolledCourseAccessTestMixin): + """ + Tests for /api/mobile/v0.5/users//course_enrollments/ + """ + REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username']} - self.client.login(username=self.username, password=self.password) - self._enroll(self.course) - serialized = CourseEnrollmentSerializer(CourseEnrollment.enrollments_for_user(self.user)[0]).data # pylint: disable=no-member - self.assertEqual(serialized['course']['number'], self.course.display_coursenumber) - self.assertEqual(serialized['course']['org'], self.course.display_organization) + def verify_success(self, response): + super(TestUserEnrollmentApi, self).verify_success(response) + courses = response.data + self.assertEqual(len(courses), 1) -# Tests for user-course-status + found_course = courses[0]['course'] + self.assertTrue('video_outline' in found_course) + self.assertTrue('course_handouts' in found_course) + self.assertEqual(found_course['id'], unicode(self.course.id)) + self.assertEqual(courses[0]['mode'], 'honor') - def _course_status_url(self): - """ - Convenience to fetch the url for our user and course - """ - return reverse('user-course-status', kwargs={'username': self.username, 'course_id': unicode(self.course.id)}) + def verify_failure(self, response): + self.assertEqual(response.status_code, 200) + courses = response.data + self.assertEqual(len(courses), 0) + + +class CourseStatusAPITestCase(MobileAPITestCase): + """ + Base test class for /api/mobile/v0.5/users//course_status_info/{course_id} + """ + REVERSE_INFO = {'name': 'user-course-status', 'params': ['username', 'course_id']} def _setup_course_skeleton(self): """ @@ -163,154 +88,134 @@ class TestUserApi(ModuleStoreTestCase, APITestCase): other_unit = ItemFactory.create( parent_location=sub_section.location, ) - return section, sub_section, unit, other_unit - def test_course_status_course_not_found(self): - self.client.login(username=self.username, password=self.password) - url = reverse('user-course-status', kwargs={'username': self.username, 'course_id': 'a/b/c'}) - response = self.client.get(url) - json_data = json.loads(response.content) - self.assertEqual(response.status_code, 404) - self.assertEqual(json_data, errors.ERROR_INVALID_COURSE_ID) - def test_course_status_wrong_user(self): - url = reverse('user-course-status', kwargs={'username': 'other_user', 'course_id': unicode(self.course.id)}) - self.client.login(username=self.username, password=self.password) - response = self.client.get(url) - self.assertEqual(response.status_code, 403) - - def test_course_status_no_auth(self): - url = self._course_status_url() - response = self.client.get(url) - self.assertEqual(response.status_code, 401) - - def test_default_value(self): +class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin, MobileEnrolledCourseAccessTestMixin): + """ + Tests for GET of /api/mobile/v0.5/users//course_status_info/{course_id} + """ + def test_success(self): + self.login_and_enroll() (section, sub_section, unit, __) = self._setup_course_skeleton() - self.client.login(username=self.username, password=self.password) - url = self._course_status_url() - result = self.client.get(url) - json_data = json.loads(result.content) - - self.assertEqual(result.status_code, 200) - self.assertEqual(json_data["last_visited_module_id"], unicode(unit.location)) + response = self.api_response() + self.assertEqual(response.data["last_visited_module_id"], unicode(unit.location)) self.assertEqual( - json_data["last_visited_module_path"], + response.data["last_visited_module_path"], [unicode(module.location) for module in [unit, sub_section, section, self.course]] ) - def test_course_update_no_args(self): - self.client.login(username=self.username, password=self.password) - url = self._course_status_url() - result = self.client.patch(url) # pylint: disable=no-member - self.assertEqual(result.status_code, 200) +class TestCourseStatusPATCH(CourseStatusAPITestCase, MobileAuthUserTestMixin, MobileEnrolledCourseAccessTestMixin): + """ + Tests for PATCH of /api/mobile/v0.5/users//course_status_info/{course_id} + """ + def url_method(self, url, **kwargs): + # override implementation to use PATCH method. + return self.client.patch(url, data=kwargs.get('data', None)) # pylint: disable=no-member - def test_course_update(self): + def test_success(self): + self.login_and_enroll() (__, __, __, other_unit) = self._setup_course_skeleton() - self.client.login(username=self.username, password=self.password) - url = self._course_status_url() - result = self.client.patch( # pylint: disable=no-member - url, - {"last_visited_module_id": unicode(other_unit.location)} - ) - self.assertEqual(result.status_code, 200) - result = self.client.get(url) - json_data = json.loads(result.content) - self.assertEqual(result.status_code, 200) - self.assertEqual(json_data["last_visited_module_id"], unicode(other_unit.location)) + response = self.api_response(data={"last_visited_module_id": unicode(other_unit.location)}) + self.assertEqual(response.data["last_visited_module_id"], unicode(other_unit.location)) - def test_course_update_bad_module(self): - self.client.login(username=self.username, password=self.password) + def test_invalid_module(self): + self.login_and_enroll() + response = self.api_response(data={"last_visited_module_id": "abc"}, expected_response_code=400) + self.assertEqual(response.data, errors.ERROR_INVALID_MODULE_ID) - url = self._course_status_url() - result = self.client.patch( # pylint: disable=no-member - url, - {"last_visited_module_id": "abc"}, - ) - json_data = json.loads(result.content) - self.assertEqual(result.status_code, 400) - self.assertEqual(json_data, errors.ERROR_INVALID_MODULE_ID) + def test_nonexistent_module(self): + self.login_and_enroll() + non_existent_key = self.course.id.make_usage_key('video', 'non-existent') + response = self.api_response(data={"last_visited_module_id": non_existent_key}, expected_response_code=400) + self.assertEqual(response.data, errors.ERROR_INVALID_MODULE_ID) - def test_course_update_no_timezone(self): + def test_no_timezone(self): + self.login_and_enroll() (__, __, __, other_unit) = self._setup_course_skeleton() - self.client.login(username=self.username, password=self.password) - url = self._course_status_url() + past_date = datetime.datetime.now() - result = self.client.patch( # pylint: disable=no-member - url, - { + response = self.api_response( + data={ "last_visited_module_id": unicode(other_unit.location), "modification_date": past_date.isoformat() # pylint: disable=maybe-no-member }, + expected_response_code=400 ) + self.assertEqual(response.data, errors.ERROR_INVALID_MODIFICATION_DATE) - json_data = json.loads(result.content) - self.assertEqual(result.status_code, 400) - self.assertEqual(json_data, errors.ERROR_INVALID_MODIFICATION_DATE) - - def _test_course_update_date_sync(self, date, initial_unit, update_unit, expected_unit): + def _date_sync(self, date, initial_unit, update_unit, expected_unit): """ Helper for test cases that use a modification to decide whether to update the course status """ - self.client.login(username=self.username, password=self.password) - url = self._course_status_url() + self.login_and_enroll() + # save something so we have an initial date - self.client.patch( # pylint: disable=no-member - url, - {"last_visited_module_id": unicode(initial_unit.location)} - ) + self.api_response(data={"last_visited_module_id": unicode(initial_unit.location)}) # now actually update it - result = self.client.patch( # pylint: disable=no-member - url, - { + response = self.api_response( + data={ "last_visited_module_id": unicode(update_unit.location), "modification_date": date.isoformat() - }, + } ) + self.assertEqual(response.data["last_visited_module_id"], unicode(expected_unit.location)) - json_data = json.loads(result.content) - self.assertEqual(result.status_code, 200) - self.assertEqual(json_data["last_visited_module_id"], unicode(expected_unit.location)) - - def test_course_update_old_date(self): + def test_old_date(self): + self.login_and_enroll() (__, __, unit, other_unit) = self._setup_course_skeleton() date = timezone.now() + datetime.timedelta(days=-100) - self._test_course_update_date_sync(date, unit, other_unit, unit) + self._date_sync(date, unit, other_unit, unit) - def test_course_update_new_date(self): + def test_new_date(self): + self.login_and_enroll() (__, __, unit, other_unit) = self._setup_course_skeleton() date = timezone.now() + datetime.timedelta(days=100) - self._test_course_update_date_sync(date, unit, other_unit, other_unit) + self._date_sync(date, unit, other_unit, other_unit) - def test_course_update_no_initial_date(self): + def test_no_initial_date(self): + self.login_and_enroll() (__, __, _, other_unit) = self._setup_course_skeleton() - self.client.login(username=self.username, password=self.password) - url = self._course_status_url() - result = self.client.patch( # pylint: disable=no-member - url, - { + response = self.api_response( + data={ "last_visited_module_id": unicode(other_unit.location), "modification_date": timezone.now().isoformat() } ) - json_data = json.loads(result.content) - self.assertEqual(result.status_code, 200) - self.assertEqual(json_data["last_visited_module_id"], unicode(other_unit.location)) + self.assertEqual(response.data["last_visited_module_id"], unicode(other_unit.location)) - def test_course_update_invalid_date(self): - self.client.login(username=self.username, password=self.password) + def test_invalid_date(self): + self.login_and_enroll() + response = self.api_response(data={"modification_date": "abc"}, expected_response_code=400) + self.assertEqual(response.data, errors.ERROR_INVALID_MODIFICATION_DATE) - url = self._course_status_url() - result = self.client.patch( # pylint: disable=no-member - url, - {"modification_date": "abc"} - ) - json_data = json.loads(result.content) - self.assertEqual(result.status_code, 400) - self.assertEqual(json_data, errors.ERROR_INVALID_MODIFICATION_DATE) + +class TestCourseEnrollmentSerializer(MobileAPITestCase): + """ + Test the course enrollment serializer + """ + def test_success(self): + self.login_and_enroll() + + serialized = CourseEnrollmentSerializer(CourseEnrollment.enrollments_for_user(self.user)[0]).data # pylint: disable=no-member + self.assertEqual(serialized['course']['video_outline'], None) + self.assertEqual(serialized['course']['name'], self.course.display_name) + self.assertEqual(serialized['course']['number'], self.course.id.course) + self.assertEqual(serialized['course']['org'], self.course.id.org) + + def test_with_display_overrides(self): + self.login_and_enroll() + + self.course.display_coursenumber = "overridden_number" + self.course.display_organization = "overridden_org" + modulestore().update_item(self.course, self.user.id) + + serialized = CourseEnrollmentSerializer(CourseEnrollment.enrollments_for_user(self.user)[0]).data # pylint: disable=no-member + self.assertEqual(serialized['course']['number'], self.course.display_coursenumber) + self.assertEqual(serialized['course']['org'], self.course.display_organization) diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index 2a809db81f..22f59d4220 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -8,39 +8,28 @@ from courseware.module_render import get_module_for_descriptor from django.shortcuts import redirect from django.utils import dateparse -from rest_framework import generics, permissions, views -from rest_framework.authentication import OAuth2Authentication, SessionAuthentication -from rest_framework.decorators import api_view, authentication_classes, permission_classes - -from rest_framework.permissions import IsAuthenticated +from rest_framework import generics, views +from rest_framework.decorators import api_view from rest_framework.response import Response from courseware.views import get_current_child, save_positions_recursively_up -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import UsageKey from opaque_keys import InvalidKeyError from student.models import CourseEnrollment, User -from mobile_api.utils import mobile_available_when_enrolled - from xblock.fields import Scope from xblock.runtime import KeyValueStore from xmodule.modulestore.django import modulestore - +from xmodule.modulestore.exceptions import ItemNotFoundError from .serializers import CourseEnrollmentSerializer, UserSerializer from mobile_api import errors +from mobile_api.utils import mobile_access_when_enrolled, mobile_view, MobileView, mobile_course_access -class IsUser(permissions.BasePermission): - """ - Permission that checks to see if the request user matches the User models - """ - def has_object_permission(self, request, view, obj): - return request.user == obj - - +@MobileView(is_user=True) class UserDetail(generics.RetrieveAPIView): """ **Use Case** @@ -70,8 +59,6 @@ class UserDetail(generics.RetrieveAPIView): * course_enrollments: The URI to list the courses the currently logged in user is enrolled in. """ - authentication_classes = (OAuth2Authentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated, IsUser) queryset = ( User.objects.all() .select_related('profile', 'course_enrollments') @@ -80,8 +67,7 @@ class UserDetail(generics.RetrieveAPIView): lookup_field = 'username' -@authentication_classes((OAuth2Authentication, SessionAuthentication)) -@permission_classes((IsAuthenticated,)) +@MobileView(is_user=True) class UserCourseStatus(views.APIView): """ Endpoints for getting and setting meta data @@ -113,28 +99,7 @@ class UserCourseStatus(views.APIView): path.reverse() return path - def _process_arguments(self, request, username, course_id, course_handler): - """ - Checks and processes the arguments to our endpoint - then passes the processed and verified arguments on to something that - does the work specific to the individual case - """ - if username != request.user.username: - return Response(errors.ERROR_INVALID_USER_ID, status=403) - - course = None - try: - course_key = CourseKey.from_string(course_id) - course = modulestore().get_course(course_key, depth=None) - except InvalidKeyError: - pass - - if not course: - return Response(errors.ERROR_INVALID_COURSE_ID, status=404) # pylint: disable=lost-exception - - return course_handler(course) - - def get_course_info(self, request, course): + def _get_course_info(self, request, course): """ Returns the course status """ @@ -145,33 +110,16 @@ class UserCourseStatus(views.APIView): "last_visited_module_path": path_ids, }) - def get(self, request, username, course_id): - """ - **Use Case** - - Get meta data about user's status within a specific course - - **Example request**: - - GET /api/mobile/v0.5/users/{username}/course_status_info/{course_id} - - **Response Values** - - * last_visited_module_id: The id of the last module visited by the user in the given course - - * last_visited_module_path: The ids of the modules in the path from the last visited module - to the course module - """ - - return self._process_arguments(request, username, course_id, lambda course: self.get_course_info(request, course)) - def _update_last_visited_module_id(self, request, course, module_key, modification_date): """ Saves the module id if the found modification_date is less recent than the passed modification date """ field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course.id, request.user, course, depth=2) - module_descriptor = modulestore().get_item(module_key) + try: + module_descriptor = modulestore().get_item(module_key) + except ItemNotFoundError: + return Response(errors.ERROR_INVALID_MODULE_ID, status=400) module = get_module_for_descriptor(request.user, request, module_descriptor, field_data_cache, course.id) if modification_date: @@ -186,15 +134,34 @@ class UserCourseStatus(views.APIView): original_store_date = student_module.modified if modification_date < original_store_date: # old modification date so skip update - return self.get_course_info(request, course) + return self._get_course_info(request, course) - if module: - save_positions_recursively_up(request.user, request, field_data_cache, module) - return self.get_course_info(request, course) - else: - return Response(errors.ERROR_INVALID_MODULE_ID, status=400) + save_positions_recursively_up(request.user, request, field_data_cache, module) + return self._get_course_info(request, course) - def patch(self, request, username, course_id): + @mobile_course_access() + def get(self, request, course, *args, **kwargs): # pylint: disable=unused-argument + """ + **Use Case** + + Get meta data about user's status within a specific course + + **Example request**: + + GET /api/mobile/v0.5/users/{username}/course_status_info/{course_id} + + **Response Values** + + * last_visited_module_id: The id of the last module visited by the user in the given course + + * last_visited_module_path: The ids of the modules in the path from the last visited module + to the course module + """ + + return self._get_course_info(request, course) + + @mobile_course_access() + def patch(self, request, course, *args, **kwargs): # pylint: disable=unused-argument """ **Use Case** @@ -212,35 +179,30 @@ class UserCourseStatus(views.APIView): **Response Values** - The same as doing a GET on this path + The same as doing a GET on this path """ - def handle_course(course): - """ - Updates the course_status once the arguments are checked - """ - module_id = request.DATA.get("last_visited_module_id") - modification_date_string = request.DATA.get("modification_date") - modification_date = None - if modification_date_string: - modification_date = dateparse.parse_datetime(modification_date_string) - if not modification_date or not modification_date.tzinfo: - return Response(errors.ERROR_INVALID_MODIFICATION_DATE, status=400) + module_id = request.DATA.get("last_visited_module_id") + modification_date_string = request.DATA.get("modification_date") + modification_date = None + if modification_date_string: + modification_date = dateparse.parse_datetime(modification_date_string) + if not modification_date or not modification_date.tzinfo: + return Response(errors.ERROR_INVALID_MODIFICATION_DATE, status=400) - if module_id: - try: - module_key = UsageKey.from_string(module_id) - except InvalidKeyError: - return Response(errors.ERROR_INVALID_MODULE_ID, status=400) + if module_id: + try: + module_key = UsageKey.from_string(module_id) + except InvalidKeyError: + return Response(errors.ERROR_INVALID_MODULE_ID, status=400) - return self._update_last_visited_module_id(request, course, module_key, modification_date) - else: - # The arguments are optional, so if there's no argument just succeed - return self.get_course_info(request, course) - - return self._process_arguments(request, username, course_id, handle_course) + return self._update_last_visited_module_id(request, course, module_key, modification_date) + else: + # The arguments are optional, so if there's no argument just succeed + return self._get_course_info(request, course) +@MobileView(is_user=True) class UserCourseEnrollmentsList(generics.ListAPIView): """ **Use Case** @@ -274,38 +236,22 @@ class UserCourseEnrollmentsList(generics.ListAPIView): * start: The data and time the course starts. * course_image: The path to the course image. """ - authentication_classes = (OAuth2Authentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated, IsUser) queryset = CourseEnrollment.objects.all() serializer_class = CourseEnrollmentSerializer lookup_field = 'username' def get_queryset(self): - qset = self.queryset.filter( - user__username=self.kwargs['username'], is_active=True - ).order_by('created') - return mobile_course_enrollments(qset, self.request.user) + enrollments = self.queryset.filter(user__username=self.kwargs['username'], is_active=True).order_by('created') + return [ + enrollment for enrollment in enrollments + if mobile_access_when_enrolled(enrollment.course, self.request.user) + ] @api_view(["GET"]) -@authentication_classes((OAuth2Authentication, SessionAuthentication)) -@permission_classes((IsAuthenticated,)) +@mobile_view() def my_user_info(request): """ Redirect to the currently-logged-in user's info page """ return redirect("user-detail", username=request.user.username) - - -def mobile_course_enrollments(enrollments, user): - """ - Return enrollments only if courses are mobile_available (or if the user has - privileged (beta, staff, instructor) access) - - :param enrollments is a list of CourseEnrollments. - """ - for enr in enrollments: - course = enr.course - - if mobile_available_when_enrolled(course, user): - yield enr diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index afb6155f52..ccd81687e8 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -1,31 +1,91 @@ """ -Tests for video outline API +Common utility methods and decorators for Mobile APIs. """ -from courseware import access -from student.roles import CourseBetaTesterRole -from student import auth +import functools +from django.http import Http404 + +from opaque_keys.edx.keys import CourseKey +from courseware.courses import get_course_with_access +from rest_framework import permissions +from rest_framework.authentication import OAuth2Authentication, SessionAuthentication -def mobile_available_when_enrolled(course, user): +def mobile_course_access(depth=0, verify_enrolled=True): + """ + Method decorator for a mobile API endpoint that verifies the user has access to the course in a mobile context. + """ + def _decorator(func): + """Outer method decorator.""" + @functools.wraps(func) + def _wrapper(self, request, *args, **kwargs): + """ + Expects kwargs to contain 'course_id'. + Passes the course descriptor to the given decorated function. + Raises 404 if access to course is disallowed. + """ + course_id = CourseKey.from_string(kwargs.pop('course_id')) + course = get_course_with_access( + request.user, + 'load_mobile' if verify_enrolled else 'load_mobile_no_enrollment_check', + course_id, + depth=depth + ) + return func(self, request, course=course, *args, **kwargs) + return _wrapper + return _decorator + + +def mobile_access_when_enrolled(course, user): """ Determines whether a user has access to a course in a mobile context. - Checks if the course is marked as mobile_available or the user has extra permissions - that gives them access anyway. - Does not check if the user is actually enrolled in the course + Checks the mobile_available flag and the start_date. + Note: Does not check if the user is actually enrolled in the course. """ - # The course doesn't always really exist -- we can have bad data in the enrollments # pointing to non-existent (or removed) courses, in which case `course` is None. if not course: - return None + return False + try: + return get_course_with_access(user, 'load_mobile_no_enrollment_check', course.id) is not None + except Http404: + return False - # Implicitly includes instructor role via the following has_access check - beta_tester_role = CourseBetaTesterRole(course.id) - return ( - course.mobile_available - or auth.has_access(user, beta_tester_role) - or access.has_access(user, 'staff', course) - ) +def mobile_view(is_user=False): + """ + Function decorator that abstracts the authentication and permission checks for mobile api views. + """ + class IsUser(permissions.BasePermission): + """ + Permission that checks to see if the request user matches the user in the URL. + """ + def has_permission(self, request, view): + return request.user.username == request.parser_context.get('kwargs', {}).get('username', None) + + def _decorator(func): + """ + Requires either OAuth2 or Session-based authentication. + If is_user is True, also requires username in URL matches the request user. + """ + func.authentication_classes = (OAuth2Authentication, SessionAuthentication) + func.permission_classes = (permissions.IsAuthenticated,) + if is_user: + func.permission_classes += (IsUser,) + return func + return _decorator + + +class MobileView(object): + """ + Class decorator that abstracts the authentication and permission checks for mobile api views. + """ + def __init__(self, is_user=False): + self.is_user = is_user + + def __call__(self, cls): + class _Decorator(cls): + """Inner decorator class to wrap the given class.""" + mobile_view(self.is_user)(cls) + return _Decorator diff --git a/lms/djangoapps/mobile_api/video_outlines/tests.py b/lms/djangoapps/mobile_api/video_outlines/tests.py index 0a4d08d4f5..afdd1e9ce2 100644 --- a/lms/djangoapps/mobile_api/video_outlines/tests.py +++ b/lms/djangoapps/mobile_api/video_outlines/tests.py @@ -1,40 +1,23 @@ """ Tests for video outline API """ -import copy -import ddt from uuid import uuid4 from collections import namedtuple -from django.core.urlresolvers import reverse -from django.test.utils import override_settings -from django.conf import settings from edxval import api -from rest_framework.test import APITestCase - -from courseware.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.factories import ItemFactory from xmodule.video_module import transcripts_utils -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.django import modulestore -from mobile_api.tests import ROLE_CASES - -TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex +from ..testutils import MobileAPITestCase, MobileAuthTestMixin, MobileEnrolledCourseAccessTestMixin -@ddt.ddt -@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE, CONTENTSTORE=TEST_DATA_CONTENTSTORE) -class TestVideoOutline(ModuleStoreTestCase, APITestCase): +class TestVideoAPITestCase(MobileAPITestCase): """ - Tests for /api/mobile/v0.5/video_outlines/ + Base test class for video related mobile APIs """ def setUp(self): - super(TestVideoOutline, self).setUp() - self.user = UserFactory.create() - self.course = CourseFactory.create(mobile_available=True) + super(TestVideoAPITestCase, self).setUp() self.section = ItemFactory.create( parent_location=self.course.location, category="chapter", @@ -105,32 +88,6 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase): } ]}) - self.client.login(username=self.user.username, password='test') - - @ddt.data(*ROLE_CASES) - @ddt.unpack - def test_non_mobile_access(self, role, should_succeed): - nonmobile = CourseFactory.create(mobile_available=False) - - if role: - role(nonmobile.id).add_users(self.user) - - url = reverse('video-summary-list', kwargs={'course_id': unicode(nonmobile.id)}) - response = self.client.get(url) - if should_succeed: - self.assertEqual(response.status_code, 200) - else: - self.assertEqual(response.status_code, 403) - - def _get_video_summary_list(self): - """ - Calls the video-summary-list endpoint, expecting a success response - """ - url = reverse('video-summary-list', kwargs={'course_id': unicode(self.course.id)}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - return response.data # pylint: disable=maybe-no-member - def _create_video_with_subs(self): """ Creates and returns a video with stored subtitles. @@ -156,7 +113,15 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase): sub=subid ) + +class TestVideoSummaryList(TestVideoAPITestCase, MobileAuthTestMixin, MobileEnrolledCourseAccessTestMixin): + """ + Tests for /api/mobile/v0.5/video_outlines/courses/{course_id}.. + """ + REVERSE_INFO = {'name': 'video-summary-list', 'params': ['course_id']} + def test_course_list(self): + self.login_and_enroll() self._create_video_with_subs() ItemFactory.create( parent_location=self.other_unit.location, @@ -178,7 +143,7 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase): visible_to_staff_only=True, ) - course_outline = self._get_video_summary_list() + course_outline = self.api_response().data self.assertEqual(len(course_outline), 3) vid = course_outline[0] self.assertTrue('test_subsection_omega_%CE%A9' in vid['section_url']) @@ -195,18 +160,20 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase): self.assertEqual(course_outline[2]['summary']['video_url'], self.html5_video_url) self.assertEqual(course_outline[2]['summary']['size'], 0) - def test_course_list_with_nameless_unit(self): + def test_with_nameless_unit(self): + self.login_and_enroll() ItemFactory.create( parent_location=self.nameless_unit.location, category="video", edx_video_id=self.edx_video_id, display_name=u"test draft video omega 2 \u03a9" ) - course_outline = self._get_video_summary_list() + course_outline = self.api_response().data self.assertEqual(len(course_outline), 1) self.assertEqual(course_outline[0]['path'][2]['name'], self.nameless_unit.location.block_id) - def test_course_list_with_hidden_blocks(self): + def test_with_hidden_blocks(self): + self.login_and_enroll() hidden_subsection = ItemFactory.create( parent_location=self.section.location, category="sequential", @@ -231,10 +198,11 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase): category="video", edx_video_id=self.edx_video_id, ) - course_outline = self._get_video_summary_list() + course_outline = self.api_response().data self.assertEqual(len(course_outline), 0) - def test_course_list_language(self): + def test_language(self): + self.login_and_enroll() video = ItemFactory.create( parent_location=self.nameless_unit.location, category="video", @@ -258,11 +226,12 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase): for case in language_cases: video.transcripts = case.transcripts modulestore().update_item(video, self.user.id) - course_outline = self._get_video_summary_list() + course_outline = self.api_response().data self.assertEqual(len(course_outline), 1) self.assertEqual(course_outline[0]['summary']['language'], case.expected_language) - def test_course_list_transcripts(self): + def test_transcripts(self): + self.login_and_enroll() video = ItemFactory.create( parent_location=self.nameless_unit.location, category="video", @@ -290,25 +259,32 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase): video.transcripts = case.transcripts video.sub = case.english_subtitle modulestore().update_item(video, self.user.id) - course_outline = self._get_video_summary_list() + course_outline = self.api_response().data self.assertEqual(len(course_outline), 1) self.assertSetEqual( set(course_outline[0]['summary']['transcripts'].keys()), set(case.expected_transcripts) ) - def test_transcripts_detail(self): - video = self._create_video_with_subs() - kwargs = { - 'course_id': unicode(self.course.id), - 'block_id': unicode(video.scope_ids.usage_id.block_id), - 'lang': 'pl' - } - url = reverse('video-transcripts-detail', kwargs=kwargs) - response = self.client.get(url) - self.assertEqual(response.status_code, 404) - kwargs['lang'] = 'en' - url = reverse('video-transcripts-detail', kwargs=kwargs) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) +class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileEnrolledCourseAccessTestMixin): + """ + Tests for /api/mobile/v0.5/video_outlines/transcripts/{course_id}.. + """ + REVERSE_INFO = {'name': 'video-transcripts-detail', 'params': ['course_id']} + + def setUp(self): + super(TestTranscriptsDetail, self).setUp() + self.video = self._create_video_with_subs() + + def reverse_url(self, reverse_args=None, **kwargs): + reverse_args = reverse_args or {} + reverse_args.update({ + 'block_id': self.video.location.block_id, + 'lang': kwargs.get('lang', 'en'), + }) + return super(TestTranscriptsDetail, self).reverse_url(reverse_args, **kwargs) + + def test_incorrect_language(self): + self.login_and_enroll() + self.api_response(expected_response_code=404, lang='pl') diff --git a/lms/djangoapps/mobile_api/video_outlines/views.py b/lms/djangoapps/mobile_api/video_outlines/views.py index 3ed9ea4994..4876b8da4d 100644 --- a/lms/djangoapps/mobile_api/video_outlines/views.py +++ b/lms/djangoapps/mobile_api/video_outlines/views.py @@ -10,21 +10,18 @@ from functools import partial from django.http import Http404, HttpResponse -from rest_framework import generics, permissions -from rest_framework.authentication import OAuth2Authentication, SessionAuthentication +from rest_framework import generics from rest_framework.response import Response -from rest_framework.exceptions import PermissionDenied -from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import BlockUsageLocator from xmodule.exceptions import NotFoundError from xmodule.modulestore.django import modulestore -from mobile_api.utils import mobile_available_when_enrolled - +from ..utils import MobileView, mobile_course_access from .serializers import BlockOutline, video_summary +@MobileView() class VideoSummaryList(generics.ListAPIView): """ **Use Case** @@ -78,16 +75,12 @@ class VideoSummaryList(generics.ListAPIView): * size: The size of the video file """ - authentication_classes = (OAuth2Authentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated,) - - def list(self, request, *args, **kwargs): - course_id = CourseKey.from_string(kwargs['course_id']) - course = get_mobile_course(course_id, request.user) + @mobile_course_access(depth=None) + def list(self, request, course, *args, **kwargs): video_outline = list( BlockOutline( - course_id, + course.id, course, {"video": partial(video_summary, course)}, request, @@ -96,6 +89,7 @@ class VideoSummaryList(generics.ListAPIView): return Response(video_outline) +@MobileView() class VideoTranscripts(generics.RetrieveAPIView): """ **Use Case** @@ -111,16 +105,14 @@ class VideoTranscripts(generics.RetrieveAPIView): An HttpResponse with an SRT file download. """ - authentication_classes = (OAuth2Authentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated,) - def get(self, request, *args, **kwargs): - course_key = CourseKey.from_string(kwargs['course_id']) + @mobile_course_access() + def get(self, request, course, *args, **kwargs): block_id = kwargs['block_id'] lang = kwargs['lang'] usage_key = BlockUsageLocator( - course_key, block_type="video", block_id=block_id + course.id, block_type="video", block_id=block_id ) try: video_descriptor = modulestore().get_item(usage_key) @@ -132,15 +124,3 @@ class VideoTranscripts(generics.RetrieveAPIView): response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) return response - - -def get_mobile_course(course_id, user): - """ - Return only a CourseDescriptor if the course is mobile-ready or if the - requesting user is a staff member. - """ - course = modulestore().get_course(course_id, depth=None) - if mobile_available_when_enrolled(course, user): - return course - - raise PermissionDenied(detail="Course not available on mobile.")