Merge remote-tracking branch 'origin/master' into fix/vik/studio-oe

This commit is contained in:
Vik Paruchuri
2013-07-11 10:52:50 -04:00
752 changed files with 19052 additions and 6652 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* -text

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ node_modules
.prereqs_cache
autodeploy.properties
.ws_migrations_complete
.vagrant/

View File

@@ -78,3 +78,5 @@ Peter Fogg <peter.p.fogg@gmail.com>
Bethany LaPenta <lapentab@mit.edu>
Renzo Lucioni <renzolucioni@gmail.com>
Felix Sun <felixsun@mit.edu>
Adam Palay <adam@edx.org>
Ian Hoover <ihoover@edx.org>

View File

@@ -5,14 +5,36 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Common: Added *experimental* support for jsinput type.
Common: Added setting to specify Celery Broker vhost
Studio: Add table for tracking course creator permissions (not yet used).
Update rake django-admin[syncdb] and rake django-admin[migrate] so they
run for both LMS and CMS.
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
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
LMS: Removed press releases
Common: Updated Sass and Bourbon libraries, added Neat library
LMS: Users are no longer auto-activated if they click "reset password"
This is now done when they click on the link in the reset password
email they receive (along with usual path through activation email).
LMS: Fixed a reflected XSS problem in the static textbook views.
LMS: Problem rescoring. Added options on the Grades tab of the
Instructor Dashboard to allow a particular student's submission for a
particular problem to be rescored. Provides an option to see a

View File

@@ -1,7 +1,8 @@
source 'https://rubygems.org'
gem 'rake', '~> 10.0.3'
gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6'
gem 'sass', '3.2.9'
gem 'bourbon', '~> 3.1.8'
gem 'neat', '~> 1.3.0'
gem 'colorize', '~> 0.5.8'
gem 'launchy', '~> 2.1.2'
gem 'sys-proctable', '~> 0.9.3'

10
LICENSE
View File

@@ -659,3 +659,13 @@ specific requirements.
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.
EdX Inc. wishes to state, in clarification of the above license terms, that
any public, independently available web service offered over the network and
communicating with edX's copyrighted works by any form of inter-service
communication, including but not limited to Remote Procedure Call (RPC)
interfaces, is not a work based on our copyrighted work within the meaning
of the license. "Corresponding Source" of this work, or works based on this
work, as defined by the terms of this license do not include source code
files for programs used solely to provide those public, independently
available web services.

138
README.md
View File

@@ -2,8 +2,136 @@ This is the main edX platform which consists of LMS and Studio.
See [code.edx.org](http://code.edx.org/) for other parts of the edX code base.
Installation
============
Installation - The first time
=============================
The following instructions will help you to download and setup a virtual machine
with a minimal amount of steps, using Vagrant. It is recommended for a first
installation, as it will save you from many of the common pitfalls of the
installation process.
1. Make sure you have plenty of available disk space, >5GB
2. Install Git: http://git-scm.com/downloads
3. Install VirtualBox: https://www.virtualbox.org/wiki/Download_Old_Builds_4_2
(you need version 4.2.12, as later/earlier versions might not work well with
Vagrant)
4. Install Vagrant: http://www.vagrantup.com/ (Vagrant 1.2.2 or later)
5. Open a terminal
6. Download the project: `git clone git://github.com/edx/edx-platform.git`
7. Enter the project directory: `cd edx-platform/`
8. (Windows only) Run the commands to
[deal with line endings and symlinks under Windows](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#dealing-with-line-endings-and-symlinks-under-windows)
9. Start: `vagrant up`
The last step might require your host machine's administrator password to setup NFS.
Afterwards, it will download an image, install all the dependencies and configure
the VM. It will take a while, go grab a coffee.
Once completed, hopefully you should see a "Success!" message indicating that the
installation went fine. (If not, refer to the
[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting).)
Note: by default, the VM will get the IP `192.168.20.40`. If you need to use a
different IP, you can edit the file `Vagrantfile`. If you have already started the
VM with `vagrant up`, see "Stopping and restarting the VM" below to take the change
into account.
Accessing the VM
----------------
Once the installation is finished, to log into the virtual machine:
```
$ vagrant ssh
```
Note: This won't work from Windows, install install PuTTY from
http://www.chiark.greenend.org.uk/%7Esgtatham/putty/download.html instead. Then
connect to 127.0.0.1, port 2222, using vagrant/vagrant as a user/password.
Using edX
---------
Once inside the VM, you can start Studio and LMS with the following commands
(from the `/opt/edx/edx-platform` folder):
Learning management system (LMS):
```
$ rake lms[cms.dev,0.0.0.0:8000]
```
Studio:
```
$ rake cms[dev,0.0.0.0:8001]
```
Once started, open the following URLs in your browser:
* Learning management system (LMS): http://192.168.20.40:8000/
* Studio (CMS): http://192.168.20.40:8001/
You can develop by editing the files directly in the `edx-platform/` directory you
downloaded before, you don't need to connect to the VM to edit them (the VM uses
those files to run edX, mirroring the folder in `/opt/edx/edx-platform`).
You may also want to create a super-user with:
```
$ rake django-admin["createsuperuser"]
```
Also note that if you register a new user through the web interface,
the activiation email will be posted to your VM's terminal window (search for
lines similar to):
```
Subject: Your account for edX Studio
From: registration@edx.org
```
and find the activation URL for the account you've created.
See the [Frequently Asked Questions](https://github.com/edx/edx-platform/wiki/Frequently-Asked-Questions)
for more usage tips.
Stopping & starting
-------------------
To stop the VM (from your `edx-platform/` directory):
```
$ vagrant halt
```
To restart:
```
$ vagrant up
```
or, to start without attempting to update the dependencies:
```
$ vagrant up --no-provision
```
Troubleshooting
---------------
If anything doesn't work as expected, see the
[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting).
Installation - Advanced
=======================
Note: The following installation instructions are for advanced users & developers
who are familiar with setting up Python, Ruby & node.js virtual environments.
Even if you know what you are doing, edX has a large code base with multiple
dependencies, so you might still want to use the method described above the
first time, as Vagrant helps avoiding issues due to the different environments.
There is a `scripts/create-dev-env.sh` that will attempt to set up a development
environment.
@@ -152,6 +280,12 @@ otherwise noted.
Please see ``LICENSE.txt`` for details.
Documentation
------------
High-level documentation of the code is located in the `doc` subdirectory. Start
with `overview.md` to get an introduction to the architecture of the system.
How to Contribute
-----------------

33
Vagrantfile vendored Normal file
View File

@@ -0,0 +1,33 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
config.vm.box = "precise32"
config.vm.box_url = "http://files.vagrantup.com/precise32.box"
config.vm.network :forwarded_port, guest: 8000, host: 9000
config.vm.network :forwarded_port, guest: 8001, host: 9001
# Create a private network, which allows host-only access to the machine
# using a specific IP.
config.vm.network :private_network, ip: "192.168.20.40"
nfs_setting = RUBY_PLATFORM =~ /darwin/ || RUBY_PLATFORM =~ /linux/
config.vm.synced_folder ".", "/opt/edx/edx-platform", id: "vagrant-root", :nfs => nfs_setting
# Make it so that network access from the vagrant guest is able to
# use SSH private keys that are present on the host without copying
# them into the VM.
config.ssh.forward_agent = true
config.vm.provider :virtualbox do |vb|
# Use VBoxManage to customize the VM. For example to change memory:
vb.customize ["modifyvm", :id, "--memory", "1024"]
# This setting makes it so that network access from inside the vagrant guest
# is able to resolve DNS using the hosts VPN connection.
vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
end
config.vm.provision :shell, :path => "scripts/vagrant-provisioning.sh"
end

View File

@@ -36,7 +36,7 @@ def get_course_groupname_for_role(location, role):
def get_users_in_course_group_by_role(location, role):
groupname = get_course_groupname_for_role(location, role)
(group, created) = Group.objects.get_or_create(name=groupname)
(group, _created) = Group.objects.get_or_create(name=groupname)
return group.user_set.all()
@@ -59,6 +59,7 @@ def create_new_course_group(creator, location, role):
return
def _delete_course_group(location):
"""
This is to be called only by either a command line code path or through a app which has already
@@ -75,6 +76,7 @@ def _delete_course_group(location):
user.groups.remove(staff)
user.save()
def _copy_course_group(source, dest):
"""
This is to be called only by either a command line code path or through an app which has already
@@ -205,3 +207,29 @@ def is_user_in_creator_group(user):
return user.groups.filter(name=COURSE_CREATOR_GROUP_NAME).count() > 0
return True
def get_users_with_instructor_role():
"""
Returns all users with the role 'instructor'
"""
return _get_users_with_role(INSTRUCTOR_ROLE_NAME)
def get_users_with_staff_role():
"""
Returns all users with the role 'staff'
"""
return _get_users_with_role(STAFF_ROLE_NAME)
def _get_users_with_role(role):
"""
Returns all users with the specified role.
"""
users = set()
for group in Group.objects.all():
if group.name.startswith(role + "_"):
for user in group.user_set.all():
users.add(user)
return users

View File

@@ -9,7 +9,8 @@ from django.core.exceptions import PermissionDenied
from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group,\
create_all_course_groups, add_user_to_course_group, STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME,\
is_user_in_course_group_role, remove_user_from_course_group
is_user_in_course_group_role, remove_user_from_course_group, get_users_with_staff_role,\
get_users_with_instructor_role
class CreatorGroupTest(TestCase):
@@ -174,3 +175,28 @@ class CourseGroupTest(TestCase):
create_all_course_groups(self.creator, self.location)
with self.assertRaises(PermissionDenied):
remove_user_from_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME)
def test_get_staff(self):
# Do this test with staff in 2 different classes.
create_all_course_groups(self.creator, self.location)
add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
location2 = 'i4x', 'mitX', '103', 'course2', 'test2'
staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo')
create_all_course_groups(self.creator, location2)
add_user_to_course_group(self.creator, staff2, location2, STAFF_ROLE_NAME)
self.assertSetEqual({self.staff, staff2, self.creator}, get_users_with_staff_role())
def test_get_instructor(self):
# Do this test with creators in 2 different classes.
create_all_course_groups(self.creator, self.location)
add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
location2 = 'i4x', 'mitX', '103', 'course2', 'test2'
creator2 = User.objects.create_user('testcreator2', 'testcreator2+courses@edx.org', 'foo')
staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo')
create_all_course_groups(creator2, location2)
add_user_to_course_group(creator2, staff2, location2, STAFF_ROLE_NAME)
self.assertSetEqual({self.creator, creator2}, get_users_with_instructor_role())

View 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

View File

@@ -2,7 +2,7 @@
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_false, assert_equal, assert_regexp_matches, 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$')

View File

@@ -115,7 +115,7 @@ def clickActionLink(checklist, task, actionText):
# text will be empty initially, wait for it to populate
def verify_action_link_text(driver):
return action_link.text == actionText
return world.css_text('#course-checklist' + str(checklist) + ' a', index=task) == actionText
world.wait_for(verify_action_link_text)
action_link.click()
world.css_click('#course-checklist' + str(checklist) + ' a', index=task)

View File

@@ -0,0 +1,69 @@
Feature: Component Adding
As a course author, I want to be able to add a wide variety of components
@skip
Scenario: I can add components
Given I have opened a new course in studio
And I am editing a new unit
When I add the following components:
| Component |
| Discussion |
| Blank HTML |
| LaTex |
| Blank Problem|
| Dropdown |
| Multi Choice |
| Numerical |
| Text Input |
| Advanced |
| Circuit |
| Custom Python|
| Image Mapped |
| Math Input |
| Problem LaTex|
| Adaptive Hint|
| Video |
Then I see the following components:
| Component |
| Discussion |
| Blank HTML |
| LaTex |
| Blank Problem|
| Dropdown |
| Multi Choice |
| Numerical |
| Text Input |
| Advanced |
| Circuit |
| Custom Python|
| Image Mapped |
| Math Input |
| Problem LaTex|
| Adaptive Hint|
| Video |
@skip
Scenario: I can delete Components
Given I have opened a new course in studio
And I am editing a new unit
And I add the following components:
| Component |
| Discussion |
| Blank HTML |
| LaTex |
| Blank Problem|
| Dropdown |
| Multi Choice |
| Numerical |
| Text Input |
| Advanced |
| Circuit |
| Custom Python|
| Image Mapped |
| Math Input |
| Problem LaTex|
| Adaptive Hint|
| Video |
When I will confirm all alerts
And I delete all components
Then I see no components

View File

@@ -0,0 +1,126 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true
DATA_LOCATION = 'i4x://edx/templates'
@step(u'I am editing a new unit')
def add_unit(step):
css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item',
'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item']
for selector in css_selectors:
world.css_click(selector)
@step(u'I add the following components:')
def add_components(step):
for component in [step_hash['Component'] for step_hash in step.hashes]:
assert component in COMPONENT_DICTIONARY
for css in COMPONENT_DICTIONARY[component]['steps']:
world.css_click(css)
@step(u'I see the following components')
def check_components(step):
for component in [step_hash['Component'] for step_hash in step.hashes]:
assert component in COMPONENT_DICTIONARY
assert_true(COMPONENT_DICTIONARY[component]['found_func'](), "{} couldn't be found".format(component))
@step(u'I delete all components')
def delete_all_components(step):
for _ in range(len(COMPONENT_DICTIONARY)):
world.css_click('a.delete-button')
@step(u'I see no components')
def see_no_components(steps):
assert world.is_css_not_present('li.component')
def step_selector_list(data_type, path, index=1):
selector_list = ['a[data-type="{}"]'.format(data_type)]
if index != 1:
selector_list.append('a[id="ui-id-{}"]'.format(index))
if path is not None:
selector_list.append('a[data-location="{}/{}/{}"]'.format(DATA_LOCATION, data_type, path))
return selector_list
def found_text_func(text):
return lambda: world.browser.is_text_present(text)
def found_css_func(css):
return lambda: world.is_css_present(css, wait_time=2)
COMPONENT_DICTIONARY = {
'Discussion': {
'steps': step_selector_list('discussion', None),
'found_func': found_css_func('section.xmodule_DiscussionModule')
},
'Blank HTML': {
'steps': step_selector_list('html', 'Blank_HTML_Page'),
#this one is a blank html so a more refined search is being done
'found_func': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')]
},
'LaTex': {
'steps': step_selector_list('html', 'E-text_Written_in_LaTeX'),
'found_func': found_text_func('EXAMPLE: E-TEXT PAGE')
},
'Blank Problem': {
'steps': step_selector_list('problem', 'Blank_Common_Problem'),
'found_func': found_text_func('BLANK COMMON PROBLEM')
},
'Dropdown': {
'steps': step_selector_list('problem', 'Dropdown'),
'found_func': found_text_func('DROPDOWN')
},
'Multi Choice': {
'steps': step_selector_list('problem', 'Multiple_Choice'),
'found_func': found_text_func('MULTIPLE CHOICE')
},
'Numerical': {
'steps': step_selector_list('problem', 'Numerical_Input'),
'found_func': found_text_func('NUMERICAL INPUT')
},
'Text Input': {
'steps': step_selector_list('problem', 'Text_Input'),
'found_func': found_text_func('TEXT INPUT')
},
'Advanced': {
'steps': step_selector_list('problem', 'Blank_Advanced_Problem', index=2),
'found_func': found_text_func('BLANK ADVANCED PROBLEM')
},
'Circuit': {
'steps': step_selector_list('problem', 'Circuit_Schematic_Builder', index=2),
'found_func': found_text_func('CIRCUIT SCHEMATIC BUILDER')
},
'Custom Python': {
'steps': step_selector_list('problem', 'Custom_Python-Evaluated_Input', index=2),
'found_func': found_text_func('CUSTOM PYTHON-EVALUATED INPUT')
},
'Image Mapped': {
'steps': step_selector_list('problem', 'Image_Mapped_Input', index=2),
'found_func': found_text_func('IMAGE MAPPED INPUT')
},
'Math Input': {
'steps': step_selector_list('problem', 'Math_Expression_Input', index=2),
'found_func': found_text_func('MATH EXPRESSION INPUT')
},
'Problem LaTex': {
'steps': step_selector_list('problem', 'Problem_Written_in_LaTeX', index=2),
'found_func': found_text_func('PROBLEM WRITTEN IN LATEX')
},
'Adaptive Hint': {
'steps': step_selector_list('problem', 'Problem_with_Adaptive_Hint', index=2),
'found_func': found_text_func('PROBLEM WITH ADAPTIVE HINT')
},
'Video': {
'steps': step_selector_list('video', None),
'found_func': found_css_func('section.xmodule_VideoModule')
}
}

View File

@@ -60,8 +60,7 @@ def change_date(_step, new_date):
@step(u'I should see the date "([^"]*)"$')
def check_date(_step, date):
date_css = 'span.date-display'
date_html = world.css_find(date_css)
assert date == date_html.html
assert date == world.css_html(date_css)
@step(u'I modify the handout to "([^"]*)"$')
@@ -74,8 +73,7 @@ def edit_handouts(_step, text):
@step(u'I see the handout "([^"]*)"$')
def check_handout(_step, handout):
handout_css = 'div.handouts-content'
handouts = world.css_find(handout_css)
assert handout in handouts.html
assert handout in world.css_html(handout_css)
def change_text(text):

View File

@@ -47,7 +47,7 @@ def confirm_change(step):
range_css = '.range'
all_ranges = world.css_find(range_css)
for i in range(len(all_ranges)):
assert all_ranges[i].html != '0-50'
assert world.css_html(range_css, index=i) != '0-50'
@step(u'I change assignment type "([^"]*)" to "([^"]*)"$')

View File

@@ -1,30 +1,35 @@
Feature: Problem Editor
As a course author, I want to be able to create problems and edit their settings.
@skip
Scenario: User can view metadata
Given I have created a Blank Common Problem
When I edit and select Settings
Then I see five alphabetized settings and their expected values
And Edit High Level Source is not visible
@skip
Scenario: User can modify String values
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can modify the display name
And my display name change is persisted on save
@skip
Scenario: User can specify special characters in String values
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can specify special characters in the display name
And my special characters and persisted on save
@skip
Scenario: User can revert display name to unset
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can revert the display name to unset
And my display name is unset on save
@skip
Scenario: User can select values in a Select
Given I have created a Blank Common Problem
When I edit and select Settings
@@ -32,6 +37,7 @@ Feature: Problem Editor
And my change to randomization is persisted
And I can revert to the default value for randomization
@skip
Scenario: User can modify float input values
Given I have created a Blank Common Problem
When I edit and select Settings
@@ -39,21 +45,25 @@ Feature: Problem Editor
And my change to weight is persisted
And I can revert to the default value of unset for weight
@skip
Scenario: User cannot type letters in float number field
Given I have created a Blank Common Problem
When I edit and select Settings
Then if I set the weight to "abc", it remains unset
@skip
Scenario: User cannot type decimal values integer number field
Given I have created a Blank Common Problem
When I edit and select Settings
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234"
@skip
Scenario: User cannot type out of range values in an integer number field
Given I have created a Blank Common Problem
When I edit and select Settings
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0"
@skip
Scenario: Settings changes are not saved on Cancel
Given I have created a Blank Common Problem
When I edit and select Settings
@@ -61,11 +71,13 @@ Feature: Problem Editor
And I can modify the display name
Then If I press Cancel my changes are not persisted
@skip
Scenario: Edit High Level source is available for LaTeX problem
Given I have created a LaTeX Problem
When I edit and select Settings
Then Edit High Level Source is visible
@skip
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
Given I have created a LaTeX Problem
When I edit and compile the High Level Source

View File

@@ -3,6 +3,7 @@ Feature: Create Section
As a course author
I want to create and edit sections
@skip
Scenario: Add a new section to a course
Given I have opened a new course in Studio
When I click the New Section link

