Merge branch 'master' into ormsbee/verifyuser3

Conflicts:
	common/djangoapps/course_modes/models.py
	lms/djangoapps/shoppingcart/models.py
	lms/djangoapps/shoppingcart/processors/CyberSource.py
	lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py
	lms/djangoapps/shoppingcart/tests/test_models.py
	lms/djangoapps/shoppingcart/tests/test_views.py
	lms/djangoapps/shoppingcart/urls.py
	lms/djangoapps/shoppingcart/views.py
	lms/envs/common.py
	lms/envs/dev.py
	lms/static/sass/base/_variables.scss
This commit is contained in:
David Ormsbee
2013-09-04 10:58:30 -04:00
377 changed files with 14855 additions and 4020 deletions

View File

@@ -2,7 +2,7 @@
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_false, assert_equal, assert_regexp_matches
from nose.tools import assert_false, assert_equal, assert_regexp_matches # pylint: disable=E0611
from common import type_in_codemirror, press_the_notification_button
KEY_CSS = '.key input.policy-key'

View File

@@ -2,7 +2,7 @@
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true, assert_equal, assert_in
from nose.tools import assert_true, assert_equal, assert_in # pylint: disable=E0611
from terrain.steps import reload_the_page
from selenium.common.exceptions import StaleElementReferenceException

View File

@@ -2,7 +2,7 @@
# pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true
from nose.tools import assert_true # pylint: disable=E0611
from auth.authz import get_user_by_email, get_course_groupname_for_role
from django.conf import settings
@@ -265,9 +265,8 @@ def type_in_codemirror(index, text):
def upload_file(filename):
file_css = '.upload-dialog input[type=file]'
upload = world.css_find(file_css).first
path = os.path.join(TEST_ROOT, filename)
upload._element.send_keys(os.path.abspath(path))
world.browser.execute_script("$('input.file-input').css('display', 'block')")
world.browser.attach_file('file', os.path.abspath(path))
button_css = '.upload-dialog .action-upload'
world.css_click(button_css)

View File

@@ -2,7 +2,7 @@
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true
from nose.tools import assert_true # pylint: disable=E0611
DATA_LOCATION = 'i4x://edx/templates'

View File

@@ -2,7 +2,7 @@
#pylint: disable=C0111
from lettuce import world
from nose.tools import assert_equal
from nose.tools import assert_equal # pylint: disable=E0611
from terrain.steps import reload_the_page

View File

@@ -3,7 +3,7 @@
from lettuce import world, step
from common import *
from nose.tools import assert_true, assert_false, assert_equal
from nose.tools import assert_true, assert_false, assert_equal # pylint: disable=E0611
from logging import getLogger
logger = getLogger(__name__)

View File

@@ -7,7 +7,7 @@ from selenium.webdriver.common.keys import Keys
from common import type_in_codemirror, upload_file
from django.conf import settings
from nose.tools import assert_true, assert_false, assert_equal
from nose.tools import assert_true, assert_false, assert_equal # pylint: disable=E0611
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
@@ -168,15 +168,18 @@ def i_see_new_course_image(_step):
img = images[0]
expected_src = '/c4x/MITx/999/asset/image.jpg'
# Don't worry about the domain in the URL
assert img['src'].endswith(expected_src)
try:
assert img['src'].endswith(expected_src)
except AssertionError as e:
e.args += ('Was looking for {}'.format(expected_src), 'Found {}'.format(img['src']))
raise
@step('the image URL should be present in the field')
def image_url_present(_step):
field_css = '#course-image-url'
field = world.css_find(field_css).first
expected_value = '/c4x/MITx/999/asset/image.jpg'
assert field.value == expected_value
assert world.css_value(field_css) == expected_value
############### HELPER METHODS ####################

View File

@@ -5,7 +5,7 @@ from lettuce import world, step
from common import create_studio_user
from django.contrib.auth.models import Group
from auth.authz import get_course_groupname_for_role, get_user_by_email
from nose.tools import assert_true
from nose.tools import assert_true # pylint: disable=E0611
PASSWORD = 'test'
EMAIL_EXTENSION = '@edx.org'

View File

@@ -45,3 +45,25 @@ Feature: Course updates
When I modify the handout to "<ol>Test</ol>"
Then I see the handout "Test"
And I see a "saving" notification
Scenario: Static links are rewritten when previewing a course update
Given I have opened a new course in Studio
And I go to the course updates page
When I add a new update with the text "<img src='/static/my_img.jpg'/>"
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
Then I should see the update "/c4x/MITx/999/asset/my_img.jpg"
And I change the update from "/static/my_img.jpg" to "<img src='/static/modified.jpg'/>"
Then I should see the update "/c4x/MITx/999/asset/modified.jpg"
And when I reload the page
Then I should see the update "/c4x/MITx/999/asset/modified.jpg"
Scenario: Static links are rewritten when previewing handouts
Given I have opened a new course in Studio
And I go to the course updates page
When I modify the handout to "<ol><img src='/static/my_img.jpg'/></ol>"
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
Then I see the handout "/c4x/MITx/999/asset/my_img.jpg"
And I change the handout from "/static/my_img.jpg" to "<img src='/static/modified.jpg'/>"
Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
And when I reload the page
Then I see the handout "/c4x/MITx/999/asset/modified.jpg"

View File

@@ -38,6 +38,16 @@ def modify_update(_step, text):
change_text(text)
@step(u'I change the update from "([^"]*)" to "([^"]*)"$')
def change_existing_update(_step, before, after):
verify_text_in_editor_and_update('div.post-preview a.edit-button', before, after)
@step(u'I change the handout from "([^"]*)" to "([^"]*)"$')
def change_existing_handout(_step, before, after):
verify_text_in_editor_and_update('div.course-handouts a.edit-button', before, after)
@step(u'I delete the update$')
def click_button(_step):
button_css = 'div.post-preview a.delete-button'
@@ -80,3 +90,10 @@ def change_text(text):
type_in_codemirror(0, text)
save_css = 'a.save-button'
world.css_click(save_css)
def verify_text_in_editor_and_update(button_css, before, after):
world.css_click(button_css)
text = world.css_find(".cm-string").html
assert before in text
change_text(after)

View File

@@ -2,7 +2,7 @@
#pylint: disable=C0111
from lettuce import world, step
from nose.tools import assert_equal
from nose.tools import assert_equal # pylint: disable=E0611
from common import type_in_codemirror
DISPLAY_NAME = "Display Name"

View File

@@ -3,7 +3,7 @@
from lettuce import world, step
from common import *
from nose.tools import assert_equal
from nose.tools import assert_equal # pylint: disable=E0611
############### ACTIONS ####################

View File

@@ -11,8 +11,9 @@ Feature: Static Pages
Given I have opened a new course in Studio
And I go to the static pages page
And I add a new page
When I will confirm all alerts
And I "delete" the "Empty" page
Then I am shown a prompt
When I confirm the prompt
Then I should not see a "Empty" static page
# Safari won't update the name properly

View File

@@ -3,7 +3,7 @@
from lettuce import world, step
from common import *
from nose.tools import assert_equal
from nose.tools import assert_equal # pylint: disable=E0611
############### ACTIONS ####################

View File

@@ -29,7 +29,7 @@ def correct_video_settings(_step):
['Download Track', '', False],
['Download Video', '', False],
['End Time', '0', False],
['HTML5 Subtitles', '', False],
['HTML5 Timed Transcript', '', False],
['Show Captions', 'True', False],
['Start Time', '0', False],
['Video Sources', '', False],

View File

@@ -3,11 +3,6 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml_importer import check_module_metadata_editability
from xmodule.course_module import CourseDescriptor
from request_cache.middleware import RequestCache
from django.core.cache import get_cache
CACHE = get_cache('mongo_metadata_inheritance')
class Command(BaseCommand):
help = '''Enumerates through the course and find common errors'''
@@ -21,12 +16,6 @@ class Command(BaseCommand):
loc = CourseDescriptor.id_to_location(loc_str)
store = modulestore()
# setup a request cache so we don't throttle the DB with all the metadata inheritance requests
store.set_modulestore_configuration({
'metadata_inheritance_cache_subsystem': CACHE,
'request_cache': RequestCache.get_request_cache()
})
course = store.get_item(loc, depth=3)
err_cnt = 0

View File

@@ -9,14 +9,10 @@ from xmodule.course_module import CourseDescriptor
from auth.authz import _copy_course_group
#
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3
#
from request_cache.middleware import RequestCache
from django.core.cache import get_cache
CACHE = get_cache('mongo_metadata_inheritance')
class Command(BaseCommand):
"""Clone a MongoDB-backed course to another location"""
help = 'Clone a MongoDB backed course to another location'
@@ -32,11 +28,6 @@ class Command(BaseCommand):
mstore = modulestore('direct')
cstore = contentstore()
mstore.set_modulestore_configuration({
'metadata_inheritance_cache_subsystem': CACHE,
'request_cache': RequestCache.get_request_cache()
})
org, course_num, run = dest_course_id.split("/")
mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))

View File

@@ -9,14 +9,11 @@ from xmodule.course_module import CourseDescriptor
from .prompt import query_yes_no
from auth.authz import _delete_course_group
from request_cache.middleware import RequestCache
from django.core.cache import get_cache
#
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
#
CACHE = get_cache('mongo_metadata_inheritance')
class Command(BaseCommand):
help = '''Delete a MongoDB backed course'''
@@ -36,11 +33,6 @@ class Command(BaseCommand):
ms = modulestore('direct')
cs = contentstore()
ms.set_modulestore_configuration({
'metadata_inheritance_cache_subsystem': CACHE,
'request_cache': RequestCache.get_request_cache()
})
org, course_num, run = course_id.split("/")
ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))

View File

@@ -0,0 +1,8 @@
"""
Define test configuration for modulestores.
"""
from xmodule.modulestore.tests.django_utils import studio_store_config
from django.conf import settings
TEST_MODULESTORE = studio_store_config(settings.TEST_ROOT / "data")

View File

@@ -60,11 +60,11 @@ class UploadTestCase(CourseTestCase):
f = BytesIO("sample content")
f.name = "sample.txt"
resp = self.client.post(self.url, {"name": "my-name", "file": f})
self.assert2XX(resp.status_code)
self.assertEquals(resp.status_code, 200)
def test_no_file(self):
resp = self.client.post(self.url, {"name": "file.txt"})
self.assert4XX(resp.status_code)
self.assertEquals(resp.status_code, 400)
def test_get(self):
resp = self.client.get(self.url)

View File

@@ -3,6 +3,9 @@
import json
import shutil
import mock
from textwrap import dedent
from django.test.client import Client
from django.test.utils import override_settings
from django.conf import settings
@@ -22,6 +25,7 @@ from contentstore.tests.utils import parse_json
from auth.authz import add_user_to_creator_group
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.modulestore_config import TEST_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore import Location, mongo
@@ -65,7 +69,7 @@ class MongoCollectionFindWrapper(object):
return self.original(query, *args, **kwargs)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
class ContentStoreToyCourseTest(ModuleStoreTestCase):
"""
Tests that rely on the toy courses.
@@ -312,7 +316,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None]))
self.assertIn('/static/', handouts.data)
def test_import_textbook_as_content_element(self):
@mock.patch('xmodule.course_module.requests.get')
def test_import_textbook_as_content_element(self, mock_get):
mock_get.return_value.text = dedent("""
<?xml version="1.0"?><table_of_contents>
<entry page="5" page_label="ii" name="Table of Contents"/>
</table_of_contents>
""").strip()
module_store = modulestore('direct')
import_from_xml(module_store, 'common/test/data/', ['toy'])
@@ -845,7 +856,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
filesystem = OSFS(root_dir / ('test_export/' + dirname))
self.assertTrue(filesystem.exists(item.location.name + filename_suffix))
def test_export_course(self):
@mock.patch('xmodule.course_module.requests.get')
def test_export_course(self, mock_get):
mock_get.return_value.text = dedent("""
<?xml version="1.0"?><table_of_contents>
<entry page="5" page_label="ii" name="Table of Contents"/>
</table_of_contents>
""").strip()
module_store = modulestore('direct')
draft_store = modulestore('draft')
content_store = contentstore()
@@ -1122,12 +1140,15 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
wrapper = MongoCollectionFindWrapper(module_store.collection.find)
module_store.collection.find = wrapper.find
print module_store.metadata_inheritance_cache_subsystem
print module_store.request_cache
course = module_store.get_item(location, depth=2)
# make sure we haven't done too many round trips to DB
# note we say 4 round trips here for 1) the course, 2 & 3) for the chapters and sequentials, and
# 4) because of the RT due to calculating the inherited metadata
self.assertEqual(wrapper.counter, 4)
# note we say 3 round trips here for 1) the course, and 2 & 3) for the chapters and sequentials
# Because we're querying from the top of the tree, we cache information needed for inheritance,
# so we don't need to make an extra query to compute it.
self.assertEqual(wrapper.counter, 3)
# make sure we pre-fetched a known sequential which should be at depth=2
self.assertTrue(Location(['i4x', 'edX', 'toy', 'sequential',
@@ -1163,7 +1184,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
export_to_xml(module_store, content_store, location, root_dir, 'test_export')
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
class ContentStoreTest(ModuleStoreTestCase):
"""
Tests for the CMS ContentStore application.
@@ -1408,7 +1429,7 @@ class ContentStoreTest(ModuleStoreTestCase):
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Chapter 2')
# go to various pages
@@ -1418,92 +1439,92 @@ class ContentStoreTest(ModuleStoreTestCase):
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# export page
resp = self.client.get(reverse('export_course',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# manage users
resp = self.client.get(reverse('manage_users',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# course info
resp = self.client.get(reverse('course_info',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# settings_details
resp = self.client.get(reverse('settings_details',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# settings_details
resp = self.client.get(reverse('settings_grading',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# static_pages
resp = self.client.get(reverse('static_pages',
kwargs={'org': loc.org,
'course': loc.course,
'coursename': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# static_pages
resp = self.client.get(reverse('asset_index',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# go look at a subsection page
subsection_location = loc.replace(category='sequential', name='test_sequence')
resp = self.client.get(reverse('edit_subsection',
kwargs={'location': subsection_location.url()}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# go look at the Edit page
unit_location = loc.replace(category='vertical', name='test_vertical')
resp = self.client.get(reverse('edit_unit',
kwargs={'location': unit_location.url()}))
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# delete a component
del_loc = loc.replace(category='html', name='test_html')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# delete a unit
del_loc = loc.replace(category='vertical', name='test_vertical')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# delete a unit
del_loc = loc.replace(category='sequential', name='test_sequence')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# delete a chapter
del_loc = loc.replace(category='chapter', name='chapter_2')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
def test_import_into_new_course_id(self):
module_store = modulestore('direct')
@@ -1690,6 +1711,7 @@ class ContentStoreTest(ModuleStoreTestCase):
content_store.find(location)
@override_settings(MODULESTORE=TEST_MODULESTORE)
class MetadataSaveTestCase(ModuleStoreTestCase):
"""Test that metadata is correctly cached and decached."""

View File

@@ -439,12 +439,12 @@ class CourseGraderUpdatesTest(CourseTestCase):
def test_get(self):
resp = self.client.get(self.url)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
obj = json.loads(resp.content)
def test_delete(self):
resp = self.client.delete(self.url)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
def test_post(self):
grader = {
@@ -455,5 +455,5 @@ class CourseGraderUpdatesTest(CourseTestCase):
"weight": 17.3,
}
resp = self.client.post(self.url, grader)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
obj = json.loads(resp.content)

View File

@@ -3,10 +3,13 @@ from unittest import skip
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from django.test.client import Client
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.modulestore_config import TEST_MODULESTORE
@override_settings(MODULESTORE=TEST_MODULESTORE)
class InternationalizationTest(ModuleStoreTestCase):
"""
Tests to validate Internationalization.

View File

@@ -0,0 +1,94 @@
"""
Unit tests for course import and export
"""
import os
import shutil
import tarfile
import tempfile
import copy
from uuid import uuid4
from pymongo import MongoClient
from .utils import CourseTestCase
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from django.conf import settings
from xmodule.contentstore.django import _CONTENTSTORE
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class ImportTestCase(CourseTestCase):
"""
Unit tests for importing a course
"""
def setUp(self):
super(ImportTestCase, self).setUp()
self.url = reverse("import_course", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
})
self.content_dir = tempfile.mkdtemp()
def touch(name):
""" Equivalent to shell's 'touch'"""
with file(name, 'a'):
os.utime(name, None)
# Create tar test files -----------------------------------------------
# OK course:
good_dir = tempfile.mkdtemp(dir=self.content_dir)
os.makedirs(os.path.join(good_dir, "course"))
with open(os.path.join(good_dir, "course.xml"), "w+") as f:
f.write('<course url_name="2013_Spring" org="EDx" course="0.00x"/>')
with open(os.path.join(good_dir, "course", "2013_Spring.xml"), "w+") as f:
f.write('<course></course>')
self.good_tar = os.path.join(self.content_dir, "good.tar.gz")
with tarfile.open(self.good_tar, "w:gz") as gtar:
gtar.add(good_dir)
# Bad course (no 'course.xml' file):
bad_dir = tempfile.mkdtemp(dir=self.content_dir)
touch(os.path.join(bad_dir, "bad.xml"))
self.bad_tar = os.path.join(self.content_dir, "bad.tar.gz")
with tarfile.open(self.bad_tar, "w:gz") as btar:
btar.add(bad_dir)
def tearDown(self):
shutil.rmtree(self.content_dir)
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db'])
_CONTENTSTORE.clear()
def test_no_coursexml(self):
"""
Check that the response for a tar.gz import without a course.xml is
correct.
"""
with open(self.bad_tar) as btar:
resp = self.client.post(
self.url,
{
"name": self.bad_tar,
"course-data": [btar]
})
self.assertEquals(resp.status_code, 415)
def test_with_coursexml(self):
"""
Check that the response for a tar.gz import with a course.xml is
correct.
"""
with open(self.good_tar) as gtar:
resp = self.client.post(
self.url,
{
"name": self.good_tar,
"course-data": [gtar]
})
self.assertEquals(resp.status_code, 200)

View File

@@ -12,24 +12,26 @@ import copy
from django.contrib.auth.models import User
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.modulestore_config import TEST_MODULESTORE
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import _CONTENTSTORE
from xmodule.course_module import CourseDescriptor
from xmodule.exceptions import NotFoundError
from uuid import uuid4
from pymongo import MongoClient
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
"""
Tests that rely on the toy and test_import_course courses.
@@ -58,6 +60,10 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
self.client = Client()
self.client.login(username=uname, password=password)
def tearDown(self):
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db'])
_CONTENTSTORE.clear()
def load_test_import_course(self):
'''
Load the standard course used to test imports (for do_import_static=False behavior).
@@ -121,3 +127,9 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None]))
self.assertIn('/static/', handouts.data)
def test_tab_name_imports_correctly(self):
module_store, content_store, course, course_location = self.load_test_import_course()
print "course tabs = {0}".format(course.tabs)
self.assertEqual(course.tabs[2]['name'],'Syllabus')

View File

@@ -34,7 +34,7 @@ class DeleteItem(CourseTestCase):
resp.content,
"application/json"
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
class TestCreateItem(CourseTestCase):

View File

@@ -23,7 +23,7 @@ class TextbookIndexTestCase(CourseTestCase):
def test_view_index(self):
"Basic check that the textbook index page responds correctly"
resp = self.client.get(self.url)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# we don't have resp.context right now,
# due to bugs in our testing harness :(
if resp.context:
@@ -36,7 +36,7 @@ class TextbookIndexTestCase(CourseTestCase):
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
obj = json.loads(resp.content)
self.assertEqual(self.course.pdf_textbooks, obj)
@@ -73,7 +73,7 @@ class TextbookIndexTestCase(CourseTestCase):
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
obj = json.loads(resp.content)
self.assertEqual(content, obj)
@@ -90,7 +90,7 @@ class TextbookIndexTestCase(CourseTestCase):
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
# reload course
store = get_modulestore(self.course.location)
@@ -111,7 +111,7 @@ class TextbookIndexTestCase(CourseTestCase):
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 400)
obj = json.loads(resp.content)
self.assertIn("error", obj)
@@ -184,7 +184,7 @@ class TextbookCreateTestCase(CourseTestCase):
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 400)
self.assertNotIn("Location", resp)
@@ -238,14 +238,14 @@ class TextbookByIdTestCase(CourseTestCase):
def test_get_1(self):
"Get the first textbook"
resp = self.client.get(self.url1)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
compare = json.loads(resp.content)
self.assertEqual(compare, self.textbook1)
def test_get_2(self):
"Get the second textbook"
resp = self.client.get(self.url2)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
compare = json.loads(resp.content)
self.assertEqual(compare, self.textbook2)
@@ -257,7 +257,7 @@ class TextbookByIdTestCase(CourseTestCase):
def test_delete(self):
"Delete a textbook by ID"
resp = self.client.delete(self.url1)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
course = self.store.get_item(self.course.location)
self.assertEqual(course.pdf_textbooks, [self.textbook2])
@@ -288,7 +288,7 @@ class TextbookByIdTestCase(CourseTestCase):
)
self.assertEqual(resp.status_code, 201)
resp2 = self.client.get(url)
self.assert2XX(resp2.status_code)
self.assertEqual(resp2.status_code, 200)
compare = json.loads(resp2.content)
self.assertEqual(compare, textbook)
course = self.store.get_item(self.course.location)
@@ -311,7 +311,7 @@ class TextbookByIdTestCase(CourseTestCase):
)
self.assertEqual(resp.status_code, 201)
resp2 = self.client.get(self.url2)
self.assert2XX(resp2.status_code)
self.assertEqual(resp2.status_code, 200)
compare = json.loads(resp2.content)
self.assertEqual(compare, replacement)
course = self.store.get_item(self.course.location)

View File

@@ -72,13 +72,13 @@ class UsersTestCase(CourseTestCase):
def test_detail_inactive(self):
resp = self.client.get(self.inactive_detail_url)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 200)
result = json.loads(resp.content)
self.assertFalse(result["active"])
def test_detail_invalid(self):
resp = self.client.get(self.invalid_detail_url)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 404)
result = json.loads(resp.content)
self.assertIn("error", result)
@@ -87,7 +87,7 @@ class UsersTestCase(CourseTestCase):
self.detail_url,
data={"role": None},
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
@@ -103,7 +103,7 @@ class UsersTestCase(CourseTestCase):
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
@@ -122,7 +122,7 @@ class UsersTestCase(CourseTestCase):
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
@@ -142,7 +142,7 @@ class UsersTestCase(CourseTestCase):
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
@@ -157,7 +157,7 @@ class UsersTestCase(CourseTestCase):
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
self.assert_not_enrolled()
@@ -169,7 +169,7 @@ class UsersTestCase(CourseTestCase):
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
self.assert_not_enrolled()
@@ -180,7 +180,7 @@ class UsersTestCase(CourseTestCase):
data={"role": "staff"},
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
@@ -197,7 +197,7 @@ class UsersTestCase(CourseTestCase):
self.detail_url,
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
@@ -214,7 +214,7 @@ class UsersTestCase(CourseTestCase):
self.detail_url,
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()]
@@ -273,7 +273,7 @@ class UsersTestCase(CourseTestCase):
data={"role": "instructor"},
HTTP_ACCEPT="application/json",
)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
@@ -288,7 +288,7 @@ class UsersTestCase(CourseTestCase):
data={"role": "instructor"},
HTTP_ACCEPT="application/json",
)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
@@ -306,7 +306,7 @@ class UsersTestCase(CourseTestCase):
})
resp = self.client.delete(self_url)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
# reload user from DB
user = User.objects.get(email=self.user.email)
groups = [g.name for g in user.groups.all()]
@@ -321,7 +321,7 @@ class UsersTestCase(CourseTestCase):
self.ext_user.save()
resp = self.client.delete(self.detail_url)
self.assert4XX(resp.status_code)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
# reload user from DB
@@ -347,7 +347,7 @@ class UsersTestCase(CourseTestCase):
self.detail_url,
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
self.assert_enrolled()
def test_staff_to_instructor_still_enrolled(self):
@@ -366,7 +366,7 @@ class UsersTestCase(CourseTestCase):
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assert2XX(resp.status_code)
self.assertEqual(resp.status_code, 204)
self.assert_enrolled()
def assert_not_enrolled(self):

View File

@@ -1,15 +1,18 @@
from django.test.client import Client
from django.test.utils import override_settings
from django.core.cache import cache
from django.core.urlresolvers import reverse
from .utils import parse_json, user, registration
from contentstore.tests.utils import parse_json, user, registration
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from contentstore.tests.modulestore_config import TEST_MODULESTORE
import datetime
from pytz import UTC
@override_settings(MODULESTORE=TEST_MODULESTORE)
class ContentStoreTestCase(ModuleStoreTestCase):
def _login(self, email, password):
"""

View File

@@ -7,9 +7,11 @@ import json
from student.models import Registration
from django.contrib.auth.models import User
from django.test.client import Client
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from contentstore.tests.modulestore_config import TEST_MODULESTORE
def parse_json(response):
@@ -27,6 +29,7 @@ def registration(email):
return Registration.objects.get(user__email=email)
@override_settings(MODULESTORE=TEST_MODULESTORE)
class CourseTestCase(ModuleStoreTestCase):
def setUp(self):
"""

View File

@@ -10,6 +10,7 @@ from .component import *
from .course import *
from .error import *
from .item import *
from .import_export import *
from .preview import *
from .public import *
from .user import *

View File

@@ -4,6 +4,7 @@ import os
import tarfile
import shutil
import cgi
import re
from functools import partial
from tempfile import mkdtemp
from path import path
@@ -35,9 +36,7 @@ from .access import get_location_and_verify_access
from util.json_request import JsonResponse
__all__ = ['asset_index', 'upload_asset', 'import_course',
'generate_export_course', 'export_course']
__all__ = ['asset_index', 'upload_asset']
def assets_to_json_dict(assets):
"""
@@ -167,7 +166,7 @@ def upload_asset(request, org, course, coursename):
sc_partial = partial(StaticContent, content_loc, filename, mime_type)
if chunked:
content = sc_partial(upload_file.chunks())
temp_filepath = upload_file.temporary_file_path()
tempfile_path = upload_file.temporary_file_path()
else:
content = sc_partial(upload_file.read())
tempfile_path = None
@@ -260,179 +259,3 @@ def remove_asset(request, org, course, name):
return HttpResponse()
@ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT"))
@login_required
def import_course(request, org, course, name):
"""
This method will handle a POST request to upload and import a .tar.gz file into a specified course
"""
location = get_location_and_verify_access(request, org, course, name)
if request.method in ('POST', 'PUT'):
filename = request.FILES['course-data'].name
if not filename.endswith('.tar.gz'):
return HttpResponse(json.dumps({'ErrMsg': 'We only support uploading a .tar.gz file.'}))
data_root = path(settings.GITHUB_REPO_ROOT)
course_subdir = "{0}-{1}-{2}".format(org, course, name)
course_dir = data_root / course_subdir
if not course_dir.isdir():
os.mkdir(course_dir)
temp_filepath = course_dir / filename
logging.debug('importing course to {0}'.format(temp_filepath))
# stream out the uploaded files in chunks to disk
temp_file = open(temp_filepath, 'wb+')
for chunk in request.FILES['course-data'].chunks():
temp_file.write(chunk)
temp_file.close()
tar_file = tarfile.open(temp_filepath)
tar_file.extractall(course_dir + '/')
# find the 'course.xml' file
dirpath = None
for dirpath, _dirnames, filenames in os.walk(course_dir):
for filename in filenames:
if filename == 'course.xml':
break
if filename == 'course.xml':
break
if filename != 'course.xml':
return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'}))
logging.debug('found course.xml at {0}'.format(dirpath))
if dirpath != course_dir:
for fname in os.listdir(dirpath):
shutil.move(dirpath / fname, course_dir)
_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,
draft_store=modulestore())
# we can blow this away when we're done importing.
shutil.rmtree(course_dir)
logging.debug('new course at {0}'.format(course_items[0].location))
create_all_course_groups(request.user, course_items[0].location)
logging.debug('created all course groups at {0}'.format(course_items[0].location))
return HttpResponse(json.dumps({'Status': 'OK'}))
else:
course_module = modulestore().get_item(location)
return render_to_response('import.html', {
'context_course': course_module,
'successful_import_redirect_url': reverse('course_index', kwargs={
'org': location.org,
'course': location.course,
'name': location.name,
})
})
@ensure_csrf_cookie
@login_required
def generate_export_course(request, org, course, name):
"""
This method will serialize out a course to a .tar.gz file which contains a XML-based representation of
the course
"""
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_instance(location.course_id, location)
loc = Location(location)
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
root_dir = path(mkdtemp())
try:
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
except SerializationError, e:
logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
unit = None
failed_item = None
parent = None
try:
failed_item = modulestore().get_instance(course_module.location.course_id, e.location)
parent_locs = modulestore().get_parent_locations(failed_item.location, course_module.location.course_id)
if len(parent_locs) > 0:
parent = modulestore().get_item(parent_locs[0])
if parent.location.category == 'vertical':
unit = parent
except:
# if we have a nested exception, then we'll show the more generic error message
pass
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': '',
'in_err': True,
'raw_err_msg': str(e),
'failed_module': failed_item,
'unit': unit,
'edit_unit_url': reverse('edit_unit', kwargs={
'location': parent.location
}) if parent else '',
'course_home_url': reverse('course_index', kwargs={
'org': org,
'course': course,
'name': name
})
})
except Exception, e:
logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': '',
'in_err': True,
'unit': None,
'raw_err_msg': str(e),
'course_home_url': reverse('course_index', kwargs={
'org': org,
'course': course,
'name': name
})
})
logging.debug('tar file being generated at {0}'.format(export_file.name))
tar_file = tarfile.open(name=export_file.name, mode='w:gz')
tar_file.add(root_dir / name, arcname=name)
tar_file.close()
# remove temp dir
shutil.rmtree(root_dir / name)
wrapper = FileWrapper(export_file)
response = HttpResponse(wrapper, content_type='application/x-tgz')
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
response['Content-Length'] = os.path.getsize(export_file.name)
return response
@ensure_csrf_cookie
@login_required
def export_course(request, org, course, name):
"""
This method serves up the 'Export Course' page
"""
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location)
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': ''
})

View File

@@ -18,6 +18,7 @@ from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.exceptions import (
ItemNotFoundError, InvalidLocationError)
@@ -206,7 +207,8 @@ def course_info(request, org, course, name, provided_id=None):
'context_course': course_module,
'url_base': "/" + org + "/" + course + "/",
'course_updates': json.dumps(get_course_updates(location)),
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() })
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url(),
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(location) + '/'})
@expect_json

View File

@@ -0,0 +1,325 @@
"""
These views handle all actions in Studio related to import and exporting of
courses
"""
import logging
import os
import tarfile
import shutil
import re
from tempfile import mkdtemp
from path import path
from contextlib import contextmanager
from django.conf import settings
from django.http import HttpResponse
from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile
from django.views.decorators.http import require_http_methods
from mitxmako.shortcuts import render_to_response
from auth.authz import create_all_course_groups
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.exceptions import SerializationError
from .access import get_location_and_verify_access
from util.json_request import JsonResponse
__all__ = ['import_course', 'generate_export_course', 'export_course']
log = logging.getLogger(__name__)
# Regex to capture Content-Range header ranges.
CONTENT_RE = re.compile(r"(?P<start>\d{1,11})-(?P<stop>\d{1,11})/(?P<end>\d{1,11})")
@ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT"))
@login_required
def import_course(request, org, course, name):
"""
This method will handle a POST request to upload and import a .tar.gz file
into a specified course
"""
location = get_location_and_verify_access(request, org, course, name)
@contextmanager
def wfile(filename, dirname):
"""
A with-context that creates `filename` on entry and removes it on exit.
`filename` is truncted on creation. Additionally removes dirname on
exit.
"""
open("file", "w").close()
try:
yield filename
finally:
os.remove(filename)
shutil.rmtree(dirname)
if request.method == 'POST':
data_root = path(settings.GITHUB_REPO_ROOT)
course_subdir = "{0}-{1}-{2}".format(org, course, name)
course_dir = data_root / course_subdir
filename = request.FILES['course-data'].name
if not filename.endswith('.tar.gz'):
return JsonResponse(
{'ErrMsg': 'We only support uploading a .tar.gz file.'},
status=415
)
temp_filepath = course_dir / filename
if not course_dir.isdir():
os.mkdir(course_dir)
logging.debug('importing course to {0}'.format(temp_filepath))
# Get upload chunks byte ranges
try:
matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"])
content_range = matches.groupdict()
except KeyError: # Single chunk
# no Content-Range header, so make one that will work
content_range = {'start': 0, 'stop': 1, 'end': 2}
# stream out the uploaded files in chunks to disk
if int(content_range['start']) == 0:
mode = "wb+"
else:
mode = "ab+"
size = os.path.getsize(temp_filepath)
# Check to make sure we haven't missed a chunk
# This shouldn't happen, even if different instances are handling
# the same session, but it's always better to catch errors earlier.
if size < int(content_range['start']):
log.warning(
"Reported range %s does not match size downloaded so far %s",
content_range['start'],
size
)
return JsonResponse(
{'ErrMsg': 'File upload corrupted. Please try again'},
status=409
)
# The last request sometimes comes twice. This happens because
# nginx sends a 499 error code when the response takes too long.
elif size > int(content_range['stop']) and size == int(content_range['end']):
return JsonResponse({'ImportStatus': 1})
with open(temp_filepath, mode) as temp_file:
for chunk in request.FILES['course-data'].chunks():
temp_file.write(chunk)
size = os.path.getsize(temp_filepath)
if int(content_range['stop']) != int(content_range['end']) - 1:
# More chunks coming
return JsonResponse({
"files": [{
"name": filename,
"size": size,
"deleteUrl": "",
"deleteType": "",
"url": reverse('import_course', kwargs={
'org': location.org,
'course': location.course,
'name': location.name
}),
"thumbnailUrl": ""
}]
})
else: # This was the last chunk.
# 'Lock' with status info.
status_file = data_root / (course + filename + ".lock")
# Do everything from now on in a with-context, to be sure we've
# properly cleaned up.
with wfile(status_file, course_dir):
with open(status_file, 'w+') as sf:
sf.write("Extracting")
tar_file = tarfile.open(temp_filepath)
tar_file.extractall(course_dir + '/')
with open(status_file, 'w+') as sf:
sf.write("Verifying")
# find the 'course.xml' file
dirpath = None
def get_all_files(directory):
"""
For each file in the directory, yield a 2-tuple of (file-name,
directory-path)
"""
for dirpath, _dirnames, filenames in os.walk(directory):
for filename in filenames:
yield (filename, dirpath)
def get_dir_for_fname(directory, filename):
"""
Returns the dirpath for the first file found in the directory
with the given name. If there is no file in the directory with
the specified name, return None.
"""
for fname, dirpath in get_all_files(directory):
if fname == filename:
return dirpath
return None
fname = "course.xml"
dirpath = get_dir_for_fname(course_dir, fname)
if not dirpath:
return JsonResponse(
{'ErrMsg': 'Could not find the course.xml file in the package.'},
status=415
)
logging.debug('found course.xml at {0}'.format(dirpath))
if dirpath != course_dir:
for fname in os.listdir(dirpath):
shutil.move(dirpath / fname, course_dir)
_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,
draft_store=modulestore()
)
logging.debug('new course at {0}'.format(course_items[0].location))
with open(status_file, 'w') as sf:
sf.write("Updating course")
create_all_course_groups(request.user, course_items[0].location)
logging.debug('created all course groups at {0}'.format(course_items[0].location))
return JsonResponse({'Status': 'OK'})
else:
course_module = modulestore().get_item(location)
return render_to_response('import.html', {
'context_course': course_module,
'successful_import_redirect_url': reverse('course_index', kwargs={
'org': location.org,
'course': location.course,
'name': location.name,
})
})
@ensure_csrf_cookie
@login_required
def generate_export_course(request, org, course, name):
"""
This method will serialize out a course to a .tar.gz file which contains a
XML-based representation of the course
"""
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_instance(location.course_id, location)
loc = Location(location)
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
root_dir = path(mkdtemp())
try:
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
except SerializationError, e:
logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
unit = None
failed_item = None
parent = None
try:
failed_item = modulestore().get_instance(course_module.location.course_id, e.location)
parent_locs = modulestore().get_parent_locations(failed_item.location, course_module.location.course_id)
if len(parent_locs) > 0:
parent = modulestore().get_item(parent_locs[0])
if parent.location.category == 'vertical':
unit = parent
except:
# if we have a nested exception, then we'll show the more generic error message
pass
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': '',
'in_err': True,
'raw_err_msg': str(e),
'failed_module': failed_item,
'unit': unit,
'edit_unit_url': reverse('edit_unit', kwargs={
'location': parent.location
}) if parent else '',
'course_home_url': reverse('course_index', kwargs={
'org': org,
'course': course,
'name': name
})
})
except Exception, e:
logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': '',
'in_err': True,
'unit': None,
'raw_err_msg': str(e),
'course_home_url': reverse('course_index', kwargs={
'org': org,
'course': course,
'name': name
})
})
logging.debug('tar file being generated at {0}'.format(export_file.name))
tar_file = tarfile.open(name=export_file.name, mode='w:gz')
tar_file.add(root_dir / name, arcname=name)
tar_file.close()
# remove temp dir
shutil.rmtree(root_dir / name)
wrapper = FileWrapper(export_file)
response = HttpResponse(wrapper, content_type='application/x-tgz')
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
response['Content-Length'] = os.path.getsize(export_file.name)
return response
@ensure_csrf_cookie
@login_required
def export_course(request, org, course, name):
"""
This method serves up the 'Export Course' page
"""
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location)
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': ''
})

