Merge pull request #1421 from edx/anton/metadata-time-field
Anton/metadata time field
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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': '<video />'
|
||||
}
|
||||
|
||||
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('''\
|
||||
<video url_name="SampleProblem1" start_time="0:00:05" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false">
|
||||
<source src="http://www.example.com/source.mp4"/>
|
||||
<source src="http://www.example.com/source.ogg"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</video>
|
||||
''')
|
||||
|
||||
self.assertXmlEqual(expected, xml)
|
||||
|
||||
def test_export_to_xml_empty_parameters(self):
|
||||
"""Test XML export with defaults."""
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
7
common/static/js/vendor/jquery.maskedinput.min.js
vendored
Normal file
7
common/static/js/vendor/jquery.maskedinput.min.js
vendored
Normal file
@@ -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;)if(n=a.charAt(pos-1),s[t].test(n)){R[t]=n,i=t;break}if(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);
|
||||
@@ -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': '<video />'
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user