diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature
new file mode 100644
index 0000000000..4708a60be1
--- /dev/null
+++ b/cms/djangoapps/contentstore/features/advanced-settings.feature
@@ -0,0 +1,51 @@
+Feature: Advanced (manual) course policy
+ In order to specify course policy settings for which no custom user interface exists
+ I want to be able to manually enter JSON key/value pairs
+
+ Scenario: A course author sees only display_name on a newly created course
+ Given I have opened a new course in Studio
+ When I select the Advanced Settings
+ Then I see only the display name
+
+ Scenario: Test if there are no policy settings without existing UI controls
+ Given I am on the Advanced Course Settings page in Studio
+ When I delete the display name
+ Then there are no advanced policy settings
+ And I reload the page
+ Then there are no advanced policy settings
+
+ Scenario: Test cancel editing key name
+ Given I am on the Advanced Course Settings page in Studio
+ When I edit the name of a policy key
+ And I press the "Cancel" notification button
+ Then the policy key name is unchanged
+
+ Scenario: Test editing key name
+ Given I am on the Advanced Course Settings page in Studio
+ When I edit the name of a policy key
+ And I press the "Save" notification button
+ Then the policy key name is changed
+
+ Scenario: Test cancel editing key value
+ Given I am on the Advanced Course Settings page in Studio
+ When I edit the value of a policy key
+ And I press the "Cancel" notification button
+ Then the policy key value is unchanged
+
+ Scenario: Test editing key value
+ Given I am on the Advanced Course Settings page in Studio
+ When I edit the value of a policy key
+ And I press the "Save" notification button
+ Then the policy key value is changed
+
+ Scenario: Add new entries, and they appear alphabetically after save
+ Given I am on the Advanced Course Settings page in Studio
+ When I create New Entries
+ Then they are alphabetized
+ And I reload the page
+ Then they are alphabetized
+
+ Scenario: Test how multi-line input appears
+ Given I am on the Advanced Course Settings page in Studio
+ When I create a JSON object
+ Then it is displayed as formatted
diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py
new file mode 100644
index 0000000000..91daf70718
--- /dev/null
+++ b/cms/djangoapps/contentstore/features/advanced-settings.py
@@ -0,0 +1,182 @@
+from lettuce import world, step
+from common import *
+import time
+
+from nose.tools import assert_equal
+from nose.tools import assert_true
+
+"""
+http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
+"""
+from selenium.webdriver.common.keys import Keys
+
+
+############### ACTIONS ####################
+@step('I select the Advanced Settings$')
+def i_select_advanced_settings(step):
+ expand_icon_css = 'li.nav-course-settings i.icon-expand'
+ if world.browser.is_element_present_by_css(expand_icon_css):
+ css_click(expand_icon_css)
+ link_css = 'li.nav-course-settings-advanced a'
+ css_click(link_css)
+
+
+@step('I am on the Advanced Course Settings page in Studio$')
+def i_am_on_advanced_course_settings(step):
+ step.given('I have opened a new course in Studio')
+ step.given('I select the Advanced Settings')
+
+
+# TODO: this is copied from terrain's step.py. Need to figure out how to share that code.
+@step('I reload the page$')
+def reload_the_page(step):
+ world.browser.reload()
+
+
+@step(u'I edit the name of a policy key$')
+def edit_the_name_of_a_policy_key(step):
+ policy_key_css = 'input.policy-key'
+ e = css_find(policy_key_css).first
+ e.fill('new')
+
+
+@step(u'I press the "([^"]*)" notification button$')
+def press_the_notification_button(step, name):
+ world.browser.click_link_by_text(name)
+
+
+@step(u'I edit the value of a policy key$')
+def edit_the_value_of_a_policy_key(step):
+ """
+ It is hard to figure out how to get into the CodeMirror
+ area, so cheat and do it from the policy key field :)
+ """
+ policy_key_css = 'input.policy-key'
+ e = css_find(policy_key_css).first
+ e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
+
+
+@step('I delete the display name$')
+def delete_the_display_name(step):
+ delete_entry(0)
+ click_save()
+
+
+@step('create New Entries$')
+def create_new_entries(step):
+ create_entry("z", "apple")
+ create_entry("a", "zebra")
+ click_save()
+
+
+@step('I create a JSON object$')
+def create_JSON_object(step):
+ create_entry("json", '{"key": "value", "key_2": "value_2"}')
+ click_save()
+
+
+############### RESULTS ####################
+@step('I see only the display name$')
+def i_see_only_display_name(step):
+ assert_policy_entries(["display_name"], ['"Robot Super Course"'])
+
+
+@step('there are no advanced policy settings$')
+def no_policy_settings(step):
+ assert_policy_entries([], [])
+
+
+@step('they are alphabetized$')
+def they_are_alphabetized(step):
+ assert_policy_entries(["a", "display_name", "z"], ['"zebra"', '"Robot Super Course"', '"apple"'])
+
+
+@step('it is displayed as formatted$')
+def it_is_formatted(step):
+ assert_policy_entries(["display_name", "json"], ['"Robot Super Course"', '{\n "key": "value",\n "key_2": "value_2"\n}'])
+
+
+@step(u'the policy key name is unchanged$')
+def the_policy_key_name_is_unchanged(step):
+ policy_key_css = 'input.policy-key'
+ e = css_find(policy_key_css).first
+ assert_equal(e.value, 'display_name')
+
+
+@step(u'the policy key name is changed$')
+def the_policy_key_name_is_changed(step):
+ policy_key_css = 'input.policy-key'
+ e = css_find(policy_key_css).first
+ assert_equal(e.value, 'new')
+
+
+@step(u'the policy key value is unchanged$')
+def the_policy_key_value_is_unchanged(step):
+ policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
+ e = css_find(policy_value_css).first
+ assert_equal(e.value, '"Robot Super Course"')
+
+
+@step(u'the policy key value is changed$')
+def the_policy_key_value_is_unchanged(step):
+ policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
+ e = css_find(policy_value_css).first
+ assert_equal(e.value, '"Robot Super Course X"')
+
+
+############# HELPERS ###############
+def create_entry(key, value):
+ # Scroll down the page so the button is visible
+ world.scroll_to_bottom()
+ css_click_at('a.new-advanced-policy-item', 10, 10)
+ new_key_css = 'div#__new_advanced_key__ input'
+ new_key_element = css_find(new_key_css).first
+ new_key_element.fill(key)
+# For some reason have to get the instance for each command (get error that it is no longer attached to the DOM)
+# Have to do all this because Selenium has a bug that fill does not remove existing text
+ new_value_css = 'div.CodeMirror textarea'
+ css_find(new_value_css).last.fill("")
+ css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE)
+ css_find(new_value_css).last.fill(value)
+
+
+def delete_entry(index):
+ """
+ Delete the nth entry where index is 0-based
+ """
+ css = '.delete-button'
+ assert_true(world.browser.is_element_present_by_css(css, 5))
+ delete_buttons = css_find(css)
+ assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index))
+ delete_buttons[index].click()
+
+
+def assert_policy_entries(expected_keys, expected_values):
+ assert_entries('.key input', expected_keys)
+ assert_entries('.json', expected_values)
+
+
+def assert_entries(css, expected_values):
+ webElements = css_find(css)
+ assert_equal(len(expected_values), len(webElements))
+# Sometimes get stale reference if I hold on to the array of elements
+ for counter in range(len(expected_values)):
+ assert_equal(expected_values[counter], css_find(css)[counter].value)
+
+
+def click_save():
+ css = ".save-button"
+
+ def is_shown(driver):
+ visible = css_find(css).first.visible
+ if visible:
+ # Even when waiting for visible, this fails sporadically. Adding in a small wait.
+ time.sleep(float(1))
+ return visible
+ wait_for(is_shown)
+ css_click(css)
+
+
+def fill_last_field(value):
+ newValue = css_find('#__new_advanced_key__ input').first
+ newValue.fill(value)
diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py
index 925bb101f3..61b4fee9f6 100644
--- a/cms/djangoapps/contentstore/features/common.py
+++ b/cms/djangoapps/contentstore/features/common.py
@@ -1,12 +1,13 @@
from lettuce import world, step
-from factories import *
-from django.core.management import call_command
from lettuce.django import django_url
-from django.conf import settings
-from django.core.management import call_command
from nose.tools import assert_true
from nose.tools import assert_equal
+from selenium.webdriver.support.ui import WebDriverWait
+
+from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory
+from terrain.factories import CourseFactory, GroupFactory
import xmodule.modulestore.django
+from auth.authz import get_user_by_email
from logging import getLogger
logger = getLogger(__name__)
@@ -44,6 +45,13 @@ def i_press_the_category_delete_icon(step, category):
assert False, 'Invalid category: %s' % category
css_click(css)
+
+@step('I have opened a new course in Studio$')
+def i_have_opened_a_new_course(step):
+ clear_courses()
+ log_into_studio()
+ create_a_course()
+
####### HELPER FUNCTIONS ##############
@@ -86,13 +94,38 @@ def assert_css_with_text(css, text):
def css_click(css):
+ assert_true(world.browser.is_element_present_by_css(css, 5))
world.browser.find_by_css(css).first.click()
+def css_click_at(css, x=10, y=10):
+ '''
+ A method to click at x,y coordinates of the element
+ rather than in the center of the element
+ '''
+ assert_true(world.browser.is_element_present_by_css(css, 5))
+ e = world.browser.find_by_css(css).first
+ e.action_chains.move_to_element_with_offset(e._element, x, y)
+ e.action_chains.click()
+ e.action_chains.perform()
+
+
def css_fill(css, value):
world.browser.find_by_css(css).first.fill(value)
+def css_find(css):
+ return world.browser.find_by_css(css)
+
+
+def wait_for(func):
+ WebDriverWait(world.browser.driver, 10).until(func)
+
+
+def id_find(id):
+ return world.browser.find_by_id(id)
+
+
def clear_courses():
flush_xmodule_store()
@@ -129,9 +162,18 @@ def log_into_studio(
def create_a_course():
- css_click('a.new-course-button')
- fill_in_course_info()
- css_click('input.new-course-save')
+ c = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
+
+ # Add the user to the instructor group of the course
+ # so they will have the permissions to see it in studio
+ g = GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course')
+ u = get_user_by_email('robot+studio@edx.org')
+ u.groups.add(g)
+ u.save()
+ world.browser.reload()
+
+ course_link_css = 'span.class-name'
+ css_click(course_link_css)
course_title_css = 'span.course-title'
assert_true(world.browser.is_element_present_by_css(course_title_css, 5))
diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py
index 3bcaeab6c4..ca67c477fb 100644
--- a/cms/djangoapps/contentstore/features/section.py
+++ b/cms/djangoapps/contentstore/features/section.py
@@ -4,13 +4,6 @@ from common import *
############### ACTIONS ####################
-@step('I have opened a new course in Studio$')
-def i_have_opened_a_new_course(step):
- clear_courses()
- log_into_studio()
- create_a_course()
-
-
@step('I click the new section link$')
def i_click_new_section_link(step):
link_css = 'a.new-courseware-section-button'
@@ -46,6 +39,7 @@ def i_save_a_new_section_release_date(step):
css_fill(time_css, '12:00am')
css_click('a.save-button')
+
############ ASSERTIONS ###################
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 86503d2136..5560d2e39b 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -1,7 +1,5 @@
import datetime
-import time
import json
-import calendar
import copy
from util import converters
from util.converters import jsdate_to_time
@@ -11,7 +9,6 @@ from django.test.client import Client
from django.core.urlresolvers import reverse
from django.utils.timezone import UTC
-import xmodule
from xmodule.modulestore import Location
from cms.djangoapps.models.settings.course_details import (CourseDetails,
CourseSettingsEncoder)
@@ -22,6 +19,10 @@ from django.test import TestCase
from utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
+from cms.djangoapps.models.settings.course_metadata import CourseMetadata
+from xmodule.modulestore.xml_importer import import_from_xml
+from xmodule.modulestore.django import modulestore
+
# YYYY-MM-DDThh:mm:ss.s+/-HH:MM
class ConvertersTestCase(TestCase):
@@ -261,3 +262,64 @@ class CourseGradingTest(CourseTestCase):
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
+
+class CourseMetadataEditingTest(CourseTestCase):
+ def setUp(self):
+ CourseTestCase.setUp(self)
+ # add in the full class too
+ import_from_xml(modulestore(), 'common/test/data/', ['full'])
+ self.fullcourse_location = Location(['i4x','edX','full','course','6.002_Spring_2012', None])
+
+
+ def test_fetch_initial_fields(self):
+ test_model = CourseMetadata.fetch(self.course_location)
+ self.assertIn('display_name', test_model, 'Missing editable metadata field')
+ self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
+
+ test_model = CourseMetadata.fetch(self.fullcourse_location)
+ self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
+ self.assertIn('display_name', test_model, 'full missing editable metadata field')
+ self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
+ self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
+ self.assertIn('showanswer', test_model, 'showanswer field ')
+ self.assertIn('xqa_key', test_model, 'xqa_key field ')
+
+ def test_update_from_json(self):
+ test_model = CourseMetadata.update_from_json(self.course_location,
+ { "a" : 1,
+ "b_a_c_h" : { "c" : "test" },
+ "test_text" : "a text string"})
+ self.update_check(test_model)
+ # try fresh fetch to ensure persistence
+ test_model = CourseMetadata.fetch(self.course_location)
+ self.update_check(test_model)
+ # now change some of the existing metadata
+ test_model = CourseMetadata.update_from_json(self.course_location,
+ { "a" : 2,
+ "display_name" : "jolly roger"})
+ self.assertIn('display_name', test_model, 'Missing editable metadata field')
+ self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
+ self.assertIn('a', test_model, 'Missing revised a metadata field')
+ self.assertEqual(test_model['a'], 2, "a not expected value")
+
+ def update_check(self, test_model):
+ self.assertIn('display_name', test_model, 'Missing editable metadata field')
+ self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
+ self.assertIn('a', test_model, 'Missing new a metadata field')
+ self.assertEqual(test_model['a'], 1, "a not expected value")
+ self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field')
+ self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value")
+ self.assertIn('test_text', test_model, 'Missing test_text metadata field')
+ self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value")
+
+
+ def test_delete_key(self):
+ test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']})
+ # ensure no harm
+ self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
+ self.assertIn('display_name', test_model, 'full missing editable metadata field')
+ self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
+ self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
+ # check for deletion effectiveness
+ self.assertNotIn('showanswer', test_model, 'showanswer field still in')
+ self.assertNotIn('xqa_key', test_model, 'xqa_key field still in')
\ No newline at end of file
diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py
index 6d5905afe7..639f2258e0 100644
--- a/cms/djangoapps/contentstore/views.py
+++ b/cms/djangoapps/contentstore/views.py
@@ -58,8 +58,8 @@ from cms.djangoapps.models.settings.course_details import CourseDetails,\
CourseSettingsEncoder
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
-from lxml import etree
from django.shortcuts import redirect
+from cms.djangoapps.models.settings.course_metadata import CourseMetadata
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
@@ -365,7 +365,6 @@ def preview_component(request, location):
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
})
-
@expect_json
@login_required
@ensure_csrf_cookie
@@ -682,7 +681,6 @@ def create_draft(request):
return HttpResponse()
-
@login_required
@expect_json
def publish_draft(request):
@@ -712,7 +710,6 @@ def unpublish_unit(request):
return HttpResponse()
-
@login_required
@expect_json
def clone_item(request):
@@ -901,7 +898,6 @@ def remove_user(request, location):
def landing(request, org, course, coursename):
return render_to_response('temp-course-landing.html', {})
-
@login_required
@ensure_csrf_cookie
def static_pages(request, org, course, coursename):
@@ -1005,7 +1001,6 @@ def edit_tabs(request, org, course, coursename):
'components': components
})
-
def not_found(request):
return render_to_response('error.html', {'error': '404'})
@@ -1041,7 +1036,6 @@ def course_info(request, org, course, name, provided_id=None):
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
})
-
@expect_json
@login_required
@ensure_csrf_cookie
@@ -1112,7 +1106,6 @@ def module_info(request, module_location):
else:
return HttpResponseBadRequest()
-
@login_required
@ensure_csrf_cookie
def get_course_settings(request, org, course, name):
@@ -1159,6 +1152,28 @@ def course_config_graders_page(request, org, course, name):
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
})
+@login_required
+@ensure_csrf_cookie
+def course_config_advanced_page(request, org, course, name):
+ """
+ Send models and views as well as html for editing the advanced course settings 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)
+
+ return render_to_response('settings_advanced.html', {
+ 'context_course': course_module,
+ 'course_location' : location,
+ 'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST),
+ 'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
+ })
@expect_json
@login_required
@@ -1191,7 +1206,6 @@ def course_settings_updates(request, org, course, name, section):
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
mimetype="application/json")
-
@expect_json
@login_required
@ensure_csrf_cookie
@@ -1226,6 +1240,37 @@ def course_grader_updates(request, org, course, name, grader_index=None):
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)),
mimetype="application/json")
+
+## NB: expect_json failed on ["key", "key2"] and json payload
+@login_required
+@ensure_csrf_cookie
+def course_advanced_updates(request, org, course, name):
+ """
+ restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
+ the payload is either a key or a list of keys to delete.
+
+ org, course: 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()
+
+ # 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 real_method == 'GET':
+ return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
+ elif real_method == 'DELETE':
+ return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json")
+ elif real_method == 'POST' or real_method == 'PUT':
+ # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
+ return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json")
+
@login_required
@ensure_csrf_cookie
@@ -1286,7 +1331,6 @@ def asset_index(request, org, course, name):
def edge(request):
return render_to_response('university_profiles/edge.html', {})
-
@login_required
@expect_json
def create_new_course(request):
@@ -1342,7 +1386,6 @@ def create_new_course(request):
return HttpResponse(json.dumps({'id': new_course.location.url()}))
-
def initialize_course_tabs(course):
# 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
@@ -1360,7 +1403,6 @@ def initialize_course_tabs(course):
modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
-
@ensure_csrf_cookie
@login_required
def import_course(request, org, course, name):
@@ -1438,7 +1480,6 @@ def import_course(request, org, course, name):
course_module.location.name])
})
-
@ensure_csrf_cookie
@login_required
def generate_export_course(request, org, course, name):
@@ -1490,7 +1531,6 @@ def export_course(request, org, course, name):
'successful_import_redirect_url': ''
})
-
def event(request):
'''
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
new file mode 100644
index 0000000000..d088d75665
--- /dev/null
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -0,0 +1,70 @@
+from xmodule.modulestore import Location
+from contentstore.utils import get_modulestore
+from xmodule.x_module import XModuleDescriptor
+
+
+class CourseMetadata(object):
+ '''
+ For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones.
+ The objects have no predefined attrs but instead are obj encodings of the editable metadata.
+ '''
+ # __new_advanced_key__ is used by client not server; so, could argue against it being here
+ FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__']
+
+ @classmethod
+ def fetch(cls, course_location):
+ """
+ Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model.
+ """
+ if not isinstance(course_location, Location):
+ course_location = Location(course_location)
+
+ course = {}
+
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ for k, v in descriptor.metadata.iteritems():
+ if k not in cls.FILTERED_LIST:
+ course[k] = v
+
+ return course
+
+ @classmethod
+ def update_from_json(cls, course_location, jsondict):
+ """
+ Decode the json into CourseMetadata and save any changed attrs to the db.
+
+ Ensures none of the fields are in the blacklist.
+ """
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ dirty = False
+
+ for k, v in jsondict.iteritems():
+ # should it be an error if one of the filtered list items is in the payload?
+ if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v):
+ dirty = True
+ descriptor.metadata[k] = v
+
+ if dirty:
+ get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+
+ # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
+ # it persisted correctly
+ return cls.fetch(course_location)
+
+ @classmethod
+ def delete_key(cls, course_location, payload):
+ '''
+ Remove the given metadata key(s) from the course. payload can be a single key or [key..]
+ '''
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ for key in payload['deleteKeys']:
+ if key in descriptor.metadata:
+ del descriptor.metadata[key]
+
+ get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+
+ return cls.fetch(course_location)
+
\ No newline at end of file
diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html
new file mode 100644
index 0000000000..0312fdd344
--- /dev/null
+++ b/cms/static/client_templates/advanced_entry.html
@@ -0,0 +1,16 @@
+
+
+
+
+ Keys are case sensitive and cannot contain spaces or start with a number
+
+%block>
\ No newline at end of file
diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html
index 61cb59e995..923cc35313 100644
--- a/cms/templates/settings_graders.html
+++ b/cms/templates/settings_graders.html
@@ -126,7 +126,7 @@ from contentstore import utils