diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py new file mode 100644 index 0000000000..b2ee3bf9c8 --- /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.append(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/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py new file mode 100644 index 0000000000..cd07e4556d --- /dev/null +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -0,0 +1,83 @@ +import logging + +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, Http404 + +def get_module_info(store, location, parent_location = None): + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except ItemNotFoundError: + raise Http404 + + return { + 'id': module.location.url(), + 'data': module.definition['data'], + 'metadata': module.metadata + } + +def set_module_info(store, location, post_data): + module = None + isNew = False + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except: + pass + + if module is None: + # new module at this location + # presume that we have an 'Empty' template + template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) + module = store.clone_item(template_location, location) + isNew = True + + logging.debug('post = {0}'.format(post_data)) + + if post_data.get('data') is not None: + data = post_data['data'] + logging.debug('data = {0}'.format(data)) + store.update_item(location, data) + + # cdodge: note calling request.POST.get('children') will return None if children is an empty array + # so it lead to a bug whereby the last component to be deleted in the UI was not actually + # deleting the children object from the children collection + if 'children' in post_data and post_data['children'] is not None: + children = post_data['children'] + store.update_children(location, children) + + # 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 + # 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 post_data.get('metadata') is not None: + posted_metadata = post_data['metadata'] + + # 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(): + + # let's strip out any metadata fields from the postback which have been identified as system metadata + # and therefore should not be user-editable, so we should accept them back from the client + if metadata_key in module.system_metadata_fields: + del posted_metadata[metadata_key] + elif posted_metadata[metadata_key] is None: + # remove both from passed in collection as well as the collection read in from the modulestore + if metadata_key in module.metadata: + del module.metadata[metadata_key] + del posted_metadata[metadata_key] + + # overlay the new metadata over the modulestore sourced collection to support partial updates + module.metadata.update(posted_metadata) + + # commit to datastore + store.update_metadata(location, module.metadata) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 18afd331d0..508236a1e9 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -33,6 +33,31 @@ def get_course_location_for_item(location): return location +def get_course_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) + + # @hack! We need to find the course location however, we don't + # know the 'name' parameter in this context, so we have + # to assume there's only one item in this query even though we are not specifying a name + course_search_location = ['i4x', item_loc.org, item_loc.course, 'course', None] + courses = modulestore().get_items(course_search_location) + + # make sure we found exactly one match on this above course search + found_cnt = len(courses) + if found_cnt == 0: + raise BaseException('Could not find course at {0}'.format(course_search_location)) + + if found_cnt > 1: + raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + + return courses[0] + def get_lms_link_for_item(location, preview=False): location = Location(location) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 8cc9886396..780a92035c 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -1,22 +1,15 @@ -import traceback from util.json_request import expect_json -import exceptions import json import logging -import mimetypes import os -import StringIO import sys import time import tarfile import shutil -import tempfile from datetime import datetime from collections import defaultdict from uuid import uuid4 -from lxml import etree from path import path -from shutil import rmtree # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' from PIL import Image @@ -28,8 +21,6 @@ from django.core.context_processors import csrf from django_future.csrf import ensure_csrf_cookie from django.core.urlresolvers import reverse from django.conf import settings -from django import forms -from django.shortcuts import redirect from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError @@ -43,23 +34,22 @@ from mitxmako.shortcuts import render_to_response, render_to_string from xmodule.modulestore.django import modulestore from xmodule_modifiers import replace_static_urls, wrap_xmodule from xmodule.exceptions import NotFoundError -from xmodule.timeparse import parse_time, stringify_time from functools import partial -from itertools import groupby -from operator import attrgetter from xmodule.contentstore.django import contentstore from xmodule.contentstore.content import StaticContent -from cache_toolbox.core import set_cached_content, get_cached_content, del_cached_content 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 INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups -from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState +from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item -from xmodule.templates import all_templates from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.xml import edx_xml_parser +from contentstore.course_info_model import get_course_updates,\ + update_course_updates, delete_course_update +from cache_toolbox.core import del_cached_content +from xmodule.timeparse import stringify_time +from contentstore.module_info_model import get_module_info, set_module_info log = logging.getLogger(__name__) @@ -346,7 +336,7 @@ def edit_unit(request, location): def preview_component(request, location): # TODO (vshnayder): change name from id to location in coffee+html as well. if not has_access(request.user, location): - raise Http404 # TODO (vshnayder): better error + raise HttpResponseForbidden() component = modulestore().get_item(location) @@ -915,6 +905,87 @@ def server_error(request): return render_to_response('error.html', {'error': '500'}) +@login_required +@ensure_csrf_cookie +def course_info(request, org, course, name, provided_id=None): + """ + Send models and views as well as html for editing the course info to the client. + + org, course, name: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + course_module = modulestore().get_item(location) + + # get current updates + location = ['i4x', org, course, 'course_info', "updates"] + + return render_to_response('course_info.html', { + 'active_tab': 'courseinfo-tab', + 'context_course': course_module, + 'url_base' : "/" + org + "/" + course + "/", + 'course_updates' : json.dumps(get_course_updates(location)), + 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() + }) + +@expect_json +@login_required +@ensure_csrf_cookie +def course_info_updates(request, org, course, provided_id=None): + """ + restful CRUD operations on course_info updates. + + org, course: Attributes of the Location for the item to edit + provided_id should be none if it's new (create) and a composite of the update db id + index otherwise. + """ + # ??? No way to check for access permission afaik + # get current updates + location = ['i4x', org, course, 'course_info', "updates"] + # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! + if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: + real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] + else: + real_method = request.method + + if request.method == 'GET': + return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json") + elif real_method == 'POST': + # new instance (unless django makes PUT a POST): updates are coming as POST. Not sure why. + return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json") + elif real_method == 'PUT': + return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json") + elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE + return HttpResponse(json.dumps(delete_course_update(location, request.POST, provided_id)), mimetype="application/json") + + +@expect_json +@login_required +@ensure_csrf_cookie +def module_info(request, module_location): + location = Location(module_location) + + # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! + if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: + real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] + else: + real_method = request.method + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + if real_method == 'GET': + return HttpResponse(json.dumps(get_module_info(_modulestore(location), location)), mimetype="application/json") + elif real_method == 'POST' or real_method == 'PUT': + return HttpResponse(json.dumps(set_module_info(_modulestore(location), location, request.POST)), mimetype="application/json") + else: + raise Http400 + + @login_required @ensure_csrf_cookie def asset_index(request, org, course, name): 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_handouts.html b/cms/static/coffee/src/client_templates/course_info_handouts.html new file mode 100644 index 0000000000..958a1c77d6 --- /dev/null +++ b/cms/static/coffee/src/client_templates/course_info_handouts.html @@ -0,0 +1,19 @@ +Edit + +

            Course Handouts

            +<%if (model.get('data') != null) { %> +
            + <%= model.get('data') %> +
            +<% } else {%> +

            You have no handouts defined

            +<% } %> +
            +
            + +
            +
            + Save + Cancel +
            +
            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..79775db5e3 --- /dev/null +++ b/cms/static/coffee/src/client_templates/course_info_update.html @@ -0,0 +1,29 @@ +
          1. + +
            +
            + + + +
            +
            + +
            +
            + + Save + Cancel +
            +
            +
            +
            + Edit + Delete +
            +

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

            +
            <%= updateModel.get('content') %>
            +
            +
          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/img/delete-icon.png b/cms/static/img/delete-icon.png index 1855a2943d..9c7f65daef 100644 Binary files a/cms/static/img/delete-icon.png and b/cms/static/img/delete-icon.png differ diff --git a/cms/static/img/edit-icon.png b/cms/static/img/edit-icon.png index 2da9551010..748d3d2115 100644 Binary files a/cms/static/img/edit-icon.png and b/cms/static/img/edit-icon.png differ diff --git a/cms/static/js/models/course_info.js b/cms/static/js/models/course_info.js new file mode 100644 index 0000000000..8cb5a654cb --- /dev/null +++ b/cms/static/js/models/course_info.js @@ -0,0 +1,36 @@ +// 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, yy', 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/models/module_info.js b/cms/static/js/models/module_info.js new file mode 100644 index 0000000000..6a593372c4 --- /dev/null +++ b/cms/static/js/models/module_info.js @@ -0,0 +1,10 @@ +CMS.Models.ModuleInfo = Backbone.Model.extend({ + url: function() {return "/module_info/" + this.id;}, + + defaults: { + "id": null, + "data": null, + "metadata" : null, + "children" : null + }, +}); \ 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..a18ddf3dfe --- /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.6", + 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..9f662a0697 --- /dev/null +++ b/cms/static/js/views/course_info_edit.js @@ -0,0 +1,275 @@ +/* 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') + }); + + new CMS.Views.ClassInfoHandoutsView({ + el: this.$('#course-handouts-view'), + model: this.model.get('handouts') + }); + 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(); + this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' }); + return this; + }, + + onNew: function(event) { + var self = this; + // 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 $textArea = $newForm.find(".new-update-content").first(); + if (this.$codeMirror == null ) { + this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), { + mode: "text/html", + lineNumbers: true, + lineWrapping: true, + }); + } + + var updateEle = this.$el.find("#course-update-list"); + $(updateEle).prepend($newForm); + $newForm.addClass('editing'); + this.$currentPost = $newForm.closest('li'); + + $modalCover.show(); + $modalCover.bind('click', function() { + self.closeEditor(self, true); + }); + + $('.date').datepicker('destroy'); + $('.date').datepicker({ 'dateFormat': 'MM d, yy' }); + }, + + onSave: function(event) { + var targetModel = this.eventModel(event); + console.log(this.contentEntry(event).val()); + targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() }); + // push change to display, hide the editor, submit the change + this.closeEditor(this); + 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.closeEditor(this, !targetModel.id); + }, + + onEdit: function(event) { + var self = this; + this.$currentPost = $(event.target).closest('li'); + this.$currentPost.addClass('editing'); + + $(this.editor(event)).show(); + var $textArea = this.$currentPost.find(".new-update-content").first(); + if (this.$codeMirror == null ) { + this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), { + mode: "text/html", + lineNumbers: true, + lineWrapping: true, + }); + } + + $modalCover.show(); + var targetModel = this.eventModel(event); + $modalCover.bind('click', function() { + self.closeEditor(self); + }); + }, + + 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();}}); + } + }); + }, + + closeEditor: function(self, removePost) { + var targetModel = self.collection.getByCid(self.$currentPost.attr('name')); + + if(removePost) { + self.$currentPost.remove(); + } + + // close the modal and insert the appropriate data + self.$currentPost.removeClass('editing'); + self.$currentPost.find('.date-display').html(targetModel.get('date')); + self.$currentPost.find('.date').val(targetModel.get('date')); + self.$currentPost.find('.update-contents').html(targetModel.get('content')); + self.$currentPost.find('.new-update-content').val(targetModel.get('content')); + self.$currentPost.find('form').hide(); + $modalCover.unbind('click'); + $modalCover.hide(); + }, + + // 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").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(); + } + +}); + +// the handouts view is dumb right now; it needs tied to a model and all that jazz +CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({ + // collection is CourseUpdateCollection + events: { + "click .save-button" : "onSave", + "click .cancel-button" : "onCancel", + "click .edit-button" : "onEdit" + }, + + initialize: function() { + var self = this; + this.model.fetch( + { + complete: function() { + window.templateLoader.loadRemoteTemplate("course_info_handouts", + "/static/coffee/src/client_templates/course_info_handouts.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); + } + ); + } + } + ); + }, + + render: function () { + var updateEle = this.$el; + var self = this; + this.$el.html( + $(this.template( { + model: this.model + }) + ) + ); + this.$preview = this.$el.find('.handouts-content'); + this.$form = this.$el.find(".edit-handouts-form"); + this.$editor = this.$form.find('.handouts-content-editor'); + this.$form.hide(); + + return this; + }, + + onEdit: function(event) { + var self = this; + this.$editor.val(this.$preview.html()); + this.$form.show(); + if (this.$codeMirror == null) { + this.$codeMirror = CodeMirror.fromTextArea(this.$editor.get(0), { + mode: "text/html", + lineNumbers: true, + lineWrapping: true, + }); + } + $modalCover.show(); + $modalCover.bind('click', function() { + self.closeEditor(self); + }); + }, + + onSave: function(event) { + this.model.set('data', this.$codeMirror.getValue()); + this.render(); + this.model.save(); + this.$form.hide(); + this.closeEditor(this); + }, + + onCancel: function(event) { + this.$form.hide(); + this.closeEditor(this); + }, + + closeEditor: function(self) { + this.$form.hide(); + $modalCover.unbind('click'); + $modalCover.hide(); + } +}); \ No newline at end of file diff --git a/cms/static/sass/_cms_mixins.scss b/cms/static/sass/_cms_mixins.scss index 80a82fb7f3..3a8f24b5a9 100644 --- a/cms/static/sass/_cms_mixins.scss +++ b/cms/static/sass/_cms_mixins.scss @@ -48,18 +48,18 @@ } @mixin white-button { - @include button; - border: 1px solid $darkGrey; - border-radius: 3px; - @include linear-gradient(top, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); - background-color: #dfe5eb; - @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset); - color: #778192; + @include button; + border: 1px solid $darkGrey; + border-radius: 3px; + @include linear-gradient(top, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); + background-color: #dfe5eb; + @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset); + color: #778192; - &:hover { - background-color: #f2f6f9; - color: #778192; - } + &:hover { + background-color: #f2f6f9; + color: #778192; + } } @mixin orange-button { diff --git a/cms/static/sass/_course-info.scss b/cms/static/sass/_course-info.scss index 6eb08943b1..2ec22ebfea 100644 --- a/cms/static/sass/_course-info.scss +++ b/cms/static/sass/_course-info.scss @@ -1,77 +1,179 @@ -body.updates { +.course-info { h2 { margin-bottom: 24px; font-size: 22px; font-weight: 300; } + + .course-info-wrapper { + display: table; + width: 100%; + clear: both; + } + + .main-column, + .course-handouts { + float: none; + display: table-cell; + } + + .main-column { + border-radius: 3px 0 0 3px; + border-right-color: $mediumGrey; + } + + .CodeMirror { + border: 1px solid #3c3c3c; + background: #fff; + color: #3c3c3c; + } } .course-updates { padding: 30px 40px; + margin: 0; - li { - padding: 24px 0 32px; + .update-list > li { + padding: 34px 0 42px; border-top: 1px solid #cbd1db; - } - h3 { - margin-bottom: 18px; - font-size: 14px; - font-weight: 700; - color: #646464; - letter-spacing: 1px; - text-transform: uppercase; + &.editing { + position: relative; + z-index: 1001; + padding: 0; + border-top: none; + border-radius: 3px; + background: #fff; + + .post-preview { + display: none; + } + } + + h1 { + float: none; + font-size: 24px; + font-weight: 300; + } + + h2 { + margin-bottom: 18px; + font-size: 14px; + font-weight: 700; + line-height: 30px; + color: #646464; + letter-spacing: 1px; + text-transform: uppercase; + } + + h3 { + margin: 34px 0 11px; + font-size: 16px; + font-weight: 700; + } } .update-contents { - padding-left: 30px; - p { - font-size: 14px; - line-height: 18px; + font-size: 16px; + line-height: 25px; } p + p { - margin-top: 18px; + margin-top: 25px; + } + + .primary { + border: 1px solid #ddd; + background: #f6f6f6; + padding: 20px; } } .new-update-button { - @include grey-button; + @include blue-button; display: block; text-align: center; - padding: 12px 0; + padding: 18px 0; margin-bottom: 28px; } .new-update-form { @include edit-box; margin-bottom: 24px; + padding: 30px; + border: none; textarea { height: 180px; } } + + .post-actions { + float: right; + + .edit-button, + .delete-button{ + float: left; + @include white-button; + padding: 3px 10px 4px; + margin-left: 7px; + font-size: 12px; + font-weight: 400; + + .edit-icon, + .delete-icon { + margin-right: 4px; + } + } + } } .course-handouts { - padding: 15px 20px; + width: 30%; + padding: 20px 30px; + margin: 0; + border-radius: 0 3px 3px 0; + border-left: none; + background: $lightGrey; - .new-handout-button { - @include grey-button; - display: block; - text-align: center; - padding: 12px 0; - margin-bottom: 28px; + h2 { + font-size: 18px; + font-weight: 700; } - li { - margin-bottom: 10px; + .edit-button { + float: right; + @include white-button; + padding: 3px 10px 4px; + margin-left: 7px; + font-size: 12px; + font-weight: 400; + + .edit-icon, + .delete-icon { + margin-right: 4px; + } + } + + .handouts-content { font-size: 14px; } - .new-handout-form { - @include edit-box; - margin-bottom: 24px; + .treeview-handoutsnav li { + margin-bottom: 12px; + } +} + +.edit-handouts-form { + @include edit-box; + position: absolute; + right: 0; + z-index: 10001; + width: 800px; + padding: 30px; + + textarea { + height: 300px; } } \ No newline at end of file diff --git a/cms/static/sass/_unit.scss b/cms/static/sass/_unit.scss index a7a33f6024..fdf737fc12 100644 --- a/cms/static/sass/_unit.scss +++ b/cms/static/sass/_unit.scss @@ -4,6 +4,7 @@ } .main-column { + clear: both; float: left; width: 70%; } diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html new file mode 100644 index 0000000000..f4fa661b6e --- /dev/null +++ b/cms/templates/course_info.html @@ -0,0 +1,60 @@ +<%inherit file="base.html" /> +<%namespace name='static' file='static_content.html'/> + + +<%block name="title">Course Info +<%block name="bodyclass">course-info + +<%block name="jsextra"> + + + + + + + + + + + + +<%block name="content"> +
            +
            +

            Course Info

            +
            +
            + +
            + +
            +
            +
            + + \ No newline at end of file diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 877f03533c..73ce3f0604 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -10,6 +10,7 @@ ${context_course.display_name}
            • Courseware
            • +
            • Course Info
            • Tabs
            • Assets
            • Users
            • diff --git a/cms/urls.py b/cms/urls.py index e0dbc68129..5df3215d12 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -34,11 +34,20 @@ urlpatterns = ('', 'contentstore.views.remove_user', name='remove_user'), url(r'^(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)/remove_user$', 'contentstore.views.remove_user', name='remove_user'), - url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', name='static_pages'), + url(r'^(?P[^/]+)/(?P[^/]+)/info/(?P[^/]+)$', 'contentstore.views.course_info', name='course_info'), + # ??? Is the following necessary or will the one below work w/ id=None if not sent? + # url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates$', 'contentstore.views.course_info_updates', name='course_info'), + url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates/(?P.*)$', 'contentstore.views.course_info_updates', name='course_info'), + url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', + name='static_pages'), url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_static', name='edit_static'), url(r'^edit_tabs/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'), url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), + # this is a generic method to return the data/metadata associated with a xmodule + url(r'^module_info/(?P.*)$', 'contentstore.views.module_info', name='module_info'), + + # temporary landing page for a course url(r'^edge/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.landing', name='landing'), 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