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
......
...@@ -6,8 +6,6 @@ import json ...@@ -6,8 +6,6 @@ import json
import copy import copy
import mock import mock
from django.contrib.auth.models import User
from django.test.client import Client
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.timezone import UTC from django.utils.timezone import UTC
from django.test.utils import override_settings from django.test.utils import override_settings
...@@ -17,45 +15,12 @@ from models.settings.course_details import (CourseDetails, CourseSettingsEncoder ...@@ -17,45 +15,12 @@ from models.settings.course_details import (CourseDetails, CourseSettingsEncoder
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from models.settings.course_metadata import CourseMetadata from models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.fields import Date from xmodule.fields import Date
from .utils import CourseTestCase
class CourseTestCase(ModuleStoreTestCase):
"""
Base class for test classes below.
"""
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)
course = CourseFactory.create(template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course')
self.course_location = course.location
class CourseDetailsTestCase(CourseTestCase): class CourseDetailsTestCase(CourseTestCase):
...@@ -63,8 +28,8 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -63,8 +28,8 @@ class CourseDetailsTestCase(CourseTestCase):
Tests the first course settings page (course dates, overview, etc.). Tests the first course settings page (course dates, overview, etc.).
""" """
def test_virgin_fetch(self): def test_virgin_fetch(self):
details = CourseDetails.fetch(self.course_location) details = CourseDetails.fetch(self.course.location)
self.assertEqual(details.course_location, self.course_location, "Location not copied into") self.assertEqual(details.course_location, self.course.location, "Location not copied into")
self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNotNone(details.start_date.tzinfo)
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
...@@ -75,10 +40,10 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -75,10 +40,10 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort)) self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
def test_encoder(self): def test_encoder(self):
details = CourseDetails.fetch(self.course_location) details = CourseDetails.fetch(self.course.location)
jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails) jsondetails = json.loads(jsondetails)
self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=") self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=")
self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
...@@ -91,10 +56,12 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -91,10 +56,12 @@ class CourseDetailsTestCase(CourseTestCase):
""" """
Test the encoder out of its original constrained purpose to see if it functions for general use Test the encoder out of its original constrained purpose to see if it functions for general use
""" """
details = {'location': Location(['tag', 'org', 'course', 'category', 'name']), details = {
'number': 1, 'location': Location(['tag', 'org', 'course', 'category', 'name']),
'string': 'string', 'number': 1,
'datetime': datetime.datetime.now(UTC())} 'string': 'string',
'datetime': datetime.datetime.now(UTC())
}
jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails) jsondetails = json.loads(jsondetails)
...@@ -105,7 +72,7 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -105,7 +72,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertEqual(jsondetails['string'], 'string') self.assertEqual(jsondetails['string'], 'string')
def test_update_and_fetch(self): def test_update_and_fetch(self):
jsondetails = CourseDetails.fetch(self.course_location) jsondetails = CourseDetails.fetch(self.course.location)
jsondetails.syllabus = "<a href='foo'>bar</a>" jsondetails.syllabus = "<a href='foo'>bar</a>"
# encode - decode to convert date fields and other data which changes form # encode - decode to convert date fields and other data which changes form
self.assertEqual( self.assertEqual(
...@@ -138,9 +105,9 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -138,9 +105,9 @@ class CourseDetailsTestCase(CourseTestCase):
settings_details_url = reverse( settings_details_url = reverse(
'settings_details', 'settings_details',
kwargs={ kwargs={
'org': self.course_location.org, 'org': self.course.location.org,
'name': self.course_location.name, 'name': self.course.location.name,
'course': self.course_location.course 'course': self.course.location.course
} }
) )
...@@ -162,9 +129,9 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -162,9 +129,9 @@ class CourseDetailsTestCase(CourseTestCase):
settings_details_url = reverse( settings_details_url = reverse(
'settings_details', 'settings_details',
kwargs={ kwargs={
'org': self.course_location.org, 'org': self.course.location.org,
'name': self.course_location.name, 'name': self.course.location.name,
'course': self.course_location.course 'course': self.course.location.course
} }
) )
...@@ -204,11 +171,12 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -204,11 +171,12 @@ class CourseDetailsViewTest(CourseTestCase):
return Date().to_json(dt) return Date().to_json(dt)
def test_update_and_fetch(self): def test_update_and_fetch(self):
details = CourseDetails.fetch(self.course_location) loc = self.course.location
details = CourseDetails.fetch(loc)
# resp s/b json from here on # resp s/b json from here on
url = reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course, url = reverse('course_settings', kwargs={'org': loc.org, 'course': loc.course,
'name': self.course_location.name, 'section': 'details'}) 'name': loc.name, 'section': 'details'})
resp = self.client.get(url) resp = self.client.get(url)
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get") self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get")
...@@ -251,49 +219,49 @@ class CourseGradingTest(CourseTestCase): ...@@ -251,49 +219,49 @@ class CourseGradingTest(CourseTestCase):
Tests for the course settings grading page. Tests for the course settings grading page.
""" """
def test_initial_grader(self): def test_initial_grader(self):
descriptor = get_modulestore(self.course_location).get_item(self.course_location) descriptor = get_modulestore(self.course.location).get_item(self.course.location)
test_grader = CourseGradingModel(descriptor) test_grader = CourseGradingModel(descriptor)
# ??? How much should this test bake in expectations about defaults and thus fail if defaults change? # ??? How much should this test bake in expectations about defaults and thus fail if defaults change?
self.assertEqual(self.course_location, test_grader.course_location, "Course locations") self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
def test_fetch_grader(self): def test_fetch_grader(self):
test_grader = CourseGradingModel.fetch(self.course_location.url()) test_grader = CourseGradingModel.fetch(self.course.location.url())
self.assertEqual(self.course_location, test_grader.course_location, "Course locations") self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
test_grader = CourseGradingModel.fetch(self.course_location) test_grader = CourseGradingModel.fetch(self.course.location)
self.assertEqual(self.course_location, test_grader.course_location, "Course locations") self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
for i, grader in enumerate(test_grader.graders): for i, grader in enumerate(test_grader.graders):
subgrader = CourseGradingModel.fetch_grader(self.course_location, i) subgrader = CourseGradingModel.fetch_grader(self.course.location, i)
self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal") self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal")
subgrader = CourseGradingModel.fetch_grader(self.course_location.list(), 0) subgrader = CourseGradingModel.fetch_grader(self.course.location.list(), 0)
self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list") self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list")
def test_fetch_cutoffs(self): def test_fetch_cutoffs(self):
test_grader = CourseGradingModel.fetch_cutoffs(self.course_location) test_grader = CourseGradingModel.fetch_cutoffs(self.course.location)
# ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think) # ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think)
self.assertIsNotNone(test_grader, "No cutoffs via fetch") self.assertIsNotNone(test_grader, "No cutoffs via fetch")
test_grader = CourseGradingModel.fetch_cutoffs(self.course_location.url()) test_grader = CourseGradingModel.fetch_cutoffs(self.course.location.url())
self.assertIsNotNone(test_grader, "No cutoffs via fetch with url") self.assertIsNotNone(test_grader, "No cutoffs via fetch with url")
def test_fetch_grace(self): def test_fetch_grace(self):
test_grader = CourseGradingModel.fetch_grace_period(self.course_location) test_grader = CourseGradingModel.fetch_grace_period(self.course.location)
# almost a worthless test # almost a worthless test
self.assertIn('grace_period', test_grader, "No grace via fetch") self.assertIn('grace_period', test_grader, "No grace via fetch")
test_grader = CourseGradingModel.fetch_grace_period(self.course_location.url()) test_grader = CourseGradingModel.fetch_grace_period(self.course.location.url())
self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url") self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url")
def test_update_from_json(self): def test_update_from_json(self):
test_grader = CourseGradingModel.fetch(self.course_location) test_grader = CourseGradingModel.fetch(self.course.location)
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update") self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
...@@ -307,11 +275,10 @@ class CourseGradingTest(CourseTestCase): ...@@ -307,11 +275,10 @@ class CourseGradingTest(CourseTestCase):
test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0} test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
print test_grader.grace_period, altered_grader.grace_period
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
def test_update_grader_from_json(self): def test_update_grader_from_json(self):
test_grader = CourseGradingModel.fetch(self.course_location) test_grader = CourseGradingModel.fetch(self.course.location)
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update") self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
...@@ -331,11 +298,11 @@ class CourseMetadataEditingTest(CourseTestCase): ...@@ -331,11 +298,11 @@ class CourseMetadataEditingTest(CourseTestCase):
def setUp(self): def setUp(self):
CourseTestCase.setUp(self) CourseTestCase.setUp(self)
# add in the full class too # add in the full class too
import_from_xml(get_modulestore(self.course_location), 'common/test/data/', ['full']) import_from_xml(get_modulestore(self.course.location), 'common/test/data/', ['full'])
self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]) self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])
def test_fetch_initial_fields(self): def test_fetch_initial_fields(self):
test_model = CourseMetadata.fetch(self.course_location) test_model = CourseMetadata.fetch(self.course.location)
self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
...@@ -348,17 +315,17 @@ class CourseMetadataEditingTest(CourseTestCase): ...@@ -348,17 +315,17 @@ class CourseMetadataEditingTest(CourseTestCase):
self.assertIn('xqa_key', test_model, 'xqa_key field ') self.assertIn('xqa_key', test_model, 'xqa_key field ')
def test_update_from_json(self): def test_update_from_json(self):
test_model = CourseMetadata.update_from_json(self.course_location, { test_model = CourseMetadata.update_from_json(self.course.location, {
"advertised_start": "start A", "advertised_start": "start A",
"testcenter_info": {"c": "test"}, "testcenter_info": {"c": "test"},
"days_early_for_beta": 2 "days_early_for_beta": 2
}) })
self.update_check(test_model) self.update_check(test_model)
# try fresh fetch to ensure persistence # try fresh fetch to ensure persistence
test_model = CourseMetadata.fetch(self.course_location) test_model = CourseMetadata.fetch(self.course.location)
self.update_check(test_model) self.update_check(test_model)
# now change some of the existing metadata # now change some of the existing metadata
test_model = CourseMetadata.update_from_json(self.course_location, { test_model = CourseMetadata.update_from_json(self.course.location, {
"advertised_start": "start B", "advertised_start": "start B",
"display_name": "jolly roger"} "display_name": "jolly roger"}
) )
...@@ -387,3 +354,35 @@ class CourseMetadataEditingTest(CourseTestCase): ...@@ -387,3 +354,35 @@ class CourseMetadataEditingTest(CourseTestCase):
# check for deletion effectiveness # check for deletion effectiveness
self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in') self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in') self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
class CourseGraderUpdatesTest(CourseTestCase):
def setUp(self):
super(CourseGraderUpdatesTest, self).setUp()
self.url = reverse("course_settings", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'grader_index': 0,
})
def test_get(self):
resp = self.client.get(self.url)
self.assert2XX(resp.status_code)
obj = json.loads(resp.content)
def test_delete(self):
resp = self.client.delete(self.url)
self.assert2XX(resp.status_code)
def test_post(self):
grader = {
"type": "manual",
"min_count": 5,
"drop_count": 10,
"short_label": "yo momma",
"weight": 17.3,
}
resp = self.client.post(self.url, grader)
self.assert2XX(resp.status_code)
obj = json.loads(resp.content)
...@@ -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 unittest import TestCase
from .utils import CourseTestCase
from django.core.urlresolvers import reverse
from contentstore.utils import get_modulestore
from xmodule.modulestore.inheritance import own_metadata
from contentstore.views.course import (
validate_textbooks_json, validate_textbook_json, TextbookValidationError)
class TextbookIndexTestCase(CourseTestCase):
"Test cases for the textbook index page"
def setUp(self):
"Set the URL for tests"
super(TextbookIndexTestCase, self).setUp()
self.url = reverse('textbook_index', kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
})
def test_view_index(self):
"Basic check that the textbook index page responds correctly"
resp = self.client.get(self.url)
self.assert2XX(resp.status_code)
# we don't have resp.context right now,
# due to bugs in our testing harness :(
if resp.context:
self.assertEqual(resp.context['course'], self.course)
def test_view_index_xhr(self):
"Check that we get a JSON response when requested via AJAX"
resp = self.client.get(
self.url,
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert2XX(resp.status_code)
obj = json.loads(resp.content)
self.assertEqual(self.course.pdf_textbooks, obj)
def test_view_index_xhr_content(self):
"Check that the response maps to the content of the modulestore"
content = [
{
"tab_title": "my textbook",
"url": "/abc.pdf",
"id": "992"
}, {
"tab_title": "pineapple",
"id": "0pineapple",
"chapters": [
{
"title": "The Fruit",
"url": "/a/b/fruit.pdf",
}, {
"title": "The Legend",
"url": "/b/c/legend.pdf",
}
]
}
]
self.course.pdf_textbooks = content
store = get_modulestore(self.course.location)
store.update_metadata(self.course.location, own_metadata(self.course))
resp = self.client.get(
self.url,
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert2XX(resp.status_code)
obj = json.loads(resp.content)
self.assertEqual(content, obj)
def test_view_index_xhr_post(self):
"Check that you can save information to the server"
textbooks = [
{"tab_title": "Hi, mom!"},
{"tab_title": "Textbook 2"},
]
resp = self.client.post(
self.url,
data=json.dumps(textbooks),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert2XX(resp.status_code)
# reload course
store = get_modulestore(self.course.location)
course = store.get_item(self.course.location)
# should be the same, except for added ID
no_ids = []
for textbook in course.pdf_textbooks:
del textbook["id"]
no_ids.append(textbook)
self.assertEqual(no_ids, textbooks)
def test_view_index_xhr_post_invalid(self):
"Check that you can't save invalid JSON"
resp = self.client.post(
self.url,
data="invalid",
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assert4XX(resp.status_code)
obj = json.loads(resp.content)
self.assertIn("error", obj)
class TextbookCreateTestCase(CourseTestCase):
"Test cases for creating a new PDF textbook"
def setUp(self):
"Set up a url and some textbook content for tests"
super(TextbookCreateTestCase, self).setUp()
self.url = reverse('create_textbook', kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
})
self.textbook = {
"tab_title": "Economics",
"chapters": {
"title": "Chapter 1",
"url": "/a/b/c/ch1.pdf",
}
}
def test_happy_path(self):
"Test that you can create a textbook"
resp = self.client.post(
self.url,
data=json.dumps(self.textbook),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(resp.status_code, 201)
self.assertIn("Location", resp)
textbook = json.loads(resp.content)
self.assertIn("id", textbook)
del textbook["id"]
self.assertEqual(self.textbook, textbook)
def test_get(self):
"Test that GET is not allowed"
resp = self.client.get(
self.url,
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(resp.status_code, 405)
def test_valid_id(self):
"Textbook IDs must begin with a number; try a valid one"
self.textbook["id"] = "7x5"
resp = self.client.post(
self.url,
data=json.dumps(self.textbook),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(resp.status_code, 201)
textbook = json.loads(resp.content)
self.assertEqual(self.textbook, textbook)
def test_invalid_id(self):
"Textbook IDs must begin with a number; try an invalid one"
self.textbook["id"] = "xxx"
resp = self.client.post(
self.url,
data=json.dumps(self.textbook),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assert4XX(resp.status_code)
self.assertNotIn("Location", resp)
class TextbookByIdTestCase(CourseTestCase):
"Test cases for the `textbook_by_id` view"
def setUp(self):
"Set some useful content and URLs for tests"
super(TextbookByIdTestCase, self).setUp()
self.textbook1 = {
"tab_title": "Economics",
"id": 1,
"chapters": {
"title": "Chapter 1",
"url": "/a/b/c/ch1.pdf",
}
}
self.url1 = reverse('textbook_by_id', kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'tid': 1,
})
self.textbook2 = {
"tab_title": "Algebra",
"id": 2,
"chapters": {
"title": "Chapter 11",
"url": "/a/b/ch11.pdf",
}
}
self.url2 = reverse('textbook_by_id', kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'tid': 2,
})
self.course.pdf_textbooks = [self.textbook1, self.textbook2]
self.store = get_modulestore(self.course.location)
self.store.update_metadata(self.course.location, own_metadata(self.course))
self.url_nonexist = reverse('textbook_by_id', kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'tid': 20,
})
def test_get_1(self):
"Get the first textbook"
resp = self.client.get(self.url1)
self.assert2XX(resp.status_code)
compare = json.loads(resp.content)
self.assertEqual(compare, self.textbook1)
def test_get_2(self):
"Get the second textbook"
resp = self.client.get(self.url2)
self.assert2XX(resp.status_code)
compare = json.loads(resp.content)
self.assertEqual(compare, self.textbook2)
def test_get_nonexistant(self):
"Get a nonexistent textbook"
resp = self.client.get(self.url_nonexist)
self.assertEqual(resp.status_code, 404)
def test_delete(self):
"Delete a textbook by ID"
resp = self.client.delete(self.url1)
self.assert2XX(resp.status_code)
course = self.store.get_item(self.course.location)
self.assertEqual(course.pdf_textbooks, [self.textbook2])
def test_delete_nonexistant(self):
"Delete a textbook by ID, when the ID doesn't match an existing textbook"
resp = self.client.delete(self.url_nonexist)
self.assertEqual(resp.status_code, 404)
course = self.store.get_item(self.course.location)
self.assertEqual(course.pdf_textbooks, [self.textbook1, self.textbook2])
def test_create_new_by_id(self):
"Create a textbook by ID"
textbook = {
"tab_title": "a new textbook",
"url": "supercool.pdf",
"id": "1supercool",
}
url = reverse("textbook_by_id", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'tid': "1supercool",
})
resp = self.client.post(
url,
data=json.dumps(textbook),
content_type="application/json",
)
self.assertEqual(resp.status_code, 201)
resp2 = self.client.get(url)
self.assert2XX(resp2.status_code)
compare = json.loads(resp2.content)
self.assertEqual(compare, textbook)
course = self.store.get_item(self.course.location)
self.assertEqual(
course.pdf_textbooks,
[self.textbook1, self.textbook2, textbook]
)
def test_replace_by_id(self):
"Create a textbook by ID, overwriting an existing textbook ID"
replacement = {
"tab_title": "You've been replaced!",
"url": "supercool.pdf",
"id": "2",
}
resp = self.client.post(
self.url2,
data=json.dumps(replacement),
content_type="application/json",
)
self.assertEqual(resp.status_code, 201)
resp2 = self.client.get(self.url2)
self.assert2XX(resp2.status_code)
compare = json.loads(resp2.content)
self.assertEqual(compare, replacement)
course = self.store.get_item(self.course.location)
self.assertEqual(
course.pdf_textbooks,
[self.textbook1, replacement]
)
class TextbookValidationTestCase(TestCase):
"Tests for the code to validate the structure of a PDF textbook"
def setUp(self):
"Set some useful content for tests"
self.tb1 = {
"tab_title": "Hi, mom!",
"url": "/mom.pdf"
}
self.tb2 = {
"tab_title": "Hi, dad!",
"chapters": [
{
"title": "Baseball",
"url": "baseball.pdf",
}, {
"title": "Basketball",
"url": "crazypants.pdf",
}
]
}
self.textbooks = [self.tb1, self.tb2]
def test_happy_path_plural(self):
"Test that the plural validator works properly"
result = validate_textbooks_json(json.dumps(self.textbooks))
self.assertEqual(self.textbooks, result)
def test_happy_path_singular_1(self):
"Test that the singular validator works properly"
result = validate_textbook_json(json.dumps(self.tb1))
self.assertEqual(self.tb1, result)
def test_happy_path_singular_2(self):
"Test that the singular validator works properly, with different data"
result = validate_textbook_json(json.dumps(self.tb2))
self.assertEqual(self.tb2, result)
def test_valid_id(self):
"Test that a valid ID doesn't trip the validator, and comes out unchanged"
self.tb1["id"] = 1
result = validate_textbook_json(json.dumps(self.tb1))
self.assertEqual(self.tb1, result)
def test_invalid_id(self):
"Test that an invalid ID trips the validator"
self.tb1["id"] = "abc"
with self.assertRaises(TextbookValidationError):
validate_textbook_json(json.dumps(self.tb1))
def test_invalid_json_plural(self):
"Test that invalid JSON trips the plural validator"
with self.assertRaises(TextbookValidationError):
validate_textbooks_json("[{'abc'}]")
def test_invalid_json_singular(self):
"Test that invalid JSON trips the singluar validator"
with self.assertRaises(TextbookValidationError):
validate_textbook_json("[{1]}")
def test_wrong_json_plural(self):
"Test that a JSON object trips the plural validators (requires a list)"
with self.assertRaises(TextbookValidationError):
validate_textbooks_json('{"tab_title": "Hi, mom!"}')
def test_wrong_json_singular(self):
"Test that a JSON list trips the plural validators (requires an object)"
with self.assertRaises(TextbookValidationError):
validate_textbook_json('[{"tab_title": "Hi, mom!"}, {"tab_title": "Hi, dad!"}]')
def test_no_tab_title_plural(self):
"Test that `tab_title` is required for the plural validator"
with self.assertRaises(TextbookValidationError):
validate_textbooks_json('[{"url": "/textbook.pdf"}]')
def test_no_tab_title_singular(self):
"Test that `tab_title` is required for the singular validator"
with self.assertRaises(TextbookValidationError):
validate_textbook_json('{"url": "/textbook.pdf"}')
def test_duplicate_ids(self):
"Test that duplicate IDs in the plural validator trips the validator"
textbooks = [{
"tab_title": "name one",
"url": "one.pdf",
"id": 1,
}, {
"tab_title": "name two",
"url": "two.pdf",
"id": 1,
}]
with self.assertRaises(TextbookValidationError):
validate_textbooks_json(json.dumps(textbooks))
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()
""" """
Views related to operations on course objects Views related to operations on course objects
""" """
#pylint: disable=W0402
import json import json
import random
import string
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
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
from django.views.decorators.http import require_http_methods, require_POST
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponse, HttpResponseBadRequest
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest
from util.json_request import JsonResponse
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, \ from xmodule.modulestore.inheritance import own_metadata
InvalidLocationError
from xmodule.modulestore.exceptions import (
ItemNotFoundError, InvalidLocationError)
from xmodule.modulestore import Location from xmodule.modulestore import Location
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update from contentstore.course_info_model import (
from contentstore.utils import get_lms_link_for_item, add_extra_panel_tab, remove_extra_panel_tab get_course_updates, update_course_updates, delete_course_update)
from models.settings.course_details import CourseDetails, CourseSettingsEncoder from contentstore.utils import (
get_lms_link_for_item, add_extra_panel_tab, remove_extra_panel_tab,
get_modulestore)
from models.settings.course_details import (
CourseDetails, CourseSettingsEncoder)
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
from models.settings.course_metadata import CourseMetadata from models.settings.course_metadata import CourseMetadata
from auth.authz import create_all_course_groups, is_user_in_creator_group from auth.authz import create_all_course_groups, is_user_in_creator_group
from util.json_request import expect_json from util.json_request import expect_json
from .access import has_access, get_location_and_verify_access from .access import has_access, get_location_and_verify_access
from .requests import get_request_method
from .tabs import initialize_course_tabs from .tabs import initialize_course_tabs
from .component import OPEN_ENDED_COMPONENT_TYPES, \ from .component import (
NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY OPEN_ENDED_COMPONENT_TYPES, NOTE_COMPONENT_TYPES,
ADVANCED_COMPONENT_POLICY_KEY)
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
import datetime import datetime
...@@ -39,7 +51,8 @@ __all__ = ['course_index', 'create_new_course', 'course_info', ...@@ -39,7 +51,8 @@ __all__ = ['course_index', 'create_new_course', 'course_info',
'course_config_advanced_page', 'course_config_advanced_page',
'course_settings_updates', 'course_settings_updates',
'course_grader_updates', 'course_grader_updates',
'course_advanced_updates'] 'course_advanced_updates', 'textbook_index', 'textbook_by_id',
'create_textbook']
@login_required @login_required
...@@ -64,7 +77,6 @@ def course_index(request, org, course, name): ...@@ -64,7 +77,6 @@ def course_index(request, org, course, name):
sections = course.get_children() sections = course.get_children()
return render_to_response('overview.html', { return render_to_response('overview.html', {
'active_tab': 'courseware',
'context_course': course, 'context_course': course,
'lms_link': lms_link, 'lms_link': lms_link,
'sections': sections, 'sections': sections,
...@@ -80,7 +92,9 @@ def course_index(request, org, course, name): ...@@ -80,7 +92,9 @@ def course_index(request, org, course, name):
@login_required @login_required
@expect_json @expect_json
def create_new_course(request): def create_new_course(request):
"""
Create a new course
"""
if not is_user_in_creator_group(request.user): if not is_user_in_creator_group(request.user):
raise PermissionDenied() raise PermissionDenied()
...@@ -97,8 +111,9 @@ def create_new_course(request): ...@@ -97,8 +111,9 @@ def create_new_course(request):
try: try:
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name)) dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
except InvalidLocationError as error: except InvalidLocationError as error:
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" + return JsonResponse({
display_name + "'.\n\n" + error.message})) "ErrMsg": "Unable to create course '{name}'.\n\n{err}".format(
name=display_name, err=error.message)})
# see if the course already exists # see if the course already exists
existing_course = None existing_course = None
...@@ -108,13 +123,13 @@ def create_new_course(request): ...@@ -108,13 +123,13 @@ def create_new_course(request):
pass pass
if existing_course is not None: if existing_course is not None:
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with this name.'})) return JsonResponse({'ErrMsg': 'There is already a course defined with this name.'})
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None] course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
courses = modulestore().get_items(course_search_location) courses = modulestore().get_items(course_search_location)
if len(courses) > 0: if len(courses) > 0:
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with the same organization and course number.'})) return JsonResponse({'ErrMsg': 'There is already a course defined with the same organization and course number.'})
new_course = modulestore('direct').clone_item(template, dest_location) new_course = modulestore('direct').clone_item(template, dest_location)
...@@ -137,7 +152,7 @@ def create_new_course(request): ...@@ -137,7 +152,7 @@ def create_new_course(request):
# seed the forums # seed the forums
seed_permissions_roles(new_course.location.course_id) seed_permissions_roles(new_course.location.course_id)
return HttpResponse(json.dumps({'id': new_course.location.url()})) return JsonResponse({'id': new_course.location.url()})
@login_required @login_required
...@@ -156,7 +171,6 @@ def course_info(request, org, course, name, provided_id=None): ...@@ -156,7 +171,6 @@ def course_info(request, org, course, name, provided_id=None):
location = Location(['i4x', org, course, 'course_info', "updates"]) location = Location(['i4x', org, course, 'course_info', "updates"])
return render_to_response('course_info.html', { return render_to_response('course_info.html', {
'active_tab': 'courseinfo-tab',
'context_course': course_module, 'context_course': course_module,
'url_base': "/" + org + "/" + course + "/", 'url_base': "/" + org + "/" + course + "/",
'course_updates': json.dumps(get_course_updates(location)), 'course_updates': json.dumps(get_course_updates(location)),
...@@ -187,22 +201,17 @@ def course_info_updates(request, org, course, provided_id=None): ...@@ -187,22 +201,17 @@ def course_info_updates(request, org, course, provided_id=None):
if not has_access(request.user, location): if not has_access(request.user, location):
raise PermissionDenied() raise PermissionDenied()
real_method = get_request_method(request)
if request.method == 'GET': if request.method == 'GET':
return HttpResponse(json.dumps(get_course_updates(location)), return JsonResponse(get_course_updates(location))
mimetype="application/json") elif request.method == 'DELETE':
elif real_method == 'DELETE':
try: try:
return HttpResponse(json.dumps(delete_course_update(location, return JsonResponse(delete_course_update(location, request.POST, provided_id))
request.POST, provided_id)), mimetype="application/json")
except: except:
return HttpResponseBadRequest("Failed to delete", return HttpResponseBadRequest("Failed to delete",
content_type="text/plain") content_type="text/plain")
elif request.method == 'POST': elif request.method == 'POST':
try: try:
return HttpResponse(json.dumps(update_course_updates(location, return JsonResponse(update_course_updates(location, request.POST, provided_id))
request.POST, provided_id)), mimetype="application/json")
except: except:
return HttpResponseBadRequest("Failed to save", return HttpResponseBadRequest("Failed to save",
content_type="text/plain") content_type="text/plain")
...@@ -293,14 +302,13 @@ def course_settings_updates(request, org, course, name, section): ...@@ -293,14 +302,13 @@ def course_settings_updates(request, org, course, name, section):
if request.method == 'GET': if request.method == 'GET':
# Cannot just do a get w/o knowing the course name :-( # Cannot just do a get w/o knowing the course name :-(
return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course', name])), cls=CourseSettingsEncoder), return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder)
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(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder)
mimetype="application/json")
@expect_json @expect_json
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def course_grader_updates(request, org, course, name, grader_index=None): def course_grader_updates(request, org, course, name, grader_index=None):
...@@ -313,22 +321,19 @@ def course_grader_updates(request, org, course, name, grader_index=None): ...@@ -313,22 +321,19 @@ def course_grader_updates(request, org, course, name, grader_index=None):
location = get_location_and_verify_access(request, org, course, name) location = get_location_and_verify_access(request, org, course, name)
real_method = get_request_method(request) if request.method == 'GET':
if real_method == 'GET':
# Cannot just do a get w/o knowing the course name :-( # Cannot just do a get w/o knowing the course name :-(
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(location), grader_index)), return JsonResponse(CourseGradingModel.fetch_grader(Location(location), grader_index))
mimetype="application/json") elif request.method == "DELETE":
elif real_method == "DELETE":
# ??? Should this return anything? Perhaps success fail? # ??? Should this return anything? Perhaps success fail?
CourseGradingModel.delete_grader(Location(location), grader_index) CourseGradingModel.delete_grader(Location(location), grader_index)
return HttpResponse() return JsonResponse()
elif request.method == 'POST': # post or put, doesn't matter. else: # post or put, doesn't matter.
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(location), request.POST)), return JsonResponse(CourseGradingModel.update_grader_from_json(Location(location), request.POST))
mimetype="application/json")
# # NB: expect_json failed on ["key", "key2"] and json payload # # NB: expect_json failed on ["key", "key2"] and json payload
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def course_advanced_updates(request, org, course, name): def course_advanced_updates(request, org, course, name):
...@@ -340,16 +345,11 @@ def course_advanced_updates(request, org, course, name): ...@@ -340,16 +345,11 @@ def course_advanced_updates(request, org, course, name):
""" """
location = get_location_and_verify_access(request, org, course, name) location = get_location_and_verify_access(request, org, course, name)
real_method = get_request_method(request) if request.method == 'GET':
return JsonResponse(CourseMetadata.fetch(location))
if real_method == 'GET': elif request.method == 'DELETE':
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), return JsonResponse(CourseMetadata.delete_key(location, json.loads(request.body)))
mimetype="application/json") else:
elif real_method == 'DELETE':
return HttpResponse(json.dumps(CourseMetadata.delete_key(location,
json.loads(request.body))),
mimetype="application/json")
elif real_method == 'POST' or real_method == 'PUT':
# NOTE: request.POST is messed up because expect_json # NOTE: request.POST is messed up because expect_json
# cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key # cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
request_body = json.loads(request.body) request_body = json.loads(request.body)
...@@ -401,10 +401,204 @@ def course_advanced_updates(request, org, course, name): ...@@ -401,10 +401,204 @@ def course_advanced_updates(request, org, course, name):
# Indicate that tabs should *not* be filtered out of the metadata # Indicate that tabs should *not* be filtered out of the metadata
filter_tabs = False filter_tabs = False
try: try:
response_json = json.dumps(CourseMetadata.update_from_json(location, return JsonResponse(CourseMetadata.update_from_json(location,
request_body, request_body,
filter_tabs=filter_tabs)) filter_tabs=filter_tabs))
except (TypeError, ValueError), e: except (TypeError, ValueError) as err:
return HttpResponseBadRequest("Incorrect setting format. " + str(e), content_type="text/plain") return HttpResponseBadRequest("Incorrect setting format. " + str(err), content_type="text/plain")
class TextbookValidationError(Exception):
"An error thrown when a textbook input is invalid"
pass
def validate_textbooks_json(text):
"""
Validate the given text as representing a single PDF textbook
"""
try:
textbooks = json.loads(text)
except ValueError:
raise TextbookValidationError("invalid JSON")
if not isinstance(textbooks, (list, tuple)):
raise TextbookValidationError("must be JSON list")
for textbook in textbooks:
validate_textbook_json(textbook)
# check specified IDs for uniqueness
all_ids = [textbook["id"] for textbook in textbooks if "id" in textbook]
unique_ids = set(all_ids)
if len(all_ids) > len(unique_ids):
raise TextbookValidationError("IDs must be unique")
return textbooks
def validate_textbook_json(textbook):
"""
Validate the given text as representing a list of PDF textbooks
"""
if isinstance(textbook, basestring):
try:
textbook = json.loads(textbook)
except ValueError:
raise TextbookValidationError("invalid JSON")
if not isinstance(textbook, dict):
raise TextbookValidationError("must be JSON object")
if not textbook.get("tab_title"):
raise TextbookValidationError("must have tab_title")
tid = str(textbook.get("id", ""))
if tid and not tid[0].isdigit():
raise TextbookValidationError("textbook ID must start with a digit")
return textbook
def assign_textbook_id(textbook, used_ids=()):
"""
Return an ID that can be assigned to a textbook
and doesn't match the used_ids
"""
tid = Location.clean(textbook["tab_title"])
if not tid[0].isdigit():
# stick a random digit in front
tid = random.choice(string.digits) + tid
while tid in used_ids:
# add a random ASCII character to the end
tid = tid + random.choice(string.ascii_lowercase)
return tid
@login_required
@ensure_csrf_cookie
def textbook_index(request, org, course, name):
"""
Display an editable textbook overview.
org, course, name: Attributes of the Location for the item to edit
"""
location = get_location_and_verify_access(request, org, course, name)
store = get_modulestore(location)
course_module = store.get_item(location, depth=3)
if request.is_ajax():
if request.method == 'GET':
return JsonResponse(course_module.pdf_textbooks)
elif request.method == 'POST':
try:
textbooks = validate_textbooks_json(request.body)
except TextbookValidationError as err:
return JsonResponse({"error": err.message}, status=400)
tids = set(t["id"] for t in textbooks if "id" in t)
for textbook in textbooks:
if not "id" in textbook:
tid = assign_textbook_id(textbook, tids)
textbook["id"] = tid
tids.add(tid)
if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs):
course_module.tabs.append({"type": "pdf_textbooks"})
course_module.pdf_textbooks = textbooks
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(course_module.pdf_textbooks)
else:
upload_asset_url = reverse('upload_asset', kwargs={
'org': org,
'course': course,
'coursename': name,
})
textbook_url = reverse('textbook_index', kwargs={
'org': org,
'course': course,
'name': name,
})
return render_to_response('textbooks.html', {
'context_course': course_module,
'course': course_module,
'upload_asset_url': upload_asset_url,
'textbook_url': textbook_url,
})
@require_POST
@login_required
@ensure_csrf_cookie
def create_textbook(request, org, course, name):
"""
JSON API endpoint for creating a textbook. Used by the Backbone application.
"""
location = get_location_and_verify_access(request, org, course, name)
store = get_modulestore(location)
course_module = store.get_item(location, depth=0)
return HttpResponse(response_json, mimetype="application/json") try:
textbook = validate_textbook_json(request.body)
except TextbookValidationError as err:
return JsonResponse({"error": err.message}, status=400)
if not textbook.get("id"):
tids = set(t["id"] for t in course_module.pdf_textbooks if "id" in t)
textbook["id"] = assign_textbook_id(textbook, tids)
existing = course_module.pdf_textbooks
existing.append(textbook)
course_module.pdf_textbooks = existing
if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs):
tabs = course_module.tabs
tabs.append({"type": "pdf_textbooks"})
course_module.tabs = tabs
store.update_metadata(course_module.location, own_metadata(course_module))
resp = JsonResponse(textbook, status=201)
resp["Location"] = reverse("textbook_by_id", kwargs={
'org': org,
'course': course,
'name': name,
'tid': textbook["id"],
})
return resp
@login_required
@ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
def textbook_by_id(request, org, course, name, tid):
"""
JSON API endpoint for manipulating a textbook via its internal ID.
Used by the Backbone application.
"""
location = get_location_and_verify_access(request, org, course, name)
store = get_modulestore(location)
course_module = store.get_item(location, depth=3)
matching_id = [tb for tb in course_module.pdf_textbooks
if str(tb.get("id")) == str(tid)]
if matching_id:
textbook = matching_id[0]
else:
textbook = None
if request.method == 'GET':
if not textbook:
return JsonResponse(status=404)
return JsonResponse(textbook)
elif request.method in ('POST', 'PUT'):
try:
new_textbook = validate_textbook_json(request.body)
except TextbookValidationError as err:
return JsonResponse({"error": err.message}, status=400)
new_textbook["id"] = tid
if textbook:
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
new_textbooks.append(new_textbook)
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
course_module.pdf_textbooks = new_textbooks
else:
course_module.pdf_textbooks.append(new_textbook)
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(new_textbook, status=201)
elif request.method == 'DELETE':
if not textbook:
return JsonResponse(status=404)
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
course_module.pdf_textbooks = new_textbooks
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse()
#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
......
feedbackTpl = readFixtures('system-feedback.underscore')
beforeEach ->
# remove this when we upgrade jasmine-jquery
@addMatchers
toContainText: (text) ->
trimmedText = $.trim(@actual.text())
if text and $.isFunction(text.test)
return text.test(trimmedText)
else
return trimmedText.indexOf(text) != -1;
describe "CMS.Views.ShowTextbook", ->
tpl = readFixtures('show-textbook.underscore')
beforeEach ->
setFixtures($("<script>", {id: "show-textbook-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
appendSetFixtures(sandbox({id: "page-notification"}))
appendSetFixtures(sandbox({id: "page-prompt"}))
@model = new CMS.Models.Textbook({name: "Life Sciences", id: "0life-sciences"})
spyOn(@model, "destroy").andCallThrough()
@collection = new CMS.Collections.TextbookSet([@model])
@view = new CMS.Views.ShowTextbook({model: @model})
@promptSpies = spyOnConstructor(CMS.Views.Prompt, "Warning", ["show", "hide"])
@promptSpies.show.andReturn(@promptSpies)
window.section = new CMS.Models.Section({
id: "5",
name: "Course Name",
url_name: "course_name",
org: "course_org",
num: "course_num",
revision: "course_rev"
});
afterEach ->
delete window.section
describe "Basic", ->
it "should render properly", ->
@view.render()
expect(@view.$el).toContainText("Life Sciences")
it "should set the 'editing' property on the model when the edit button is clicked", ->
@view.render().$(".edit").click()
expect(@model.get("editing")).toBeTruthy()
it "should pop a delete confirmation when the delete button is clicked", ->
@view.render().$(".delete").click()
expect(@promptSpies.constructor).toHaveBeenCalled()
ctorOptions = @promptSpies.constructor.mostRecentCall.args[0]
expect(ctorOptions.title).toMatch(/Life Sciences/)
# hasn't actually been removed
expect(@model.destroy).not.toHaveBeenCalled()
expect(@collection).toContain(@model)
it "should show chapters appropriately", ->
@model.get("chapters").add([{}, {}, {}])
@model.set('showChapters', false)
@view.render().$(".show-chapters").click()
expect(@model.get('showChapters')).toBeTruthy()
it "should hide chapters appropriately", ->
@model.get("chapters").add([{}, {}, {}])
@model.set('showChapters', true)
@view.render().$(".hide-chapters").click()
expect(@model.get('showChapters')).toBeFalsy()
describe "AJAX", ->
beforeEach ->
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
@savingSpies = spyOnConstructor(CMS.Views.Notification, "Saving",
["show", "hide"])
@savingSpies.show.andReturn(@savingSpies)
afterEach ->
@xhr.restore()
it "should destroy itself on confirmation", ->
@view.render().$(".delete").click()
ctorOptions = @promptSpies.constructor.mostRecentCall.args[0]
# run the primary function to indicate confirmation
ctorOptions.actions.primary.click(@promptSpies)
# AJAX request has been sent, but not yet returned
expect(@model.destroy).toHaveBeenCalled()
expect(@requests.length).toEqual(1)
expect(@savingSpies.constructor).toHaveBeenCalled()
expect(@savingSpies.show).toHaveBeenCalled()
expect(@savingSpies.hide).not.toHaveBeenCalled()
savingOptions = @savingSpies.constructor.mostRecentCall.args[0]
expect(savingOptions.title).toMatch(/Deleting/)
# return a success response
@requests[0].respond(200)
expect(@savingSpies.hide).toHaveBeenCalled()
expect(@collection.contains(@model)).toBeFalsy()
describe "CMS.Views.EditTextbook", ->
describe "Basic", ->
tpl = readFixtures('edit-textbook.underscore')
chapterTpl = readFixtures('edit-chapter.underscore')
beforeEach ->
setFixtures($("<script>", {id: "edit-textbook-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "edit-chapter-tpl", type: "text/template"}).text(chapterTpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
appendSetFixtures(sandbox({id: "page-notification"}))
appendSetFixtures(sandbox({id: "page-prompt"}))
@model = new CMS.Models.Textbook({name: "Life Sciences", editing: true})
spyOn(@model, 'save')
@collection = new CMS.Collections.TextbookSet()
@collection.add(@model)
@view = new CMS.Views.EditTextbook({model: @model})
spyOn(@view, 'render').andCallThrough()
it "should render properly", ->
@view.render()
expect(@view.$("input[name=textbook-name]").val()).toEqual("Life Sciences")
it "should allow you to create new empty chapters", ->
@view.render()
numChapters = @model.get("chapters").length
@view.$(".action-add-chapter").click()
expect(@model.get("chapters").length).toEqual(numChapters+1)
expect(@model.get("chapters").last().isEmpty()).toBeTruthy()
it "should save properly", ->
@view.render()
@view.$("input[name=textbook-name]").val("starfish")
@view.$("input[name=chapter1-name]").val("wallflower")
@view.$("input[name=chapter1-asset-path]").val("foobar")
@view.$("form").submit()
expect(@model.get("name")).toEqual("starfish")
chapter = @model.get("chapters").first()
expect(chapter.get("name")).toEqual("wallflower")
expect(chapter.get("asset_path")).toEqual("foobar")
expect(@model.save).toHaveBeenCalled()
it "should not save on invalid", ->
@view.render()
@view.$("input[name=textbook-name]").val("")
@view.$("input[name=chapter1-asset-path]").val("foobar.pdf")
@view.$("form").submit()
expect(@model.validationError).toBeTruthy()
expect(@model.save).not.toHaveBeenCalled()
it "does not save on cancel", ->
@model.get("chapters").add([{name: "a", asset_path: "b"}])
@view.render()
@view.$("input[name=textbook-name]").val("starfish")
@view.$("input[name=chapter1-asset-path]").val("foobar.pdf")
@view.$(".action-cancel").click()
expect(@model.get("name")).not.toEqual("starfish")
chapter = @model.get("chapters").first()
expect(chapter.get("asset_path")).not.toEqual("foobar")
expect(@model.save).not.toHaveBeenCalled()
it "should be possible to correct validation errors", ->
@view.render()
@view.$("input[name=textbook-name]").val("")
@view.$("input[name=chapter1-asset-path]").val("foobar.pdf")
@view.$("form").submit()
expect(@model.validationError).toBeTruthy()
expect(@model.save).not.toHaveBeenCalled()
@view.$("input[name=textbook-name]").val("starfish")
@view.$("input[name=chapter1-name]").val("foobar")
@view.$("form").submit()
expect(@model.validationError).toBeFalsy()
expect(@model.save).toHaveBeenCalled()
it "removes all empty chapters on cancel if the model has a non-empty chapter", ->
chapters = @model.get("chapters")
chapters.at(0).set("name", "non-empty")
@model.setOriginalAttributes()
@view.render()
chapters.add([{}, {}, {}]) # add three empty chapters
expect(chapters.length).toEqual(4)
@view.$(".action-cancel").click()
expect(chapters.length).toEqual(1)
expect(chapters.first().get('name')).toEqual("non-empty")
it "removes all empty chapters on cancel except one if the model has no non-empty chapters", ->
chapters = @model.get("chapters")
@view.render()
chapters.add([{}, {}, {}]) # add three empty chapters
expect(chapters.length).toEqual(4)
@view.$(".action-cancel").click()
expect(chapters.length).toEqual(1)
describe "CMS.Views.ListTextbooks", ->
noTextbooksTpl = readFixtures("no-textbooks.underscore")
beforeEach ->
setFixtures($("<script>", {id: "no-textbooks-tpl", type: "text/template"}).text(noTextbooksTpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
@showSpies = spyOnConstructor(CMS.Views, "ShowTextbook", ["render"])
@showSpies.render.andReturn(@showSpies) # equivalent of `return this`
showEl = $("<li>")
@showSpies.$el = showEl
@showSpies.el = showEl.get(0)
@editSpies = spyOnConstructor(CMS.Views, "EditTextbook", ["render"])
editEl = $("<li>")
@editSpies.render.andReturn(@editSpies)
@editSpies.$el = editEl
@editSpies.el= editEl.get(0)
@collection = new CMS.Collections.TextbookSet
@view = new CMS.Views.ListTextbooks({collection: @collection})
@view.render()
it "should render the empty template if there are no textbooks", ->
expect(@view.$el).toContainText("You haven't added any textbooks to this course yet")
expect(@view.$el).toContain(".new-button")
expect(@showSpies.constructor).not.toHaveBeenCalled()
expect(@editSpies.constructor).not.toHaveBeenCalled()
it "should render ShowTextbook views by default if no textbook is being edited", ->
# add three empty textbooks to the collection
@collection.add([{}, {}, {}])
# reset spies due to re-rendering on collection modification
@showSpies.constructor.reset()
@editSpies.constructor.reset()
# render once and test
@view.render()
expect(@view.$el).not.toContainText(
"You haven't added any textbooks to this course yet")
expect(@showSpies.constructor).toHaveBeenCalled()
expect(@showSpies.constructor.calls.length).toEqual(3);
expect(@editSpies.constructor).not.toHaveBeenCalled()
it "should render an EditTextbook view for a textbook being edited", ->
# add three empty textbooks to the collection: the first and third
# should be shown, and the second should be edited
@collection.add([{editing: false}, {editing: true}, {editing: false}])
editing = @collection.at(1)
expect(editing.get("editing")).toBeTruthy()
# reset spies
@showSpies.constructor.reset()
@editSpies.constructor.reset()
# render once and test
@view.render()
expect(@showSpies.constructor).toHaveBeenCalled()
expect(@showSpies.constructor.calls.length).toEqual(2)
expect(@showSpies.constructor).not.toHaveBeenCalledWith({model: editing})
expect(@editSpies.constructor).toHaveBeenCalled()
expect(@editSpies.constructor.calls.length).toEqual(1)
expect(@editSpies.constructor).toHaveBeenCalledWith({model: editing})
it "should add a new textbook when the new-button is clicked", ->
# reset spies
@showSpies.constructor.reset()
@editSpies.constructor.reset()
# test
@view.$(".new-button").click()
expect(@collection.length).toEqual(1)
expect(@view.$el).toContain(@editSpies.$el)
expect(@view.$el).not.toContain(@showSpies.$el)
describe "CMS.Views.EditChapter", ->
tpl = readFixtures("edit-chapter.underscore")
beforeEach ->
setFixtures($("<script>", {id: "edit-chapter-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
@model = new CMS.Models.Chapter
name: "Chapter 1"
asset_path: "/ch1.pdf"
@collection = new CMS.Collections.ChapterSet()
@collection.add(@model)
@view = new CMS.Views.EditChapter({model: @model})
spyOn(@view, "remove").andCallThrough()
CMS.URL.UPLOAD_ASSET = "/upload"
window.section = new CMS.Models.Section({name: "abcde"})
afterEach ->
delete CMS.URL.UPLOAD_ASSET
delete window.section
it "can render", ->
@view.render()
expect(@view.$("input.chapter-name").val()).toEqual("Chapter 1")
expect(@view.$("input.chapter-asset-path").val()).toEqual("/ch1.pdf")
it "can delete itself", ->
@view.render().$(".action-close").click()
expect(@collection.length).toEqual(0)
expect(@view.remove).toHaveBeenCalled()
it "can open an upload dialog", ->
uploadSpies = spyOnConstructor(CMS.Views, "UploadDialog", ["show", "el"])
uploadSpies.show.andReturn(uploadSpies)
@view.render().$(".action-upload").click()
ctorOptions = uploadSpies.constructor.mostRecentCall.args[0]
expect(ctorOptions.model.get('title')).toMatch(/abcde/)
expect(ctorOptions.chapter).toBe(@model)
expect(uploadSpies.show).toHaveBeenCalled()
it "saves content when opening upload dialog", ->
@view.render()
@view.$("input.chapter-name").val("rainbows")
@view.$("input.chapter-asset-path").val("unicorns")
@view.$(".action-upload").click()
expect(@model.get("name")).toEqual("rainbows")
expect(@model.get("asset_path")).toEqual("unicorns")
describe "CMS.Views.UploadDialog", ->
tpl = readFixtures("upload-dialog.underscore")
beforeEach ->
setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
CMS.URL.UPLOAD_ASSET = "/upload"
@model = new CMS.Models.FileUpload()
@chapter = new CMS.Models.Chapter()
@view = new CMS.Views.UploadDialog({model: @model, chapter: @chapter})
spyOn(@view, 'remove').andCallThrough()
# create mock file input, so that we aren't subject to browser restrictions
@mockFiles = []
mockFileInput = jasmine.createSpy('mockFileInput')
mockFileInput.files = @mockFiles
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
jqMockFileInput.get.andReturn(mockFileInput)
realMethod = @view.$
spyOn(@view, "$").andCallFake (selector) ->
if selector == "input[type=file]"
jqMockFileInput
else
realMethod.apply(this, arguments)
afterEach ->
delete CMS.URL.UPLOAD_ASSET
describe "Basic", ->
it "should be shown by default", ->
expect(@view.options.shown).toBeTruthy()
it "should render without a file selected", ->
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "should render with a PDF selected", ->
file = {name: "fake.pdf", "type": "application/pdf"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).not.toContain("#upload_error")
expect(@view.$(".action-upload")).not.toHaveClass("disabled")
it "should render an error with an invalid file type selected", ->
file = {name: "fake.png", "type": "image/png"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).toContain("#upload_error")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "adds body class on show()", ->
@view.show()
expect(@view.options.shown).toBeTruthy()
# can't test: this blows up the spec runner
# expect($("body")).toHaveClass("dialog-is-shown")
it "removes body class on hide()", ->
@view.hide()
expect(@view.options.shown).toBeFalsy()
# can't test: this blows up the spec runner
# expect($("body")).not.toHaveClass("dialog-is-shown")
describe "Uploads", ->
beforeEach ->
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
@clock = sinon.useFakeTimers()
afterEach ->
@xhr.restore()
@clock.restore()
it "can upload correctly", ->
@view.upload()
expect(@model.get("uploading")).toBeTruthy()
expect(@requests.length).toEqual(1)
request = @requests[0]
expect(request.url).toEqual("/upload")
expect(request.method).toEqual("POST")
request.respond(200, {"Content-Type": "application/json"},
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
expect(@model.get("uploading")).toBeFalsy()
expect(@model.get("finished")).toBeTruthy()
expect(@chapter.get("name")).toEqual("starfish")
expect(@chapter.get("asset_path")).toEqual("/uploaded/starfish.pdf")
it "can handle upload errors", ->
@view.upload()
@requests[0].respond(500)
expect(@model.get("title")).toMatch(/error/)
expect(@view.remove).not.toHaveBeenCalled()
it "removes itself after two seconds on successful upload", ->
@view.upload()
@requests[0].respond(200, {"Content-Type": "application/json"},
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
expect(@view.remove).not.toHaveBeenCalled()
@clock.tick(2001)
expect(@view.remove).toHaveBeenCalled()
...@@ -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;
CMS.Views.ShowTextbook = Backbone.View.extend({
initialize: function() {
this.template = _.template($("#show-textbook-tpl").text());
this.listenTo(this.model, "change", this.render);
},
tagName: "section",
className: "textbook",
events: {
"click .edit": "editTextbook",
"click .delete": "confirmDelete",
"click .show-chapters": "showChapters",
"click .hide-chapters": "hideChapters"
},
render: function() {
var attrs = $.extend({}, this.model.attributes);
attrs.bookindex = this.model.collection.indexOf(this.model);
attrs.course = window.section.attributes;
this.$el.html(this.template(attrs));
return this;
},
editTextbook: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set("editing", true);
},
confirmDelete: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
var textbook = this.model, collection = this.model.collection;
var msg = new CMS.Views.Prompt.Warning({
title: _.str.sprintf(gettext("Delete “%s”?"),
textbook.escape('name')),
message: gettext("Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed."),
actions: {
primary: {
text: gettext("Delete"),
click: function(view) {
view.hide();
var delmsg = new CMS.Views.Notification.Saving({
title: gettext("Deleting&hellip;")
}).show();
textbook.destroy({
complete: function() {
delmsg.hide();
}
});
}
},
secondary: {
text: gettext("Cancel"),
click: function(view) {
view.hide();
}
}
}
}).show();
},
showChapters: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showChapters', true);
},
hideChapters: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showChapters', false);
}
});
CMS.Views.EditTextbook = Backbone.View.extend({
initialize: function() {
this.template = _.template($("#edit-textbook-tpl").text());
this.listenTo(this.model, "invalid", this.render);
var chapters = this.model.get('chapters');
this.listenTo(chapters, "add", this.addOne);
this.listenTo(chapters, "reset", this.addAll);
this.listenTo(chapters, "all", this.render);
},
tagName: "section",
className: "textbook",
render: function() {
this.$el.html(this.template({
name: this.model.escape('name'),
error: this.model.validationError
}));
this.addAll();
return this;
},
events: {
"change input[name=textbook-name]": "setName",
"submit": "setAndClose",
"click .action-cancel": "cancel",
"click .action-add-chapter": "createChapter"
},
addOne: function(chapter) {
var view = new CMS.Views.EditChapter({model: chapter});
this.$("ol.chapters").append(view.render().el);
return this;
},
addAll: function() {
this.model.get('chapters').each(this.addOne, this);
},
createChapter: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.setValues();
this.model.get('chapters').add([{}]);
},
setName: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set("name", this.$("#textbook-name-input").val(), {silent: true});
},
setValues: function() {
this.setName();
var that = this;
_.each(this.$("li"), function(li, i) {
var chapter = that.model.get('chapters').at(i);
if(!chapter) { return; }
chapter.set({
"name": $(".chapter-name", li).val(),
"asset_path": $(".chapter-asset-path", li).val()
});
});
return this;
},
setAndClose: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.setValues();
if(!this.model.isValid()) { return; }
var saving = new CMS.Views.Notification.Saving({
title: gettext("Saving&hellip;")
}).show();
var that = this;
this.model.save({}, {
success: function() {
that.model.setOriginalAttributes();
that.close();
},
complete: function() {
saving.hide();
}
});
},
cancel: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.reset();
return this.close();
},
close: function() {
var textbooks = this.model.collection;
this.remove();
if(this.model.isNew()) {
// if the textbook has never been saved, remove it
textbooks.remove(this.model);
}
// don't forget to tell the model that it's no longer being edited
this.model.set("editing", false);
return this;
}
});
CMS.Views.ListTextbooks = Backbone.View.extend({
initialize: function() {
this.emptyTemplate = _.template($("#no-textbooks-tpl").text());
this.listenTo(this.collection, 'all', this.render);
this.listenTo(this.collection, 'destroy', this.handleDestroy);
},
tagName: "div",
className: "textbooks-list",
render: function() {
var textbooks = this.collection;
if(textbooks.length === 0) {
this.$el.html(this.emptyTemplate());
} else {
this.$el.empty();
var that = this;
textbooks.each(function(textbook) {
var view;
if (textbook.get("editing")) {
view = new CMS.Views.EditTextbook({model: textbook});
} else {
view = new CMS.Views.ShowTextbook({model: textbook});
}
that.$el.append(view.render().el);
});
}
return this;
},
events: {
"click .new-button": "addOne"
},
addOne: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.collection.add([{editing: true}]);
},
handleDestroy: function(model, collection, options) {
collection.remove(model);
}
});
CMS.Views.EditChapter = Backbone.View.extend({
initialize: function() {
this.template = _.template($("#edit-chapter-tpl").text());
this.listenTo(this.model, "change", this.render);
},
tagName: "li",
className: function() {
return "field-group chapter chapter" + this.model.get('order');
},
render: function() {
this.$el.html(this.template({
name: this.model.escape('name'),
asset_path: this.model.escape('asset_path'),
order: this.model.get('order'),
error: this.model.validationError
}));
return this;
},
events: {
"change .chapter-name": "changeName",
"change .chapter-asset-path": "changeAssetPath",
"click .action-close": "removeChapter",
"click .action-upload": "openUploadDialog",
"submit": "uploadAsset"
},
changeName: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$(".chapter-name").val()
}, {silent: true});
return this;
},
changeAssetPath: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
asset_path: this.$(".chapter-asset-path").val()
}, {silent: true});
return this;
},
removeChapter: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.collection.remove(this.model);
return this.remove();
},
openUploadDialog: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$("input.chapter-name").val(),
asset_path: this.$("input.chapter-asset-path").val()
});
var msg = new CMS.Models.FileUpload({
title: _.str.sprintf(gettext("Upload a new asset to %s"),
section.escape('name')),
message: "Files must be in PDF format."
});
var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model});
$(".wrapper-view").after(view.show().el);
}
});
CMS.Views.UploadDialog = Backbone.View.extend({
options: {
shown: true,
successMessageTimeout: 2000 // 2 seconds
},
initialize: function() {
this.template = _.template($("#upload-dialog-tpl").text());
this.listenTo(this.model, "change", this.render);
},
render: function() {
var isValid = this.model.isValid()
var selectedFile = this.model.get('selectedFile');
var oldInput = this.$("input[type=file]").get(0);
this.$el.html(this.template({
shown: this.options.shown,
url: CMS.URL.UPLOAD_ASSET,
title: this.model.escape('title'),
message: this.model.escape('message'),
selectedFile: selectedFile,
uploading: this.model.get('uploading'),
uploadedBytes: this.model.get('uploadedBytes'),
totalBytes: this.model.get('totalBytes'),
finished: this.model.get('finished'),
error: this.model.validationError
}));
// Ideally, we'd like to tell the browser to pre-populate the
// <input type="file"> with the selectedFile if we have one -- but
// browser security prohibits that. So instead, we'll swap out the
// new input (that has no file selected) with the old input (that
// already has the selectedFile selected). However, we only want to do
// this if the selected file is valid: if it isn't, we want to render
// a blank input to prompt the user to upload a different (valid) file.
if (selectedFile && isValid) {
$(oldInput).removeClass("error");
this.$('input[type=file]').replaceWith(oldInput);
}
return this;
},
events: {
"change input[type=file]": "selectFile",
"click .action-cancel": "hideAndRemove",
"click .action-upload": "upload"
},
selectFile: function(e) {
this.model.set({
selectedFile: e.target.files[0] || null
});
},
show: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = true;
$body.addClass('dialog-is-shown');
return this.render();
},
hide: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = false;
$body.removeClass('dialog-is-shown');
return this.render();
},
hideAndRemove: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
return this.hide().remove();
},
upload: function(e) {
this.model.set('uploading', true);
this.$("form").ajaxSubmit({
success: _.bind(this.success, this),
error: _.bind(this.error, this),
uploadProgress: _.bind(this.progress, this),
data: {
// don't show the generic error notification; we're in a modal,
// and we're better off modifying it instead.
notifyOnError: false
}
});
},
progress: function(event, position, total, percentComplete) {
this.model.set({
"uploadedBytes": position,
"totalBytes": total
});
},
success: function(response, statusText, xhr, form) {
this.model.set({
uploading: false,
finished: true
});
var chapter = this.options.chapter;
if(chapter) {
var options = {};
if(!chapter.get("name")) {
options.name = response.displayname;
}
options.asset_path = response.url;
chapter.set(options);
}
var that = this;
this.removalTimeout = setTimeout(function() {
that.hide().remove();
}, this.options.successMessageTimeout);
},
error: function() {
this.model.set({
"uploading": false,
"uploadedBytes": 0,
"title": gettext("We're sorry, there was an error")
});
}
});
...@@ -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,
......
// studio - views - textbooks
// ====================
body.course.textbooks {
.content-primary, .content-supplementary {
@include box-sizing(border-box);
float: left;
}
.content-primary {
width: flex-grid(9, 12);
margin-right: flex-gutter();
.no-textbook-content {
@extend .ui-well;
padding: ($baseline*2);
background-color: $gray-l4;
text-align: center;
color: $gray;
.new-button {
@include font-size(14);
margin-left: $baseline;
[class^="icon-"] {
margin-right: ($baseline/2);
}
}
}
.textbook {
@extend .window;
position: relative;
.view-textbook {
padding: $baseline ($baseline*1.5);
header {
margin-bottom: 0;
border-bottom: 0;
}
.textbook-title {
@extend .t-title4;
margin-right: ($baseline*14);
font-weight: bold;
}
.ui-toggle-expansion {
@include transition(rotate .15s ease-in-out .25s);
@include font-size(21);
display: inline-block;
width: ($baseline*0.75);
vertical-align: text-bottom;
}
&.is-selectable {
cursor: pointer;
&:hover {
color: $blue;
.ui-toggle-expansion {
color: $blue;
}
}
}
.chapters {
margin-left: $baseline;
.chapter {
@extend .t-copy-sub2;
margin-bottom: ($baseline/4);
border-bottom: 1px solid $gray-l4;
.chapter-name {
display: inline-block;
vertical-align: middle;
width: 45%;
margin-right: ($baseline/2);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chapter-asset-path {
display: inline-block;
width: 50%;
color: $gray-l1;
}
}
}
.actions {
@include transition(opacity .15s .25s ease-in-out);
opacity: 0.0;
position: absolute;
top: $baseline;
right: $baseline;
.action {
display: inline-block;
margin-right: ($baseline/4);
.view {
@include blue-button;
@extend .t-action4;
}
.edit {
@include blue-button;
@extend .t-action4;
}
.delete {
@extend .btn-non;
}
}
}
}
&:hover .actions {
opacity: 1.0;
}
.edit-textbook {
@include box-sizing(border-box);
@include border-radius(2px);
width: 100%;
background: $white;
.wrapper-form {
padding: $baseline ($baseline*1.5);
}
fieldset {
margin-bottom: $baseline;
}
.actions {
@include box-shadow(inset 0 1px 2px $shadow);
border-top: 1px solid $gray-l1;
padding: ($baseline*0.75) $baseline;
background: $gray-l6;
// add a chapter is below with chapters styling
.action-primary {
@include blue-button;
@extend .t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
text-transform: uppercase;
}
.action-secondary {
@include grey-button;
@extend .t-action2;
@include transition(all .15s);
display: inline-block;
padding: ($baseline/5) $baseline;
font-weight: 600;
text-transform: uppercase;
}
}
.copy {
@extend .t-copy-sub2;
margin: ($baseline) 0 ($baseline/2) 0;
color: $gray;
strong {
font-weight: 600;
}
}
.chapters-fields,
.textbook-fields {
@extend .no-list;
.field {
margin: 0 0 ($baseline*0.75) 0;
&:last-child {
margin-bottom: 0;
}
&.required {
label {
font-weight: 600;
}
label:after {
margin-left: ($baseline/4);
content: "*";
}
}
label, input, textarea {
display: block;
}
label {
@extend .t-copy-sub1;
@include transition(color, 0.15s, ease-in-out);
margin: 0 0 ($baseline/4) 0;
&.is-focused {
color: $blue;
}
}
&.add-textbook-name label {
@extend .t-title5;
}
//this section is borrowed from _account.scss - we should clean up and unify later
input, textarea {
@extend .t-copy-base;
height: 100%;
width: 100%;
padding: ($baseline/2);
&.long {
width: 100%;
}
&.short {
width: 25%;
}
::-webkit-input-placeholder {
color: $gray-l4;
}
:-moz-placeholder {
color: $gray-l3;
}
::-moz-placeholder {
color: $gray-l3;
}
:-ms-input-placeholder {
color: $gray-l3;
}
&:focus {
+ .tip {
color: $gray;
}
}
}
textarea.long {
height: ($baseline*5);
}
input[type="checkbox"] {
display: inline-block;
margin-right: ($baseline/4);
width: auto;
height: auto;
& + label {
display: inline-block;
}
}
.tip {
@extend .t-copy-sub2;
@include transition(color, 0.15s, ease-in-out);
display: block;
margin-top: ($baseline/4);
color: $gray-l3;
}
&.error {
label {
color: $red;
}
input {
border-color: $red;
}
}
}
.field-group {
@include clearfix();
margin: 0 0 ($baseline/2) 0;
.field {
display: block;
width: 46%;
border-bottom: none;
margin: 0 ($baseline*0.75) 0 0;
padding: ($baseline/4) 0 0 0;
float: left;
position: relative;
input, textarea {
width: 100%;
}
.action-upload {
@extend .btn-flat-outline;
position: absolute;
top: 3px;
right: 0;
}
}
.action-close {
@include transition(color 0.25s ease-in-out);
@include font-size(22);
display: inline-block;
float: right;
margin-top: ($baseline*2);
border: 0;
padding: 0;
background: transparent;
color: $blue-l3;
&:hover {
color: $blue;
}
}
}
}
.action-add-chapter {
@extend .btn-flat-outline;
@include font-size(16);
display: block;
width: 100%;
margin: ($baseline*1.5) 0 0 0;
padding: ($baseline/2);
font-weight: 600;
}
}
}
}
.content-supplementary {
width: flex-grid(3, 12);
}
// dialog
.wrapper-dialog {
@extend .depth5;
@include transition(all 0.05s ease-in-out);
position: fixed;
top: 0;
background: $black-t2;
width: 100%;
height: 100%;
text-align: center;
&:before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
margin-right: -0.25em; /* Adjusts for spacing */
}
.dialog {
@include box-shadow(0px 0px 7px $shadow-d1);
@include box-sizing(border-box);
@include border-radius(($baseline/5));
background-color: $gray-l4;
display: inline-block;
vertical-align: middle;
width: $baseline*23;
padding: 7px;
text-align: left;
.title {
@extend .t-title5;
margin-bottom: ($baseline/2);
font-weight: 600;
color: $black;
}
.message {
@extend .t-copy-sub2;
color: $gray;
}
.error {
color: $white;
}
form {
padding: 0;
.form-content {
@include box-shadow(0 0 3px $shadow-d1);
padding: ($baseline*1.5);
background-color: $white;
}
input[type="file"] {
@extend .t-copy-sub2;
}
.status-upload {
height: 30px;
margin-top: $baseline;
.wrapper-progress {
@include box-shadow(inset 0 0 3px $shadow-d1);
display: block;
border-radius: ($baseline*0.75);
background-color: $gray-l5;
padding: 1px 8px 2px 8px;
height: 25px;
progress {
display: inline-block;
vertical-align: middle;
width: 100%;
border: none;
border-radius: ($baseline*0.75);
background-color: $gray-l5;
&::-webkit-progress-bar {
background-color: transparent;
border-radius: ($baseline*0.75);
}
&::-webkit-progress-value {
background-color: $pink;
border-radius: ($baseline*0.75);
}
&::-moz-progress-bar {
background-color: $pink;
border-radius: ($baseline*0.75);
}
}
}
.message-status {
@include border-top-radius(2px);
@include box-sizing(border-box);
@include font-size(14);
display: none;
border-bottom: 2px solid $yellow;
margin: 0 0 20px 0;
padding: 10px 20px;
font-weight: 500;
background: $paleYellow;
.text {
display: inline-block;
}
&.error {
border-color: $red-d2;
background: $red-l1;
color: $white;
}
&.confirm {
border-color: $green-d2;
background: $green-l1;
color: $white;
}
&.is-shown {
display: block;
}
}
}
.actions {
padding: ($baseline*0.75) $baseline ($baseline/2) $baseline;
.action-item {
@extend .t-action4;
display: inline-block;
margin-right: ($baseline*0.75);
&:last-child {
margin-right: 0;
}
}
.action-primary {
@include blue-button();
@include font-size(12); // needed due to bad button mixins for now
border-color: $blue-d1;
color: $white;
}
a {
color: $blue;
&:hover {
color: $blue-s2;
}
}
}
}
}
}
// ====================
// js enabled
.js {
// dialog set-up
.wrapper-dialog {
visibility: hidden;
pointer-events: none;
.dialog {
opacity: 0;
}
}
// dialog showing/hiding
&.dialog-is-shown {
.wrapper-dialog {
-webkit-filter: blur(2px) grayscale(25%);
filter: blur(2px) grayscale(25%);
}
.wrapper-dialog.is-shown {
visibility: visible;
pointer-events: auto;
.dialog {
opacity: 1.0;
}
}
}
}
}
...@@ -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