diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f10ef6215b..a68ad7530f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,13 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Mobile API available for courses that opt in using the Course Advanced +Setting "Mobile Course Available" (only used in limited closed beta). + +Studio: Video Module now has an optional advanced setting "EdX Video ID" for +courses where assets are managed entirely by the video team. This is optional +and opt-in (only used in a limited closed beta for now). + LMS: Do not allow individual due dates to be earlier than the normal due date. LMS-6563 Blades: Course teams can turn off Chinese Caching from Studio. BLD-1207 diff --git a/cms/envs/common.py b/cms/envs/common.py index 44c787b99c..713fb5c8a8 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -642,7 +642,10 @@ OPTIONAL_APPS = ( 'openassessment.assessment', 'openassessment.fileupload', 'openassessment.workflow', - 'openassessment.xblock' + 'openassessment.xblock', + + # edxval + 'edxval' ) diff --git a/cms/urls.py b/cms/urls.py index c71a766c33..bd99ae6c7e 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -94,6 +94,8 @@ urlpatterns += patterns( url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'), url(r'^group_configurations/{}/(?P\d+)/?$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_detail_handler'), + + url(r'^api/val/v0/', include('edxval.urls')), ) js_info_dict = { diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 38b87c0925..89e81d6529 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -37,6 +37,8 @@ from eventtracking import tracker from importlib import import_module from opaque_keys.edx.locations import SlashSeparatedCourseKey +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore import lms.lib.comment_client as cc from util.query import use_read_replica_if_available @@ -1018,6 +1020,14 @@ class CourseEnrollment(models.Model): else: return True + @property + def username(self): + return self.user.username + + @property + def course(self): + return modulestore().get_course(self.course_id) + class CourseEnrollmentAllowed(models.Model): """ @@ -1064,7 +1074,7 @@ class CourseAccessRole(models.Model): convenience function to make eq overrides easier and clearer. arbitrary decision that role is primary, followed by org, course, and then user """ - return (self.role, self.org, self.course_id, self.user) + return (self.role, self.org, self.course_id, self.user_id) def __eq__(self, other): """ diff --git a/common/djangoapps/user_api/serializers.py b/common/djangoapps/user_api/serializers.py index edd9b1e7cc..09aa25e939 100644 --- a/common/djangoapps/user_api/serializers.py +++ b/common/djangoapps/user_api/serializers.py @@ -18,7 +18,7 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = User # This list is the minimal set required by the notification service - fields = ("id", "email", "name", "username", "preferences") + fields = ("id", "url", "email", "name", "username", "preferences") read_only_fields = ("id", "email", "username") diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 0b50213ed8..37a678b1d4 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -274,6 +274,13 @@ class CourseFields(object): help=_("Enter true or false. If true, the course appears in the list of new courses on edx.org, and a New! badge temporarily appears next to the course image."), scope=Scope.settings ) + mobile_available = Boolean( + display_name=_("Mobile Course Available"), + help=_("Enter true or false. If true, the course will be available to mobile devices."), + default=False, + scope=Scope.settings + ) + no_grade = Boolean( display_name=_("Course Not Graded"), help=_("Enter true or false. If true, the course will not be graded."), diff --git a/common/lib/xmodule/xmodule/video_module/transcripts_utils.py b/common/lib/xmodule/xmodule/video_module/transcripts_utils.py index 3a1fa48948..73b0bf01b7 100644 --- a/common/lib/xmodule/xmodule/video_module/transcripts_utils.py +++ b/common/lib/xmodule/xmodule/video_module/transcripts_utils.py @@ -506,3 +506,89 @@ class Transcript(object): pass return StaticContent.compute_location(location.course_key, filename) + +class VideoTranscriptsMixin(object): + """Mixin class for transcript functionality. + + This is necessary for both VideoModule and VideoDescriptor. + """ + + def available_translations(self, verify_assets=True): + """Return a list of language codes for which we have transcripts. + + Args: + verify_assets (boolean): If True, checks to ensure that the transcripts + really exist in the contentstore. If False, we just look at the + VideoDescriptor fields and do not query the contentstore. One reason + we might do this is to avoid slamming contentstore() with queries + when trying to make a listing of videos and their languages. + + Defaults to True. + """ + translations = [] + + # If we're not verifying the assets, we just trust our field values + if not verify_assets: + if self.sub: + translations = ['en'] + translations += list(self.transcripts) + return translations + + # If we've gotten this far, we're going to verify that the transcripts + # being referenced are actually in the contentstore. + if self.sub: # check if sjson exists for 'en'. + try: + Transcript.asset(self.location, self.sub, 'en') + except NotFoundError: + pass + else: + translations = ['en'] + + for lang in self.transcripts: + try: + Transcript.asset(self.location, None, None, self.transcripts[lang]) + except NotFoundError: + continue + translations.append(lang) + + return translations + + def get_transcript(self, transcript_format='srt', lang=None): + """ + Returns transcript, filename and MIME type. + + Raises: + - NotFoundError if cannot find transcript file in storage. + - ValueError if transcript file is empty or incorrect JSON. + - KeyError if transcript file has incorrect format. + + If language is 'en', self.sub should be correct subtitles name. + If language is 'en', but if self.sub is not defined, this means that we + should search for video name in order to get proper transcript (old style courses). + If language is not 'en', give back transcript in proper language and format. + """ + if not lang: + lang = self.transcript_language + + if lang == 'en': + if self.sub: # HTML5 case and (Youtube case for new style videos) + transcript_name = self.sub + elif self.youtube_id_1_0: # old courses + transcript_name = self.youtube_id_1_0 + else: + log.debug("No subtitles for 'en' language") + raise ValueError + + data = Transcript.asset(self.location, transcript_name, lang).data + filename = u'{}.{}'.format(transcript_name, transcript_format) + content = Transcript.convert(data, 'sjson', transcript_format) + else: + data = Transcript.asset(self.location, None, None, self.transcripts[lang]).data + filename = u'{}.{}'.format(os.path.splitext(self.transcripts[lang])[0], transcript_format) + content = Transcript.convert(data, 'srt', transcript_format) + + if not content: + log.debug('no subtitles produced in get_transcript') + raise ValueError + + return content, filename, Transcript.mime_types[transcript_format] diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py index 63cd47fae9..27e81a3ad9 100644 --- a/common/lib/xmodule/xmodule/video_module/video_handlers.py +++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py @@ -133,45 +133,6 @@ class VideoStudentViewHandlers(object): else: return get_or_create_sjson(self) - def get_transcript(self, transcript_format='srt'): - """ - Returns transcript, filename and MIME type. - - Raises: - - NotFoundError if cannot find transcript file in storage. - - ValueError if transcript file is empty or incorrect JSON. - - KeyError if transcript file has incorrect format. - - If language is 'en', self.sub should be correct subtitles name. - If language is 'en', but if self.sub is not defined, this means that we - should search for video name in order to get proper transcript (old style courses). - If language is not 'en', give back transcript in proper language and format. - """ - lang = self.transcript_language - - if lang == 'en': - if self.sub: # HTML5 case and (Youtube case for new style videos) - transcript_name = self.sub - elif self.youtube_id_1_0: # old courses - transcript_name = self.youtube_id_1_0 - else: - log.debug("No subtitles for 'en' language") - raise ValueError - - data = Transcript.asset(self.location, transcript_name, lang).data - filename = u'{}.{}'.format(transcript_name, transcript_format) - content = Transcript.convert(data, 'sjson', transcript_format) - else: - data = Transcript.asset(self.location, None, None, self.transcripts[lang]).data - filename = u'{}.{}'.format(os.path.splitext(self.transcripts[lang])[0], transcript_format) - content = Transcript.convert(data, 'srt', transcript_format) - - if not content: - log.debug('no subtitles produced in get_transcript') - raise ValueError - - return content, filename, Transcript.mime_types[transcript_format] - def get_static_transcript(self, request): """ Courses that are imported with the --nostatic flag do not show @@ -283,20 +244,7 @@ class VideoStudentViewHandlers(object): response.content_type = transcript_mime_type elif dispatch == 'available_translations': - available_translations = [] - if self.sub: # check if sjson exists for 'en'. - try: - Transcript.asset(self.location, self.sub, 'en') - except NotFoundError: - pass - else: - available_translations = ['en'] - for lang in self.transcripts: - try: - Transcript.asset(self.location, None, None, self.transcripts[lang]) - except NotFoundError: - continue - available_translations.append(lang) + available_translations = self.available_translations() if available_translations: response = Response(json.dumps(available_translations)) response.content_type = 'application/json' diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index 51d0042101..33b2f5a93c 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -15,38 +15,71 @@ Examples of html5 videos for manual testing: https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.webm https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.ogv """ +import copy import json import logging +import os.path +from collections import OrderedDict from operator import itemgetter from lxml import etree from pkg_resources import resource_string -import copy - -from collections import OrderedDict from django.conf import settings from xblock.fields import ScopeIds from xblock.runtime import KvsFieldData +from xmodule.exceptions import NotFoundError from xmodule.modulestore.inheritance import InheritanceKeyValueStore, own_metadata from xmodule.x_module import XModule, module_attr from xmodule.editing_module import TabsEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field +from .transcripts_utils import Transcript, VideoTranscriptsMixin from .video_utils import create_youtube_string, get_video_from_cdn from .video_xfields import VideoFields from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers from xmodule.video_module import manage_video_subtitles_save +# The following import/except block for edxval is temporary measure until +# edxval is a proper XBlock Runtime Service. +# +# Here's the deal: the VideoModule should be able to take advantage of edx-val +# (https://github.com/edx/edx-val) to figure out what URL to give for video +# resources that have an edx_video_id specified. edx-val is a Django app, and +# including it causes tests to fail because we run common/lib tests standalone +# without Django dependencies. The alternatives seem to be: +# +# 1. Move VideoModule out of edx-platform. +# 2. Accept the Django dependency in common/lib. +# 3. Try to import, catch the exception on failure, and check for the existence +# of edxval_api before invoking it in the code. +# 4. Make edxval an XBlock Runtime Service +# +# (1) is a longer term goal. VideoModule should be made into an XBlock and +# extracted from edx-platform entirely. But that's expensive to do because of +# the various dependencies (like templates). Need to sort this out. +# (2) is explicitly discouraged. +# (3) is what we're doing today. The code is still functional when called within +# the context of the LMS, but does not cause failure on import when running +# standalone tests. Most VideoModule tests tend to be in the LMS anyway, +# probably for historical reasons, so we're not making things notably worse. +# (4) is one of the next items on the backlog for edxval, and should get rid +# of this particular import silliness. It's just that I haven't made one before, +# and I was worried about trying it with my deadline constraints. +try: + import edxval.api as edxval_api +except ImportError: + edxval_api = None + log = logging.getLogger(__name__) _ = lambda text: text -class VideoModule(VideoFields, VideoStudentViewHandlers, XModule): +class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, XModule): """ XML source example: @@ -96,34 +129,22 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule): ]} js_module_name = "Video" - def get_html(self): + def get_transcripts_for_student(self): + """Return transcript information necessary for rendering the XModule student view. + + This is more or less a direct extraction from `get_html`. + + Returns: + Tuple of (track_url, transcript_language, sorted_languages) + + track_url -> subtitle download url + transcript_language -> default transcript language + sorted_languages -> dictionary of available transcript languages + """ track_url = None - download_video_link = None - transcript_download_format = self.transcript_download_format - sources = filter(None, self.html5_sources) - - # If the user comes from China use China CDN for html5 videos. - # 'CN' is China ISO 3166-1 country code. - # Video caching is disabled for Studio. User_location is always None in Studio. - # CountryMiddleware disabled for Studio. - cdn_url = getattr(settings, 'VIDEO_CDN_URL', {}).get(self.system.user_location) - - if getattr(self, 'video_speed_optimizations', True) and cdn_url: - for index, source_url in enumerate(sources): - new_url = get_video_from_cdn(cdn_url, source_url) - if new_url: - sources[index] = new_url - - if self.download_video: - if self.source: - download_video_link = self.source - elif self.html5_sources: - download_video_link = self.html5_sources[0] - if self.download_track: if self.track: track_url = self.track - transcript_download_format = None elif self.sub or self.transcripts: track_url = self.runtime.handler_url(self, 'transcript', 'download').rstrip('/?') @@ -154,6 +175,52 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule): sorted_languages.insert(0, ('table', 'Table of Contents')) sorted_languages = OrderedDict(sorted_languages) + return track_url, transcript_language, sorted_languages + + + def get_html(self): + transcript_download_format = self.transcript_download_format if not (self.download_track and self.track) else None + sources = filter(None, self.html5_sources) + + # If the user comes from China use China CDN for html5 videos. + # 'CN' is China ISO 3166-1 country code. + # Video caching is disabled for Studio. User_location is always None in Studio. + # CountryMiddleware disabled for Studio. + cdn_url = getattr(settings, 'VIDEO_CDN_URL', {}).get(self.system.user_location) + + if getattr(self, 'video_speed_optimizations', True) and cdn_url: + for index, source_url in enumerate(sources): + new_url = get_video_from_cdn(cdn_url, source_url) + if new_url: + sources[index] = new_url + + download_video_link = None + youtube_streams = "" + + # If we have an edx_video_id, we prefer its values over what we store + # internally for download links (source, html5_sources) and the youtube + # stream. + if self.edx_video_id and edxval_api: + val_video_urls = edxval_api.get_urls_for_profiles( + self.edx_video_id, ["desktop_mp4", "youtube"] + ) + # VAL will always give us the keys for the profiles we asked for, but + # if it doesn't have an encoded video entry for that Video + Profile, the + # value will map to `None` + if val_video_urls["desktop_mp4"]: + download_video_link = val_video_urls["desktop_mp4"] + if val_video_urls["youtube"]: + youtube_streams = "1.00:{}".format(val_video_urls["youtube"]) + + # If there was no edx_video_id, or if there was no download specified + # for it, we fall back on whatever we find in the VideoDescriptor + if not download_video_link and self.download_video: + if self.source: + download_video_link = self.source + elif self.html5_sources: + download_video_link = self.html5_sources[0] + + track_url, transcript_language, sorted_languages = self.get_transcripts_for_student() return self.system.render_template('video.html', { 'ajax_url': self.system.ajax_url + '/save_user_state', @@ -174,7 +241,7 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule): 'start': self.start_time.total_seconds(), 'sub': self.sub, 'track': track_url, - 'youtube_streams': create_youtube_string(self), + 'youtube_streams': youtube_streams or create_youtube_string(self), # TODO: Later on the value 1500 should be taken from some global # configuration setting field. 'yt_test_timeout': 1500, @@ -189,7 +256,7 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule): }) -class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescriptor, EmptyDataRawDescriptor): +class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, TabsEditingDescriptor, EmptyDataRawDescriptor): """ Descriptor for `VideoModule`. """ @@ -403,6 +470,13 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto youtube_id_1_0 = metadata_fields['youtube_id_1_0'] def get_youtube_link(video_id): + # First try a lookup in VAL. If we have a YouTube entry there, it overrides the + # one passed in. + if self.edx_video_id and edxval_api: + val_youtube_id = edxval_api.get_url_for_profile(self.edx_video_id, "youtube") + if val_youtube_id: + video_id = val_youtube_id + if video_id: return 'http://youtu.be/{0}'.format(video_id) else: diff --git a/common/lib/xmodule/xmodule/video_module/video_utils.py b/common/lib/xmodule/xmodule/video_module/video_utils.py index c22c26b02a..29181fcbdc 100644 --- a/common/lib/xmodule/xmodule/video_module/video_utils.py +++ b/common/lib/xmodule/xmodule/video_module/video_utils.py @@ -1,5 +1,5 @@ """ -Module containts utils specific for video_module but not for transcripts. +Module contains utils specific for video_module but not for transcripts. """ import json import logging diff --git a/common/lib/xmodule/xmodule/video_module/video_xfields.py b/common/lib/xmodule/xmodule/video_module/video_xfields.py index 5652498c91..95a53a4cfe 100644 --- a/common/lib/xmodule/xmodule/video_module/video_xfields.py +++ b/common/lib/xmodule/xmodule/video_module/video_xfields.py @@ -145,9 +145,14 @@ class VideoFields(object): scope=Scope.user_info, default=True ) - handout = String( help=_("Upload a handout to accompany this video. Students can download the handout by clicking Download Handout under the video."), display_name=_("Upload Handout"), scope=Scope.settings, ) + edx_video_id = String( + help=_('Optional. Use this for videos where download and streaming URLs for the videos are completely managed by edX. This will override the settings for "Default Video URL", "Video File URLs", and all YouTube IDs. If you do not know what this setting is, you can leave it blank and continue to use these other settings.'), + display_name=_("EdX Video ID"), + scope=Scope.settings, + default="", + ) diff --git a/common/test/acceptance/pages/studio/video/video.py b/common/test/acceptance/pages/studio/video/video.py index 28d0016c3a..17d42d4091 100644 --- a/common/test/acceptance/pages/studio/video/video.py +++ b/common/test/acceptance/pages/studio/video/video.py @@ -60,6 +60,7 @@ DEFAULT_SETTINGS = [ ['Default Timed Transcript', '', False], ['Download Transcript Allowed', 'False', False], ['Downloadable Transcript URL', '', False], + ['EdX Video ID', '', False], ['Show Transcript', 'True', False], ['Transcript Languages', '', False], ['Upload Handout', '', False], diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 7d3c9d53a3..3b6d614f91 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -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 diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index 8c8f247620..44c8a25499 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -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 = """ + + """ + no_video_data = { + 'download_video': 'true', + 'source': 'example_source.mp4', + 'sources': """ + + + """, + '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 = """ + + """ + data = { + 'download_video': 'true', + 'source': 'example_source.mp4', + 'sources': """ + + + """, + '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 = """ + + """ + data = { + 'download_video': 'true', + 'source': 'example_source.mp4', + 'sources': """ + + + """, + '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 = """ + + """ + cases = [ + { + 'download_video': 'true', + 'source': 'example_source.mp4', + 'sources': """ + + + """, + '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. diff --git a/lms/djangoapps/mobile_api/__init__.py b/lms/djangoapps/mobile_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/mobile_api/course_info/__init__.py b/lms/djangoapps/mobile_api/course_info/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/mobile_api/course_info/models.py b/lms/djangoapps/mobile_api/course_info/models.py new file mode 100644 index 0000000000..b78e3b5416 --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/models.py @@ -0,0 +1 @@ +# A models.py is required to make this an app (until we move to Django 1.7) \ No newline at end of file diff --git a/lms/djangoapps/mobile_api/course_info/urls.py b/lms/djangoapps/mobile_api/course_info/urls.py new file mode 100644 index 0000000000..7f17b13123 --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/urls.py @@ -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' + ), +) + diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py new file mode 100644 index 0000000000..2b60505665 --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -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 ""} + ) diff --git a/lms/djangoapps/mobile_api/models.py b/lms/djangoapps/mobile_api/models.py new file mode 100644 index 0000000000..b78e3b5416 --- /dev/null +++ b/lms/djangoapps/mobile_api/models.py @@ -0,0 +1 @@ +# A models.py is required to make this an app (until we move to Django 1.7) \ No newline at end of file diff --git a/lms/djangoapps/mobile_api/urls.py b/lms/djangoapps/mobile_api/urls.py new file mode 100644 index 0000000000..c7ea881709 --- /dev/null +++ b/lms/djangoapps/mobile_api/urls.py @@ -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')), +) diff --git a/lms/djangoapps/mobile_api/users/__init__.py b/lms/djangoapps/mobile_api/users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/mobile_api/users/models.py b/lms/djangoapps/mobile_api/users/models.py new file mode 100644 index 0000000000..b78e3b5416 --- /dev/null +++ b/lms/djangoapps/mobile_api/users/models.py @@ -0,0 +1 @@ +# A models.py is required to make this an app (until we move to Django 1.7) \ No newline at end of file diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py new file mode 100644 index 0000000000..c7c5f19a3b --- /dev/null +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -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' diff --git a/lms/djangoapps/mobile_api/users/urls.py b/lms/djangoapps/mobile_api/users/urls.py new file mode 100644 index 0000000000..ae03b2ad2c --- /dev/null +++ b/lms/djangoapps/mobile_api/users/urls.py @@ -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\w+)$', UserDetail.as_view(), name='user-detail'), + url( + r'^(?P\w+)/course_enrollments/$', + UserCourseEnrollmentsList.as_view(), + name='courseenrollment-detail' + ), +) + diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py new file mode 100644 index 0000000000..7a2f6fe40b --- /dev/null +++ b/lms/djangoapps/mobile_api/users/views.py @@ -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 diff --git a/lms/djangoapps/mobile_api/video_outlines/__init__.py b/lms/djangoapps/mobile_api/video_outlines/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/mobile_api/video_outlines/models.py b/lms/djangoapps/mobile_api/video_outlines/models.py new file mode 100644 index 0000000000..b78e3b5416 --- /dev/null +++ b/lms/djangoapps/mobile_api/video_outlines/models.py @@ -0,0 +1 @@ +# A models.py is required to make this an app (until we move to Django 1.7) \ No newline at end of file diff --git a/lms/djangoapps/mobile_api/video_outlines/serializers.py b/lms/djangoapps/mobile_api/video_outlines/serializers.py new file mode 100644 index 0000000000..d384435f3c --- /dev/null +++ b/lms/djangoapps/mobile_api/video_outlines/serializers.py @@ -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), + } diff --git a/lms/djangoapps/mobile_api/video_outlines/urls.py b/lms/djangoapps/mobile_api/video_outlines/urls.py new file mode 100644 index 0000000000..6647400b17 --- /dev/null +++ b/lms/djangoapps/mobile_api/video_outlines/urls.py @@ -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[^/]*)/(?P[^/]*)$'.format(settings.COURSE_ID_PATTERN), + VideoTranscripts.as_view(), + name='video-transcripts-detail' + ), +) + diff --git a/lms/djangoapps/mobile_api/video_outlines/views.py b/lms/djangoapps/mobile_api/video_outlines/views.py new file mode 100644 index 0000000000..7606c1c231 --- /dev/null +++ b/lms/djangoapps/mobile_api/video_outlines/views.py @@ -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.") diff --git a/lms/envs/common.py b/lms/envs/common.py index 52e202eb05..9ab1c5b0b9 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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: diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 7294d05098..6432027b73 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -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 diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 84229bd609..9a9e9b417c 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -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: diff --git a/lms/envs/test.py b/lms/envs/test.py index a0e52a1d0b..a9d96698fe 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -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 diff --git a/lms/urls.py b/lms/urls.py index 9804d8e9f5..bedf8e0ba9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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')), diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 86731a039b..1ed769c6c6 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -33,7 +33,7 @@ django-ses==0.4.1 django-storages==1.1.5 django-threaded-multihost==1.4-1 django-method-override==0.1.0 -djangorestframework==2.3.5 +djangorestframework==2.3.14 django==1.4.14 feedparser==5.1.3 firebase-token-generator==1.3.2 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index ab7578a811..032ff643fd 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -33,3 +33,4 @@ -e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease -e git+https://github.com/edx/i18n-tools.git@56f048af9b6868613c14aeae760548834c495011#egg=i18n-tools -e git+https://github.com/edx/edx-oauth2-provider.git@0.2.1#egg=oauth2-provider +-e git+https://github.com/edx/edx-val.git@a3c54afe30375f7a5755ba6f6412a91de23c3b86#egg=edx-val