View File

@@ -9,14 +9,14 @@ from selenium.webdriver.common.keys import Keys
def go_to_static(_step):
menu_css = 'li.nav-course-courseware'
static_css = 'li.nav-course-courseware-pages'
world.css_find(menu_css).click()
world.css_find(static_css).click()
world.css_click(menu_css)
world.css_click(static_css)
@step(u'I add a new page')
def add_page(_step):
button_css = 'a.new-button'
world.css_find(button_css).click()
world.css_click(button_css)
@step(u'I should( not)? see a "([^"]*)" static page$')
@@ -33,13 +33,13 @@ def click_edit_delete(_step, edit_delete, page):
button_css = 'a.%s-button' % edit_delete
index = get_index(page)
assert index != -1
world.css_find(button_css)[index].click()
world.css_click(button_css, index=index)
@step(u'I change the name to "([^"]*)"$')
def change_name(_step, new_name):
settings_css = '#settings-mode'
world.css_find(settings_css).click()
world.css_click(settings_css)
input_css = 'input.setting-input'
name_input = world.css_find(input_css)
old_name = name_input.value
@@ -47,13 +47,13 @@ def change_name(_step, new_name):
name_input._element.send_keys(Keys.END, Keys.BACK_SPACE)
name_input._element.send_keys(new_name)
save_button = 'a.save-button'
world.css_find(save_button).click()
world.css_click(save_button)
def get_index(name):
page_name_css = 'section[data-type="HTMLModule"]'
all_pages = world.css_find(page_name_css)
for i in range(len(all_pages)):
if all_pages[i].html == '\n {name}\n'.format(name=name):
if world.css_html(page_name_css, index=i) == '\n {name}\n'.format(name=name):
return i
return -1

View File

@@ -32,6 +32,7 @@ Feature: Create Subsection
And I reload the page
Then I see the correct dates
@skip
Scenario: Delete a subsection
Given I have opened a new course section in Studio
And I have added a new subsection

View 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"

View 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)

View File

@@ -21,6 +21,7 @@ Feature: Upload Files
When I upload the file "test"
And I delete the file "test"
Then I should not see the file "test" was uploaded
And I see a confirmation that the file was deleted
Scenario: Users can download files
Given I have opened a new course in studio

View File

@@ -9,21 +9,21 @@ import random
import os
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
HTTP_PREFIX = "http://localhost:8001"
HTTP_PREFIX = "http://localhost:%s" % settings.LETTUCE_SERVER_PORT
@step(u'I go to the files and uploads page')
def go_to_uploads(_step):
menu_css = 'li.nav-course-courseware'
uploads_css = 'li.nav-course-courseware-uploads'
world.css_find(menu_css).click()
world.css_find(uploads_css).click()
world.css_click(menu_css)
world.css_click(uploads_css)
@step(u'I upload the file "([^"]*)"$')
def upload_file(_step, file_name):
upload_css = 'a.upload-button'
world.css_find(upload_css).click()
world.css_click(upload_css)
file_css = 'input.file-input'
upload = world.css_find(file_css)
@@ -32,7 +32,7 @@ def upload_file(_step, file_name):
upload._element.send_keys(os.path.abspath(path))
close_css = 'a.close-button'
world.css_find(close_css).click()
world.css_click(close_css)
@step(u'I should( not)? see the file "([^"]*)" was uploaded$')
@@ -67,7 +67,7 @@ def no_duplicate(_step, file_name):
all_names = world.css_find(names_css)
only_one = False
for i in range(len(all_names)):
if file_name == all_names[i].html:
if file_name == world.css_html(names_css, index=i):
only_one = not only_one
assert only_one
@@ -90,11 +90,17 @@ def modify_upload(_step, file_name):
cur_file.write(new_text)
@step('I see a confirmation that the file was deleted')
def i_see_a_delete_confirmation(_step):
alert_css = '#notification-confirmation'
assert world.is_css_present(alert_css)
def get_index(file_name):
names_css = 'td.name-col > a.filename'
all_names = world.css_find(names_css)
for i in range(len(all_names)):
if file_name == all_names[i].html:
if file_name == world.css_html(names_css, index=i):
return i
return -1

View File

@@ -18,6 +18,7 @@ Feature: Video Component
Given I have created a Video component
Then when I view the video it does show the captions
@skip
Scenario: Captions are toggled correctly
Given I have created a Video component
And I have toggled captions

View File

@@ -0,0 +1,48 @@
"""
Script for granting existing course instructors course creator privileges.
This script is only intended to be run once on a given environment.
"""
from auth.authz import get_users_with_instructor_role, get_users_with_staff_role
from course_creators.views import add_user_with_status_granted, add_user_with_status_unrequested
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from django.db.utils import IntegrityError
class Command(BaseCommand):
"""
Script for granting existing course instructors course creator privileges.
"""
help = 'Grants all users with INSTRUCTOR role permission to create courses'
def handle(self, *args, **options):
"""
The logic of the command.
"""
username = 'populate_creators_command'
email = 'grant+creator+access@edx.org'
try:
admin = User.objects.create_user(username, email, 'foo')
admin.is_staff = True
admin.save()
except IntegrityError:
# If the script did not complete the last time it was run,
# the admin user will already exist.
admin = User.objects.get(username=username, email=email)
for user in get_users_with_instructor_role():
add_user_with_status_granted(admin, user)
# Some users will be both staff and instructors. Those folks have been
# added with status granted above, and add_user_with_status_unrequested
# will not try to add them again if they already exist in the course creator database.
for user in get_users_with_staff_role():
add_user_with_status_unrequested(admin, user)
# There could be users who are not in either staff or instructor (they've
# never actually done anything in Studio). I plan to add those as unrequested
# when they first go to their dashboard.
admin.delete()

View 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")

View File

@@ -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)

View File

@@ -1,3 +1,5 @@
#pylint: disable=E1101
import json
import shutil
import mock
@@ -344,6 +346,28 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
err_cnt = perform_xlint('common/test/data', ['full'])
self.assertGreater(err_cnt, 0)
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*'])
def test_module_preview_in_whitelist(self):
'''
Tests the ajax callback to render an XModule
'''
direct_store = modulestore('direct')
import_from_xml(direct_store, 'common/test/data/', ['full'])
html_module_location = Location(['i4x', 'edX', 'full', 'html', 'html_90', None])
url = reverse('preview_component', kwargs={'location': html_module_location.url()})
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn('Inline content', resp.content)
# also try a custom response which will trigger the 'is this course in whitelist' logic
problem_module_location = Location(['i4x', 'edX', 'full', 'problem', 'H1P1_Energy', None])
url = reverse('preview_component', kwargs={'location': problem_module_location.url()})
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
def test_delete(self):
direct_store = modulestore('direct')
import_from_xml(direct_store, 'common/test/data/', ['full'])
@@ -612,18 +636,42 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertEqual(resp.status_code, 400)
def test_delete_course(self):
"""
This test will import a course, make a draft item, and delete it. This will also assert that the
draft content is also deleted
"""
module_store = modulestore('direct')
import_from_xml(module_store, 'common/test/data/', ['full'])
content_store = contentstore()
draft_store = modulestore('draft')
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
# verify that we actually have assets
assets = content_store.get_all_content_for_course(location)
self.assertNotEquals(len(assets), 0)
# get a vertical (and components in it) to put into 'draft'
vertical = module_store.get_item(Location(['i4x', 'edX', 'full',
'vertical', 'vertical_66', None]), depth=1)
draft_store.clone_item(vertical.location, vertical.location)
for child in vertical.get_children():
draft_store.clone_item(child.location, child.location)
# delete the course
delete_course(module_store, content_store, location, commit=True)
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
# assert that there's absolutely no non-draft modules in the course
# this should also include all draft items
items = draft_store.get_items(Location(['i4x', 'edX', 'full', None, None]))
self.assertEqual(len(items), 0)
# assert that all content in the asset library is also deleted
assets = content_store.get_all_content_for_course(location)
self.assertEqual(len(assets), 0)
def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''):
filesystem = OSFS(root_dir / 'test_export')
self.assertTrue(filesystem.exists(dirname))

View File

@@ -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,8 +72,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertEqual(jsondetails['string'], 'string')
def test_update_and_fetch(self):
# # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
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(
@@ -128,15 +94,20 @@ class CourseDetailsTestCase(CourseTestCase):
CourseDetails.update_from_json(jsondetails.__dict__).effort,
jsondetails.effort, "After set effort"
)
jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
self.assertEqual(
CourseDetails.update_from_json(jsondetails.__dict__).start_date,
jsondetails.start_date
)
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
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
}
)
@@ -158,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
}
)
@@ -200,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")
@@ -235,8 +207,7 @@ class CourseDetailsViewTest(CourseTestCase):
dt1 = date.from_json(encoded[field])
dt2 = details[field]
expected_delta = datetime.timedelta(0)
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
self.assertEqual(dt1, dt2, msg="{} != {} at {}".format(dt1, dt2, context))
else:
self.fail(field + " missing from encoded but in details at " + context)
elif field in encoded and encoded[field] is not None:
@@ -248,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")
@@ -304,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")
@@ -328,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")
@@ -345,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"}
)
@@ -384,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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,40 @@
"""Tests for CMS's requests to logs"""
import mock
from django.test import TestCase
from django.core.urlresolvers import reverse
from contentstore.views.requests import event as cms_user_track
class CMSLogTest(TestCase):
"""
Tests that request to logs from CMS return 204s
"""
def test_post_answers_to_log(self):
"""
Checks that student answer requests submitted to cms's "/event" url
via POST are correctly returned as 204s
"""
requests = [
{"event": "my_event", "event_type": "my_event_type", "page": "my_page"},
{"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"}
]
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_SQL_TRACKING_LOGS': True}):
for request_params in requests:
response = self.client.post(reverse(cms_user_track), request_params)
self.assertEqual(response.status_code, 204)
def test_get_answers_to_log(self):
"""
Checks that student answer requests submitted to cms's "/event" url
via GET are correctly returned as 204s
"""
requests = [
{"event": "my_event", "event_type": "my_event_type", "page": "my_page"},
{"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"}
]
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_SQL_TRACKING_LOGS': True}):
for request_params in requests:
response = self.client.get(reverse(cms_user_track), request_params)
self.assertEqual(response.status_code, 204)

View 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))

View 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")

View File

@@ -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):

View File

@@ -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',
)

View File

@@ -1,3 +1,5 @@
#pylint: disable=E1103, E1101
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore

View File

@@ -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 *

View File

@@ -2,12 +2,13 @@ from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME
from auth.authz import is_user_in_course_group_role
from django.core.exceptions import PermissionDenied
from ..utils import get_course_location_for_item
from xmodule.modulestore import Location
def get_location_and_verify_access(request, org, course, name):
"""
Create the location tuple verify that the user has permissions
to view the location. Returns the location.
Create the location, verify that the user has permissions
to view the location. Returns the location as a Location
"""
location = ['i4x', org, course, 'course', name]
@@ -15,7 +16,7 @@ def get_location_and_verify_access(request, org, course, name):
if not has_access(request.user, location):
raise PermissionDenied()
return location
return Location(location)
def has_access(user, location, role=STAFF_ROLE_NAME):

View File

@@ -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':
@@ -258,7 +303,7 @@ def import_course(request, org, course, name):
_module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
[course_subdir], load_error_modules=False,
static_content_store=contentstore(),
target_location_namespace=Location(location),
target_location_namespace=location,
draft_store=modulestore())
# we can blow this away when we're done importing.
@@ -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': ''
})

View File

@@ -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,14 +64,15 @@ 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)
checklists, modified = expand_checklist_action_urls(course_module)
# seeming noop which triggers kvs to record that the metadata is not default
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.",
@@ -79,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):

View File

@@ -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',
@@ -38,7 +39,8 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES',
log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
# NOTE: edit_unit assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
NOTE_COMPONENT_TYPES = ['notes']
@@ -208,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,
@@ -220,7 +221,7 @@ def edit_unit(request, location):
'section': containing_section,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'unit_state': unit_state,
'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None
})
@@ -233,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
@@ -290,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):
@@ -299,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))
@@ -308,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))

View File

@@ -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
@@ -153,10 +168,9 @@ def course_info(request, org, course, name, provided_id=None):
course_module = modulestore().get_item(location)
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
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()

View File

@@ -1,20 +1,47 @@
from django.http import HttpResponseServerError, HttpResponseNotFound
#pylint: disable=C0111,W0613
from django.http import (HttpResponse, HttpResponseServerError,
HttpResponseNotFound)
from mitxmako.shortcuts import render_to_string, render_to_response
import functools
import json
__all__ = ['not_found', 'server_error', 'render_404', 'render_500']
def jsonable_error(status=500, message="The Studio servers encountered an error"):
"""
A decorator to make an error view return an JSON-formatted message if
it was requested via AJAX.
"""
def outer(func):
@functools.wraps(func)
def inner(request, *args, **kwargs):
if request.is_ajax():
content = json.dumps({"error": message})
return HttpResponse(content, content_type="application/json",
status=status)
else:
return func(request, *args, **kwargs)
return inner
return outer
@jsonable_error(404, "Resource not found")
def not_found(request):
return render_to_response('error.html', {'error': '404'})
@jsonable_error(500, "The Studio servers encountered an error")
def server_error(request):
return render_to_response('error.html', {'error': '500'})
@jsonable_error(404, "Resource not found")
def render_404(request):
return HttpResponseNotFound(render_to_string('404.html', {}))
@jsonable_error(500, "The Studio servers encountered an error")
def render_500(request):
return HttpResponseServerError(render_to_string('500.html', {}))

View File

@@ -17,10 +17,13 @@ from xmodule.modulestore.mongo import MongoUsage
from xmodule.x_module import ModuleSystem
from xblock.runtime import DbModel
from util.sandboxing import can_execute_unsafe_code
import static_replace
from .session_kv_store import SessionKeyValueStore
from .requests import render_from_lms
from .access import has_access
from ..utils import get_course_for_item
__all__ = ['preview_dispatch', 'preview_component']
@@ -65,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)
@@ -93,6 +96,8 @@ def preview_module_system(request, preview_id, descriptor):
MongoUsage(preview_id, descriptor.location.url()),
)
course_id = get_course_for_item(descriptor.location).location.course_id
return ModuleSystem(
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
@@ -104,6 +109,7 @@ def preview_module_system(request, preview_id, descriptor):
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
user=request.user,
xblock_model_data=preview_model_data,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
)

View File

@@ -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

View File

@@ -25,4 +25,4 @@ class SessionKeyValueStore(KeyValueStore):
del self._session[tuple(key)]
def has(self, key):
return key in self._descriptor_model_data or key in self._session
return key.field_name in self._descriptor_model_data or tuple(key) in self._session

View File

@@ -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,
})

View File

@@ -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"})

View File

@@ -0,0 +1,63 @@
"""
django admin page for the course creators table
"""
from course_creators.models import CourseCreator, update_creator_state
from course_creators.views import update_course_creator_group
from django.contrib import admin
from django.dispatch import receiver
def get_email(obj):
""" Returns the email address for a user """
return obj.user.email
get_email.short_description = 'email'
class CourseCreatorAdmin(admin.ModelAdmin):
"""
Admin for the course creator table.
"""
# Fields to display on the overview page.
list_display = ['user', get_email, 'state', 'state_changed', 'note']
readonly_fields = ['user', 'state_changed']
# Controls the order on the edit form (without this, read-only fields appear at the end).
fieldsets = (
(None, {
'fields': ['user', 'state', 'state_changed', 'note']
}),
)
# Fields that filtering support
list_filter = ['state', 'state_changed']
# Fields that search supports.
search_fields = ['user__username', 'user__email', 'state', 'note']
# Turn off the action bar (we have no bulk actions)
actions = None
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return request.user.is_staff
def save_model(self, request, obj, form, change):
# Store who is making the request.
obj.admin = request.user
obj.save()
admin.site.register(CourseCreator, CourseCreatorAdmin)
@receiver(update_creator_state, sender=CourseCreator)
def update_creator_group_callback(sender, **kwargs):
"""
Callback for when the model's creator status has changed.
"""
update_course_creator_group(kwargs['caller'], kwargs['user'], kwargs['add'])

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseCreator'
db.create_table('course_creators_coursecreator', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], unique=True)),
('state_changed', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('state', self.gf('django.db.models.fields.CharField')(default='unrequested', max_length=24)),
('note', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)),
))
db.send_create_signal('course_creators', ['CourseCreator'])
def backwards(self, orm):
# Deleting model 'CourseCreator'
db.delete_table('course_creators_coursecreator')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'course_creators.coursecreator': {
'Meta': {'object_name': 'CourseCreator'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'note': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'state': ('django.db.models.fields.CharField', [], {'default': "'unrequested'", 'max_length': '24'}),
'state_changed': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
}
}
complete_apps = ['course_creators']

View File

@@ -0,0 +1,71 @@
"""
Table for storing information about whether or not Studio users have course creation privileges.
"""
from django.db import models
from django.db.models.signals import post_init, post_save
from django.dispatch import receiver, Signal
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.translation import ugettext as _
# A signal that will be sent when users should be added or removed from the creator group
update_creator_state = Signal(providing_args=["caller", "user", "add"])
class CourseCreator(models.Model):
"""
Creates the database table model.
"""
UNREQUESTED = 'unrequested'
PENDING = 'pending'
GRANTED = 'granted'
DENIED = 'denied'
# Second value is the "human-readable" version.
STATES = (
(UNREQUESTED, _(u'unrequested')),
(PENDING, _(u'pending')),
(GRANTED, _(u'granted')),
(DENIED, _(u'denied')),
)
user = models.ForeignKey(User, help_text=_("Studio user"), unique=True)
state_changed = models.DateTimeField('state last updated', auto_now_add=True,
help_text=_("The date when state was last updated"))
state = models.CharField(max_length=24, blank=False, choices=STATES, default=UNREQUESTED,
help_text=_("Current course creator state"))
note = models.CharField(max_length=512, blank=True, help_text=_("Optional notes about this user (for example, "
"why course creation access was denied)"))
def __unicode__(self):
return u'%str | %str [%str] | %str' % (self.user, self.state, self.state_changed, self.note)
@receiver(post_init, sender=CourseCreator)
def post_init_callback(sender, **kwargs):
"""
Extend to store previous state.
"""
instance = kwargs['instance']
instance.orig_state = instance.state
@receiver(post_save, sender=CourseCreator)
def post_save_callback(sender, **kwargs):
"""
Extend to update state_changed time and modify the course creator group in authz.py.
"""
instance = kwargs['instance']
# We only wish to modify the state_changed time if the state has been modified. We don't wish to
# modify it for changes to the notes field.
if instance.state != instance.orig_state:
update_creator_state.send(
sender=sender,
caller=instance.admin,
user=instance.user,
add=instance.state == CourseCreator.GRANTED
)
instance.state_changed = timezone.now()
instance.orig_state = instance.state
instance.save()

View File

@@ -0,0 +1,80 @@
"""
Tests course_creators.admin.py.
"""
from django.test import TestCase
from django.contrib.auth.models import User
from django.contrib.admin.sites import AdminSite
from django.http import HttpRequest
import mock
from course_creators.admin import CourseCreatorAdmin
from course_creators.models import CourseCreator
from auth.authz import is_user_in_creator_group
class CourseCreatorAdminTest(TestCase):
"""
Tests for course creator admin.
"""
def setUp(self):
""" Test case setup """
self.user = User.objects.create_user('test_user', 'test_user+courses@edx.org', 'foo')
self.table_entry = CourseCreator(user=self.user)
self.table_entry.save()
self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo')
self.admin.is_staff = True
self.request = HttpRequest()
self.request.user = self.admin
self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite())
def test_change_status(self):
"""
Tests that updates to state impact the creator group maintained in authz.py.
"""
def change_state(state, is_creator):
""" Helper method for changing state """
self.table_entry.state = state
self.creator_admin.save_model(self.request, self.table_entry, None, True)
self.assertEqual(is_creator, is_user_in_creator_group(self.user))
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
# User is initially unrequested.
self.assertFalse(is_user_in_creator_group(self.user))
change_state(CourseCreator.GRANTED, True)
change_state(CourseCreator.DENIED, False)
change_state(CourseCreator.GRANTED, True)
change_state(CourseCreator.PENDING, False)
change_state(CourseCreator.GRANTED, True)
change_state(CourseCreator.UNREQUESTED, False)
def test_add_permission(self):
"""
Tests that staff cannot add entries
"""
self.assertFalse(self.creator_admin.has_add_permission(self.request))
def test_delete_permission(self):
"""
Tests that staff cannot delete entries
"""
self.assertFalse(self.creator_admin.has_delete_permission(self.request))
def test_change_permission(self):
"""
Tests that only staff can change entries
"""
self.assertTrue(self.creator_admin.has_change_permission(self.request))
self.request.user = self.user
self.assertFalse(self.creator_admin.has_change_permission(self.request))

View File

@@ -0,0 +1,71 @@
"""
Tests course_creators.views.py.
"""
from django.test import TestCase
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from course_creators.views import add_user_with_status_unrequested, add_user_with_status_granted
from course_creators.views import get_course_creator_status, update_course_creator_group
from course_creators.models import CourseCreator
from auth.authz import is_user_in_creator_group
import mock
class CourseCreatorView(TestCase):
"""
Tests for modifying the course creator table.
"""
def setUp(self):
""" Test case setup """
self.user = User.objects.create_user('test_user', 'test_user+courses@edx.org', 'foo')
self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo')
self.admin.is_staff = True
def test_staff_permission_required(self):
"""
Tests that add methods and course creator group method must be called with staff permissions.
"""
with self.assertRaises(PermissionDenied):
add_user_with_status_granted(self.user, self.user)
with self.assertRaises(PermissionDenied):
add_user_with_status_unrequested(self.user, self.user)
with self.assertRaises(PermissionDenied):
update_course_creator_group(self.user, self.user, True)
def test_table_initially_empty(self):
self.assertIsNone(get_course_creator_status(self.user))
def test_add_unrequested(self):
add_user_with_status_unrequested(self.admin, self.user)
self.assertEqual('unrequested', get_course_creator_status(self.user))
# Calling add again will be a no-op (even if state is different).
add_user_with_status_granted(self.admin, self.user)
self.assertEqual('unrequested', get_course_creator_status(self.user))
def test_add_granted(self):
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
# Calling add_user_with_status_granted impacts is_user_in_course_group_role.
self.assertFalse(is_user_in_creator_group(self.user))
add_user_with_status_granted(self.admin, self.user)
self.assertEqual('granted', get_course_creator_status(self.user))
# Calling add again will be a no-op (even if state is different).
add_user_with_status_unrequested(self.admin, self.user)
self.assertEqual('granted', get_course_creator_status(self.user))
self.assertTrue(is_user_in_creator_group(self.user))
def test_update_creator_group(self):
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertFalse(is_user_in_creator_group(self.user))
update_course_creator_group(self.admin, self.user, True)
self.assertTrue(is_user_in_creator_group(self.user))
update_course_creator_group(self.admin, self.user, False)
self.assertFalse(is_user_in_creator_group(self.user))

View File

@@ -0,0 +1,76 @@
"""
Methods for interacting programmatically with the user creator table.
"""
from course_creators.models import CourseCreator
from django.core.exceptions import PermissionDenied
from auth.authz import add_user_to_creator_group, remove_user_from_creator_group
def add_user_with_status_unrequested(caller, user):
"""
Adds a user to the course creator table with status 'unrequested'.
If the user is already in the table, this method is a no-op
(state will not be changed). Caller must have staff permissions.
"""
_add_user(caller, user, CourseCreator.UNREQUESTED)
def add_user_with_status_granted(caller, user):
"""
Adds a user to the course creator table with status 'granted'.
If the user is already in the table, this method is a no-op
(state will not be changed). Caller must have staff permissions.
This method also adds the user to the course creator group maintained by authz.py.
"""
_add_user(caller, user, CourseCreator.GRANTED)
update_course_creator_group(caller, user, True)
def update_course_creator_group(caller, user, add):
"""
Method for adding and removing users from the creator group.
Caller must have staff permissions.
"""
if add:
add_user_to_creator_group(caller, user)
else:
remove_user_from_creator_group(caller, user)
def get_course_creator_status(user):
"""
Returns the status for a particular user, or None if user is not in the table.
Possible return values are:
'unrequested' = user has not requested course creation rights
'pending' = user has requested course creation rights
'granted' = user has been granted course creation rights
'denied' = user has been denied course creation rights
None = user does not exist in the table
"""
user = CourseCreator.objects.filter(user=user)
if user.count() == 0:
return None
else:
# User is defined to be unique, can assume a single entry.
return user[0].state
def _add_user(caller, user, state):
"""
Adds a user to the course creator table with the specified state.
If the user is already in the table, this method is a no-op
(state will not be changed).
"""
if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
raise PermissionDenied
if CourseCreator.objects.filter(user=user).count() == 0:
entry = CourseCreator(user=user, state=state)
entry.save()

View File

@@ -74,7 +74,7 @@ class CourseDetails(object):
Decode the json into CourseDetails and save any changed attrs to the db
"""
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
course_location = jsondict['course_location']
course_location = Location(jsondict['course_location'])
# Will probably want to cache the inflight courses because every blur generates an update
descriptor = get_modulestore(course_location).get_item(course_location)

View File

@@ -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,

View File

@@ -16,19 +16,25 @@ DEBUG = True
# Disable warnings for acceptance tests, to make the logs readable
import logging
logging.disable(logging.ERROR)
import os
import random
def seed():
return os.getppid()
MODULESTORE_OPTIONS = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'acceptance_modulestore',
'db': 'acceptance_xmodule',
'collection': 'acceptance_modulestore_%s' % seed(),
'fs_root': TEST_ROOT / "data",
'render_template': 'mitxmako.shortcuts.render_to_string',
}
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'direct': {
@@ -36,7 +42,7 @@ MODULESTORE = {
'OPTIONS': MODULESTORE_OPTIONS
},
'draft': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
}
}
@@ -45,7 +51,7 @@ CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db': 'acceptance_xcontent',
'db': 'acceptance_xcontent_%s' % seed(),
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
@@ -61,13 +67,13 @@ CONTENTSTORE = {
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': TEST_ROOT / "db" / "test_mitx.db",
'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db",
'NAME': TEST_ROOT / "db" / "test_mitx_%s.db" % seed(),
'TEST_NAME': TEST_ROOT / "db" / "test_mitx_%s.db" % seed(),
}
}
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',)
LETTUCE_SERVER_PORT = 8001
LETTUCE_SERVER_PORT = random.randint(1024, 65535)
LETTUCE_BROWSER = 'chrome'

View File

@@ -0,0 +1,77 @@
"""
This config file extends the test environment configuration
so that we can run the lettuce acceptance tests.
This is used in the django-admin call as acceptance.py
contains random seeding, causing django-admin to create a random collection
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .test import *
# You need to start the server in debug mode,
# otherwise the browser will not render the pages correctly
DEBUG = True
# Disable warnings for acceptance tests, to make the logs readable
import logging
logging.disable(logging.ERROR)
import os
import random
MODULESTORE_OPTIONS = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'acceptance_xmodule',
'collection': 'acceptance_modulestore',
'fs_root': TEST_ROOT / "data",
'render_template': 'mitxmako.shortcuts.render_to_string',
}
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'draft': {
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
}
}
CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db': 'acceptance_xcontent',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
}
# Set this up so that rake lms[acceptance] and running the
# harvest command both use the same (test) database
# which they can flush without messing up your dev db
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': TEST_ROOT / "db" / "test_mitx.db",
'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db",
}
}
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',)
LETTUCE_SERVER_PORT = random.randint(1024, 65535)
LETTUCE_BROWSER = 'chrome'

View File

@@ -105,6 +105,8 @@ ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", [])
#Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
@@ -142,10 +144,12 @@ DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
# Celery Broker
CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "")
CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "")
CELERY_BROKER_VHOST = ENV_TOKENS.get("CELERY_BROKER_VHOST", "")
CELERY_BROKER_USER = AUTH_TOKENS.get("CELERY_BROKER_USER", "")
CELERY_BROKER_PASSWORD = AUTH_TOKENS.get("CELERY_BROKER_PASSWORD", "")
BROKER_URL = "{0}://{1}:{2}@{3}".format(CELERY_BROKER_TRANSPORT,
CELERY_BROKER_USER,
CELERY_BROKER_PASSWORD,
CELERY_BROKER_HOSTNAME)
BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
CELERY_BROKER_USER,
CELERY_BROKER_PASSWORD,
CELERY_BROKER_HOSTNAME,
CELERY_BROKER_VHOST)

View File

@@ -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,
@@ -54,7 +54,11 @@ MITX_FEATURES = {
'ENABLE_SERVICE_STATUS': False,
# Don't autoplay videos for course authors
'AUTOPLAY_VIDEOS': False
'AUTOPLAY_VIDEOS': False,
# If set to True, new Studio users won't be able to author courses unless
# edX has explicitly added them to the course creator group.
'ENABLE_CREATOR_GROUP': False
}
ENABLE_JASMINE = False
@@ -139,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',
@@ -238,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
@@ -320,6 +326,7 @@ INSTALLED_APPS = (
'django.contrib.messages',
'djcelery',
'south',
'method_override',
# Monitor the status of services
'service_status',
@@ -327,6 +334,7 @@ INSTALLED_APPS = (
# For CMS
'contentstore',
'auth',
'course_creators',
'student', # misleading name due to sharing with lms
'course_groups', # not used in cms (yet), but tests run
@@ -341,6 +349,9 @@ INSTALLED_APPS = (
# comment common
'django_comment_common',
# for course creator table
'django.contrib.admin'
)
################# EDX MARKETING SITE ##################################

8
cms/envs/debug_upload.py Normal file
View 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',
)

View File

@@ -27,7 +27,7 @@ modulestore_options = {
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': modulestore_options
},
'direct': {
@@ -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 = {

View File

@@ -53,7 +53,7 @@ MODULESTORE_OPTIONS = {
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'direct': {
@@ -61,7 +61,7 @@ MODULESTORE = {
'OPTIONS': MODULESTORE_OPTIONS
},
'draft': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
}
}
@@ -140,3 +140,6 @@ SEGMENT_IO_KEY = '***REMOVED***'
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
# This is to disable a test under the common directory that will not pass when run under CMS
MITX_FEATURES['DISABLE_PASSWORD_RESET_EMAIL_TEST'] = True

0
cms/manage.py Normal file → Executable file
View File

View 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'])

View File

@@ -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"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()

View File

@@ -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

View 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()

View File

@@ -3,6 +3,8 @@ AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix)
@CMS =
Models: {}
Views: {}
Collections: {}
URL: {}
prefix: $("meta[name='path_prefix']").attr('content')
@@ -17,14 +19,17 @@ $ ->
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false
return
return
if jqXHR.responseText
try
message = JSON.parse(jqXHR.responseText).error
catch error
message = _.str.truncate(jqXHR.responseText, 300)
else
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
msg = new CMS.Views.Notification.Error(
"title": gettext("Studio's having trouble saving your work")
"message": message
"title": gettext("Studio's having trouble saving your work")
"message": message
)
msg.show()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -56,11 +56,11 @@ $(document).ready(function() {
// nav - dropdown related
$body.click(function(e) {
$('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dropdown .nav-item .title').removeClass('is-selected');
$('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dd .nav-item .title').removeClass('is-selected');
});
$('.nav-dropdown .nav-item .title').click(function(e) {
$('.nav-dd .nav-item .title').click(function(e) {
$subnav = $(this).parent().find('.wrapper-nav-sub');
$title = $(this).parent().find('.title');
@@ -71,8 +71,8 @@ $(document).ready(function() {
$subnav.removeClass('is-shown');
$title.removeClass('is-selected');
} else {
$('.nav-dropdown .nav-item .title').removeClass('is-selected');
$('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dd .nav-item .title').removeClass('is-selected');
$('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown');
$title.addClass('is-selected');
$subnav.addClass('is-shown');
}

View File

@@ -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&hellip;"),
closeIcon: false,
minShown: 1250
title: gettext("Saving&hellip;")
});
}
this.msg.show();

View 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}
};
}
}
});

View File

@@ -23,7 +23,12 @@ function removeAsset(e){
{ 'location': row.data('id') },
function() {
// show the post-commit confirmation
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
var deleted = new CMS.Views.Notification.Confirmation({
title: gettext("Your file has been deleted."),
closeIcon: false,
maxShown: 2000
});
deleted.show();
row.remove();
analytics.track('Deleted Asset', {
'course': course_location_analytics,

View File

@@ -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;

View 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&hellip;")
}).show();
textbook.destroy({
complete: function() {
delmsg.hide();
}
});
}
},
secondary: {
text: gettext("Cancel"),
click: function(view) {
view.hide();
}
}
}
}).show();
},
showChapters: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showChapters', true);
},
hideChapters: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showChapters', false);
}
});
CMS.Views.EditTextbook = Backbone.View.extend({
initialize: function() {
this.template = _.template($("#edit-textbook-tpl").text());
this.listenTo(this.model, "invalid", this.render);
var chapters = this.model.get('chapters');
this.listenTo(chapters, "add", this.addOne);
this.listenTo(chapters, "reset", this.addAll);
this.listenTo(chapters, "all", this.render);
},
tagName: "section",
className: "textbook",
render: function() {
this.$el.html(this.template({
name: this.model.escape('name'),
error: this.model.validationError
}));
this.addAll();
return this;
},
events: {
"change input[name=textbook-name]": "setName",
"submit": "setAndClose",
"click .action-cancel": "cancel",
"click .action-add-chapter": "createChapter"
},
addOne: function(chapter) {
var view = new CMS.Views.EditChapter({model: chapter});
this.$("ol.chapters").append(view.render().el);
return this;
},
addAll: function() {
this.model.get('chapters').each(this.addOne, this);
},
createChapter: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.setValues();
this.model.get('chapters').add([{}]);
},
setName: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set("name", this.$("#textbook-name-input").val(), {silent: true});
},
setValues: function() {
this.setName();
var that = this;
_.each(this.$("li"), function(li, i) {
var chapter = that.model.get('chapters').at(i);
if(!chapter) { return; }
chapter.set({
"name": $(".chapter-name", li).val(),
"asset_path": $(".chapter-asset-path", li).val()
});
});
return this;
},
setAndClose: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.setValues();
if(!this.model.isValid()) { return; }
var saving = new CMS.Views.Notification.Saving({
title: gettext("Saving&hellip;")
}).show();
var that = this;
this.model.save({}, {
success: function() {
that.model.setOriginalAttributes();
that.close();
},
complete: function() {
saving.hide();
}
});
},
cancel: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.reset();
return this.close();
},
close: function() {
var textbooks = this.model.collection;
this.remove();
if(this.model.isNew()) {
// if the textbook has never been saved, remove it
textbooks.remove(this.model);
}
// don't forget to tell the model that it's no longer being edited
this.model.set("editing", false);
return this;
}
});
CMS.Views.ListTextbooks = Backbone.View.extend({
initialize: function() {
this.emptyTemplate = _.template($("#no-textbooks-tpl").text());
this.listenTo(this.collection, 'all', this.render);
this.listenTo(this.collection, 'destroy', this.handleDestroy);
},
tagName: "div",
className: "textbooks-list",
render: function() {
var textbooks = this.collection;
if(textbooks.length === 0) {
this.$el.html(this.emptyTemplate());
} else {
this.$el.empty();
var that = this;
textbooks.each(function(textbook) {
var view;
if (textbook.get("editing")) {
view = new CMS.Views.EditTextbook({model: textbook});
} else {
view = new CMS.Views.ShowTextbook({model: textbook});
}
that.$el.append(view.render().el);
});
}
return this;
},
events: {
"click .new-button": "addOne"
},
addOne: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.collection.add([{editing: true}]);
},
handleDestroy: function(model, collection, options) {
collection.remove(model);
}
});
CMS.Views.EditChapter = Backbone.View.extend({
initialize: function() {
this.template = _.template($("#edit-chapter-tpl").text());
this.listenTo(this.model, "change", this.render);
},
tagName: "li",
className: function() {
return "field-group chapter chapter" + this.model.get('order');
},
render: function() {
this.$el.html(this.template({
name: this.model.escape('name'),
asset_path: this.model.escape('asset_path'),
order: this.model.get('order'),
error: this.model.validationError
}));
return this;
},
events: {
"change .chapter-name": "changeName",
"change .chapter-asset-path": "changeAssetPath",
"click .action-close": "removeChapter",
"click .action-upload": "openUploadDialog",
"submit": "uploadAsset"
},
changeName: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$(".chapter-name").val()
}, {silent: true});
return this;
},
changeAssetPath: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
asset_path: this.$(".chapter-asset-path").val()
}, {silent: true});
return this;
},
removeChapter: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.collection.remove(this.model);
return this.remove();
},
openUploadDialog: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$("input.chapter-name").val(),
asset_path: this.$("input.chapter-asset-path").val()
});
var msg = new CMS.Models.FileUpload({
title: _.str.sprintf(gettext("Upload a new asset to %s"),
section.escape('name')),
message: "Files must be in PDF format."
});
var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model});
$(".wrapper-view").after(view.show().el);
}
});
CMS.Views.UploadDialog = Backbone.View.extend({
options: {
shown: true,
successMessageTimeout: 2000 // 2 seconds
},
initialize: function() {
this.template = _.template($("#upload-dialog-tpl").text());
this.listenTo(this.model, "change", this.render);
},
render: function() {
var isValid = this.model.isValid()
var selectedFile = this.model.get('selectedFile');
var oldInput = this.$("input[type=file]").get(0);
this.$el.html(this.template({
shown: this.options.shown,
url: CMS.URL.UPLOAD_ASSET,
title: this.model.escape('title'),
message: this.model.escape('message'),
selectedFile: selectedFile,
uploading: this.model.get('uploading'),
uploadedBytes: this.model.get('uploadedBytes'),
totalBytes: this.model.get('totalBytes'),
finished: this.model.get('finished'),
error: this.model.validationError
}));
// Ideally, we'd like to tell the browser to pre-populate the
// <input type="file"> with the selectedFile if we have one -- but
// browser security prohibits that. So instead, we'll swap out the
// new input (that has no file selected) with the old input (that
// already has the selectedFile selected). However, we only want to do
// this if the selected file is valid: if it isn't, we want to render
// a blank input to prompt the user to upload a different (valid) file.
if (selectedFile && isValid) {
$(oldInput).removeClass("error");
this.$('input[type=file]').replaceWith(oldInput);
}
return this;
},
events: {
"change input[type=file]": "selectFile",
"click .action-cancel": "hideAndRemove",
"click .action-upload": "upload"
},
selectFile: function(e) {
this.model.set({
selectedFile: e.target.files[0] || null
});
},
show: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = true;
$body.addClass('dialog-is-shown');
return this.render();
},
hide: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.options.shown = false;
$body.removeClass('dialog-is-shown');
return this.render();
},
hideAndRemove: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
return this.hide().remove();
},
upload: function(e) {
this.model.set('uploading', true);
this.$("form").ajaxSubmit({
success: _.bind(this.success, this),
error: _.bind(this.error, this),
uploadProgress: _.bind(this.progress, this),
data: {
// don't show the generic error notification; we're in a modal,
// and we're better off modifying it instead.
notifyOnError: false
}
});
},
progress: function(event, position, total, percentComplete) {
this.model.set({
"uploadedBytes": position,
"totalBytes": total
});
},
success: function(response, statusText, xhr, form) {
this.model.set({
uploading: false,
finished: true
});
var chapter = this.options.chapter;
if(chapter) {
var options = {};
if(!chapter.get("name")) {
options.name = response.displayname;
}
options.asset_path = response.url;
chapter.set(options);
}
var that = this;
this.removalTimeout = setTimeout(function() {
that.hide().remove();
}, this.options.successMessageTimeout);
},
error: function() {
this.model.set({
"uploading": false,
"uploadedBytes": 0,
"title": gettext("We're sorry, there was an error")
});
}
});

View File

@@ -19,9 +19,9 @@ body, input, button {
}
a {
@include transition(color $tmg-f2 ease-in-out 0s);
text-decoration: none;
color: $blue;
@include transition(color 0.25s ease-in-out);
&:hover {
color: $orange-d1;
@@ -313,11 +313,6 @@ p, ul, ol, dl {
.view-button {
}
.upload-button .icon-plus {
@extend .t-action2;
line-height: 0 !important;
}
}
}
@@ -596,8 +591,8 @@ hr.divide {
}
.window {
// @include border-radius(3px);
// @include box-shadow(0 1px 1px $shadow-l1);
// border-radius: 3px;
// box-shadow: 0 1px 1px $shadow-l1;
// margin-bottom: $baseline;
// border: 1px solid $gray-l2;
// background: $white;
@@ -612,7 +607,7 @@ hr.divide {
border-radius: 2px 2px 0 0;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset;
font-size: 14px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
}
@@ -647,6 +642,7 @@ hr.divide {
// system notifications
.toast-notification {
@include transition(all $tmg-f2 linear 0s);
display: none;
position: fixed;
top: 20px;
@@ -658,11 +654,10 @@ hr.divide {
border: 1px solid #333;
@include linear-gradient(top, rgba(255, 255, 255, .1), rgba(255, 255, 255, 0));
background-color: rgba(30, 30, 30, .92);
@include box-shadow(0 1px 3px rgba(0, 0, 0, .3), 0 1px 0 rgba(255, 255, 255, .1) inset);
box-shadow: 0 1px 3px rgba(0, 0, 0, .3), 0 1px 0 rgba(255, 255, 255, .1) inset;
font-size: 13px;
text-align: center;
color: #fff;
@include transition(all .2s);
p, span {
color: #fff;
@@ -751,9 +746,6 @@ hr.divide {
}
.icon-plus {
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/4);
margin-top: -2px;
line-height: 0;
}
@@ -802,7 +794,7 @@ hr.divide {
.tooltip {
@include font-size(12);
@include transition(opacity 0.1s ease-out);
@include transition(opacity $tmg-f3 ease-out 0s);
position: absolute;
top: 0;
left: 0;
@@ -865,7 +857,7 @@ body.js {
.content-modal {
@include border-bottom-radius(2px);
@include box-sizing(border-box);
@include box-shadow(0 2px 4px $shadow-d1);
box-shadow: 0 2px 4px $shadow-d1;
position: relative;
display: none;
width: 700px;
@@ -875,7 +867,7 @@ body.js {
background: $white;
.action-modal-close {
@include transition(top .25s ease-in-out);
@include transition(top $tmg-f3 ease-in-out 0s);
@include border-bottom-radius(3px);
position: absolute;
top: -3px;

View File

@@ -1,454 +0,0 @@
// studio - utilities - INHERITED mixins and extends
// NOTE: these are older/poorly architected mixins that we want to move away from using or refactor in the future.
// They are still referenced when styliing current interface elements and as such need to be preserved for the time being.
// talbs: we need to slowly ween ourselves off of these
// ====================
// line-height (old way)
@function lh($amount: 1) {
@return $body-line-height * $amount;
}
// inherited - vertical and horizontal centering
@mixin vertically-and-horizontally-centered ($height, $width) {
left: 50%;
margin-left: -$width / 2;
min-height: $height;
min-width: $width;
position: absolute;
top: 150px;
}
// ====================
// inherited - dividers
.faded-hr-divider {
@include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%,
rgba(200,200,200, 1) 50%,
rgba(200,200,200, 0)));
height: 1px;
width: 100%;
}
.faded-hr-divider-medium {
@include background-image(linear-gradient(180deg, rgba(240,240,240, 0) 0%,
rgba(240,240,240, 1) 50%,
rgba(240,240,240, 0)));
height: 1px;
width: 100%;
}
.faded-hr-divider-light {
@include background-image(linear-gradient(180deg, rgba(255,255,255, 0) 0%,
rgba(255,255,255, 0.8) 50%,
rgba(255,255,255, 0)));
height: 1px;
width: 100%;
}
.faded-vertical-divider {
@include background-image(linear-gradient(90deg, rgba(200,200,200, 0) 0%,
rgba(200,200,200, 1) 50%,
rgba(200,200,200, 0)));
height: 100%;
width: 1px;
}
.faded-vertical-divider-light {
@include background-image(linear-gradient(90deg, rgba(255,255,255, 0) 0%,
rgba(255,255,255, 0.6) 50%,
rgba(255,255,255, 0)));
height: 100%;
width: 1px;
}
.vertical-divider {
@extend .faded-vertical-divider;
position: relative;
&::after {
@extend .faded-vertical-divider-light;
content: "";
display: block;
position: absolute;
left: 1px;
}
}
.horizontal-divider {
border: none;
@extend .faded-hr-divider;
position: relative;
&::after {
@extend .faded-hr-divider-light;
content: "";
display: block;
position: absolute;
top: 1px;
}
}
.fade-right-hr-divider {
@include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%,
rgba(200,200,200, 1)));
border: none;
}
.fade-left-hr-divider {
@include background-image(linear-gradient(180deg, rgba(200,200,200, 1) 0%,
rgba(200,200,200, 0)));
border: none;
}
// ====================
// inherited - ui
.window {
@include clearfix();
@include border-radius(3px);
@include box-shadow(0 1px 1px $shadow-l1);
margin-bottom: $baseline;
border: 1px solid $gray-l2;
background: $white;
}
// ====================
// mixins - grandfathered
@mixin button {
@include font-size(14);
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 0 0 rgba(0, 0, 0, 0));
@include transition(background-color .15s, box-shadow .15s);
display: inline-block;
padding: ($baseline/5) $baseline ($baseline/4);
font-weight: 700;
&.disabled {
border: 1px solid $gray-l1 !important;
border-radius: 3px !important;
background: $gray-l1 !important;
color: $gray-d1 !important;
pointer-events: none;
cursor: none;
&:hover {
box-shadow: 0 0 0 0 !important;
}
}
&:hover, &.active {
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 1px 1px rgba(0, 0, 0, .15));
}
}
@mixin green-button {
@include button;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
border: 1px solid $green-d1;
border-radius: 3px;
background-color: $green;
color: $white;
&:hover {
background-color: $green-s1;
color: $white;
}
&.disabled {
border: 1px solid $green-l3 !important;
background: $green-l3 !important;
color: $white !important;
@include box-shadow(none);
}
}
@mixin blue-button {
@include button;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
border: 1px solid $blue-d1;
border-radius: 3px;
background-color: $blue;
color: $white;
&:hover, &.active {
background-color: $blue-s2;
color: $white;
}
&.disabled {
@include box-shadow(none);
border: 1px solid $blue-l3 !important;
background: $blue-l3 !important;
color: $white !important;
}
}
@mixin red-button {
@include button;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
border: 1px solid $red-d1;
border-radius: 3px;
background-color: $red;
color: $white;
&:hover, &.active {
background-color: $red-s1;
color: $white;
}
&.disabled {
@include box-shadow(none);
border: 1px solid $red-l3 !important;
background: $red-l3 !important;
color: $white !important;
}
}
@mixin pink-button {
@include button;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
border: 1px solid $pink-d1;
border-radius: 3px;
background-color: $pink;
color: $white;
&:hover, &.active {
background-color: $pink-s1;
color: $white;
}
&.disabled {
@include box-shadow(none);
border: 1px solid $pink-l3 !important;
background: $pink-l3 !important;
color: $white !important;
}
}
@mixin orange-button {
@include button;
@include linear-gradient(top, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0) 60%);
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
border: 1px solid $orange-d1;
border-radius: 3px;
background-color: $orange;
color: $gray-d2;
&:hover {
background-color: $orange-s2;
color: $gray-d2;
}
&.disabled {
border: 1px solid $orange-l3 !important;
background: $orange-l2 !important;
color: $gray-l1 !important;
@include box-shadow(none);
}
}
@mixin white-button {
@include button;
@include linear-gradient(top, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0));
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
border: 1px solid $mediumGrey;
border-radius: 3px;
background-color: #dfe5eb;
color: rgb(92, 103, 122);
text-shadow: 0 1px 0 rgba(255, 255, 255, .5);
&:hover {
background-color: rgb(222, 236, 247);
color: rgb(92, 103, 122);
}
}
@mixin grey-button {
@include button;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
border: 1px solid $gray-d2;
border-radius: 3px;
background-color: #d1dae3;
color: #6d788b;
&:hover {
background-color: #d9e3ee;
color: #6d788b;
}
}
@mixin gray-button {
@include button;
@include linear-gradient(top, $white-t1, rgba(255, 255, 255, 0));
@include box-shadow(0 1px 0 $white-t1 inset);
border: 1px solid $gray-d1;
border-radius: 3px;
background-color: $gray-d2;
color: $gray-l3;
&:hover {
background-color: $gray-d3;
color: $white;
}
}
@mixin dark-grey-button {
@include button;
border: 1px solid $gray-d2;
border-radius: 3px;
background: -webkit-linear-gradient(top, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0)) $gray-d1;
box-shadow: 0 1px 0 rgba(255, 255, 255, .2) inset;
color: $white;
&:hover {
background-color: $gray-d4;
color: $white;
}
}
@mixin edit-box {
@include box-shadow(0 1px 0 rgba(255, 255, 255, .2) inset);
padding: 15px 20px;
border-radius: 3px;
background-color: $lightBluishGrey2;
color: #3c3c3c;
label {
color: $baseFontColor;
}
input,
textarea {
border: 1px solid $darkGrey;
}
textarea {
min-height: 80px;
}
h5 {
margin-bottom: 8px;
color: #fff;
font-weight: 700;
}
.row {
margin-bottom: 10px;
padding: 0;
border: none;
}
.save-button {
@include blue-button;
margin-top: 0;
}
.cancel-button {
@include white-button;
margin-top: 0;
}
}
@mixin tree-view {
border: 1px solid $mediumGrey;
background: $lightGrey;
.branch {
margin-bottom: 10px;
&.collapsed {
margin-bottom: 0;
}
}
.branch > .section-item {
border-top: 1px solid #c5cad4;
}
.section-item {
position: relative;
display: block;
padding: 6px 8px 8px 16px;
background: #edf1f5;
font-size: 13px;
&:hover {
background: #fffcf1;
.item-actions {
display: block;
}
}
&.editing {
background: #fffcf1;
}
.draft-item:after,
.public-item:after,
.private-item:after {
margin-left: 3px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.draft-item:after {
content: "- draft";
}
.private-item:after {
content: "- private";
}
.private-item {
color: #a4aab7;
}
.draft-item {
color: #9f7d10;
}
}
a {
color: $baseFontColor;
&.new-unit-item {
color: #6d788b;
}
}
ol {
.section-item {
padding-left: 56px;
}
.new-unit-item {
margin-left: 56px;
}
}
ol ol {
.section-item {
padding-left: 96px;
}
.new-unit-item {
margin-left: 96px;
}
}
}
// ====================
// sunsetted mixins
@mixin active {
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
@include box-shadow(0 -1px 0 rgba(0, 0, 0, .2) inset, 0 1px 0 #fff inset);
background-color: rgba(255, 255, 255, .3);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
}

View File

@@ -0,0 +1 @@
../../../common/static/sass/_mixins-inherited.scss

View File

@@ -143,7 +143,7 @@ abbr[title] {
width: 100%;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
//background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset;
li:first-child {
margin-left: 20px;

View File

@@ -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

View File

@@ -47,7 +47,7 @@ $gray-d2: shade($gray,40%);
$gray-d3: shade($gray,60%);
$gray-d4: shade($gray,80%);
$blue: rgb(85, 151, 221);
$blue: rgb(0, 159, 230);
$blue-l1: tint($blue,20%);
$blue-l2: tint($blue,40%);
$blue-l3: tint($blue,60%);
@@ -161,6 +161,17 @@ $shadow-d2: rgba($black, 0.6);
// ====================
// timing - used for animation/transition mixin syncing
$tmg-s3: 3.0s;
$tmg-s2: 2.0s;
$tmg-s1: 1.0s;
$tmg-avg: 0.75s;
$tmg-f1: 0.50s;
$tmg-f2: 0.25s;
$tmg-f3: 0.125s;
// ====================
// specific UI
$notification-height: ($baseline*10);

View File

@@ -1,36 +1,140 @@
// studio animations & keyframes
// ====================
// rotate clockwise
@mixin rotateClockwise {
// fade in
@include keyframes(fadeIn) {
0% {
opacity: 0.0;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1.0;
}
}
// canned animation - use if you want out of the box/non-customized anim
.anim-fadeIn {
@include animation(fadeIn $tmg-f2 linear 1);
}
// fade out
@include keyframes(fadeOut) {
0% {
opacity: 1.0;
}
50% {
opacity: 0.5;
}
100% {
opacity: 0.0;
}
}
// canned animation - use if you want out of the box/non-customized anim
.anim-fadeOut {
@include animation(fadeOut $tmg-f2 linear 1);
}
// ====================
// rotate up
@include keyframes(rotateUp) {
0% {
@include transform(rotate(0deg));
}
50% {
@include transform(rotate(-90deg));
}
100% {
@include transform(rotate(-180deg));
}
}
// canned animation - use if you want out of the box/non-customized anim
.anim-rotateUp {
@include animation(rotateUp $tmg-f2 ease-in-out 1);
}
// rotate up
@include keyframes(rotateDown) {
0% {
@include transform(rotate(0deg));
}
50% {
@include transform(rotate(90deg));
}
100% {
@include transform(rotate(180deg));
}
}
// canned animation - use if you want out of the box/non-customized anim
.anim-rotateDown {
@include animation(rotateDown $tmg-f2 ease-in-out 1);
}
// rotate clockwise
@include keyframes(rotateCW) {
0% {
@include transform(rotate(0deg));
}
50% {
@include transform(rotate(180deg));
}
100% {
@include transform(rotate(360deg));
}
}
@-moz-keyframes rotateClockwise { @include rotateClockwise(); }
@-webkit-keyframes rotateClockwise { @include rotateClockwise(); }
@-o-keyframes rotateClockwise { @include rotateClockwise(); }
@keyframes rotateClockwise { @include rotateClockwise();}
@mixin anim-rotateClockwise($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(rotateClockwise);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
// canned animation - use if you want out of the box/non-customized anim
.anim-rotateCW {
@include animation(rotateCW $tmg-s1 linear infinite);
}
// rotate counter-clockwise
@include keyframes(rotateCCW) {
0% {
@include transform(rotate(0deg));
}
50% {
@include transform(rotate(-180deg));
}
100% {
@include transform(rotate(-360deg));
}
}
// canned animation - use if you want out of the box/non-customized anim
.anim-rotateCCW {
@include animation(rotateCCW $tmg-s1 linear infinite);
}
// ====================
// notifications slide up
@mixin notificationsSlideUp {
@include keyframes(notificationSlideUp) {
0% {
@include transform(translateY(0));
}
@@ -44,25 +148,8 @@
}
}
@-moz-keyframes notificationsSlideUp { @include notificationsSlideUp(); }
@-webkit-keyframes notificationsSlideUp { @include notificationsSlideUp(); }
@-o-keyframes notificationsSlideUp { @include notificationsSlideUp(); }
@keyframes notificationsSlideUp { @include notificationsSlideUp();}
@mixin anim-notificationsSlideUp($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(notificationsSlideUp);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// notifications slide down
@mixin notificationsSlideDown {
@include keyframes(notificationSlideDown) {
0% {
@include transform(translateY(-($notification-height*0.99)));
}
@@ -76,57 +163,12 @@
}
}
@-moz-keyframes notificationsSlideDown { @include notificationsSlideDown(); }
@-webkit-keyframes notificationsSlideDown { @include notificationsSlideDown(); }
@-o-keyframes notificationsSlideDown { @include notificationsSlideDown(); }
@keyframes notificationsSlideDown { @include notificationsSlideDown();}
@mixin anim-notificationsSlideDown($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(notificationsSlideDown);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// bounce in
@mixin bounceIn {
0% {
opacity: 0.0;
@include transform(scale(0.3));
}
50% {
opacity: 1.0;
@include transform(scale(1.05));
}
100% {
@include transform(scale(1));
}
}
@-moz-keyframes bounceIn { @include bounceIn(); }
@-webkit-keyframes bounceIn { @include bounceIn(); }
@-o-keyframes bounceIn { @include bounceIn(); }
@keyframes bounceIn { @include bounceIn();}
@mixin anim-bounceIn($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(bounceIn);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// bounce in
@mixin bounceOut {
@include keyframes(bounceIn) {
0% {
opacity: 0.0;
@include transform(scale(0.3));
@@ -140,7 +182,16 @@
100% {
@include transform(scale(1));
}
}
// canned animation - use if you want out of the box/non-customized anim
.anim-bounceIn {
@include animation(bounceIn $tmg-f1 ease-in-out 1);
}
// bounce out
@include keyframes(bounceOut) {
0% {
@include transform(scale(1));
}
@@ -156,16 +207,7 @@
}
}
@-moz-keyframes bounceOut { @include bounceOut(); }
@-webkit-keyframes bounceOut { @include bounceOut(); }
@-o-keyframes bounceOut { @include bounceOut(); }
@keyframes bounceOut { @include bounceOut();}
@mixin anim-bounceOut($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(bounceOut);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
// canned animation - use if you want out of the box/non-customized anim
.anim-bounceOut {
@include animation(bounceOut $tmg-f1 ease-in-out 1);
}

View File

@@ -1,11 +1,11 @@
.expand-collapse-icon {
@include transition(none);
position: relative;
display: inline-block;
width: 9px;
height: 11px;
margin-right: 10px;
background: url(../img/expand-collapse-icons.png) no-repeat;
@include transition(none);
&.expand {
top: 1px;
@@ -101,7 +101,7 @@
background: url(../img/edit-icon.png) no-repeat;
&.white {
background: url(../img/edit-icon-white.png) no-repeat;
background: url(../img/edit-icon-white.png) no-repeat;
}
}
@@ -132,7 +132,7 @@
background: url(../img/delete-icon.png) no-repeat;
&.white {
background: url(../img/delete-icon-white.png) no-repeat;
background: url(../img/delete-icon-white.png) no-repeat;
}
}

View File

@@ -58,6 +58,7 @@
@import 'views/unit';
@import 'views/users';
@import 'views/checklists';
@import 'views/textbooks';
// temp - inherited
@import 'assets/content-types';

View File

@@ -135,7 +135,60 @@
// ====================
// layout-based buttons
// 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;
@extend .btn-pill;
padding:($baseline/4) ($baseline/2);
border-width: 1px;
border-style: solid;
border-color: transparent;
text-align: center;
&:hover, &:active {
@extend .fake-link;
border-color: $gray-l3;
}
&.current, &.active, &.is-selected {
box-shadow: inset 0 1px 2px 1px $shadow-l1;
border-color: $gray-l3;
}
}
// layout-based buttons - nav dd
.btn-dd-nav-primary {
@extend .btn-dd;
background: $white;
border-color: $white;
color: $gray-d1;
&:hover, &:active {
background: $white;
color: $blue-s1;
}
&.current, &.active {
background: $white;
color: $gray-d4;
&:hover, &:active {
color: $blue-s1;
}
}
}
// ====================

Some files were not shown because too many files have changed in this diff Show More