Commit 2eb89d05 by David Baumgold

Merge pull request #221 from edx/pdf-textbooks

PDF textbooks in Studio
parents bd37230f cef33bf2
...@@ -15,6 +15,8 @@ LMS: Added *experimental* crowdsource hinting manager page. ...@@ -15,6 +15,8 @@ LMS: Added *experimental* crowdsource hinting manager page.
XModule: Added *experimental* crowdsource hinting module. XModule: Added *experimental* crowdsource hinting module.
Studio: Added support for uploading and managing PDF textbooks
Common: Student information is now passed to the tracking log via POST instead of GET. Common: Student information is now passed to the tracking log via POST instead of GET.
Common: Add tests for documentation generation to test suite Common: Add tests for documentation generation to test suite
......
from django.core.files.uploadhandler import FileUploadHandler
import time
class DebugFileUploader(FileUploadHandler):
def __init__(self, request=None):
super(DebugFileUploader, self).__init__(request)
self.count = 0
def receive_data_chunk(self, raw_data, start):
time.sleep(1)
self.count = self.count + len(raw_data)
fail_at = None
if 'fail_at' in self.request.GET:
fail_at = int(self.request.GET.get('fail_at'))
if fail_at and self.count > fail_at:
raise Exception('Triggered fail')
return raw_data
def file_complete(self, file_size):
return None
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
#pylint: disable=W0621 #pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true from nose.tools import assert_false, assert_equal, assert_regexp_matches
from common import type_in_codemirror from common import type_in_codemirror
KEY_CSS = '.key input.policy-key' KEY_CSS = '.key input.policy-key'
...@@ -36,7 +36,7 @@ def press_the_notification_button(step, name): ...@@ -36,7 +36,7 @@ def press_the_notification_button(step, name):
error_showing = world.is_css_present('.is-shown.wrapper-notification-error') error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
return confirmation_dismissed or error_showing return confirmation_dismissed or error_showing
assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.') world.css_click(css, success_condition=save_clicked)
@step(u'I edit the value of a policy key$') @step(u'I edit the value of a policy key$')
......
Feature: Textbooks
Scenario: No textbooks
Given I have opened a new course in Studio
When I go to the textbooks page
Then I should see a message telling me to create a new textbook
Scenario: Create a textbook
Given I have opened a new course in Studio
And I go to the textbooks page
When I click on the New Textbook button
And I name my textbook "Economics"
And I name the first chapter "Chapter 1"
And I click the Upload Asset link for the first chapter
And I upload the textbook "textbook.pdf"
And I wait for "2" seconds
And I save the textbook
Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf"
And I reload the page
Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf"
Scenario: Create a textbook with multiple chapters
Given I have opened a new course in Studio
And I go to the textbooks page
When I click on the New Textbook button
And I name my textbook "History"
And I name the first chapter "Britain"
And I type in "britain.pdf" for the first chapter asset
And I click Add a Chapter
And I name the second chapter "America"
And I type in "america.pdf" for the second chapter asset
And I save the textbook
Then I should see a textbook named "History" with 2 chapters
And I click the textbook chapters
Then I should see a textbook named "History" with 2 chapters
And the first chapter should be named "Britain"
And the first chapter should have an asset called "britain.pdf"
And the second chapter should be named "America"
And the second chapter should have an asset called "america.pdf"
And I reload the page
Then I should see a textbook named "History" with 2 chapters
And I click the textbook chapters
Then I should see a textbook named "History" with 2 chapters
And the first chapter should be named "Britain"
And the first chapter should have an asset called "britain.pdf"
And the second chapter should be named "America"
And the second chapter should have an asset called "america.pdf"
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from django.conf import settings
import os
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
@step(u'I go to the textbooks page')
def go_to_uploads(_step):
world.click_course_content()
menu_css = 'li.nav-course-courseware-textbooks'
world.css_find(menu_css).click()
@step(u'I should see a message telling me to create a new textbook')
def assert_create_new_textbook_msg(_step):
css = ".wrapper-content .no-textbook-content"
assert world.is_css_present(css)
no_tb = world.css_find(css)
assert "You haven't added any textbooks" in no_tb.text
@step(u'I upload the textbook "([^"]*)"$')
def upload_file(_step, file_name):
file_css = '.upload-dialog input[type=file]'
upload = world.css_find(file_css)
# uploading the file itself
path = os.path.join(TEST_ROOT, 'uploads', file_name)
upload._element.send_keys(os.path.abspath(path))
button_css = ".upload-dialog .action-upload"
world.css_click(button_css)
@step(u'I click (on )?the New Textbook button')
def click_new_textbook(_step, on):
button_css = ".nav-actions .new-button"
button = world.css_find(button_css)
button.click()
@step(u'I name my textbook "([^"]*)"')
def name_textbook(_step, name):
input_css = ".textbook input[name=textbook-name]"
world.css_fill(input_css, name)
@step(u'I name the (first|second|third) chapter "([^"]*)"')
def name_chapter(_step, ordinal, name):
index = ["first", "second", "third"].index(ordinal)
input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1)
world.css_fill(input_css, name)
@step(u'I type in "([^"]*)" for the (first|second|third) chapter asset')
def asset_chapter(_step, name, ordinal):
index = ["first", "second", "third"].index(ordinal)
input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1)
world.css_fill(input_css, name)
@step(u'I click the Upload Asset link for the (first|second|third) chapter')
def click_upload_asset(_step, ordinal):
index = ["first", "second", "third"].index(ordinal)
button_css = ".textbook .chapter{i} .action-upload".format(i=index+1)
world.css_click(button_css)
@step(u'I click Add a Chapter')
def click_add_chapter(_step):
button_css = ".textbook .action-add-chapter"
world.css_click(button_css)
@step(u'I save the textbook')
def save_textbook(_step):
submit_css = "form.edit-textbook button[type=submit]"
world.css_click(submit_css)
@step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"')
def check_textbook(_step, textbook_name, chapter_name):
title = world.css_find(".textbook h3.textbook-title")
chapter = world.css_find(".textbook .wrap-textbook p")
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
assert chapter.text == chapter_name, "{} != {}".format(chapter.text, chapter_name)
@step(u'I should see a textbook named "([^"]*)" with (\d+) chapters')
def check_textbook_chapters(_step, textbook_name, num_chapters_str):
num_chapters = int(num_chapters_str)
title = world.css_find(".textbook .view-textbook h3.textbook-title")
toggle = world.css_find(".textbook .view-textbook .chapter-toggle")
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
assert toggle.text == "{num} PDF Chapters".format(num=num_chapters), \
"Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle.text)
@step(u'I click the textbook chapters')
def click_chapters(_step):
world.css_click(".textbook a.chapter-toggle")
@step(u'the (first|second|third) chapter should be named "([^"]*)"')
def check_chapter_name(_step, ordinal, name):
index = ["first", "second", "third"].index(ordinal)
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
element = chapter.find_by_css(".chapter-name")
assert element.text == name, "Expected chapter named {expected}, found chapter named {actual}".format(
expected=name, actual=element.text)
@step(u'the (first|second|third) chapter should have an asset called "([^"]*)"')
def check_chapter_asset(_step, ordinal, name):
index = ["first", "second", "third"].index(ordinal)
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
element = chapter.find_by_css(".chapter-asset-path")
assert element.text == name, "Expected chapter with asset {expected}, found chapter with asset {actual}".format(
expected=name, actual=element.text)
"""
Unit tests for the asset upload endpoint.
"""
import json
from datetime import datetime
from io import BytesIO
from pytz import UTC
from unittest import TestCase, skip
from .utils import CourseTestCase
from django.core.urlresolvers import reverse
from contentstore.views import assets
class AssetsTestCase(CourseTestCase):
def setUp(self):
super(AssetsTestCase, self).setUp()
self.url = reverse("asset_index", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
})
def test_basic(self):
resp = self.client.get(self.url)
self.assertEquals(resp.status_code, 200)
def test_json(self):
resp = self.client.get(
self.url,
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEquals(resp.status_code, 200)
content = json.loads(resp.content)
self.assertIsInstance(content, list)
class UploadTestCase(CourseTestCase):
"""
Unit tests for uploading a file
"""
def setUp(self):
super(UploadTestCase, self).setUp()
self.url = reverse("upload_asset", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'coursename': self.course.location.name,
})
@skip("CorruptGridFile error on continuous integration server")
def test_happy_path(self):
file = BytesIO("sample content")
file.name = "sample.txt"
resp = self.client.post(self.url, {"name": "my-name", "file": file})
self.assert2XX(resp.status_code)
def test_no_file(self):
resp = self.client.post(self.url, {"name": "file.txt"})
self.assert4XX(resp.status_code)
def test_get(self):
resp = self.client.get(self.url)
self.assertEquals(resp.status_code, 405)
class AssetsToJsonTestCase(TestCase):
"""
Unit tests for transforming the results of a database call into something
we can send out to the client via JSON.
"""
def test_basic(self):
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
asset = {
"displayname": "foo",
"chunkSize": 512,
"filename": "foo.png",
"length": 100,
"uploadDate": upload_date,
"_id": {
"course": "course",
"org": "org",
"revision": 12,
"category": "category",
"name": "name",
"tag": "tag",
}
}
output = assets.assets_to_json_dict([asset])
self.assertEquals(len(output), 1)
compare = output[0]
self.assertEquals(compare["name"], "foo")
self.assertEquals(compare["path"], "foo.png")
self.assertEquals(compare["uploaded"], upload_date.isoformat())
self.assertEquals(compare["id"], "/tag/org/course/12/category/name")
""" Unit tests for checklist methods in views.py. """ """ Unit tests for checklist methods in views.py. """
from contentstore.utils import get_modulestore, get_url_reverse from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
import json import json
from .utils import CourseTestCase
class ChecklistTestCase(CourseTestCase): class ChecklistTestCase(CourseTestCase):
...@@ -117,4 +117,4 @@ class ChecklistTestCase(CourseTestCase): ...@@ -117,4 +117,4 @@ class ChecklistTestCase(CourseTestCase):
'name': self.course.location.name, 'name': self.course.location.name,
'checklist_index': 100}) 'checklist_index': 100})
response = self.client.delete(update_url) response = self.client.delete(update_url)
self.assertContains(response, 'Unsupported request', status_code=400) self.assertEqual(response.status_code, 405)
#pylint: disable=E1101
import json import json
import shutil import shutil
import mock import mock
......
...@@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase): ...@@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase):
'''Go through each interface and ensure it works.''' '''Go through each interface and ensure it works.'''
# first get the update to force the creation # first get the update to force the creation
url = reverse('course_info', url = reverse('course_info',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'name': self.course_location.name}) 'name': self.course.location.name})
self.client.get(url) self.client.get(url)
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">' init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
...@@ -20,8 +20,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -20,8 +20,8 @@ class CourseUpdateTest(CourseTestCase):
payload = {'content': content, payload = {'content': content,
'date': 'January 8, 2013'} 'date': 'January 8, 2013'}
url = reverse('course_info_json', url = reverse('course_info_json',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': ''}) 'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json") resp = self.client.post(url, json.dumps(payload), "application/json")
...@@ -31,8 +31,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -31,8 +31,8 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(payload['content'], content) self.assertHTMLEqual(payload['content'], content)
first_update_url = reverse('course_info_json', first_update_url = reverse('course_info_json',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': payload['id']}) 'provided_id': payload['id']})
content += '<div>div <p>p<br/></p></div>' content += '<div>div <p>p<br/></p></div>'
payload['content'] = content payload['content'] = content
...@@ -47,8 +47,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -47,8 +47,8 @@ class CourseUpdateTest(CourseTestCase):
payload = {'content': content, payload = {'content': content,
'date': 'January 11, 2013'} 'date': 'January 11, 2013'}
url = reverse('course_info_json', url = reverse('course_info_json',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': ''}) 'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json") resp = self.client.post(url, json.dumps(payload), "application/json")
...@@ -58,8 +58,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -58,8 +58,8 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(content, payload['content'], "self closing ol") self.assertHTMLEqual(content, payload['content'], "self closing ol")
url = reverse('course_info_json', url = reverse('course_info_json',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': ''}) 'provided_id': ''})
resp = self.client.get(url) resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
...@@ -73,8 +73,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -73,8 +73,8 @@ class CourseUpdateTest(CourseTestCase):
# now try to update a non-existent update # now try to update a non-existent update
url = reverse('course_info_json', url = reverse('course_info_json',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': '9'}) 'provided_id': '9'})
content = 'blah blah' content = 'blah blah'
payload = {'content': content, payload = {'content': content,
...@@ -87,8 +87,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -87,8 +87,8 @@ class CourseUpdateTest(CourseTestCase):
content = '<garbage tag No closing brace to force <span>error</span>' content = '<garbage tag No closing brace to force <span>error</span>'
payload = {'content': content, payload = {'content': content,
'date': 'January 11, 2013'} 'date': 'January 11, 2013'}
url = reverse('course_info_json', kwargs={'org': self.course_location.org, url = reverse('course_info_json', kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': ''}) 'provided_id': ''})
self.assertContains( self.assertContains(
...@@ -99,8 +99,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -99,8 +99,8 @@ class CourseUpdateTest(CourseTestCase):
content = "<p><br><br></p>" content = "<p><br><br></p>"
payload = {'content': content, payload = {'content': content,
'date': 'January 11, 2013'} 'date': 'January 11, 2013'}
url = reverse('course_info_json', kwargs={'org': self.course_location.org, url = reverse('course_info_json', kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': ''}) 'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json") resp = self.client.post(url, json.dumps(payload), "application/json")
...@@ -108,8 +108,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -108,8 +108,8 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(content, json.loads(resp.content)['content']) self.assertHTMLEqual(content, json.loads(resp.content)['content'])
# now try to delete a non-existent update # now try to delete a non-existent update
url = reverse('course_info_json', kwargs={'org': self.course_location.org, url = reverse('course_info_json', kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': '19'}) 'provided_id': '19'})
payload = {'content': content, payload = {'content': content,
'date': 'January 21, 2013'} 'date': 'January 21, 2013'}
...@@ -119,8 +119,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -119,8 +119,8 @@ class CourseUpdateTest(CourseTestCase):
content = 'blah blah' content = 'blah blah'
payload = {'content': content, payload = {'content': content,
'date': 'January 28, 2013'} 'date': 'January 28, 2013'}
url = reverse('course_info_json', kwargs={'org': self.course_location.org, url = reverse('course_info_json', kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': ''}) 'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json") resp = self.client.post(url, json.dumps(payload), "application/json")
payload = json.loads(resp.content) payload = json.loads(resp.content)
...@@ -128,16 +128,16 @@ class CourseUpdateTest(CourseTestCase): ...@@ -128,16 +128,16 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(content, payload['content'], "single iframe") self.assertHTMLEqual(content, payload['content'], "single iframe")
# first count the entries # first count the entries
url = reverse('course_info_json', url = reverse('course_info_json',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': ''}) 'provided_id': ''})
resp = self.client.get(url) resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
before_delete = len(payload) before_delete = len(payload)
url = reverse('course_info_json', url = reverse('course_info_json',
kwargs={'org': self.course_location.org, kwargs={'org': self.course.location.org,
'course': self.course_location.course, 'course': self.course.location.course,
'provided_id': this_id}) 'provided_id': this_id})
resp = self.client.delete(url) resp = self.client.delete(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
......
...@@ -22,7 +22,3 @@ class DeleteItem(CourseTestCase): ...@@ -22,7 +22,3 @@ class DeleteItem(CourseTestCase):
# Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore). # Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore).
resp = self.client.post(reverse('delete_item'), resp.content, "application/json") resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
import json
from .utils import CourseTestCase
from django.core.urlresolvers import reverse
class UsersTestCase(CourseTestCase):
def setUp(self):
super(UsersTestCase, self).setUp()
self.url = reverse("add_user", kwargs={"location": ""})
def test_empty(self):
resp = self.client.post(self.url)
self.assertEqual(resp.status_code, 400)
content = json.loads(resp.content)
self.assertEqual(content["Status"], "Failed")
...@@ -10,11 +10,13 @@ from pytz import UTC ...@@ -10,11 +10,13 @@ from pytz import UTC
class ContentStoreTestCase(ModuleStoreTestCase): class ContentStoreTestCase(ModuleStoreTestCase):
def _login(self, email, pw): def _login(self, email, password):
"""Login. View should always return 200. The success/fail is in the """
returned json""" Login. View should always return 200. The success/fail is in the
returned json
"""
resp = self.client.post(reverse('login_post'), resp = self.client.post(reverse('login_post'),
{'email': email, 'password': pw}) {'email': email, 'password': password})
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
return resp return resp
...@@ -25,12 +27,12 @@ class ContentStoreTestCase(ModuleStoreTestCase): ...@@ -25,12 +27,12 @@ class ContentStoreTestCase(ModuleStoreTestCase):
self.assertTrue(data['success']) self.assertTrue(data['success'])
return resp return resp
def _create_account(self, username, email, pw): def _create_account(self, username, email, password):
"""Try to create an account. No error checking""" """Try to create an account. No error checking"""
resp = self.client.post('/create_account', { resp = self.client.post('/create_account', {
'username': username, 'username': username,
'email': email, 'email': email,
'password': pw, 'password': password,
'location': 'home', 'location': 'home',
'language': 'Franglish', 'language': 'Franglish',
'name': 'Fred Weasley', 'name': 'Fred Weasley',
...@@ -39,9 +41,9 @@ class ContentStoreTestCase(ModuleStoreTestCase): ...@@ -39,9 +41,9 @@ class ContentStoreTestCase(ModuleStoreTestCase):
}) })
return resp return resp
def create_account(self, username, email, pw): def create_account(self, username, email, password):
"""Create the account and check that it worked""" """Create the account and check that it worked"""
resp = self._create_account(username, email, pw) resp = self._create_account(username, email, password)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = parse_json(resp) data = parse_json(resp)
self.assertEqual(data['success'], True) self.assertEqual(data['success'], True)
...@@ -88,7 +90,7 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -88,7 +90,7 @@ class AuthTestCase(ContentStoreTestCase):
reverse('signup'), reverse('signup'),
) )
for page in pages: for page in pages:
print "Checking '{0}'".format(page) print("Checking '{0}'".format(page))
self.check_page_get(page, 200) self.check_page_get(page, 200)
def test_create_account_errors(self): def test_create_account_errors(self):
...@@ -146,17 +148,17 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -146,17 +148,17 @@ class AuthTestCase(ContentStoreTestCase):
self.client = Client() self.client = Client()
# Not logged in. Should redirect to login. # Not logged in. Should redirect to login.
print 'Not logged in' print('Not logged in')
for page in auth_pages: for page in auth_pages:
print "Checking '{0}'".format(page) print("Checking '{0}'".format(page))
self.check_page_get(page, expected=302) self.check_page_get(page, expected=302)
# Logged in should work. # Logged in should work.
self.login(self.email, self.pw) self.login(self.email, self.pw)
print 'Logged in' print('Logged in')
for page in simple_auth_pages: for page in simple_auth_pages:
print "Checking '{0}'".format(page) print("Checking '{0}'".format(page))
self.check_page_get(page, expected=200) self.check_page_get(page, expected=200)
def test_index_auth(self): def test_index_auth(self):
......
...@@ -6,6 +6,10 @@ import json ...@@ -6,6 +6,10 @@ import json
from student.models import Registration from student.models import Registration
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test.client import Client
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
def parse_json(response): def parse_json(response):
...@@ -21,3 +25,37 @@ def user(email): ...@@ -21,3 +25,37 @@ def user(email):
def registration(email): def registration(email):
"""look up registration object by email""" """look up registration object by email"""
return Registration.objects.get(user__email=email) return Registration.objects.get(user__email=email)
class CourseTestCase(ModuleStoreTestCase):
def setUp(self):
"""
These tests need a user in the DB so that the django Test Client
can log them in.
They inherit from the ModuleStoreTestCase class so that the mongodb collection
will be cleared out before each test case execution and deleted
afterwards.
"""
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
# Create the use so we can log them in.
self.user = User.objects.create_user(uname, email, password)
# Note that we do not actually need to do anything
# for registration if we directly mark them active.
self.user.is_active = True
# Staff has access to view all courses
self.user.is_staff = True
self.user.save()
self.client = Client()
self.client.login(username=uname, password=password)
self.course = CourseFactory.create(
template='i4x://edx/templates/course/Empty',
org='MITx',
number='999',
display_name='Robot Super Course',
)
#pylint: disable=E1103, E1101
from django.conf import settings from django.conf import settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
......
# pylint: disable=W0401, W0511 # pylint: disable=W0401, W0511
"All view functions for contentstore, broken out into submodules"
# Disable warnings about import from wildcard # Disable warnings about import from wildcard
# All files below declare exports with __all__ # All files below declare exports with __all__
from .assets import * from .assets import *
......
...@@ -13,6 +13,7 @@ from django_future.csrf import ensure_csrf_cookie ...@@ -13,6 +13,7 @@ from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.servers.basehttp import FileWrapper from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
from django.views.decorators.http import require_POST
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from cache_toolbox.core import del_cached_content from cache_toolbox.core import del_cached_content
...@@ -30,11 +31,45 @@ from xmodule.exceptions import NotFoundError ...@@ -30,11 +31,45 @@ from xmodule.exceptions import NotFoundError
from ..utils import get_url_reverse from ..utils import get_url_reverse
from .access import get_location_and_verify_access 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', 'import_course', 'generate_export_course', 'export_course']
def assets_to_json_dict(assets):
"""
Transform the results of a contentstore query into something appropriate
for output via JSON.
"""
ret = []
for asset in assets:
obj = {
"name": asset.get("displayname", ""),
"chunkSize": asset.get("chunkSize", 0),
"path": asset.get("filename", ""),
"length": asset.get("length", 0),
}
uploaded = asset.get("uploadDate")
if uploaded:
obj["uploaded"] = uploaded.isoformat()
thumbnail = asset.get("thumbnail_location")
if thumbnail:
obj["thumbnail"] = thumbnail
id_info = asset.get("_id")
if id_info:
obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}".format(
org=id_info.get("org", ""),
course=id_info.get("course", ""),
revision=id_info.get("revision", ""),
tag=id_info.get("tag", ""),
category=id_info.get("category", ""),
name=id_info.get("name", ""),
)
ret.append(obj)
return ret
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def asset_index(request, org, course, name): def asset_index(request, org, course, name):
...@@ -59,6 +94,9 @@ def asset_index(request, org, course, name): ...@@ -59,6 +94,9 @@ def asset_index(request, org, course, name):
# sort in reverse upload date order # sort in reverse upload date order
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True) assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
if request.META.get('HTTP_ACCEPT', "").startswith("application/json"):
return JsonResponse(assets_to_json_dict(assets))
asset_display = [] asset_display = []
for asset in assets: for asset in assets:
asset_id = asset['_id'] asset_id = asset['_id']
...@@ -77,7 +115,6 @@ def asset_index(request, org, course, name): ...@@ -77,7 +115,6 @@ def asset_index(request, org, course, name):
asset_display.append(display_info) asset_display.append(display_info)
return render_to_response('asset_index.html', { return render_to_response('asset_index.html', {
'active_tab': 'assets',
'context_course': course_module, 'context_course': course_module,
'assets': asset_display, 'assets': asset_display,
'upload_asset_callback_url': upload_asset_callback_url, 'upload_asset_callback_url': upload_asset_callback_url,
...@@ -89,17 +126,14 @@ def asset_index(request, org, course, name): ...@@ -89,17 +126,14 @@ def asset_index(request, org, course, name):
}) })
@login_required @require_POST
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required
def upload_asset(request, org, course, coursename): def upload_asset(request, org, course, coursename):
''' '''
cdodge: this method allows for POST uploading of files into the course asset library, which will This method allows for POST uploading of files into the course asset library, which will
be supported by GridFS in MongoDB. be supported by GridFS in MongoDB.
''' '''
if request.method != 'POST':
# (cdodge) @todo: Is there a way to do a - say - 'raise Http400'?
return HttpResponseBadRequest()
# construct a location from the passed in path # construct a location from the passed in path
location = get_location_and_verify_access(request, org, course, coursename) location = get_location_and_verify_access(request, org, course, coursename)
...@@ -118,16 +152,25 @@ def upload_asset(request, org, course, coursename): ...@@ -118,16 +152,25 @@ def upload_asset(request, org, course, coursename):
# compute a 'filename' which is similar to the location formatting, we're using the 'filename' # compute a 'filename' which is similar to the location formatting, we're using the 'filename'
# nomenclature since we're using a FileSystem paradigm here. We're just imposing # nomenclature since we're using a FileSystem paradigm here. We're just imposing
# the Location string formatting expectations to keep things a bit more consistent # the Location string formatting expectations to keep things a bit more consistent
upload_file = request.FILES['file']
filename = request.FILES['file'].name filename = upload_file.name
mime_type = request.FILES['file'].content_type mime_type = upload_file.content_type
filedata = request.FILES['file'].read()
content_loc = StaticContent.compute_location(org, course, filename) content_loc = StaticContent.compute_location(org, course, filename)
content = StaticContent(content_loc, filename, mime_type, filedata)
chunked = upload_file.multiple_chunks()
if chunked:
content = StaticContent(content_loc, filename, mime_type, upload_file.chunks())
else:
content = StaticContent(content_loc, filename, mime_type, upload_file.read())
thumbnail_content = None
thumbnail_location = None
# first let's see if a thumbnail can be created # first let's see if a thumbnail can be created
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content) (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content,
tempfile_path=None if not chunked else
upload_file.temporary_file_path())
# delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show) # delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
del_cached_content(thumbnail_location) del_cached_content(thumbnail_location)
...@@ -149,7 +192,7 @@ def upload_asset(request, org, course, coursename): ...@@ -149,7 +192,7 @@ def upload_asset(request, org, course, coursename):
'msg': 'Upload completed' 'msg': 'Upload completed'
} }
response = HttpResponse(json.dumps(response_payload)) response = JsonResponse(response_payload)
response['asset_url'] = StaticContent.get_url_path_from_location(content.location) response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
return response return response
...@@ -208,7 +251,9 @@ def remove_asset(request, org, course, name): ...@@ -208,7 +251,9 @@ def remove_asset(request, org, course, name):
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
def import_course(request, org, course, name): 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) location = get_location_and_verify_access(request, org, course, name)
if request.method == 'POST': if request.method == 'POST':
...@@ -274,7 +319,6 @@ def import_course(request, org, course, name): ...@@ -274,7 +319,6 @@ def import_course(request, org, course, name):
return render_to_response('import.html', { return render_to_response('import.html', {
'context_course': course_module, 'context_course': course_module,
'active_tab': 'import',
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module) 'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
}) })
...@@ -282,6 +326,10 @@ def import_course(request, org, course, name): ...@@ -282,6 +326,10 @@ def import_course(request, org, course, name):
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
def generate_export_course(request, org, course, name): 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) location = get_location_and_verify_access(request, org, course, name)
loc = Location(location) loc = Location(location)
...@@ -312,13 +360,14 @@ def generate_export_course(request, org, course, name): ...@@ -312,13 +360,14 @@ def generate_export_course(request, org, course, name):
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
def export_course(request, org, course, name): 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) location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location) course_module = modulestore().get_item(location)
return render_to_response('export.html', { return render_to_response('export.html', {
'context_course': course_module, 'context_course': course_module,
'active_tab': 'export',
'successful_import_redirect_url': '' 'successful_import_redirect_url': ''
}) })
import json import json
from django.http import HttpResponse, HttpResponseBadRequest from util.json_request import JsonResponse
from django.http import HttpResponseBadRequest
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
...@@ -9,7 +11,6 @@ from xmodule.modulestore import Location ...@@ -9,7 +11,6 @@ from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from ..utils import get_modulestore, get_url_reverse from ..utils import get_modulestore, get_url_reverse
from .requests import get_request_method
from .access import get_location_and_verify_access from .access import get_location_and_verify_access
__all__ = ['get_checklists', 'update_checklist'] __all__ = ['get_checklists', 'update_checklist']
...@@ -46,6 +47,7 @@ def get_checklists(request, org, course, name): ...@@ -46,6 +47,7 @@ def get_checklists(request, org, course, name):
}) })
@require_http_methods(("GET", "POST", "PUT"))
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
def update_checklist(request, org, course, name, checklist_index=None): def update_checklist(request, org, course, name, checklist_index=None):
...@@ -62,8 +64,7 @@ def update_checklist(request, org, course, name, checklist_index=None): ...@@ -62,8 +64,7 @@ def update_checklist(request, org, course, name, checklist_index=None):
modulestore = get_modulestore(location) modulestore = get_modulestore(location)
course_module = modulestore.get_item(location) course_module = modulestore.get_item(location)
real_method = get_request_method(request) if request.method in ("POST", "PUT"):
if real_method == 'POST' or real_method == 'PUT':
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists): if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
index = int(checklist_index) index = int(checklist_index)
course_module.checklists[index] = json.loads(request.body) course_module.checklists[index] = json.loads(request.body)
...@@ -71,7 +72,7 @@ def update_checklist(request, org, course, name, checklist_index=None): ...@@ -71,7 +72,7 @@ def update_checklist(request, org, course, name, checklist_index=None):
course_module.checklists = course_module.checklists course_module.checklists = course_module.checklists
checklists, _ = expand_checklist_action_urls(course_module) checklists, _ = expand_checklist_action_urls(course_module)
modulestore.update_metadata(location, own_metadata(course_module)) modulestore.update_metadata(location, own_metadata(course_module))
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json") return JsonResponse(checklists[index])
else: else:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Could not save checklist state because the checklist index was out of range or unspecified.", "Could not save checklist state because the checklist index was out of range or unspecified.",
...@@ -81,9 +82,7 @@ def update_checklist(request, org, course, name, checklist_index=None): ...@@ -81,9 +82,7 @@ def update_checklist(request, org, course, name, checklist_index=None):
checklists, modified = expand_checklist_action_urls(course_module) checklists, modified = expand_checklist_action_urls(course_module)
if modified: if modified:
modulestore.update_metadata(location, own_metadata(course_module)) modulestore.update_metadata(location, own_metadata(course_module))
return HttpResponse(json.dumps(checklists), mimetype="application/json") return JsonResponse(checklists)
else:
return HttpResponseBadRequest("Unsupported request.", content_type="text/plain")
def expand_checklist_action_urls(course_module): def expand_checklist_action_urls(course_module):
......
...@@ -4,6 +4,7 @@ from collections import defaultdict ...@@ -4,6 +4,7 @@ from collections import defaultdict
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.conf import settings from django.conf import settings
...@@ -15,7 +16,7 @@ from xmodule.modulestore.django import modulestore ...@@ -15,7 +16,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.util.date_utils import get_default_time_display from xmodule.util.date_utils import get_default_time_display
from xblock.core import Scope from xblock.core import Scope
from util.json_request import expect_json from util.json_request import expect_json, JsonResponse
from contentstore.module_info_model import get_module_info, set_module_info from contentstore.module_info_model import get_module_info, set_module_info
from contentstore.utils import get_modulestore, get_lms_link_for_item, \ from contentstore.utils import get_modulestore, get_lms_link_for_item, \
...@@ -23,7 +24,7 @@ from contentstore.utils import get_modulestore, get_lms_link_for_item, \ ...@@ -23,7 +24,7 @@ from contentstore.utils import get_modulestore, get_lms_link_for_item, \
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
from .requests import get_request_method, _xmodule_recurse from .requests import _xmodule_recurse
from .access import has_access from .access import has_access
__all__ = ['OPEN_ENDED_COMPONENT_TYPES', __all__ = ['OPEN_ENDED_COMPONENT_TYPES',
...@@ -209,7 +210,6 @@ def edit_unit(request, location): ...@@ -209,7 +210,6 @@ def edit_unit(request, location):
return render_to_response('unit.html', { return render_to_response('unit.html', {
'context_course': course, 'context_course': course,
'active_tab': 'courseware',
'unit': item, 'unit': item,
'unit_location': location, 'unit_location': location,
'components': components, 'components': components,
...@@ -234,14 +234,12 @@ def assignment_type_update(request, org, course, category, name): ...@@ -234,14 +234,12 @@ def assignment_type_update(request, org, course, category, name):
''' '''
location = Location(['i4x', org, course, category, name]) location = Location(['i4x', org, course, category, name])
if not has_access(request.user, location): if not has_access(request.user, location):
raise HttpResponseForbidden() return HttpResponseForbidden()
if request.method == 'GET': if request.method == 'GET':
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)), return JsonResponse(CourseGradingModel.get_section_grader_type(location))
mimetype="application/json")
elif request.method == 'POST': # post or put, doesn't matter. elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)), return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST))
mimetype="application/json")
@login_required @login_required
...@@ -291,6 +289,7 @@ def unpublish_unit(request): ...@@ -291,6 +289,7 @@ def unpublish_unit(request):
@expect_json @expect_json
@require_http_methods(("GET", "POST", "PUT"))
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def module_info(request, module_location): def module_info(request, module_location):
...@@ -300,8 +299,6 @@ def module_info(request, module_location): ...@@ -300,8 +299,6 @@ def module_info(request, module_location):
if not has_access(request.user, location): if not has_access(request.user, location):
raise PermissionDenied() raise PermissionDenied()
real_method = get_request_method(request)
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true'] rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links)) logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links))
...@@ -309,9 +306,7 @@ def module_info(request, module_location): ...@@ -309,9 +306,7 @@ def module_info(request, module_location):
if not has_access(request.user, location): if not has_access(request.user, location):
raise PermissionDenied() raise PermissionDenied()
if real_method == 'GET': if request.method == 'GET':
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json") return JsonResponse(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links))
elif real_method == 'POST' or real_method == 'PUT': elif request.method in ("POST", "PUT"):
return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json") return JsonResponse(set_module_info(get_modulestore(location), location, request.POST))
else:
return HttpResponseBadRequest()
#pylint: disable=C0111,W0613
from django.http import (HttpResponse, HttpResponseServerError, from django.http import (HttpResponse, HttpResponseServerError,
HttpResponseNotFound) HttpResponseNotFound)
from mitxmako.shortcuts import render_to_string, render_to_response from mitxmako.shortcuts import render_to_string, render_to_response
......
...@@ -68,7 +68,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None): ...@@ -68,7 +68,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
def preview_component(request, location): def preview_component(request, location):
# TODO (vshnayder): change name from id to location in coffee+html as well. # TODO (vshnayder): change name from id to location in coffee+html as well.
if not has_access(request.user, location): if not has_access(request.user, location):
raise HttpResponseForbidden() return HttpResponseForbidden()
component = modulestore().get_item(location) component = modulestore().get_item(location)
......
import json
from django.http import HttpResponse from django.http import HttpResponse
from mitxmako.shortcuts import render_to_string, render_to_response from mitxmako.shortcuts import render_to_string, render_to_response
...@@ -24,28 +22,6 @@ def event(request): ...@@ -24,28 +22,6 @@ def event(request):
return HttpResponse(status=204) return HttpResponse(status=204)
def get_request_method(request):
"""
Using HTTP_X_HTTP_METHOD_OVERRIDE, in the request metadata, determine
what type of request came from the client, and return it.
"""
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
return real_method
def create_json_response(errmsg=None):
if errmsg is not None:
resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg': errmsg}))
else:
resp = HttpResponse(json.dumps({'Status': 'OK'}))
return resp
def render_from_lms(template_name, dictionary, context=None, namespace='main'): def render_from_lms(template_name, dictionary, context=None, namespace='main'):
""" """
Render a template using the LMS MAKO_TEMPLATES Render a template using the LMS MAKO_TEMPLATES
......
...@@ -10,18 +10,20 @@ from mitxmako.shortcuts import render_to_response ...@@ -10,18 +10,20 @@ from mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from ..utils import get_course_for_item from ..utils import get_course_for_item, get_modulestore
from .access import get_location_and_verify_access from .access import get_location_and_verify_access
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static'] __all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static']
def initialize_course_tabs(course): def initialize_course_tabs(course):
# set up the default tabs """
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or set up the default tabs
# at least a list populated with the minimal times I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better at least a list populated with the minimal times
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
"""
# This logic is repeated in xmodule/modulestore/tests/factories.py # This logic is repeated in xmodule/modulestore/tests/factories.py
# so if you change anything here, you need to also change it there. # so if you change anything here, you need to also change it there.
...@@ -82,7 +84,8 @@ def reorder_static_tabs(request): ...@@ -82,7 +84,8 @@ def reorder_static_tabs(request):
@ensure_csrf_cookie @ensure_csrf_cookie
def edit_tabs(request, org, course, coursename): def edit_tabs(request, org, course, coursename):
location = ['i4x', org, course, 'course', coursename] location = ['i4x', org, course, 'course', coursename]
course_item = modulestore().get_item(location) store = get_modulestore(location)
course_item = store.get_item(location)
# check that logged in user has permissions to this item # check that logged in user has permissions to this item
if not has_access(request.user, location): if not has_access(request.user, location):
...@@ -108,7 +111,6 @@ def edit_tabs(request, org, course, coursename): ...@@ -108,7 +111,6 @@ def edit_tabs(request, org, course, coursename):
] ]
return render_to_response('edit-tabs.html', { return render_to_response('edit-tabs.html', {
'active_tab': 'pages',
'context_course': course_item, 'context_course': course_item,
'components': components 'components': components
}) })
...@@ -123,7 +125,6 @@ def static_pages(request, org, course, coursename): ...@@ -123,7 +125,6 @@ def static_pages(request, org, course, coursename):
course = modulestore().get_item(location) course = modulestore().get_item(location)
return render_to_response('static-pages.html', { return render_to_response('static-pages.html', {
'active_tab': 'pages',
'context_course': course, 'context_course': course,
}) })
......
...@@ -8,28 +8,11 @@ from mitxmako.shortcuts import render_to_response ...@@ -8,28 +8,11 @@ from mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from contentstore.utils import get_url_reverse, get_lms_link_for_item from contentstore.utils import get_url_reverse, get_lms_link_for_item
from util.json_request import expect_json from util.json_request import expect_json, JsonResponse
from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
from .access import has_access from .access import has_access
from .requests import create_json_response
def user_author_string(user):
'''Get an author string for commits by this user. Format:
first last <email@email.com>.
If the first and last names are blank, uses the username instead.
Assumes that the email is not blank.
'''
f = user.first_name
l = user.last_name
if f == '' and l == '':
f = user.username
return '{first} {last} <{email}>'.format(first=f,
last=l,
email=user.email)
@login_required @login_required
...@@ -73,7 +56,6 @@ def manage_users(request, location): ...@@ -73,7 +56,6 @@ def manage_users(request, location):
course_module = modulestore().get_item(location) course_module = modulestore().get_item(location)
return render_to_response('manage_users.html', { return render_to_response('manage_users.html', {
'active_tab': 'users',
'context_course': course_module, 'context_course': course_module,
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME), 'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'), 'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'),
...@@ -91,10 +73,14 @@ def add_user(request, location): ...@@ -91,10 +73,14 @@ def add_user(request, location):
This POST-back view will add a user - specified by email - to the list of editors for This POST-back view will add a user - specified by email - to the list of editors for
the specified course the specified course
''' '''
email = request.POST["email"] email = request.POST.get("email")
if email == '': if not email:
return create_json_response('Please specify an email address.') msg = {
'Status': 'Failed',
'ErrMsg': 'Please specify an email address.',
}
return JsonResponse(msg, 400)
# check that logged in user has admin permissions to this course # check that logged in user has admin permissions to this course
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
...@@ -104,16 +90,24 @@ def add_user(request, location): ...@@ -104,16 +90,24 @@ def add_user(request, location):
# user doesn't exist?!? Return error. # user doesn't exist?!? Return error.
if user is None: if user is None:
return create_json_response('Could not find user by email address \'{0}\'.'.format(email)) msg = {
'Status': 'Failed',
'ErrMsg': "Could not find user by email address '{0}'.".format(email),
}
return JsonResponse(msg, 404)
# user exists, but hasn't activated account?!? # user exists, but hasn't activated account?!?
if not user.is_active: if not user.is_active:
return create_json_response('User {0} has registered but has not yet activated his/her account.'.format(email)) msg = {
'Status': 'Failed',
'ErrMsg': 'User {0} has registered but has not yet activated his/her account.'.format(email),
}
return JsonResponse(msg, 400)
# ok, we're cool to add to the course group # ok, we're cool to add to the course group
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME) add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
return create_json_response() return JsonResponse({"Status": "OK"})
@expect_json @expect_json
...@@ -133,7 +127,11 @@ def remove_user(request, location): ...@@ -133,7 +127,11 @@ def remove_user(request, location):
user = get_user_by_email(email) user = get_user_by_email(email)
if user is None: if user is None:
return create_json_response('Could not find user by email address \'{0}\'.'.format(email)) msg = {
'Status': 'Failed',
'ErrMsg': "Could not find user by email address '{0}'.".format(email),
}
return JsonResponse(msg, 404)
# make sure we're not removing ourselves # make sure we're not removing ourselves
if user.id == request.user.id: if user.id == request.user.id:
...@@ -141,4 +139,4 @@ def remove_user(request, location): ...@@ -141,4 +139,4 @@ def remove_user(request, location):
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME) remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
return create_json_response() return JsonResponse({"Status": "OK"})
...@@ -61,19 +61,19 @@ class CourseMetadata(object): ...@@ -61,19 +61,19 @@ class CourseMetadata(object):
if not filter_tabs: if not filter_tabs:
filtered_list.remove("tabs") filtered_list.remove("tabs")
for k, v in jsondict.iteritems(): for key, val in jsondict.iteritems():
# should it be an error if one of the filtered list items is in the payload? # should it be an error if one of the filtered list items is in the payload?
if k in filtered_list: if key in filtered_list:
continue continue
if hasattr(descriptor, k) and getattr(descriptor, k) != v: if hasattr(descriptor, key) and getattr(descriptor, key) != val:
dirty = True dirty = True
value = getattr(CourseDescriptor, k).from_json(v) value = getattr(CourseDescriptor, key).from_json(val)
setattr(descriptor, k, value) setattr(descriptor, key, value)
elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k: elif hasattr(descriptor.lms, key) and getattr(descriptor.lms, key) != key:
dirty = True dirty = True
value = getattr(CourseDescriptor.lms, k).from_json(v) value = getattr(CourseDescriptor.lms, key).from_json(val)
setattr(descriptor.lms, k, value) setattr(descriptor.lms, key, value)
if dirty: if dirty:
get_modulestore(course_location).update_metadata(course_location, get_modulestore(course_location).update_metadata(course_location,
......
...@@ -32,21 +32,21 @@ from path import path ...@@ -32,21 +32,21 @@ from path import path
MITX_FEATURES = { MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True, 'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False, 'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False, 'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES': False, 'AUTH_USE_MIT_CERTIFICATES': False,
# do not display video when running automated acceptance tests # do not display video when running automated acceptance tests
'STUB_VIDEO_FOR_TESTING': False, 'STUB_VIDEO_FOR_TESTING': False,
# email address for staff (eg to request course creation) # email address for staff (eg to request course creation)
'STAFF_EMAIL': '', 'STAFF_EMAIL': '',
'STUDIO_NPS_SURVEY': True, 'STUDIO_NPS_SURVEY': True,
# Segment.io - must explicitly turn it on for production # Segment.io - must explicitly turn it on for production
'SEGMENT_IO': False, 'SEGMENT_IO': False,
...@@ -143,6 +143,7 @@ MIDDLEWARE_CLASSES = ( ...@@ -143,6 +143,7 @@ MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'method_override.middleware.MethodOverrideMiddleware',
# Instead of AuthenticationMiddleware, we use a cache-backed version # Instead of AuthenticationMiddleware, we use a cache-backed version
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware', 'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
...@@ -242,6 +243,7 @@ PIPELINE_JS = { ...@@ -242,6 +243,7 @@ PIPELINE_JS = {
) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js', ) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
'js/models/section.js', 'js/views/section.js', 'js/models/section.js', 'js/views/section.js',
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/models/textbook.js', 'js/views/textbook.js',
'js/views/assets.js'], 'js/views/assets.js'],
'output_filename': 'js/cms-application.js', 'output_filename': 'js/cms-application.js',
'test_order': 0 'test_order': 0
...@@ -324,6 +326,7 @@ INSTALLED_APPS = ( ...@@ -324,6 +326,7 @@ INSTALLED_APPS = (
'django.contrib.messages', 'django.contrib.messages',
'djcelery', 'djcelery',
'south', 'south',
'method_override',
# Monitor the status of services # Monitor the status of services
'service_status', 'service_status',
......
#pylint: disable=W0614, W0401
from .dev import *
FILE_UPLOAD_HANDLERS = (
'contentstore.debug_file_uploader.DebugFileUploader',
'django.core.files.uploadhandler.MemoryFileUploadHandler',
'django.core.files.uploadhandler.TemporaryFileUploadHandler',
)
...@@ -36,6 +36,7 @@ MODULESTORE = { ...@@ -36,6 +36,7 @@ MODULESTORE = {
} }
} }
# cdodge: This is the specifier for the MongoDB (using GridFS) backed static content store # cdodge: This is the specifier for the MongoDB (using GridFS) backed static content store
# This is for static content for courseware, not system static content (e.g. javascript, css, edX branding, etc) # This is for static content for courseware, not system static content (e.g. javascript, css, edX branding, etc)
CONTENTSTORE = { CONTENTSTORE = {
......
File mode changed from 100644 to 100755
...@@ -6,10 +6,10 @@ from request_cache.middleware import RequestCache ...@@ -6,10 +6,10 @@ from request_cache.middleware import RequestCache
from django.core.cache import get_cache 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:
store = modulestore(store_name) store = modulestore(store_name)
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']) modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
......
...@@ -9,8 +9,11 @@ ...@@ -9,8 +9,11 @@
"js/vendor/underscore-min.js", "js/vendor/underscore-min.js",
"js/vendor/underscore.string.min.js", "js/vendor/underscore.string.min.js",
"js/vendor/backbone-min.js", "js/vendor/backbone-min.js",
"js/vendor/backbone-associations-min.js",
"js/vendor/jquery.leanModal.min.js", "js/vendor/jquery.leanModal.min.js",
"js/vendor/jquery.form.js",
"js/vendor/sinon-1.7.1.js", "js/vendor/sinon-1.7.1.js",
"js/vendor/jasmine-stealth.js",
"js/test/i18n.js" "js/test/i18n.js"
] ]
} }
../../../templates/js/edit-chapter.underscore
\ No newline at end of file
../../../templates/js/edit-textbook.underscore
\ No newline at end of file
../../../templates/js/no-textbooks.underscore
\ No newline at end of file
../../../templates/js/show-textbook.underscore
\ No newline at end of file
../../../templates/js/upload-dialog.underscore
\ No newline at end of file
beforeEach ->
@addMatchers
toBeInstanceOf: (expected) ->
return @actual instanceof expected
describe "CMS.Models.Textbook", ->
beforeEach ->
@model = new CMS.Models.Textbook()
describe "Basic", ->
it "should have an empty name by default", ->
expect(@model.get("name")).toEqual("")
it "should not show chapters by default", ->
expect(@model.get("showChapters")).toBeFalsy()
it "should have a ChapterSet with one chapter by default", ->
chapters = @model.get("chapters")
expect(chapters).toBeInstanceOf(CMS.Collections.ChapterSet)
expect(chapters.length).toEqual(1)
expect(chapters.at(0).isEmpty()).toBeTruthy()
it "should be empty by default", ->
expect(@model.isEmpty()).toBeTruthy()
it "should have a URL set", ->
expect(_.result(@model, "url")).toBeTruthy()
it "should be able to reset itself", ->
@model.set("name", "foobar")
@model.reset()
expect(@model.get("name")).toEqual("")
it "should not be dirty by default", ->
expect(@model.isDirty()).toBeFalsy()
it "should be dirty after it's been changed", ->
@model.set("name", "foobar")
expect(@model.isDirty()).toBeTruthy()
it "should not be dirty after calling setOriginalAttributes", ->
@model.set("name", "foobar")
@model.setOriginalAttributes()
expect(@model.isDirty()).toBeFalsy()
describe "Input/Output", ->
deepAttributes = (obj) ->
if obj instanceof Backbone.Model
deepAttributes(obj.attributes)
else if obj instanceof Backbone.Collection
obj.map(deepAttributes);
else if _.isArray(obj)
_.map(obj, deepAttributes);
else if _.isObject(obj)
attributes = {};
for own prop, val of obj
attributes[prop] = deepAttributes(val)
attributes
else
obj
it "should match server model to client model", ->
serverModelSpec = {
"tab_title": "My Textbook",
"chapters": [
{"title": "Chapter 1", "url": "/ch1.pdf"},
{"title": "Chapter 2", "url": "/ch2.pdf"},
]
}
clientModelSpec = {
"name": "My Textbook",
"showChapters": false,
"editing": false,
"chapters": [{
"name": "Chapter 1",
"asset_path": "/ch1.pdf",
"order": 1
}, {
"name": "Chapter 2",
"asset_path": "/ch2.pdf",
"order": 2
}
]
}
model = new CMS.Models.Textbook(serverModelSpec, {parse: true})
expect(deepAttributes(model)).toEqual(clientModelSpec)
expect(model.toJSON()).toEqual(serverModelSpec)
describe "Validation", ->
it "requires a name", ->
model = new CMS.Models.Textbook({name: ""})
expect(model.isValid()).toBeFalsy()
it "requires at least one chapter", ->
model = new CMS.Models.Textbook({name: "foo"})
model.get("chapters").reset()
expect(model.isValid()).toBeFalsy()
it "requires a valid chapter", ->
chapter = new CMS.Models.Chapter()
chapter.isValid = -> false
model = new CMS.Models.Textbook({name: "foo"})
model.get("chapters").reset([chapter])
expect(model.isValid()).toBeFalsy()
it "requires all chapters to be valid", ->
chapter1 = new CMS.Models.Chapter()
chapter1.isValid = -> true
chapter2 = new CMS.Models.Chapter()
chapter2.isValid = -> false
model = new CMS.Models.Textbook({name: "foo"})
model.get("chapters").reset([chapter1, chapter2])
expect(model.isValid()).toBeFalsy()
it "can pass validation", ->
chapter = new CMS.Models.Chapter()
chapter.isValid = -> true
model = new CMS.Models.Textbook({name: "foo"})
model.get("chapters").reset([chapter])
expect(model.isValid()).toBeTruthy()
describe "CMS.Collections.TextbookSet", ->
beforeEach ->
CMS.URL.TEXTBOOK = "/textbooks"
@collection = new CMS.Collections.TextbookSet()
afterEach ->
delete CMS.URL.TEXTBOOK
it "should have a url set", ->
expect(_.result(@collection, "url"), "/textbooks")
it "can call save", ->
spyOn(@collection, "sync")
@collection.save()
expect(@collection.sync).toHaveBeenCalledWith("update", @collection, undefined)
describe "CMS.Models.Chapter", ->
beforeEach ->
@model = new CMS.Models.Chapter()
describe "Basic", ->
it "should have a name by default", ->
expect(@model.get("name")).toEqual("")
it "should have an asset_path by default", ->
expect(@model.get("asset_path")).toEqual("")
it "should have an order by default", ->
expect(@model.get("order")).toEqual(1)
it "should be empty by default", ->
expect(@model.isEmpty()).toBeTruthy()
describe "Validation", ->
it "requires a name", ->
model = new CMS.Models.Chapter({name: "", asset_path: "a.pdf"})
expect(model.isValid()).toBeFalsy()
it "requires an asset_path", ->
model = new CMS.Models.Chapter({name: "a", asset_path: ""})
expect(model.isValid()).toBeFalsy()
it "can pass validation", ->
model = new CMS.Models.Chapter({name: "a", asset_path: "a.pdf"})
expect(model.isValid()).toBeTruthy()
describe "CMS.Collections.ChapterSet", ->
beforeEach ->
@collection = new CMS.Collections.ChapterSet()
it "is empty by default", ->
expect(@collection.isEmpty()).toBeTruthy()
it "is empty if all chapters are empty", ->
@collection.add([{}, {}, {}])
expect(@collection.isEmpty()).toBeTruthy()
it "is not empty if a chapter is not empty", ->
@collection.add([{}, {name: "full"}, {}])
expect(@collection.isEmpty()).toBeFalsy()
it "should have a nextOrder function", ->
expect(@collection.nextOrder()).toEqual(1)
@collection.add([{}])
expect(@collection.nextOrder()).toEqual(2)
@collection.add([{}])
expect(@collection.nextOrder()).toEqual(3)
# verify that it doesn't just return an incrementing value each time
expect(@collection.nextOrder()).toEqual(3)
# try going back one
@collection.remove(@collection.last())
expect(@collection.nextOrder()).toEqual(2)
describe "CMS.Models.FileUpload", ->
beforeEach ->
@model = new CMS.Models.FileUpload()
it "is unfinished by default", ->
expect(@model.get("finished")).toBeFalsy()
it "is not uploading by default", ->
expect(@model.get("uploading")).toBeFalsy()
it "is valid by default", ->
expect(@model.isValid()).toBeTruthy()
it "is valid for PDF files", ->
file = {"type": "application/pdf"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeTruthy()
it "is invalid for text files", ->
file = {"type": "text/plain"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy()
it "is invalid for PNG files", ->
file = {"type": "image/png"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy()
...@@ -98,6 +98,16 @@ describe "CMS.Views.Prompt", -> ...@@ -98,6 +98,16 @@ describe "CMS.Views.Prompt", ->
view.hide() view.hide()
# expect($("body")).not.toHaveClass("prompt-is-shown") # expect($("body")).not.toHaveClass("prompt-is-shown")
describe "CMS.Views.Notification.Saving", ->
beforeEach ->
@view = new CMS.Views.Notification.Saving()
it "should have minShown set to 1250 by default", ->
expect(@view.options.minShown).toEqual(1250)
it "should have closeIcon set to false by default", ->
expect(@view.options.closeIcon).toBeFalsy()
describe "CMS.Views.SystemFeedback click events", -> describe "CMS.Views.SystemFeedback click events", ->
beforeEach -> beforeEach ->
@primaryClickSpy = jasmine.createSpy('primaryClick') @primaryClickSpy = jasmine.createSpy('primaryClick')
...@@ -204,17 +214,22 @@ describe "CMS.Views.SystemFeedback multiple secondary actions", -> ...@@ -204,17 +214,22 @@ describe "CMS.Views.SystemFeedback multiple secondary actions", ->
describe "CMS.Views.Notification minShown and maxShown", -> describe "CMS.Views.Notification minShown and maxShown", ->
beforeEach -> beforeEach ->
@showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show') @showSpy = spyOn(CMS.Views.Notification.Confirmation.prototype, 'show')
@showSpy.andCallThrough() @showSpy.andCallThrough()
@hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide') @hideSpy = spyOn(CMS.Views.Notification.Confirmation.prototype, 'hide')
@hideSpy.andCallThrough() @hideSpy.andCallThrough()
@clock = sinon.useFakeTimers() @clock = sinon.useFakeTimers()
afterEach -> afterEach ->
@clock.restore() @clock.restore()
it "should not have minShown or maxShown by default", ->
view = new CMS.Views.Notification.Confirmation()
expect(view.options.minShown).toEqual(0)
expect(view.options.maxShown).toEqual(Infinity)
it "a minShown view should not hide too quickly", -> it "a minShown view should not hide too quickly", ->
view = new CMS.Views.Notification.Saving({minShown: 1000}) view = new CMS.Views.Notification.Confirmation({minShown: 1000})
view.show() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
...@@ -227,7 +242,7 @@ describe "CMS.Views.Notification minShown and maxShown", -> ...@@ -227,7 +242,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view should hide by itself", -> it "a maxShown view should hide by itself", ->
view = new CMS.Views.Notification.Saving({maxShown: 1000}) view = new CMS.Views.Notification.Confirmation({maxShown: 1000})
view.show() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
...@@ -236,7 +251,7 @@ describe "CMS.Views.Notification minShown and maxShown", -> ...@@ -236,7 +251,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a minShown view can stay visible longer", -> it "a minShown view can stay visible longer", ->
view = new CMS.Views.Notification.Saving({minShown: 1000}) view = new CMS.Views.Notification.Confirmation({minShown: 1000})
view.show() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
...@@ -250,7 +265,7 @@ describe "CMS.Views.Notification minShown and maxShown", -> ...@@ -250,7 +265,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view can hide early", -> it "a maxShown view can hide early", ->
view = new CMS.Views.Notification.Saving({maxShown: 1000}) view = new CMS.Views.Notification.Confirmation({maxShown: 1000})
view.show() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
...@@ -264,7 +279,7 @@ describe "CMS.Views.Notification minShown and maxShown", -> ...@@ -264,7 +279,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a view can have both maxShown and minShown", -> it "a view can have both maxShown and minShown", ->
view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000}) view = new CMS.Views.Notification.Confirmation({minShown: 1000, maxShown: 2000})
view.show() view.show()
# can't hide early # can't hide early
......
...@@ -3,6 +3,8 @@ AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix) ...@@ -3,6 +3,8 @@ AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix)
@CMS = @CMS =
Models: {} Models: {}
Views: {} Views: {}
Collections: {}
URL: {}
prefix: $("meta[name='path_prefix']").attr('content') prefix: $("meta[name='path_prefix']").attr('content')
...@@ -17,7 +19,7 @@ $ -> ...@@ -17,7 +19,7 @@ $ ->
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) -> $(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false if ajaxSettings.notifyOnError is false
return return
if jqXHR.responseText if jqXHR.responseText
try try
message = JSON.parse(jqXHR.responseText).error message = JSON.parse(jqXHR.responseText).error
......
...@@ -23,9 +23,7 @@ CMS.Models.Section = Backbone.Model.extend({ ...@@ -23,9 +23,7 @@ CMS.Models.Section = Backbone.Model.extend({
showNotification: function() { showNotification: function() {
if(!this.msg) { if(!this.msg) {
this.msg = new CMS.Views.Notification.Saving({ this.msg = new CMS.Views.Notification.Saving({
title: gettext("Saving&hellip;"), title: gettext("Saving&hellip;")
closeIcon: false,
minShown: 1250
}); });
} }
this.msg.show(); this.msg.show();
......
CMS.Models.Textbook = Backbone.AssociatedModel.extend({
defaults: function() {
return {
name: "",
chapters: new CMS.Collections.ChapterSet([{}]),
showChapters: false,
editing: false
};
},
relations: [{
type: Backbone.Many,
key: "chapters",
relatedModel: "CMS.Models.Chapter",
collectionType: "CMS.Collections.ChapterSet"
}],
initialize: function() {
this.setOriginalAttributes();
return this;
},
setOriginalAttributes: function() {
this._originalAttributes = this.parse(this.toJSON());
},
reset: function() {
this.set(this._originalAttributes, {parse: true});
},
isDirty: function() {
return !_.isEqual(this._originalAttributes, this.parse(this.toJSON()));
},
isEmpty: function() {
return !this.get('name') && this.get('chapters').isEmpty();
},
url: function() {
if(this.isNew()) {
return CMS.URL.TEXTBOOKS + "/new";
} else {
return CMS.URL.TEXTBOOKS + "/" + this.id;
}
},
parse: function(response) {
var ret = $.extend(true, {}, response);
if("tab_title" in ret && !("name" in ret)) {
ret.name = ret.tab_title;
delete ret.tab_title;
}
if("url" in ret && !("chapters" in ret)) {
ret.chapters = {"url": ret.url};
delete ret.url;
}
_.each(ret.chapters, function(chapter, i) {
chapter.order = chapter.order || i+1;
});
return ret;
},
toJSON: function() {
return {
tab_title: this.get('name'),
chapters: this.get('chapters').toJSON()
};
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if (!attrs.name) {
return {
message: "Textbook name is required",
attributes: {name: true}
};
}
if (attrs.chapters.length === 0) {
return {
message: "Please add at least one chapter",
attributes: {chapters: true}
};
} else {
// validate all chapters
var invalidChapters = [];
attrs.chapters.each(function(chapter) {
if(!chapter.isValid()) {
invalidChapters.push(chapter);
}
});
if(!_.isEmpty(invalidChapters)) {
return {
message: "All chapters must have a name and asset",
attributes: {chapters: invalidChapters}
};
}
}
}
});
CMS.Collections.TextbookSet = Backbone.Collection.extend({
model: CMS.Models.Textbook,
url: function() { return CMS.URL.TEXTBOOKS; },
save: function(options) {
return this.sync('update', this, options);
}
});
CMS.Models.Chapter = Backbone.AssociatedModel.extend({
defaults: function() {
return {
name: "",
asset_path: "",
order: this.collection ? this.collection.nextOrder() : 1
};
},
isEmpty: function() {
return !this.get('name') && !this.get('asset_path');
},
parse: function(response) {
if("title" in response && !("name" in response)) {
response.name = response.title;
delete response.title;
}
if("url" in response && !("asset_path" in response)) {
response.asset_path = response.url;
delete response.url;
}
return response;
},
toJSON: function() {
return {
title: this.get('name'),
url: this.get('asset_path')
};
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if(!attrs.name && !attrs.asset_path) {
return {
message: "Chapter name and asset_path are both required",
attributes: {name: true, asset_path: true}
};
} else if(!attrs.name) {
return {
message: "Chapter name is required",
attributes: {name: true}
};
} else if (!attrs.asset_path) {
return {
message: "asset_path is required",
attributes: {asset_path: true}
};
}
}
});
CMS.Collections.ChapterSet = Backbone.Collection.extend({
model: CMS.Models.Chapter,
comparator: "order",
nextOrder: function() {
if(!this.length) return 1;
return this.last().get('order') + 1;
},
isEmpty: function() {
return this.length === 0 || this.every(function(m) { return m.isEmpty(); });
}
});
CMS.Models.FileUpload = Backbone.Model.extend({
defaults: {
"title": "",
"message": "",
"selectedFile": null,
"uploading": false,
"uploadedBytes": 0,
"totalBytes": 0,
"finished": false
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if(attrs.selectedFile && attrs.selectedFile.type !== "application/pdf") {
return {
message: "Only PDF files can be uploaded. Please select a file ending in .pdf to upload.",
attributes: {selectedFile: true}
};
}
}
});
...@@ -186,3 +186,9 @@ _.each(types, function(type) { ...@@ -186,3 +186,9 @@ _.each(types, function(type) {
klass[capitalCamel(intent)] = subklass; klass[capitalCamel(intent)] = subklass;
}); });
}); });
// set more sensible defaults for Notification-Saving views
var savingOptions = CMS.Views.Notification.Saving.prototype.options;
savingOptions.minShown = 1250;
savingOptions.closeIcon = false;
...@@ -313,11 +313,6 @@ p, ul, ol, dl { ...@@ -313,11 +313,6 @@ p, ul, ol, dl {
.view-button { .view-button {
} }
.upload-button .icon-plus {
@extend .t-action2;
line-height: 0 !important;
}
} }
} }
...@@ -751,9 +746,6 @@ hr.divide { ...@@ -751,9 +746,6 @@ hr.divide {
} }
.icon-plus { .icon-plus {
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/4);
margin-top: -2px; margin-top: -2px;
line-height: 0; line-height: 0;
} }
......
// studio - shame // studio - shame
// // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) // // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/)
// ==================== // ====================
// known things to do (paint the fence, sand the floor, wax on/off)
// ====================
// * centralize and move form styling into forms.scss - cms/static/sass/views/_textbooks.scss and cms/static/sass/views/_settings.scss
// * move dialogue styles into cms/static/sass/elements/_modal.scss
// * use the @include placeholder Bourbon mixin (http://bourbon.io/docs/#placeholder) for any placeholder styling
...@@ -58,6 +58,7 @@ ...@@ -58,6 +58,7 @@
@import 'views/unit'; @import 'views/unit';
@import 'views/users'; @import 'views/users';
@import 'views/checklists'; @import 'views/checklists';
@import 'views/textbooks';
// temp - inherited // temp - inherited
@import 'assets/content-types'; @import 'assets/content-types';
......
...@@ -135,6 +135,18 @@ ...@@ -135,6 +135,18 @@
// ==================== // ====================
// button elements
.button {
[class^="icon-"] {
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/4);
}
}
// ====================
// simple dropdown button styling - should we move this elsewhere? // simple dropdown button styling - should we move this elsewhere?
.btn-dd { .btn-dd {
@extend .btn; @extend .btn;
......
...@@ -349,6 +349,7 @@ body.course.outline .nav-course-courseware-outline, ...@@ -349,6 +349,7 @@ body.course.outline .nav-course-courseware-outline,
body.course.updates .nav-course-courseware-updates, body.course.updates .nav-course-courseware-updates,
body.course.pages .nav-course-courseware-pages, body.course.pages .nav-course-courseware-pages,
body.course.uploads .nav-course-courseware-uploads, body.course.uploads .nav-course-courseware-uploads,
body.course.textbooks .nav-course-courseware-textbooks,
// course settings // course settings
body.course.schedule .nav-course-settings .title, body.course.schedule .nav-course-settings .title,
......
...@@ -48,7 +48,7 @@ ...@@ -48,7 +48,7 @@
<h3 class="sr">Page Actions</h3> <h3 class="sr">Page Actions</h3>
<ul> <ul>
<li class="nav-item"> <li class="nav-item">
<a href="#" class="button upload-button new-button"><i class="icon-cloud-upload"></i> Upload New File</a> <a href="#" class="button upload-button new-button"><i class="icon-plus"></i> Upload New File</a>
</li> </li>
</ul> </ul>
</nav> </nav>
......
...@@ -40,6 +40,7 @@ ...@@ -40,6 +40,7 @@
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/underscore.string.min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/underscore.string.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/backbone-associations-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/jquery.markitup.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/markitup/jquery.markitup.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script>
......
<div class="input-wrap field text required field-add-chapter-name chapter<%= order %>-name
<% if (error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="chapter<%= order %>-name"><%= gettext("Chapter Name") %></label>
<input id="chapter<%= order %>-name" name="chapter<%= order %>-name" class="chapter-name short" placeholder="<%= _.str.sprintf(gettext("Chapter %s"), order) %>" value="<%= name %>" type="text">
<span class="tip tip-stacked"><%= gettext("provide the title/name of the chapter that will be used in navigating") %></span>
</div>
<div class="input-wrap field text required field-add-chapter-asset chapter<%= order %>-asset
<% if (error && error.attributes && error.attributes.asset_path) { print('error'); } %>">
<label for="chapter<%= order %>-asset-path"><%= gettext("Chapter Asset") %></label>
<input id="chapter<%= order %>-asset-path" name="chapter<%= order %>-asset-path" class="chapter-asset-path" placeholder="<%= _.str.sprintf(gettext("path/to/introductionToCookieBaking-CH%d.pdf"), order) %>" value="<%= asset_path %>" type="text">
<span class="tip tip-stacked"><%= gettext("upload a PDF file or provide the path to a Studio asset file") %></span>
<button class="action action-upload"><%= gettext("Upload Asset") %></button>
</div>
<a href="" class="action action-close"><i class="icon-remove-sign"></i> <span class="sr"><%= gettext("delete chapter") %></span></a>
<form class="edit-textbook" id="edit_textbook_form">
<div class="wrapper-form">
<% if (error && error.message) { %>
<div id="edit_textbook_error" class="message message-status message-status error is-shown" name="edit_textbook_error">
<%= gettext(error.message) %>
</div>
<% } %>
<fieldset class="textbook-fields">
<legend class="sr"><%= gettext("Textbook information") %></legend>
<div class="input-wrap field text required add-textbook-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="textbook-name-input"><%= gettext("Textbook Name") %></label>
<input id="textbook-name-input" name="textbook-name" type="text" placeholder="<%= gettext("Introduction to Cookie Baking") %>" value="<%= name %>">
<span class="tip tip-stacked"><%= gettext("provide the title/name of the text book as you would like your students to see it") %></span>
</div>
</fieldset>
<fieldset class="chapters-fields">
<legend class="sr"><%= gettext("Chapter(s) information") %></legend>
<ol class="chapters list-input enum"></ol>
<button class="action action-add-chapter"><i class="icon-plus"></i> <%= gettext("Add a Chapter") %></button>
</fieldset>
</div>
<div class="actions">
<button class="action action-primary" type="submit"><%= gettext("Save") %></button>
<button class="action action-secondary action-cancel"><%= gettext("Cancel") %></button>
</div>
</form>
<div class="no-textbook-content">
<p><%= gettext("You haven't added any textbooks to this course yet.") %><a href="#" class="button new-button"><i class="icon-plus"></i><%= gettext("Add your first textbook") %></a></p>
</div>
<div class="view-textbook">
<div class="wrap-textbook">
<header>
<h3 class="textbook-title"><%= name %></h3>
</header>
<% if(chapters.length > 1) {%>
<p><a href="#" class="chapter-toggle
<% if(showChapters){ print('hide'); } else { print('show'); } %>-chapters">
<i class="ui-toggle-expansion icon-caret-<% if(showChapters){ print('down'); } else { print('right'); } %>"></i>
<%= chapters.length %> PDF Chapters
</a></p>
<% } else if(chapters.length === 1) { %>
<p>
<%= chapters.at(0).get("asset_path") %>
</p>
<% } %>
<% if(showChapters) { %>
<ol class="chapters">
<% chapters.each(function(chapter) { %>
<li class="chapter">
<span class="chapter-name"><%= chapter.get('name') %></span>
<span class="chapter-asset-path"><%= chapter.get('asset_path') %></span>
</li>
<% }) %>
</ol>
<% } %>
</div>
<ul class="actions textbook-actions">
<li class="action action-view">
<a href="//<%= CMS.URL.LMS_BASE %>/courses/<%= course.org %>/<%= course.num %>/<%= course.url_name %>/pdfbook/<%= bookindex %>/" class="view"><%= gettext("View Live") %></a>
</li>
<li class="action action-edit">
<button class="edit"><%= gettext("Edit") %></button>
</li>
<li class="action action-delete">
<button class="delete action-icon"><i class="icon-trash"></i><span><%= gettext("Delete") %></span></button>
</li>
</ul>
</div>
<div id="dialog-assetupload"
class="wrapper wrapper-dialog wrapper-dialog-assetupload <% if(shown) { print('is-shown') } %>"
aria-describedby="dialog-assetupload-description"
aria-labelledby="dialog-assetupload-title"
aria-hidden="<%= !shown %>"
role="dialog">
<div class="dialog confirm">
<form class="upload-dialog" method="POST" action="<%= url %>" enctype="multipart/form-data">
<div class="form-content">
<h2 class="title"><%= title %></h2>
<% if(error) {%>
<div id="upload_error" class="message message-status message-status error is-shown" name="upload_error">
<p><%= gettext(error.message) %></p>
</div>
<% } %>
<p id="dialog-assetupload-description" class="message"><%= message %></p>
<input type="file" name="file" <% if(error && error.attributes && error.attributes.selectedFile) {%>class="error"<% } %> />
<div class="status-upload">
<% if(uploading) { %>
<div class="wrapper-progress">
<% if (uploadedBytes && totalBytes) { %>
<progress value="<%= uploadedBytes %>" max="<%= totalBytes %>"><%= uploadedBytes/totalBytes*100 %>%</progress>
<% } else { %>
<progress></progress>
<% } %>
</div>
<% } %>
<% if(finished) { %>
<div id="upload_confirm" class="message message-status message-status confirm is-shown" name="upload_confirm">
<p><%= gettext("Success!") %></p>
</div>
<% } %>
</div>
</div>
<div class="actions">
<h3 class="sr"><%= gettext('Form Actions') %></h3>
<ul>
<li class="action-item">
<a href="#" class="button action-primary action-upload <% if (!selectedFile || error) { %>disabled<% } %>"><%= gettext('Upload') %></a>
</li>
<li class="action-item">
<a href="#" class="button action-secondary action-cancel"><%= gettext('Cancel') %></a>
</li>
</ul>
</div>
</form>
</div>
</div>
<%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/>
<%! import json %>
<%! from django.utils.translation import ugettext as _ %>
<%block name="title">${_("Textbooks")}</%block>
<%block name="bodyclass">is-signedin course textbooks</%block>
<%block name="header_extras">
% for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "upload-dialog"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
CMS.URL.UPLOAD_ASSET = "${upload_asset_url}"
CMS.URL.TEXTBOOKS = "${textbook_url}"
CMS.URL.LMS_BASE = "${settings.LMS_BASE}"
window.section = new CMS.Models.Section({
id: "${course.id}",
name: "${course.display_name_with_default | h}",
url_name: "${course.location.name | h}",
org: "${course.location.org | h}",
num: "${course.location.course | h}",
revision: "${course.location.revision | h}"
});
var textbooks = new CMS.Collections.TextbookSet(${json.dumps(course.pdf_textbooks)}, {parse: true});
var tbView = new CMS.Views.ListTextbooks({collection: textbooks});
$(function() {
$(".content-primary").append(tbView.render().el);
$(".nav-actions .new-button").click(function(e) {
tbView.addOne(e);
})
$(window).on("beforeunload", function() {
var dirty = textbooks.find(function(textbook) { return textbook.isDirty(); });
if(dirty) {
return gettext("You have unsaved changes. Do you really want to leave this page?");
}
})
})
</script>
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Textbooks")}
</h1>
<nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button new-button"><i class="icon-plus"></i> ${_("New Textbook")}</a>
</li>
</ul>
</nav>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">${_("Why should I break my text into chapters?")}</h3>
<p>${_("It's best practice to break your course's textbook into multiple chapters to reduce loading times for students. Breaking up textbooks into chapters can also help students more easily find topic-based information.")}</p>
</div>
<div class="bit">
<h3 class="title-3">${_("What if my book isn't divided into chapters?")}</h3>
<p>${_("If you haven't broken your textbook into chapters, you can upload the entire text as Chapter 1.")}</p>
</div>
</aside>
</section>
</div>
</%block>
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<div class="wrapper-header wrapper" id="view-top"> <div class="wrapper-header wrapper" id="view-top">
<header class="primary" role="banner"> <header class="primary" role="banner">
...@@ -8,7 +10,7 @@ ...@@ -8,7 +10,7 @@
% if context_course: % if context_course:
<% ctx_loc = context_course.location %> <% ctx_loc = context_course.location %>
<h2 class="info-course"> <h2 class="info-course">
<span class="sr">Current Course:</span> <span class="sr">${_("Current Course:")}</span>
<a class="course-link" href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}"> <a class="course-link" href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">
<span class="course-org">${ctx_loc.org}</span><span class="course-number">${ctx_loc.course}</span> <span class="course-org">${ctx_loc.org}</span><span class="course-number">${ctx_loc.course}</span>
<span class="course-title" title="${context_course.display_name_with_default}">${context_course.display_name_with_default}</span> <span class="course-title" title="${context_course.display_name_with_default}">${context_course.display_name_with_default}</span>
...@@ -16,26 +18,28 @@ ...@@ -16,26 +18,28 @@
</h2> </h2>
<nav class="nav-course nav-dd ui-left"> <nav class="nav-course nav-dd ui-left">
<h2 class="sr">${context_course.display_name_with_default}'s Navigation:</h2> <h2 class="sr">${_("{}'s Navigation:".format(context_course.display_name_with_default))}</h2>
<ol> <ol>
<li class="nav-item nav-course-courseware"> <li class="nav-item nav-course-courseware">
<h3 class="title"><span class="label"><span class="label-prefix sr">Course </span>Content</span> <i class="icon-caret-down ui-toggle-dd"></i></h3> <h3 class="title"><span class="label"><span class="label-prefix sr">${_("Course")} </span>${_("Content")}</span> <i class="icon-caret-down ui-toggle-dd"></i></h3>
<div class="wrapper wrapper-nav-sub"> <div class="wrapper wrapper-nav-sub">
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
<li class="nav-item nav-course-courseware-outline"> <li class="nav-item nav-course-courseware-outline">
<a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Outline</a> <a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Outline")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-updates"> <li class="nav-item nav-course-courseware-updates">
<a href="${reverse('course_info', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Updates</a> <a href="${reverse('course_info', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Updates")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-pages"> <li class="nav-item nav-course-courseware-pages">
<a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}">Static Pages</a> <a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}">${_("Static Pages")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-uploads"> <li class="nav-item nav-course-courseware-uploads">
<a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Files &amp; Uploads</a> <a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Files &amp; Uploads")}</a>
</li>
<li class="nav-item nav-course-courseware-textbooks">
<a href="${reverse('textbook_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Textbooks")}</a>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -43,22 +47,22 @@ ...@@ -43,22 +47,22 @@
</li> </li>
<li class="nav-item nav-course-settings"> <li class="nav-item nav-course-settings">
<h3 class="title"><span class="label"><span class="label-prefix sr">Course </span>Settings</span> <i class="icon-caret-down ui-toggle-dd"></i></h3> <h3 class="title"><span class="label"><span class="label-prefix sr">${_("Course")} </span>${_("Settings")}</span> <i class="icon-caret-down ui-toggle-dd"></i></h3>
<div class="wrapper wrapper-nav-sub"> <div class="wrapper wrapper-nav-sub">
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
<li class="nav-item nav-course-settings-schedule"> <li class="nav-item nav-course-settings-schedule">
<a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Schedule &amp; Details</a> <a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Schedule &amp; Details")}</a>
</li> </li>
<li class="nav-item nav-course-settings-grading"> <li class="nav-item nav-course-settings-grading">
<a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Grading</a> <a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a>
</li> </li>
<li class="nav-item nav-course-settings-team"> <li class="nav-item nav-course-settings-team">
<a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a> <a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">${_("Course Team")}</a>
</li> </li>
<li class="nav-item nav-course-settings-advanced"> <li class="nav-item nav-course-settings-advanced">
<a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a> <a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -66,19 +70,19 @@ ...@@ -66,19 +70,19 @@
</li> </li>
<li class="nav-item nav-course-tools"> <li class="nav-item nav-course-tools">
<h3 class="title"><span class="label">Tools</span> <i class="icon-caret-down ui-toggle-dd"></i></h3> <h3 class="title"><span class="label">${_("Tools")}</span> <i class="icon-caret-down ui-toggle-dd"></i></h3>
<div class="wrapper wrapper-nav-sub"> <div class="wrapper wrapper-nav-sub">
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
<li class="nav-item nav-course-tools-checklists"> <li class="nav-item nav-course-tools-checklists">
<a href="${reverse('checklists', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Checklists</a> <a href="${reverse('checklists', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Checklists")}</a>
</li> </li>
<li class="nav-item nav-course-tools-import"> <li class="nav-item nav-course-tools-import">
<a href="${reverse('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Import</a> <a href="${reverse('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Import")}</a>
</li> </li>
<li class="nav-item nav-course-tools-export"> <li class="nav-item nav-course-tools-export">
<a href="${reverse('export_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Export</a> <a href="${reverse('export_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Export")}</a>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -122,10 +126,10 @@ ...@@ -122,10 +126,10 @@
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
<li class="nav-item nav-account-dashboard"> <li class="nav-item nav-account-dashboard">
<a href="/">My Courses</a> <a href="/">${_("My Courses")}</a>
</li> </li>
<li class="nav-item nav-account-signout"> <li class="nav-item nav-account-signout">
<a class="action action-signout" href="${reverse('logout')}">Sign Out</a> <a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -136,19 +140,19 @@ ...@@ -136,19 +140,19 @@
% else: % else:
<nav class="nav-not-signedin nav-pitch"> <nav class="nav-not-signedin nav-pitch">
<h2 class="sr">You're not currently signed in</h2> <h2 class="sr">${_("You're not currently signed in")}</h2>
<ol> <ol>
<li class="nav-item nav-not-signedin-hiw"> <li class="nav-item nav-not-signedin-hiw">
<a href="/">How Studio Works</a> <a href="/">${_("How Studio Works")}</a>
</li> </li>
<li class="nav-item nav-not-signedin-help"> <li class="nav-item nav-not-signedin-help">
<a href="http://help.edge.edx.org/" rel="external">Studio Help</a> <a href="http://help.edge.edx.org/" rel="external">${_("Studio Help")}</a>
</li> </li>
<li class="nav-item nav-not-signedin-signup"> <li class="nav-item nav-not-signedin-signup">
<a class="action action-signup" href="${reverse('signup')}">Sign Up</a> <a class="action action-signup" href="${reverse('signup')}">${_("Sign Up")}</a>
</li> </li>
<li class="nav-item nav-not-signedin-signin"> <li class="nav-item nav-not-signedin-signin">
<a class="action action-signin" href="${reverse('login')}">Sign In</a> <a class="action action-signin" href="${reverse('login')}">${_("Sign In")}</a>
</li> </li>
</ol> </ol>
</nav> </nav>
......
...@@ -81,6 +81,12 @@ urlpatterns = ('', # nopep8 ...@@ -81,6 +81,12 @@ urlpatterns = ('', # nopep8
'contentstore.views.asset_index', name='asset_index'), 'contentstore.views.asset_index', name='asset_index'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)/remove$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)/remove$',
'contentstore.views.assets.remove_asset', name='remove_asset'), 'contentstore.views.assets.remove_asset', name='remove_asset'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$',
'contentstore.views.textbook_index', name='textbook_index'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$',
'contentstore.views.create_textbook', name='create_textbook'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/(?P<tid>\d[^/]*)$',
'contentstore.views.textbook_by_id', name='textbook_by_id'),
# this is a generic method to return the data/metadata associated with a xmodule # this is a generic method to return the data/metadata associated with a xmodule
url(r'^module_info/(?P<module_location>.*)$', url(r'^module_info/(?P<module_location>.*)$',
...@@ -94,9 +100,6 @@ urlpatterns = ('', # nopep8 ...@@ -94,9 +100,6 @@ urlpatterns = ('', # nopep8
url(r'^not_found$', 'contentstore.views.not_found', name='not_found'), url(r'^not_found$', 'contentstore.views.not_found', name='not_found'),
url(r'^server_error$', 'contentstore.views.server_error', name='server_error'), url(r'^server_error$', 'contentstore.views.server_error', name='server_error'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
'contentstore.views.asset_index', name='asset_index'),
# temporary landing page for edge # temporary landing page for edge
url(r'^edge$', 'contentstore.views.edge', name='edge'), url(r'^edge$', 'contentstore.views.edge', name='edge'),
# noop to squelch ajax errors # noop to squelch ajax errors
...@@ -151,5 +154,6 @@ urlpatterns += (url(r'^admin/', include(admin.site.urls)),) ...@@ -151,5 +154,6 @@ urlpatterns += (url(r'^admin/', include(admin.site.urls)),)
urlpatterns = patterns(*urlpatterns) urlpatterns = patterns(*urlpatterns)
# Custom error pages # Custom error pages
#pylint: disable=C0103
handler404 = 'contentstore.views.render_404' handler404 = 'contentstore.views.render_404'
handler500 = 'contentstore.views.render_500' handler500 = 'contentstore.views.render_500'
...@@ -24,17 +24,21 @@ class StaticContentServer(object): ...@@ -24,17 +24,21 @@ class StaticContentServer(object):
if content is None: if content is None:
# nope, not in cache, let's fetch from DB # nope, not in cache, let's fetch from DB
try: try:
content = contentstore().find(loc) content = contentstore().find(loc, as_stream=True)
except NotFoundError: except NotFoundError:
response = HttpResponse() response = HttpResponse()
response.status_code = 404 response.status_code = 404
return response return response
# since we fetched it from DB, let's cache it going forward # since we fetched it from DB, let's cache it going forward, but only if it's < 1MB
set_cached_content(content) # this is because I haven't been able to find a means to stream data out of memcached
if content.length is not None:
if content.length < 1048576:
# since we've queried as a stream, let's read in the stream into memory to set in cache
content = content.copy_to_in_mem()
set_cached_content(content)
else: else:
# @todo: we probably want to have 'cache hit' counters so we can # NOP here, but we may wish to add a "cache-hit" counter in the future
# measure the efficacy of our caches
pass pass
# see if the last-modified at hasn't changed, if not return a 302 (Not Modified) # see if the last-modified at hasn't changed, if not return a 302 (Not Modified)
...@@ -50,7 +54,7 @@ class StaticContentServer(object): ...@@ -50,7 +54,7 @@ class StaticContentServer(object):
if if_modified_since == last_modified_at_str: if if_modified_since == last_modified_at_str:
return HttpResponseNotModified() return HttpResponseNotModified()
response = HttpResponse(content.data, content_type=content.content_type) response = HttpResponse(content.stream_data(), content_type=content.content_type)
response['Last-Modified'] = last_modified_at_str response['Last-Modified'] = last_modified_at_str
return response return response
...@@ -69,24 +69,24 @@ def css_click(css_selector, index=0, max_attempts=5, success_condition=lambda: T ...@@ -69,24 +69,24 @@ def css_click(css_selector, index=0, max_attempts=5, success_condition=lambda: T
This function will return True if the click worked (taking into account both errors and the optional This function will return True if the click worked (taking into account both errors and the optional
success_condition). success_condition).
""" """
assert is_css_present(css_selector) assert is_css_present(css_selector), "{} is not present".format(css_selector)
attempt = 0 for _ in range(max_attempts):
result = False
while attempt < max_attempts:
try: try:
world.css_find(css_selector)[index].click() world.css_find(css_selector)[index].click()
if success_condition(): if success_condition():
result = True return
break
except WebDriverException: except WebDriverException:
# Occasionally, MathJax or other JavaScript can cover up # Occasionally, MathJax or other JavaScript can cover up
# an element temporarily. # an element temporarily.
# If this happens, wait a second, then try again # If this happens, wait a second, then try again
world.wait(1) world.wait(1)
attempt += 1
except: except:
attempt += 1 pass
return result else:
# try once more, letting exceptions raise
world.css_find(css_selector)[index].click()
if not success_condition():
raise Exception("unsuccessful click")
@world.absorb @world.absorb
...@@ -101,24 +101,24 @@ def css_check(css_selector, index=0, max_attempts=5, success_condition=lambda: T ...@@ -101,24 +101,24 @@ def css_check(css_selector, index=0, max_attempts=5, success_condition=lambda: T
This function will return True if the check worked (taking into account both errors and the optional This function will return True if the check worked (taking into account both errors and the optional
success_condition). success_condition).
""" """
assert is_css_present(css_selector) assert is_css_present(css_selector), "{} is not present".format(css_selector)
attempt = 0 for _ in range(max_attempts):
result = False
while attempt < max_attempts:
try: try:
world.css_find(css_selector)[index].check() world.css_find(css_selector)[index].check()
if success_condition(): if success_condition():
result = True return
break
except WebDriverException: except WebDriverException:
# Occasionally, MathJax or other JavaScript can cover up # Occasionally, MathJax or other JavaScript can cover up
# an element temporarily. # an element temporarily.
# If this happens, wait a second, then try again # If this happens, wait a second, then try again
world.wait(1) world.wait(1)
attempt += 1
except: except:
attempt += 1 pass
return result else:
# try once more, letting exceptions raise
world.css_find(css_selector)[index].check()
if not success_condition():
raise Exception("unsuccessful check")
@world.absorb @world.absorb
...@@ -143,7 +143,7 @@ def id_click(elem_id): ...@@ -143,7 +143,7 @@ def id_click(elem_id):
@world.absorb @world.absorb
def css_fill(css_selector, text): def css_fill(css_selector, text):
assert is_css_present(css_selector) assert is_css_present(css_selector), "{} is not present".format(css_selector)
world.browser.find_by_css(css_selector).first.fill(text) world.browser.find_by_css(css_selector).first.fill(text)
...@@ -184,7 +184,7 @@ def css_html(css_selector, index=0, max_attempts=5): ...@@ -184,7 +184,7 @@ def css_html(css_selector, index=0, max_attempts=5):
@world.absorb @world.absorb
def css_visible(css_selector): def css_visible(css_selector):
assert is_css_present(css_selector) assert is_css_present(css_selector), "{} is not present".format(css_selector)
return world.browser.find_by_css(css_selector).visible return world.browser.find_by_css(css_selector).visible
...@@ -203,10 +203,16 @@ def dialogs_closed(): ...@@ -203,10 +203,16 @@ def dialogs_closed():
def save_the_html(path='/tmp'): def save_the_html(path='/tmp'):
url = world.browser.url url = world.browser.url
html = world.browser.html.encode('ascii', 'ignore') html = world.browser.html.encode('ascii', 'ignore')
filename = '%s.html' % quote_plus(url) filename = "{path}/{name}.html".format(path=path, name=quote_plus(url))
file = open('%s/%s' % (path, filename), 'w') with open(filename, "w") as f:
file.write(html) f.write(html)
file.close()
@world.absorb
def click_course_content():
course_content_css = 'li.nav-course-courseware'
if world.browser.is_element_present_by_css(course_content_css):
world.css_click(course_content_css)
@world.absorb @world.absorb
......
from functools import wraps from functools import wraps
import copy import copy
import json import json
from django.core.serializers import serialize
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.query import QuerySet
from django.http import HttpResponse
def expect_json(view_function): def expect_json(view_function):
...@@ -21,3 +25,22 @@ def expect_json(view_function): ...@@ -21,3 +25,22 @@ def expect_json(view_function):
return view_function(request, *args, **kwargs) return view_function(request, *args, **kwargs)
return expect_json_with_cloned_request return expect_json_with_cloned_request
class JsonResponse(HttpResponse):
"""
Django HttpResponse subclass that has sensible defaults for outputting JSON.
"""
def __init__(self, object=None, status=None, encoder=DjangoJSONEncoder,
*args, **kwargs):
if object in (None, ""):
content = ""
status = status or 204
elif isinstance(object, QuerySet):
content = serialize('json', object)
else:
content = json.dumps(object, cls=encoder, indent=2, ensure_ascii=False)
kwargs.setdefault("content_type", "application/json")
if status:
kwargs["status"] = status
super(JsonResponse, self).__init__(content, *args, **kwargs)
from django.http import HttpResponse
from util.json_request import JsonResponse
import json
import unittest
import mock
class JsonResponseTestCase(unittest.TestCase):
def test_empty(self):
resp = JsonResponse()
self.assertIsInstance(resp, HttpResponse)
self.assertEqual(resp.content, "")
self.assertEqual(resp.status_code, 204)
self.assertEqual(resp["content-type"], "application/json")
def test_empty_string(self):
resp = JsonResponse("")
self.assertIsInstance(resp, HttpResponse)
self.assertEqual(resp.content, "")
self.assertEqual(resp.status_code, 204)
self.assertEqual(resp["content-type"], "application/json")
def test_string(self):
resp = JsonResponse("foo")
self.assertEqual(resp.content, '"foo"')
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp["content-type"], "application/json")
def test_dict(self):
obj = {"foo": "bar"}
resp = JsonResponse(obj)
compare = json.loads(resp.content)
self.assertEqual(obj, compare)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp["content-type"], "application/json")
def test_set_status_kwarg(self):
obj = {"error": "resource not found"}
resp = JsonResponse(obj, status=404)
compare = json.loads(resp.content)
self.assertEqual(obj, compare)
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp["content-type"], "application/json")
def test_set_status_arg(self):
obj = {"error": "resource not found"}
resp = JsonResponse(obj, 404)
compare = json.loads(resp.content)
self.assertEqual(obj, compare)
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp["content-type"], "application/json")
def test_encoder(self):
obj = [1, 2, 3]
encoder = object()
with mock.patch.object(json, "dumps", return_value="[1,2,3]") as dumps:
resp = JsonResponse(obj, encoder=encoder)
self.assertEqual(resp.status_code, 200)
compare = json.loads(resp.content)
self.assertEqual(obj, compare)
kwargs = dumps.call_args[1]
self.assertIs(kwargs["cls"], encoder)
...@@ -14,11 +14,13 @@ from PIL import Image ...@@ -14,11 +14,13 @@ from PIL import Image
class StaticContent(object): class StaticContent(object):
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None): def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None,
length=None):
self.location = loc self.location = loc
self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed
self.content_type = content_type self.content_type = content_type
self.data = data self._data = data
self.length = length
self.last_modified_at = last_modified_at self.last_modified_at = last_modified_at
self.thumbnail_location = Location(thumbnail_location) if thumbnail_location is not None else None self.thumbnail_location = Location(thumbnail_location) if thumbnail_location is not None else None
# optional information about where this file was imported from. This is needed to support import/export # optional information about where this file was imported from. This is needed to support import/export
...@@ -45,6 +47,10 @@ class StaticContent(object): ...@@ -45,6 +47,10 @@ class StaticContent(object):
def get_url_path(self): def get_url_path(self):
return StaticContent.get_url_path_from_location(self.location) return StaticContent.get_url_path_from_location(self.location)
@property
def data(self):
return self._data
@staticmethod @staticmethod
def get_url_path_from_location(location): def get_url_path_from_location(location):
if location is not None: if location is not None:
...@@ -80,6 +86,35 @@ class StaticContent(object): ...@@ -80,6 +86,35 @@ class StaticContent(object):
loc = StaticContent.compute_location(course_namespace.org, course_namespace.course, path) loc = StaticContent.compute_location(course_namespace.org, course_namespace.course, path)
return StaticContent.get_url_path_from_location(loc) return StaticContent.get_url_path_from_location(loc)
def stream_data(self):
yield self._data
class StaticContentStream(StaticContent):
def __init__(self, loc, name, content_type, stream, last_modified_at=None, thumbnail_location=None, import_path=None,
length=None):
super(StaticContentStream, self).__init__(loc, name, content_type, None, last_modified_at=last_modified_at,
thumbnail_location=thumbnail_location, import_path=import_path,
length=length)
self._stream = stream
def stream_data(self):
while True:
chunk = self._stream.read(1024)
if len(chunk) == 0:
break
yield chunk
def close(self):
self._stream.close()
def copy_to_in_mem(self):
self._stream.seek(0)
content = StaticContent(self.location, self.name, self.content_type, self._stream.read(),
last_modified_at=self.last_modified_at, thumbnail_location=self.thumbnail_location,
import_path=self.import_path, length=self.length)
return content
class ContentStore(object): class ContentStore(object):
''' '''
...@@ -113,7 +148,7 @@ class ContentStore(object): ...@@ -113,7 +148,7 @@ class ContentStore(object):
''' '''
raise NotImplementedError raise NotImplementedError
def generate_thumbnail(self, content): def generate_thumbnail(self, content, tempfile_path=None):
thumbnail_content = None thumbnail_content = None
# use a naming convention to associate originals with the thumbnail # use a naming convention to associate originals with the thumbnail
thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name) thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name)
...@@ -129,7 +164,10 @@ class ContentStore(object): ...@@ -129,7 +164,10 @@ class ContentStore(object):
# My understanding is that PIL will maintain aspect ratios while restricting # My understanding is that PIL will maintain aspect ratios while restricting
# the max-height/width to be whatever you pass in as 'size' # the max-height/width to be whatever you pass in as 'size'
# @todo: move the thumbnail size to a configuration setting?!? # @todo: move the thumbnail size to a configuration setting?!?
im = Image.open(StringIO.StringIO(content.data)) if tempfile_path is None:
im = Image.open(StringIO.StringIO(content.data))
else:
im = Image.open(tempfile_path)
# I've seen some exceptions from the PIL library when trying to save palletted # I've seen some exceptions from the PIL library when trying to save palletted
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first. # PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
......
...@@ -8,7 +8,7 @@ from xmodule.contentstore.content import XASSET_LOCATION_TAG ...@@ -8,7 +8,7 @@ from xmodule.contentstore.content import XASSET_LOCATION_TAG
import logging import logging
from .content import StaticContent, ContentStore from .content import StaticContent, ContentStore, StaticContentStream
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from fs.osfs import OSFS from fs.osfs import OSFS
import os import os
...@@ -35,8 +35,11 @@ class MongoContentStore(ContentStore): ...@@ -35,8 +35,11 @@ class MongoContentStore(ContentStore):
with self.fs.new_file(_id=id, filename=content.get_url_path(), content_type=content.content_type, with self.fs.new_file(_id=id, filename=content.get_url_path(), content_type=content.content_type,
displayname=content.name, thumbnail_location=content.thumbnail_location, displayname=content.name, thumbnail_location=content.thumbnail_location,
import_path=content.import_path) as fp: import_path=content.import_path) as fp:
if hasattr(content.data, '__iter__'):
fp.write(content.data) for chunk in content.data:
fp.write(chunk)
else:
fp.write(content.data)
return content return content
...@@ -44,20 +47,42 @@ class MongoContentStore(ContentStore): ...@@ -44,20 +47,42 @@ class MongoContentStore(ContentStore):
if self.fs.exists({"_id": id}): if self.fs.exists({"_id": id}):
self.fs.delete(id) self.fs.delete(id)
def find(self, location, throw_on_not_found=True): def find(self, location, throw_on_not_found=True, as_stream=False):
id = StaticContent.get_id_from_location(location) id = StaticContent.get_id_from_location(location)
try: try:
with self.fs.get(id) as fp: if as_stream:
return StaticContent(location, fp.displayname, fp.content_type, fp.read(), fp = self.fs.get(id)
fp.uploadDate, return StaticContentStream(location, fp.displayname, fp.content_type, fp, last_modified_at=fp.uploadDate,
thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None, thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
import_path=fp.import_path if hasattr(fp, 'import_path') else None) import_path=fp.import_path if hasattr(fp, 'import_path') else None,
length=fp.length)
else:
with self.fs.get(id) as fp:
return StaticContent(location, fp.displayname, fp.content_type, fp.read(), last_modified_at=fp.uploadDate,
thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
import_path=fp.import_path if hasattr(fp, 'import_path') else None,
length=fp.length)
except NoFile: except NoFile:
if throw_on_not_found: if throw_on_not_found:
raise NotFoundError() raise NotFoundError()
else: else:
return None return None
def get_stream(self, location):
id = StaticContent.get_id_from_location(location)
try:
handle = self.fs.get(id)
except NoFile:
raise NotFoundError()
return handle
def close_stream(self, handle):
try:
handle.close()
except:
pass
def export(self, location, output_directory): def export(self, location, output_directory):
content = self.find(location) content = self.find(location)
......
...@@ -6,6 +6,7 @@ from django.test import TestCase ...@@ -6,6 +6,7 @@ from django.test import TestCase
from django.conf import settings from django.conf import settings
import xmodule.modulestore.django import xmodule.modulestore.django
from xmodule.templates import update_templates from xmodule.templates import update_templates
from unittest.util import safe_repr
def mongo_store_config(data_dir): def mongo_store_config(data_dir):
...@@ -183,3 +184,35 @@ class ModuleStoreTestCase(TestCase): ...@@ -183,3 +184,35 @@ class ModuleStoreTestCase(TestCase):
# Call superclass implementation # Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown() super(ModuleStoreTestCase, self)._post_teardown()
def assert2XX(self, status_code, msg=None):
"""
Assert that the given value is a success status (between 200 and 299)
"""
if not 200 <= status_code < 300:
msg = self._formatMessage(msg, "%s is not a success status" % safe_repr(status_code))
raise self.failureExecption(msg)
def assert3XX(self, status_code, msg=None):
"""
Assert that the given value is a redirection status (between 300 and 399)
"""
if not 300 <= status_code < 400:
msg = self._formatMessage(msg, "%s is not a redirection status" % safe_repr(status_code))
raise self.failureExecption(msg)
def assert4XX(self, status_code, msg=None):
"""
Assert that the given value is a client error status (between 400 and 499)
"""
if not 400 <= status_code < 500:
msg = self._formatMessage(msg, "%s is not a client error status" % safe_repr(status_code))
raise self.failureExecption(msg)
def assert5XX(self, status_code, msg=None):
"""
Assert that the given value is a server error status (between 500 and 599)
"""
if not 500 <= status_code < 600:
msg = self._formatMessage(msg, "%s is not a server error status" % safe_repr(status_code))
raise self.failureExecption(msg)
window.gettext = window.ngettext = function(){}; window.gettext = window.ngettext = function(s){return s;};
(function(){var v=this,g,h,w,m,r,s,z,o,A,B;"undefined"===typeof window?(g=require("underscore"),h=require("backbone"),"undefined"!==typeof exports&&(exports=module.exports=h)):(g=v._,h=v.Backbone);w=h.Model;m=h.Collection;r=w.prototype;s=m.prototype;A=/[\.\[\]]+/g;z="change add remove reset sort destroy".split(" ");B=["reset","sort"];h.Associations={VERSION:"0.5.0"};h.Associations.Many=h.Many="Many";h.Associations.One=h.One="One";o=h.AssociatedModel=h.Associations.AssociatedModel=w.extend({relations:void 0,
_proxyCalls:void 0,get:function(a){var c=r.get.call(this,a);return c?c:this._getAttr.apply(this,arguments)},set:function(a,c,d){var b;if(g.isObject(a)||a==null){b=a;d=c}else{b={};b[a]=c}a=this._set(b,d);this._processPendingEvents();return a},_set:function(a,c){var d,b,n,f,j=this;if(!a)return this;for(d in a){b||(b={});if(d.match(A)){var k=x(d);f=g.initial(k);k=k[k.length-1];f=this.get(f);if(f instanceof o){f=b[f.cid]||(b[f.cid]={model:f,data:{}});f.data[k]=a[d]}}else{f=b[this.cid]||(b[this.cid]={model:this,
data:{}});f.data[d]=a[d]}}if(b)for(n in b){f=b[n];this._setAttr.call(f.model,f.data,c)||(j=false)}else j=this._setAttr.call(this,a,c);return j},_setAttr:function(a,c){var d;c||(c={});if(c.unset)for(d in a)a[d]=void 0;this.parents=this.parents||[];this.relations&&g.each(this.relations,function(b){var d=b.key,f=b.relatedModel,j=b.collectionType,k=b.map,i=this.attributes[d],y=i&&i.idAttribute,e,q,l,p;f&&g.isString(f)&&(f=t(f));j&&g.isString(j)&&(j=t(j));k&&g.isString(k)&&(k=t(k));q=b.options?g.extend({},
b.options,c):c;if(a[d]){e=g.result(a,d);e=k?k(e):e;if(b.type===h.Many){if(j&&!j.prototype instanceof m)throw Error("collectionType must inherit from Backbone.Collection");if(e instanceof m)l=e;else if(i){i._deferEvents=true;i.set(e,c);l=i}else{l=j?new j:this._createCollection(f);l.add(e,q)}}else if(b.type===h.One&&f)if(e instanceof o)l=e;else if(i)if(i&&e[y]&&i.get(y)===e[y]){i._deferEvents=true;i._set(e,c);l=i}else l=new f(e,q);else l=new f(e,q);if((p=a[d]=l)&&!p._proxyCallback){p._proxyCallback=
function(){return this._bubbleEvent.call(this,d,p,arguments)};p.on("all",p._proxyCallback,this)}}if(a.hasOwnProperty(d)){b=a[d];f=this.attributes[d];if(b){b.parents=b.parents||[];g.indexOf(b.parents,this)==-1&&b.parents.push(this)}else if(f&&f.parents.length>0)f.parents=g.difference(f.parents,[this])}},this);return r.set.call(this,a,c)},_bubbleEvent:function(a,c,d){var b=d[0].split(":"),n=b[0],f=d[0]=="nested-change",j=d[1],k=d[2],i=-1,h=c._proxyCalls,e,q=g.indexOf(z,n)!==-1;if(!f){g.size(b)>1&&(e=
b[1]);g.indexOf(B,n)!==-1&&(k=j);if(c instanceof m&&q&&j){var l=x(e),p=g.initial(l);(b=c.find(function(a){if(j===a)return true;if(!a)return false;var b=a.get(p);if((b instanceof o||b instanceof m)&&j===b)return true;b=a.get(l);if((b instanceof o||b instanceof m)&&j===b||b instanceof m&&k&&k===b)return true}))&&(i=c.indexOf(b))}e=a+(i!==-1&&(n==="change"||e)?"["+i+"]":"")+(e?"."+e:"");if(/\[\*\]/g.test(e))return this;b=e.replace(/\[\d+\]/g,"[*]");i=[];i.push.apply(i,d);i[0]=n+":"+e;h=c._proxyCalls=
h||{};if(this._isEventAvailable.call(this,h,e))return this;h[e]=true;if("change"===n){this._previousAttributes[a]=c._previousAttributes;this.changed[a]=c}this.trigger.apply(this,i);"change"===n&&this.get(e)!=d[2]&&this.trigger.apply(this,["nested-change",e,d[1]]);h&&e&&delete h[e];if(e!==b){i[0]=n+":"+b;this.trigger.apply(this,i)}return this}},_isEventAvailable:function(a,c){return g.find(a,function(a,b){return c.indexOf(b,c.length-b.length)!==-1})},_createCollection:function(a){var c=a;g.isString(c)&&
(c=t(c));if(c&&c.prototype instanceof o){a=new m;a.model=c}else throw Error("type must inherit from Backbone.AssociatedModel");return a},_processPendingEvents:function(){if(!this.visited){this.visited=true;this._deferEvents=false;g.each(this._pendingEvents,function(a){a.c.trigger.apply(a.c,a.a)});this._pendingEvents=[];g.each(this.relations,function(a){(a=this.attributes[a.key])&&a._processPendingEvents()},this);delete this.visited}},trigger:function(a){if(this._deferEvents){this._pendingEvents=this._pendingEvents||
[];this._pendingEvents.push({c:this,a:arguments})}else r.trigger.apply(this,arguments)},toJSON:function(a){var c,d;if(!this.visited){this.visited=true;c=r.toJSON.apply(this,arguments);this.relations&&g.each(this.relations,function(b){var h=this.attributes[b.key];if(h){d=h.toJSON(a);c[b.key]=g.isArray(d)?g.compact(d):d}},this);delete this.visited}return c},clone:function(){return new this.constructor(this.toJSON())},_getAttr:function(a){var c=this,a=x(a),d,b;if(!(g.size(a)<1)){for(b=0;b<a.length;b++){d=
a[b];if(!c)break;c=c instanceof m?isNaN(d)?void 0:c.at(d):c.attributes[d]}return c}}});var C=/[^\.\[\]]+/g,x=function(a){return a===""?[""]:g.isString(a)?a.match(C):a||[]},t=function(a){return g.reduce(a.split("."),function(a,d){return a[d]},v)},D=function(a,c,d){var b;g.find(a,function(a){if(b=g.find(a.relations,function(b){return a.get(b.key)===c},this))return true},this);return b&&b.map?b.map(d):d},u={};g.each(["set","remove","reset"],function(a){u[a]=m.prototype[a];s[a]=function(c,d){this.model.prototype instanceof
o&&this.parents&&(arguments[0]=D(this.parents,this,c));return u[a].apply(this,arguments)}});u.trigger=s.trigger;s.trigger=function(a){if(this._deferEvents){this._pendingEvents=this._pendingEvents||[];this._pendingEvents.push({c:this,a:arguments})}else u.trigger.apply(this,arguments)};s._processPendingEvents=o.prototype._processPendingEvents}).call(this);
// Generated by CoffeeScript 1.3.3
/*
jasmine-stealth 0.0.12
Makes Jasmine spies a bit more robust
site: https://github.com/searls/jasmine-stealth
*/
(function() {
var Captor, fake, root, unfakes, whatToDoWhenTheSpyGetsCalled, _,
__hasProp = {}.hasOwnProperty,
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
root = this;
_ = function(obj) {
return {
each: function(iterator) {
var item, _i, _len, _results;
_results = [];
for (_i = 0, _len = obj.length; _i < _len; _i++) {
item = obj[_i];
_results.push(iterator(item));
}
return _results;
},
isFunction: function() {
return Object.prototype.toString.call(obj) === "[object Function]";
},
isString: function() {
return Object.prototype.toString.call(obj) === "[object String]";
}
};
};
root.spyOnConstructor = function(owner, classToFake, methodsToSpy) {
var fakeClass, spies;
if (methodsToSpy == null) {
methodsToSpy = [];
}
if (_(methodsToSpy).isString()) {
methodsToSpy = [methodsToSpy];
}
spies = {
constructor: jasmine.createSpy("" + classToFake + "'s constructor")
};
fakeClass = (function() {
function _Class() {
spies.constructor.apply(this, arguments);
}
return _Class;
})();
_(methodsToSpy).each(function(methodName) {
spies[methodName] = jasmine.createSpy("" + classToFake + "#" + methodName);
return fakeClass.prototype[methodName] = function() {
return spies[methodName].apply(this, arguments);
};
});
fake(owner, classToFake, fakeClass);
return spies;
};
unfakes = [];
afterEach(function() {
_(unfakes).each(function(u) {
return u();
});
return unfakes = [];
});
fake = function(owner, thingToFake, newThing) {
var originalThing;
originalThing = owner[thingToFake];
owner[thingToFake] = newThing;
return unfakes.push(function() {
return owner[thingToFake] = originalThing;
});
};
root.stubFor = root.spyOn;
jasmine.createStub = jasmine.createSpy;
jasmine.createStubObj = function(baseName, stubbings) {
var name, obj, stubbing;
if (stubbings.constructor === Array) {
return jasmine.createSpyObj(baseName, stubbings);
} else {
obj = {};
for (name in stubbings) {
stubbing = stubbings[name];
obj[name] = jasmine.createSpy(baseName + "." + name);
if (_(stubbing).isFunction()) {
obj[name].andCallFake(stubbing);
} else {
obj[name].andReturn(stubbing);
}
}
return obj;
}
};
whatToDoWhenTheSpyGetsCalled = function(spy) {
var matchesStub, priorStubbing;
matchesStub = function(stubbing, args, context) {
switch (stubbing.type) {
case "args":
return jasmine.getEnv().equals_(stubbing.ifThis, jasmine.util.argsToArray(args));
case "context":
return jasmine.getEnv().equals_(stubbing.ifThis, context);
}
};
priorStubbing = spy.plan();
return spy.andCallFake(function() {
var i, stubbing;
i = 0;
while (i < spy._stealth_stubbings.length) {
stubbing = spy._stealth_stubbings[i];
if (matchesStub(stubbing, arguments, this)) {
if (Object.prototype.toString.call(stubbing.thenThat) === "[object Function]") {
return stubbing.thenThat();
} else {
return stubbing.thenThat;
}
}
i++;
}
return priorStubbing;
});
};
jasmine.Spy.prototype.whenContext = function(context) {
var addStubbing, spy;
spy = this;
spy._stealth_stubbings || (spy._stealth_stubbings = []);
whatToDoWhenTheSpyGetsCalled(spy);
addStubbing = function(thenThat) {
spy._stealth_stubbings.push({
type: 'context',
ifThis: context,
thenThat: thenThat
});
return spy;
};
return {
thenReturn: addStubbing,
thenCallFake: addStubbing
};
};
jasmine.Spy.prototype.when = function() {
var addStubbing, ifThis, spy;
spy = this;
ifThis = jasmine.util.argsToArray(arguments);
spy._stealth_stubbings || (spy._stealth_stubbings = []);
whatToDoWhenTheSpyGetsCalled(spy);
addStubbing = function(thenThat) {
spy._stealth_stubbings.push({
type: 'args',
ifThis: ifThis,
thenThat: thenThat
});
return spy;
};
return {
thenReturn: addStubbing,
thenCallFake: addStubbing
};
};
jasmine.Spy.prototype.mostRecentCallThat = function(callThat, context) {
var i;
i = this.calls.length - 1;
while (i >= 0) {
if (callThat.call(context || this, this.calls[i]) === true) {
return this.calls[i];
}
i--;
}
};
jasmine.Matchers.ArgThat = (function(_super) {
__extends(ArgThat, _super);
function ArgThat(matcher) {
this.matcher = matcher;
}
ArgThat.prototype.jasmineMatches = function(actual) {
return this.matcher(actual);
};
return ArgThat;
})(jasmine.Matchers.Any);
jasmine.Matchers.ArgThat.prototype.matches = jasmine.Matchers.ArgThat.prototype.jasmineMatches;
jasmine.argThat = function(expected) {
return new jasmine.Matchers.ArgThat(expected);
};
jasmine.Matchers.Capture = (function(_super) {
__extends(Capture, _super);
function Capture(captor) {
this.captor = captor;
}
Capture.prototype.jasmineMatches = function(actual) {
this.captor.value = actual;
return true;
};
return Capture;
})(jasmine.Matchers.Any);
jasmine.Matchers.Capture.prototype.matches = jasmine.Matchers.Capture.prototype.jasmineMatches;
Captor = (function() {
function Captor() {}
Captor.prototype.capture = function() {
return new jasmine.Matchers.Capture(this);
};
return Captor;
})();
jasmine.captor = function() {
return new Captor();
};
}).call(this);
...@@ -190,9 +190,54 @@ ...@@ -190,9 +190,54 @@
} }
} }
.btn-flat-outline {
@extend .t-action4;
@include transition(all .15s);
font-weight: 600;
text-align: center;
border-radius: ($baseline/4);
border: 1px solid $blue-l2;
padding: 1px ($baseline/2) 2px ($baseline/2);
background-color: $white;
color: $blue-l2;
&:hover {
border: 1px solid $blue;
background-color: $blue;
color: $white;
}
&.is-disabled,
&[disabled="disabled"]{
border: 1px solid $gray-l2;
background-color: $gray-l4;
color: $gray-l2;
pointer-events: none;
}
}
// button with no button shell until hover for understated actions
.btn-non {
@include transition(all .15s);
border: none;
border-radius: ($baseline/4);
background: none;
padding: 3px ($baseline/2);
vertical-align: middle;
color: $gray-l1;
&:hover {
background-color: $gray-l1;
color: $white;
}
span {
@extend .text-sr;
}
}
// UI archetypes - well // UI archetypes - well
.ui-well { .ui-well {
@include box-shadow(inset 0 1px 2px 1px $shadow); @include box-shadow(inset 0 1px 2px 1px $shadow);
padding: ($baseline*0.75); padding: ($baseline*0.75);
} }
...@@ -19,6 +19,7 @@ django-sekizai==0.6.1 ...@@ -19,6 +19,7 @@ django-sekizai==0.6.1
django-ses==0.4.1 django-ses==0.4.1
django-storages==1.1.5 django-storages==1.1.5
django-threaded-multihost==1.4-1 django-threaded-multihost==1.4-1
django-method-override==0.1.0
django==1.4.5 django==1.4.5
feedparser==5.1.3 feedparser==5.1.3
fs==0.4.0 fs==0.4.0
......
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