Merge pull request #1088 from MITx/feature/dhm/course-info
Course info style and implementation
This commit is contained in:
148
cms/djangoapps/contentstore/course_info_model.py
Normal file
148
cms/djangoapps/contentstore/course_info_model.py
Normal file
@@ -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" : "<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.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("<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))
|
||||
83
cms/djangoapps/contentstore/module_info_model.py
Normal file
83
cms/djangoapps/contentstore/module_info_model.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<a href="#" class="edit-button"><span class="edit-icon"></span>Edit</a>
|
||||
|
||||
<h2>Course Handouts</h2>
|
||||
<%if (model.get('data') != null) { %>
|
||||
<div class="handouts-content">
|
||||
<%= model.get('data') %>
|
||||
</div>
|
||||
<% } else {%>
|
||||
<p>You have no handouts defined</p>
|
||||
<% } %>
|
||||
<form class="edit-handouts-form" style="display: block;">
|
||||
<div class="row">
|
||||
<textarea class="handouts-content-editor text-editor"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a href="#" class="save-button">Save</a>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,29 @@
|
||||
<li name="<%- updateModel.cid %>">
|
||||
<!-- 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" class="date" value="<%= updateModel.get('date') %>">
|
||||
</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>
|
||||
<div class="post-preview">
|
||||
<div class="post-actions">
|
||||
<a href="#" class="edit-button" name="<%- updateModel.cid %>"><span class="edit-icon"></span>Edit</a>
|
||||
<a href="#" class="delete-button" name="<%- updateModel.cid %>"><span class="delete-icon"></span>Delete</a>
|
||||
</div>
|
||||
<h2>
|
||||
<span class="calendar-icon"></span><span class="date-display"><%=
|
||||
updateModel.get('date') %></span>
|
||||
</h2>
|
||||
<div class="update-contents"><%= updateModel.get('content') %></div>
|
||||
</div>
|
||||
</li>
|
||||
14
cms/static/coffee/src/client_templates/load_templates.html
Normal file
14
cms/static/coffee/src/client_templates/load_templates.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- 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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 970 B After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.9 KiB |
36
cms/static/js/models/course_info.js
Normal file
36
cms/static/js/models/course_info.js
Normal file
@@ -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
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
10
cms/static/js/models/module_info.js
Normal file
10
cms/static/js/models/module_info.js
Normal file
@@ -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
|
||||
},
|
||||
});
|
||||
77
cms/static/js/template_loader.js
Normal file
77
cms/static/js/template_loader.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// <!-- 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.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;
|
||||
})();
|
||||
275
cms/static/js/views/course_info_edit.js
Normal file
275
cms/static/js/views/course_info_edit.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
}
|
||||
|
||||
.main-column {
|
||||
clear: both;
|
||||
float: left;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
60
cms/templates/course_info.html
Normal file
60
cms/templates/course_info.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<%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="bodyclass">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/models/module_info.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/course_info_edit.js')}"></script>
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
|
||||
<script src="${static.url('js/vendor/timepicker/jquery.timepicker.js')}"></script>
|
||||
<script src="${static.url('js/vendor/timepicker/datepair.js')}"></script>
|
||||
<script src="${static.url('js/vendor/date.js')}"></script>
|
||||
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
$(document).ready(function(){
|
||||
var course_updates = new CMS.Models.CourseUpdateCollection();
|
||||
course_updates.reset(${course_updates|n});
|
||||
course_updates.urlbase = '${url_base}';
|
||||
|
||||
var course_handouts = new CMS.Models.ModuleInfo({
|
||||
id: '${handouts_location}'
|
||||
});
|
||||
course_handouts.urlbase = '${url_base}';
|
||||
|
||||
var editor = new CMS.Views.CourseInfoEdit({
|
||||
el: $('.main-wrapper'),
|
||||
model : new CMS.Models.CourseInfo({
|
||||
courseId : '${context_course.location}',
|
||||
updates : course_updates,
|
||||
handouts : course_handouts
|
||||
})
|
||||
});
|
||||
editor.render();
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="main-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<h1>Course Info</h1>
|
||||
<div class="course-info-wrapper">
|
||||
<div class="main-column window">
|
||||
<article class="course-updates" id="course-update-view">
|
||||
<h2>Course Updates & News</h2>
|
||||
<a href="#" class="new-update-button">New Update</a>
|
||||
<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 -->
|
||||
</article>
|
||||
</div>
|
||||
<div class="sidebar window course-handouts" id="course-handouts-view"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -10,6 +10,7 @@
|
||||
<a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" class="class-name">${context_course.display_name}</a>
|
||||
<ul class="class-nav">
|
||||
<li><a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='courseware-tab'>Courseware</a></li>
|
||||
<li><a href="${reverse('course_info', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='courseinfo-tab'>Course Info</a></li>
|
||||
<li><a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}" id='pages-tab'>Tabs</a></li>
|
||||
<li><a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='assets-tab'>Assets</a></li>
|
||||
<li><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}" id='users-tab'>Users</a></li>
|
||||
|
||||
11
cms/urls.py
11
cms/urls.py
@@ -34,11 +34,20 @@ urlpatterns = ('',
|
||||
'contentstore.views.remove_user', name='remove_user'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<name>[^/]+)/remove_user$',
|
||||
'contentstore.views.remove_user', name='remove_user'),
|
||||
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.static_pages', name='static_pages'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/info/(?P<name>[^/]+)$', '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<org>[^/]+)/(?P<course>[^/]+)/course_info/updates$', 'contentstore.views.course_info_updates', name='course_info'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$', 'contentstore.views.course_info_updates', name='course_info'),
|
||||
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.static_pages',
|
||||
name='static_pages'),
|
||||
url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_static', name='edit_static'),
|
||||
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', '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<module_location>.*)$', 'contentstore.views.module_info', name='module_info'),
|
||||
|
||||
|
||||
# temporary landing page for a course
|
||||
url(r'^edge/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.landing', name='landing'),
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Empty
|
||||
data: "<p>This is where you can add additional information about your course.</p>"
|
||||
data: "<ol></ol>"
|
||||
children: []
|
||||
Reference in New Issue
Block a user