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': '