diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py
index 87fa1d7f54..6f7801469e 100644
--- a/cms/djangoapps/contentstore/features/video-editor.py
+++ b/cms/djangoapps/contentstore/features/video-editor.py
@@ -42,10 +42,10 @@ def correct_video_settings(_step):
['Display Name', 'Video', False],
['Download Transcript', '', False],
['Download Video', '', False],
- ['End Time', '0', False],
+ ['End Time', '00:00:00', False],
['HTML5 Transcript', '', False],
['Show Transcript', 'True', False],
- ['Start Time', '0', False],
+ ['Start Time', '00:00:00', False],
['Video Sources', '', False],
['Youtube ID', 'OEoXaMPEzfM', False],
['Youtube ID for .75x speed', '', False],
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index de8a901e9d..bd349a97e3 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -18,6 +18,7 @@ requirejs.config({
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
"jquery.immediateDescendents": "xmodule_js/common_static/coffee/src/jquery.immediateDescendents",
+ "jquery.maskedinput": "xmodule_js/common_static/js/vendor/jquery.maskedinput.min",
"datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
"date": "xmodule_js/common_static/js/vendor/date",
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
@@ -94,6 +95,10 @@ requirejs.config({
deps: ["jquery"],
exports: "jQuery.fn.inputNumber"
},
+ "jquery.maskedinput": {
+ deps: ["jquery"],
+ exports: "jQuery.fn.mask"
+ },
"jquery.tinymce": {
deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce"
diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee
index 293cf0fadb..792291ff12 100644
--- a/cms/static/coffee/spec/views/metadata_edit_spec.coffee
+++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee
@@ -81,6 +81,18 @@ define ["js/models/metadata", "js/collections/metadata", "js/views/metadata", "c
value: ["the first display value", "the second"]
}
+ timeEntry = {
+ default_value: "00:00:00",
+ display_name: "Time",
+ explicitly_set: true,
+ field_name: "relative_time",
+ help: "Specifies the name for this component.",
+ options: [],
+ type: MetadataModel.RELATIVE_TIME_TYPE,
+ value: "12:12:12"
+ }
+
+
# Test for the editor that creates the individual views.
describe "MetadataView.Editor creates editors for each field", ->
beforeEach ->
@@ -103,17 +115,18 @@ define ["js/models/metadata", "js/collections/metadata", "js/views/metadata", "c
type: "unknown type",
value: null
},
- listEntry
+ listEntry,
+ timeEntry
]
)
it "creates child views on initialize, and sorts them alphabetically", ->
view = new MetadataView.Editor({collection: @model})
childModels = view.collection.models
- expect(childModels.length).toBe(6)
+ expect(childModels.length).toBe(7)
# Be sure to check list view as well as other input types
childViews = view.$el.find('.setting-input, .list-settings')
- expect(childViews.length).toBe(6)
+ expect(childViews.length).toBe(7)
verifyEntry = (index, display_name, type) ->
expect(childModels[index].get('display_name')).toBe(display_name)
@@ -123,8 +136,9 @@ define ["js/models/metadata", "js/collections/metadata", "js/views/metadata", "c
verifyEntry(1, 'Inputs', 'number')
verifyEntry(2, 'List', '')
verifyEntry(3, 'Show Answer', 'select-one')
- verifyEntry(4, 'Unknown', 'text')
- verifyEntry(5, 'Weight', 'number')
+ verifyEntry(4, 'Time', 'text')
+ verifyEntry(5, 'Unknown', 'text')
+ verifyEntry(6, 'Weight', 'number')
it "returns its display name", ->
view = new MetadataView.Editor({collection: @model})
@@ -361,3 +375,23 @@ define ["js/models/metadata", "js/collections/metadata", "js/views/metadata", "c
@el.find('input').last().val('third setting')
@el.find('input').last().trigger('input')
expect(@el.find('.create-setting')).not.toHaveClass('is-disabled')
+
+ describe "MetadataView.RelativeTime allows the user to enter time string in HH:mm:ss format", ->
+ beforeEach ->
+ model = new MetadataModel(timeEntry)
+ @view = new MetadataView.RelativeTime({model: model})
+
+ it "uses a text input type", ->
+ assertInputType(@view, 'text')
+
+ it "returns the intial value upon initialization", ->
+ assertValueInView(@view, '12:12:12')
+
+ it "can update its value in the view", ->
+ assertCanUpdateView(@view, "23:59:59")
+
+ it "has a clear method to revert to the model default", ->
+ assertClear(@view, '00:00:00')
+
+ it "has an update model method", ->
+ assertUpdateModel(@view, '12:12:12', '23:59:59')
diff --git a/cms/static/js/models/metadata.js b/cms/static/js/models/metadata.js
index f4421b3079..3c5fd07b98 100644
--- a/cms/static/js/models/metadata.js
+++ b/cms/static/js/models/metadata.js
@@ -108,6 +108,7 @@ define(["backbone"], function(Backbone) {
Metadata.GENERIC_TYPE = "Generic";
Metadata.LIST_TYPE = "List";
Metadata.VIDEO_LIST_TYPE = "VideoList";
+ Metadata.RELATIVE_TIME_TYPE = "RelativeTime";
return Metadata;
});
diff --git a/cms/static/js/views/metadata.js b/cms/static/js/views/metadata.js
index b555f7c048..f58e68067c 100644
--- a/cms/static/js/views/metadata.js
+++ b/cms/static/js/views/metadata.js
@@ -1,8 +1,7 @@
-
define(
[
"backbone", "underscore", "js/models/metadata", "js/views/abstract_editor",
- "js/views/transcripts/metadata_videolist"
+ "js/views/transcripts/metadata_videolist", "jquery.maskedinput"
],
function(Backbone, _, MetadataModel, AbstractEditor, VideoList) {
var Metadata = {};
@@ -40,6 +39,9 @@ function(Backbone, _, MetadataModel, AbstractEditor, VideoList) {
else if(model.getType() === MetadataModel.VIDEO_LIST_TYPE) {
new VideoList(data);
}
+ else if(model.getType() === MetadataModel.RELATIVE_TIME_TYPE) {
+ new Metadata.RelativeTime(data);
+ }
else {
// Everything else is treated as GENERIC_TYPE, which uses String editor.
new Metadata.String(data);
@@ -292,5 +294,61 @@ function(Backbone, _, MetadataModel, AbstractEditor, VideoList) {
}
});
+ Metadata.RelativeTime = AbstractEditor.extend({
+
+ events : {
+ "change input" : "updateModel",
+ "keypress .setting-input" : "showClearButton" ,
+ "click .setting-clear" : "clear"
+ },
+
+ templateName: "metadata-string-entry",
+
+ initialize: function () {
+ AbstractEditor.prototype.initialize.apply(this);
+
+ // This list of definitions is used for creating appropriate
+ // time format mask;
+ //
+ // For example, mask 'hH:mM:sS':
+ // min value: 00:00:00
+ // max value: 23:59:59
+ //
+ // With this mask user cannot set following values:
+ // 93:23:23, 23:60:60, 77:77:77, etc.
+ var definitions = {
+ h: '[0-2]',
+ H: '[0-3]',
+ m: '[0-5]',
+ s: '[0-5]',
+ M: '[0-9]',
+ S: '[0-9]'
+ };
+
+ $.each(definitions, function(key, value) {
+ $.mask.definitions[key] = value;
+ });
+
+ this.$el
+ .find('#' + this.uniqueId)
+ .mask('hH:mM:sS', { placeholder: '0' });
+ },
+
+ getValueFromEditor : function () {
+ var $input = this.$el.find('#' + this.uniqueId),
+ value = $input.val();
+
+ return value;
+ },
+
+ setValueInEditor : function (value) {
+ if (!value) {
+ value = '00:00:00';
+ }
+
+ this.$el.find('input').val(value);
+ }
+ });
+
return Metadata;
});
diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml
index 81c53e0f91..34c6966968 100644
--- a/cms/static/js_test.yml
+++ b/cms/static/js_test.yml
@@ -48,6 +48,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/jasmine-jquery.js
- xmodule_js/common_static/js/vendor/jasmine-stealth.js
- xmodule_js/common_static/js/vendor/jasmine.async.js
+ - xmodule_js/common_static/js/vendor/jquery.maskedinput.min.js
- xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js
- xmodule_js/src/xmodule.js
- xmodule_js/common_static/js/test/i18n.js
diff --git a/cms/templates/base.html b/cms/templates/base.html
index 541960911a..ded53b8188 100644
--- a/cms/templates/base.html
+++ b/cms/templates/base.html
@@ -52,6 +52,7 @@ var require = {
"jquery.qtip": "js/vendor/jquery.qtip.min",
"jquery.scrollTo": "js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.flot": "js/vendor/flot/jquery.flot.min",
+ "jquery.maskedinput": "js/vendor/jquery.maskedinput.min",
"jquery.fileupload": "js/vendor/jQuery-File-Upload/js/jquery.fileupload",
"jquery.iframe-transport": "js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "js/vendor/html5-input-polyfills/number-polyfill",
@@ -125,6 +126,10 @@ var require = {
deps: ["jquery"],
exports: "jQuery.fn.plot"
},
+ "jquery.maskedinput": {
+ deps: ["jquery"],
+ exports: "jQuery.fn.mask"
+ },
"jquery.fileupload": {
deps: ["jquery.iframe-transport"],
exports: "jQuery.fn.fileupload"
diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py
index a0b53859ce..7eefd06086 100644
--- a/common/lib/xmodule/xmodule/fields.py
+++ b/common/lib/xmodule/xmodule/fields.py
@@ -116,3 +116,105 @@ class Timedelta(Field):
if cur_value > 0:
values.append("%d %s" % (cur_value, attr))
return ' '.join(values)
+
+
+class RelativeTime(Field):
+ """
+ Field for start_time and end_time video module properties.
+
+ It was decided, that python representation of start_time and end_time
+ should be python datetime.timedelta object, to be consistent with
+ common time representation.
+
+ At the same time, serialized representation should be "HH:MM:SS"
+ This format is convenient to use in XML (and it is used now),
+ and also it is used in frond-end studio editor of video module as format
+ for start and end time fields.
+
+ In database we previously had float type for start_time and end_time fields,
+ so we are checking it also.
+
+ Python object of RelativeTime is datetime.timedelta.
+ JSONed representation of RelativeTime is "HH:MM:SS"
+ """
+ # Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
+ MUTABLE = False
+
+ def _isotime_to_timedelta(self, value):
+ """
+ Validate that value in "HH:MM:SS" format and convert to timedelta.
+
+ Validate that user, that edits XML, sets proper format, and
+ that max value that can be used by user is "23:59:59".
+ """
+ try:
+ obj_time = time.strptime(value, '%H:%M:%S')
+ except ValueError as e:
+ raise ValueError(
+ "Incorrect RelativeTime value {!r} was set in XML or serialized. "
+ "Original parse message is {}".format(value, e.message)
+ )
+ return datetime.timedelta(
+ hours=obj_time.tm_hour,
+ minutes=obj_time.tm_min,
+ seconds=obj_time.tm_sec
+ )
+
+ def from_json(self, value):
+ """
+ Convert value is in 'HH:MM:SS' format to datetime.timedelta.
+
+ If not value, returns 0.
+ If value is float (backward compatibility issue), convert to timedelta.
+ """
+ if not value:
+ return datetime.timedelta(seconds=0)
+
+ # We've seen serialized versions of float in this field
+ if isinstance(value, float):
+ return datetime.timedelta(seconds=value)
+
+ if isinstance(value, basestring):
+ return self._isotime_to_timedelta(value)
+
+ msg = "RelativeTime Field {0} has bad value '{1!r}'".format(self._name, value)
+ raise TypeError(msg)
+
+ def to_json(self, value):
+ """
+ Convert datetime.timedelta to "HH:MM:SS" format.
+
+ If not value, return "00:00:00"
+
+ Backward compatibility: check if value is float, and convert it. No exceptions here.
+
+ If value is not float, but is exceed 23:59:59, raise exception.
+ """
+ if not value:
+ return "00:00:00"
+
+ if isinstance(value, float): # backward compatibility
+ value = min(value, 86400)
+ return self.timedelta_to_string(datetime.timedelta(seconds=value))
+
+ if isinstance(value, datetime.timedelta):
+ if value.total_seconds() > 86400: # sanity check
+ raise ValueError(
+ "RelativeTime max value is 23:59:59=86400.0 seconds, "
+ "but {} seconds is passed".format(value.total_seconds())
+ )
+ return self.timedelta_to_string(value)
+
+ raise TypeError("RelativeTime: cannot convert {!r} to json".format(value))
+
+ def timedelta_to_string(self, value):
+ """
+ Makes first 'H' in str representation non-optional.
+
+ str(timedelta) has [H]H:MM:SS format, which is not suitable
+ for front-end (and ISO time standard), so we force HH:MM:SS format.
+ """
+ stringified = str(value)
+ if len(stringified) == 7:
+ stringified = '0' + stringified
+ return stringified
diff --git a/common/lib/xmodule/xmodule/tests/test_fields.py b/common/lib/xmodule/xmodule/tests/test_fields.py
index 8453adaa20..47318ee4c5 100644
--- a/common/lib/xmodule/xmodule/tests/test_fields.py
+++ b/common/lib/xmodule/xmodule/tests/test_fields.py
@@ -2,7 +2,7 @@
import datetime
import unittest
from django.utils.timezone import UTC
-from xmodule.fields import Date, Timedelta
+from xmodule.fields import Date, Timedelta, RelativeTime
from xmodule.timeinfo import TimeInfo
import time
@@ -116,3 +116,59 @@ class TimeInfoTest(unittest.TestCase):
timeinfo = TimeInfo(due_date, grace_pd_string)
self.assertEqual(timeinfo.close_date,
due_date + Timedelta().from_json(grace_pd_string))
+
+
+class RelativeTimeTest(unittest.TestCase):
+
+ delta = RelativeTime()
+
+ def test_from_json(self):
+ self.assertEqual(
+ RelativeTimeTest.delta.from_json('0:05:07'),
+ datetime.timedelta(seconds=307)
+ )
+
+ self.assertEqual(
+ RelativeTimeTest.delta.from_json(100.0),
+ datetime.timedelta(seconds=100)
+ )
+ self.assertEqual(
+ RelativeTimeTest.delta.from_json(None),
+ datetime.timedelta(seconds=0)
+ )
+
+ with self.assertRaises(TypeError):
+ RelativeTimeTest.delta.from_json(1234) # int
+
+ with self.assertRaises(ValueError):
+ RelativeTimeTest.delta.from_json("77:77:77")
+
+ def test_to_json(self):
+ self.assertEqual(
+ "01:02:03",
+ RelativeTimeTest.delta.to_json(datetime.timedelta(seconds=3723))
+ )
+ self.assertEqual(
+ "00:00:00",
+ RelativeTimeTest.delta.to_json(None)
+ )
+ self.assertEqual(
+ "00:01:40",
+ RelativeTimeTest.delta.to_json(100.0)
+ )
+
+ with self.assertRaisesRegexp(ValueError, "RelativeTime max value is 23:59:59=86400.0 seconds, but 90000.0 seconds is passed"):
+ RelativeTimeTest.delta.to_json(datetime.timedelta(seconds=90000))
+
+ with self.assertRaises(TypeError):
+ RelativeTimeTest.delta.to_json("123")
+
+ def test_str(self):
+ self.assertEqual(
+ "01:02:03",
+ RelativeTimeTest.delta.to_json(datetime.timedelta(seconds=3723))
+ )
+ self.assertEqual(
+ "11:02:03",
+ RelativeTimeTest.delta.to_json(datetime.timedelta(seconds=39723))
+ )
diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py
index b6475c22e1..5e2f915ba4 100644
--- a/common/lib/xmodule/xmodule/tests/test_video.py
+++ b/common/lib/xmodule/xmodule/tests/test_video.py
@@ -14,6 +14,7 @@ the course, section, subsection, unit, etc.
"""
import unittest
+import datetime
from mock import Mock
from . import LogicTest
@@ -36,24 +37,6 @@ class VideoModuleTest(LogicTest):
'data': ''
}
- def test_parse_time_empty(self):
- """Ensure parse_time returns correctly with None or empty string."""
- expected = ''
- self.assertEqual(VideoDescriptor._parse_time(None), expected)
- self.assertEqual(VideoDescriptor._parse_time(''), expected)
-
- def test_parse_time(self):
- """Ensure that times are parsed correctly into seconds."""
- expected = 247
- output = VideoDescriptor._parse_time('00:04:07')
- self.assertEqual(output, expected)
-
- def test_parse_time_with_float(self):
- """Ensure that times are parsed correctly into seconds."""
- expected = 247
- output = VideoDescriptor._parse_time('247.0')
- self.assertEqual(output, expected)
-
def test_parse_youtube(self):
"""Test parsing old-style Youtube ID strings into a dict."""
youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg'
@@ -224,8 +207,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': 'rABDYkeK0x8',
'show_captions': False,
- 'start_time': 1.0,
- 'end_time': 60,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'],
'data': ''
@@ -250,8 +233,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': 'rABDYkeK0x8',
'show_captions': False,
- 'start_time': 1.0,
- 'end_time': 60,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'source': 'http://www.example.com/source.mp4',
'html5_sources': ['http://www.example.com/source.mp4'],
@@ -279,8 +262,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': '',
'show_captions': True,
- 'start_time': 0.0,
- 'end_time': 0.0,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
'track': 'http://www.example.com/track',
'source': 'http://www.example.com/source.mp4',
'html5_sources': ['http://www.example.com/source.mp4'],
@@ -300,8 +283,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '',
'youtube_id_1_5': '',
'show_captions': True,
- 'start_time': 0.0,
- 'end_time': 0.0,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
'track': '',
'source': '',
'html5_sources': [],
@@ -334,8 +317,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': 'OEoXaMPEzf125',
'youtube_id_1_5': 'OEoXaMPEzf15',
'show_captions': False,
- 'start_time': 0.0,
- 'end_time': 0.0,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
'track': 'http://download_track',
'source': 'http://download_video',
'html5_sources': ["source_1", "source_2"],
@@ -356,8 +339,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': '',
'show_captions': True,
- 'start_time': 0.0,
- 'end_time': 0.0,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
'track': '',
'source': '',
'html5_sources': [],
@@ -386,8 +369,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': 'rABDYkeK0x8',
'show_captions': False,
- 'start_time': 1.0,
- 'end_time': 60,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
@@ -415,8 +398,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': 'rABDYkeK0x8',
'show_captions': False,
- 'start_time': 1.0,
- 'end_time': 60,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
@@ -444,8 +427,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': 'rABDYkeK0x8',
'show_captions': False,
- 'start_time': 1.0,
- 'end_time': 60.0,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
@@ -474,8 +457,8 @@ class VideoExportTestCase(unittest.TestCase):
desc.youtube_id_1_25 = '1EeWXzPdhSA'
desc.youtube_id_1_5 = 'rABDYkeK0x8'
desc.show_captions = False
- desc.start_time = 1.0
- desc.end_time = 60
+ desc.start_time = datetime.timedelta(seconds=1.0)
+ desc.end_time = datetime.timedelta(seconds=60)
desc.track = 'http://www.example.com/track'
desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
@@ -490,6 +473,33 @@ class VideoExportTestCase(unittest.TestCase):
self.assertXmlEqual(expected, xml)
+ def test_export_to_xml_empty_end_time(self):
+ """Test that we write the correct XML on export."""
+ module_system = DummySystem(load_error_modules=True)
+ location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
+ desc = VideoDescriptor(module_system, DictFieldData({}), ScopeIds(None, None, location, location))
+
+ desc.youtube_id_0_75 = 'izygArpw-Qo'
+ desc.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ desc.youtube_id_1_25 = '1EeWXzPdhSA'
+ desc.youtube_id_1_5 = 'rABDYkeK0x8'
+ desc.show_captions = False
+ desc.start_time = datetime.timedelta(seconds=5.0)
+ desc.end_time = datetime.timedelta(seconds=0.0)
+ desc.track = 'http://www.example.com/track'
+ desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
+
+ xml = desc.definition_to_xml(None) # We don't use the `resource_fs` parameter
+ expected = etree.fromstring('''\
+
+ ''')
+
+ self.assertXmlEqual(expected, xml)
+
def test_export_to_xml_empty_parameters(self):
"""Test XML export with defaults."""
module_system = DummySystem(load_error_modules=True)
diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py
index f3f3a961f7..b649397df4 100644
--- a/common/lib/xmodule/xmodule/tests/test_xml_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py
@@ -10,7 +10,7 @@ from xblock.field_data import DictFieldData
from xblock.fields import Scope, String, Dict, Boolean, Integer, Float, Any, List
from xblock.runtime import DbModel
-from xmodule.fields import Date, Timedelta
+from xmodule.fields import Date, Timedelta, RelativeTime
from xmodule.modulestore.inheritance import InheritanceKeyValueStore, InheritanceMixin
from xmodule.xml_module import XmlDescriptor, serialize_field, deserialize_field
from xmodule.course_module import CourseDescriptor
@@ -389,6 +389,28 @@ class TestDeserializeTimedelta(TestDeserialize):
self.assertDeserializeNonString()
+class TestDeserializeRelativeTime(TestDeserialize):
+ """ Tests deserialize as related to Timedelta type. """
+
+ test_field = RelativeTime
+
+ def test_deserialize(self):
+ """
+ There is no check for
+
+ self.assertDeserializeEqual('10:20:30', '10:20:30')
+ self.assertDeserializeNonString()
+
+ because these two tests work only because json.loads fires exception,
+ and xml_module.deserialized_field catches it and returns same value,
+ so there is nothing field-specific here.
+ But other modules do it, so I'm leaving this comment for PR reviewers.
+ """
+
+ # test that from_json produces no exceptions
+ self.assertDeserializeEqual('10:20:30', '"10:20:30"')
+
+
class TestXmlAttributes(XModuleXmlImportTest):
def test_unknown_attribute(self):
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index eccad071e9..e95b387088 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -28,7 +28,8 @@ 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 xmodule.modulestore import Location
-from xblock.fields import Scope, String, Boolean, Float, List, Integer, ScopeIds
+from xblock.fields import Scope, String, Boolean, List, Integer, ScopeIds
+from xmodule.fields import RelativeTime
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
from xblock.runtime import DbModel
@@ -79,18 +80,20 @@ class VideoFields(object):
scope=Scope.settings,
default=""
)
- start_time = Float(
- help="Start time for the video.",
+ start_time = RelativeTime( # datetime.timedelta object
+ help="Start time for the video (HH:MM:SS).",
display_name="Start Time",
scope=Scope.settings,
- default=0.0
+ default=datetime.timedelta(seconds=0)
)
- end_time = Float(
- help="End time for the video.",
+ end_time = RelativeTime( # datetime.timedelta object
+ help="End time for the video (HH:MM:SS).",
display_name="End Time",
scope=Scope.settings,
- default=0.0
+ default=datetime.timedelta(seconds=0)
)
+ #front-end code of video player checks logical validity of (start_time, end_time) pair.
+
source = String(
help="The external URL to download the video. This appears as a link beneath the video.",
display_name="Download Video",
@@ -182,8 +185,8 @@ class VideoModule(VideoFields, XModule):
'data_dir': getattr(self, 'data_dir', None),
'caption_asset_path': caption_asset_path,
'show_captions': json.dumps(self.show_captions),
- 'start': self.start_time,
- 'end': self.end_time,
+ 'start': self.start_time.total_seconds(),
+ 'end': self.end_time.total_seconds(),
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', False),
# TODO: Later on the value 1500 should be taken from some global
# configuration setting field.
@@ -265,8 +268,8 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
attrs = {
'display_name': self.display_name,
'show_captions': json.dumps(self.show_captions),
- 'start_time': datetime.timedelta(seconds=self.start_time),
- 'end_time': datetime.timedelta(seconds=self.end_time),
+ 'start_time': self.start_time,
+ 'end_time': self.end_time,
'sub': self.sub,
}
for key, value in attrs.items():
@@ -359,9 +362,10 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
xml = etree.fromstring(xml_data)
field_data = {}
+ # Convert between key types for certain attributes --
+ # necessary for backwards compatibility.
conversions = {
- 'start_time': cls._parse_time,
- 'end_time': cls._parse_time
+ # example: 'start_time': cls._example_convert_start_time
}
# Convert between key names for certain attributes --
@@ -406,24 +410,6 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
return field_data
- @classmethod
- def _parse_time(cls, str_time):
- """Converts s in '12:34:45' format to seconds. If s is
- None, returns empty string"""
- if not str_time:
- return ''
- else:
- try:
- obj_time = time.strptime(str_time, '%H:%M:%S')
- return datetime.timedelta(
- hours=obj_time.tm_hour,
- minutes=obj_time.tm_min,
- seconds=obj_time.tm_sec
- ).total_seconds()
- except ValueError:
- # We've seen serialized versions of float in this field
- return float(str_time)
-
def _create_youtube_string(module):
"""
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 7b5a2241a2..c9f8ca4eab 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -13,6 +13,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecif
from xblock.core import XBlock
from xblock.fields import Scope, Integer, Float, List, XBlockMixin, String
+from xmodule.fields import RelativeTime
from xblock.fragment import Fragment
from xblock.runtime import Runtime
from xmodule.errortracker import exc_info_to_str
@@ -708,6 +709,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
editor_type = "Float"
elif isinstance(field, List):
editor_type = "List"
+ elif isinstance(field, RelativeTime):
+ editor_type = "RelativeTime"
metadata_fields[field.name]['type'] = editor_type
metadata_fields[field.name]['options'] = [] if values is None else values
diff --git a/common/static/js/vendor/jquery.maskedinput.min.js b/common/static/js/vendor/jquery.maskedinput.min.js
new file mode 100644
index 0000000000..4769d6fda4
--- /dev/null
+++ b/common/static/js/vendor/jquery.maskedinput.min.js
@@ -0,0 +1,7 @@
+/*
+ Masked Input plugin for jQuery
+ Copyright (c) 2007-2013 Josh Bush (digitalbush.com)
+ Licensed under the MIT license (http://digitalbush.com/projects/masked-input-plugin/#license)
+ Version: 1.3.1
+*/
+(function(e){function t(){var e=document.createElement("input"),t="onpaste";return e.setAttribute(t,""),"function"==typeof e[t]?"paste":"input"}var n,a=t()+".mask",r=navigator.userAgent,i=/iphone/i.test(r),o=/android/i.test(r);e.mask={definitions:{9:"[0-9]",a:"[A-Za-z]","*":"[A-Za-z0-9]"},dataName:"rawMaskFn",placeholder:"_"},e.fn.extend({caret:function(e,t){var n;if(0!==this.length&&!this.is(":hidden"))return"number"==typeof e?(t="number"==typeof t?t:e,this.each(function(){this.setSelectionRange?this.setSelectionRange(e,t):this.createTextRange&&(n=this.createTextRange(),n.collapse(!0),n.moveEnd("character",t),n.moveStart("character",e),n.select())})):(this[0].setSelectionRange?(e=this[0].selectionStart,t=this[0].selectionEnd):document.selection&&document.selection.createRange&&(n=document.selection.createRange(),e=0-n.duplicate().moveStart("character",-1e5),t=e+n.text.length),{begin:e,end:t})},unmask:function(){return this.trigger("unmask")},mask:function(t,r){var c,l,s,u,f,h;return!t&&this.length>0?(c=e(this[0]),c.data(e.mask.dataName)()):(r=e.extend({placeholder:e.mask.placeholder,completed:null},r),l=e.mask.definitions,s=[],u=h=t.length,f=null,e.each(t.split(""),function(e,t){"?"==t?(h--,u=e):l[t]?(s.push(RegExp(l[t])),null===f&&(f=s.length-1)):s.push(null)}),this.trigger("unmask").each(function(){function c(e){for(;h>++e&&!s[e];);return e}function d(e){for(;--e>=0&&!s[e];);return e}function m(e,t){var n,a;if(!(0>e)){for(n=e,a=c(t);h>n;n++)if(s[n]){if(!(h>a&&s[n].test(R[a])))break;R[n]=R[a],R[a]=r.placeholder,a=c(a)}b(),x.caret(Math.max(f,e))}}function p(e){var t,n,a,i;for(t=e,n=r.placeholder;h>t;t++)if(s[t]){if(a=c(t),i=R[t],R[t]=n,!(h>a&&s[a].test(i)))break;n=i}}function g(e){var t,n,a,r=e.which;8===r||46===r||i&&127===r?(t=x.caret(),n=t.begin,a=t.end,0===a-n&&(n=46!==r?d(n):a=c(n-1),a=46===r?c(a):a),k(n,a),m(n,a-1),e.preventDefault()):27==r&&(x.val(S),x.caret(0,y()),e.preventDefault())}function v(t){var n,a,i,l=t.which,u=x.caret();t.ctrlKey||t.altKey||t.metaKey||32>l||l&&(0!==u.end-u.begin&&(k(u.begin,u.end),m(u.begin,u.end-1)),n=c(u.begin-1),h>n&&(a=String.fromCharCode(l),s[n].test(a)&&(p(n),R[n]=a,b(),i=c(n),o?setTimeout(e.proxy(e.fn.caret,x,i),0):x.caret(i),r.completed&&i>=h&&r.completed.call(x))),t.preventDefault())}function k(e,t){var n;for(n=e;t>n&&h>n;n++)s[n]&&(R[n]=r.placeholder)}function b(){x.val(R.join(""))}function y(e){var t,n,a=x.val(),i=-1;for(t=0,pos=0;h>t;t++)if(s[t]){for(R[t]=r.placeholder;pos++a.length)break}else R[t]===a.charAt(pos)&&t!==u&&(pos++,i=t);return e?b():u>i+1?(x.val(""),k(0,h)):(b(),x.val(x.val().substring(0,i+1))),u?t:f}var x=e(this),R=e.map(t.split(""),function(e){return"?"!=e?l[e]?r.placeholder:e:void 0}),S=x.val();x.data(e.mask.dataName,function(){return e.map(R,function(e,t){return s[t]&&e!=r.placeholder?e:null}).join("")}),x.attr("readonly")||x.one("unmask",function(){x.unbind(".mask").removeData(e.mask.dataName)}).bind("focus.mask",function(){clearTimeout(n);var e;S=x.val(),e=y(),n=setTimeout(function(){b(),e==t.length?x.caret(0,e):x.caret(e)},10)}).bind("blur.mask",function(){y(),x.val()!=S&&x.change()}).bind("keydown.mask",g).bind("keypress.mask",v).bind(a,function(){setTimeout(function(){var e=y(!0);x.caret(e),r.completed&&e==x.val().length&&r.completed.call(x)},0)}),y()}))}})})(jQuery);
diff --git a/lms/djangoapps/courseware/tests/test_video_xml.py b/lms/djangoapps/courseware/tests/test_video_xml.py
index 616c518ffe..cf202db212 100644
--- a/lms/djangoapps/courseware/tests/test_video_xml.py
+++ b/lms/djangoapps/courseware/tests/test_video_xml.py
@@ -15,7 +15,6 @@ common/lib/xmodule/xmodule/modulestore/tests/factories.py to create the
course, section, subsection, unit, etc.
"""
-import json
import unittest
from django.conf import settings
@@ -109,21 +108,6 @@ class VideoModuleLogicTest(LogicTest):
'data': ''
}
- def test_parse_time(self):
- """Ensure that times are parsed correctly into seconds."""
- output = VideoDescriptor._parse_time('00:04:07')
- self.assertEqual(output, 247)
-
- def test_parse_time_none(self):
- """Check parsing of None."""
- output = VideoDescriptor._parse_time(None)
- self.assertEqual(output, '')
-
- def test_parse_time_empty(self):
- """Check parsing of the empty string."""
- output = VideoDescriptor._parse_time('')
- self.assertEqual(output, '')
-
def test_parse_youtube(self):
"""Test parsing old-style Youtube ID strings into a dict."""
youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg'