Commit 01e15c1e by John Jarvis

Merge branch 'master' into drupal-new

parents 0c1fd783 7e35bf19
...@@ -11,8 +11,10 @@ import json ...@@ -11,8 +11,10 @@ import json
from fs.osfs import OSFS from fs.osfs import OSFS
import copy import copy
from json import loads from json import loads
import traceback
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.dispatch import Signal
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from .utils import ModuleStoreTestCase, parse_json from .utils import ModuleStoreTestCase, parse_json
...@@ -215,13 +217,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -215,13 +217,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
module_store = modulestore('direct') module_store = modulestore('direct')
found = False found = False
item = None
items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None]) items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None])
found = len(items) > 0 found = len(items) > 0
self.assertTrue(found) self.assertTrue(found)
# check that there's actually content in the 'question' field # check that there's actually content in the 'question' field
self.assertGreater(len(items[0].question),0) self.assertGreater(len(items[0].question), 0)
def test_xlint_fails(self): def test_xlint_fails(self):
err_cnt = perform_xlint('common/test/data', ['full']) err_cnt = perform_xlint('common/test/data', ['full'])
...@@ -234,14 +235,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -234,14 +235,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None]))
# make sure the parent no longer points to the child object which was deleted # make sure the parent no longer points to the child object which was deleted
self.assertTrue(sequential.location.url() in chapter.children) self.assertTrue(sequential.location.url() in chapter.children)
self.client.post(reverse('delete_item'), self.client.post(reverse('delete_item'),
json.dumps({'id': sequential.location.url(), 'delete_children': 'true', 'delete_all_versions': 'true'}), json.dumps({'id': sequential.location.url(), 'delete_children': 'true', 'delete_all_versions': 'true'}),
"application/json") "application/json")
found = False found = False
try: try:
...@@ -252,7 +253,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -252,7 +253,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertFalse(found) self.assertFalse(found)
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None]))
# make sure the parent no longer points to the child object which was deleted # make sure the parent no longer points to the child object which was deleted
self.assertFalse(sequential.location.url() in chapter.children) self.assertFalse(sequential.location.url() in chapter.children)
...@@ -275,7 +276,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -275,7 +276,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
import_from_xml(modulestore(), 'common/test/data/', ['full']) import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct') module_store = modulestore('direct')
content_store = contentstore()
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
course = module_store.get_item(source_location) course = module_store.get_item(source_location)
...@@ -288,7 +288,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -288,7 +288,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
'org': 'MITx', 'org': 'MITx',
'number': '999', 'number': '999',
'display_name': 'Robot Super Course', 'display_name': 'Robot Super Course',
} }
import_from_xml(modulestore(), 'common/test/data/', ['full']) import_from_xml(modulestore(), 'common/test/data/', ['full'])
...@@ -347,17 +347,44 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -347,17 +347,44 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
def test_export_course(self): def test_export_course(self):
module_store = modulestore('direct') module_store = modulestore('direct')
draft_store = modulestore('draft')
content_store = contentstore() content_store = contentstore()
import_from_xml(module_store, 'common/test/data/', ['full']) import_from_xml(module_store, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
# get a vertical (and components in it) to put into 'draft'
vertical = module_store.get_item(Location(['i4x', 'edX', 'full',
'vertical', 'vertical_66', None]), depth=1)
draft_store.clone_item(vertical.location, vertical.location)
for child in vertical.get_children():
draft_store.clone_item(child.location, child.location)
root_dir = path(mkdtemp_clean()) root_dir = path(mkdtemp_clean())
# now create a private vertical
private_vertical = draft_store.clone_item(vertical.location,
Location(['i4x', 'edX', 'full', 'vertical', 'a_private_vertical', None]))
# add private to list of children
sequential = module_store.get_item(Location(['i4x', 'edX', 'full',
'sequential', 'Administrivia_and_Circuit_Elements', None]))
private_location_no_draft = private_vertical.location._replace(revision=None)
module_store.update_children(sequential.location, sequential.children +
[private_location_no_draft.url()])
# read back the sequential, to make sure we have a pointer to
sequential = module_store.get_item(Location(['i4x', 'edX', 'full',
'sequential', 'Administrivia_and_Circuit_Elements', None]))
self.assertIn(private_location_no_draft.url(), sequential.children)
print 'Exporting to tempdir = {0}'.format(root_dir) print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir # export out to a tempdir
export_to_xml(module_store, content_store, location, root_dir, 'test_export') export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store)
# check for static tabs # check for static tabs
self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html') self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html')
...@@ -391,20 +418,36 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -391,20 +418,36 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
delete_course(module_store, content_store, location) delete_course(module_store, content_store, location)
# reimport # reimport
import_from_xml(module_store, root_dir, ['test_export']) import_from_xml(module_store, root_dir, ['test_export'], draft_store=draft_store)
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0) self.assertGreater(len(items), 0)
for descriptor in items: for descriptor in items:
print "Checking {0}....".format(descriptor.location.url()) # don't try to look at private verticals. Right now we're running
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) # the service in non-draft aware
self.assertEqual(resp.status_code, 200) if getattr(descriptor, 'is_draft', False):
print "Checking {0}....".format(descriptor.location.url())
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
self.assertEqual(resp.status_code, 200)
# verify that we have the content in the draft store as well
vertical = draft_store.get_item(Location(['i4x', 'edX', 'full',
'vertical', 'vertical_66', None]), depth=1)
self.assertTrue(getattr(vertical, 'is_draft', False))
for child in vertical.get_children():
self.assertTrue(getattr(child, 'is_draft', False))
# verify that we have the private vertical
test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'full',
'vertical', 'vertical_66', None]))
self.assertTrue(getattr(test_private_vertical, 'is_draft', False))
shutil.rmtree(root_dir) shutil.rmtree(root_dir)
def test_course_handouts_rewrites(self): def test_course_handouts_rewrites(self):
module_store = modulestore('direct') module_store = modulestore('direct')
content_store = contentstore()
# import a test course # import a test course
import_from_xml(module_store, 'common/test/data/', ['full']) import_from_xml(module_store, 'common/test/data/', ['full'])
...@@ -437,11 +480,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -437,11 +480,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# make sure we pre-fetched a known sequential which should be at depth=2 # make sure we pre-fetched a known sequential which should be at depth=2
self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential', self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential',
'Administrivia_and_Circuit_Elements', None]) in course.system.module_data) 'Administrivia_and_Circuit_Elements', None]) in course.system.module_data)
# make sure we don't have a specific vertical which should be at depth=3 # make sure we don't have a specific vertical which should be at depth=3
self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', None])
None]) in course.system.module_data) in course.system.module_data)
def test_export_course_with_unknown_metadata(self): def test_export_course_with_unknown_metadata(self):
module_store = modulestore('direct') module_store = modulestore('direct')
...@@ -468,10 +511,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -468,10 +511,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
export_to_xml(module_store, content_store, location, root_dir, 'test_export') export_to_xml(module_store, content_store, location, root_dir, 'test_export')
exported = True exported = True
except Exception: except Exception:
print 'Exception thrown: {0}'.format(traceback.format_exc())
pass pass
self.assertTrue(exported) self.assertTrue(exported)
class ContentStoreTest(ModuleStoreTestCase): class ContentStoreTest(ModuleStoreTestCase):
""" """
Tests for the CMS ContentStore application. Tests for the CMS ContentStore application.
...@@ -506,7 +551,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -506,7 +551,7 @@ class ContentStoreTest(ModuleStoreTestCase):
'org': 'MITx', 'org': 'MITx',
'number': '999', 'number': '999',
'display_name': 'Robot Super Course', 'display_name': 'Robot Super Course',
} }
def test_create_course(self): def test_create_course(self):
"""Test new course creation - happy path""" """Test new course creation - happy path"""
...@@ -533,7 +578,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -533,7 +578,7 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(data['ErrMsg'], self.assertEqual(data['ErrMsg'],
'There is already a course defined with the same organization and course number.') 'There is already a course defined with the same organization and course number.')
def test_create_course_with_bad_organization(self): def test_create_course_with_bad_organization(self):
"""Test new course creation - error path for bad organization name""" """Test new course creation - error path for bad organization name"""
...@@ -543,7 +588,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -543,7 +588,7 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(data['ErrMsg'], self.assertEqual(data['ErrMsg'],
"Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.")
def test_course_index_view_with_no_courses(self): def test_course_index_view_with_no_courses(self):
"""Test viewing the index page with no courses""" """Test viewing the index page with no courses"""
...@@ -579,10 +624,10 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -579,10 +624,10 @@ class ContentStoreTest(ModuleStoreTestCase):
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
data = { data = {
'org': 'MITx', 'org': 'MITx',
'course': '999', 'course': '999',
'name': Location.clean('Robot Super Course'), 'name': Location.clean('Robot Super Course'),
} }
resp = self.client.get(reverse('course_index', kwargs=data)) resp = self.client.get(reverse('course_index', kwargs=data))
self.assertContains(resp, self.assertContains(resp,
...@@ -598,7 +643,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -598,7 +643,7 @@ class ContentStoreTest(ModuleStoreTestCase):
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
'template': 'i4x://edx/templates/chapter/Empty', 'template': 'i4x://edx/templates/chapter/Empty',
'display_name': 'Section One', 'display_name': 'Section One',
} }
resp = self.client.post(reverse('clone_item'), section_data) resp = self.client.post(reverse('clone_item'), section_data)
...@@ -614,7 +659,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -614,7 +659,7 @@ class ContentStoreTest(ModuleStoreTestCase):
problem_data = { problem_data = {
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
'template': 'i4x://edx/templates/problem/Blank_Common_Problem' 'template': 'i4x://edx/templates/problem/Blank_Common_Problem'
} }
resp = self.client.post(reverse('clone_item'), problem_data) resp = self.client.post(reverse('clone_item'), problem_data)
...@@ -748,6 +793,45 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -748,6 +793,45 @@ class ContentStoreTest(ModuleStoreTestCase):
# make sure we found the item (e.g. it didn't error while loading) # make sure we found the item (e.g. it didn't error while loading)
self.assertTrue(did_load_item) self.assertTrue(did_load_item)
def test_forum_id_generation(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct')
new_component_location = Location('i4x', 'edX', 'full', 'discussion', 'new_component')
source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag')
# crate a new module and add it as a child to a vertical
module_store.clone_item(source_template_location, new_component_location)
new_discussion_item = module_store.get_item(new_component_location)
self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$')
def test_update_modulestore_signal_did_fire(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct')
try:
module_store.modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
self.got_signal = False
def _signal_hander(modulestore=None, course_id=None, location=None, **kwargs):
self.got_signal = True
module_store.modulestore_update_signal.connect(_signal_hander)
new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component')
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
# crate a new module
module_store.clone_item(source_template_location, new_component_location)
finally:
module_store.modulestore_update_signal = None
self.assertTrue(self.got_signal)
def test_metadata_inheritance(self): def test_metadata_inheritance(self):
import_from_xml(modulestore(), 'common/test/data/', ['full']) import_from_xml(modulestore(), 'common/test/data/', ['full'])
......
...@@ -11,6 +11,7 @@ DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_ta ...@@ -11,6 +11,7 @@ DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_ta
#In order to instantiate an open ended tab automatically, need to have this data #In order to instantiate an open ended tab automatically, need to have this data
OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"} OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"}
def get_modulestore(location): def get_modulestore(location):
""" """
Returns the correct modulestore to use for modifying the specified location Returns the correct modulestore to use for modifying the specified location
......
...@@ -1586,7 +1586,8 @@ def import_course(request, org, course, name): ...@@ -1586,7 +1586,8 @@ def import_course(request, org, course, name):
shutil.move(r / fname, course_dir) shutil.move(r / fname, course_dir)
module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT, module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
[course_subdir], load_error_modules=False, static_content_store=contentstore(), target_location_namespace=Location(location)) [course_subdir], load_error_modules=False, static_content_store=contentstore(),
target_location_namespace=Location(location), draft_store=modulestore())
# we can blow this away when we're done importing. # we can blow this away when we're done importing.
shutil.rmtree(course_dir) shutil.rmtree(course_dir)
...@@ -1620,8 +1621,8 @@ def generate_export_course(request, org, course, name): ...@@ -1620,8 +1621,8 @@ def generate_export_course(request, org, course, name):
logging.debug('root = {0}'.format(root_dir)) logging.debug('root = {0}'.format(root_dir))
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name) export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
# filename = root_dir / name + '.tar.gz' #filename = root_dir / name + '.tar.gz'
logging.debug('tar file being generated at {0}'.format(export_file.name)) logging.debug('tar file being generated at {0}'.format(export_file.name))
tf = tarfile.open(name=export_file.name, mode='w:gz') tf = tarfile.open(name=export_file.name, mode='w:gz')
......
from dogapi import dog_http_api, dog_stats_api from dogapi import dog_http_api, dog_stats_api
from django.conf import settings from django.conf import settings
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from django.dispatch import Signal
from request_cache.middleware import RequestCache from request_cache.middleware import RequestCache
from django.core.cache import get_cache, InvalidCacheBackendError from django.core.cache import get_cache
cache = get_cache('mongo_metadata_inheritance') cache = get_cache('mongo_metadata_inheritance')
for store_name in settings.MODULESTORE: for store_name in settings.MODULESTORE:
...@@ -11,6 +12,8 @@ for store_name in settings.MODULESTORE: ...@@ -11,6 +12,8 @@ for store_name in settings.MODULESTORE:
store.metadata_inheritance_cache_subsystem = cache store.metadata_inheritance_cache_subsystem = cache
store.request_cache = RequestCache.get_request_cache() store.request_cache = RequestCache.get_request_cache()
modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
store.modulestore_update_signal = modulestore_update_signal
if hasattr(settings, 'DATADOG_API'): if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API dog_http_api.api_key = settings.DATADOG_API
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)
...@@ -225,7 +225,6 @@ function toggleSections(e) { ...@@ -225,7 +225,6 @@ function toggleSections(e) {
function editSectionPublishDate(e) { function editSectionPublishDate(e) {
e.preventDefault(); e.preventDefault();
$modal = $('.edit-subsection-publish-settings').show(); $modal = $('.edit-subsection-publish-settings').show();
$modal = $('.edit-subsection-publish-settings').show();
$modal.attr('data-id', $(this).attr('data-id')); $modal.attr('data-id', $(this).attr('data-id'));
$modal.find('.start-date').val($(this).attr('data-date')); $modal.find('.start-date').val($(this).attr('data-date'));
$modal.find('.start-time').val($(this).attr('data-time')); $modal.find('.start-time').val($(this).attr('data-time'));
......
...@@ -97,7 +97,7 @@ ...@@ -97,7 +97,7 @@
color: $blue; color: $blue;
&:hover, &:active { &:hover, &:active {
background: $blue-l3; background: $blue-l4;
color: $blue-s2; color: $blue-s2;
} }
......
...@@ -8,11 +8,11 @@ input[type="password"], ...@@ -8,11 +8,11 @@ input[type="password"],
textarea.text { textarea.text {
padding: 6px 8px 8px; padding: 6px 8px 8px;
@include box-sizing(border-box); @include box-sizing(border-box);
border: 1px solid $mediumGrey; border: 1px solid $gray-l2;
border-radius: 2px; border-radius: 2px;
@include linear-gradient($lightGrey, tint($lightGrey, 90%)); @include linear-gradient($gray-l5, $white);
background-color: $lightGrey; background-color: $gray-l5;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset); @include box-shadow(inset 0 1px 2px $shadow-l1);
font-family: 'Open Sans', sans-serif; font-family: 'Open Sans', sans-serif;
font-size: 11px; font-size: 11px;
color: $baseFontColor; color: $baseFontColor;
...@@ -21,7 +21,7 @@ textarea.text { ...@@ -21,7 +21,7 @@ textarea.text {
&::-webkit-input-placeholder, &::-webkit-input-placeholder,
&:-moz-placeholder, &:-moz-placeholder,
&:-ms-input-placeholder { &:-ms-input-placeholder {
color: #979faf; color: $gray-l2;
} }
&:focus { &:focus {
...@@ -30,7 +30,72 @@ textarea.text { ...@@ -30,7 +30,72 @@ textarea.text {
} }
} }
// forms - specific // ====================
// forms - fields - not editable
.field.is-not-editable {
& label.is-focused {
color: $gray-d2;
}
label, input, textarea {
pointer-events: none;
}
}
// ====================
// field with error
.field.error {
input, textarea {
border-color: $red;
}
}
// ====================
// forms - additional UI
form {
.note {
@include box-sizing(border-box);
.title {
}
.copy {
}
// note with actions
&.has-actions {
@include clearfix();
.title {
}
.copy {
}
.list-actions {
}
}
}
.note-promotion {
}
}
// ====================
// forms - grandfathered
input.search { input.search {
padding: 6px 15px 8px 30px; padding: 6px 15px 8px 30px;
@include box-sizing(border-box); @include box-sizing(border-box);
...@@ -73,4 +138,4 @@ code { ...@@ -73,4 +138,4 @@ code {
background-color: #edf1f5; background-color: #edf1f5;
@include box-shadow(0 1px 2px rgba(0, 0, 0, 0.1) inset); @include box-shadow(0 1px 2px rgba(0, 0, 0, 0.1) inset);
font-family: Monaco, monospace; font-family: Monaco, monospace;
} }
\ No newline at end of file
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
body.signup, body.signin { body.signup, body.signin {
.wrapper-content { .wrapper-content {
margin: 0; margin: ($baseline*1.5) 0 0 0;
padding: 0 $baseline; padding: 0 $baseline;
position: relative; position: relative;
width: 100%; width: 100%;
...@@ -18,7 +18,7 @@ body.signup, body.signin { ...@@ -18,7 +18,7 @@ body.signup, body.signin {
width: flex-grid(12); width: flex-grid(12);
margin: 0 auto; margin: 0 auto;
color: $gray-d2; color: $gray-d2;
header { header {
position: relative; position: relative;
margin-bottom: $baseline; margin-bottom: $baseline;
...@@ -121,7 +121,7 @@ body.signup, body.signin { ...@@ -121,7 +121,7 @@ body.signup, body.signin {
@include font-size(16); @include font-size(16);
height: 100%; height: 100%;
width: 100%; width: 100%;
padding: ($baseline/2); padding: ($baseline/2);
&.long { &.long {
width: 100%; width: 100%;
...@@ -136,15 +136,15 @@ body.signup, body.signin { ...@@ -136,15 +136,15 @@ body.signup, body.signin {
} }
:-moz-placeholder { :-moz-placeholder {
color: $gray-l3; color: $gray-l3;
} }
::-moz-placeholder { ::-moz-placeholder {
color: $gray-l3; color: $gray-l3;
} }
:-ms-input-placeholder { :-ms-input-placeholder {
color: $gray-l3; color: $gray-l3;
} }
&:focus { &:focus {
......
...@@ -147,7 +147,7 @@ body.course.settings { ...@@ -147,7 +147,7 @@ body.course.settings {
} }
label { label {
@include font-size(14); @extend .t-copy-sub1;
@include transition(color, 0.15s, ease-in-out); @include transition(color, 0.15s, ease-in-out);
margin: 0 0 ($baseline/4) 0; margin: 0 0 ($baseline/4) 0;
font-weight: 400; font-weight: 400;
...@@ -161,7 +161,7 @@ body.course.settings { ...@@ -161,7 +161,7 @@ body.course.settings {
@include placeholder($gray-l4); @include placeholder($gray-l4);
@include font-size(16); @include font-size(16);
@include size(100%,100%); @include size(100%,100%);
padding: ($baseline/2); padding: ($baseline/2);
&.long { &.long {
} }
...@@ -212,7 +212,7 @@ body.course.settings { ...@@ -212,7 +212,7 @@ body.course.settings {
padding: $baseline; padding: $baseline;
&:last-child { &:last-child {
padding-bottom: $baseline; padding-bottom: $baseline;
} }
.actions { .actions {
...@@ -238,33 +238,36 @@ body.course.settings { ...@@ -238,33 +238,36 @@ body.course.settings {
} }
} }
// not editable fields
.field.is-not-editable {
& label.is-focused {
color: $gray-d2;
}
}
// field with error
.field.error {
input, textarea {
border-color: $red;
}
}
// specific fields - basic // specific fields - basic
&.basic { &.basic {
.list-input { .list-input {
@include clearfix(); @include clearfix();
padding: 0 ($baseline/2);
.field { .field {
margin-bottom: 0; margin-bottom: 0;
} }
} }
// course details that should appear more like content than elements to change
.field.is-not-editable {
label {
}
input, textarea {
@extend .t-copy-lead1;
@include box-shadow(none);
border: none;
background: none;
padding: 0;
margin: 0;
font-weight: 600;
}
}
#field-course-organization { #field-course-organization {
float: left; float: left;
width: flex-grid(2, 9); width: flex-grid(2, 9);
...@@ -281,6 +284,58 @@ body.course.settings { ...@@ -281,6 +284,58 @@ body.course.settings {
float: left; float: left;
width: flex-grid(5, 9); width: flex-grid(5, 9);
} }
// course link note
.note-promotion-courseURL {
@include box-shadow(0 2px 1px $shadow-l1);
@include border-radius(($baseline/5));
margin-top: ($baseline*1.5);
border: 1px solid $gray-l2;
padding: ($baseline/2) 0 0 0;
.title {
@extend .t-copy-sub1;
margin: 0 0 ($baseline/10) 0;
padding: 0 ($baseline/2);
.tip {
display: inline;
margin-left: ($baseline/4);
}
}
.copy {
padding: 0 ($baseline/2) ($baseline/2) ($baseline/2);
.link-courseURL {
@extend .t-copy-lead1;
&:hover {
}
}
}
.list-actions {
@include box-shadow(inset 0 1px 1px $shadow-l1);
border-top: 1px solid $gray-l2;
padding: ($baseline/2);
background: $gray-l5;
.action-primary {
@include blue-button();
@include font-size(13);
font-weight: 600;
.icon {
@extend .t-icon;
@include font-size(16);
display: inline-block;
vertical-align: middle;
}
}
}
}
} }
// specific fields - schedule // specific fields - schedule
...@@ -322,7 +377,7 @@ body.course.settings { ...@@ -322,7 +377,7 @@ body.course.settings {
} }
} }
} }
// specific fields - overview // specific fields - overview
#field-course-overview { #field-course-overview {
...@@ -468,7 +523,7 @@ body.course.settings { ...@@ -468,7 +523,7 @@ body.course.settings {
} }
} }
} }
.grade-specific-bar { .grade-specific-bar {
height: 50px !important; height: 50px !important;
} }
...@@ -479,7 +534,7 @@ body.course.settings { ...@@ -479,7 +534,7 @@ body.course.settings {
li { li {
position: absolute; position: absolute;
top: 0; top: 0;
height: 50px; height: 50px;
text-align: right; text-align: right;
@include border-radius(2px); @include border-radius(2px);
...@@ -600,8 +655,8 @@ body.course.settings { ...@@ -600,8 +655,8 @@ body.course.settings {
} }
#field-course-grading-assignment-shortname, #field-course-grading-assignment-shortname,
#field-course-grading-assignment-totalassignments, #field-course-grading-assignment-totalassignments,
#field-course-grading-assignment-gradeweight, #field-course-grading-assignment-gradeweight,
#field-course-grading-assignment-droppable { #field-course-grading-assignment-droppable {
width: flex-grid(2, 6); width: flex-grid(2, 6);
} }
...@@ -734,4 +789,4 @@ body.course.settings { ...@@ -734,4 +789,4 @@ body.course.settings {
.content-supplementary { .content-supplementary {
width: flex-grid(3, 12); width: flex-grid(3, 12);
} }
} }
\ No newline at end of file
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! <%!
from contentstore import utils from contentstore import utils
%> %>
...@@ -13,17 +13,17 @@ from contentstore import utils ...@@ -13,17 +13,17 @@ from contentstore import utils
<script src="${static.url('js/vendor/timepicker/jquery.timepicker.js')}"></script> <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/timepicker/datepair.js')}"></script>
<script src="${static.url('js/vendor/date.js')}"></script> <script src="${static.url('js/vendor/date.js')}"></script>
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script> <script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/server_error.js')}"></script> <script type="text/javascript" src="${static.url('js/views/server_error.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script> <script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script> <script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script> <script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script> <script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
// hilighting labels when fields are focused in // hilighting labels when fields are focused in
$("form :input").focus(function() { $("form :input").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused"); $("label[for='" + this.id + "']").addClass("is-focused");
...@@ -32,18 +32,18 @@ from contentstore import utils ...@@ -32,18 +32,18 @@ from contentstore import utils
}); });
var model = new CMS.Models.Settings.CourseDetails(); var model = new CMS.Models.Settings.CourseDetails();
model.urlRoot = '${details_url}'; model.urlRoot = '${details_url}';
model.fetch({success : model.fetch({success :
function(model) { function(model) {
var editor = new CMS.Views.Settings.Details({ var editor = new CMS.Views.Settings.Details({
el: $('.settings-details'), el: $('.settings-details'),
model: model model: model
}); });
editor.render(); editor.render();
} }
}); });
}); });
</script> </script>
</%block> </%block>
...@@ -62,10 +62,10 @@ from contentstore import utils ...@@ -62,10 +62,10 @@ from contentstore import utils
<article class="content-primary" role="main"> <article class="content-primary" role="main">
<form id="settings_details" class="settings-details" method="post" action=""> <form id="settings_details" class="settings-details" method="post" action="">
<section class="group-settings basic"> <section class="group-settings basic">
<header> <header>
<h2 class="title-2">Basic Information</h2> <h2 class="title-2">Basic Information</h2>
<span class="tip">The nuts and bolts of your course</span> <span class="tip">The nuts and bolts of your course</span>
</header> </header>
<ol class="list-input"> <ol class="list-input">
<li class="field text is-not-editable" id="field-course-organization"> <li class="field text is-not-editable" id="field-course-organization">
...@@ -83,45 +83,57 @@ from contentstore import utils ...@@ -83,45 +83,57 @@ from contentstore import utils
<input title="This field is disabled: this information cannot be changed." type="text" class="long" id="course-name" value="[Course Name]" readonly /> <input title="This field is disabled: this information cannot be changed." type="text" class="long" id="course-name" value="[Course Name]" readonly />
</li> </li>
</ol> </ol>
<span class="tip tip-stacked">These are used in <a rel="external" href="${utils.get_lms_link_for_about_page(course_location)}" />your course URL</a>, and cannot be changed</span>
<div class="note note-promotion note-promotion-courseURL has-actions">
<h3 class="title">Course Summary Page <span class="tip">(for student enrollment and access)</span></h3>
<div class="copy">
<p><a class="link-courseURL" rel="external" href="${utils.get_lms_link_for_about_page(course_location)}" />${utils.get_lms_link_for_about_page(course_location)}</a></p>
</div>
<ul class="list-actions">
<li class="action-item">
<a title="Send a note to students via email" href="mailto:john.doe@gmail.com?Subject=Enroll%20in%20COURSENAME&body=Hi,%20COURSENAME,%20provided%20by%20edX,%20is%20almost%20ready%20to%20begin.%20Please%20enroll%20for%20this%20course%20at%20${utils.get_lms_link_for_about_page(course_location)}." class="action action-primary"><i class="ss-icon icon ss-symbolicons-standard icon icon-inline icon-announcement">&#x2709;</i> Send an invitation to your students</a>
</li>
</ul>
</div>
</section> </section>
<hr class="divide" /> <hr class="divide" />
<section class="group-settings schedule"> <section class="group-settings schedule">
<header> <header>
<h2 class="title-2">Course Schedule</h2> <h2 class="title-2">Course Schedule</h2>
<span class="tip">Important steps and segments of your course</span> <span class="tip">Important steps and segments of your course</span>
</header> </header>
<ol class="list-input"> <ol class="list-input">
<li class="field-group field-group-course-start" id="course-start"> <li class="field-group field-group-course-start" id="course-start">
<div class="field date" id="field-course-start-date"> <div class="field date" id="field-course-start-date">
<label for="course-start-date">Course Start Date</label> <label for="course-start-date">Course Start Date</label>
<input type="text" class="start-date date start datepicker" id="course-start-date" placeholder="MM/DD/YYYY" autocomplete="off" /> <input type="text" class="start-date date start datepicker" id="course-start-date" placeholder="MM/DD/YYYY" autocomplete="off" />
<span class="tip tip-stacked">First day the course begins</span> <span class="tip tip-stacked">First day the course begins</span>
</div> </div>
<div class="field time" id="field-course-start-time"> <div class="field time" id="field-course-start-time">
<label for="course-start-time">Course Start Time</label> <label for="course-start-time">Course Start Time</label>
<input type="text" class="time start timepicker" id="course-start-time" value="" placeholder="HH:MM" autocomplete="off" /> <input type="text" class="time start timepicker" id="course-start-time" value="" placeholder="HH:MM" autocomplete="off" />
<span class="tip tip-stacked" id="timezone"></span> <span class="tip tip-stacked" id="timezone"></span>
</div> </div>
</li> </li>
<li class="field-group field-group-course-end" id="course-end"> <li class="field-group field-group-course-end" id="course-end">
<div class="field date" id="field-course-end-date"> <div class="field date" id="field-course-end-date">
<label for="course-end-date">Course End Date</label> <label for="course-end-date">Course End Date</label>
<input type="text" class="end-date date end" id="course-end-date" placeholder="MM/DD/YYYY" autocomplete="off" /> <input type="text" class="end-date date end" id="course-end-date" placeholder="MM/DD/YYYY" autocomplete="off" />
<span class="tip tip-stacked">Last day your course is active</span> <span class="tip tip-stacked">Last day your course is active</span>
</div> </div>
<div class="field time" id="field-course-end-time"> <div class="field time" id="field-course-end-time">
<label for="course-end-time">Course End Time</label> <label for="course-end-time">Course End Time</label>
<input type="text" class="time end" id="course-end-time" value="" placeholder="HH:MM" autocomplete="off" /> <input type="text" class="time end" id="course-end-time" value="" placeholder="HH:MM" autocomplete="off" />
<span class="tip tip-stacked" id="timezone"></span> <span class="tip tip-stacked" id="timezone"></span>
</div> </div>
</li> </li>
</ol> </ol>
<ol class="list-input"> <ol class="list-input">
...@@ -129,33 +141,33 @@ from contentstore import utils ...@@ -129,33 +141,33 @@ from contentstore import utils
<div class="field date" id="field-enrollment-start-date"> <div class="field date" id="field-enrollment-start-date">
<label for="course-enrollment-start-date">Enrollment Start Date</label> <label for="course-enrollment-start-date">Enrollment Start Date</label>
<input type="text" class="start-date date start" id="course-enrollment-start-date" placeholder="MM/DD/YYYY" autocomplete="off" /> <input type="text" class="start-date date start" id="course-enrollment-start-date" placeholder="MM/DD/YYYY" autocomplete="off" />
<span class="tip tip-stacked">First day students can enroll</span> <span class="tip tip-stacked">First day students can enroll</span>
</div> </div>
<div class="field time" id="field-enrollment-start-time"> <div class="field time" id="field-enrollment-start-time">
<label for="course-enrollment-start-time">Enrollment Start Time</label> <label for="course-enrollment-start-time">Enrollment Start Time</label>
<input type="text" class="time start" id="course-enrollment-start-time" value="" placeholder="HH:MM" autocomplete="off" /> <input type="text" class="time start" id="course-enrollment-start-time" value="" placeholder="HH:MM" autocomplete="off" />
<span class="tip tip-stacked" id="timezone"></span> <span class="tip tip-stacked" id="timezone"></span>
</div> </div>
</li> </li>
<li class="field-group field-group-enrollment-end" id="enrollment-end"> <li class="field-group field-group-enrollment-end" id="enrollment-end">
<div class="field date" id="field-enrollment-end-date"> <div class="field date" id="field-enrollment-end-date">
<label for="course-enrollment-end-date">Enrollment End Date</label> <label for="course-enrollment-end-date">Enrollment End Date</label>
<input type="text" class="end-date date end" id="course-enrollment-end-date" placeholder="MM/DD/YYYY" autocomplete="off" /> <input type="text" class="end-date date end" id="course-enrollment-end-date" placeholder="MM/DD/YYYY" autocomplete="off" />
<span class="tip tip-stacked">Last day students can enroll</span> <span class="tip tip-stacked">Last day students can enroll</span>
</div> </div>
<div class="field time" id="field-enrollment-end-time"> <div class="field time" id="field-enrollment-end-time">
<label for="course-enrollment-end-time">Enrollment End Time</label> <label for="course-enrollment-end-time">Enrollment End Time</label>
<input type="text" class="time end" id="course-enrollment-end-time" value="" placeholder="HH:MM" autocomplete="off" /> <input type="text" class="time end" id="course-enrollment-end-time" value="" placeholder="HH:MM" autocomplete="off" />
<span class="tip tip-stacked" id="timezone"></span> <span class="tip tip-stacked" id="timezone"></span>
</div> </div>
</li> </li>
</ol> </ol>
</section> </section>
<hr class="divide" /> <hr class="divide" />
<section class="group-settings marketing"> <section class="group-settings marketing">
<header> <header>
...@@ -167,45 +179,44 @@ from contentstore import utils ...@@ -167,45 +179,44 @@ from contentstore import utils
<li class="field text" id="field-course-overview"> <li class="field text" id="field-course-overview">
<label for="course-overview">Course Overview</label> <label for="course-overview">Course Overview</label>
<textarea class="tinymce text-editor" id="course-overview"></textarea> <textarea class="tinymce text-editor" id="course-overview"></textarea>
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a href="${utils.get_lms_link_for_about_page(course_location)}">your course summary page</a></span> <span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a class="link-courseURL" rel="external" href="${utils.get_lms_link_for_about_page(course_location)}">your course summary page</a></span>
</li> </li>
<li class="field video" id="field-course-introduction-video"> <li class="field video" id="field-course-introduction-video">
<label for="course-overview">Course Introduction Video</label> <label for="course-overview">Course Introduction Video</label>
<div class="input input-existing"> <div class="input input-existing">
<div class="current current-course-introduction-video"> <div class="current current-course-introduction-video">
<iframe width="618" height="350" src="" frameborder="0" allowfullscreen></iframe> <iframe width="618" height="350" src="" frameborder="0" allowfullscreen></iframe>
</div>
</div>
<div class="actions"> <div class="actions">
<a href="#" class="remove-item remove-course-introduction-video remove-video-data"><span class="delete-icon"></span> Delete Current Video</a> <a href="#" class="remove-item remove-course-introduction-video remove-video-data"><span class="delete-icon"></span> Delete Current Video</a>
</div> </div>
</div> </div>
<div class="input"> <div class="input">
<input type="text" class="long new-course-introduction-video add-video-data" id="course-introduction-video" value="" placeholder="your YouTube video's ID" autocomplete="off" /> <input type="text" class="long new-course-introduction-video add-video-data" id="course-introduction-video" value="" placeholder="your YouTube video's ID" autocomplete="off" />
<span class="tip tip-stacked">Enter your YouTube video's ID (along with any restriction parameters)</span> <span class="tip tip-stacked">Enter your YouTube video's ID (along with any restriction parameters)</span>
</div> </div>
</li> </li>
</ol> </ol>
</section> </section>
<hr class="divide" /> <hr class="divide" />
<section class="group-settings requirements"> <section class="group-settings requirements">
<header> <header>
<h2 class="title-2">Requirements</h2> <h2 class="title-2">Requirements</h2>
<span class="tip">Expectations of the students taking this course</span> <span class="tip">Expectations of the students taking this course</span>
</header> </header>
<ol class="list-input"> <ol class="list-input">
<li class="field text" id="field-course-effort"> <li class="field text" id="field-course-effort">
<label for="course-effort">Hours of Effort per Week</label> <label for="course-effort">Hours of Effort per Week</label>
<input type="text" class="short time" id="course-effort" placeholder="HH:MM" /> <input type="text" class="short time" id="course-effort" placeholder="HH:MM" />
<span class="tip tip-inline">Time spent on all course work</span> <span class="tip tip-inline">Time spent on all course work</span>
</li> </li>
</ol> </ol>
</section> </section>
</form> </form>
</article> </article>
...@@ -215,7 +226,7 @@ from contentstore import utils ...@@ -215,7 +226,7 @@ from contentstore import utils
<p>Your course's schedule settings determine when students can enroll in and begin a course as well as when the course.</p> <p>Your course's schedule settings determine when students can enroll in and begin a course as well as when the course.</p>
<p>Additionally, details provided on this page are also used in edX's catalog of courses, which new and returning students use to choose new courses to study.</p> <p>Additionally, details provided on this page are also used in edX's catalog of courses, which new and returning students use to choose new courses to study.</p>
</div> </div>
<div class="bit"> <div class="bit">
% if context_course: % if context_course:
...@@ -234,4 +245,4 @@ from contentstore import utils ...@@ -234,4 +245,4 @@ from contentstore import utils
</aside> </aside>
</section> </section>
</div> </div>
</%block> </%block>
\ No newline at end of file
<%include file="metadata-edit.html" />
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
<form id="hls-form" enctype="multipart/form-data"> <form id="hls-form" enctype="multipart/form-data">
<section class="source-edit"> <section class="source-edit">
<textarea name="" data-metadata-name="source_code" class="source-edit-box hls-data" rows="8" cols="40">${metadata['source_code']|h}</textarea> <textarea name="" data-metadata-name="source_code" class="source-edit-box hls-data" rows="8" cols="40">${editable_metadata_fields['source_code']|h}</textarea>
</section> </section>
<div class="submit"> <div class="submit">
<button type="reset" class="hls-compile">Save &amp; Compile to edX XML</button> <button type="reset" class="hls-compile">Save &amp; Compile to edX XML</button>
......
...@@ -99,6 +99,7 @@ class CapaFields(object): ...@@ -99,6 +99,7 @@ class CapaFields(object):
seed = StringyInteger(help="Random seed for this student", scope=Scope.user_state) seed = StringyInteger(help="Random seed for this student", scope=Scope.user_state)
weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings)
markdown = String(help="Markdown source of this module", scope=Scope.settings) markdown = String(help="Markdown source of this module", scope=Scope.settings)
source_code = String(help="Source code for LaTeX and Word problems. This feature is not well-supported.", scope=Scope.settings)
class CapaModule(CapaFields, XModule): class CapaModule(CapaFields, XModule):
......
...@@ -3,6 +3,7 @@ from pkg_resources import resource_string, resource_listdir ...@@ -3,6 +3,7 @@ from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xblock.core import String, Scope from xblock.core import String, Scope
...@@ -28,7 +29,7 @@ class DiscussionModule(DiscussionFields, XModule): ...@@ -28,7 +29,7 @@ class DiscussionModule(DiscussionFields, XModule):
return self.system.render_template('discussion/_discussion_module.html', context) return self.system.render_template('discussion/_discussion_module.html', context)
class DiscussionDescriptor(DiscussionFields, RawDescriptor): class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor):
module_class = DiscussionModule module_class = DiscussionModule
template_dir_name = "discussion" template_dir_name = "discussion"
......
...@@ -41,6 +41,18 @@ class XMLEditingDescriptor(EditingDescriptor): ...@@ -41,6 +41,18 @@ class XMLEditingDescriptor(EditingDescriptor):
js_module_name = "XMLEditingDescriptor" js_module_name = "XMLEditingDescriptor"
class MetadataOnlyEditingDescriptor(EditingDescriptor):
"""
Module which only provides an editing interface for the metadata, it does
not expose a UI for editing the module data
"""
js = {'coffee': [resource_string(__name__, 'js/src/raw/edit/metadata-only.coffee')]}
js_module_name = "MetadataOnlyEditingDescriptor"
mako_template = "widgets/metadata-only-edit.html"
class JSONEditingDescriptor(EditingDescriptor): class JSONEditingDescriptor(EditingDescriptor):
""" """
Module that provides a raw editing view of its data as XML. It does not perform Module that provides a raw editing view of its data as XML. It does not perform
......
...@@ -118,8 +118,8 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): ...@@ -118,8 +118,8 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
with system.resources_fs.open(filepath) as file: with system.resources_fs.open(filepath) as file:
html = file.read().decode('utf-8') html = file.read().decode('utf-8')
# Log a warning if we can't parse the file, but don't error # Log a warning if we can't parse the file, but don't error
if not check_html(html): if not check_html(html) and len(html) > 0:
msg = "Couldn't parse html in {0}.".format(filepath) msg = "Couldn't parse html in {0}, content = {1}".format(filepath, html)
log.warning(msg) log.warning(msg)
system.error_tracker("Warning: " + msg) system.error_tracker("Warning: " + msg)
...@@ -156,7 +156,8 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): ...@@ -156,7 +156,8 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True) resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as file: with resource_fs.open(filepath, 'w') as file:
file.write(self.data.encode('utf-8')) html_data = self.data.encode('utf-8')
file.write(html_data)
# write out the relative name # write out the relative name
relname = path(pathname).basename() relname = path(pathname).basename()
......
class @MetadataOnlyEditingDescriptor extends XModule.Descriptor
constructor: (@element) ->
save: ->
data: null
...@@ -252,7 +252,6 @@ class Location(_LocationBase): ...@@ -252,7 +252,6 @@ class Location(_LocationBase):
def __repr__(self): def __repr__(self):
return "Location%s" % repr(tuple(self)) return "Location%s" % repr(tuple(self))
@property @property
def course_id(self): def course_id(self):
"""Return the ID of the Course that this item belongs to by looking """Return the ID of the Course that this item belongs to by looking
...@@ -414,7 +413,6 @@ class ModuleStore(object): ...@@ -414,7 +413,6 @@ class ModuleStore(object):
return courses return courses
class ModuleStoreBase(ModuleStore): class ModuleStoreBase(ModuleStore):
''' '''
Implement interface functionality that can be shared. Implement interface functionality that can be shared.
...@@ -425,6 +423,7 @@ class ModuleStoreBase(ModuleStore): ...@@ -425,6 +423,7 @@ class ModuleStoreBase(ModuleStore):
''' '''
self._location_errors = {} # location -> ErrorLog self._location_errors = {} # location -> ErrorLog
self.metadata_inheritance_cache = None self.metadata_inheritance_cache = None
self.modulestore_update_signal = None # can be set by runtime to route notifications of datastore changes
def _get_errorlog(self, location): def _get_errorlog(self, location):
""" """
......
...@@ -3,7 +3,6 @@ from datetime import datetime ...@@ -3,7 +3,6 @@ from datetime import datetime
from . import ModuleStoreBase, Location, namedtuple_to_son from . import ModuleStoreBase, Location, namedtuple_to_son
from .exceptions import ItemNotFoundError from .exceptions import ItemNotFoundError
from .inheritance import own_metadata from .inheritance import own_metadata
import logging
DRAFT = 'draft' DRAFT = 'draft'
...@@ -107,7 +106,7 @@ class DraftModuleStore(ModuleStoreBase): ...@@ -107,7 +106,7 @@ class DraftModuleStore(ModuleStoreBase):
""" """
return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location))) return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location)))
def update_item(self, location, data): def update_item(self, location, data, allow_not_found=False):
""" """
Set the data in the item specified by the location to Set the data in the item specified by the location to
data data
...@@ -116,9 +115,13 @@ class DraftModuleStore(ModuleStoreBase): ...@@ -116,9 +115,13 @@ class DraftModuleStore(ModuleStoreBase):
data: A nested dictionary of problem data data: A nested dictionary of problem data
""" """
draft_loc = as_draft(location) draft_loc = as_draft(location)
draft_item = self.get_item(location) try:
if not getattr(draft_item, 'is_draft', False): draft_item = self.get_item(location)
self.clone_item(location, draft_loc) if not getattr(draft_item, 'is_draft', False):
self.clone_item(location, draft_loc)
except ItemNotFoundError, e:
if not allow_not_found:
raise e
return super(DraftModuleStore, self).update_item(draft_loc, data) return super(DraftModuleStore, self).update_item(draft_loc, data)
...@@ -164,7 +167,6 @@ class DraftModuleStore(ModuleStoreBase): ...@@ -164,7 +167,6 @@ class DraftModuleStore(ModuleStoreBase):
""" """
return super(DraftModuleStore, self).delete_item(as_draft(location)) return super(DraftModuleStore, self).delete_item(as_draft(location))
def get_parent_locations(self, location, course_id): def get_parent_locations(self, location, course_id):
'''Find all locations that are the parents of this location. Needed '''Find all locations that are the parents of this location. Needed
for path_to_location(). for path_to_location().
...@@ -178,6 +180,7 @@ class DraftModuleStore(ModuleStoreBase): ...@@ -178,6 +180,7 @@ class DraftModuleStore(ModuleStoreBase):
Save a current draft to the underlying modulestore Save a current draft to the underlying modulestore
""" """
draft = self.get_item(location) draft = self.get_item(location)
draft.cms.published_date = datetime.utcnow() draft.cms.published_date = datetime.utcnow()
draft.cms.published_by = published_by_id draft.cms.published_by = published_by_id
super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data) super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data)
...@@ -221,6 +224,6 @@ class DraftModuleStore(ModuleStoreBase): ...@@ -221,6 +224,6 @@ class DraftModuleStore(ModuleStoreBase):
# convert the dict - which is used for look ups - back into a list # convert the dict - which is used for look ups - back into a list
for key, value in to_process_dict.iteritems(): for key, value in to_process_dict.iteritems():
queried_children.append(value) queried_children.append(value)
return queried_children return queried_children
...@@ -9,6 +9,7 @@ from itertools import repeat ...@@ -9,6 +9,7 @@ from itertools import repeat
from path import path from path import path
from datetime import datetime from datetime import datetime
from operator import attrgetter from operator import attrgetter
from uuid import uuid4
from importlib import import_module from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str from xmodule.errortracker import null_error_tracker, exc_info_to_str
...@@ -30,6 +31,10 @@ log = logging.getLogger(__name__) ...@@ -30,6 +31,10 @@ log = logging.getLogger(__name__)
# there is only one revision for each item. Once we start versioning inside the CMS, # there is only one revision for each item. Once we start versioning inside the CMS,
# that assumption will have to change # that assumption will have to change
def get_course_id_no_run(location):
'''
'''
return "/".join([location.org, location.course])
class MongoKeyValueStore(KeyValueStore): class MongoKeyValueStore(KeyValueStore):
""" """
...@@ -333,7 +338,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -333,7 +338,7 @@ class MongoModuleStore(ModuleStoreBase):
''' '''
key = metadata_cache_key(location) key = metadata_cache_key(location)
tree = {} tree = {}
if not force_refresh: if not force_refresh:
# see if we are first in the request cache (if present) # see if we are first in the request cache (if present)
if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}): if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}):
...@@ -348,7 +353,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -348,7 +353,7 @@ class MongoModuleStore(ModuleStoreBase):
if not tree: if not tree:
# if not in subsystem, or we are on force refresh, then we have to compute # if not in subsystem, or we are on force refresh, then we have to compute
tree = self.compute_metadata_inheritance_tree(location) tree = self.compute_metadata_inheritance_tree(location)
# now write out computed tree to caching subsystem (e.g. memcached), if available # now write out computed tree to caching subsystem (e.g. memcached), if available
if self.metadata_inheritance_cache_subsystem is not None: if self.metadata_inheritance_cache_subsystem is not None:
self.metadata_inheritance_cache_subsystem.set(key, tree) self.metadata_inheritance_cache_subsystem.set(key, tree)
...@@ -541,8 +546,15 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -541,8 +546,15 @@ class MongoModuleStore(ModuleStoreBase):
Clone a new item that is a copy of the item at the location `source` Clone a new item that is a copy of the item at the location `source`
and writes it to `location` and writes it to `location`
""" """
item = None
try: try:
source_item = self.collection.find_one(location_to_query(source)) source_item = self.collection.find_one(location_to_query(source))
# allow for some programmatically generated substitutions in metadata, e.g. Discussion_id's should be auto-generated
for key in source_item['metadata'].keys():
if source_item['metadata'][key] == '$$GUID$$':
source_item['metadata'][key] = uuid4().hex
source_item['_id'] = Location(location).dict() source_item['_id'] = Location(location).dict()
self.collection.insert( self.collection.insert(
source_item, source_item,
...@@ -566,12 +578,19 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -566,12 +578,19 @@ class MongoModuleStore(ModuleStoreBase):
course.tabs = existing_tabs course.tabs = existing_tabs
self.update_metadata(course.location, course._model_data._kvs._metadata) self.update_metadata(course.location, course._model_data._kvs._metadata)
return item
except pymongo.errors.DuplicateKeyError: except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(location) raise DuplicateItemError(location)
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location)) self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
return item
def fire_updated_modulestore_signal(self, course_id, location):
if self.modulestore_update_signal is not None:
self.modulestore_update_signal.send(self, modulestore=self, course_id=course_id,
location=location)
def get_course_for_item(self, location, depth=0): def get_course_for_item(self, location, depth=0):
''' '''
...@@ -643,6 +662,8 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -643,6 +662,8 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'definition.children': children}) self._update_single_item(location, {'definition.children': children})
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location)) self.refresh_cached_metadata_inheritance_tree(Location(location))
# fire signal that we've written to DB
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def update_metadata(self, location, metadata): def update_metadata(self, location, metadata):
""" """
...@@ -669,6 +690,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -669,6 +690,7 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'metadata': metadata}) self._update_single_item(location, {'metadata': metadata})
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(loc) self.refresh_cached_metadata_inheritance_tree(loc)
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def delete_item(self, location): def delete_item(self, location):
""" """
...@@ -692,6 +714,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -692,6 +714,7 @@ class MongoModuleStore(ModuleStoreBase):
safe=self.collection.safe) safe=self.collection.safe)
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location)) self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def get_parent_locations(self, location, course_id): def get_parent_locations(self, location, course_id):
'''Find all locations that are the parents of this location in this '''Find all locations that are the parents of this location in this
......
import logging import logging
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from fs.osfs import OSFS from fs.osfs import OSFS
from json import dumps from json import dumps
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir): def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir, draft_modulestore=None):
course = modulestore.get_item(course_location) course = modulestore.get_item(course_location)
...@@ -40,6 +39,24 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d ...@@ -40,6 +39,24 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
policy = {'course/' + course.location.name: own_metadata(course)} policy = {'course/' + course.location.name: own_metadata(course)}
course_policy.write(dumps(policy)) course_policy.write(dumps(policy))
# export draft content
# NOTE: this code assumes that verticals are the top most draftable container
# should we change the application, then this assumption will no longer
# be valid
if draft_modulestore is not None:
draft_verticals = draft_modulestore.get_items([None, course_location.org, course_location.course,
'vertical', None, 'draft'])
if len(draft_verticals) > 0:
draft_course_dir = export_fs.makeopendir('drafts')
for draft_vertical in draft_verticals:
parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id)
logging.debug('parent_locs = {0}'.format(parent_locs))
draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url()
sequential = modulestore.get_item(Location(parent_locs[0]))
index = sequential.children.index(draft_vertical.location.url())
draft_vertical.xml_attributes['index_in_children_list'] = str(index)
draft_vertical.export_to_xml(draft_course_dir)
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix=''): def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix=''):
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None) query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
......
...@@ -6,17 +6,17 @@ from path import path ...@@ -6,17 +6,17 @@ from path import path
from xblock.core import Scope from xblock.core import Scope
from .xml import XMLModuleStore from .xml import XMLModuleStore, ImportSystem, ParentTracker
from .exceptions import DuplicateItemError
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX from xmodule.contentstore.content import StaticContent
from .inheritance import own_metadata from .inheritance import own_metadata
from xmodule.errortracker import make_error_tracker
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def import_static_content(modules, course_loc, course_data_path, static_content_store, target_location_namespace, def import_static_content(modules, course_loc, course_data_path, static_content_store, target_location_namespace,
subpath='static', verbose=False): subpath='static', verbose=False):
remap_dict = {} remap_dict = {}
...@@ -107,10 +107,10 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path, ...@@ -107,10 +107,10 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path,
# the caller passed in # the caller passed in
if module.location.category != 'course': if module.location.category != 'course':
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
course=target_location_namespace.course) course=target_location_namespace.course)
else: else:
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
course=target_location_namespace.course, name=target_location_namespace.name) course=target_location_namespace.course, name=target_location_namespace.name)
# then remap children pointers since they too will be re-namespaced # then remap children pointers since they too will be re-namespaced
if module.has_children: if module.has_children:
...@@ -119,7 +119,7 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path, ...@@ -119,7 +119,7 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path,
for child in children_locs: for child in children_locs:
child_loc = Location(child) child_loc = Location(child)
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
course=target_location_namespace.course) course=target_location_namespace.course)
new_locs.append(new_child_loc.url()) new_locs.append(new_child_loc.url())
...@@ -139,8 +139,7 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path, ...@@ -139,8 +139,7 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path,
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
# no good, so we have to do this kludge # no good, so we have to do this kludge
if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path, lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path, static_content_store, link, remap_dict))
static_content_store, link, remap_dict))
for key in remap_dict.keys(): for key in remap_dict.keys():
module.data = module.data.replace(key, remap_dict[key]) module.data = module.data.replace(key, remap_dict[key])
...@@ -163,9 +162,9 @@ def import_course_from_xml(modulestore, static_content_store, course_data_path, ...@@ -163,9 +162,9 @@ def import_course_from_xml(modulestore, static_content_store, course_data_path,
# if there is *any* tabs - then there at least needs to be some predefined ones # if there is *any* tabs - then there at least needs to be some predefined ones
if module.tabs is None or len(module.tabs) == 0: if module.tabs is None or len(module.tabs) == 0:
module.tabs = [{"type": "courseware"}, module.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"}, {"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"}, {"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
# so let's make sure we import in case there are no other references to it in the modules # so let's make sure we import in case there are no other references to it in the modules
...@@ -175,7 +174,8 @@ def import_course_from_xml(modulestore, static_content_store, course_data_path, ...@@ -175,7 +174,8 @@ def import_course_from_xml(modulestore, static_content_store, course_data_path,
def import_from_xml(store, data_dir, course_dirs=None, def import_from_xml(store, data_dir, course_dirs=None,
default_class='xmodule.raw_module.RawDescriptor', default_class='xmodule.raw_module.RawDescriptor',
load_error_modules=True, static_content_store=None, target_location_namespace=None, verbose=False): load_error_modules=True, static_content_store=None, target_location_namespace=None,
verbose=False, draft_store=None):
""" """
Import the specified xml data_dir into the "store" modulestore, Import the specified xml data_dir into the "store" modulestore,
using org and course as the location org and course. using org and course as the location org and course.
...@@ -190,7 +190,7 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -190,7 +190,7 @@ def import_from_xml(store, data_dir, course_dirs=None,
""" """
module_store = XMLModuleStore( xml_module_store = XMLModuleStore(
data_dir, data_dir,
default_class=default_class, default_class=default_class,
course_dirs=course_dirs, course_dirs=course_dirs,
...@@ -201,7 +201,7 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -201,7 +201,7 @@ def import_from_xml(store, data_dir, course_dirs=None,
# to enumerate the entire collection of course modules. It will be left as a TBD to implement that # to enumerate the entire collection of course modules. It will be left as a TBD to implement that
# method on XmlModuleStore. # method on XmlModuleStore.
course_items = [] course_items = []
for course_id in module_store.modules.keys(): for course_id in xml_module_store.modules.keys():
if target_location_namespace is not None: if target_location_namespace is not None:
pseudo_course_id = '/'.join([target_location_namespace.org, target_location_namespace.course]) pseudo_course_id = '/'.join([target_location_namespace.org, target_location_namespace.course])
...@@ -222,7 +222,7 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -222,7 +222,7 @@ def import_from_xml(store, data_dir, course_dirs=None,
# Quick scan to get course module as we need some info from there. Also we need to make sure that the # Quick scan to get course module as we need some info from there. Also we need to make sure that the
# course module is committed first into the store # course module is committed first into the store
for module in module_store.modules[course_id].itervalues(): for module in xml_module_store.modules[course_id].itervalues():
if module.category == 'course': if module.category == 'course':
course_data_path = path(data_dir) / module.data_dir course_data_path = path(data_dir) / module.data_dir
course_location = module.location course_location = module.location
...@@ -235,15 +235,11 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -235,15 +235,11 @@ def import_from_xml(store, data_dir, course_dirs=None,
# if there is *any* tabs - then there at least needs to be some predefined ones # if there is *any* tabs - then there at least needs to be some predefined ones
if module.tabs is None or len(module.tabs) == 0: if module.tabs is None or len(module.tabs) == 0:
module.tabs = [{"type": "courseware"}, module.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"}, {"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"}, {"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
if hasattr(module, 'data'): import_module(module, store, course_data_path, static_content_store)
store.update_item(module.location, module.data)
store.update_children(module.location, module.children)
store.update_metadata(module.location, dict(own_metadata(module)))
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
# so let's make sure we import in case there are no other references to it in the modules # so let's make sure we import in case there are no other references to it in the modules
...@@ -251,17 +247,16 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -251,17 +247,16 @@ def import_from_xml(store, data_dir, course_dirs=None,
course_items.append(module) course_items.append(module)
# then import all the static content # then import all the static content
if static_content_store is not None: if static_content_store is not None:
_namespace_rename = target_location_namespace if target_location_namespace is not None else course_location _namespace_rename = target_location_namespace if target_location_namespace is not None else course_location
# first pass to find everything in /static/ # first pass to find everything in /static/
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store, import_static_content(xml_module_store.modules[course_id], course_location, course_data_path, static_content_store,
_namespace_rename, subpath='static', verbose=verbose) _namespace_rename, subpath='static', verbose=verbose)
# finally loop through all the modules # finally loop through all the modules
for module in module_store.modules[course_id].itervalues(): for module in xml_module_store.modules[course_id].itervalues():
if module.category == 'course': if module.category == 'course':
# we've already saved the course module up at the top of the loop # we've already saved the course module up at the top of the loop
...@@ -275,59 +270,149 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -275,59 +270,149 @@ def import_from_xml(store, data_dir, course_dirs=None,
if verbose: if verbose:
log.debug('importing module location {0}'.format(module.location)) log.debug('importing module location {0}'.format(module.location))
content = {} import_module(module, store, course_data_path, static_content_store)
for field in module.fields:
if field.scope != Scope.content: # now import any 'draft' items
continue if draft_store is not None:
try: import_course_draft(xml_module_store, draft_store, course_data_path,
content[field.name] = module._model_data[field.name] static_content_store, target_location_namespace if target_location_namespace is not None
except KeyError: else course_location)
# Ignore any missing keys in _model_data
pass
if 'data' in content:
module_data = content['data']
# cdodge: now go through any link references to '/static/' and make sure we've imported
# it as a StaticContent asset
try:
remap_dict = {}
# use the rewrite_links as a utility means to enumerate through all links
# in the module data. We use that to load that reference into our asset store
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
# do the rewrites natively in that code.
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
# no good, so we have to do this kludge
if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
lxml_rewrite_links(module_data,
lambda link: verify_content_links(module, course_data_path, static_content_store, link, remap_dict))
for key in remap_dict.keys():
module_data = module_data.replace(key, remap_dict[key])
except Exception:
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
else:
module_data = content
store.update_item(module.location, module_data)
if hasattr(module, 'children') and module.children != []:
store.update_children(module.location, module.children)
# NOTE: It's important to use own_metadata here to avoid writing
# inherited metadata everywhere.
store.update_metadata(module.location, dict(own_metadata(module)))
finally: finally:
# turn back on all write signalling # turn back on all write signalling
if pseudo_course_id in store.ignore_write_events_on_courses: if pseudo_course_id in store.ignore_write_events_on_courses:
store.ignore_write_events_on_courses.remove(pseudo_course_id) store.ignore_write_events_on_courses.remove(pseudo_course_id)
store.refresh_cached_metadata_inheritance_tree(target_location_namespace if store.refresh_cached_metadata_inheritance_tree(target_location_namespace if
target_location_namespace is not None else course_location) target_location_namespace is not None else course_location)
return xml_module_store, course_items
def import_module(module, store, course_data_path, static_content_store, allow_not_found=False):
content = {}
for field in module.fields:
if field.scope != Scope.content:
continue
try:
content[field.name] = module._model_data[field.name]
except KeyError:
# Ignore any missing keys in _model_data
pass
module_data = {}
if 'data' in content:
module_data = content['data']
# cdodge: now go through any link references to '/static/' and make sure we've imported
# it as a StaticContent asset
try:
remap_dict = {}
# use the rewrite_links as a utility means to enumerate through all links
# in the module data. We use that to load that reference into our asset store
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
# do the rewrites natively in that code.
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
# no good, so we have to do this kludge
if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path, static_content_store, link, remap_dict))
for key in remap_dict.keys():
module_data = module_data.replace(key, remap_dict[key])
except Exception:
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
else:
module_data = content
if allow_not_found:
store.update_item(module.location, module_data, allow_not_found=allow_not_found)
else:
store.update_item(module.location, module_data)
if hasattr(module, 'children') and module.children != []:
store.update_children(module.location, module.children)
# NOTE: It's important to use own_metadata here to avoid writing
# inherited metadata everywhere.
store.update_metadata(module.location, dict(own_metadata(module)))
def import_course_draft(xml_module_store, store, course_data_path, static_content_store, target_location_namespace):
'''
This will import all the content inside of the 'drafts' folder, if it exists
NOTE: This is not a full course import, basically in our current application only verticals (and downwards)
can be in draft. Therefore, we need to use slightly different call points into the import process_xml
as we can't simply call XMLModuleStore() constructor (like we do for importing public content)
'''
draft_dir = course_data_path + "/drafts"
if not os.path.exists(draft_dir):
return
# create a new 'System' object which will manage the importing
errorlog = make_error_tracker()
system = ImportSystem(
xml_module_store,
target_location_namespace.course_id,
draft_dir,
{},
errorlog.tracker,
ParentTracker(),
None,
)
# now walk the /vertical directory where each file in there will be a draft copy of the Vertical
for dirname, dirnames, filenames in os.walk(draft_dir + "/vertical"):
for filename in filenames:
module_path = os.path.join(dirname, filename)
with open(module_path) as f:
try:
xml = f.read().decode('utf-8')
descriptor = system.process_xml(xml)
def _import_module(module):
module.location = module.location._replace(revision='draft')
# make sure our parent has us in its list of children
# this is to make sure private only verticals show up in the list of children since
# they would have been filtered out from the non-draft store export
if module.location.category == 'vertical':
module.location = module.location._replace(revision=None)
sequential_url = module.xml_attributes['parent_sequential_url']
index = int(module.xml_attributes['index_in_children_list'])
seq_location = Location(sequential_url)
# IMPORTANT: Be sure to update the sequential in the NEW namespace
seq_location = seq_location._replace(org=target_location_namespace.org,
course=target_location_namespace.course
)
sequential = store.get_item(seq_location)
if module.location.url() not in sequential.children:
sequential.children.insert(index, module.location.url())
store.update_children(sequential.location, sequential.children)
del module.xml_attributes['parent_sequential_url']
del module.xml_attributes['index_in_children_list']
import_module(module, store, course_data_path, static_content_store, allow_not_found=True)
for child in module.get_children():
_import_module(child)
# HACK: since we are doing partial imports of drafts
# the vertical doesn't have the 'url-name' set in the attributes (they are normally in the parent
# object, aka sequential), so we have to replace the location.name with the XML filename
# that is part of the pack
fn, fileExtension = os.path.splitext(filename)
descriptor.location = descriptor.location._replace(name=fn)
_import_module(descriptor)
except Exception, e:
logging.exception('There was an error. {0}'.format(unicode(e)))
pass
return module_store, course_items
def remap_namespace(module, target_location_namespace): def remap_namespace(module, target_location_namespace):
if target_location_namespace is None: if target_location_namespace is None:
...@@ -337,20 +422,20 @@ def remap_namespace(module, target_location_namespace): ...@@ -337,20 +422,20 @@ def remap_namespace(module, target_location_namespace):
# the caller passed in # the caller passed in
if module.location.category != 'course': if module.location.category != 'course':
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
course=target_location_namespace.course) course=target_location_namespace.course)
else: else:
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
course=target_location_namespace.course, name=target_location_namespace.name) course=target_location_namespace.course, name=target_location_namespace.name)
# then remap children pointers since they too will be re-namespaced # then remap children pointers since they too will be re-namespaced
if hasattr(module,'children'): if hasattr(module, 'children'):
children_locs = module.children children_locs = module.children
if children_locs is not None and children_locs != []: if children_locs is not None and children_locs != []:
new_locs = [] new_locs = []
for child in children_locs: for child in children_locs:
child_loc = Location(child) child_loc = Location(child)
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
course=target_location_namespace.course) course=target_location_namespace.course)
new_locs.append(new_child_loc.url()) new_locs.append(new_child_loc.url())
...@@ -365,7 +450,7 @@ def allowed_metadata_by_category(category): ...@@ -365,7 +450,7 @@ def allowed_metadata_by_category(category):
'vertical': [], 'vertical': [],
'chapter': ['start'], 'chapter': ['start'],
'sequential': ['due', 'format', 'start', 'graded'] 'sequential': ['due', 'format', 'start', 'graded']
}.get(category,['*']) }.get(category, ['*'])
def check_module_metadata_editability(module): def check_module_metadata_editability(module):
...@@ -380,7 +465,6 @@ def check_module_metadata_editability(module): ...@@ -380,7 +465,6 @@ def check_module_metadata_editability(module):
allowed = allowed + ['xml_attributes', 'display_name'] allowed = allowed + ['xml_attributes', 'display_name']
err_cnt = 0 err_cnt = 0
my_metadata = dict(own_metadata(module))
illegal_keys = set(own_metadata(module).keys()) - set(allowed) illegal_keys = set(own_metadata(module).keys()) - set(allowed)
if len(illegal_keys) > 0: if len(illegal_keys) > 0:
...@@ -423,7 +507,7 @@ def validate_data_source_path_existence(path, is_err=True, extra_msg=None): ...@@ -423,7 +507,7 @@ def validate_data_source_path_existence(path, is_err=True, extra_msg=None):
_cnt = 0 _cnt = 0
if not os.path.exists(path): if not os.path.exists(path):
print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if
extra_msg is not None else '')) extra_msg is not None else ''))
_cnt = 1 _cnt = 1
return _cnt return _cnt
...@@ -435,13 +519,13 @@ def validate_data_source_paths(data_dir, course_dir): ...@@ -435,13 +519,13 @@ def validate_data_source_paths(data_dir, course_dir):
warn_cnt = 0 warn_cnt = 0
err_cnt += validate_data_source_path_existence(course_path / 'static') err_cnt += validate_data_source_path_existence(course_path / 'static')
warn_cnt += validate_data_source_path_existence(course_path / 'static/subs', is_err=False, warn_cnt += validate_data_source_path_existence(course_path / 'static/subs', is_err=False,
extra_msg='Video captions (if they are used) will not work unless they are static/subs.') extra_msg='Video captions (if they are used) will not work unless they are static/subs.')
return err_cnt, warn_cnt return err_cnt, warn_cnt
def perform_xlint(data_dir, course_dirs, def perform_xlint(data_dir, course_dirs,
default_class='xmodule.raw_module.RawDescriptor', default_class='xmodule.raw_module.RawDescriptor',
load_error_modules=True): load_error_modules=True):
err_cnt = 0 err_cnt = 0
warn_cnt = 0 warn_cnt = 0
...@@ -497,7 +581,6 @@ def perform_xlint(data_dir, course_dirs, ...@@ -497,7 +581,6 @@ def perform_xlint(data_dir, course_dirs,
print "WARN: Missing course marketing video. It is recommended that every course have a marketing video." print "WARN: Missing course marketing video. It is recommended that every course have a marketing video."
warn_cnt += 1 warn_cnt += 1
print "\n\n------------------------------------------\nVALIDATION SUMMARY: {0} Errors {1} Warnings\n".format(err_cnt, warn_cnt) print "\n\n------------------------------------------\nVALIDATION SUMMARY: {0} Errors {1} Warnings\n".format(err_cnt, warn_cnt)
if err_cnt > 0: if err_cnt > 0:
......
...@@ -29,6 +29,6 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor): ...@@ -29,6 +29,6 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
line, offset = err.position line, offset = err.position
msg = ("Unable to create xml for problem {loc}. " msg = ("Unable to create xml for problem {loc}. "
"Context: '{context}'".format( "Context: '{context}'".format(
context=lines[line - 1][offset - 40:offset + 40], context=lines[line - 1][offset - 40:offset + 40],
loc=self.location)) loc=self.location))
raise Exception, msg, sys.exc_info()[2] raise Exception, msg, sys.exc_info()[2]
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
metadata: metadata:
display_name: Discussion Tag display_name: Discussion Tag
for: Topic-Level Student-Visible Label for: Topic-Level Student-Visible Label
id: 6002x_group_discussion_by_this id: $$GUID$$
discussion_category: Week 1 discussion_category: Week 1
data: | data: |
<discussion for="Topic-Level Student-Visible Label" id="6002x_group_discussion_by_this" discussion_category="Week 1" /> <discussion />
children: [] children: []
--- ---
metadata: metadata:
display_name: E-text Written in LaTeX display_name: E-text Written in LaTeX
source_processor_url: https://qisx.mit.edu:5443/latex2edx
source_code: | source_code: |
\subsection{Example of E-text in LaTeX} \subsection{Example of E-text in LaTeX}
......
--- ---
metadata: metadata:
display_name: Problem Written in LaTeX display_name: Problem Written in LaTeX
source_processor_url: https://studio-input-filter.mitx.mit.edu/latex2edx
source_code: | source_code: |
% Nearly any kind of edX problem can be authored using Latex as % Nearly any kind of edX problem can be authored using Latex as
% the source language. Write latex as usual, including equations. The % the source language. Write latex as usual, including equations. The
......
--- ---
metadata: metadata:
display_name: Problem with Adaptive Hint display_name: Problem with Adaptive Hint
source_processor_url: https://qisx.mit.edu:5443/latex2edx
source_code: | source_code: |
\subsection{Problem With Adaptive Hint} \subsection{Problem With Adaptive Hint}
......
...@@ -340,7 +340,9 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): ...@@ -340,7 +340,9 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
# cdodge: this is a list of metadata names which are 'system' metadata # cdodge: this is a list of metadata names which are 'system' metadata
# and should not be edited by an end-user # and should not be edited by an end-user
system_metadata_fields = ['data_dir', 'published_date', 'published_by', 'is_draft', 'xml_attributes']
system_metadata_fields = ['data_dir', 'published_date', 'published_by', 'is_draft',
'discussion_id', 'xml_attributes']
# A list of descriptor attributes that must be equal for the descriptors to # A list of descriptor attributes that must be equal for the descriptors to
# be equal # be equal
......
...@@ -110,8 +110,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -110,8 +110,7 @@ class XmlDescriptor(XModuleDescriptor):
'name', 'slug') 'name', 'slug')
metadata_to_strip = ('data_dir', metadata_to_strip = ('data_dir',
# cdodge: @TODO: We need to figure out a way to export out 'tabs' and 'grading_policy' which is on the course 'tabs', 'grading_policy', 'published_by', 'published_date',
'tabs', 'grading_policy', 'is_draft', 'published_by', 'published_date',
'discussion_blackouts', 'testcenter_info', 'discussion_blackouts', 'testcenter_info',
# VS[compat] -- remove the below attrs once everything is in the CMS # VS[compat] -- remove the below attrs once everything is in the CMS
'course', 'org', 'url_name', 'filename', 'course', 'org', 'url_name', 'filename',
...@@ -135,7 +134,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -135,7 +134,7 @@ class XmlDescriptor(XModuleDescriptor):
'graded': bool_map, 'graded': bool_map,
'hide_progress_tab': bool_map, 'hide_progress_tab': bool_map,
'allow_anonymous': bool_map, 'allow_anonymous': bool_map,
'allow_anonymous_to_peers': bool_map 'allow_anonymous_to_peers': bool_map,
} }
......
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib.auth.models import User from factories import UserFactory, RegistrationFactory, UserProfileFactory
from student.models import Registration, UserProfile
import json import json
class LoginTest(TestCase): class LoginTest(TestCase):
''' '''
Test student.views.login_user() view Test student.views.login_user() view
''' '''
def setUp(self): def setUp(self):
# Create one user and save it to the database # Create one user and save it to the database
self.user = User.objects.create_user('test', 'test@edx.org', 'test_password') self.user = UserFactory.build(username='test', email='test@edx.org')
self.user.is_active = True self.user.set_password('test_password')
self.user.save() self.user.save()
# Create a registration for the user # Create a registration for the user
Registration().register(self.user) registration = RegistrationFactory(user=self.user)
registration.register(self.user)
registration.activate()
# Create a profile for the user # Create a profile for the user
UserProfile(user=self.user).save() UserProfileFactory(user=self.user)
# Create the test client # Create the test client
self.client = Client() self.client = Client()
...@@ -42,19 +43,17 @@ class LoginTest(TestCase): ...@@ -42,19 +43,17 @@ class LoginTest(TestCase):
response = self._login_response(unicode_email, 'test_password') response = self._login_response(unicode_email, 'test_password')
self._assert_response(response, success=True) self._assert_response(response, success=True)
def test_login_fail_no_user_exists(self): def test_login_fail_no_user_exists(self):
response = self._login_response('not_a_user@edx.org', 'test_password') response = self._login_response('not_a_user@edx.org', 'test_password')
self._assert_response(response, success=False, self._assert_response(response, success=False,
value='Email or password is incorrect') value='Email or password is incorrect')
def test_login_fail_wrong_password(self): def test_login_fail_wrong_password(self):
response = self._login_response('test@edx.org', 'wrong_password') response = self._login_response('test@edx.org', 'wrong_password')
self._assert_response(response, success=False, self._assert_response(response, success=False,
value='Email or password is incorrect') value='Email or password is incorrect')
def test_login_not_activated(self): def test_login_not_activated(self):
# De-activate the user # De-activate the user
self.user.is_active = False self.user.is_active = False
self.user.save() self.user.save()
...@@ -62,8 +61,7 @@ class LoginTest(TestCase): ...@@ -62,8 +61,7 @@ class LoginTest(TestCase):
# Should now be unable to login # Should now be unable to login
response = self._login_response('test@edx.org', 'test_password') response = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=False, self._assert_response(response, success=False,
value="This account has not been activated") value="This account has not been activated")
def test_login_unicode_email(self): def test_login_unicode_email(self):
unicode_email = u'test@edx.org' + unichr(40960) unicode_email = u'test@edx.org' + unichr(40960)
...@@ -95,13 +93,13 @@ class LoginTest(TestCase): ...@@ -95,13 +93,13 @@ class LoginTest(TestCase):
try: try:
response_dict = json.loads(response.content) response_dict = json.loads(response.content)
except ValueError: except ValueError:
self.fail("Could not parse response content as JSON: %s" self.fail("Could not parse response content as JSON: %s"
% str(response.content)) % str(response.content))
if success is not None: if success is not None:
self.assertEqual(response_dict['success'], success) self.assertEqual(response_dict['success'], success)
if value is not None: if value is not None:
msg = ("'%s' did not contain '%s'" % msg = ("'%s' did not contain '%s'" %
(str(response_dict['value']), str(value))) (str(response_dict['value']), str(value)))
self.assertTrue(value in response_dict['value'], msg) self.assertTrue(value in response_dict['value'], msg)
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django_comment_client.models import Permission, Role from django_comment_client.models import Role
class Command(BaseCommand): class Command(BaseCommand):
...@@ -12,18 +12,19 @@ class Command(BaseCommand): ...@@ -12,18 +12,19 @@ class Command(BaseCommand):
if len(args) > 1: if len(args) > 1:
raise CommandError("Too many arguments") raise CommandError("Too many arguments")
course_id = args[0] course_id = args[0]
administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0] administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0]
moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0] moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0]
community_ta_role = Role.objects.get_or_create(name="Community TA", course_id=course_id)[0] community_ta_role = Role.objects.get_or_create(name="Community TA", course_id=course_id)[0]
student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0] student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0]
for per in ["vote", "update_thread", "follow_thread", "unfollow_thread", for per in ["vote", "update_thread", "follow_thread", "unfollow_thread",
"update_comment", "create_sub_comment", "unvote", "create_thread", "update_comment", "create_sub_comment", "unvote", "create_thread",
"follow_commentable", "unfollow_commentable", "create_comment", ]: "follow_commentable", "unfollow_commentable", "create_comment", ]:
student_role.add_permission(per) student_role.add_permission(per)
for per in ["edit_content", "delete_thread", "openclose_thread", for per in ["edit_content", "delete_thread", "openclose_thread",
"endorse_comment", "delete_comment", "see_all_cohorts"]: "endorse_comment", "delete_comment", "see_all_cohorts"]:
moderator_role.add_permission(per) moderator_role.add_permission(per)
for per in ["manage_moderator"]: for per in ["manage_moderator"]:
......
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
import json
from logging import getLogger
logger = getLogger(__name__)
class MockCommentServiceRequestHandler(BaseHTTPRequestHandler):
'''
A handler for Comment Service POST requests.
'''
protocol = "HTTP/1.0"
def do_POST(self):
'''
Handle a POST request from the client
Used by the APIs for comment threads, commentables, comments,
subscriptions, commentables, users
'''
# Retrieve the POST data into a dict.
# It should have been sent in json format
length = int(self.headers.getheader('content-length'))
data_string = self.rfile.read(length)
post_dict = json.loads(data_string)
# Log the request
logger.debug("Comment Service received POST request %s to path %s" %
(json.dumps(post_dict), self.path))
# Every good post has at least an API key
if 'api_key' in post_dict:
response = self.server._response_str
# Log the response
logger.debug("Comment Service: sending response %s" % json.dumps(response))
# Send a response back to the client
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(response)
else:
# Respond with failure
self.send_response(500, 'Bad Request: does not contain API key')
self.send_header('Content-type', 'text/plain')
self.end_headers()
return False
class MockCommentServiceServer(HTTPServer):
'''
A mock Comment Service server that responds
to POST requests to localhost.
'''
def __init__(self, port_num,
response={'username': 'new', 'external_id': 1}):
'''
Initialize the mock Comment Service server instance.
*port_num* is the localhost port to listen to
*response* is a dictionary that will be JSON-serialized
and sent in response to comment service requests.
'''
self._response_str = json.dumps(response)
handler = MockCommentServiceRequestHandler
address = ('', port_num)
HTTPServer.__init__(self, address, handler)
def shutdown(self):
'''
Stop the server and free up the port
'''
# First call superclass shutdown()
HTTPServer.shutdown(self)
# We also need to manually close the socket
self.socket.close()
import unittest
import threading
import json
import urllib2
from mock_cs_server import MockCommentServiceServer
from nose.plugins.skip import SkipTest
class MockCommentServiceServerTest(unittest.TestCase):
'''
A mock version of the Comment Service server that listens on a local
port and responds with pre-defined grade messages.
'''
def setUp(self):
# This is a test of the test setup,
# so it does not need to run as part of the unit test suite
# You can re-enable it by commenting out the line below
raise SkipTest
# Create the server
server_port = 4567
self.server_url = 'http://127.0.0.1:%d' % server_port
# Start up the server and tell it that by default it should
# return this as its json response
self.expected_response = {'username': 'user100', 'external_id': '4'}
self.server = MockCommentServiceServer(port_num=server_port,
response=self.expected_response)
# Start the server in a separate daemon thread
server_thread = threading.Thread(target=self.server.serve_forever)
server_thread.daemon = True
server_thread.start()
def tearDown(self):
# Stop the server, freeing up the port
self.server.shutdown()
def test_new_user_request(self):
"""
Test the mock comment service using an example
of how you would create a new user
"""
# Send a request
values = {'username': u'user100', 'api_key': 'TEST_API_KEY',
'external_id': '4', 'email': u'user100@edx.org'}
data = json.dumps(values)
headers = {'Content-Type': 'application/json', 'Content-Length': len(data)}
req = urllib2.Request(self.server_url + '/api/v1/users/4', data, headers)
# Send the request to the mock cs server
response = urllib2.urlopen(req)
# Receive the reply from the mock cs server
response_dict = json.loads(response.read())
# You should have received the response specified in the setup above
self.assertEqual(response_dict, self.expected_response)
...@@ -146,28 +146,16 @@ def sort_map_entries(category_map): ...@@ -146,28 +146,16 @@ def sort_map_entries(category_map):
def initialize_discussion_info(course): def initialize_discussion_info(course):
global _DISCUSSIONINFO global _DISCUSSIONINFO
# only cache in-memory discussion information for 10 minutes
# this is because we need a short-term hack fix for
# mongo-backed courseware whereby new discussion modules can be added
# without LMS service restart
if _DISCUSSIONINFO[course.id]:
timestamp = _DISCUSSIONINFO[course.id].get('timestamp', datetime.now())
age = datetime.now() - timestamp
# expire every 5 minutes
if age.seconds < 300:
return
course_id = course.id course_id = course.id
discussion_id_map = {} discussion_id_map = {}
unexpanded_category_map = defaultdict(list) unexpanded_category_map = defaultdict(list)
# get all discussion models within this course_id # get all discussion models within this course_id
all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course, 'discussion', None], course_id=course_id) all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course,
'discussion', None], course_id=course_id)
for module in all_modules: for module in all_modules:
skip_module = False skip_module = False
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment