Commit 842bbe92 by Don Mitchell

Everything tested and ready for Tom to fix

parent fbc48026
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" : "<ol>[<li><h2>date</h2>content</li>]</ol>"} "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("<ol></ol>")
# Confirm that root is <ol>, iterate over <li>, pull out <h2> 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("<ol></ol>")
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 <ol>, iterate over <li>, pull out <h2> 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("<ol></ol>")
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
......@@ -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"
]
}
<li>
<!-- FIXME what style should we use for initially hidden? --> <!-- TODO decide whether this should use codemirror -->
<form class="new-update-form">
<div class="row">
<label class="inline-label">Date:</label>
<!-- TODO replace w/ date widget and actual date (problem is that persisted version is "Month day" not an actual date obj -->
<input type="text" id="date-entry"
value="<%= updateModel.get('date') %>"></input>
</div>
<div class="row">
<textarea class="new-update-content text-editor"><%= updateModel.get('content') %></textarea>
</div>
<div class="row">
<!-- cid rather than id b/c new ones have cid's not id's -->
<a href="#" class="save-button" name="<%= updateModel.cid %>">Save</a>
<a href="#" class="cancel-button" name="<%= updateModel.cid %>">Cancel</a>
</div>
</form>
<h2>
<span class="calendar-icon"></span><span id="date-display"><%=
updateModel.get('date') %></span>
</h2>
<div class="update-contents"><%= updateModel.get('content') %></div>
<div class="row">
<a href="#" class="edit-button" name="<%- updateModel.cid %>">Edit</a>
<a href="#" class="delete-button" name="<%- updateModel.cid %>"">Delete</a>
</div>
</li>
\ No newline at end of file
<!-- In order to enable better debugging of templates, put them in
the script tag section.
TODO add lazy load fn to load templates as needed (called
from backbone view initialize to set this.template of the view)
-->
<%block name="jsextra">
<script type="text/javascript" charset="utf-8">
// How do I load an html file server side so I can
// Precompiling your templates can be a big help when debugging errors you can't reproduce. This is because precompiled templates can provide line numbers and a stack trace, something that is not possible when compiling templates on the client. The source property is available on the compiled template function for easy precompilation.
// <script>CMS.course_info_update = <%= _.template(jstText).source %>;</script>
</script>
</%block>
\ No newline at end of file
## 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 = "<ol></ol>"
# TODO change label to 'clear'
onNew: (event) ->
ele = $(@model.definition.data).find("ol")
if (ele)
ele = $(ele).first().prepend("<li><h2>" + $.datepicker.formatDate('MM d', new Date()) + "</h2>/n</li>");
\ No newline at end of file
// 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
// <!-- from https://github.com/Gazler/Underscore-Template-Loader/blob/master/index.html -->
// 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;
})();
/* 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
<%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/>
<!-- TODO decode course # from context_course into title -->
<%block name="title">Course Info</%block>
<%block name="jsextra">
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_info.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/course_info_edit.js')}"></script>
<script type="text/javascript" charset="utf-8">
$(document).ready(function(){
editor = new CMS.Views.CourseInfoEdit({
el: $('.course-updates'),
model : new CMS.Models.Module({id : '${course_updates.location.url()}'})
});
$(".new-update-button").bind('click', editor.onNew);
});
$(document).ready(function(){
var course_updates = new CMS.Models.CourseUpdateCollection();
course_updates.reset(${course_updates|n});
course_updates.urlbase = '${url_base}';
var editor = new CMS.Views.CourseInfoEdit({
el: $('.main-wrapper'),
model : new CMS.Models.CourseInfo({
courseId : '${context_course.location}',
updates : course_updates,
// FIXME add handouts
handouts : null})
});
editor.render();
});
</script>
</%block>
......@@ -19,10 +34,10 @@ $(document).ready(function(){
<div class="inner-wrapper">
<h1>Course Info</h1>
<div class="main-column">
<div class="window">
<div class="unit-body window" id="course-update-view">
<h2>Updates</h2>
<a href="#" class="new-update-button">New Update</a>
<div class="course-updates"></div>
<ol class="update-list" id="course-update-list"></ol>
<!-- probably replace w/ a vertical where each element of the vertical is a separate update w/ a date and html field -->
</div>
</div>
......
---
metadata:
display_name: Empty
data: "<p>This is where you can add additional information about your course.</p>"
data: "<ol></ol>"
children: []
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment