Commit e871df55 by Chris

Merge pull request #6575 from edx/clrux/xuetangx-branding-update

Video player branding addition for XuetangX
parents 8fb739ee 9e2494b2
......@@ -312,3 +312,7 @@ VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIP
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
# Video Caching. Pairing country codes with CDN URLs.
# Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='}
VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {})
# -*- coding: utf-8 -*-
"""
This config file runs the simplest dev environment using sqlite, and db-based
sessions. Assumes structure:
......@@ -249,3 +250,7 @@ FEATURES['MILESTONES_APP'] = True
# ENTRANCE EXAMS
FEATURES['ENTRANCE_EXAMS'] = True
ENTRANCE_EXAM_MIN_SCORE_PCT = 50
VIDEO_CDN_URL = {
'CN': 'http://api.xuetangx.com/edx/video?s3_url='
}
......@@ -72,6 +72,31 @@ div.video {
border-radius: 3px;
}
}
.branding {
display: inline-block;
float: right;
margin: 15px 0 0 10px;
vertical-align: top;
.host-tag {
@include margin-right($baseline/2);
position: absolute;
left: -9999em;
display: inline-block;
vertical-align: middle;
font-size: 70%;
color: #777;
}
.brand-logo {
display: inline-block;
max-width: 100%;
max-height: ($baseline*2);
padding: ($baseline/4) 0;
vertical-align: middle;
}
}
}
article.video-wrapper {
......@@ -741,7 +766,7 @@ div.video {
bottom: 0;
left: 0;
position: absolute;
width: 100%;
width: 100%;
}
}
......
......@@ -2,14 +2,12 @@
# pylint: disable=abstract-method
"""Video is ungraded Xmodule for support video content.
It's new improved video module, which support additional feature:
- Can play non-YouTube video sources via in-browser HTML5 video player.
- YouTube defaults to HTML5 mode from the start.
- Speed changes in both YouTube and non-YouTube videos happen via
in-browser HTML5 video method (when in HTML5 mode).
- Navigational subtitles can be disabled altogether via an attribute
in XML.
Examples of html5 videos for manual testing:
https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp4
https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.webm
......@@ -73,6 +71,11 @@ try:
except ImportError:
edxval_api = None
try:
from branding.models import BrandingInfoConfig
except ImportError:
BrandingInfoConfig = None
log = logging.getLogger(__name__)
_ = lambda text: text
......@@ -80,7 +83,6 @@ _ = lambda text: text
class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, XModule):
"""
XML source example:
<video show_captions="true"
youtube="0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg"
url_name="lecture_21_3" display_name="S19V3: Vacancies"
......@@ -129,12 +131,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
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
......@@ -175,6 +174,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
sources = filter(None, self.html5_sources)
download_video_link = None
branding_info = None
youtube_streams = ""
# If we have an edx_video_id, we prefer its values over what we store
......@@ -213,8 +213,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
# 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:
branding_info = BrandingInfoConfig.get_config().get(self.system.user_location)
for index, source_url in enumerate(sources):
new_url = get_video_from_cdn(cdn_url, source_url)
if new_url:
......@@ -233,6 +234,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
return self.system.render_template('video.html', {
'ajax_url': self.system.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
'branding_info': branding_info,
# This won't work when we move to data that
# isn't on the filesystem
'data_dir': getattr(self, 'data_dir', None),
......@@ -286,7 +288,6 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
def __init__(self, *args, **kwargs):
"""
Mostly handles backward compatibility issues.
`source` is deprecated field.
a) If `source` exists and `source` is not `html5_sources`: show `source`
field on front-end as not-editable but clearable. Dropdown is a new
......@@ -331,21 +332,16 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
def editor_saved(self, user, old_metadata, old_content):
"""
Used to update video values during `self`:save method from CMS.
old_metadata: dict, values of fields of `self` with scope=settings which were explicitly set by user.
old_content, same as `old_metadata` but for scope=content.
Due to nature of code flow in item.py::_save_item, before current function is called,
fields of `self` instance have been already updated, but not yet saved.
To obtain values, which were changed by user input,
one should compare own_metadata(self) and old_medatada.
Video player has two tabs, and due to nature of sync between tabs,
metadata from Basic tab is always sent when video player is edited and saved first time, for example:
{'youtube_id_1_0': u'OEoXaMPEzfM', 'display_name': u'Video', 'sub': u'OEoXaMPEzfM', 'html5_sources': []},
that's why these fields will always present in old_metadata after first save. This should be fixed.
At consequent save requests html5_sources are always sent too, disregard of their change by user.
That means that html5_sources are always in list of fields that were changed (`metadata` param in save_item).
This should be fixed too.
......@@ -390,7 +386,6 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
"""
Creates an instance of this descriptor from the supplied xml_data.
This may be overridden by subclasses
xml_data: A string of xml that will be translated into data and children for
this module
system: A DescriptorSystem for interacting with external resources
......
# -*- coding: utf-8 -*-
"""
Module contains utils specific for video_module but not for transcripts.
"""
......@@ -33,6 +34,8 @@ def create_youtube_string(module):
])
# def get_video_from_cdn(cdn_base_url, original_video_url, cdn_branding_logo_url):
# Not sure if this third variable is necessary...
def get_video_from_cdn(cdn_base_url, original_video_url):
"""
Get video URL from CDN.
......@@ -46,7 +49,7 @@ def get_video_from_cdn(cdn_base_url, original_video_url):
"http://cm12.c110.play.bokecc.com/flvs/ca/QxcVl/u39EQbA0Ra-20.mp4",
"http://bm1.42.play.bokecc.com/flvs/ca/QxcVl/u39EQbA0Ra-20.mp4"
],
"s3_url": "http://s3.amazonaws.com/BESTech/CS169/download/CS169_v13_w5l2s3.mp4"
"s3_url": "http://s3.amazonaws.com/BESTech/CS169/download/CS169_v13_w5l2s3.mp4",
}
where `s3_url` is requested original video url and `sources` is the list of
alternative links.
......
'''
Django admin pages for Video Branding Configuration.
'''
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
from .models import BrandingInfoConfig
admin.site.register(BrandingInfoConfig, ConfigurationModelAdmin)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'BrandingInfoConfig'
db.create_table('branding_brandinginfoconfig', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
('configuration', self.gf('django.db.models.fields.TextField')()),
))
db.send_create_signal('branding', ['BrandingInfoConfig'])
def backwards(self, orm):
# Deleting model 'BrandingInfoConfig'
db.delete_table('branding_brandinginfoconfig')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'branding.brandinginfoconfig': {
'Meta': {'object_name': 'BrandingInfoConfig'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'configuration': ('django.db.models.fields.TextField', [], {}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
}
}
complete_apps = ['branding']
"""
Model used by Video module for Branding configuration.
Includes:
BrandingInfoConfig: A ConfigurationModel for managing how Video Module will
use Branding.
"""
import json
from django.db.models import TextField
from django.core.exceptions import ValidationError
from config_models.models import ConfigurationModel
class BrandingInfoConfig(ConfigurationModel):
"""
Configuration for Branding.
Example of configuration that must be stored:
{
"CN": {
"url": "http://www.xuetangx.com",
"logo_src": "http://www.xuetangx.com/static/images/logo.png",
"logo_tag": "Video hosted by XuetangX.com"
}
}
"""
configuration = TextField(
help_text="JSON data of Configuration for Video Branding."
)
def clean(self):
"""
Validates configuration text field.
"""
try:
json.loads(self.configuration)
except ValueError:
raise ValidationError('Must be valid JSON string.')
@classmethod
def get_config(cls):
"""
Get the Video Branding Configuration.
"""
info = cls.current()
return json.loads(info.configuration) if info.enabled else {}
"""
Tests for the Video Branding configuration.
"""
from django.test import TestCase
from django.core.exceptions import ValidationError
from branding.models import BrandingInfoConfig
class BrandingInfoConfigTest(TestCase):
"""
Test the BrandingInfoConfig model.
"""
def setUp(self):
self.configuration_string = """{
"CN": {
"url": "http://www.xuetangx.com",
"logo_src": "http://www.xuetangx.com/static/images/logo.png",
"logo_tag": "Video hosted by XuetangX.com"
}
}"""
self.config = BrandingInfoConfig(configuration=self.configuration_string)
def test_create(self):
"""
Tests creation of configuration.
"""
self.config.save()
self.assertEquals(self.config.configuration, self.configuration_string)
def test_clean_bad_json(self):
"""
Tests if bad Json string was given.
"""
self.config = BrandingInfoConfig(configuration='{"bad":"test"')
self.assertRaises(ValidationError, self.config.clean)
def test_get(self):
"""
Tests get configuration from saved string.
"""
self.config.enabled = True
self.config.save()
expected_config = {
"CN": {
"url": "http://www.xuetangx.com",
"logo_src": "http://www.xuetangx.com/static/images/logo.png",
"logo_tag": "Video hosted by XuetangX.com"
}
}
self.assertEquals(self.config.get_config(), expected_config)
def test_get_not_enabled(self):
"""
Tests get configuration that is not enabled.
"""
self.config.enabled = False
self.config.save()
self.assertEquals(self.config.get_config(), {})
......@@ -112,7 +112,7 @@ class AnonymousIndexPageTest(ModuleStoreTestCase):
# Response should be instance of HttpResponseRedirect.
self.assertIsInstance(response, HttpResponseRedirect)
# Location should be "/login".
self.assertEqual(response._headers.get("location")[1], "/login")
self.assertEqual(response._headers.get("location")[1], "/login") # pylint: disable=protected-access
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
......@@ -198,7 +198,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
def test_course_cards_sorted_by_default_sorting(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
((template, context), _) = RENDER_MOCK.call_args
((template, context), _) = RENDER_MOCK.call_args # pylint: disable=unpacking-non-sequence
self.assertEqual(template, 'index.html')
# Now the courses will be stored in their announcement dates.
......@@ -209,7 +209,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
# check the /courses view
response = self.client.get(reverse('branding.views.courses'))
self.assertEqual(response.status_code, 200)
((template, context), _) = RENDER_MOCK.call_args
((template, context), _) = RENDER_MOCK.call_args # pylint: disable=unpacking-non-sequence
self.assertEqual(template, 'courseware/courses.html')
# Now the courses will be stored in their announcement dates.
......@@ -223,7 +223,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
def test_course_cards_sorted_by_start_date_show_earliest_first(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
((template, context), _) = RENDER_MOCK.call_args
((template, context), _) = RENDER_MOCK.call_args # pylint: disable=unpacking-non-sequence
self.assertEqual(template, 'index.html')
# now the courses will be sorted by their creation dates, earliest first.
......@@ -234,7 +234,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
# check the /courses view as well
response = self.client.get(reverse('branding.views.courses'))
self.assertEqual(response.status_code, 200)
((template, context), _) = RENDER_MOCK.call_args
((template, context), _) = RENDER_MOCK.call_args # pylint: disable=unpacking-non-sequence
self.assertEqual(template, 'courseware/courses.html')
# now the courses will be sorted by their creation dates, earliest first.
......
......@@ -39,6 +39,7 @@ class TestVideoYouTube(TestVideo):
expected_context = {
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
'branding_info': None,
'data_dir': getattr(self, 'data_dir', None),
'display_name': u'A Name',
'end': 3610.0,
......@@ -102,6 +103,7 @@ class TestVideoNonYouTube(TestVideo):
expected_context = {
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'branding_info': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
......@@ -204,6 +206,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
sources = json.dumps([u'example.mp4', u'example.webm'])
expected_context = {
'branding_info': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
......@@ -320,6 +323,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
]
initial_context = {
'branding_info': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
......@@ -459,6 +463,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
# Video found for edx_video_id
initial_context = {
'branding_info': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
......@@ -576,6 +581,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
# Video found for edx_video_id
initial_context = {
'branding_info': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
......@@ -628,11 +634,22 @@ class TestGetHtmlMethod(BaseTestXmodule):
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
)
# pylint: disable=invalid-name
@patch('xmodule.video_module.video_module.BrandingInfoConfig')
@patch('xmodule.video_module.video_module.get_video_from_cdn')
def test_get_html_cdn_source(self, mocked_get_video):
def test_get_html_cdn_source(self, mocked_get_video, mock_BrandingInfoConfig):
"""
Test if sources got from CDN.
"""
mock_BrandingInfoConfig.get_config.return_value = {
"CN": {
'url': 'http://www.xuetangx.com',
'logo_src': 'http://www.xuetangx.com/static/images/logo.png',
'logo_tag': 'Video hosted by XuetangX.com'
}
}
def side_effect(*args, **kwargs):
cdn = {
'http://example.com/example.mp4': 'http://cdn_example.com/example.mp4',
......@@ -679,6 +696,11 @@ class TestGetHtmlMethod(BaseTestXmodule):
]
initial_context = {
'branding_info': {
'logo_src': 'http://www.xuetangx.com/static/images/logo.png',
'logo_tag': 'Video hosted by XuetangX.com',
'url': 'http://www.xuetangx.com'
},
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
......
......@@ -1568,6 +1568,7 @@ INSTALLED_APPS = (
'licenses',
'openedx.core.djangoapps.course_groups',
'bulk_email',
'branding',
# External auth (OpenID, shib)
'external_auth',
......
# -*- coding: utf-8 -*-
"""
This config file runs the simplest dev environment using sqlite, and db-based
sessions. Assumes structure:
......
......@@ -121,8 +121,8 @@
% else:
<li class="a11y-menu-item">
% endif
## This is necessary so we don't scrape 'display_name' as a string.
<% dname = item['display_name'] %>
## This is necessary so we don't scrape 'display_name' as a string.
<% dname = item['display_name'] %>
<a class="a11y-menu-item-link" href="#${item['value']}" title="${_(dname)}" data-value="${item['value']}">
${_(dname)}
</a>
......@@ -141,5 +141,12 @@
${('<a href="%s" target="_blank">' + _('Download Handout') + '</a>') % handout}
</li>
% endif
% if branding_info:
<li id="branding" class="branding">
<span class="host-tag">${branding_info['logo_tag']}</span>
<a href="${branding_info['url']}" target="_blank" title="${branding_info['logo_tag']}"><img class="brand-logo" src="${branding_info['logo_src']}" alt="${branding_info['logo_tag']}" /></a>
</li>
% endif
</ul>
</div>
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