View File

@@ -246,7 +246,7 @@ PIPELINE_JS = {
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/models/uploads.js', 'js/views/uploads.js',
'js/models/textbook.js', 'js/views/textbook.js',
'js/views/assets.js', 'js/utility.js',
'js/views/assets.js', 'js/src/utility.js',
'js/models/settings/course_grading_policy.js'],
'output_filename': 'js/cms-application.js',
'test_order': 0

View File

@@ -1,53 +0,0 @@
"""
This configuration is used for running jasmine tests
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .test import *
from logsettings import get_logger_config
ENABLE_JASMINE = True
DEBUG = True
LOGGING = get_logger_config(TEST_ROOT / "log",
logging_env="dev",
tracking_filename="tracking.log",
dev_env=True,
debug=True,
local_loglevel='ERROR',
console_loglevel='ERROR')
PIPELINE_JS['js-test-source'] = {
'source_filenames': sum([
pipeline_group['source_filenames']
for group_name, pipeline_group
in sorted(PIPELINE_JS.items(), key=lambda item: item[1].get('test_order', 1e100))
if group_name != 'spec'
], []),
'output_filename': 'js/cms-test-source.js'
}
PIPELINE_JS['spec'] = {
'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.js')),
'output_filename': 'js/cms-spec.js'
}
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
JASMINE_REPORT_DIR = os.environ.get('JASMINE_REPORT_DIR', 'reports/cms/jasmine')
TEMPLATE_CONTEXT_PROCESSORS += ('settings_context_processor.context_processors.settings',)
TEMPLATE_VISIBLE_SETTINGS = ('JASMINE_REPORT_DIR', )
STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib')
STATICFILES_DIRS.append(REPO_ROOT/'node_modules/jasmine-reporters/src')
# Remove the localization middleware class because it requires the test database
# to be sync'd and migrated in order to run the jasmine tests interactively
# with a browser
MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \
if e != 'django.middleware.locale.LocaleMiddleware')
INSTALLED_APPS += ('django_jasmine', 'settings_context_processor')

View File

@@ -1,22 +0,0 @@
from dogapi import dog_http_api, dog_stats_api
from django.conf import settings
from xmodule.modulestore.django import modulestore
from django.dispatch import Signal
from request_cache.middleware import RequestCache
from django.core.cache import get_cache
CACHE = get_cache('mongo_metadata_inheritance')
for store_name in settings.MODULESTORE:
store = modulestore(store_name)
store.set_modulestore_configuration({
'metadata_inheritance_cache_subsystem': CACHE,
'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'):
dog_http_api.api_key = settings.DATADOG_API
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)

25
cms/startup.py Normal file
View File

@@ -0,0 +1,25 @@
"""
Module with code executed during Studio startup
"""
from django.conf import settings
# Force settings to run so that the python path is modified
settings.INSTALLED_APPS # pylint: disable=W0104
from django_startup import autostartup
# TODO: Remove this code once Studio/CMS runs via wsgi in all environments
INITIALIZED = False
def run():
"""
Executed during django startup
"""
global INITIALIZED
if INITIALIZED:
return
INITIALIZED = True
autostartup()

View File

@@ -1,20 +0,0 @@
{
"static_files": [
"../jsi18n/",
"js/vendor/RequireJS.js",
"js/vendor/jquery.min.js",
"js/vendor/jquery-ui.min.js",
"js/vendor/jquery.ui.draggable.js",
"js/vendor/jquery.cookie.js",
"js/vendor/json2.js",
"js/vendor/underscore-min.js",
"js/vendor/underscore.string.min.js",
"js/vendor/backbone-min.js",
"js/vendor/backbone-associations-min.js",
"js/vendor/jquery.leanModal.min.js",
"js/vendor/jquery.form.js",
"js/vendor/sinon-1.7.1.js",
"js/vendor/jasmine-stealth.js",
"js/test/i18n.js"
]
}

1
cms/static/coffee/fixtures Symbolic link
View File

@@ -0,0 +1 @@
../../templates/js/

View File

@@ -1 +0,0 @@
../../../templates/js/edit-chapter.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/edit-textbook.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-editor.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-list-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-number-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-option-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-string-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/no-textbooks.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/section-name-edit.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/show-textbook.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/system-feedback.underscore

View File

@@ -1,33 +0,0 @@
<div class="base_wrapper">
<section class="editor-with-tabs">
<div class="wrapper-comp-editor" id="editor-tab-id" data-html_id='test_id'>
<div class="edit-header">
<ul class="editor-tabs">
<li class="inner_tab_wrap"><a href="#tab-0" class="tab">Tab 0 Editor</a></li>
<li class="inner_tab_wrap"><a href="#tab-1" class="tab">Tab 1 Transcripts</a></li>
<li class="inner_tab_wrap" id="settings"><a href="#tab-2" class="tab">Tab 2 Settings</a></li>
</ul>
</div>
<div class="tabs-wrapper">
<div class="component-tab" id="tab-0">
<textarea name="" class="edit-box">XML Editor Text</textarea>
</div>
<div class="component-tab" id="tab-1">
Transcripts
</div>
<div class="component-tab" id="tab-2">
Subtitles
</div>
</div>
<div class="wrapper-comp-settings">
<ul>
<li id="editor-mode"><a>Editor</a></li>
<li id="settings-mode"><a>Settings</a></li>
</ul>
</div>
</div>
</section>
<div class="component-edit-header" style="display: block"/>
</div>

View File

@@ -1 +0,0 @@
../../../templates/js/upload-dialog.underscore

View File

@@ -1,4 +1,4 @@
jasmine.getFixtures().fixturesPath = 'fixtures'
jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
# Stub jQuery.cookie
@stubCookies =

View File

@@ -1,95 +0,0 @@
describe "TabsEditingDescriptor", ->
beforeEach ->
@isInactiveClass = "is-inactive"
@isCurrent = "current"
loadFixtures 'tabs-edit.html'
@descriptor = new TabsEditingDescriptor($('.base_wrapper'))
@html_id = 'test_id'
@tab_0_switch = jasmine.createSpy('tab_0_switch');
@tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate');
@tab_1_switch = jasmine.createSpy('tab_1_switch');
@tab_1_modelUpdate = jasmine.createSpy('tab_1_modelUpdate');
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate)
TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 0 Editor', @tab_0_switch)
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 1 Transcripts', @tab_1_modelUpdate)
TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 1 Transcripts', @tab_1_switch)
spyOn($.fn, 'hide').andCallThrough()
spyOn($.fn, 'show').andCallThrough()
spyOn(TabsEditingDescriptor.Model, 'initialize')
spyOn(TabsEditingDescriptor.Model, 'updateValue')
afterEach ->
TabsEditingDescriptor.Model.modules= {}
describe "constructor", ->
it "first tab should be visible", ->
expect(@descriptor.$tabs.first()).toHaveClass(@isCurrent)
expect(@descriptor.$content.first()).not.toHaveClass(@isInactiveClass)
describe "onSwitchEditor", ->
it "switching tabs changes styles", ->
@descriptor.$tabs.eq(1).trigger("click")
expect(@descriptor.$tabs.eq(0)).not.toHaveClass(@isCurrent)
expect(@descriptor.$content.eq(0)).toHaveClass(@isInactiveClass)
expect(@descriptor.$tabs.eq(1)).toHaveClass(@isCurrent)
expect(@descriptor.$content.eq(1)).not.toHaveClass(@isInactiveClass)
expect(@tab_1_switch).toHaveBeenCalled()
it "if click on current tab, nothing should happen", ->
spyOn($.fn, 'trigger').andCallThrough()
currentTab = @descriptor.$tabs.filter('.' + @isCurrent)
@descriptor.$tabs.eq(0).trigger("click")
expect(@descriptor.$tabs.filter('.' + @isCurrent)).toEqual(currentTab)
expect($.fn.trigger.calls.length).toEqual(1)
it "onSwitch function call", ->
@descriptor.$tabs.eq(1).trigger("click")
expect(TabsEditingDescriptor.Model.updateValue).toHaveBeenCalled()
expect(@tab_1_switch).toHaveBeenCalled()
describe "save", ->
it "function for current tab should be called", ->
@descriptor.$tabs.eq(1).trigger("click")
data = @descriptor.save().data
expect(@tab_1_modelUpdate).toHaveBeenCalled()
it "detach click event", ->
spyOn($.fn, "off")
@descriptor.save()
expect($.fn.off).toHaveBeenCalledWith(
'click',
'.editor-tabs .tab',
@descriptor.onSwitchEditor
)
describe "editor/settings header", ->
it "is hidden", ->
expect(@descriptor.element.find(".component-edit-header").css('display')).toEqual('none')
describe "TabsEditingDescriptor special save cases", ->
beforeEach ->
@isInactiveClass = "is-inactive"
@isCurrent = "current"
loadFixtures 'tabs-edit.html'
@descriptor = new window.TabsEditingDescriptor($('.base_wrapper'))
@html_id = 'test_id'
describe "save", ->
it "case: no init", ->
data = @descriptor.save().data
expect(data).toEqual(null)
it "case: no function in model update", ->
TabsEditingDescriptor.Model.initialize(@html_id)
data = @descriptor.save().data
expect(data).toEqual(null)
it "case: no function in model update, but value presented", ->
@tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate').andReturn(1)
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate)
@descriptor.$tabs.eq(1).trigger("click")
expect(@tab_0_modelUpdate).toHaveBeenCalled()
data = @descriptor.save().data
expect(data).toEqual(1)

View File

@@ -0,0 +1,144 @@
courseInfoPage = """
<div class="course-info-wrapper">
<div class="main-column window">
<article class="course-updates" id="course-update-view">
<ol class="update-list" id="course-update-list"></ol>
</article>
</div>
<div class="sidebar window course-handouts" id="course-handouts-view"></div>
</div>
"""
commonSetup = () ->
window.analytics = jasmine.createSpyObj('analytics', ['track'])
window.course_location_analytics = jasmine.createSpy()
window.courseUpdatesXhr = sinon.useFakeXMLHttpRequest()
requests = []
window.courseUpdatesXhr.onCreate = (xhr) -> requests.push(xhr)
return requests
commonCleanup = () ->
window.courseUpdatesXhr.restore()
delete window.analytics
delete window.course_location_analytics
describe "Course Updates", ->
courseInfoTemplate = readFixtures('course_info_update.underscore')
beforeEach ->
setFixtures($("<script>", {id: "course_info_update-tpl", type: "text/template"}).text(courseInfoTemplate))
appendSetFixtures courseInfoPage
@collection = new CMS.Models.CourseUpdateCollection()
@courseInfoEdit = new CMS.Views.ClassInfoUpdateView({
el: $('.course-updates'),
collection: @collection,
base_asset_url : 'base-asset-url/'
})
@courseInfoEdit.render()
@event = {
preventDefault : () -> 'no op'
}
@createNewUpdate = () ->
# Edit button is not in the template under test (it is in parent HTML).
# Therefore call onNew directly.
@courseInfoEdit.onNew(@event)
spyOn(@courseInfoEdit.$codeMirror, 'getValue').andReturn('/static/image.jpg')
@courseInfoEdit.$el.find('.save-button').click()
@requests = commonSetup()
afterEach ->
commonCleanup()
it "does not rewrite links on save", ->
# Create a new update, verifying that the model is created
# in the collection and save is called.
expect(@collection.isEmpty()).toBeTruthy()
@courseInfoEdit.onNew(@event)
expect(@collection.length).toEqual(1)
model = @collection.at(0)
spyOn(model, "save").andCallThrough()
spyOn(@courseInfoEdit.$codeMirror, 'getValue').andReturn('/static/image.jpg')
# Click the "Save button."
@courseInfoEdit.$el.find('.save-button').click()
expect(model.save).toHaveBeenCalled()
# Verify content sent to server does not have rewritten links.
contentSaved = JSON.parse(this.requests[0].requestBody).content
expect(contentSaved).toEqual('/static/image.jpg')
it "does rewrite links for preview", ->
# Create a new update.
@createNewUpdate()
# Verify the link is rewritten for preview purposes.
previewContents = @courseInfoEdit.$el.find('.update-contents').html()
expect(previewContents).toEqual('base-asset-url/image.jpg')
it "shows static links in edit mode", ->
@createNewUpdate()
# Click edit and verify CodeMirror contents.
@courseInfoEdit.$el.find('.edit-button').click()
expect(@courseInfoEdit.$codeMirror.getValue()).toEqual('/static/image.jpg')
describe "Course Handouts", ->
handoutsTemplate = readFixtures('course_info_handouts.underscore')
beforeEach ->
setFixtures($("<script>", {id: "course_info_handouts-tpl", type: "text/template"}).text(handoutsTemplate))
appendSetFixtures courseInfoPage
@model = new CMS.Models.ModuleInfo({
id: 'handouts-id',
data: '/static/fromServer.jpg'
})
@handoutsEdit = new CMS.Views.ClassInfoHandoutsView({
el: $('#course-handouts-view'),
model: @model,
base_asset_url: 'base-asset-url/'
});
@handoutsEdit.render()
@requests = commonSetup()
afterEach ->
commonCleanup()
it "does not rewrite links on save", ->
# Enter something in the handouts section, verifying that the model is saved
# when "Save" is clicked.
@handoutsEdit.$el.find('.edit-button').click()
spyOn(@handoutsEdit.$codeMirror, 'getValue').andReturn('/static/image.jpg')
spyOn(@model, "save").andCallThrough()
@handoutsEdit.$el.find('.save-button').click()
expect(@model.save).toHaveBeenCalled()
contentSaved = JSON.parse(this.requests[0].requestBody).data
expect(contentSaved).toEqual('/static/image.jpg')
it "does rewrite links in initial content", ->
expect(@handoutsEdit.$preview.html().trim()).toBe('base-asset-url/fromServer.jpg')
it "does rewrite links after edit", ->
# Edit handouts and save.
@handoutsEdit.$el.find('.edit-button').click()
spyOn(@handoutsEdit.$codeMirror, 'getValue').andReturn('/static/image.jpg')
@handoutsEdit.$el.find('.save-button').click()
# Verify preview text.
expect(@handoutsEdit.$preview.html().trim()).toBe('base-asset-url/image.jpg')
it "shows static links in edit mode", ->
# Click edit and verify CodeMirror contents.
@handoutsEdit.$el.find('.edit-button').click()
expect(@handoutsEdit.$codeMirror.getValue().trim()).toEqual('/static/fromServer.jpg')

View File

@@ -64,20 +64,31 @@ class CMS.Views.TabsEdit extends Backbone.View
course: course_location_analytics
deleteTab: (event) =>
if not confirm 'Are you sure you want to delete this component? This action cannot be undone.'
return
$component = $(event.currentTarget).parents('.component')
analytics.track "Deleted Static Page",
course: course_location_analytics
id: $component.data('id')
$.post('/delete_item', {
id: $component.data('id')
}, =>
$component.remove()
)
confirm = new CMS.Views.Prompt.Warning
title: gettext('Delete Component Confirmation')
message: gettext('Are you sure you want to delete this component? This action cannot be undone.')
actions:
primary:
text: gettext("OK")
click: (view) ->
view.hide()
$component = $(event.currentTarget).parents('.component')
analytics.track "Deleted Static Page",
course: course_location_analytics
id: $component.data('id')
deleting = new CMS.Views.Notification.Mini
title: gettext('Deleting') + '&hellip;'
deleting.show()
$.post('/delete_item', {
id: $component.data('id')
}, =>
$component.remove()
deleting.hide()
)
secondary: [
text: gettext('Cancel')
click: (view) ->
view.hide()
]
confirm.show()

View File

@@ -1,140 +0,0 @@
@font-face{font-family:'Open Sans';font-style:normal;font-weight:700;src:local("Open Sans Bold"),local("OpenSans-Bold"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/k3k702ZOKiLJc3WVjuplzKRDOzjiPcYnFooOUGCOsRk.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:normal;font-weight:300;src:local("Open Sans Light"),local("OpenSans-Light"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/DXI1ORHCpsQm3Vp6mXoaTaRDOzjiPcYnFooOUGCOsRk.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:italic;font-weight:700;src:local("Open Sans Bold Italic"),local("OpenSans-BoldItalic"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/PRmiXeptR36kaC0GEAetxhbnBKKEOwRKgsHDreGcocg.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:italic;font-weight:300;src:local("Open Sans Light Italic"),local("OpenSansLight-Italic"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/PRmiXeptR36kaC0GEAetxvR_54zmj3SbGZQh3vCOwvY.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:italic;font-weight:400;src:local("Open Sans Italic"),local("OpenSans-Italic"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/xjAJXh38I15wypJXxuGMBrrIa-7acMAeDBVuclsi6Gc.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;src:local("Open Sans"),local("OpenSans"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/cJZKeOuBrn4kERxqtaUH3bO3LdcAZYWl9Si6vvxL-qU.woff) format("woff")}
.mceContentBody {
padding: 10px;
background-color: #fff;
font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #3c3c3c;
scrollbar-3dlight-color: #F0F0EE;
scrollbar-arrow-color: #676662;
scrollbar-base-color: #F0F0EE;
scrollbar-darkshadow-color: #DDDDDD;
scrollbar-face-color: #E0E0DD;
scrollbar-highlight-color: #F0F0EE;
scrollbar-shadow-color: #F0F0EE;
scrollbar-track-color: #F5F5F5;
}
h1 {
color: #3c3c3c;
font-weight: normal;
font-size: 2em;
line-height: 1.4em;
letter-spacing: 1px;
margin: 0 0 1.416em 0;
}
h2 {
color: #646464;
font-weight: normal;
font-size: 1.2em;
line-height: 1.2em;
letter-spacing: 1px;
margin-bottom: 15px;
text-transform: uppercase;
-webkit-font-smoothing: antialiased;
}
h3, h4, h5, h6 {
margin: 0 0 10px 0;
font-weight: 600;
}
h3 {
font-size: 1.2em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: .83em;
}
h6 {
font-size: 0.75em;
}
p {
margin-bottom: 1.416em;
font-size: 1em;
line-height: 1.6em !important;
color: #3c3c3c;
}
em, i {
font-style: italic;
}
strong, b {
font-style: bold;
}
p + p, ul + p, ol + p {
margin-top: 20px;
}
ol, ul {
margin: 1em 0;
padding: 0 0 0 1em;
color: #3c3c3c;
}
ol li, ul li {
margin-bottom: 0.708em;
}
ol {
list-style: decimal outside none;
}
ul {
list-style: disc outside none;
}
a, a:link, a:visited, a:hover, a:active {
color: #1d9dd9;
}
img {
max-width: 100%;
}
pre {
margin: 1em 0;
color: #3c3c3c;
font-family: monospace, serif;
font-size: 1em;
white-space: pre-wrap;
word-wrap: break-word;
}
code {
font-family: monospace, serif;
background: none;
color: #3c3c3c;
padding: 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 16px;
}
th {
background: #eee;
font-weight: bold;
}
table td, th {
margin: 20px 0;
padding: 10px;
border: 1px solid #ccc !important;
text-align: left;
font-size: 14px;
}

View File

@@ -3,6 +3,26 @@
The render here adds views for each update/handout by delegating to their collections but does not
generate any html for the surrounding page.
*/
var editWithCodeMirror = function(model, contentName, baseAssetUrl, textArea) {
var content = rewriteStaticLinks(model.get(contentName), baseAssetUrl, '/static/');
model.set(contentName, content);
var $codeMirror = CodeMirror.fromTextArea(textArea, {
mode: "text/html",
lineNumbers: true,
lineWrapping: true
});
$codeMirror.setValue(content);
$codeMirror.clearHistory();
return $codeMirror;
};
var changeContentToPreview = function (model, contentName, baseAssetUrl) {
var content = rewriteStaticLinks(model.get(contentName), '/static/', baseAssetUrl);
model.set(contentName, content);
return content;
};
CMS.Views.CourseInfoEdit = Backbone.View.extend({
// takes CMS.Models.CourseInfo as model
tagName: 'div',
@@ -11,18 +31,19 @@ CMS.Views.CourseInfoEdit = Backbone.View.extend({
// instantiate the ClassInfoUpdateView and delegate the proper dom to it
new CMS.Views.ClassInfoUpdateView({
el: $('body.updates'),
collection: this.model.get('updates')
collection: this.model.get('updates'),
base_asset_url: this.model.get('base_asset_url')
});
new CMS.Views.ClassInfoHandoutsView({
el: this.$('#course-handouts-view'),
model: this.model.get('handouts')
model: this.model.get('handouts'),
base_asset_url: this.model.get('base_asset_url')
});
return this;
}
});
// ??? Programming style question: should each of these classes be in separate files?
CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
// collection is CourseUpdateCollection
events: {
@@ -48,6 +69,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
var self = this;
this.collection.each(function (update) {
try {
changeContentToPreview(update, 'content', self.options['base_asset_url'])
var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
} catch (e) {
@@ -72,20 +94,18 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
$(updateEle).prepend($newForm);
var $textArea = $newForm.find(".new-update-content").first();
if (this.$codeMirror == null ) {
this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true,
});
}
this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true
});
$newForm.addClass('editing');
this.$currentPost = $newForm.closest('li');
window.$modalCover.show();
window.$modalCover.bind('click', function() {
self.closeEditor(self, true);
self.closeEditor(true);
});
$('.date').datepicker('destroy');
@@ -110,7 +130,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
ele.remove();
}
});
this.closeEditor(this);
this.closeEditor();
analytics.track('Saved Course Update', {
'course': course_location_analytics,
@@ -122,8 +142,10 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
event.preventDefault();
// change editor contents back to model values and hide the editor
$(this.editor(event)).hide();
// If the model was never created (user created a new update, then pressed Cancel),
// we wish to remove it from the DOM.
var targetModel = this.eventModel(event);
this.closeEditor(this, !targetModel.id);
this.closeEditor(!targetModel.id);
},
onEdit: function(event) {
@@ -134,16 +156,10 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
$(this.editor(event)).show();
var $textArea = this.$currentPost.find(".new-update-content").first();
if (this.$codeMirror == null ) {
this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true,
});
}
var targetModel = this.eventModel(event);
this.$codeMirror = editWithCodeMirror(targetModel, 'content', self.options['base_asset_url'], $textArea.get(0));
window.$modalCover.show();
var targetModel = this.eventModel(event);
window.$modalCover.bind('click', function() {
self.closeEditor(self);
});
@@ -193,31 +209,35 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
}
});
confirm.show();
},
},
closeEditor: function(self, removePost) {
var targetModel = self.collection.get(self.$currentPost.attr('name'));
closeEditor: function(removePost) {
var targetModel = this.collection.get(this.$currentPost.attr('name'));
if(removePost) {
self.$currentPost.remove();
this.$currentPost.remove();
}
else {
// close the modal and insert the appropriate data
this.$currentPost.removeClass('editing');
this.$currentPost.find('.date-display').html(targetModel.get('date'));
this.$currentPost.find('.date').val(targetModel.get('date'));
var content = changeContentToPreview(targetModel, 'content', this.options['base_asset_url'])
try {
// just in case the content causes an error (embedded js errors)
this.$currentPost.find('.update-contents').html(content);
this.$currentPost.find('.new-update-content').val(content);
} catch (e) {
// ignore but handle rest of page
}
this.$currentPost.find('form').hide();
this.$currentPost.find('.CodeMirror').remove();
}
// close the modal and insert the appropriate data
self.$currentPost.removeClass('editing');
self.$currentPost.find('.date-display').html(targetModel.get('date'));
self.$currentPost.find('.date').val(targetModel.get('date'));
try {
// just in case the content causes an error (embedded js errors)
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
} catch (e) {
// ignore but handle rest of page
}
self.$currentPost.find('form').hide();
window.$modalCover.unbind('click');
window.$modalCover.hide();
this.$codeMirror = null;
self.$currentPost.find('.CodeMirror').remove();
},
// Dereferencing from events to screen elements
@@ -275,8 +295,8 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
},
render: function () {
var updateEle = this.$el;
var self = this;
changeContentToPreview(this.model, 'data', this.options['base_asset_url'])
this.$el.html(
$(this.template( {
model: this.model
@@ -295,22 +315,17 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
var self = this;
this.$editor.val(this.$preview.html());
this.$form.show();
if (this.$codeMirror == null) {
this.$codeMirror = CodeMirror.fromTextArea(this.$editor.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true,
});
}
this.$codeMirror = editWithCodeMirror(self.model, 'data', self.options['base_asset_url'], this.$editor.get(0));
window.$modalCover.show();
window.$modalCover.bind('click', function() {
self.closeEditor(self);
self.closeEditor();
});
},
onSave: function(event) {
this.model.set('data', this.$codeMirror.getValue());
this.render();
var saving = new CMS.Views.Notification.Mini({
title: gettext('Saving') + '&hellip;'
});
@@ -320,8 +335,9 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
saving.hide();
}
});
this.render();
this.$form.hide();
this.closeEditor(this);
this.closeEditor();
analytics.track('Saved Course Handouts', {
'course': course_location_analytics
@@ -331,14 +347,14 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
onCancel: function(event) {
this.$form.hide();
this.closeEditor(this);
this.closeEditor();
},
closeEditor: function(self) {
closeEditor: function() {
this.$form.hide();
window.$modalCover.unbind('click');
window.$modalCover.hide();
self.$form.find('.CodeMirror').remove();
this.$form.find('.CodeMirror').remove();
this.$codeMirror = null;
}
});

98
cms/static/js_test.yml Normal file
View File

@@ -0,0 +1,98 @@
---
# JavaScript test suite description
#
#
# To run all the tests and print results to the console:
#
# js-test-tool run TEST_SUITE --use-firefox
#
# where `TEST_SUITE` is this file.
#
#
# To run the tests in your default browser ("dev mode"):
#
# js-test-tool dev TEST_SUITE
#
test_suite_name: cms
test_runner: jasmine
# Path prepended to source files in the coverage report (optional)
# For example, if the source path
# is "src/source.js" (relative to this YAML file)
# and the prepend path is "base/dir"
# then the coverage report will show
# "base/dir/src/source.js"
prepend_path: cms/static
# Paths to library JavaScript files (optional)
lib_paths:
- xmodule_js/common_static/coffee/src/ajax_prefix.js
- xmodule_js/common_static/coffee/src/logger.js
- xmodule_js/common_static/js/vendor/RequireJS.js
- xmodule_js/common_static/js/vendor/json2.js
- xmodule_js/common_static/js/vendor/jquery.min.js
- xmodule_js/common_static/js/vendor/jquery-ui.min.js
- xmodule_js/common_static/js/vendor/jquery.cookie.js
- xmodule_js/common_static/js/vendor/jquery.qtip.min.js
- xmodule_js/common_static/js/vendor/swfobject/swfobject.js
- xmodule_js/common_static/js/vendor/jquery.ba-bbq.min.js
- xmodule_js/common_static/js/vendor/annotator.min.js
- xmodule_js/common_static/js/vendor/annotator.store.min.js
- xmodule_js/common_static/js/vendor/annotator.tags.min.js
- xmodule_js/common_static/js/vendor/underscore-min.js
- xmodule_js/common_static/js/vendor/underscore.string.min.js
- xmodule_js/common_static/js/vendor/backbone-min.js
- xmodule_js/common_static/js/vendor/backbone-associations-min.js
- xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js
- xmodule_js/common_static/js/vendor/jquery.leanModal.min.js
- xmodule_js/common_static/js/vendor/jquery.form.js
- xmodule_js/common_static/js/vendor/sinon-1.7.1.js
- xmodule_js/common_static/js/vendor/jasmine-jquery.js
- xmodule_js/common_static/js/vendor/jasmine-stealth.js
- xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js
- xmodule_js/src/xmodule.js
- xmodule_js/src
- xmodule_js/common_static/js/test/add_ajax_prefix.js
- xmodule_js/common_static/js/src/utility.js
# Paths to source JavaScript files
src_paths:
- coffee/src
- js
# Paths to spec (test) JavaScript files
spec_paths:
- coffee/spec/helpers.js
- coffee/spec
# Paths to fixture files (optional)
# The fixture path will be set automatically when using jasmine-jquery.
# (https://github.com/velesin/jasmine-jquery)
#
# You can then access fixtures using paths relative to
# the test suite description:
#
# loadFixtures('path/to/fixture/fixture.html');
#
fixture_paths:
- coffee/fixtures
# Regular expressions used to exclude *.js files from
# appearing in the test runner page.
# Files are included by default, which means that they
# are loaded using a <script> tag in the test runner page.
# When loading many files, this can be slow, so
# exclude any files you don't need.
#exclude_from_page:
# - path/to/lib/exclude/*
# Regular expression used to guarantee that a *.js file
# is included in the test runner page.
# If a file name matches both `exclude_from_page` and
# `include_in_page`, the file WILL be included.
# You can use this to exclude all files in a directory,
# but make an exception for particular files.
#include_in_page:
# - path/to/lib/exclude/exception_*.js

View File

@@ -57,6 +57,11 @@ body.course.import {
color: $error-red;
}
.status-block {
display: none;
font-size: 13px;
}
.choose-file-button {
@include blue-button;
padding: 10px 50px 11px;

1
cms/static/xmodule_js Symbolic link
View File

@@ -0,0 +1 @@
../../common/lib/xmodule/xmodule/js/

View File

@@ -39,6 +39,7 @@
model : new CMS.Models.CourseInfo({
courseId : '${context_course.location}',
updates : course_updates,
base_asset_url : '${base_asset_url}',
handouts : course_handouts
})
});

View File

@@ -25,13 +25,16 @@
<p>${_("File uploads must be gzipped tar files (.tar.gz) containing, at a minimum, a {filename} file.").format(filename='<code>course.xml</code>')}</p>
<p>${_("Please note that if your course has any problems with auto-generated {nodename} nodes, re-importing your course could cause the loss of student data associated with those problems.").format(nodename='<code>url_name</code>')}</p>
</div>
<form action="${reverse('import_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" method="post" enctype="multipart/form-data" class="import-form">
<form id="fileupload" method="post" enctype="multipart/form-data"
class="import-form" url="${reverse('import_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}">
<h2>${_("Course to import:")}</h2>
<p class="error-block"></p>
<a href="#" class="choose-file-button">${_("Choose File")}</a>
<p class="file-name-block"><span class="file-name"></span><a href="#" class="choose-file-button-inline">${_("change")}</a></p>
<input type="file" name="course-data" class="file-input">
<input type="submit" value="${_('Replace my course with the one above')}" class="submit-button">
<input type="file" name="course-data" class="file-input" >
<input type="submit" value="${_('Replace my course with the one above')}" class="submit-button" >
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
<p class="status-block">Unpacking...</p>
<div class="progress-bar">
<div class="progress-fill"></div>
<div class="percent">0%</div>
@@ -43,6 +46,9 @@
</%block>
<%block name="jsextra">
<script src="${static.url('js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js')}"> </script>
<script src="${static.url('js/vendor/jQuery-File-Upload/js/jquery.fileupload.js')}"> </script>
<script>
(function() {
@@ -50,33 +56,61 @@ var bar = $('.progress-bar');
var fill = $('.progress-fill');
var percent = $('.percent');
var status = $('#status');
var statusBlock = $('.status-block');
var submitBtn = $('.submit-button');
$('form').ajaxForm({
beforeSend: function() {
status.empty();
var percentVal = '0%';
$('#fileupload').fileupload({
dataType: 'json',
type: 'POST',
maxChunkSize: 20 * 1000000, // 20 MB
autoUpload: false,
add: function(e, data) {
submitBtn.unbind('click');
var file = data.files[0];
if (file.type == "application/x-gzip") {
submitBtn.click(function(e){
e.preventDefault();
submitBtn.hide();
data.submit().complete(function(result, textStatus, xhr) {
if (result.status != 200) {
alert('${_("Your import has failed.")}\n\n' + JSON.parse(result.responseText)["ErrMsg"]);
submitBtn.show();
bar.hide();
} else {
if (result.responseText["ImportStatus"] == 1) {
bar.hide();
statusBlock.show();
}
}
});
});
} else {
data.files = [];
}
},
progressall: function(e, data){
var percentVal = parseInt(data.loaded / data.total * 100, 10) + "%";
bar.show();
fill.width(percentVal);
percent.html(percentVal);
submitBtn.hide();
},
uploadProgress: function(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
fill.width(percentVal);
percent.html(percentVal);
},
complete: function(xhr) {
if (xhr.status == 200) {
done: function(e, data){
bar.hide();
alert('${_("Your import was successful.")}');
window.location = '${successful_import_redirect_url}';
}
else
alert('${_("Your import has failed.")}\n\n' + xhr.responseText);
submitBtn.show();
bar.hide();
}
});
},
sequentialUploads: true
});
})();
</script>
</%block>

View File

@@ -1,9 +1,9 @@
from django.conf import settings
from django.conf.urls import patterns, include, url
# Import this file so it can do its work, even though we don't use the name.
# pylint: disable=W0611
from . import one_time_startup
# TODO: This should be removed once the CMS is running via wsgi on all production servers
import cms.startup as startup
startup.run()
# There is a course creators admin table.
from ratelimitbackend import admin
@@ -135,10 +135,6 @@ urlpatterns += (
url(r'^jsi18n/$', 'django.views.i18n.javascript_catalog', js_info_dict),
)
if settings.ENABLE_JASMINE:
urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),)
if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'):
urlpatterns += (
url(r'^status/', include('service_status.urls')),

12
cms/wsgi.py Normal file
View File

@@ -0,0 +1,12 @@
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.envs.aws")
import cms.startup as startup
startup.run()
# This application object is used by the development server
# as well as any WSGI server configured to use this file.
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()