Merge pull request #6398 from edx/mobile/MA-199
MA-199 Course Authorization framework in mobile API
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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))
|
||||
|
||||
204
lms/djangoapps/mobile_api/testutils.py
Normal file
204
lms/djangoapps/mobile_api/testutils.py
Normal file
@@ -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': <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)
|
||||
@@ -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/<user_name>...
|
||||
"""
|
||||
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/<user_name>/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/<user_name>/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/<user_name>/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/<user_name>/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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user