diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index fc801ac684..4000f011ba 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1,13 +1,14 @@ +from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -''' -cdodge: for a given Xmodule, return the course that it belongs to -NOTE: This makes a lot of assumptions about the format of the course location -Also we have to assert that this module maps to only one course item - it'll throw an -assert if not -''' def get_course_location_for_item(location): + ''' + cdodge: for a given Xmodule, return the course that it belongs to + NOTE: This makes a lot of assumptions about the format of the course location + Also we have to assert that this module maps to only one course item - it'll throw an + assert if not + ''' item_loc = Location(location) # check to see if item is already a course, if so we can skip this @@ -29,3 +30,18 @@ def get_course_location_for_item(location): location = courses[0].location return location + + +def get_lms_link_for_item(item): + if settings.LMS_BASE is not None: + lms_link = "{lms_base}/courses/{course_id}/jump_to/{location}".format( + lms_base=settings.LMS_BASE, + # TODO: These will need to be changed to point to the particular instance of this problem in the particular course + course_id = modulestore().get_containing_courses(item.location)[0].id, + location=item.location, + ) + else: + lms_link = None + + return lms_link + diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index b8da38f2c7..c1c3fc92d0 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -43,7 +43,7 @@ from cache_toolbox.core import set_cached_content, get_cached_content, del_cache from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group from auth.authz import ADMIN_ROLE_NAME, EDITOR_ROLE_NAME -from .utils import get_course_location_for_item +from .utils import get_course_location_for_item, get_lms_link_for_item from xmodule.templates import all_templates @@ -143,13 +143,18 @@ def edit_subsection(request, location): item = modulestore().get_item(location) + lms_link = get_lms_link_for_item(item) + # make sure that location references a 'sequential', otherwise return BadRequest if item.location.category != 'sequential': return HttpResponseBadRequest + logging.debug('Start = {0}'.format(item.start)) + return render_to_response('edit_subsection.html', {'subsection': item, - 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty') + 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), + 'lms_link': lms_link }) @login_required @@ -167,15 +172,7 @@ def edit_unit(request, location): item = modulestore().get_item(location) - if settings.LMS_BASE is not None: - lms_link = "{lms_base}/courses/{course_id}/jump_to/{location}".format( - lms_base=settings.LMS_BASE, - # TODO: These will need to be changed to point to the particular instance of this problem in the particular course - course_id = modulestore().get_containing_courses(item.location)[0].id, - location=item.location, - ) - else: - lms_link = None + lms_link = get_lms_link_for_item(item) component_templates = defaultdict(list) @@ -443,15 +440,29 @@ def save_item(request): # cdodge: also commit any metadata which might have been passed along in the # POST from the client, if it is there - # note, that the postback is not the complete metadata, as there's system metadata which is + # NOTE, that the postback is not the complete metadata, as there's system metadata which is # not presented to the end-user for editing. So let's fetch the original and # 'apply' the submitted metadata, so we don't end up deleting system metadata if request.POST['metadata']: posted_metadata = request.POST['metadata'] # fetch original existing_item = modulestore().get_item(item_location) + # update existing metadata with submitted metadata (which can be partial) + # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' + for metadata_key in posted_metadata.keys(): + # NOTE: We don't want clients to be able to delete 'system metadata' which are not intended to be user + # editable + if posted_metadata[metadata_key] is None and metadata_key not in existing_item.system_metadata_fields: + # remove both from passed in collection as well as the collection read in from the modulestore + if metadata_key in existing_item.metadata: + del existing_item.metadata[metadata_key] + del posted_metadata[metadata_key] + + # overlay the new metadata over the modulestore sourced collection to support partial updates existing_item.metadata.update(posted_metadata) + + # commit to datastore modulestore().update_metadata(item_location, existing_item.metadata) return HttpResponse() diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 66f76c265f..06e8f33c92 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -31,6 +31,11 @@ $(document).ready(function() { $('.sortable-unit-list').sortable(); $('.sortable-unit-list').disableSelection(); $('.sortable-unit-list').bind('sortstop', onUnitReordered); + + // expand/collapse methods for optional date setters + $('.set-date').bind('click', showDateSetter); + $('.remove-date').bind('click', removeDateSetter); + }); // This method only changes the ordering of the child objects in a subsection @@ -55,6 +60,27 @@ function onUnitReordered() { }); } +function getEdxTimeFromDateTimeInputs(date_id, time_id, format) { + var input_date = $('#'+date_id).val(); + var input_time = $('#'+time_id).val(); + + var edxTimeStr = null; + + if (input_date != '') { + if (input_time == '') + input_time = '00:00'; + + // Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing + date = Date.parse(input_date+" "+input_time); + if (format == null) + format = 'yyyy-MM-ddTHH:mm'; + + edxTimeStr = date.toString(format); + } + + return edxTimeStr; +} + function saveSubsection(e) { e.preventDefault(); @@ -65,10 +91,18 @@ function saveSubsection(e) { metadata = {}; for(var i=0; i< metadata_fields.length;i++) { - el = metadata_fields[i]; - metadata[$(el).data("metadata-name")] = el.value; + el = metadata_fields[i]; + metadata[$(el).data("metadata-name")] = el.value; } + + // Piece back together the date/time UI elements into one date/time string + // NOTE: our various "date/time" metadata elements don't always utilize the same formatting string + // so make sure we're passing back the correct format + metadata['start'] = getEdxTimeFromDateTimeInputs('start_date', 'start_time'); + metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time', 'MMMM dd HH:mm'); + + // reordering is done through immediate callbacks when the resorting has completed in the UI children =[]; $.ajax({ @@ -198,8 +232,22 @@ function hideHistoryModal(e) { $modalCover.hide(); } - - +function showDateSetter(e) { + e.preventDefault(); + var $block = $(this).closest('.due-date-input'); + $(this).hide(); + $block.find('.date-setter').show(); +} + +function removeDateSetter(e) { + e.preventDefault(); + var $block = $(this).closest('.due-date-input'); + $block.find('.date-setter').hide(); + $block.find('.set-date').show(); + // clear out the values + $block.find('.date').val(''); + $block.find('.time').val(''); +} diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index 578e2a9ceb..3fa1e135dd 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -1,9 +1,18 @@ <%inherit file="base.html" /> +<%! + from time import mktime + import dateutil.parser + import logging + from datetime import datetime +%> + <%! from django.core.urlresolvers import reverse %> <%block name="bodyclass">subsection <%block name="title">CMS Subsection <%namespace name="units" file="widgets/units.html" /> +<%namespace name='static' file='static_content.html'/> +<%namespace name='datetime' module='datetime'/> <%block name="content">
@@ -15,14 +24,14 @@
- - + +
${units.enum_units(subsection)}
-
+
@@ -35,31 +44,52 @@
-
- - +
+ <% + start_time = datetime.fromtimestamp(mktime(subsection.start)) if subsection.start is not None else None + %> + +
-

