diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py new file mode 100644 index 0000000000..87dfa5da8f --- /dev/null +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -0,0 +1,148 @@ +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from lxml import etree +import re +from django.http import HttpResponseBadRequest + +## TODO store as array of { date, content } and override course_info_module.definition_from_xml +## This should be in a class which inherits from XmlDescriptor +def get_course_updates(location): + """ + Retrieve the relevant course_info updates and unpack into the model which the client expects: + [{id : location.url() + idx to make unique, date : string, content : html string}] + """ + try: + course_updates = modulestore('direct').get_item(location) + except ItemNotFoundError: + template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"]) + course_updates = modulestore('direct').clone_item(template, Location(location)) + + # current db rep: {"_id" : locationjson, "definition" : { "data" : "
    [
  1. date

    content
  2. ]
"} "metadata" : ignored} + location_base = course_updates.location.url() + + # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. + try: + course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True)) + except etree.XMLSyntaxError: + course_html_parsed = etree.fromstring("
    ") + + # Confirm that root is
      , iterate over
    1. , pull out

      subs and then rest of val + course_upd_collection = [] + if course_html_parsed.tag == 'ol': + # 0 is the oldest so that new ones get unique idx + for idx, update in enumerate(course_html_parsed.iter("li")): + if (len(update) == 0): + continue + elif (len(update) == 1): + content = update.find("h2").tail + else: + content = etree.tostring(update[1]) + + course_upd_collection.append({"id" : location_base + "/" + str(idx), + "date" : update.findtext("h2"), + "content" : content}) + # return newest to oldest + course_upd_collection.reverse() + return course_upd_collection + +def update_course_updates(location, update, passed_id=None): + """ + Either add or update the given course update. It will add it if the passed_id is absent or None. It will update it if + it has an passed_id which has a valid value. Until updates have distinct values, the passed_id is the location url + an index + into the html structure. + """ + try: + course_updates = modulestore('direct').get_item(location) + except ItemNotFoundError: + return HttpResponseBadRequest + + # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. + try: + course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True)) + except etree.XMLSyntaxError: + course_html_parsed = etree.fromstring("
        ") + + try: + new_html_parsed = etree.fromstring(update['content'], etree.XMLParser(remove_blank_text=True)) + except etree.XMLSyntaxError: + new_html_parsed = None + + # Confirm that root is
          , iterate over
        1. , pull out

          subs and then rest of val + if course_html_parsed.tag == 'ol': + # ??? Should this use the id in the json or in the url or does it matter? + if passed_id: + element = course_html_parsed.findall("li")[get_idx(passed_id)] + element[0].text = update['date'] + if (len(element) == 1): + if new_html_parsed is not None: + element[0].tail = None + element.append(new_html_parsed) + else: + element[0].tail = update['content'] + else: + if new_html_parsed is not None: + element[1] = new_html_parsed + else: + element.pop(1) + element[0].tail = update['content'] + else: + idx = len(course_html_parsed.findall("li")) + passed_id = course_updates.location.url() + "/" + str(idx) + element = etree.SubElement(course_html_parsed, "li") + date_element = etree.SubElement(element, "h2") + date_element.text = update['date'] + if new_html_parsed is not None: + element[1] = new_html_parsed + else: + date_element.tail = update['content'] + + # update db record + course_updates.definition['data'] = etree.tostring(course_html_parsed) + modulestore('direct').update_item(location, course_updates.definition['data']) + + return {"id" : passed_id, + "date" : update['date'], + "content" :update['content']} + +def delete_course_update(location, update, passed_id): + """ + Delete the given course_info update from the db. + Returns the resulting course_updates b/c their ids change. + """ + if not passed_id: + return HttpResponseBadRequest + + try: + course_updates = modulestore('direct').get_item(location) + except ItemNotFoundError: + return HttpResponseBadRequest + + # TODO use delete_blank_text parser throughout and cache as a static var in a class + # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. + try: + course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True)) + except etree.XMLSyntaxError: + course_html_parsed = etree.fromstring("
            ") + + if course_html_parsed.tag == 'ol': + # ??? Should this use the id in the json or in the url or does it matter? + element_to_delete = course_html_parsed.xpath('/ol/li[position()=' + str(get_idx(passed_id) + 1) + "]") + if element_to_delete: + course_html_parsed.remove(element_to_delete[0]) + + # update db record + course_updates.definition['data'] = etree.tostring(course_html_parsed) + store = modulestore('direct') + store.update_item(location, course_updates.definition['data']) + + return get_course_updates(location) + +def get_idx(passed_id): + """ + From the url w/ idx appended, get the idx. + """ + # TODO compile this regex into a class static and reuse for each call + idx_matcher = re.search(r'.*/(\d)+$', passed_id) + if idx_matcher: + return int(idx_matcher.group(1)) \ No newline at end of file diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index b396bec944..7b2719a047 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -3,6 +3,6 @@ "/static/js/vendor/jquery.min.js", "/static/js/vendor/json2.js", "/static/js/vendor/underscore-min.js", - "/static/js/vendor/backbone-min.js" + "/static/js/vendor/backbone.js" ] } diff --git a/cms/static/coffee/src/client_templates/course_info_update.html b/cms/static/coffee/src/client_templates/course_info_update.html new file mode 100644 index 0000000000..54a4a38dde --- /dev/null +++ b/cms/static/coffee/src/client_templates/course_info_update.html @@ -0,0 +1,28 @@ +
          1. + +
            +
            + + + +
            +
            + +
            +
            + + Save + Cancel +
            +
            +

            + <%= + updateModel.get('date') %> +

            +
            <%= updateModel.get('content') %>
            +
            + Edit + Delete +
            +
          2. \ No newline at end of file diff --git a/cms/static/coffee/src/client_templates/load_templates.html b/cms/static/coffee/src/client_templates/load_templates.html new file mode 100644 index 0000000000..3ff88d6fe5 --- /dev/null +++ b/cms/static/coffee/src/client_templates/load_templates.html @@ -0,0 +1,14 @@ + + +<%block name="jsextra"> + + + + \ No newline at end of file diff --git a/cms/static/coffee/src/views/course_info_edit.coffee b/cms/static/coffee/src/views/course_info_edit.coffee deleted file mode 100644 index 49cb90c47d..0000000000 --- a/cms/static/coffee/src/views/course_info_edit.coffee +++ /dev/null @@ -1,63 +0,0 @@ -## Derived from and should inherit from a common ancestor w/ ModuleEdit -class CMS.Views.CourseInfoEdit extends Backbone.View - tagName: 'div' - className: 'component' - - events: - "click .component-editor .cancel-button": 'clickCancelButton' - "click .component-editor .save-button": 'clickSaveButton' - "click .component-actions .edit-button": 'clickEditButton' - "click .component-actions .delete-button": 'onDelete' - - initialize: -> - @render() - - $component_editor: => @$el.find('.component-editor') - - loadDisplay: -> - XModule.loadModule(@$el.find('.xmodule_display')) - - loadEdit: -> - if not @module - @module = XModule.loadModule(@$el.find('.xmodule_edit')) - - # don't show metadata (deprecated for course_info) - render: -> - if @model.id - @$el.load("/preview_component/#{@model.id}", => - @loadDisplay() - @delegateEvents() - ) - - clickSaveButton: (event) => - event.preventDefault() - data = @module.save() - @model.save(data).done( => - # # showToastMessage("Your changes have been saved.", null, 3) - @module = null - @render() - @$el.removeClass('editing') - ).fail( -> - showToastMessage("There was an error saving your changes. Please try again.", null, 3) - ) - - clickCancelButton: (event) -> - event.preventDefault() - @$el.removeClass('editing') - @$component_editor().slideUp(150) - - clickEditButton: (event) -> - event.preventDefault() - @$el.addClass('editing') - @$component_editor().slideDown(150) - @loadEdit() - - onDelete: (event) -> - # clear contents, don't delete - @model.definition.data = "
              " - # TODO change label to 'clear' - - onNew: (event) -> - ele = $(@model.definition.data).find("ol") - if (ele) - ele = $(ele).first().prepend("
            1. " + $.datepicker.formatDate('MM d', new Date()) + "

              /n
            2. "); \ No newline at end of file diff --git a/cms/static/js/models/course_info.js b/cms/static/js/models/course_info.js new file mode 100644 index 0000000000..b1d10c8d16 --- /dev/null +++ b/cms/static/js/models/course_info.js @@ -0,0 +1,34 @@ +// single per course holds the updates and handouts +CMS.Models.CourseInfo = Backbone.Model.extend({ + // This model class is not suited for restful operations and is considered just a server side initialized container + url: '', + + defaults: { + "courseId": "", // the location url + "updates" : null, // UpdateCollection + "handouts": null // HandoutCollection + }, + + idAttribute : "courseId" +}); + +// course update -- biggest kludge here is the lack of a real id to map updates to originals +CMS.Models.CourseUpdate = Backbone.Model.extend({ + defaults: { + "date" : $.datepicker.formatDate('MM d', new Date()), + "content" : "" + } +}); + +/* + The intitializer of this collection must set id to the update's location.url and courseLocation to the course's location. Must pass the + collection of updates as [{ date : "month day", content : "html"}] +*/ +CMS.Models.CourseUpdateCollection = Backbone.Collection.extend({ + url : function() {return this.urlbase + "course_info/updates/";}, + + model : CMS.Models.CourseUpdate +}); + + + \ No newline at end of file diff --git a/cms/static/js/template_loader.js b/cms/static/js/template_loader.js new file mode 100644 index 0000000000..b266575b7a --- /dev/null +++ b/cms/static/js/template_loader.js @@ -0,0 +1,77 @@ +// +// TODO Figure out how to initialize w/ static views from server (don't call .load but instead inject in django as strings) +// so this only loads the lazily loaded ones. +(function() { + if (typeof window.templateLoader == 'function') return; + + var templateLoader = { + templateVersion: "0.0.3", + templates: {}, + loadRemoteTemplate: function(templateName, filename, callback) { + if (!this.templates[templateName]) { + var self = this; + jQuery.ajax({url : filename, + success : function(data) { + self.addTemplate(templateName, data); + self.saveLocalTemplates(); + callback(data); + }, + error : function(xhdr, textStatus, errorThrown) { + console.log(textStatus); }, + dataType : "html" + }) + } + else { + callback(this.templates[templateName]); + } + }, + + addTemplate: function(templateName, data) { + // is there a reason this doesn't go ahead and compile the template? _.template(data) + // I suppose localstorage use would still req raw string rather than compiled version, but that sd work + // if it maintains a separate cache of uncompiled ones + this.templates[templateName] = data; + }, + + localStorageAvailable: function() { + try { + return 'localStorage' in window && window['localStorage'] !== null; + } catch (e) { + return false; + } + }, + + saveLocalTemplates: function() { + if (this.localStorageAvailable) { + localStorage.setItem("templates", JSON.stringify(this.templates)); + localStorage.setItem("templateVersion", this.templateVersion); + } + }, + + loadLocalTemplates: function() { + if (this.localStorageAvailable) { + var templateVersion = localStorage.getItem("templateVersion"); + if (templateVersion && templateVersion == this.templateVersion) { + var templates = localStorage.getItem("templates"); + if (templates) { + templates = JSON.parse(templates); + for (var x in templates) { + if (!this.templates[x]) { + this.addTemplate(x, templates[x]); + } + } + } + } + else { + localStorage.removeItem("templates"); + localStorage.removeItem("templateVersion"); + } + } + } + + + + }; + templateLoader.loadLocalTemplates(); + window.templateLoader = templateLoader; + })(); diff --git a/cms/static/js/views/course_info_edit.js b/cms/static/js/views/course_info_edit.js new file mode 100644 index 0000000000..4f504d72e8 --- /dev/null +++ b/cms/static/js/views/course_info_edit.js @@ -0,0 +1,138 @@ +/* this view should own everything on the page which has controls effecting its operation + generate other views for the individual editors. + The render here adds views for each update/handout by delegating to their collections but does not + generate any html for the surrounding page. +*/ +CMS.Views.CourseInfoEdit = Backbone.View.extend({ + // takes CMS.Models.CourseInfo as model + tagName: 'div', + + render: function() { + // instantiate the ClassInfoUpdateView and delegate the proper dom to it + new CMS.Views.ClassInfoUpdateView({ + el: this.$('#course-update-view'), + collection: this.model.get('updates') + }); + // TODO instantiate the handouts view + return this; + } +}); + +// ??? Programming style question: should each of these classes be in separate files? +CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ + // collection is CourseUpdateCollection + events: { + "click .new-update-button" : "onNew", + "click .save-button" : "onSave", + "click .cancel-button" : "onCancel", + "click .edit-button" : "onEdit", + "click .delete-button" : "onDelete" + }, + + initialize: function() { + var self = this; + // instantiates an editor template for each update in the collection + window.templateLoader.loadRemoteTemplate("course_info_update", + // TODO Where should the template reside? how to use the static.url to create the path? + "/static/coffee/src/client_templates/course_info_update.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); + } + ); + }, + + render: function () { + // iterate over updates and create views for each using the template + var updateEle = this.$el.find("#course-update-list"); + // remove and then add all children + $(updateEle).empty(); + var self = this; + this.collection.each(function (update) { + var newEle = self.template({ updateModel : update }); + $(updateEle).append(newEle); + }); + this.$el.find(".new-update-form").hide(); + return this; + }, + + onNew: function(event) { + // create new obj, insert into collection, and render this one ele overriding the hidden attr + var newModel = new CMS.Models.CourseUpdate(); + this.collection.add(newModel, {at : 0}); + + var newForm = this.template({ updateModel : newModel }); + var updateEle = this.$el.find("#course-update-list"); + $(updateEle).append(newForm); + $(newForm).find(".new-update-form").show(); + }, + + onSave: function(event) { + var targetModel = this.eventModel(event); + targetModel.set({ date : this.dateEntry(event).val(), content : this.contentEntry(event).val() }); + // push change to display, hide the editor, submit the change + $(this.dateDisplay(event)).val(targetModel.get('date')); + $(this.contentDisplay(event)).val(targetModel.get('content')); + $(this.editor(event)).hide(); + + targetModel.save(); + }, + + onCancel: function(event) { + // change editor contents back to model values and hide the editor + $(this.editor(event)).hide(); + var targetModel = this.eventModel(event); + $(this.dateEntry(event)).val(targetModel.get('date')); + $(this.contentEntry(event)).val(targetModel.get('content')); + }, + + onEdit: function(event) { + $(this.editor(event)).show(); + }, + + onDelete: function(event) { + // TODO ask for confirmation + // remove the dom element and delete the model + var targetModel = this.eventModel(event); + this.modelDom(event).remove(); + var cacheThis = this; + targetModel.destroy({success : function (model, response) { + cacheThis.collection.fetch({success : function() {cacheThis.render();}}); + } + }); + }, + + // Dereferencing from events to screen elements + eventModel: function(event) { + // not sure if it should be currentTarget or delegateTarget + return this.collection.getByCid($(event.currentTarget).attr("name")); + }, + + modelDom: function(event) { + return $(event.currentTarget).closest("li"); + }, + + editor: function(event) { + var li = $(event.currentTarget).closest("li"); + if (li) return $(li).find("form").first(); + }, + + dateEntry: function(event) { + var li = $(event.currentTarget).closest("li"); + if (li) return $(li).find("#date-entry").first(); + }, + + contentEntry: function(event) { + return $(event.currentTarget).closest("li").find(".new-update-content").first(); + }, + + dateDisplay: function(event) { + return $(event.currentTarget).closest("li").find("#date-display").first(); + }, + + contentDisplay: function(event) { + return $(event.currentTarget).closest("li").find(".update-contents").first(); + } + +}); + \ No newline at end of file diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html index 72facf7c2e..f68aff008b 100644 --- a/cms/templates/course_info.html +++ b/cms/templates/course_info.html @@ -1,16 +1,31 @@ <%inherit file="base.html" /> +<%namespace name='static' file='static_content.html'/> + <%block name="title">Course Info <%block name="jsextra"> + + + + @@ -19,10 +34,10 @@ $(document).ready(function(){

              Course Info

              -
              +

              Updates

              New Update -
              +
                diff --git a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml index fa3ed606bd..c6958ed887 100644 --- a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml +++ b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml @@ -1,5 +1,5 @@ --- metadata: display_name: Empty -data: "

                This is where you can add additional information about your course.

                " +data: "
                  " children: [] \ No newline at end of file