Commit 5349b55b by Nimisha Asthagiri

MA-199 Course Authorization framework in mobile API.

parent 8d577563
......@@ -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),
......
"""
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')
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 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 test_about_static_rewrites(self):
REVERSE_INFO = {'name': 'course-about-detail', 'params': ['course_id']}
def verify_success(self, response):
super(TestAbout, self).verify_success(response)
self.assertTrue('overview' in response.data)
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()
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'])
......@@ -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.
#
......
"""
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))
"""
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': <django reverse name>, 'params': [<list of params in the URL>]}
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)
"""
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):
"""
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
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 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)
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
return (
course.mobile_available
or auth.has_access(user, beta_tester_role)
or access.has_access(user, 'staff', course)
)
def __call__(self, cls):
class _Decorator(cls):
"""Inner decorator class to wrap the given class."""
mobile_view(self.is_user)(cls)
return _Decorator
"""
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')
......@@ -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.")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment