@@ -15,6 +15,8 @@ LMS: Added *experimental* crowdsource hinting manager page.
|
||||
|
||||
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: Add tests for documentation generation to test suite
|
||||
|
||||
22
cms/djangoapps/contentstore/debug_file_uploader.py
Normal file
22
cms/djangoapps/contentstore/debug_file_uploader.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
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
|
||||
|
||||
KEY_CSS = '.key input.policy-key'
|
||||
@@ -36,7 +36,7 @@ def press_the_notification_button(step, name):
|
||||
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
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$')
|
||||
|
||||
47
cms/djangoapps/contentstore/features/textbooks.feature
Normal file
47
cms/djangoapps/contentstore/features/textbooks.feature
Normal file
@@ -0,0 +1,47 @@
|
||||
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"
|
||||
121
cms/djangoapps/contentstore/features/textbooks.py
Normal file
121
cms/djangoapps/contentstore/features/textbooks.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#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)
|
||||
95
cms/djangoapps/contentstore/tests/test_assets.py
Normal file
95
cms/djangoapps/contentstore/tests/test_assets.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
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")
|
||||
@@ -1,10 +1,10 @@
|
||||
""" Unit tests for checklist methods in views.py. """
|
||||
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.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
import json
|
||||
from .utils import CourseTestCase
|
||||
|
||||
|
||||
class ChecklistTestCase(CourseTestCase):
|
||||
@@ -117,4 +117,4 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 100})
|
||||
response = self.client.delete(update_url)
|
||||
self.assertContains(response, 'Unsupported request', status_code=400)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pylint: disable=E1101
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import mock
|
||||
|
||||
@@ -6,8 +6,6 @@ import json
|
||||
import copy
|
||||
import mock
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.timezone import UTC
|
||||
from django.test.utils import override_settings
|
||||
@@ -17,45 +15,12 @@ from models.settings.course_details import (CourseDetails, CourseSettingsEncoder
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
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 xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.fields import Date
|
||||
|
||||
|
||||
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
|
||||
from .utils import CourseTestCase
|
||||
|
||||
|
||||
class CourseDetailsTestCase(CourseTestCase):
|
||||
@@ -63,8 +28,8 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
Tests the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course_location)
|
||||
self.assertEqual(details.course_location, self.course_location, "Location not copied into")
|
||||
details = CourseDetails.fetch(self.course.location)
|
||||
self.assertEqual(details.course_location, self.course.location, "Location not copied into")
|
||||
self.assertIsNotNone(details.start_date.tzinfo)
|
||||
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))
|
||||
@@ -75,10 +40,10 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
|
||||
|
||||
def test_encoder(self):
|
||||
details = CourseDetails.fetch(self.course_location)
|
||||
details = CourseDetails.fetch(self.course.location)
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
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['enrollment_start'], "enrollment_start date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
|
||||
@@ -91,10 +56,12 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
"""
|
||||
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']),
|
||||
'number': 1,
|
||||
'string': 'string',
|
||||
'datetime': datetime.datetime.now(UTC())}
|
||||
details = {
|
||||
'location': Location(['tag', 'org', 'course', 'category', 'name']),
|
||||
'number': 1,
|
||||
'string': 'string',
|
||||
'datetime': datetime.datetime.now(UTC())
|
||||
}
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
|
||||
@@ -105,7 +72,7 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertEqual(jsondetails['string'], 'string')
|
||||
|
||||
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>"
|
||||
# encode - decode to convert date fields and other data which changes form
|
||||
self.assertEqual(
|
||||
@@ -138,9 +105,9 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course_location.org,
|
||||
'name': self.course_location.name,
|
||||
'course': self.course_location.course
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
|
||||
@@ -162,9 +129,9 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course_location.org,
|
||||
'name': self.course_location.name,
|
||||
'course': self.course_location.course
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
|
||||
@@ -204,11 +171,12 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
return Date().to_json(dt)
|
||||
|
||||
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
|
||||
url = reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course,
|
||||
'name': self.course_location.name, 'section': 'details'})
|
||||
url = reverse('course_settings', kwargs={'org': loc.org, 'course': loc.course,
|
||||
'name': loc.name, 'section': 'details'})
|
||||
resp = self.client.get(url)
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get")
|
||||
|
||||
@@ -251,49 +219,49 @@ class CourseGradingTest(CourseTestCase):
|
||||
Tests for the course settings grading page.
|
||||
"""
|
||||
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)
|
||||
# ??? 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.grade_cutoffs, "No cutoffs")
|
||||
|
||||
def test_fetch_grader(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course_location.url())
|
||||
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
|
||||
test_grader = CourseGradingModel.fetch(self.course.location.url())
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
|
||||
test_grader = CourseGradingModel.fetch(self.course_location)
|
||||
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
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__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
|
||||
|
||||
@@ -307,11 +275,10 @@ class CourseGradingTest(CourseTestCase):
|
||||
|
||||
test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
|
||||
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")
|
||||
|
||||
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])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
|
||||
|
||||
@@ -331,11 +298,11 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
def setUp(self):
|
||||
CourseTestCase.setUp(self)
|
||||
# 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])
|
||||
|
||||
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.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
|
||||
@@ -348,17 +315,17 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
||||
|
||||
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",
|
||||
"testcenter_info": {"c": "test"},
|
||||
"days_early_for_beta": 2
|
||||
})
|
||||
self.update_check(test_model)
|
||||
# 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)
|
||||
# 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",
|
||||
"display_name": "jolly roger"}
|
||||
)
|
||||
@@ -387,3 +354,35 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
# check for deletion effectiveness
|
||||
self.assertEqual('closed', test_model['showanswer'], 'showanswer 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):
|
||||
'''Go through each interface and ensure it works.'''
|
||||
# first get the update to force the creation
|
||||
url = reverse('course_info',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'name': self.course_location.name})
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name})
|
||||
self.client.get(url)
|
||||
|
||||
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
|
||||
@@ -20,8 +20,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 8, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
@@ -31,8 +31,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(payload['content'], content)
|
||||
|
||||
first_update_url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': payload['id']})
|
||||
content += '<div>div <p>p<br/></p></div>'
|
||||
payload['content'] = content
|
||||
@@ -47,8 +47,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
@@ -58,8 +58,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, payload['content'], "self closing ol")
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
payload = json.loads(resp.content)
|
||||
@@ -73,8 +73,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
|
||||
# now try to update a non-existent update
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': '9'})
|
||||
content = 'blah blah'
|
||||
payload = {'content': content,
|
||||
@@ -87,8 +87,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
content = '<garbage tag No closing brace to force <span>error</span>'
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
self.assertContains(
|
||||
@@ -99,8 +99,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
content = "<p><br><br></p>"
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
@@ -108,8 +108,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'])
|
||||
|
||||
# now try to delete a non-existent update
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': '19'})
|
||||
payload = {'content': content,
|
||||
'date': 'January 21, 2013'}
|
||||
@@ -119,8 +119,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
content = 'blah blah'
|
||||
payload = {'content': content,
|
||||
'date': 'January 28, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
payload = json.loads(resp.content)
|
||||
@@ -128,16 +128,16 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, payload['content'], "single iframe")
|
||||
# first count the entries
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
payload = json.loads(resp.content)
|
||||
before_delete = len(payload)
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': this_id})
|
||||
resp = self.client.delete(url)
|
||||
payload = json.loads(resp.content)
|
||||
|
||||
@@ -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).
|
||||
resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
410
cms/djangoapps/contentstore/tests/test_textbooks.py
Normal file
410
cms/djangoapps/contentstore/tests/test_textbooks.py
Normal file
@@ -0,0 +1,410 @@
|
||||
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))
|
||||
15
cms/djangoapps/contentstore/tests/test_users.py
Normal file
15
cms/djangoapps/contentstore/tests/test_users.py
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
|
||||
|
||||
class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
def _login(self, email, pw):
|
||||
"""Login. View should always return 200. The success/fail is in the
|
||||
returned json"""
|
||||
def _login(self, email, password):
|
||||
"""
|
||||
Login. View should always return 200. The success/fail is in the
|
||||
returned json
|
||||
"""
|
||||
resp = self.client.post(reverse('login_post'),
|
||||
{'email': email, 'password': pw})
|
||||
{'email': email, 'password': password})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return resp
|
||||
|
||||
@@ -25,12 +27,12 @@ class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
self.assertTrue(data['success'])
|
||||
return resp
|
||||
|
||||
def _create_account(self, username, email, pw):
|
||||
def _create_account(self, username, email, password):
|
||||
"""Try to create an account. No error checking"""
|
||||
resp = self.client.post('/create_account', {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': pw,
|
||||
'password': password,
|
||||
'location': 'home',
|
||||
'language': 'Franglish',
|
||||
'name': 'Fred Weasley',
|
||||
@@ -39,9 +41,9 @@ class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
})
|
||||
return resp
|
||||
|
||||
def create_account(self, username, email, pw):
|
||||
def create_account(self, username, email, password):
|
||||
"""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)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['success'], True)
|
||||
@@ -88,7 +90,7 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
reverse('signup'),
|
||||
)
|
||||
for page in pages:
|
||||
print "Checking '{0}'".format(page)
|
||||
print("Checking '{0}'".format(page))
|
||||
self.check_page_get(page, 200)
|
||||
|
||||
def test_create_account_errors(self):
|
||||
@@ -146,17 +148,17 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
self.client = Client()
|
||||
|
||||
# Not logged in. Should redirect to login.
|
||||
print 'Not logged in'
|
||||
print('Not logged in')
|
||||
for page in auth_pages:
|
||||
print "Checking '{0}'".format(page)
|
||||
print("Checking '{0}'".format(page))
|
||||
self.check_page_get(page, expected=302)
|
||||
|
||||
# Logged in should work.
|
||||
self.login(self.email, self.pw)
|
||||
|
||||
print 'Logged in'
|
||||
print('Logged in')
|
||||
for page in simple_auth_pages:
|
||||
print "Checking '{0}'".format(page)
|
||||
print("Checking '{0}'".format(page))
|
||||
self.check_page_get(page, expected=200)
|
||||
|
||||
def test_index_auth(self):
|
||||
|
||||
@@ -6,6 +6,10 @@ import json
|
||||
|
||||
from student.models import Registration
|
||||
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):
|
||||
@@ -21,3 +25,37 @@ def user(email):
|
||||
def registration(email):
|
||||
"""look up registration object by 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',
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pylint: disable=E1103, E1101
|
||||
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# pylint: disable=W0401, W0511
|
||||
|
||||
"All view functions for contentstore, broken out into submodules"
|
||||
|
||||
# Disable warnings about import from wildcard
|
||||
# All files below declare exports with __all__
|
||||
from .assets import *
|
||||
|
||||
@@ -13,6 +13,7 @@ from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from cache_toolbox.core import del_cached_content
|
||||
@@ -30,11 +31,45 @@ from xmodule.exceptions import NotFoundError
|
||||
|
||||
from ..utils import get_url_reverse
|
||||
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']
|
||||
|
||||
|
||||
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
|
||||
@ensure_csrf_cookie
|
||||
def asset_index(request, org, course, name):
|
||||
@@ -59,6 +94,9 @@ def asset_index(request, org, course, name):
|
||||
# sort in reverse upload date order
|
||||
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 = []
|
||||
for asset in assets:
|
||||
asset_id = asset['_id']
|
||||
@@ -77,7 +115,6 @@ def asset_index(request, org, course, name):
|
||||
asset_display.append(display_info)
|
||||
|
||||
return render_to_response('asset_index.html', {
|
||||
'active_tab': 'assets',
|
||||
'context_course': course_module,
|
||||
'assets': asset_display,
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
@@ -89,17 +126,14 @@ def asset_index(request, org, course, name):
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
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.
|
||||
'''
|
||||
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
|
||||
location = get_location_and_verify_access(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'
|
||||
# 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
|
||||
|
||||
filename = request.FILES['file'].name
|
||||
mime_type = request.FILES['file'].content_type
|
||||
filedata = request.FILES['file'].read()
|
||||
upload_file = request.FILES['file']
|
||||
filename = upload_file.name
|
||||
mime_type = upload_file.content_type
|
||||
|
||||
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
|
||||
(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)
|
||||
del_cached_content(thumbnail_location)
|
||||
@@ -149,7 +192,7 @@ def upload_asset(request, org, course, coursename):
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = HttpResponse(json.dumps(response_payload))
|
||||
response = JsonResponse(response_payload)
|
||||
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
|
||||
return response
|
||||
|
||||
@@ -208,7 +251,9 @@ def remove_asset(request, org, course, name):
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
"""
|
||||
This method will handle a POST request to upload and import a .tar.gz file into a specified course
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -274,7 +319,6 @@ def import_course(request, org, course, name):
|
||||
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'import',
|
||||
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
|
||||
})
|
||||
|
||||
@@ -282,6 +326,10 @@ def import_course(request, org, course, name):
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
"""
|
||||
This method will serialize out a course to a .tar.gz file which contains a XML-based representation of
|
||||
the course
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
loc = Location(location)
|
||||
@@ -312,13 +360,14 @@ def generate_export_course(request, org, course, name):
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def export_course(request, org, course, name):
|
||||
|
||||
"""
|
||||
This method serves up the 'Export Course' page
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'export',
|
||||
'successful_import_redirect_url': ''
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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.views.decorators.http import require_http_methods
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
@@ -9,7 +11,6 @@ from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from ..utils import get_modulestore, get_url_reverse
|
||||
from .requests import get_request_method
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
__all__ = ['get_checklists', 'update_checklist']
|
||||
@@ -46,6 +47,7 @@ def get_checklists(request, org, course, name):
|
||||
})
|
||||
|
||||
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
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)
|
||||
course_module = modulestore.get_item(location)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
if real_method == 'POST' or real_method == 'PUT':
|
||||
if request.method in ("POST", "PUT"):
|
||||
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
|
||||
index = int(checklist_index)
|
||||
course_module.checklists[index] = json.loads(request.body)
|
||||
@@ -71,7 +72,7 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
course_module.checklists = course_module.checklists
|
||||
checklists, _ = expand_checklist_action_urls(course_module)
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
|
||||
return JsonResponse(checklists[index])
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
"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):
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest("Unsupported request.", content_type="text/plain")
|
||||
return JsonResponse(checklists)
|
||||
|
||||
|
||||
def expand_checklist_action_urls(course_module):
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections import defaultdict
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
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_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
@@ -15,7 +16,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
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.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 .requests import get_request_method, _xmodule_recurse
|
||||
from .requests import _xmodule_recurse
|
||||
from .access import has_access
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
@@ -209,7 +210,6 @@ def edit_unit(request, location):
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'active_tab': 'courseware',
|
||||
'unit': item,
|
||||
'unit_location': location,
|
||||
'components': components,
|
||||
@@ -234,14 +234,12 @@ def assignment_type_update(request, org, course, category, name):
|
||||
'''
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
raise HttpResponseForbidden()
|
||||
return HttpResponseForbidden()
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
|
||||
mimetype="application/json")
|
||||
return JsonResponse(CourseGradingModel.get_section_grader_type(location))
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
|
||||
mimetype="application/json")
|
||||
return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST))
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -291,6 +289,7 @@ def unpublish_unit(request):
|
||||
|
||||
|
||||
@expect_json
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def module_info(request, module_location):
|
||||
@@ -300,8 +299,6 @@ def module_info(request, module_location):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
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))
|
||||
|
||||
@@ -309,9 +306,7 @@ def module_info(request, module_location):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links))
|
||||
elif request.method in ("POST", "PUT"):
|
||||
return JsonResponse(set_module_info(get_modulestore(location), location, request.POST))
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
"""
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
#pylint: disable=W0402
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
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 xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, \
|
||||
InvalidLocationError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from xmodule.modulestore.exceptions import (
|
||||
ItemNotFoundError, InvalidLocationError)
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
|
||||
from contentstore.utils import get_lms_link_for_item, add_extra_panel_tab, remove_extra_panel_tab
|
||||
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
|
||||
from contentstore.course_info_model import (
|
||||
get_course_updates, update_course_updates, delete_course_update)
|
||||
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_metadata import CourseMetadata
|
||||
from auth.authz import create_all_course_groups, is_user_in_creator_group
|
||||
from util.json_request import expect_json
|
||||
|
||||
from .access import has_access, get_location_and_verify_access
|
||||
from .requests import get_request_method
|
||||
from .tabs import initialize_course_tabs
|
||||
from .component import OPEN_ENDED_COMPONENT_TYPES, \
|
||||
NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY
|
||||
from .component import (
|
||||
OPEN_ENDED_COMPONENT_TYPES, NOTE_COMPONENT_TYPES,
|
||||
ADVANCED_COMPONENT_POLICY_KEY)
|
||||
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
import datetime
|
||||
@@ -39,7 +51,8 @@ __all__ = ['course_index', 'create_new_course', 'course_info',
|
||||
'course_config_advanced_page',
|
||||
'course_settings_updates',
|
||||
'course_grader_updates',
|
||||
'course_advanced_updates']
|
||||
'course_advanced_updates', 'textbook_index', 'textbook_by_id',
|
||||
'create_textbook']
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -64,7 +77,6 @@ def course_index(request, org, course, name):
|
||||
sections = course.get_children()
|
||||
|
||||
return render_to_response('overview.html', {
|
||||
'active_tab': 'courseware',
|
||||
'context_course': course,
|
||||
'lms_link': lms_link,
|
||||
'sections': sections,
|
||||
@@ -80,7 +92,9 @@ def course_index(request, org, course, name):
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
|
||||
"""
|
||||
Create a new course
|
||||
"""
|
||||
if not is_user_in_creator_group(request.user):
|
||||
raise PermissionDenied()
|
||||
|
||||
@@ -97,8 +111,9 @@ def create_new_course(request):
|
||||
try:
|
||||
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
|
||||
except InvalidLocationError as error:
|
||||
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" +
|
||||
display_name + "'.\n\n" + error.message}))
|
||||
return JsonResponse({
|
||||
"ErrMsg": "Unable to create course '{name}'.\n\n{err}".format(
|
||||
name=display_name, err=error.message)})
|
||||
|
||||
# see if the course already exists
|
||||
existing_course = None
|
||||
@@ -108,13 +123,13 @@ def create_new_course(request):
|
||||
pass
|
||||
|
||||
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]
|
||||
courses = modulestore().get_items(course_search_location)
|
||||
|
||||
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)
|
||||
|
||||
@@ -137,7 +152,7 @@ def create_new_course(request):
|
||||
# seed the forums
|
||||
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
|
||||
@@ -156,7 +171,6 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
location = Location(['i4x', org, course, 'course_info', "updates"])
|
||||
|
||||
return render_to_response('course_info.html', {
|
||||
'active_tab': 'courseinfo-tab',
|
||||
'context_course': course_module,
|
||||
'url_base': "/" + org + "/" + course + "/",
|
||||
'course_updates': json.dumps(get_course_updates(location)),
|
||||
@@ -187,22 +201,17 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(get_course_updates(location)),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
return JsonResponse(get_course_updates(location))
|
||||
elif request.method == 'DELETE':
|
||||
try:
|
||||
return HttpResponse(json.dumps(delete_course_update(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
return JsonResponse(delete_course_update(location, request.POST, provided_id))
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to delete",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'POST':
|
||||
try:
|
||||
return HttpResponse(json.dumps(update_course_updates(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
return JsonResponse(update_course_updates(location, request.POST, provided_id))
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to save",
|
||||
content_type="text/plain")
|
||||
@@ -293,14 +302,13 @@ def course_settings_updates(request, org, course, name, section):
|
||||
|
||||
if request.method == 'GET':
|
||||
# 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),
|
||||
mimetype="application/json")
|
||||
return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder)
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder)
|
||||
|
||||
|
||||
@expect_json
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
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)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(location), grader_index)),
|
||||
mimetype="application/json")
|
||||
elif real_method == "DELETE":
|
||||
return JsonResponse(CourseGradingModel.fetch_grader(Location(location), grader_index))
|
||||
elif request.method == "DELETE":
|
||||
# ??? Should this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(location), grader_index)
|
||||
return HttpResponse()
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(location), request.POST)),
|
||||
mimetype="application/json")
|
||||
return JsonResponse()
|
||||
else: # post or put, doesn't matter.
|
||||
return JsonResponse(CourseGradingModel.update_grader_from_json(Location(location), request.POST))
|
||||
|
||||
|
||||
# # NB: expect_json failed on ["key", "key2"] and json payload
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
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)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseMetadata.fetch(location)),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
return HttpResponse(json.dumps(CourseMetadata.delete_key(location,
|
||||
json.loads(request.body))),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(CourseMetadata.fetch(location))
|
||||
elif request.method == 'DELETE':
|
||||
return JsonResponse(CourseMetadata.delete_key(location, json.loads(request.body)))
|
||||
else:
|
||||
# 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
|
||||
request_body = json.loads(request.body)
|
||||
@@ -401,10 +401,204 @@ def course_advanced_updates(request, org, course, name):
|
||||
# Indicate that tabs should *not* be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
try:
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs))
|
||||
except (TypeError, ValueError), e:
|
||||
return HttpResponseBadRequest("Incorrect setting format. " + str(e), content_type="text/plain")
|
||||
return JsonResponse(CourseMetadata.update_from_json(location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs))
|
||||
except (TypeError, ValueError) as err:
|
||||
return HttpResponseBadRequest("Incorrect setting format. " + str(err), content_type="text/plain")
|
||||
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pylint: disable=C0111,W0613
|
||||
|
||||
from django.http import (HttpResponse, HttpResponseServerError,
|
||||
HttpResponseNotFound)
|
||||
from mitxmako.shortcuts import render_to_string, render_to_response
|
||||
|
||||
@@ -68,7 +68,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
def preview_component(request, location):
|
||||
# TODO (vshnayder): change name from id to location in coffee+html as well.
|
||||
if not has_access(request.user, location):
|
||||
raise HttpResponseForbidden()
|
||||
return HttpResponseForbidden()
|
||||
|
||||
component = modulestore().get_item(location)
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpResponse
|
||||
from mitxmako.shortcuts import render_to_string, render_to_response
|
||||
|
||||
@@ -24,28 +22,6 @@ def event(request):
|
||||
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'):
|
||||
"""
|
||||
Render a template using the LMS MAKO_TEMPLATES
|
||||
|
||||
@@ -10,18 +10,20 @@ from mitxmako.shortcuts import render_to_response
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
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
|
||||
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static']
|
||||
|
||||
|
||||
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
|
||||
# at least a list populated with the minimal times
|
||||
# @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
|
||||
"""
|
||||
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
|
||||
at least a list populated with the minimal times
|
||||
@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
|
||||
# so if you change anything here, you need to also change it there.
|
||||
@@ -82,7 +84,8 @@ def reorder_static_tabs(request):
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, 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
|
||||
if not has_access(request.user, location):
|
||||
@@ -108,7 +111,6 @@ def edit_tabs(request, org, course, coursename):
|
||||
]
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course_item,
|
||||
'components': components
|
||||
})
|
||||
@@ -123,7 +125,6 @@ def static_pages(request, org, course, coursename):
|
||||
course = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('static-pages.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course,
|
||||
})
|
||||
|
||||
|
||||
@@ -8,28 +8,11 @@ from mitxmako.shortcuts import render_to_response
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
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 get_user_by_email, add_user_to_course_group, remove_user_from_course_group
|
||||
|
||||
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
|
||||
@@ -73,7 +56,6 @@ def manage_users(request, location):
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('manage_users.html', {
|
||||
'active_tab': 'users',
|
||||
'context_course': course_module,
|
||||
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
|
||||
'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'),
|
||||
@@ -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
|
||||
the specified course
|
||||
'''
|
||||
email = request.POST["email"]
|
||||
email = request.POST.get("email")
|
||||
|
||||
if email == '':
|
||||
return create_json_response('Please specify an email address.')
|
||||
if not email:
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': 'Please specify an email address.',
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
@@ -104,16 +90,24 @@ def add_user(request, location):
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
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?!?
|
||||
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
|
||||
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
return JsonResponse({"Status": "OK"})
|
||||
|
||||
|
||||
@expect_json
|
||||
@@ -133,7 +127,11 @@ def remove_user(request, location):
|
||||
|
||||
user = get_user_by_email(email)
|
||||
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
|
||||
if user.id == request.user.id:
|
||||
@@ -141,4 +139,4 @@ def remove_user(request, location):
|
||||
|
||||
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):
|
||||
if not filter_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?
|
||||
if k in filtered_list:
|
||||
if key in filtered_list:
|
||||
continue
|
||||
|
||||
if hasattr(descriptor, k) and getattr(descriptor, k) != v:
|
||||
if hasattr(descriptor, key) and getattr(descriptor, key) != val:
|
||||
dirty = True
|
||||
value = getattr(CourseDescriptor, k).from_json(v)
|
||||
setattr(descriptor, k, value)
|
||||
elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k:
|
||||
value = getattr(CourseDescriptor, key).from_json(val)
|
||||
setattr(descriptor, key, value)
|
||||
elif hasattr(descriptor.lms, key) and getattr(descriptor.lms, key) != key:
|
||||
dirty = True
|
||||
value = getattr(CourseDescriptor.lms, k).from_json(v)
|
||||
setattr(descriptor.lms, k, value)
|
||||
value = getattr(CourseDescriptor.lms, key).from_json(val)
|
||||
setattr(descriptor.lms, key, value)
|
||||
|
||||
if dirty:
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
|
||||
@@ -32,21 +32,21 @@ from path import path
|
||||
|
||||
MITX_FEATURES = {
|
||||
'USE_DJANGO_PIPELINE': True,
|
||||
|
||||
|
||||
'GITHUB_PUSH': False,
|
||||
|
||||
|
||||
'ENABLE_DISCUSSION_SERVICE': False,
|
||||
|
||||
|
||||
'AUTH_USE_MIT_CERTIFICATES': False,
|
||||
|
||||
|
||||
# do not display video when running automated acceptance tests
|
||||
'STUB_VIDEO_FOR_TESTING': False,
|
||||
|
||||
|
||||
# email address for staff (eg to request course creation)
|
||||
'STAFF_EMAIL': '',
|
||||
|
||||
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
|
||||
|
||||
# Segment.io - must explicitly turn it on for production
|
||||
'SEGMENT_IO': False,
|
||||
|
||||
@@ -143,6 +143,7 @@ MIDDLEWARE_CLASSES = (
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'method_override.middleware.MethodOverrideMiddleware',
|
||||
|
||||
# Instead of AuthenticationMiddleware, we use a cache-backed version
|
||||
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
|
||||
@@ -242,6 +243,7 @@ PIPELINE_JS = {
|
||||
) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
|
||||
'js/models/section.js', 'js/views/section.js',
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
|
||||
'js/models/textbook.js', 'js/views/textbook.js',
|
||||
'js/views/assets.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
@@ -324,6 +326,7 @@ INSTALLED_APPS = (
|
||||
'django.contrib.messages',
|
||||
'djcelery',
|
||||
'south',
|
||||
'method_override',
|
||||
|
||||
# Monitor the status of services
|
||||
'service_status',
|
||||
|
||||
8
cms/envs/debug_upload.py
Normal file
8
cms/envs/debug_upload.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#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 = {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 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)
|
||||
CONTENTSTORE = {
|
||||
|
||||
0
cms/manage.py
Normal file → Executable file
0
cms/manage.py
Normal file → Executable file
@@ -6,10 +6,10 @@ from request_cache.middleware import RequestCache
|
||||
|
||||
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:
|
||||
store = modulestore(store_name)
|
||||
store.metadata_inheritance_cache_subsystem = cache
|
||||
store.metadata_inheritance_cache_subsystem = CACHE
|
||||
store.request_cache = RequestCache.get_request_cache()
|
||||
|
||||
modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
"js/vendor/underscore-min.js",
|
||||
"js/vendor/underscore.string.min.js",
|
||||
"js/vendor/backbone-min.js",
|
||||
"js/vendor/backbone-associations-min.js",
|
||||
"js/vendor/jquery.leanModal.min.js",
|
||||
"js/vendor/jquery.form.js",
|
||||
"js/vendor/sinon-1.7.1.js",
|
||||
"js/vendor/jasmine-stealth.js",
|
||||
"js/test/i18n.js"
|
||||
]
|
||||
}
|
||||
|
||||
1
cms/static/coffee/fixtures/edit-chapter.underscore
Symbolic link
1
cms/static/coffee/fixtures/edit-chapter.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/edit-chapter.underscore
|
||||
1
cms/static/coffee/fixtures/edit-textbook.underscore
Symbolic link
1
cms/static/coffee/fixtures/edit-textbook.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/edit-textbook.underscore
|
||||
1
cms/static/coffee/fixtures/no-textbooks.underscore
Symbolic link
1
cms/static/coffee/fixtures/no-textbooks.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/no-textbooks.underscore
|
||||
1
cms/static/coffee/fixtures/show-textbook.underscore
Symbolic link
1
cms/static/coffee/fixtures/show-textbook.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/show-textbook.underscore
|
||||
1
cms/static/coffee/fixtures/upload-dialog.underscore
Symbolic link
1
cms/static/coffee/fixtures/upload-dialog.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/upload-dialog.underscore
|
||||
227
cms/static/coffee/spec/models/textbook_spec.coffee
Normal file
227
cms/static/coffee/spec/models/textbook_spec.coffee
Normal file
@@ -0,0 +1,227 @@
|
||||
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", ->
|
||||
view.hide()
|
||||
# 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", ->
|
||||
beforeEach ->
|
||||
@primaryClickSpy = jasmine.createSpy('primaryClick')
|
||||
@@ -204,17 +214,22 @@ describe "CMS.Views.SystemFeedback multiple secondary actions", ->
|
||||
|
||||
describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
beforeEach ->
|
||||
@showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show')
|
||||
@showSpy = spyOn(CMS.Views.Notification.Confirmation.prototype, 'show')
|
||||
@showSpy.andCallThrough()
|
||||
@hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide')
|
||||
@hideSpy = spyOn(CMS.Views.Notification.Confirmation.prototype, 'hide')
|
||||
@hideSpy.andCallThrough()
|
||||
@clock = sinon.useFakeTimers()
|
||||
|
||||
afterEach ->
|
||||
@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", ->
|
||||
view = new CMS.Views.Notification.Saving({minShown: 1000})
|
||||
view = new CMS.Views.Notification.Confirmation({minShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -227,7 +242,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
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()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -236,7 +251,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
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()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -250,7 +265,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
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()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -264,7 +279,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
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()
|
||||
|
||||
# can't hide early
|
||||
|
||||
423
cms/static/coffee/spec/views/textbook_spec.coffee
Normal file
423
cms/static/coffee/spec/views/textbook_spec.coffee
Normal file
@@ -0,0 +1,423 @@
|
||||
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)
|
||||
@CMS =
|
||||
Models: {}
|
||||
Views: {}
|
||||
Collections: {}
|
||||
URL: {}
|
||||
|
||||
prefix: $("meta[name='path_prefix']").attr('content')
|
||||
|
||||
@@ -17,7 +19,7 @@ $ ->
|
||||
|
||||
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
|
||||
if ajaxSettings.notifyOnError is false
|
||||
return
|
||||
return
|
||||
if jqXHR.responseText
|
||||
try
|
||||
message = JSON.parse(jqXHR.responseText).error
|
||||
|
||||
@@ -23,9 +23,7 @@ CMS.Models.Section = Backbone.Model.extend({
|
||||
showNotification: function() {
|
||||
if(!this.msg) {
|
||||
this.msg = new CMS.Views.Notification.Saving({
|
||||
title: gettext("Saving…"),
|
||||
closeIcon: false,
|
||||
minShown: 1250
|
||||
title: gettext("Saving…")
|
||||
});
|
||||
}
|
||||
this.msg.show();
|
||||
|
||||
178
cms/static/js/models/textbook.js
Normal file
178
cms/static/js/models/textbook.js
Normal file
@@ -0,0 +1,178 @@
|
||||
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) {
|
||||
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;
|
||||
|
||||
|
||||
362
cms/static/js/views/textbook.js
Normal file
362
cms/static/js/views/textbook.js
Normal file
@@ -0,0 +1,362 @@
|
||||
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…")
|
||||
}).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…")
|
||||
}).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 {
|
||||
.view-button {
|
||||
|
||||
}
|
||||
|
||||
.upload-button .icon-plus {
|
||||
@extend .t-action2;
|
||||
line-height: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,9 +746,6 @@ hr.divide {
|
||||
}
|
||||
|
||||
.icon-plus {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
margin-top: -2px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
// 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/)
|
||||
// ====================
|
||||
|
||||
|
||||
// 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 @@
|
||||
@import 'views/unit';
|
||||
@import 'views/users';
|
||||
@import 'views/checklists';
|
||||
@import 'views/textbooks';
|
||||
|
||||
// temp - inherited
|
||||
@import 'assets/content-types';
|
||||
|
||||
@@ -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?
|
||||
.btn-dd {
|
||||
@extend .btn;
|
||||
|
||||
@@ -349,6 +349,7 @@ body.course.outline .nav-course-courseware-outline,
|
||||
body.course.updates .nav-course-courseware-updates,
|
||||
body.course.pages .nav-course-courseware-pages,
|
||||
body.course.uploads .nav-course-courseware-uploads,
|
||||
body.course.textbooks .nav-course-courseware-textbooks,
|
||||
|
||||
// course settings
|
||||
body.course.schedule .nav-course-settings .title,
|
||||
|
||||
574
cms/static/sass/views/_textbooks.scss
Normal file
574
cms/static/sass/views/_textbooks.scss
Normal file
@@ -0,0 +1,574 @@
|
||||
// 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 @@
|
||||
<h3 class="sr">Page Actions</h3>
|
||||
<ul>
|
||||
<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>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -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.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-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/sets/wiki/set.js')}"></script>
|
||||
|
||||
|
||||
14
cms/templates/js/edit-chapter.underscore
Normal file
14
cms/templates/js/edit-chapter.underscore
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
28
cms/templates/js/edit-textbook.underscore
Normal file
28
cms/templates/js/edit-textbook.underscore
Normal file
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
3
cms/templates/js/no-textbooks.underscore
Normal file
3
cms/templates/js/no-textbooks.underscore
Normal file
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
49
cms/templates/js/show-textbook.underscore
Normal file
49
cms/templates/js/show-textbook.underscore
Normal file
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
55
cms/templates/js/upload-dialog.underscore
Normal file
55
cms/templates/js/upload-dialog.underscore
Normal file
@@ -0,0 +1,55 @@
|
||||
<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>
|
||||
84
cms/templates/textbooks.html
Normal file
84
cms/templates/textbooks.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<%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">> </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>
|
||||
@@ -1,4 +1,6 @@
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<div class="wrapper-header wrapper" id="view-top">
|
||||
<header class="primary" role="banner">
|
||||
|
||||
@@ -8,7 +10,7 @@
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
<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))}">
|
||||
<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>
|
||||
@@ -16,26 +18,28 @@
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<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="nav-sub">
|
||||
<ul>
|
||||
<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 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 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 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 & Uploads</a>
|
||||
<a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Files & 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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -43,22 +47,22 @@
|
||||
</li>
|
||||
|
||||
<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="nav-sub">
|
||||
<ul>
|
||||
<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 & 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 & Details")}</a>
|
||||
</li>
|
||||
<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 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 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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -66,19 +70,19 @@
|
||||
</li>
|
||||
|
||||
<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="nav-sub">
|
||||
<ul>
|
||||
<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 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 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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -122,10 +126,10 @@
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<li class="nav-item nav-account-dashboard">
|
||||
<a href="/">My Courses</a>
|
||||
<a href="/">${_("My Courses")}</a>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -136,19 +140,19 @@
|
||||
|
||||
% else:
|
||||
<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>
|
||||
<li class="nav-item nav-not-signedin-hiw">
|
||||
<a href="/">How Studio Works</a>
|
||||
<a href="/">${_("How Studio Works")}</a>
|
||||
</li>
|
||||
<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 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 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>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
10
cms/urls.py
10
cms/urls.py
@@ -81,6 +81,12 @@ urlpatterns = ('', # nopep8
|
||||
'contentstore.views.asset_index', name='asset_index'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)/remove$',
|
||||
'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
|
||||
url(r'^module_info/(?P<module_location>.*)$',
|
||||
@@ -94,9 +100,6 @@ urlpatterns = ('', # nopep8
|
||||
url(r'^not_found$', 'contentstore.views.not_found', name='not_found'),
|
||||
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
|
||||
url(r'^edge$', 'contentstore.views.edge', name='edge'),
|
||||
# noop to squelch ajax errors
|
||||
@@ -151,5 +154,6 @@ urlpatterns += (url(r'^admin/', include(admin.site.urls)),)
|
||||
urlpatterns = patterns(*urlpatterns)
|
||||
|
||||
# Custom error pages
|
||||
#pylint: disable=C0103
|
||||
handler404 = 'contentstore.views.render_404'
|
||||
handler500 = 'contentstore.views.render_500'
|
||||
|
||||
@@ -24,17 +24,21 @@ class StaticContentServer(object):
|
||||
if content is None:
|
||||
# nope, not in cache, let's fetch from DB
|
||||
try:
|
||||
content = contentstore().find(loc)
|
||||
content = contentstore().find(loc, as_stream=True)
|
||||
except NotFoundError:
|
||||
response = HttpResponse()
|
||||
response.status_code = 404
|
||||
return response
|
||||
|
||||
# since we fetched it from DB, let's cache it going forward
|
||||
set_cached_content(content)
|
||||
# since we fetched it from DB, let's cache it going forward, but only if it's < 1MB
|
||||
# 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:
|
||||
# @todo: we probably want to have 'cache hit' counters so we can
|
||||
# measure the efficacy of our caches
|
||||
# NOP here, but we may wish to add a "cache-hit" counter in the future
|
||||
pass
|
||||
|
||||
# see if the last-modified at hasn't changed, if not return a 302 (Not Modified)
|
||||
@@ -50,7 +54,7 @@ class StaticContentServer(object):
|
||||
if if_modified_since == last_modified_at_str:
|
||||
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
|
||||
|
||||
return response
|
||||
|
||||
@@ -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
|
||||
success_condition).
|
||||
"""
|
||||
assert is_css_present(css_selector)
|
||||
attempt = 0
|
||||
result = False
|
||||
while attempt < max_attempts:
|
||||
assert is_css_present(css_selector), "{} is not present".format(css_selector)
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
world.css_find(css_selector)[index].click()
|
||||
if success_condition():
|
||||
result = True
|
||||
break
|
||||
return
|
||||
except WebDriverException:
|
||||
# Occasionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
world.wait(1)
|
||||
attempt += 1
|
||||
except:
|
||||
attempt += 1
|
||||
return result
|
||||
pass
|
||||
else:
|
||||
# try once more, letting exceptions raise
|
||||
world.css_find(css_selector)[index].click()
|
||||
if not success_condition():
|
||||
raise Exception("unsuccessful click")
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -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
|
||||
success_condition).
|
||||
"""
|
||||
assert is_css_present(css_selector)
|
||||
attempt = 0
|
||||
result = False
|
||||
while attempt < max_attempts:
|
||||
assert is_css_present(css_selector), "{} is not present".format(css_selector)
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
world.css_find(css_selector)[index].check()
|
||||
if success_condition():
|
||||
result = True
|
||||
break
|
||||
return
|
||||
except WebDriverException:
|
||||
# Occasionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
world.wait(1)
|
||||
attempt += 1
|
||||
except:
|
||||
attempt += 1
|
||||
return result
|
||||
pass
|
||||
else:
|
||||
# try once more, letting exceptions raise
|
||||
world.css_find(css_selector)[index].check()
|
||||
if not success_condition():
|
||||
raise Exception("unsuccessful check")
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -143,7 +143,7 @@ def id_click(elem_id):
|
||||
|
||||
@world.absorb
|
||||
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)
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ def css_html(css_selector, index=0, max_attempts=5):
|
||||
|
||||
@world.absorb
|
||||
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
|
||||
|
||||
|
||||
@@ -203,10 +203,16 @@ def dialogs_closed():
|
||||
def save_the_html(path='/tmp'):
|
||||
url = world.browser.url
|
||||
html = world.browser.html.encode('ascii', 'ignore')
|
||||
filename = '%s.html' % quote_plus(url)
|
||||
file = open('%s/%s' % (path, filename), 'w')
|
||||
file.write(html)
|
||||
file.close()
|
||||
filename = "{path}/{name}.html".format(path=path, name=quote_plus(url))
|
||||
with open(filename, "w") as f:
|
||||
f.write(html)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from functools import wraps
|
||||
import copy
|
||||
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):
|
||||
@@ -21,3 +25,22 @@ def expect_json(view_function):
|
||||
return view_function(request, *args, **kwargs)
|
||||
|
||||
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)
|
||||
|
||||
62
common/djangoapps/util/tests/test_json_request.py
Normal file
62
common/djangoapps/util/tests/test_json_request.py
Normal file
@@ -0,0 +1,62 @@
|
||||
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
|
||||
|
||||
|
||||
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.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.data = data
|
||||
self._data = data
|
||||
self.length = length
|
||||
self.last_modified_at = last_modified_at
|
||||
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
|
||||
@@ -45,6 +47,10 @@ class StaticContent(object):
|
||||
def get_url_path(self):
|
||||
return StaticContent.get_url_path_from_location(self.location)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._data
|
||||
|
||||
@staticmethod
|
||||
def get_url_path_from_location(location):
|
||||
if location is not None:
|
||||
@@ -80,6 +86,35 @@ class StaticContent(object):
|
||||
loc = StaticContent.compute_location(course_namespace.org, course_namespace.course, path)
|
||||
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):
|
||||
'''
|
||||
@@ -113,7 +148,7 @@ class ContentStore(object):
|
||||
'''
|
||||
raise NotImplementedError
|
||||
|
||||
def generate_thumbnail(self, content):
|
||||
def generate_thumbnail(self, content, tempfile_path=None):
|
||||
thumbnail_content = None
|
||||
# use a naming convention to associate originals with the thumbnail
|
||||
thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name)
|
||||
@@ -129,7 +164,10 @@ class ContentStore(object):
|
||||
# My understanding is that PIL will maintain aspect ratios while restricting
|
||||
# the max-height/width to be whatever you pass in as 'size'
|
||||
# @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
|
||||
# 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
|
||||
|
||||
import logging
|
||||
|
||||
from .content import StaticContent, ContentStore
|
||||
from .content import StaticContent, ContentStore, StaticContentStream
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from fs.osfs import OSFS
|
||||
import os
|
||||
@@ -35,8 +35,11 @@ class MongoContentStore(ContentStore):
|
||||
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,
|
||||
import_path=content.import_path) as fp:
|
||||
|
||||
fp.write(content.data)
|
||||
if hasattr(content.data, '__iter__'):
|
||||
for chunk in content.data:
|
||||
fp.write(chunk)
|
||||
else:
|
||||
fp.write(content.data)
|
||||
|
||||
return content
|
||||
|
||||
@@ -44,20 +47,42 @@ class MongoContentStore(ContentStore):
|
||||
if self.fs.exists({"_id": 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)
|
||||
try:
|
||||
with self.fs.get(id) as fp:
|
||||
return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
|
||||
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)
|
||||
if as_stream:
|
||||
fp = self.fs.get(id)
|
||||
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,
|
||||
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:
|
||||
if throw_on_not_found:
|
||||
raise NotFoundError()
|
||||
else:
|
||||
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):
|
||||
content = self.find(location)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.test import TestCase
|
||||
from django.conf import settings
|
||||
import xmodule.modulestore.django
|
||||
from xmodule.templates import update_templates
|
||||
from unittest.util import safe_repr
|
||||
|
||||
|
||||
def mongo_store_config(data_dir):
|
||||
@@ -183,3 +184,35 @@ class ModuleStoreTestCase(TestCase):
|
||||
|
||||
# Call superclass implementation
|
||||
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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
window.gettext = window.ngettext = function(){};
|
||||
window.gettext = window.ngettext = function(s){return s;};
|
||||
|
||||
11
common/static/js/vendor/backbone-associations-min.js
vendored
Normal file
11
common/static/js/vendor/backbone-associations-min.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
(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);
|
||||
244
common/static/js/vendor/jasmine-stealth.js
vendored
Normal file
244
common/static/js/vendor/jasmine-stealth.js
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
// 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 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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-well {
|
||||
@include box-shadow(inset 0 1px 2px 1px $shadow);
|
||||
padding: ($baseline*0.75);
|
||||
}
|
||||
|
||||
|
||||
BIN
common/test/data/uploads/textbook.pdf
Normal file
BIN
common/test/data/uploads/textbook.pdf
Normal file
Binary file not shown.
@@ -19,6 +19,7 @@ django-sekizai==0.6.1
|
||||
django-ses==0.4.1
|
||||
django-storages==1.1.5
|
||||
django-threaded-multihost==1.4-1
|
||||
django-method-override==0.1.0
|
||||
django==1.4.5
|
||||
feedparser==5.1.3
|
||||
fs==0.4.0
|
||||
|
||||
Reference in New Issue
Block a user