Commit fe6668a5 by Andy Armstrong

Merge branch 'release'

Conflicts:
	common/lib/xmodule/xmodule/video_module/video_module.py
	requirements/edx/github.txt
parents ed84c1b2 4f5d8b30
......@@ -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
......
......@@ -642,7 +642,10 @@ OPTIONAL_APPS = (
'openassessment.assessment',
'openassessment.fileupload',
'openassessment.workflow',
'openassessment.xblock'
'openassessment.xblock',
# edxval
'edxval'
)
......
......@@ -94,6 +94,8 @@ urlpatterns += patterns(
url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'),
url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)/?$'.format(settings.COURSE_KEY_PATTERN),
'group_configurations_detail_handler'),
url(r'^api/val/v0/', include('edxval.urls')),
)
js_info_dict = {
......
......@@ -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):
"""
......
......@@ -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")
......
......@@ -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."),
......
......@@ -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]
......@@ -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'
......
......@@ -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):
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)
def get_transcripts_for_student(self):
"""Return transcript information necessary for rendering the XModule student view.
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
This is more or less a direct extraction from `get_html`.
if self.download_video:
if self.source:
download_video_link = self.source
elif self.html5_sources:
download_video_link = self.html5_sources[0]
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
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:
......
"""
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
......
......@@ -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="",
)
......@@ -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],
......
......@@ -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
)
)
html = ''
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
......
# A models.py is required to make this an app (until we move to Django 1.7)
\ No newline at end of file
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'
),
)
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 ""}
)
# A models.py is required to make this an app (until we move to Django 1.7)
\ No newline at end of file
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')),
)
# A models.py is required to make this an app (until we move to Django 1.7)
\ No newline at end of file
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'
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'
),
)
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
# A models.py is required to make this an app (until we move to Django 1.7)
\ No newline at end of file
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),
}
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'
),
)
"""
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')),
......
......@@ -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
......
......@@ -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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment