Merge branch 'release'
Conflicts: common/lib/xmodule/xmodule/video_module/video_module.py requirements/edx/github.txt
This commit is contained in:
@@ -232,10 +232,9 @@ def get_course_about_section(course, section_key):
|
||||
raise KeyError("Invalid about key " + str(section_key))
|
||||
|
||||
|
||||
def get_course_info_section(request, course, section_key):
|
||||
def get_course_info_section_module(request, course, section_key):
|
||||
"""
|
||||
This returns the snippet of html to be rendered on the course info page,
|
||||
given the key for the section.
|
||||
This returns the course info module for a given section_key.
|
||||
|
||||
Valid keys:
|
||||
- handouts
|
||||
@@ -247,7 +246,8 @@ def get_course_info_section(request, course, section_key):
|
||||
|
||||
# Use an empty cache
|
||||
field_data_cache = FieldDataCache([], course.id, request.user)
|
||||
info_module = get_module(
|
||||
|
||||
return get_module(
|
||||
request.user,
|
||||
request,
|
||||
usage_key,
|
||||
@@ -255,10 +255,22 @@ def get_course_info_section(request, course, section_key):
|
||||
log_if_not_found=False,
|
||||
wrap_xmodule_display=False,
|
||||
static_asset_path=course.static_asset_path
|
||||
)
|
||||
)
|
||||
|
||||
def get_course_info_section(request, course, section_key):
|
||||
"""
|
||||
This returns the snippet of html to be rendered on the course info page,
|
||||
given the key for the section.
|
||||
|
||||
Valid keys:
|
||||
- handouts
|
||||
- guest_handouts
|
||||
- updates
|
||||
- guest_updates
|
||||
"""
|
||||
info_module = get_course_info_section_module(request, course, section_key)
|
||||
|
||||
html = ''
|
||||
|
||||
if info_module is not None:
|
||||
try:
|
||||
html = info_module.render(STUDENT_VIEW).content
|
||||
|
||||
@@ -15,6 +15,13 @@ from xmodule.x_module import STUDENT_VIEW
|
||||
from xmodule.tests import get_test_descriptor_system
|
||||
from xmodule.tests.test_video import VideoDescriptorTestBase
|
||||
|
||||
from edxval.api import (
|
||||
ValVideoNotFoundError,
|
||||
get_video_info,
|
||||
create_profile,
|
||||
create_video
|
||||
)
|
||||
import mock
|
||||
|
||||
from . import BaseTestXmodule
|
||||
from .test_video_xml import SOURCE_XML
|
||||
@@ -365,6 +372,250 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
|
||||
)
|
||||
|
||||
|
||||
def test_get_html_with_non_existant_edx_video_id(self):
|
||||
"""
|
||||
Tests the VideoModule get_html where a edx_video_id is given but a video is not found
|
||||
"""
|
||||
SOURCE_XML = """
|
||||
<video show_captions="true"
|
||||
display_name="A Name"
|
||||
sub="a_sub_file.srt.sjson" source="{source}"
|
||||
download_video="{download_video}"
|
||||
start_time="01:00:03" end_time="01:00:10"
|
||||
edx_video_id="{edx_video_id}"
|
||||
>
|
||||
{sources}
|
||||
</video>
|
||||
"""
|
||||
no_video_data = {
|
||||
'download_video': 'true',
|
||||
'source': 'example_source.mp4',
|
||||
'sources': """
|
||||
<source src="example.mp4"/>
|
||||
<source src="example.webm"/>
|
||||
""",
|
||||
'edx_video_id':"meow",
|
||||
'result': {
|
||||
'download_video_link': u'example_source.mp4',
|
||||
'sources': json.dumps([u'example.mp4', u'example.webm']),
|
||||
}
|
||||
}
|
||||
DATA = SOURCE_XML.format(
|
||||
download_video=no_video_data['download_video'],
|
||||
source=no_video_data['source'],
|
||||
sources=no_video_data['sources'],
|
||||
edx_video_id=no_video_data['edx_video_id']
|
||||
)
|
||||
self.initialize_module(data=DATA)
|
||||
|
||||
# Referencing a non-existent VAL ID in courseware won't cause an error --
|
||||
# it'll just fall back to the values in the VideoDescriptor.
|
||||
self.assertIn("example_source.mp4", self.item_descriptor.render(STUDENT_VIEW).content)
|
||||
|
||||
@mock.patch('edxval.api.get_video_info')
|
||||
def test_get_html_with_mocked_edx_video_id(self, mock_get_video_info):
|
||||
mock_get_video_info.return_value = {
|
||||
'url' : '/edxval/video/example',
|
||||
'edx_video_id': u'example',
|
||||
'duration': 111.0,
|
||||
'client_video_id': u'The example video',
|
||||
'encoded_videos': [
|
||||
{
|
||||
'url': u'http://www.meowmix.com',
|
||||
'file_size': 25556,
|
||||
'bitrate': 9600,
|
||||
'profile': u'desktop_mp4'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
SOURCE_XML = """
|
||||
<video show_captions="true"
|
||||
display_name="A Name"
|
||||
sub="a_sub_file.srt.sjson" source="{source}"
|
||||
download_video="{download_video}"
|
||||
start_time="01:00:03" end_time="01:00:10"
|
||||
edx_video_id="{edx_video_id}"
|
||||
>
|
||||
{sources}
|
||||
</video>
|
||||
"""
|
||||
data = {
|
||||
'download_video': 'true',
|
||||
'source': 'example_source.mp4',
|
||||
'sources': """
|
||||
<source src="example.mp4"/>
|
||||
<source src="example.webm"/>
|
||||
""",
|
||||
'edx_video_id': "mock item",
|
||||
'result': {
|
||||
'download_video_link': u'http://www.meowmix.com',
|
||||
'sources': json.dumps([u'example.mp4', u'example.webm']),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Video found for edx_video_id
|
||||
initial_context = {
|
||||
'data_dir': getattr(self, 'data_dir', None),
|
||||
'show_captions': 'true',
|
||||
'handout': None,
|
||||
'display_name': u'A Name',
|
||||
'download_video_link': None,
|
||||
'end': 3610.0,
|
||||
'id': None,
|
||||
'sources': '[]',
|
||||
'speed': 'null',
|
||||
'general_speed': 1.0,
|
||||
'start': 3603.0,
|
||||
'saved_video_position': 0.0,
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'track': None,
|
||||
'youtube_streams': '1.00:OEoXaMPEzfM',
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_api_url': 'www.youtube.com/iframe_api',
|
||||
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
|
||||
'transcript_download_format': 'srt',
|
||||
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
|
||||
'transcript_language': u'en',
|
||||
'transcript_languages': '{"en": "English"}',
|
||||
}
|
||||
|
||||
DATA = SOURCE_XML.format(
|
||||
download_video=data['download_video'],
|
||||
source=data['source'],
|
||||
sources=data['sources'],
|
||||
edx_video_id=data['edx_video_id']
|
||||
)
|
||||
self.initialize_module(data=DATA)
|
||||
context = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
|
||||
expected_context = dict(initial_context)
|
||||
expected_context.update({
|
||||
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'translation'
|
||||
).rstrip('/?'),
|
||||
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'available_translations'
|
||||
).rstrip('/?'),
|
||||
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'id': self.item_descriptor.location.html_id(),
|
||||
})
|
||||
expected_context.update(data['result'])
|
||||
|
||||
self.assertEqual(
|
||||
context,
|
||||
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
|
||||
)
|
||||
|
||||
def test_get_html_with_existing_edx_video_id(self):
|
||||
result = create_profile(
|
||||
dict(
|
||||
profile_name="desktop_mp4",
|
||||
extension="mp4",
|
||||
width=200,
|
||||
height=2001
|
||||
)
|
||||
)
|
||||
self.assertEqual(result, "desktop_mp4")
|
||||
result = create_video(
|
||||
dict(
|
||||
client_video_id="Thunder Cats",
|
||||
duration=111,
|
||||
edx_video_id="thundercats",
|
||||
encoded_videos=[
|
||||
dict(
|
||||
url="http://fake-video.edx.org/thundercats.mp4",
|
||||
file_size=9000,
|
||||
bitrate=42,
|
||||
profile="desktop_mp4",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.assertEqual(result, "thundercats")
|
||||
|
||||
SOURCE_XML = """
|
||||
<video show_captions="true"
|
||||
display_name="A Name"
|
||||
sub="a_sub_file.srt.sjson" source="{source}"
|
||||
download_video="{download_video}"
|
||||
start_time="01:00:03" end_time="01:00:10"
|
||||
edx_video_id="{edx_video_id}"
|
||||
>
|
||||
{sources}
|
||||
</video>
|
||||
"""
|
||||
data = {
|
||||
'download_video': 'true',
|
||||
'source': 'example_source.mp4',
|
||||
'sources': """
|
||||
<source src="example.mp4"/>
|
||||
<source src="example.webm"/>
|
||||
""",
|
||||
'edx_video_id':"thundercats",
|
||||
'result': {
|
||||
'download_video_link': u'http://fake-video.edx.org/thundercats.mp4',
|
||||
'sources': json.dumps([u'example.mp4', u'example.webm']),
|
||||
}
|
||||
}
|
||||
|
||||
# Video found for edx_video_id
|
||||
initial_context = {
|
||||
'data_dir': getattr(self, 'data_dir', None),
|
||||
'show_captions': 'true',
|
||||
'handout': None,
|
||||
'display_name': u'A Name',
|
||||
'download_video_link': None,
|
||||
'end': 3610.0,
|
||||
'id': None,
|
||||
'sources': '[]',
|
||||
'speed': 'null',
|
||||
'general_speed': 1.0,
|
||||
'start': 3603.0,
|
||||
'saved_video_position': 0.0,
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'track': None,
|
||||
'youtube_streams': '1.00:OEoXaMPEzfM',
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_api_url': 'www.youtube.com/iframe_api',
|
||||
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
|
||||
'transcript_download_format': 'srt',
|
||||
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
|
||||
'transcript_language': u'en',
|
||||
'transcript_languages': '{"en": "English"}',
|
||||
}
|
||||
|
||||
DATA = SOURCE_XML.format(
|
||||
download_video=data['download_video'],
|
||||
source=data['source'],
|
||||
sources=data['sources'],
|
||||
edx_video_id=data['edx_video_id']
|
||||
)
|
||||
self.initialize_module(data=DATA)
|
||||
context = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
|
||||
expected_context = dict(initial_context)
|
||||
expected_context.update({
|
||||
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'translation'
|
||||
).rstrip('/?'),
|
||||
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'available_translations'
|
||||
).rstrip('/?'),
|
||||
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'id': self.item_descriptor.location.html_id(),
|
||||
})
|
||||
expected_context.update(data['result'])
|
||||
|
||||
self.assertEqual(
|
||||
context,
|
||||
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
|
||||
)
|
||||
|
||||
@patch('xmodule.video_module.video_module.get_video_from_cdn')
|
||||
def test_get_html_cdn_source(self, mocked_get_video):
|
||||
"""
|
||||
@@ -465,6 +716,107 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
|
||||
)
|
||||
|
||||
|
||||
@patch('xmodule.video_module.video_module.get_video_from_cdn')
|
||||
def test_get_html_cdn_source(self, mocked_get_video):
|
||||
"""
|
||||
Test if sources got from CDN.
|
||||
"""
|
||||
def side_effect(*args, **kwargs):
|
||||
cdn = {
|
||||
'http://example.com/example.mp4': 'http://cdn_example.com/example.mp4',
|
||||
'http://example.com/example.webm': 'http://cdn_example.com/example.webm',
|
||||
}
|
||||
return cdn.get(args[1])
|
||||
|
||||
mocked_get_video.side_effect = side_effect
|
||||
|
||||
SOURCE_XML = """
|
||||
<video show_captions="true"
|
||||
display_name="A Name"
|
||||
sub="a_sub_file.srt.sjson" source="{source}"
|
||||
download_video="{download_video}"
|
||||
start_time="01:00:03" end_time="01:00:10"
|
||||
>
|
||||
{sources}
|
||||
</video>
|
||||
"""
|
||||
cases = [
|
||||
{
|
||||
'download_video': 'true',
|
||||
'source': 'example_source.mp4',
|
||||
'sources': """
|
||||
<source src="http://example.com/example.mp4"/>
|
||||
<source src="http://example.com/example.webm"/>
|
||||
""",
|
||||
'result': {
|
||||
'download_video_link': u'example_source.mp4',
|
||||
'sources': json.dumps(
|
||||
[
|
||||
u'http://cdn_example.com/example.mp4',
|
||||
u'http://cdn_example.com/example.webm'
|
||||
]
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
initial_context = {
|
||||
'data_dir': getattr(self, 'data_dir', None),
|
||||
'show_captions': 'true',
|
||||
'handout': None,
|
||||
'display_name': u'A Name',
|
||||
'download_video_link': None,
|
||||
'end': 3610.0,
|
||||
'id': None,
|
||||
'sources': '[]',
|
||||
'speed': 'null',
|
||||
'general_speed': 1.0,
|
||||
'start': 3603.0,
|
||||
'saved_video_position': 0.0,
|
||||
'sub': u'a_sub_file.srt.sjson',
|
||||
'track': None,
|
||||
'youtube_streams': '1.00:OEoXaMPEzfM',
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_api_url': 'www.youtube.com/iframe_api',
|
||||
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
|
||||
'transcript_download_format': 'srt',
|
||||
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
|
||||
'transcript_language': u'en',
|
||||
'transcript_languages': '{"en": "English"}',
|
||||
}
|
||||
|
||||
for data in cases:
|
||||
DATA = SOURCE_XML.format(
|
||||
download_video=data['download_video'],
|
||||
source=data['source'],
|
||||
sources=data['sources']
|
||||
)
|
||||
self.initialize_module(data=DATA)
|
||||
self.item_descriptor.xmodule_runtime.user_location = 'CN'
|
||||
|
||||
context = self.item_descriptor.render('student_view').content
|
||||
|
||||
expected_context = dict(initial_context)
|
||||
expected_context.update({
|
||||
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'translation'
|
||||
).rstrip('/?'),
|
||||
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'available_translations'
|
||||
).rstrip('/?'),
|
||||
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'id': self.item_descriptor.location.html_id(),
|
||||
})
|
||||
expected_context.update(data['result'])
|
||||
|
||||
self.assertEqual(
|
||||
context,
|
||||
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
|
||||
)
|
||||
|
||||
|
||||
class TestVideoDescriptorInitialization(BaseTestXmodule):
|
||||
"""
|
||||
Make sure that module initialization works correctly.
|
||||
|
||||
0
lms/djangoapps/mobile_api/__init__.py
Normal file
0
lms/djangoapps/mobile_api/__init__.py
Normal file
0
lms/djangoapps/mobile_api/course_info/__init__.py
Normal file
0
lms/djangoapps/mobile_api/course_info/__init__.py
Normal file
1
lms/djangoapps/mobile_api/course_info/models.py
Normal file
1
lms/djangoapps/mobile_api/course_info/models.py
Normal file
@@ -0,0 +1 @@
|
||||
# A models.py is required to make this an app (until we move to Django 1.7)
|
||||
26
lms/djangoapps/mobile_api/course_info/urls.py
Normal file
26
lms/djangoapps/mobile_api/course_info/urls.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.conf.urls import patterns, url, include
|
||||
from django.conf import settings
|
||||
from rest_framework import routers
|
||||
from rest_framework.urlpatterns import format_suffix_patterns
|
||||
|
||||
from .views import CourseAboutDetail, CourseUpdatesList, CourseHandoutsList
|
||||
|
||||
urlpatterns = patterns(
|
||||
'mobile_api.course_info.views',
|
||||
url(
|
||||
r'^{}/about$'.format(settings.COURSE_ID_PATTERN),
|
||||
CourseAboutDetail.as_view(),
|
||||
name='course-about-detail'
|
||||
),
|
||||
url(
|
||||
r'^{}/handouts$'.format(settings.COURSE_ID_PATTERN),
|
||||
CourseHandoutsList.as_view(),
|
||||
name='course-handouts-list'
|
||||
),
|
||||
url(
|
||||
r'^{}/updates$'.format(settings.COURSE_ID_PATTERN),
|
||||
CourseUpdatesList.as_view(),
|
||||
name='course-updates-list'
|
||||
),
|
||||
)
|
||||
|
||||
59
lms/djangoapps/mobile_api/course_info/views.py
Normal file
59
lms/djangoapps/mobile_api/course_info/views.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.module_render import get_module
|
||||
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 student.models import CourseEnrollment, User
|
||||
|
||||
|
||||
class CourseUpdatesList(generics.ListAPIView):
|
||||
"""Notes:
|
||||
|
||||
1. This only works for new-style course updates and is not the older freeform
|
||||
format.
|
||||
"""
|
||||
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)
|
||||
course_updates_module = get_course_info_section_module(request, course, 'updates')
|
||||
return Response(reversed(course_updates_module.items))
|
||||
|
||||
|
||||
class CourseHandoutsList(generics.ListAPIView):
|
||||
"""Please just render this in an HTML view for now.
|
||||
"""
|
||||
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)
|
||||
course_handouts_module = get_course_info_section_module(request, course, 'handouts')
|
||||
return Response({'handouts_html': course_handouts_module.data})
|
||||
|
||||
|
||||
class CourseAboutDetail(generics.RetrieveAPIView):
|
||||
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)
|
||||
|
||||
# There are other fields, but they don't seem to be in use.
|
||||
# see courses.py:get_course_about_section.
|
||||
#
|
||||
# This can also return None, so check for that before calling strip()
|
||||
about_section_html = get_course_about_section(course, "overview")
|
||||
return Response(
|
||||
{"overview": about_section_html.strip() if about_section_html else ""}
|
||||
)
|
||||
1
lms/djangoapps/mobile_api/models.py
Normal file
1
lms/djangoapps/mobile_api/models.py
Normal file
@@ -0,0 +1 @@
|
||||
# A models.py is required to make this an app (until we move to Django 1.7)
|
||||
12
lms/djangoapps/mobile_api/urls.py
Normal file
12
lms/djangoapps/mobile_api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.conf.urls import patterns, url, include
|
||||
from rest_framework import routers
|
||||
|
||||
from .users.views import my_user_info
|
||||
|
||||
# Additionally, we include login URLs for the browseable API.
|
||||
urlpatterns = patterns('',
|
||||
url(r'^users/', include('mobile_api.users.urls')),
|
||||
url(r'^my_user_info', my_user_info),
|
||||
url(r'^video_outlines/', include('mobile_api.video_outlines.urls')),
|
||||
url(r'^course_info/', include('mobile_api.course_info.urls')),
|
||||
)
|
||||
0
lms/djangoapps/mobile_api/users/__init__.py
Normal file
0
lms/djangoapps/mobile_api/users/__init__.py
Normal file
1
lms/djangoapps/mobile_api/users/models.py
Normal file
1
lms/djangoapps/mobile_api/users/models.py
Normal file
@@ -0,0 +1 @@
|
||||
# A models.py is required to make this an app (until we move to Django 1.7)
|
||||
80
lms/djangoapps/mobile_api/users/serializers.py
Normal file
80
lms/djangoapps/mobile_api/users/serializers.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
|
||||
from courseware.courses import course_image_url
|
||||
from student.models import CourseEnrollment, User
|
||||
|
||||
|
||||
class CourseField(serializers.RelatedField):
|
||||
"""Custom field to wrap a CourseDescriptor object. Read-only."""
|
||||
|
||||
def to_native(self, course):
|
||||
course_id = unicode(course.id)
|
||||
request = self.context.get('request', None)
|
||||
if request:
|
||||
video_outline_url = reverse(
|
||||
'video-summary-list',
|
||||
kwargs={'course_id': course_id},
|
||||
request=request
|
||||
)
|
||||
course_updates_url = reverse(
|
||||
'course-updates-list',
|
||||
kwargs={'course_id': course_id},
|
||||
request=request
|
||||
)
|
||||
course_handouts_url = reverse(
|
||||
'course-handouts-list',
|
||||
kwargs={'course_id': course_id},
|
||||
request=request
|
||||
)
|
||||
course_about_url = reverse(
|
||||
'course-about-detail',
|
||||
kwargs={'course_id': course_id},
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
video_outline_url = None
|
||||
course_updates_url = None
|
||||
course_handouts_url = None
|
||||
course_about_url = None
|
||||
|
||||
return {
|
||||
"id": course_id,
|
||||
"name": course.display_name,
|
||||
"number": course.number,
|
||||
"org": course.display_org_with_default,
|
||||
"start": course.start,
|
||||
"end": course.end,
|
||||
"course_image": course_image_url(course),
|
||||
"latest_updates": {
|
||||
"video": None
|
||||
},
|
||||
"video_outline": video_outline_url,
|
||||
"course_updates": course_updates_url,
|
||||
"course_handouts": course_handouts_url,
|
||||
"course_about": course_about_url,
|
||||
}
|
||||
|
||||
|
||||
class CourseEnrollmentSerializer(serializers.ModelSerializer):
|
||||
course = CourseField()
|
||||
|
||||
class Meta:
|
||||
model = CourseEnrollment
|
||||
fields = ('created', 'mode', 'is_active', 'course')
|
||||
lookup_field = 'username'
|
||||
|
||||
|
||||
class UserSerializer(serializers.HyperlinkedModelSerializer):
|
||||
name = serializers.Field(source='profile.name')
|
||||
course_enrollments = serializers.HyperlinkedIdentityField(
|
||||
view_name='courseenrollment-detail',
|
||||
lookup_field='username'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('id', 'username', 'email', 'name', 'course_enrollments')
|
||||
lookup_field = 'username'
|
||||
16
lms/djangoapps/mobile_api/users/urls.py
Normal file
16
lms/djangoapps/mobile_api/users/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.conf.urls import patterns, url, include
|
||||
|
||||
from rest_framework import routers
|
||||
from rest_framework.urlpatterns import format_suffix_patterns
|
||||
|
||||
from .views import UserDetail, UserCourseEnrollmentsList
|
||||
|
||||
urlpatterns = patterns('mobile_api.users.views',
|
||||
url(r'^(?P<username>\w+)$', UserDetail.as_view(), name='user-detail'),
|
||||
url(
|
||||
r'^(?P<username>\w+)/course_enrollments/$',
|
||||
UserCourseEnrollmentsList.as_view(),
|
||||
name='courseenrollment-detail'
|
||||
),
|
||||
)
|
||||
|
||||
79
lms/djangoapps/mobile_api/users/views.py
Normal file
79
lms/djangoapps/mobile_api/users/views.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
|
||||
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from courseware.access import has_access
|
||||
from student.forms import PasswordResetFormNoActive
|
||||
from student.models import CourseEnrollment, User
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from .serializers import CourseEnrollmentSerializer, UserSerializer
|
||||
|
||||
|
||||
class IsUser(permissions.BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return request.user == obj
|
||||
|
||||
|
||||
class UserDetail(generics.RetrieveAPIView):
|
||||
"""Read-only information about our User.
|
||||
|
||||
This will be where users are redirected to after API login and will serve
|
||||
as a place to list all useful resources this user can access.
|
||||
"""
|
||||
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
||||
permission_classes = (permissions.IsAuthenticated, IsUser)
|
||||
queryset = (
|
||||
User.objects.all()
|
||||
.select_related('profile', 'course_enrollments')
|
||||
)
|
||||
serializer_class = UserSerializer
|
||||
lookup_field = 'username'
|
||||
|
||||
|
||||
class UserCourseEnrollmentsList(generics.ListAPIView):
|
||||
"""Read-only list of courses that this user is enrolled in."""
|
||||
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)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if request.user.username != kwargs['username']:
|
||||
raise PermissionDenied
|
||||
|
||||
return super(UserCourseEnrollmentsList, self).get(self, request, *args, **kwargs)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@authentication_classes((OAuth2Authentication, SessionAuthentication))
|
||||
@permission_classes((IsAuthenticated,))
|
||||
def my_user_info(request):
|
||||
if not request.user:
|
||||
raise PermissionDenied
|
||||
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 staff access)
|
||||
enrollments is a list of CourseEnrollments.
|
||||
"""
|
||||
for enr in enrollments:
|
||||
course = enr.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 course and (course.mobile_available or has_access(user, 'staff', course)):
|
||||
yield enr
|
||||
1
lms/djangoapps/mobile_api/video_outlines/models.py
Normal file
1
lms/djangoapps/mobile_api/video_outlines/models.py
Normal file
@@ -0,0 +1 @@
|
||||
# A models.py is required to make this an app (until we move to Django 1.7)
|
||||
140
lms/djangoapps/mobile_api/video_outlines/serializers.py
Normal file
140
lms/djangoapps/mobile_api/video_outlines/serializers.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from courseware.access import has_access
|
||||
|
||||
from edxval.api import (
|
||||
get_video_info_for_course_and_profile, ValInternalError
|
||||
)
|
||||
|
||||
|
||||
class BlockOutline(object):
|
||||
|
||||
def __init__(self, course_id, start_block, categories_to_outliner, request):
|
||||
"""Create a BlockOutline using `start_block` as a starting point."""
|
||||
self.start_block = start_block
|
||||
self.categories_to_outliner = categories_to_outliner
|
||||
self.course_id = course_id
|
||||
self.request = request # needed for making full URLS
|
||||
self.local_cache = {}
|
||||
try:
|
||||
self.local_cache['course_videos'] = get_video_info_for_course_and_profile(
|
||||
unicode(course_id), "mobile_low"
|
||||
)
|
||||
except ValInternalError:
|
||||
self.local_cache['course_videos'] = {}
|
||||
|
||||
def __iter__(self):
|
||||
child_to_parent = {}
|
||||
stack = [self.start_block]
|
||||
|
||||
# path should be optional
|
||||
def path(block):
|
||||
block_path = []
|
||||
while block in child_to_parent:
|
||||
block = child_to_parent[block]
|
||||
if block is not self.start_block:
|
||||
block_path.append({
|
||||
'name': block.display_name,
|
||||
'category': block.category,
|
||||
})
|
||||
return reversed(block_path)
|
||||
|
||||
def find_urls(block):
|
||||
block_path = []
|
||||
while block in child_to_parent:
|
||||
block = child_to_parent[block]
|
||||
block_path.append(block)
|
||||
|
||||
course, chapter, section, unit = list(reversed(block_path))[:4]
|
||||
position = 1
|
||||
unit_name = unit.url_name
|
||||
for block in section.children:
|
||||
if block.name == unit_name:
|
||||
break
|
||||
position += 1
|
||||
|
||||
kwargs = dict(
|
||||
course_id=course.id.to_deprecated_string(),
|
||||
chapter=chapter.url_name,
|
||||
section=section.url_name
|
||||
)
|
||||
section_url = reverse(
|
||||
"courseware_section",
|
||||
kwargs=kwargs,
|
||||
request=self.request,
|
||||
)
|
||||
kwargs['position'] = position
|
||||
unit_url = reverse(
|
||||
"courseware_position",
|
||||
kwargs=kwargs,
|
||||
request=self.request,
|
||||
)
|
||||
return unit_url, section_url
|
||||
|
||||
user = self.request.user
|
||||
|
||||
while stack:
|
||||
curr_block = stack.pop()
|
||||
|
||||
if curr_block.category in self.categories_to_outliner:
|
||||
if not has_access(user, 'load', curr_block, course_key=self.course_id):
|
||||
continue
|
||||
|
||||
summary_fn = self.categories_to_outliner[curr_block.category]
|
||||
block_path = list(path(block))
|
||||
unit_url, section_url = find_urls(block)
|
||||
yield {
|
||||
"path": block_path,
|
||||
"named_path": [b["name"] for b in block_path[:-1]],
|
||||
"unit_url": unit_url,
|
||||
"section_url": section_url,
|
||||
"summary": summary_fn(self.course_id, curr_block, self.request, self.local_cache)
|
||||
}
|
||||
|
||||
if curr_block.has_children:
|
||||
for block in reversed(curr_block.get_children()):
|
||||
stack.append(block)
|
||||
child_to_parent[block] = curr_block
|
||||
|
||||
|
||||
def video_summary(course, course_id, video_descriptor, request, local_cache):
|
||||
# First try to check VAL for the URLs we want.
|
||||
val_video_info = local_cache['course_videos'].get(video_descriptor.edx_video_id, {})
|
||||
if val_video_info:
|
||||
video_url = val_video_info['url']
|
||||
# Then fall back to VideoDescriptor fields for video URLs
|
||||
elif video_descriptor.html5_sources:
|
||||
video_url = video_descriptor.html5_sources[0]
|
||||
else:
|
||||
video_url = video_descriptor.source
|
||||
|
||||
# If we have the video information from VAL, we also have duration and size.
|
||||
duration = val_video_info.get('duration', None)
|
||||
size = val_video_info.get('file_size', 0)
|
||||
|
||||
# Transcripts...
|
||||
transcript_langs = video_descriptor.available_translations(verify_assets=False)
|
||||
transcripts = {
|
||||
lang: reverse(
|
||||
'video-transcripts-detail',
|
||||
kwargs={
|
||||
'course_id': unicode(course_id),
|
||||
'block_id': video_descriptor.scope_ids.usage_id.block_id,
|
||||
'lang': lang
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
for lang in transcript_langs
|
||||
}
|
||||
|
||||
return {
|
||||
"video_url": video_url,
|
||||
"video_thumbnail_url": None,
|
||||
"duration": duration,
|
||||
"size": size,
|
||||
"name": video_descriptor.display_name,
|
||||
"transcripts": transcripts,
|
||||
"language": video_descriptor.transcript_language,
|
||||
"category": video_descriptor.category,
|
||||
"id": unicode(video_descriptor.scope_ids.usage_id),
|
||||
}
|
||||
20
lms/djangoapps/mobile_api/video_outlines/urls.py
Normal file
20
lms/djangoapps/mobile_api/video_outlines/urls.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.conf.urls import patterns, url, include
|
||||
from django.conf import settings
|
||||
from rest_framework import routers
|
||||
from rest_framework.urlpatterns import format_suffix_patterns
|
||||
|
||||
from .views import VideoSummaryList, VideoTranscripts
|
||||
|
||||
urlpatterns = patterns('mobile_api.video_outlines.views',
|
||||
url(
|
||||
r'^courses/{}$'.format(settings.COURSE_ID_PATTERN),
|
||||
VideoSummaryList.as_view(),
|
||||
name='video-summary-list'
|
||||
),
|
||||
url(
|
||||
r'^transcripts/{}/(?P<block_id>[^/]*)/(?P<lang>[^/]*)$'.format(settings.COURSE_ID_PATTERN),
|
||||
VideoTranscripts.as_view(),
|
||||
name='video-transcripts-detail'
|
||||
),
|
||||
)
|
||||
|
||||
86
lms/djangoapps/mobile_api/video_outlines/views.py
Normal file
86
lms/djangoapps/mobile_api/video_outlines/views.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Video Outlines
|
||||
|
||||
We only provide the listing view for a video outline, and video outlines are
|
||||
only displayed at the course level. This is because it makes it a lot easier to
|
||||
optimize and reason about, and it avoids having to tackle the bigger problem of
|
||||
general XBlock representation in this rather specialized formatting.
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http import Http404, HttpResponse
|
||||
|
||||
from rest_framework import generics, permissions
|
||||
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import BlockUsageLocator
|
||||
|
||||
from courseware.access import has_access
|
||||
from student.models import CourseEnrollment, User
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from .serializers import BlockOutline, video_summary
|
||||
|
||||
|
||||
class VideoSummaryList(generics.ListAPIView):
|
||||
"""A list of all Videos in this Course that the user has access to."""
|
||||
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)
|
||||
|
||||
video_outline = list(
|
||||
BlockOutline(
|
||||
course_id,
|
||||
course,
|
||||
{"video": partial(video_summary, course)},
|
||||
request,
|
||||
)
|
||||
)
|
||||
return Response(video_outline)
|
||||
|
||||
|
||||
class VideoTranscripts(generics.RetrieveAPIView):
|
||||
"""Read-only view for a single transcript (SRT) file for a particular language.
|
||||
|
||||
Returns an `HttpResponse` with an SRT file download for the body.
|
||||
"""
|
||||
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
course_key = CourseKey.from_string(kwargs['course_id'])
|
||||
block_id = kwargs['block_id']
|
||||
lang = kwargs['lang']
|
||||
|
||||
usage_key = BlockUsageLocator(
|
||||
course_key, block_type="video", block_id=block_id
|
||||
)
|
||||
try:
|
||||
video_descriptor = modulestore().get_item(usage_key)
|
||||
content, filename, mimetype = video_descriptor.get_transcript(lang=lang)
|
||||
except (NotFoundError, ValueError, KeyError):
|
||||
raise Http404("Transcript not found for {}, lang: {}".format(block_id, lang))
|
||||
|
||||
response = HttpResponse(content, content_type=mimetype)
|
||||
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 course.mobile_available or has_access(user, 'staff', course):
|
||||
return course
|
||||
|
||||
raise PermissionDenied(detail="Course not available on mobile.")
|
||||
@@ -287,6 +287,13 @@ FEATURES = {
|
||||
# False to not redirect the user
|
||||
'ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER': True,
|
||||
|
||||
# Expose Mobile REST API. Note that if you use this, you must also set
|
||||
# ENABLE_OAUTH2_PROVIDER to True
|
||||
'ENABLE_MOBILE_REST_API': False,
|
||||
|
||||
# Video Abstraction Layer used to allow video teams to manage video assets
|
||||
# independently of courseware. https://github.com/edx/edx-val
|
||||
'ENABLE_VIDEO_ABSTRACTION_LAYER_API': False,
|
||||
}
|
||||
|
||||
# Ignore static asset files on import which match this pattern
|
||||
@@ -1415,7 +1422,10 @@ INSTALLED_APPS = (
|
||||
'edx_jsme', # Molecular Structure
|
||||
|
||||
# Country list
|
||||
'django_countries'
|
||||
'django_countries',
|
||||
|
||||
# edX Mobile API
|
||||
'mobile_api',
|
||||
)
|
||||
|
||||
######################### MARKETING SITE ###############################
|
||||
@@ -1737,7 +1747,10 @@ OPTIONAL_APPS = (
|
||||
'openassessment.assessment',
|
||||
'openassessment.fileupload',
|
||||
'openassessment.workflow',
|
||||
'openassessment.xblock'
|
||||
'openassessment.xblock',
|
||||
|
||||
# edxval
|
||||
'edxval'
|
||||
)
|
||||
|
||||
for app_name in OPTIONAL_APPS:
|
||||
|
||||
@@ -211,6 +211,10 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
|
||||
|
||||
FEATURES['AUTH_USE_CERTIFICATES'] = False
|
||||
|
||||
########################### External REST APIs #################################
|
||||
FEATURES['ENABLE_MOBILE_REST_API'] = True
|
||||
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
# By default don't use a worker, execute tasks as if they were local functions
|
||||
|
||||
@@ -48,7 +48,7 @@ ANALYTICS_DASHBOARD_URL = None
|
||||
|
||||
################################ DEBUG TOOLBAR ################################
|
||||
|
||||
INSTALLED_APPS += ('debug_toolbar',)
|
||||
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
|
||||
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',)
|
||||
INTERNAL_IPS = ('127.0.0.1',)
|
||||
@@ -62,12 +62,13 @@ DEBUG_TOOLBAR_PANELS = (
|
||||
'debug_toolbar.panels.sql.SQLDebugPanel',
|
||||
'debug_toolbar.panels.signals.SignalDebugPanel',
|
||||
'debug_toolbar.panels.logger.LoggingPanel',
|
||||
'debug_toolbar_mongo.panel.MongoDebugPanel',
|
||||
|
||||
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
|
||||
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
|
||||
# hit twice). So you can uncomment when you need to diagnose performance
|
||||
# problems, but you shouldn't leave it on.
|
||||
# 'debug_toolbar.panels.profiling.ProfilingPanel',
|
||||
#'debug_toolbar.panels.profiling.ProfilingDebugPanel',
|
||||
)
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
@@ -94,6 +95,10 @@ CC_PROCESSOR = {
|
||||
}
|
||||
}
|
||||
|
||||
########################### External REST APIs #################################
|
||||
FEATURES['ENABLE_MOBILE_REST_API'] = True
|
||||
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
|
||||
|
||||
#####################################################################
|
||||
# See if the developer has any local overrides.
|
||||
try:
|
||||
|
||||
@@ -220,6 +220,10 @@ OPENID_PROVIDER_TRUSTED_ROOTS = ['*']
|
||||
############################## OAUTH2 Provider ################################
|
||||
FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
|
||||
|
||||
########################### External REST APIs #################################
|
||||
FEATURES['ENABLE_MOBILE_REST_API'] = True
|
||||
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
|
||||
|
||||
###################### Payment ##############################3
|
||||
# Enable fake payment processing page
|
||||
FEATURES['ENABLE_PAYMENT_FAKE'] = True
|
||||
|
||||
@@ -70,8 +70,15 @@ urlpatterns = ('', # nopep8
|
||||
|
||||
# Feedback Form endpoint
|
||||
url(r'^submit_feedback$', 'util.views.submit_feedback'),
|
||||
|
||||
)
|
||||
|
||||
if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
|
||||
urlpatterns += (
|
||||
url(r'^api/mobile/v0.5/', include('mobile_api.urls')),
|
||||
url(r'^api/val/v0/', include('edxval.urls')),
|
||||
)
|
||||
|
||||
# if settings.FEATURES.get("MULTIPLE_ENROLLMENT_ROLES"):
|
||||
urlpatterns += (
|
||||
url(r'^verify_student/', include('verify_student.urls')),
|
||||
|
||||
Reference in New Issue
Block a user