The date above differs from the release date of Week 1 – 10/10/2012 at 12:00 am. Sync to Week 1.

+

The date above differs from the release date of Week 1 – 10/10/2012 at 12:00 am. Sync to Week 1.

Set a due date -
-

+

+

+ <% + # due date uses it own formatting for stringifying the date. As with capa_module.py, there's a utility module available for us to use + due_date = dateutil.parser.parse(subsection.metadata.get('due')) if 'due' in subsection.metadata else None + %> + + Remove due date

-
- - hideshow -
+ +<%block name="jsextra"> + + + + + + diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html index 8207b485a7..67e956561c 100644 --- a/cms/templates/widgets/units.html +++ b/cms/templates/widgets/units.html @@ -17,7 +17,7 @@ This def will enumerate through a passed in subsection and list all of the units ${unit.display_name} - - private + - private % if actions:
diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index bcd2932537..0ade3e0e7d 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -118,8 +118,6 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor): stores_state = True # For remembering where in the sequence the student is - template_dir_name = 'sequence' - js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]} js_module_name = "SequenceDescriptor" diff --git a/common/lib/xmodule/xmodule/vertical_module.py b/common/lib/xmodule/xmodule/vertical_module.py index 738ee9ac5f..397bd3e136 100644 --- a/common/lib/xmodule/xmodule/vertical_module.py +++ b/common/lib/xmodule/xmodule/vertical_module.py @@ -45,9 +45,6 @@ class VerticalModule(XModule): class VerticalDescriptor(SequenceDescriptor): module_class = VerticalModule - # cdodge: override the SequenceDescript's template_dir_name to point to default template directory - template_dir_name = "default" - js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} js_module_name = "VerticalDescriptor" diff --git a/common/static/js/vendor/timepicker/datepair.js b/common/static/js/vendor/timepicker/datepair.js new file mode 100644 index 0000000000..f210933593 --- /dev/null +++ b/common/static/js/vendor/timepicker/datepair.js @@ -0,0 +1,209 @@ +/************************ +datepair.js + +This is a component of the jquery-timepicker plugin + +http://jonthornton.github.com/jquery-timepicker/ + +requires jQuery 1.6+ + +version: 1.2.2 +************************/ + +$(function() { + + $('.datepair input.date').each(function(){ + var $this = $(this); + $this.datepicker({ 'dateFormat': 'm/d/yy' }); + + if ($this.hasClass('start') || $this.hasClass('end')) { + $this.on('changeDate change', doDatepair); + } + + }); + + $('.datepair input.time').each(function() { + var $this = $(this); + var opts = { 'showDuration': true, 'timeFormat': 'g:ia', 'scrollDefaultNow': true }; + + if ($this.hasClass('start') || $this.hasClass('end')) { + opts.onSelect = doDatepair; + } + + $this.timepicker(opts); + }); + + $('.datepair').each(initDatepair); + + function initDatepair() + { + var container = $(this); + + var startDateInput = container.find('input.start.date'); + var endDateInput = container.find('input.end.date'); + var dateDelta = 0; + + if (startDateInput.length && endDateInput.length) { + var startDate = new Date(startDateInput.val()); + var endDate = new Date(endDateInput.val()); + + dateDelta = endDate.getTime() - startDate.getTime(); + + container.data('dateDelta', dateDelta); + } + + var startTimeInput = container.find('input.start.time'); + var endTimeInput = container.find('input.end.time'); + + if (startTimeInput.length && endTimeInput.length) { + var startInt = startTimeInput.timepicker('getSecondsFromMidnight'); + var endInt = endTimeInput.timepicker('getSecondsFromMidnight'); + + container.data('timeDelta', endInt - startInt); + + if (dateDelta < 86400000) { + endTimeInput.timepicker('option', 'minTime', startInt); + } + } + } + + function doDatepair() + { + var target = $(this); + if (target.val() == '') { + return; + } + + var container = target.closest('.datepair'); + + if (target.hasClass('date')) { + updateDatePair(target, container); + + } else if (target.hasClass('time')) { + updateTimePair(target, container); + } + } + + function updateDatePair(target, container) + { + var start = container.find('input.start.date'); + var end = container.find('input.end.date'); + + if (!start.length || !end.length) { + return; + } + + var startDate = new Date(start.val()); + var endDate = new Date(end.val()); + + var oldDelta = container.data('dateDelta'); + + if (oldDelta && target.hasClass('start')) { + var newEnd = new Date(startDate.getTime()+oldDelta); + end.val(newEnd.format('m/d/Y')); + end.datepicker('update'); + return; + + } else { + var newDelta = endDate.getTime() - startDate.getTime(); + + if (newDelta < 0) { + newDelta = 0; + + if (target.hasClass('start')) { + end.val(startDate.format('m/d/Y')); + end.datepicker('update'); + } else if (target.hasClass('end')) { + start.val(endDate.format('m/d/Y')); + start.datepicker('update'); + } + } + + if (newDelta < 86400000) { + var startTimeVal = container.find('input.start.time').val(); + + if (startTimeVal) { + container.find('input.end.time').timepicker('option', {'minTime': startTimeVal}); + } + } else { + container.find('input.end.time').timepicker('option', {'minTime': null}); + } + + container.data('dateDelta', newDelta); + } + } + + function updateTimePair(target, container) + { + var start = container.find('input.start.time'); + var end = container.find('input.end.time'); + + if (!start.length || !end.length) { + return; + } + + var startInt = start.timepicker('getSecondsFromMidnight'); + var endInt = end.timepicker('getSecondsFromMidnight'); + + var oldDelta = container.data('timeDelta'); + var dateDelta = container.data('dateDelta'); + + if (target.hasClass('start') && (!dateDelta || dateDelta < 86400000)) { + end.timepicker('option', 'minTime', startInt); + } + + var endDateAdvance = 0; + var newDelta; + + if (oldDelta && target.hasClass('start')) { + // lock the duration and advance the end time + + var newEnd = (startInt+oldDelta)%86400; + + if (newEnd < 0) { + newEnd += 86400; + } + + end.timepicker('setTime', newEnd); + newDelta = newEnd - startInt; + } else if (startInt !== null && endInt !== null) { + newDelta = endInt - startInt; + } else { + return; + } + + container.data('timeDelta', newDelta); + + if (newDelta < 0 && (!oldDelta || oldDelta > 0)) { + // overnight time span. advance the end date 1 day + var endDateAdvance = 86400000; + + } else if (newDelta > 0 && oldDelta < 0) { + // switching from overnight to same-day time span. decrease the end date 1 day + var endDateAdvance = -86400000; + } + + var startInput = container.find('.start.date'); + var endInput = container.find('.end.date'); + + if (startInput.val() && !endInput.val()) { + endInput.val(startInput.val()); + endInput.datepicker('update'); + dateDelta = 0; + container.data('dateDelta', 0); + } + + if (endDateAdvance != 0) { + if (dateDelta || dateDelta === 0) { + var endDate = new Date(endInput.val()); + var newEnd = new Date(endDate.getTime() + endDateAdvance); + endInput.val(newEnd.format('m/d/Y')); + endInput.datepicker('update'); + container.data('dateDelta', dateDelta + endDateAdvance); + } + } + } +}); + +// Simulates PHP's date function +Date.prototype.format=function(format){var returnStr='';var replace=Date.replaceChars;for(var i=0;i'); + var attrs = { 'type': 'text', 'value': self.val() }; + var raw_attrs = self[0].attributes; + + for (var i=0; i < raw_attrs.length; i++) { + attrs[raw_attrs[i].nodeName] = raw_attrs[i].nodeValue; + } + + input.attr(attrs); + self.replaceWith(input); + self = input; + } + + var settings = $.extend({}, _defaults); + + if (options) { + settings = $.extend(settings, options); + } + + if (settings.minTime) { + settings.minTime = _time2int(settings.minTime); + } + + if (settings.maxTime) { + settings.maxTime = _time2int(settings.maxTime); + } + + if (settings.durationTime) { + settings.durationTime = _time2int(settings.durationTime); + } + + if (settings.lang) { + _lang = $.extend(_lang, settings.lang); + } + + self.data('timepicker-settings', settings); + self.attr('autocomplete', 'off'); + self.click(methods.show).focus(methods.show).blur(_formatValue).keydown(_keyhandler); + self.addClass('ui-timepicker-input'); + + if (self.val()) { + var prettyTime = _int2time(_time2int(self.val()), settings.timeFormat); + self.val(prettyTime); + } + + // close the dropdown when container loses focus + $("body").attr("tabindex", -1).focusin(function(e) { + if ($(e.target).closest('.ui-timepicker-input').length == 0 && $(e.target).closest('.ui-timepicker-list').length == 0) { + methods.hide(); + } + }); + + }); + }, + + show: function(e) + { + var self = $(this); + var list = self.data('timepicker-list'); + + // check if list needs to be rendered + if (!list || list.length == 0) { + _render(self); + list = self.data('timepicker-list'); + } + + // check if a flag was set to close this picker + if (self.hasClass('ui-timepicker-hideme')) { + self.removeClass('ui-timepicker-hideme'); + list.hide(); + return; + } + + if (list.is(':visible')) { + return; + } + + // make sure other pickers are hidden + methods.hide(); + + + var topMargin = parseInt(self.css('marginTop').slice(0, -2)); + if (!topMargin) topMargin = 0; // correct for IE returning "auto" + + if ((self.offset().top + self.outerHeight(true) + list.outerHeight()) > $(window).height() + $(window).scrollTop()) { + // position the dropdown on top + list.css({ 'left':(self.offset().left), 'top': self.offset().top + topMargin - list.outerHeight() }); + } else { + // put it under the input + list.css({ 'left':(self.offset().left), 'top': self.offset().top + topMargin + self.outerHeight() }); + } + + list.show(); + + var settings = self.data('timepicker-settings'); + // position scrolling + var selected = list.find('.ui-timepicker-selected'); + + if (!selected.length) { + if (self.val()) { + selected = _findRow(self, list, _time2int(self.val())); + } else if (settings.minTime === null && settings.scrollDefaultNow) { + selected = _findRow(self, list, _time2int(new Date())); + } else if (settings.scrollDefaultTime !== false) { + selected = _findRow(self, list, _time2int(settings.scrollDefaultTime)); + } + } + + if (selected && selected.length) { + var topOffset = list.scrollTop() + selected.position().top - selected.outerHeight(); + list.scrollTop(topOffset); + } else { + list.scrollTop(0); + } + + self.trigger('showTimepicker'); + }, + + hide: function(e) + { + $('.ui-timepicker-list:visible').each(function() { + var list = $(this); + var self = list.data('timepicker-input'); + var settings = self.data('timepicker-settings'); + if (settings.selectOnBlur) { + _selectValue(self); + } + + list.hide(); + self.trigger('hideTimepicker'); + }); + }, + + option: function(key, value) + { + var self = $(this); + var settings = self.data('timepicker-settings'); + var list = self.data('timepicker-list'); + + if (typeof key == 'object') { + settings = $.extend(settings, key); + + } else if (typeof key == 'string' && typeof value != 'undefined') { + settings[key] = value; + + } else if (typeof key == 'string') { + return settings[key]; + } + + if (settings.minTime) { + settings.minTime = _time2int(settings.minTime); + } + + if (settings.maxTime) { + settings.maxTime = _time2int(settings.maxTime); + } + + if (settings.durationTime) { + settings.durationTime = _time2int(settings.durationTime); + } + + self.data('timepicker-settings', settings); + + if (list) { + list.remove(); + self.data('timepicker-list', false); + } + + }, + + getSecondsFromMidnight: function() + { + return _time2int($(this).val()); + }, + + getTime: function() + { + return new Date(_baseDate.valueOf() + (_time2int($(this).val())*1000)); + }, + + setTime: function(value) + { + var self = $(this); + var prettyTime = _int2time(_time2int(value), self.data('timepicker-settings').timeFormat); + self.val(prettyTime); + } + + }; + + // private methods + + function _render(self) + { + var settings = self.data('timepicker-settings'); + var list = self.data('timepicker-list'); + + if (list && list.length) { + list.remove(); + self.data('timepicker-list', false); + } + + list = $('