diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py
index 5963814918..f680dd7262 100644
--- a/cms/djangoapps/contentstore/views.py
+++ b/cms/djangoapps/contentstore/views.py
@@ -66,7 +66,10 @@ log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
-DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential']
+DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
+
+# cdodge: these are categories which should not be parented, they are detached from the hierarchy
+DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
def _modulestore(location):
@@ -692,7 +695,9 @@ def clone_item(request):
new_item.metadata['display_name'] = display_name
_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
- _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
+
+ if new_item.location.category not in DETACHED_CATEGORIES:
+ _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
@@ -873,6 +878,25 @@ def edit_static(request, org, course, coursename):
return render_to_response('edit-static-page.html', {})
+def edit_tabs(request, org, course, coursename):
+ location = ['i4x', org, course, 'course', coursename]
+ course_item = modulestore().get_item(location)
+ static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
+
+ static_tabs = modulestore('direct').get_items(static_tabs_loc)
+
+ components = [
+ static_tab.location.url()
+ for static_tab
+ in static_tabs
+ ]
+
+ return render_to_response('edit-tabs.html', {
+ 'active_tab': 'pages',
+ 'context_course':course_item,
+ 'components': components
+ })
+
def not_found(request):
return render_to_response('error.html', {'error': '404'})
@@ -977,6 +1001,17 @@ def create_new_course(request):
# set a default start date to now
new_course.metadata['start'] = stringify_time(time.gmtime())
+ # set up the default tabs
+ # I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
+ # at least a list populated with the minimal times
+ # @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
+ # place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
+ new_course.tabs = [{"type": "courseware"},
+ {"type": "course_info", "name": "Course Info"},
+ {"type": "discussion", "name": "Discussion"},
+ {"type": "wiki", "name": "Wiki"},
+ {"type": "progress", "name": "Progress"}]
+
modulestore('direct').update_metadata(new_course.location.url(), new_course.own_metadata)
create_all_course_groups(request.user, new_course.location)
diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee
new file mode 100644
index 0000000000..34d86a3051
--- /dev/null
+++ b/cms/static/coffee/src/views/tabs.coffee
@@ -0,0 +1,54 @@
+class CMS.Views.TabsEdit extends Backbone.View
+ events:
+ 'click .new-tab': 'addNewTab'
+
+ initialize: =>
+ @$('.component').each((idx, element) =>
+ new CMS.Views.ModuleEdit(
+ el: element,
+ onDelete: @deleteTab,
+ model: new CMS.Models.Module(
+ id: $(element).data('id'),
+ )
+ )
+ )
+
+ @$('.components').sortable(
+ handle: '.drag-handle'
+ update: (event, ui) => alert 'not yet implemented!'
+ helper: 'clone'
+ opacity: '0.5'
+ placeholder: 'component-placeholder'
+ forcePlaceholderSize: true
+ axis: 'y'
+ items: '> .component'
+ )
+
+ addNewTab: (event) =>
+ event.preventDefault()
+
+ editor = new CMS.Views.ModuleEdit(
+ onDelete: @deleteTab
+ model: new CMS.Models.Module()
+ )
+
+ $('.new-component-item').before(editor.$el)
+
+ editor.cloneTemplate(
+ @model.get('id'),
+ 'i4x://edx/templates/static_tab/Empty'
+ )
+
+ deleteTab: (event) =>
+ if not confirm 'Are you sure you want to delete this component? This action cannot be undone.'
+ return
+ $component = $(event.currentTarget).parents('.component')
+ $.post('/delete_item', {
+ id: $component.data('id')
+ }, =>
+ $component.remove()
+ )
+
+
+
+
diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html
new file mode 100644
index 0000000000..94c5e38260
--- /dev/null
+++ b/cms/templates/edit-tabs.html
@@ -0,0 +1,42 @@
+<%inherit file="base.html" />
+<%! from django.core.urlresolvers import reverse %>
+<%block name="title">Tabs%block>
+<%block name="bodyclass">static-pages%block>
+
+<%block name="jsextra">
+
+%block>
+
+<%block name="content">
+
+
+
+
Static Tabs
+
+
+
+
+
+ % for id in components:
+
+ % endfor
+
+ -
+
+ New Tab
+
+
+
+
+
+
+
+
+%block>
\ No newline at end of file
diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html
index 2b9b2c7884..0f5780a5d2 100644
--- a/cms/templates/widgets/header.html
+++ b/cms/templates/widgets/header.html
@@ -10,7 +10,7 @@
${context_course.display_name}
- Courseware
- - Pages
+ - Tabs
- Assets
- Users
- Import
diff --git a/cms/urls.py b/cms/urls.py
index 8ff4e67a46..e0dbc68129 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -36,6 +36,7 @@ urlpatterns = ('',
'contentstore.views.remove_user', name='remove_user'),
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'),
# temporary landing page for a course
diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index ba5bcd872f..74fa418c91 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -35,10 +35,10 @@ setup(
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"videosequence = xmodule.seq_module:SequenceDescriptor",
"discussion = xmodule.discussion_module:DiscussionDescriptor",
- "course_info = xmodule.html_module:HtmlDescriptor",
- "static_tab = xmodule.html_module:HtmlDescriptor",
+ "course_info = xmodule.html_module:CourseInfoDescriptor",
+ "static_tab = xmodule.html_module:StaticTabDescriptor",
"custom_tag_template = xmodule.raw_module:RawDescriptor",
- "about = xmodule.html_module:HtmlDescriptor"
+ "about = xmodule.html_module:AboutDescriptor"
]
}
)
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 6e3f450324..512247a429 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -266,6 +266,10 @@ class CourseDescriptor(SequenceDescriptor):
"""
return self.metadata.get('tabs')
+ @tabs.setter
+ def tabs(self, value):
+ self.metadata['tabs'] = value
+
@property
def show_calculator(self):
return self.metadata.get("show_calculator", None) == "Yes"
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index f6dddfdd4c..cae099845a 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -170,3 +170,25 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
elt = etree.Element('html')
elt.set("filename", relname)
return elt
+
+
+class AboutDescriptor(HtmlDescriptor):
+ """
+ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
+ in order to be able to create new ones
+ """
+ template_dir_name = "about"
+
+class StaticTabDescriptor(HtmlDescriptor):
+ """
+ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
+ in order to be able to create new ones
+ """
+ template_dir_name = "statictab"
+
+class CourseInfoDescriptor(HtmlDescriptor):
+ """
+ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
+ in order to be able to create new ones
+ """
+ template_dir_name = "courseinfo"
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
index 550e6570ac..19f506906c 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
@@ -276,10 +276,49 @@ class MongoModuleStore(ModuleStoreBase):
source_item = self.collection.find_one(location_to_query(source))
source_item['_id'] = Location(location).dict()
self.collection.insert(source_item)
- return self._load_items([source_item])[0]
+ item = self._load_items([source_item])[0]
+
+ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
+ # if we add one then we need to also add it to the policy information (i.e. metadata)
+ # we should remove this once we can break this reference from the course to static tabs
+ if location.category == 'static_tab':
+ course = self.get_course_for_item(item.location)
+ existing_tabs = course.tabs or []
+ existing_tabs.append({'type':'static_tab', 'name' : item.metadata.get('display_name'), 'url_slug' : item.location.name})
+ course.tabs = existing_tabs
+ self.update_metadata(course.location, course.metadata)
+
+ return item
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(location)
+
+ def get_course_for_item(self, location):
+ '''
+ VS[compat]
+ 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
+ This is only used to support static_tabs as we need to be course module aware
+ '''
+
+ # @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', location.org, location.course, 'course', None]
+ courses = self.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 _update_single_item(self, location, update):
"""
Set update on the specified item, and raises ItemNotFoundError
@@ -327,6 +366,19 @@ class MongoModuleStore(ModuleStoreBase):
location: Something that can be passed to Location
metadata: A nested dictionary of module metadata
"""
+ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
+ # if we add one then we need to also add it to the policy information (i.e. metadata)
+ # we should remove this once we can break this reference from the course to static tabs
+ loc = Location(location)
+ if loc.category == 'static_tab':
+ course = self.get_course_for_item(loc)
+ existing_tabs = course.tabs or []
+ for tab in existing_tabs:
+ if tab.get('url_slug') == loc.name:
+ tab['name'] = metadata.get('display_name')
+ break
+ course.tabs = existing_tabs
+ self.update_metadata(course.location, course.metadata)
self._update_single_item(location, {'metadata': metadata})
@@ -336,6 +388,16 @@ class MongoModuleStore(ModuleStoreBase):
location: Something that can be passed to Location
"""
+ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
+ # if we add one then we need to also add it to the policy information (i.e. metadata)
+ # we should remove this once we can break this reference from the course to static tabs
+ if location.category == 'static_tab':
+ item = self.get_item(location)
+ course = self.get_course_for_item(item.location)
+ existing_tabs = course.tabs or []
+ course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
+ self.update_metadata(course.location, course.metadata)
+
self.collection.remove({'_id': Location(location).dict()})
def get_parent_locations(self, location):
diff --git a/common/lib/xmodule/xmodule/templates/about/empty.yaml b/common/lib/xmodule/xmodule/templates/about/empty.yaml
new file mode 100644
index 0000000000..fa3ed606bd
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/about/empty.yaml
@@ -0,0 +1,5 @@
+---
+metadata:
+ display_name: Empty
+data: "This is where you can add additional information about your course.
"
+children: []
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
new file mode 100644
index 0000000000..fa3ed606bd
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
@@ -0,0 +1,5 @@
+---
+metadata:
+ display_name: Empty
+data: "This is where you can add additional information about your course.
"
+children: []
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/statictab/empty.yaml b/common/lib/xmodule/xmodule/templates/statictab/empty.yaml
new file mode 100644
index 0000000000..410e1496c2
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/statictab/empty.yaml
@@ -0,0 +1,5 @@
+---
+metadata:
+ display_name: Empty
+data: "This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.
"
+children: []
\ No newline at end of file