Merge remote-tracking branch 'origin/master' into fix/vik/studio-oe
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* -text
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ node_modules
|
||||
.prereqs_cache
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
.vagrant/
|
||||
|
||||
2
AUTHORS
2
AUTHORS
@@ -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>
|
||||
@@ -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
|
||||
|
||||
5
Gemfile
5
Gemfile
@@ -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
10
LICENSE
@@ -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
138
README.md
@@ -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
33
Vagrantfile
vendored
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
22
cms/djangoapps/contentstore/debug_file_uploader.py
Normal file
22
cms/djangoapps/contentstore/debug_file_uploader.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.core.files.uploadhandler import FileUploadHandler
|
||||
import time
|
||||
|
||||
|
||||
class DebugFileUploader(FileUploadHandler):
|
||||
def __init__(self, request=None):
|
||||
super(DebugFileUploader, self).__init__(request)
|
||||
self.count = 0
|
||||
|
||||
def receive_data_chunk(self, raw_data, start):
|
||||
time.sleep(1)
|
||||
self.count = self.count + len(raw_data)
|
||||
fail_at = None
|
||||
if 'fail_at' in self.request.GET:
|
||||
fail_at = int(self.request.GET.get('fail_at'))
|
||||
if fail_at and self.count > fail_at:
|
||||
raise Exception('Triggered fail')
|
||||
|
||||
return raw_data
|
||||
|
||||
def file_complete(self, file_size):
|
||||
return None
|
||||
@@ -2,7 +2,7 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches
|
||||
from common import type_in_codemirror
|
||||
|
||||
KEY_CSS = '.key input.policy-key'
|
||||
@@ -36,7 +36,7 @@ def press_the_notification_button(step, name):
|
||||
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
return confirmation_dismissed or error_showing
|
||||
|
||||
assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.')
|
||||
world.css_click(css, success_condition=save_clicked)
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key$')
|
||||
|
||||
@@ -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)
|
||||
|
||||
69
cms/djangoapps/contentstore/features/component.feature
Normal file
69
cms/djangoapps/contentstore/features/component.feature
Normal 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
|
||||
126
cms/djangoapps/contentstore/features/component.py
Normal file
126
cms/djangoapps/contentstore/features/component.py
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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 "([^"]*)"$')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
47
cms/djangoapps/contentstore/features/textbooks.feature
Normal file
47
cms/djangoapps/contentstore/features/textbooks.feature
Normal file
@@ -0,0 +1,47 @@
|
||||
Feature: Textbooks
|
||||
|
||||
Scenario: No textbooks
|
||||
Given I have opened a new course in Studio
|
||||
When I go to the textbooks page
|
||||
Then I should see a message telling me to create a new textbook
|
||||
|
||||
Scenario: Create a textbook
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the textbooks page
|
||||
When I click on the New Textbook button
|
||||
And I name my textbook "Economics"
|
||||
And I name the first chapter "Chapter 1"
|
||||
And I click the Upload Asset link for the first chapter
|
||||
And I upload the textbook "textbook.pdf"
|
||||
And I wait for "2" seconds
|
||||
And I save the textbook
|
||||
Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf"
|
||||
And I reload the page
|
||||
Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf"
|
||||
|
||||
Scenario: Create a textbook with multiple chapters
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the textbooks page
|
||||
When I click on the New Textbook button
|
||||
And I name my textbook "History"
|
||||
And I name the first chapter "Britain"
|
||||
And I type in "britain.pdf" for the first chapter asset
|
||||
And I click Add a Chapter
|
||||
And I name the second chapter "America"
|
||||
And I type in "america.pdf" for the second chapter asset
|
||||
And I save the textbook
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And I click the textbook chapters
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And the first chapter should be named "Britain"
|
||||
And the first chapter should have an asset called "britain.pdf"
|
||||
And the second chapter should be named "America"
|
||||
And the second chapter should have an asset called "america.pdf"
|
||||
And I reload the page
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And I click the textbook chapters
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And the first chapter should be named "Britain"
|
||||
And the first chapter should have an asset called "britain.pdf"
|
||||
And the second chapter should be named "America"
|
||||
And the second chapter should have an asset called "america.pdf"
|
||||
121
cms/djangoapps/contentstore/features/textbooks.py
Normal file
121
cms/djangoapps/contentstore/features/textbooks.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from django.conf import settings
|
||||
import os
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
|
||||
@step(u'I go to the textbooks page')
|
||||
def go_to_uploads(_step):
|
||||
world.click_course_content()
|
||||
menu_css = 'li.nav-course-courseware-textbooks'
|
||||
world.css_find(menu_css).click()
|
||||
|
||||
|
||||
@step(u'I should see a message telling me to create a new textbook')
|
||||
def assert_create_new_textbook_msg(_step):
|
||||
css = ".wrapper-content .no-textbook-content"
|
||||
assert world.is_css_present(css)
|
||||
no_tb = world.css_find(css)
|
||||
assert "You haven't added any textbooks" in no_tb.text
|
||||
|
||||
|
||||
@step(u'I upload the textbook "([^"]*)"$')
|
||||
def upload_file(_step, file_name):
|
||||
file_css = '.upload-dialog input[type=file]'
|
||||
upload = world.css_find(file_css)
|
||||
# uploading the file itself
|
||||
path = os.path.join(TEST_ROOT, 'uploads', file_name)
|
||||
upload._element.send_keys(os.path.abspath(path))
|
||||
button_css = ".upload-dialog .action-upload"
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I click (on )?the New Textbook button')
|
||||
def click_new_textbook(_step, on):
|
||||
button_css = ".nav-actions .new-button"
|
||||
button = world.css_find(button_css)
|
||||
button.click()
|
||||
|
||||
|
||||
@step(u'I name my textbook "([^"]*)"')
|
||||
def name_textbook(_step, name):
|
||||
input_css = ".textbook input[name=textbook-name]"
|
||||
world.css_fill(input_css, name)
|
||||
|
||||
|
||||
@step(u'I name the (first|second|third) chapter "([^"]*)"')
|
||||
def name_chapter(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1)
|
||||
world.css_fill(input_css, name)
|
||||
|
||||
|
||||
@step(u'I type in "([^"]*)" for the (first|second|third) chapter asset')
|
||||
def asset_chapter(_step, name, ordinal):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1)
|
||||
world.css_fill(input_css, name)
|
||||
|
||||
|
||||
@step(u'I click the Upload Asset link for the (first|second|third) chapter')
|
||||
def click_upload_asset(_step, ordinal):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
button_css = ".textbook .chapter{i} .action-upload".format(i=index+1)
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I click Add a Chapter')
|
||||
def click_add_chapter(_step):
|
||||
button_css = ".textbook .action-add-chapter"
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I save the textbook')
|
||||
def save_textbook(_step):
|
||||
submit_css = "form.edit-textbook button[type=submit]"
|
||||
world.css_click(submit_css)
|
||||
|
||||
|
||||
@step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"')
|
||||
def check_textbook(_step, textbook_name, chapter_name):
|
||||
title = world.css_find(".textbook h3.textbook-title")
|
||||
chapter = world.css_find(".textbook .wrap-textbook p")
|
||||
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
|
||||
assert chapter.text == chapter_name, "{} != {}".format(chapter.text, chapter_name)
|
||||
|
||||
|
||||
@step(u'I should see a textbook named "([^"]*)" with (\d+) chapters')
|
||||
def check_textbook_chapters(_step, textbook_name, num_chapters_str):
|
||||
num_chapters = int(num_chapters_str)
|
||||
title = world.css_find(".textbook .view-textbook h3.textbook-title")
|
||||
toggle = world.css_find(".textbook .view-textbook .chapter-toggle")
|
||||
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
|
||||
assert toggle.text == "{num} PDF Chapters".format(num=num_chapters), \
|
||||
"Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle.text)
|
||||
|
||||
|
||||
@step(u'I click the textbook chapters')
|
||||
def click_chapters(_step):
|
||||
world.css_click(".textbook a.chapter-toggle")
|
||||
|
||||
|
||||
@step(u'the (first|second|third) chapter should be named "([^"]*)"')
|
||||
def check_chapter_name(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
|
||||
element = chapter.find_by_css(".chapter-name")
|
||||
assert element.text == name, "Expected chapter named {expected}, found chapter named {actual}".format(
|
||||
expected=name, actual=element.text)
|
||||
|
||||
|
||||
@step(u'the (first|second|third) chapter should have an asset called "([^"]*)"')
|
||||
def check_chapter_asset(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
|
||||
element = chapter.find_by_css(".chapter-asset-path")
|
||||
assert element.text == name, "Expected chapter with asset {expected}, found chapter with asset {actual}".format(
|
||||
expected=name, actual=element.text)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
95
cms/djangoapps/contentstore/tests/test_assets.py
Normal file
95
cms/djangoapps/contentstore/tests/test_assets.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Unit tests for the asset upload endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pytz import UTC
|
||||
from unittest import TestCase, skip
|
||||
from .utils import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from contentstore.views import assets
|
||||
|
||||
|
||||
class AssetsTestCase(CourseTestCase):
|
||||
def setUp(self):
|
||||
super(AssetsTestCase, self).setUp()
|
||||
self.url = reverse("asset_index", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
})
|
||||
|
||||
def test_basic(self):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
|
||||
def test_json(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
content = json.loads(resp.content)
|
||||
self.assertIsInstance(content, list)
|
||||
|
||||
|
||||
class UploadTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for uploading a file
|
||||
"""
|
||||
def setUp(self):
|
||||
super(UploadTestCase, self).setUp()
|
||||
self.url = reverse("upload_asset", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'coursename': self.course.location.name,
|
||||
})
|
||||
|
||||
@skip("CorruptGridFile error on continuous integration server")
|
||||
def test_happy_path(self):
|
||||
file = BytesIO("sample content")
|
||||
file.name = "sample.txt"
|
||||
resp = self.client.post(self.url, {"name": "my-name", "file": file})
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
def test_no_file(self):
|
||||
resp = self.client.post(self.url, {"name": "file.txt"})
|
||||
self.assert4XX(resp.status_code)
|
||||
|
||||
def test_get(self):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEquals(resp.status_code, 405)
|
||||
|
||||
|
||||
class AssetsToJsonTestCase(TestCase):
|
||||
"""
|
||||
Unit tests for transforming the results of a database call into something
|
||||
we can send out to the client via JSON.
|
||||
"""
|
||||
def test_basic(self):
|
||||
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
|
||||
asset = {
|
||||
"displayname": "foo",
|
||||
"chunkSize": 512,
|
||||
"filename": "foo.png",
|
||||
"length": 100,
|
||||
"uploadDate": upload_date,
|
||||
"_id": {
|
||||
"course": "course",
|
||||
"org": "org",
|
||||
"revision": 12,
|
||||
"category": "category",
|
||||
"name": "name",
|
||||
"tag": "tag",
|
||||
}
|
||||
}
|
||||
output = assets.assets_to_json_dict([asset])
|
||||
self.assertEquals(len(output), 1)
|
||||
compare = output[0]
|
||||
self.assertEquals(compare["name"], "foo")
|
||||
self.assertEquals(compare["path"], "foo.png")
|
||||
self.assertEquals(compare["uploaded"], upload_date.isoformat())
|
||||
self.assertEquals(compare["id"], "/tag/org/course/12/category/name")
|
||||
@@ -1,10 +1,10 @@
|
||||
""" Unit tests for checklist methods in views.py. """
|
||||
from contentstore.utils import get_modulestore, get_url_reverse
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
import json
|
||||
from .utils import CourseTestCase
|
||||
|
||||
|
||||
class ChecklistTestCase(CourseTestCase):
|
||||
@@ -117,4 +117,4 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 100})
|
||||
response = self.client.delete(update_url)
|
||||
self.assertContains(response, 'Unsupported request', status_code=400)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pylint: disable=E1101
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import mock
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase):
|
||||
'''Go through each interface and ensure it works.'''
|
||||
# first get the update to force the creation
|
||||
url = reverse('course_info',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'name': self.course_location.name})
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name})
|
||||
self.client.get(url)
|
||||
|
||||
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
|
||||
@@ -20,8 +20,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 8, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
@@ -31,8 +31,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(payload['content'], content)
|
||||
|
||||
first_update_url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': payload['id']})
|
||||
content += '<div>div <p>p<br/></p></div>'
|
||||
payload['content'] = content
|
||||
@@ -47,8 +47,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
@@ -58,8 +58,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, payload['content'], "self closing ol")
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
payload = json.loads(resp.content)
|
||||
@@ -73,8 +73,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
|
||||
# now try to update a non-existent update
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': '9'})
|
||||
content = 'blah blah'
|
||||
payload = {'content': content,
|
||||
@@ -87,8 +87,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
content = '<garbage tag No closing brace to force <span>error</span>'
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
self.assertContains(
|
||||
@@ -99,8 +99,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
content = "<p><br><br></p>"
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
@@ -108,8 +108,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'])
|
||||
|
||||
# now try to delete a non-existent update
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': '19'})
|
||||
payload = {'content': content,
|
||||
'date': 'January 21, 2013'}
|
||||
@@ -119,8 +119,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
content = 'blah blah'
|
||||
payload = {'content': content,
|
||||
'date': 'January 28, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
payload = json.loads(resp.content)
|
||||
@@ -128,16 +128,16 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, payload['content'], "single iframe")
|
||||
# first count the entries
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
payload = json.loads(resp.content)
|
||||
before_delete = len(payload)
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': this_id})
|
||||
resp = self.client.delete(url)
|
||||
payload = json.loads(resp.content)
|
||||
|
||||
@@ -22,7 +22,3 @@ class DeleteItem(CourseTestCase):
|
||||
# Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore).
|
||||
resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
40
cms/djangoapps/contentstore/tests/test_request_event.py
Normal file
40
cms/djangoapps/contentstore/tests/test_request_event.py
Normal 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)
|
||||
410
cms/djangoapps/contentstore/tests/test_textbooks.py
Normal file
410
cms/djangoapps/contentstore/tests/test_textbooks.py
Normal file
@@ -0,0 +1,410 @@
|
||||
import json
|
||||
from unittest import TestCase
|
||||
from .utils import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from contentstore.views.course import (
|
||||
validate_textbooks_json, validate_textbook_json, TextbookValidationError)
|
||||
|
||||
|
||||
class TextbookIndexTestCase(CourseTestCase):
|
||||
"Test cases for the textbook index page"
|
||||
def setUp(self):
|
||||
"Set the URL for tests"
|
||||
super(TextbookIndexTestCase, self).setUp()
|
||||
self.url = reverse('textbook_index', kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
})
|
||||
|
||||
def test_view_index(self):
|
||||
"Basic check that the textbook index page responds correctly"
|
||||
resp = self.client.get(self.url)
|
||||
self.assert2XX(resp.status_code)
|
||||
# we don't have resp.context right now,
|
||||
# due to bugs in our testing harness :(
|
||||
if resp.context:
|
||||
self.assertEqual(resp.context['course'], self.course)
|
||||
|
||||
def test_view_index_xhr(self):
|
||||
"Check that we get a JSON response when requested via AJAX"
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(self.course.pdf_textbooks, obj)
|
||||
|
||||
def test_view_index_xhr_content(self):
|
||||
"Check that the response maps to the content of the modulestore"
|
||||
content = [
|
||||
{
|
||||
"tab_title": "my textbook",
|
||||
"url": "/abc.pdf",
|
||||
"id": "992"
|
||||
}, {
|
||||
"tab_title": "pineapple",
|
||||
"id": "0pineapple",
|
||||
"chapters": [
|
||||
{
|
||||
"title": "The Fruit",
|
||||
"url": "/a/b/fruit.pdf",
|
||||
}, {
|
||||
"title": "The Legend",
|
||||
"url": "/b/c/legend.pdf",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
self.course.pdf_textbooks = content
|
||||
store = get_modulestore(self.course.location)
|
||||
store.update_metadata(self.course.location, own_metadata(self.course))
|
||||
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(content, obj)
|
||||
|
||||
def test_view_index_xhr_post(self):
|
||||
"Check that you can save information to the server"
|
||||
textbooks = [
|
||||
{"tab_title": "Hi, mom!"},
|
||||
{"tab_title": "Textbook 2"},
|
||||
]
|
||||
resp = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(textbooks),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# reload course
|
||||
store = get_modulestore(self.course.location)
|
||||
course = store.get_item(self.course.location)
|
||||
# should be the same, except for added ID
|
||||
no_ids = []
|
||||
for textbook in course.pdf_textbooks:
|
||||
del textbook["id"]
|
||||
no_ids.append(textbook)
|
||||
self.assertEqual(no_ids, textbooks)
|
||||
|
||||
def test_view_index_xhr_post_invalid(self):
|
||||
"Check that you can't save invalid JSON"
|
||||
resp = self.client.post(
|
||||
self.url,
|
||||
data="invalid",
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
|
||||
)
|
||||
self.assert4XX(resp.status_code)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertIn("error", obj)
|
||||
|
||||
|
||||
class TextbookCreateTestCase(CourseTestCase):
|
||||
"Test cases for creating a new PDF textbook"
|
||||
|
||||
def setUp(self):
|
||||
"Set up a url and some textbook content for tests"
|
||||
super(TextbookCreateTestCase, self).setUp()
|
||||
self.url = reverse('create_textbook', kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
})
|
||||
self.textbook = {
|
||||
"tab_title": "Economics",
|
||||
"chapters": {
|
||||
"title": "Chapter 1",
|
||||
"url": "/a/b/c/ch1.pdf",
|
||||
}
|
||||
}
|
||||
|
||||
def test_happy_path(self):
|
||||
"Test that you can create a textbook"
|
||||
resp = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.textbook),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertIn("Location", resp)
|
||||
textbook = json.loads(resp.content)
|
||||
self.assertIn("id", textbook)
|
||||
del textbook["id"]
|
||||
self.assertEqual(self.textbook, textbook)
|
||||
|
||||
def test_get(self):
|
||||
"Test that GET is not allowed"
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 405)
|
||||
|
||||
def test_valid_id(self):
|
||||
"Textbook IDs must begin with a number; try a valid one"
|
||||
self.textbook["id"] = "7x5"
|
||||
resp = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.textbook),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
textbook = json.loads(resp.content)
|
||||
self.assertEqual(self.textbook, textbook)
|
||||
|
||||
def test_invalid_id(self):
|
||||
"Textbook IDs must begin with a number; try an invalid one"
|
||||
self.textbook["id"] = "xxx"
|
||||
resp = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.textbook),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assert4XX(resp.status_code)
|
||||
self.assertNotIn("Location", resp)
|
||||
|
||||
|
||||
class TextbookByIdTestCase(CourseTestCase):
|
||||
"Test cases for the `textbook_by_id` view"
|
||||
|
||||
def setUp(self):
|
||||
"Set some useful content and URLs for tests"
|
||||
super(TextbookByIdTestCase, self).setUp()
|
||||
self.textbook1 = {
|
||||
"tab_title": "Economics",
|
||||
"id": 1,
|
||||
"chapters": {
|
||||
"title": "Chapter 1",
|
||||
"url": "/a/b/c/ch1.pdf",
|
||||
}
|
||||
}
|
||||
self.url1 = reverse('textbook_by_id', kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'tid': 1,
|
||||
})
|
||||
self.textbook2 = {
|
||||
"tab_title": "Algebra",
|
||||
"id": 2,
|
||||
"chapters": {
|
||||
"title": "Chapter 11",
|
||||
"url": "/a/b/ch11.pdf",
|
||||
}
|
||||
}
|
||||
self.url2 = reverse('textbook_by_id', kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'tid': 2,
|
||||
})
|
||||
self.course.pdf_textbooks = [self.textbook1, self.textbook2]
|
||||
self.store = get_modulestore(self.course.location)
|
||||
self.store.update_metadata(self.course.location, own_metadata(self.course))
|
||||
self.url_nonexist = reverse('textbook_by_id', kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'tid': 20,
|
||||
})
|
||||
|
||||
def test_get_1(self):
|
||||
"Get the first textbook"
|
||||
resp = self.client.get(self.url1)
|
||||
self.assert2XX(resp.status_code)
|
||||
compare = json.loads(resp.content)
|
||||
self.assertEqual(compare, self.textbook1)
|
||||
|
||||
def test_get_2(self):
|
||||
"Get the second textbook"
|
||||
resp = self.client.get(self.url2)
|
||||
self.assert2XX(resp.status_code)
|
||||
compare = json.loads(resp.content)
|
||||
self.assertEqual(compare, self.textbook2)
|
||||
|
||||
def test_get_nonexistant(self):
|
||||
"Get a nonexistent textbook"
|
||||
resp = self.client.get(self.url_nonexist)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_delete(self):
|
||||
"Delete a textbook by ID"
|
||||
resp = self.client.delete(self.url1)
|
||||
self.assert2XX(resp.status_code)
|
||||
course = self.store.get_item(self.course.location)
|
||||
self.assertEqual(course.pdf_textbooks, [self.textbook2])
|
||||
|
||||
def test_delete_nonexistant(self):
|
||||
"Delete a textbook by ID, when the ID doesn't match an existing textbook"
|
||||
resp = self.client.delete(self.url_nonexist)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
course = self.store.get_item(self.course.location)
|
||||
self.assertEqual(course.pdf_textbooks, [self.textbook1, self.textbook2])
|
||||
|
||||
def test_create_new_by_id(self):
|
||||
"Create a textbook by ID"
|
||||
textbook = {
|
||||
"tab_title": "a new textbook",
|
||||
"url": "supercool.pdf",
|
||||
"id": "1supercool",
|
||||
}
|
||||
url = reverse("textbook_by_id", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'tid': "1supercool",
|
||||
})
|
||||
resp = self.client.post(
|
||||
url,
|
||||
data=json.dumps(textbook),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp2 = self.client.get(url)
|
||||
self.assert2XX(resp2.status_code)
|
||||
compare = json.loads(resp2.content)
|
||||
self.assertEqual(compare, textbook)
|
||||
course = self.store.get_item(self.course.location)
|
||||
self.assertEqual(
|
||||
course.pdf_textbooks,
|
||||
[self.textbook1, self.textbook2, textbook]
|
||||
)
|
||||
|
||||
def test_replace_by_id(self):
|
||||
"Create a textbook by ID, overwriting an existing textbook ID"
|
||||
replacement = {
|
||||
"tab_title": "You've been replaced!",
|
||||
"url": "supercool.pdf",
|
||||
"id": "2",
|
||||
}
|
||||
resp = self.client.post(
|
||||
self.url2,
|
||||
data=json.dumps(replacement),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp2 = self.client.get(self.url2)
|
||||
self.assert2XX(resp2.status_code)
|
||||
compare = json.loads(resp2.content)
|
||||
self.assertEqual(compare, replacement)
|
||||
course = self.store.get_item(self.course.location)
|
||||
self.assertEqual(
|
||||
course.pdf_textbooks,
|
||||
[self.textbook1, replacement]
|
||||
)
|
||||
|
||||
|
||||
class TextbookValidationTestCase(TestCase):
|
||||
"Tests for the code to validate the structure of a PDF textbook"
|
||||
|
||||
def setUp(self):
|
||||
"Set some useful content for tests"
|
||||
self.tb1 = {
|
||||
"tab_title": "Hi, mom!",
|
||||
"url": "/mom.pdf"
|
||||
}
|
||||
self.tb2 = {
|
||||
"tab_title": "Hi, dad!",
|
||||
"chapters": [
|
||||
{
|
||||
"title": "Baseball",
|
||||
"url": "baseball.pdf",
|
||||
}, {
|
||||
"title": "Basketball",
|
||||
"url": "crazypants.pdf",
|
||||
}
|
||||
]
|
||||
}
|
||||
self.textbooks = [self.tb1, self.tb2]
|
||||
|
||||
def test_happy_path_plural(self):
|
||||
"Test that the plural validator works properly"
|
||||
result = validate_textbooks_json(json.dumps(self.textbooks))
|
||||
self.assertEqual(self.textbooks, result)
|
||||
|
||||
def test_happy_path_singular_1(self):
|
||||
"Test that the singular validator works properly"
|
||||
result = validate_textbook_json(json.dumps(self.tb1))
|
||||
self.assertEqual(self.tb1, result)
|
||||
|
||||
def test_happy_path_singular_2(self):
|
||||
"Test that the singular validator works properly, with different data"
|
||||
result = validate_textbook_json(json.dumps(self.tb2))
|
||||
self.assertEqual(self.tb2, result)
|
||||
|
||||
def test_valid_id(self):
|
||||
"Test that a valid ID doesn't trip the validator, and comes out unchanged"
|
||||
self.tb1["id"] = 1
|
||||
result = validate_textbook_json(json.dumps(self.tb1))
|
||||
self.assertEqual(self.tb1, result)
|
||||
|
||||
def test_invalid_id(self):
|
||||
"Test that an invalid ID trips the validator"
|
||||
self.tb1["id"] = "abc"
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbook_json(json.dumps(self.tb1))
|
||||
|
||||
def test_invalid_json_plural(self):
|
||||
"Test that invalid JSON trips the plural validator"
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbooks_json("[{'abc'}]")
|
||||
|
||||
def test_invalid_json_singular(self):
|
||||
"Test that invalid JSON trips the singluar validator"
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbook_json("[{1]}")
|
||||
|
||||
def test_wrong_json_plural(self):
|
||||
"Test that a JSON object trips the plural validators (requires a list)"
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbooks_json('{"tab_title": "Hi, mom!"}')
|
||||
|
||||
def test_wrong_json_singular(self):
|
||||
"Test that a JSON list trips the plural validators (requires an object)"
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbook_json('[{"tab_title": "Hi, mom!"}, {"tab_title": "Hi, dad!"}]')
|
||||
|
||||
def test_no_tab_title_plural(self):
|
||||
"Test that `tab_title` is required for the plural validator"
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbooks_json('[{"url": "/textbook.pdf"}]')
|
||||
|
||||
def test_no_tab_title_singular(self):
|
||||
"Test that `tab_title` is required for the singular validator"
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbook_json('{"url": "/textbook.pdf"}')
|
||||
|
||||
def test_duplicate_ids(self):
|
||||
"Test that duplicate IDs in the plural validator trips the validator"
|
||||
textbooks = [{
|
||||
"tab_title": "name one",
|
||||
"url": "one.pdf",
|
||||
"id": 1,
|
||||
}, {
|
||||
"tab_title": "name two",
|
||||
"url": "two.pdf",
|
||||
"id": 1,
|
||||
}]
|
||||
with self.assertRaises(TextbookValidationError):
|
||||
validate_textbooks_json(json.dumps(textbooks))
|
||||
15
cms/djangoapps/contentstore/tests/test_users.py
Normal file
15
cms/djangoapps/contentstore/tests/test_users.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import json
|
||||
from .utils import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
|
||||
class UsersTestCase(CourseTestCase):
|
||||
def setUp(self):
|
||||
super(UsersTestCase, self).setUp()
|
||||
self.url = reverse("add_user", kwargs={"location": ""})
|
||||
|
||||
def test_empty(self):
|
||||
resp = self.client.post(self.url)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
content = json.loads(resp.content)
|
||||
self.assertEqual(content["Status"], "Failed")
|
||||
@@ -10,11 +10,13 @@ from pytz import UTC
|
||||
|
||||
|
||||
class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
def _login(self, email, pw):
|
||||
"""Login. View should always return 200. The success/fail is in the
|
||||
returned json"""
|
||||
def _login(self, email, password):
|
||||
"""
|
||||
Login. View should always return 200. The success/fail is in the
|
||||
returned json
|
||||
"""
|
||||
resp = self.client.post(reverse('login_post'),
|
||||
{'email': email, 'password': pw})
|
||||
{'email': email, 'password': password})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return resp
|
||||
|
||||
@@ -25,12 +27,12 @@ class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
self.assertTrue(data['success'])
|
||||
return resp
|
||||
|
||||
def _create_account(self, username, email, pw):
|
||||
def _create_account(self, username, email, password):
|
||||
"""Try to create an account. No error checking"""
|
||||
resp = self.client.post('/create_account', {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': pw,
|
||||
'password': password,
|
||||
'location': 'home',
|
||||
'language': 'Franglish',
|
||||
'name': 'Fred Weasley',
|
||||
@@ -39,9 +41,9 @@ class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
})
|
||||
return resp
|
||||
|
||||
def create_account(self, username, email, pw):
|
||||
def create_account(self, username, email, password):
|
||||
"""Create the account and check that it worked"""
|
||||
resp = self._create_account(username, email, pw)
|
||||
resp = self._create_account(username, email, password)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['success'], True)
|
||||
@@ -88,7 +90,7 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
reverse('signup'),
|
||||
)
|
||||
for page in pages:
|
||||
print "Checking '{0}'".format(page)
|
||||
print("Checking '{0}'".format(page))
|
||||
self.check_page_get(page, 200)
|
||||
|
||||
def test_create_account_errors(self):
|
||||
@@ -146,17 +148,17 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
self.client = Client()
|
||||
|
||||
# Not logged in. Should redirect to login.
|
||||
print 'Not logged in'
|
||||
print('Not logged in')
|
||||
for page in auth_pages:
|
||||
print "Checking '{0}'".format(page)
|
||||
print("Checking '{0}'".format(page))
|
||||
self.check_page_get(page, expected=302)
|
||||
|
||||
# Logged in should work.
|
||||
self.login(self.email, self.pw)
|
||||
|
||||
print 'Logged in'
|
||||
print('Logged in')
|
||||
for page in simple_auth_pages:
|
||||
print "Checking '{0}'".format(page)
|
||||
print("Checking '{0}'".format(page))
|
||||
self.check_page_get(page, expected=200)
|
||||
|
||||
def test_index_auth(self):
|
||||
|
||||
@@ -6,6 +6,10 @@ import json
|
||||
|
||||
from student.models import Registration
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
def parse_json(response):
|
||||
@@ -21,3 +25,37 @@ def user(email):
|
||||
def registration(email):
|
||||
"""look up registration object by email"""
|
||||
return Registration.objects.get(user__email=email)
|
||||
|
||||
|
||||
class CourseTestCase(ModuleStoreTestCase):
|
||||
def setUp(self):
|
||||
"""
|
||||
These tests need a user in the DB so that the django Test Client
|
||||
can log them in.
|
||||
They inherit from the ModuleStoreTestCase class so that the mongodb collection
|
||||
will be cleared out before each test case execution and deleted
|
||||
afterwards.
|
||||
"""
|
||||
uname = 'testuser'
|
||||
email = 'test+courses@edx.org'
|
||||
password = 'foo'
|
||||
|
||||
# Create the use so we can log them in.
|
||||
self.user = User.objects.create_user(uname, email, password)
|
||||
|
||||
# Note that we do not actually need to do anything
|
||||
# for registration if we directly mark them active.
|
||||
self.user.is_active = True
|
||||
# Staff has access to view all courses
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
|
||||
self.client = Client()
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
self.course = CourseFactory.create(
|
||||
template='i4x://edx/templates/course/Empty',
|
||||
org='MITx',
|
||||
number='999',
|
||||
display_name='Robot Super Course',
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#pylint: disable=E1103, E1101
|
||||
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# pylint: disable=W0401, W0511
|
||||
|
||||
"All view functions for contentstore, broken out into submodules"
|
||||
|
||||
# Disable warnings about import from wildcard
|
||||
# All files below declare exports with __all__
|
||||
from .assets import *
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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': ''
|
||||
})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', {}))
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,18 +10,20 @@ from mitxmako.shortcuts import render_to_response
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from ..utils import get_course_for_item
|
||||
from ..utils import get_course_for_item, get_modulestore
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static']
|
||||
|
||||
|
||||
def initialize_course_tabs(course):
|
||||
# set up the default tabs
|
||||
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
# at least a list populated with the minimal times
|
||||
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
"""
|
||||
set up the default tabs
|
||||
I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
at least a list populated with the minimal times
|
||||
@TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
"""
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
@@ -82,7 +84,8 @@ def reorder_static_tabs(request):
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
course_item = modulestore().get_item(location)
|
||||
store = get_modulestore(location)
|
||||
course_item = store.get_item(location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
@@ -108,7 +111,6 @@ def edit_tabs(request, org, course, coursename):
|
||||
]
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course_item,
|
||||
'components': components
|
||||
})
|
||||
@@ -123,7 +125,6 @@ def static_pages(request, org, course, coursename):
|
||||
course = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('static-pages.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course,
|
||||
})
|
||||
|
||||
|
||||
@@ -8,28 +8,11 @@ from mitxmako.shortcuts import render_to_response
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.utils import get_url_reverse, get_lms_link_for_item
|
||||
from util.json_request import expect_json
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role
|
||||
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
|
||||
|
||||
from .access import has_access
|
||||
from .requests import create_json_response
|
||||
|
||||
|
||||
def user_author_string(user):
|
||||
'''Get an author string for commits by this user. Format:
|
||||
first last <email@email.com>.
|
||||
|
||||
If the first and last names are blank, uses the username instead.
|
||||
Assumes that the email is not blank.
|
||||
'''
|
||||
f = user.first_name
|
||||
l = user.last_name
|
||||
if f == '' and l == '':
|
||||
f = user.username
|
||||
return '{first} {last} <{email}>'.format(first=f,
|
||||
last=l,
|
||||
email=user.email)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -73,7 +56,6 @@ def manage_users(request, location):
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('manage_users.html', {
|
||||
'active_tab': 'users',
|
||||
'context_course': course_module,
|
||||
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
|
||||
'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'),
|
||||
@@ -91,10 +73,14 @@ def add_user(request, location):
|
||||
This POST-back view will add a user - specified by email - to the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
email = request.POST["email"]
|
||||
email = request.POST.get("email")
|
||||
|
||||
if email == '':
|
||||
return create_json_response('Please specify an email address.')
|
||||
if not email:
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': 'Please specify an email address.',
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
@@ -104,16 +90,24 @@ def add_user(request, location):
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': "Could not find user by email address '{0}'.".format(email),
|
||||
}
|
||||
return JsonResponse(msg, 404)
|
||||
|
||||
# user exists, but hasn't activated account?!?
|
||||
if not user.is_active:
|
||||
return create_json_response('User {0} has registered but has not yet activated his/her account.'.format(email))
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': 'User {0} has registered but has not yet activated his/her account.'.format(email),
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
# ok, we're cool to add to the course group
|
||||
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
return JsonResponse({"Status": "OK"})
|
||||
|
||||
|
||||
@expect_json
|
||||
@@ -133,7 +127,11 @@ def remove_user(request, location):
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': "Could not find user by email address '{0}'.".format(email),
|
||||
}
|
||||
return JsonResponse(msg, 404)
|
||||
|
||||
# make sure we're not removing ourselves
|
||||
if user.id == request.user.id:
|
||||
@@ -141,4 +139,4 @@ def remove_user(request, location):
|
||||
|
||||
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
return JsonResponse({"Status": "OK"})
|
||||
|
||||
0
cms/djangoapps/course_creators/__init__.py
Normal file
0
cms/djangoapps/course_creators/__init__.py
Normal file
63
cms/djangoapps/course_creators/admin.py
Normal file
63
cms/djangoapps/course_creators/admin.py
Normal 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'])
|
||||
74
cms/djangoapps/course_creators/migrations/0001_initial.py
Normal file
74
cms/djangoapps/course_creators/migrations/0001_initial.py
Normal 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']
|
||||
71
cms/djangoapps/course_creators/models.py
Normal file
71
cms/djangoapps/course_creators/models.py
Normal 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()
|
||||
80
cms/djangoapps/course_creators/tests/test_admin.py
Normal file
80
cms/djangoapps/course_creators/tests/test_admin.py
Normal 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))
|
||||
71
cms/djangoapps/course_creators/tests/test_views.py
Normal file
71
cms/djangoapps/course_creators/tests/test_views.py
Normal 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))
|
||||
76
cms/djangoapps/course_creators/views.py
Normal file
76
cms/djangoapps/course_creators/views.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
77
cms/envs/acceptance_static.py
Normal file
77
cms/envs/acceptance_static.py
Normal 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'
|
||||
@@ -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)
|
||||
|
||||
@@ -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
8
cms/envs/debug_upload.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#pylint: disable=W0614, W0401
|
||||
from .dev import *
|
||||
|
||||
FILE_UPLOAD_HANDLERS = (
|
||||
'contentstore.debug_file_uploader.DebugFileUploader',
|
||||
'django.core.files.uploadhandler.MemoryFileUploadHandler',
|
||||
'django.core.files.uploadhandler.TemporaryFileUploadHandler',
|
||||
)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
0
cms/manage.py
Normal file → Executable file
@@ -6,10 +6,10 @@ from request_cache.middleware import RequestCache
|
||||
|
||||
from django.core.cache import get_cache
|
||||
|
||||
cache = get_cache('mongo_metadata_inheritance')
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
for store_name in settings.MODULESTORE:
|
||||
store = modulestore(store_name)
|
||||
store.metadata_inheritance_cache_subsystem = cache
|
||||
store.metadata_inheritance_cache_subsystem = CACHE
|
||||
store.request_cache = RequestCache.get_request_cache()
|
||||
|
||||
modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
"js/vendor/underscore-min.js",
|
||||
"js/vendor/underscore.string.min.js",
|
||||
"js/vendor/backbone-min.js",
|
||||
"js/vendor/backbone-associations-min.js",
|
||||
"js/vendor/jquery.leanModal.min.js",
|
||||
"js/vendor/jquery.form.js",
|
||||
"js/vendor/sinon-1.7.1.js",
|
||||
"js/vendor/jasmine-stealth.js",
|
||||
"js/test/i18n.js"
|
||||
]
|
||||
}
|
||||
|
||||
1
cms/static/coffee/fixtures/edit-chapter.underscore
Symbolic link
1
cms/static/coffee/fixtures/edit-chapter.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/edit-chapter.underscore
|
||||
1
cms/static/coffee/fixtures/edit-textbook.underscore
Symbolic link
1
cms/static/coffee/fixtures/edit-textbook.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/edit-textbook.underscore
|
||||
1
cms/static/coffee/fixtures/no-textbooks.underscore
Symbolic link
1
cms/static/coffee/fixtures/no-textbooks.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/no-textbooks.underscore
|
||||
1
cms/static/coffee/fixtures/show-textbook.underscore
Symbolic link
1
cms/static/coffee/fixtures/show-textbook.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/show-textbook.underscore
|
||||
1
cms/static/coffee/fixtures/upload-dialog.underscore
Symbolic link
1
cms/static/coffee/fixtures/upload-dialog.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/upload-dialog.underscore
|
||||
227
cms/static/coffee/spec/models/textbook_spec.coffee
Normal file
227
cms/static/coffee/spec/models/textbook_spec.coffee
Normal file
@@ -0,0 +1,227 @@
|
||||
beforeEach ->
|
||||
@addMatchers
|
||||
toBeInstanceOf: (expected) ->
|
||||
return @actual instanceof expected
|
||||
|
||||
|
||||
describe "CMS.Models.Textbook", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.Textbook()
|
||||
|
||||
describe "Basic", ->
|
||||
it "should have an empty name by default", ->
|
||||
expect(@model.get("name")).toEqual("")
|
||||
|
||||
it "should not show chapters by default", ->
|
||||
expect(@model.get("showChapters")).toBeFalsy()
|
||||
|
||||
it "should have a ChapterSet with one chapter by default", ->
|
||||
chapters = @model.get("chapters")
|
||||
expect(chapters).toBeInstanceOf(CMS.Collections.ChapterSet)
|
||||
expect(chapters.length).toEqual(1)
|
||||
expect(chapters.at(0).isEmpty()).toBeTruthy()
|
||||
|
||||
it "should be empty by default", ->
|
||||
expect(@model.isEmpty()).toBeTruthy()
|
||||
|
||||
it "should have a URL set", ->
|
||||
expect(_.result(@model, "url")).toBeTruthy()
|
||||
|
||||
it "should be able to reset itself", ->
|
||||
@model.set("name", "foobar")
|
||||
@model.reset()
|
||||
expect(@model.get("name")).toEqual("")
|
||||
|
||||
it "should not be dirty by default", ->
|
||||
expect(@model.isDirty()).toBeFalsy()
|
||||
|
||||
it "should be dirty after it's been changed", ->
|
||||
@model.set("name", "foobar")
|
||||
expect(@model.isDirty()).toBeTruthy()
|
||||
|
||||
it "should not be dirty after calling setOriginalAttributes", ->
|
||||
@model.set("name", "foobar")
|
||||
@model.setOriginalAttributes()
|
||||
expect(@model.isDirty()).toBeFalsy()
|
||||
|
||||
describe "Input/Output", ->
|
||||
deepAttributes = (obj) ->
|
||||
if obj instanceof Backbone.Model
|
||||
deepAttributes(obj.attributes)
|
||||
else if obj instanceof Backbone.Collection
|
||||
obj.map(deepAttributes);
|
||||
else if _.isArray(obj)
|
||||
_.map(obj, deepAttributes);
|
||||
else if _.isObject(obj)
|
||||
attributes = {};
|
||||
for own prop, val of obj
|
||||
attributes[prop] = deepAttributes(val)
|
||||
attributes
|
||||
else
|
||||
obj
|
||||
|
||||
it "should match server model to client model", ->
|
||||
serverModelSpec = {
|
||||
"tab_title": "My Textbook",
|
||||
"chapters": [
|
||||
{"title": "Chapter 1", "url": "/ch1.pdf"},
|
||||
{"title": "Chapter 2", "url": "/ch2.pdf"},
|
||||
]
|
||||
}
|
||||
clientModelSpec = {
|
||||
"name": "My Textbook",
|
||||
"showChapters": false,
|
||||
"editing": false,
|
||||
"chapters": [{
|
||||
"name": "Chapter 1",
|
||||
"asset_path": "/ch1.pdf",
|
||||
"order": 1
|
||||
}, {
|
||||
"name": "Chapter 2",
|
||||
"asset_path": "/ch2.pdf",
|
||||
"order": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
model = new CMS.Models.Textbook(serverModelSpec, {parse: true})
|
||||
expect(deepAttributes(model)).toEqual(clientModelSpec)
|
||||
expect(model.toJSON()).toEqual(serverModelSpec)
|
||||
|
||||
describe "Validation", ->
|
||||
it "requires a name", ->
|
||||
model = new CMS.Models.Textbook({name: ""})
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
|
||||
it "requires at least one chapter", ->
|
||||
model = new CMS.Models.Textbook({name: "foo"})
|
||||
model.get("chapters").reset()
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
|
||||
it "requires a valid chapter", ->
|
||||
chapter = new CMS.Models.Chapter()
|
||||
chapter.isValid = -> false
|
||||
model = new CMS.Models.Textbook({name: "foo"})
|
||||
model.get("chapters").reset([chapter])
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
|
||||
it "requires all chapters to be valid", ->
|
||||
chapter1 = new CMS.Models.Chapter()
|
||||
chapter1.isValid = -> true
|
||||
chapter2 = new CMS.Models.Chapter()
|
||||
chapter2.isValid = -> false
|
||||
model = new CMS.Models.Textbook({name: "foo"})
|
||||
model.get("chapters").reset([chapter1, chapter2])
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
|
||||
it "can pass validation", ->
|
||||
chapter = new CMS.Models.Chapter()
|
||||
chapter.isValid = -> true
|
||||
model = new CMS.Models.Textbook({name: "foo"})
|
||||
model.get("chapters").reset([chapter])
|
||||
expect(model.isValid()).toBeTruthy()
|
||||
|
||||
|
||||
describe "CMS.Collections.TextbookSet", ->
|
||||
beforeEach ->
|
||||
CMS.URL.TEXTBOOK = "/textbooks"
|
||||
@collection = new CMS.Collections.TextbookSet()
|
||||
|
||||
afterEach ->
|
||||
delete CMS.URL.TEXTBOOK
|
||||
|
||||
it "should have a url set", ->
|
||||
expect(_.result(@collection, "url"), "/textbooks")
|
||||
|
||||
it "can call save", ->
|
||||
spyOn(@collection, "sync")
|
||||
@collection.save()
|
||||
expect(@collection.sync).toHaveBeenCalledWith("update", @collection, undefined)
|
||||
|
||||
|
||||
describe "CMS.Models.Chapter", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.Chapter()
|
||||
|
||||
describe "Basic", ->
|
||||
it "should have a name by default", ->
|
||||
expect(@model.get("name")).toEqual("")
|
||||
|
||||
it "should have an asset_path by default", ->
|
||||
expect(@model.get("asset_path")).toEqual("")
|
||||
|
||||
it "should have an order by default", ->
|
||||
expect(@model.get("order")).toEqual(1)
|
||||
|
||||
it "should be empty by default", ->
|
||||
expect(@model.isEmpty()).toBeTruthy()
|
||||
|
||||
describe "Validation", ->
|
||||
it "requires a name", ->
|
||||
model = new CMS.Models.Chapter({name: "", asset_path: "a.pdf"})
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
|
||||
it "requires an asset_path", ->
|
||||
model = new CMS.Models.Chapter({name: "a", asset_path: ""})
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
|
||||
it "can pass validation", ->
|
||||
model = new CMS.Models.Chapter({name: "a", asset_path: "a.pdf"})
|
||||
expect(model.isValid()).toBeTruthy()
|
||||
|
||||
|
||||
describe "CMS.Collections.ChapterSet", ->
|
||||
beforeEach ->
|
||||
@collection = new CMS.Collections.ChapterSet()
|
||||
|
||||
it "is empty by default", ->
|
||||
expect(@collection.isEmpty()).toBeTruthy()
|
||||
|
||||
it "is empty if all chapters are empty", ->
|
||||
@collection.add([{}, {}, {}])
|
||||
expect(@collection.isEmpty()).toBeTruthy()
|
||||
|
||||
it "is not empty if a chapter is not empty", ->
|
||||
@collection.add([{}, {name: "full"}, {}])
|
||||
expect(@collection.isEmpty()).toBeFalsy()
|
||||
|
||||
it "should have a nextOrder function", ->
|
||||
expect(@collection.nextOrder()).toEqual(1)
|
||||
@collection.add([{}])
|
||||
expect(@collection.nextOrder()).toEqual(2)
|
||||
@collection.add([{}])
|
||||
expect(@collection.nextOrder()).toEqual(3)
|
||||
# verify that it doesn't just return an incrementing value each time
|
||||
expect(@collection.nextOrder()).toEqual(3)
|
||||
# try going back one
|
||||
@collection.remove(@collection.last())
|
||||
expect(@collection.nextOrder()).toEqual(2)
|
||||
|
||||
|
||||
describe "CMS.Models.FileUpload", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.FileUpload()
|
||||
|
||||
it "is unfinished by default", ->
|
||||
expect(@model.get("finished")).toBeFalsy()
|
||||
|
||||
it "is not uploading by default", ->
|
||||
expect(@model.get("uploading")).toBeFalsy()
|
||||
|
||||
it "is valid by default", ->
|
||||
expect(@model.isValid()).toBeTruthy()
|
||||
|
||||
it "is valid for PDF files", ->
|
||||
file = {"type": "application/pdf"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeTruthy()
|
||||
|
||||
it "is invalid for text files", ->
|
||||
file = {"type": "text/plain"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeFalsy()
|
||||
|
||||
it "is invalid for PNG files", ->
|
||||
file = {"type": "image/png"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeFalsy()
|
||||
@@ -98,6 +98,16 @@ describe "CMS.Views.Prompt", ->
|
||||
view.hide()
|
||||
# expect($("body")).not.toHaveClass("prompt-is-shown")
|
||||
|
||||
describe "CMS.Views.Notification.Saving", ->
|
||||
beforeEach ->
|
||||
@view = new CMS.Views.Notification.Saving()
|
||||
|
||||
it "should have minShown set to 1250 by default", ->
|
||||
expect(@view.options.minShown).toEqual(1250)
|
||||
|
||||
it "should have closeIcon set to false by default", ->
|
||||
expect(@view.options.closeIcon).toBeFalsy()
|
||||
|
||||
describe "CMS.Views.SystemFeedback click events", ->
|
||||
beforeEach ->
|
||||
@primaryClickSpy = jasmine.createSpy('primaryClick')
|
||||
@@ -204,17 +214,22 @@ describe "CMS.Views.SystemFeedback multiple secondary actions", ->
|
||||
|
||||
describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
beforeEach ->
|
||||
@showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show')
|
||||
@showSpy = spyOn(CMS.Views.Notification.Confirmation.prototype, 'show')
|
||||
@showSpy.andCallThrough()
|
||||
@hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide')
|
||||
@hideSpy = spyOn(CMS.Views.Notification.Confirmation.prototype, 'hide')
|
||||
@hideSpy.andCallThrough()
|
||||
@clock = sinon.useFakeTimers()
|
||||
|
||||
afterEach ->
|
||||
@clock.restore()
|
||||
|
||||
it "should not have minShown or maxShown by default", ->
|
||||
view = new CMS.Views.Notification.Confirmation()
|
||||
expect(view.options.minShown).toEqual(0)
|
||||
expect(view.options.maxShown).toEqual(Infinity)
|
||||
|
||||
it "a minShown view should not hide too quickly", ->
|
||||
view = new CMS.Views.Notification.Saving({minShown: 1000})
|
||||
view = new CMS.Views.Notification.Confirmation({minShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -227,7 +242,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a maxShown view should hide by itself", ->
|
||||
view = new CMS.Views.Notification.Saving({maxShown: 1000})
|
||||
view = new CMS.Views.Notification.Confirmation({maxShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -236,7 +251,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a minShown view can stay visible longer", ->
|
||||
view = new CMS.Views.Notification.Saving({minShown: 1000})
|
||||
view = new CMS.Views.Notification.Confirmation({minShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -250,7 +265,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a maxShown view can hide early", ->
|
||||
view = new CMS.Views.Notification.Saving({maxShown: 1000})
|
||||
view = new CMS.Views.Notification.Confirmation({maxShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
@@ -264,7 +279,7 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a view can have both maxShown and minShown", ->
|
||||
view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000})
|
||||
view = new CMS.Views.Notification.Confirmation({minShown: 1000, maxShown: 2000})
|
||||
view.show()
|
||||
|
||||
# can't hide early
|
||||
|
||||
423
cms/static/coffee/spec/views/textbook_spec.coffee
Normal file
423
cms/static/coffee/spec/views/textbook_spec.coffee
Normal file
@@ -0,0 +1,423 @@
|
||||
feedbackTpl = readFixtures('system-feedback.underscore')
|
||||
|
||||
beforeEach ->
|
||||
# remove this when we upgrade jasmine-jquery
|
||||
@addMatchers
|
||||
toContainText: (text) ->
|
||||
trimmedText = $.trim(@actual.text())
|
||||
if text and $.isFunction(text.test)
|
||||
return text.test(trimmedText)
|
||||
else
|
||||
return trimmedText.indexOf(text) != -1;
|
||||
|
||||
describe "CMS.Views.ShowTextbook", ->
|
||||
tpl = readFixtures('show-textbook.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "show-textbook-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
|
||||
appendSetFixtures(sandbox({id: "page-notification"}))
|
||||
appendSetFixtures(sandbox({id: "page-prompt"}))
|
||||
@model = new CMS.Models.Textbook({name: "Life Sciences", id: "0life-sciences"})
|
||||
spyOn(@model, "destroy").andCallThrough()
|
||||
@collection = new CMS.Collections.TextbookSet([@model])
|
||||
@view = new CMS.Views.ShowTextbook({model: @model})
|
||||
|
||||
@promptSpies = spyOnConstructor(CMS.Views.Prompt, "Warning", ["show", "hide"])
|
||||
@promptSpies.show.andReturn(@promptSpies)
|
||||
window.section = new CMS.Models.Section({
|
||||
id: "5",
|
||||
name: "Course Name",
|
||||
url_name: "course_name",
|
||||
org: "course_org",
|
||||
num: "course_num",
|
||||
revision: "course_rev"
|
||||
});
|
||||
|
||||
afterEach ->
|
||||
delete window.section
|
||||
|
||||
describe "Basic", ->
|
||||
it "should render properly", ->
|
||||
@view.render()
|
||||
expect(@view.$el).toContainText("Life Sciences")
|
||||
|
||||
it "should set the 'editing' property on the model when the edit button is clicked", ->
|
||||
@view.render().$(".edit").click()
|
||||
expect(@model.get("editing")).toBeTruthy()
|
||||
|
||||
it "should pop a delete confirmation when the delete button is clicked", ->
|
||||
@view.render().$(".delete").click()
|
||||
expect(@promptSpies.constructor).toHaveBeenCalled()
|
||||
ctorOptions = @promptSpies.constructor.mostRecentCall.args[0]
|
||||
expect(ctorOptions.title).toMatch(/Life Sciences/)
|
||||
# hasn't actually been removed
|
||||
expect(@model.destroy).not.toHaveBeenCalled()
|
||||
expect(@collection).toContain(@model)
|
||||
|
||||
it "should show chapters appropriately", ->
|
||||
@model.get("chapters").add([{}, {}, {}])
|
||||
@model.set('showChapters', false)
|
||||
@view.render().$(".show-chapters").click()
|
||||
expect(@model.get('showChapters')).toBeTruthy()
|
||||
|
||||
it "should hide chapters appropriately", ->
|
||||
@model.get("chapters").add([{}, {}, {}])
|
||||
@model.set('showChapters', true)
|
||||
@view.render().$(".hide-chapters").click()
|
||||
expect(@model.get('showChapters')).toBeFalsy()
|
||||
|
||||
describe "AJAX", ->
|
||||
beforeEach ->
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
|
||||
@savingSpies = spyOnConstructor(CMS.Views.Notification, "Saving",
|
||||
["show", "hide"])
|
||||
@savingSpies.show.andReturn(@savingSpies)
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
|
||||
it "should destroy itself on confirmation", ->
|
||||
@view.render().$(".delete").click()
|
||||
ctorOptions = @promptSpies.constructor.mostRecentCall.args[0]
|
||||
# run the primary function to indicate confirmation
|
||||
ctorOptions.actions.primary.click(@promptSpies)
|
||||
# AJAX request has been sent, but not yet returned
|
||||
expect(@model.destroy).toHaveBeenCalled()
|
||||
expect(@requests.length).toEqual(1)
|
||||
expect(@savingSpies.constructor).toHaveBeenCalled()
|
||||
expect(@savingSpies.show).toHaveBeenCalled()
|
||||
expect(@savingSpies.hide).not.toHaveBeenCalled()
|
||||
savingOptions = @savingSpies.constructor.mostRecentCall.args[0]
|
||||
expect(savingOptions.title).toMatch(/Deleting/)
|
||||
# return a success response
|
||||
@requests[0].respond(200)
|
||||
expect(@savingSpies.hide).toHaveBeenCalled()
|
||||
expect(@collection.contains(@model)).toBeFalsy()
|
||||
|
||||
describe "CMS.Views.EditTextbook", ->
|
||||
describe "Basic", ->
|
||||
tpl = readFixtures('edit-textbook.underscore')
|
||||
chapterTpl = readFixtures('edit-chapter.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "edit-textbook-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures($("<script>", {id: "edit-chapter-tpl", type: "text/template"}).text(chapterTpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
|
||||
appendSetFixtures(sandbox({id: "page-notification"}))
|
||||
appendSetFixtures(sandbox({id: "page-prompt"}))
|
||||
@model = new CMS.Models.Textbook({name: "Life Sciences", editing: true})
|
||||
spyOn(@model, 'save')
|
||||
@collection = new CMS.Collections.TextbookSet()
|
||||
@collection.add(@model)
|
||||
@view = new CMS.Views.EditTextbook({model: @model})
|
||||
spyOn(@view, 'render').andCallThrough()
|
||||
|
||||
it "should render properly", ->
|
||||
@view.render()
|
||||
expect(@view.$("input[name=textbook-name]").val()).toEqual("Life Sciences")
|
||||
|
||||
it "should allow you to create new empty chapters", ->
|
||||
@view.render()
|
||||
numChapters = @model.get("chapters").length
|
||||
@view.$(".action-add-chapter").click()
|
||||
expect(@model.get("chapters").length).toEqual(numChapters+1)
|
||||
expect(@model.get("chapters").last().isEmpty()).toBeTruthy()
|
||||
|
||||
it "should save properly", ->
|
||||
@view.render()
|
||||
@view.$("input[name=textbook-name]").val("starfish")
|
||||
@view.$("input[name=chapter1-name]").val("wallflower")
|
||||
@view.$("input[name=chapter1-asset-path]").val("foobar")
|
||||
@view.$("form").submit()
|
||||
expect(@model.get("name")).toEqual("starfish")
|
||||
chapter = @model.get("chapters").first()
|
||||
expect(chapter.get("name")).toEqual("wallflower")
|
||||
expect(chapter.get("asset_path")).toEqual("foobar")
|
||||
expect(@model.save).toHaveBeenCalled()
|
||||
|
||||
it "should not save on invalid", ->
|
||||
@view.render()
|
||||
@view.$("input[name=textbook-name]").val("")
|
||||
@view.$("input[name=chapter1-asset-path]").val("foobar.pdf")
|
||||
@view.$("form").submit()
|
||||
expect(@model.validationError).toBeTruthy()
|
||||
expect(@model.save).not.toHaveBeenCalled()
|
||||
|
||||
it "does not save on cancel", ->
|
||||
@model.get("chapters").add([{name: "a", asset_path: "b"}])
|
||||
@view.render()
|
||||
@view.$("input[name=textbook-name]").val("starfish")
|
||||
@view.$("input[name=chapter1-asset-path]").val("foobar.pdf")
|
||||
@view.$(".action-cancel").click()
|
||||
expect(@model.get("name")).not.toEqual("starfish")
|
||||
chapter = @model.get("chapters").first()
|
||||
expect(chapter.get("asset_path")).not.toEqual("foobar")
|
||||
expect(@model.save).not.toHaveBeenCalled()
|
||||
|
||||
it "should be possible to correct validation errors", ->
|
||||
@view.render()
|
||||
@view.$("input[name=textbook-name]").val("")
|
||||
@view.$("input[name=chapter1-asset-path]").val("foobar.pdf")
|
||||
@view.$("form").submit()
|
||||
expect(@model.validationError).toBeTruthy()
|
||||
expect(@model.save).not.toHaveBeenCalled()
|
||||
@view.$("input[name=textbook-name]").val("starfish")
|
||||
@view.$("input[name=chapter1-name]").val("foobar")
|
||||
@view.$("form").submit()
|
||||
expect(@model.validationError).toBeFalsy()
|
||||
expect(@model.save).toHaveBeenCalled()
|
||||
|
||||
it "removes all empty chapters on cancel if the model has a non-empty chapter", ->
|
||||
chapters = @model.get("chapters")
|
||||
chapters.at(0).set("name", "non-empty")
|
||||
@model.setOriginalAttributes()
|
||||
@view.render()
|
||||
chapters.add([{}, {}, {}]) # add three empty chapters
|
||||
expect(chapters.length).toEqual(4)
|
||||
@view.$(".action-cancel").click()
|
||||
expect(chapters.length).toEqual(1)
|
||||
expect(chapters.first().get('name')).toEqual("non-empty")
|
||||
|
||||
it "removes all empty chapters on cancel except one if the model has no non-empty chapters", ->
|
||||
chapters = @model.get("chapters")
|
||||
@view.render()
|
||||
chapters.add([{}, {}, {}]) # add three empty chapters
|
||||
expect(chapters.length).toEqual(4)
|
||||
@view.$(".action-cancel").click()
|
||||
expect(chapters.length).toEqual(1)
|
||||
|
||||
|
||||
describe "CMS.Views.ListTextbooks", ->
|
||||
noTextbooksTpl = readFixtures("no-textbooks.underscore")
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "no-textbooks-tpl", type: "text/template"}).text(noTextbooksTpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
|
||||
@showSpies = spyOnConstructor(CMS.Views, "ShowTextbook", ["render"])
|
||||
@showSpies.render.andReturn(@showSpies) # equivalent of `return this`
|
||||
showEl = $("<li>")
|
||||
@showSpies.$el = showEl
|
||||
@showSpies.el = showEl.get(0)
|
||||
@editSpies = spyOnConstructor(CMS.Views, "EditTextbook", ["render"])
|
||||
editEl = $("<li>")
|
||||
@editSpies.render.andReturn(@editSpies)
|
||||
@editSpies.$el = editEl
|
||||
@editSpies.el= editEl.get(0)
|
||||
|
||||
@collection = new CMS.Collections.TextbookSet
|
||||
@view = new CMS.Views.ListTextbooks({collection: @collection})
|
||||
@view.render()
|
||||
|
||||
it "should render the empty template if there are no textbooks", ->
|
||||
expect(@view.$el).toContainText("You haven't added any textbooks to this course yet")
|
||||
expect(@view.$el).toContain(".new-button")
|
||||
expect(@showSpies.constructor).not.toHaveBeenCalled()
|
||||
expect(@editSpies.constructor).not.toHaveBeenCalled()
|
||||
|
||||
it "should render ShowTextbook views by default if no textbook is being edited", ->
|
||||
# add three empty textbooks to the collection
|
||||
@collection.add([{}, {}, {}])
|
||||
# reset spies due to re-rendering on collection modification
|
||||
@showSpies.constructor.reset()
|
||||
@editSpies.constructor.reset()
|
||||
# render once and test
|
||||
@view.render()
|
||||
|
||||
expect(@view.$el).not.toContainText(
|
||||
"You haven't added any textbooks to this course yet")
|
||||
expect(@showSpies.constructor).toHaveBeenCalled()
|
||||
expect(@showSpies.constructor.calls.length).toEqual(3);
|
||||
expect(@editSpies.constructor).not.toHaveBeenCalled()
|
||||
|
||||
it "should render an EditTextbook view for a textbook being edited", ->
|
||||
# add three empty textbooks to the collection: the first and third
|
||||
# should be shown, and the second should be edited
|
||||
@collection.add([{editing: false}, {editing: true}, {editing: false}])
|
||||
editing = @collection.at(1)
|
||||
expect(editing.get("editing")).toBeTruthy()
|
||||
# reset spies
|
||||
@showSpies.constructor.reset()
|
||||
@editSpies.constructor.reset()
|
||||
# render once and test
|
||||
@view.render()
|
||||
|
||||
expect(@showSpies.constructor).toHaveBeenCalled()
|
||||
expect(@showSpies.constructor.calls.length).toEqual(2)
|
||||
expect(@showSpies.constructor).not.toHaveBeenCalledWith({model: editing})
|
||||
expect(@editSpies.constructor).toHaveBeenCalled()
|
||||
expect(@editSpies.constructor.calls.length).toEqual(1)
|
||||
expect(@editSpies.constructor).toHaveBeenCalledWith({model: editing})
|
||||
|
||||
it "should add a new textbook when the new-button is clicked", ->
|
||||
# reset spies
|
||||
@showSpies.constructor.reset()
|
||||
@editSpies.constructor.reset()
|
||||
# test
|
||||
@view.$(".new-button").click()
|
||||
|
||||
expect(@collection.length).toEqual(1)
|
||||
expect(@view.$el).toContain(@editSpies.$el)
|
||||
expect(@view.$el).not.toContain(@showSpies.$el)
|
||||
|
||||
|
||||
describe "CMS.Views.EditChapter", ->
|
||||
tpl = readFixtures("edit-chapter.underscore")
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "edit-chapter-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
|
||||
@model = new CMS.Models.Chapter
|
||||
name: "Chapter 1"
|
||||
asset_path: "/ch1.pdf"
|
||||
@collection = new CMS.Collections.ChapterSet()
|
||||
@collection.add(@model)
|
||||
@view = new CMS.Views.EditChapter({model: @model})
|
||||
spyOn(@view, "remove").andCallThrough()
|
||||
CMS.URL.UPLOAD_ASSET = "/upload"
|
||||
window.section = new CMS.Models.Section({name: "abcde"})
|
||||
|
||||
afterEach ->
|
||||
delete CMS.URL.UPLOAD_ASSET
|
||||
delete window.section
|
||||
|
||||
it "can render", ->
|
||||
@view.render()
|
||||
expect(@view.$("input.chapter-name").val()).toEqual("Chapter 1")
|
||||
expect(@view.$("input.chapter-asset-path").val()).toEqual("/ch1.pdf")
|
||||
|
||||
it "can delete itself", ->
|
||||
@view.render().$(".action-close").click()
|
||||
expect(@collection.length).toEqual(0)
|
||||
expect(@view.remove).toHaveBeenCalled()
|
||||
|
||||
it "can open an upload dialog", ->
|
||||
uploadSpies = spyOnConstructor(CMS.Views, "UploadDialog", ["show", "el"])
|
||||
uploadSpies.show.andReturn(uploadSpies)
|
||||
|
||||
@view.render().$(".action-upload").click()
|
||||
ctorOptions = uploadSpies.constructor.mostRecentCall.args[0]
|
||||
expect(ctorOptions.model.get('title')).toMatch(/abcde/)
|
||||
expect(ctorOptions.chapter).toBe(@model)
|
||||
expect(uploadSpies.show).toHaveBeenCalled()
|
||||
|
||||
it "saves content when opening upload dialog", ->
|
||||
@view.render()
|
||||
@view.$("input.chapter-name").val("rainbows")
|
||||
@view.$("input.chapter-asset-path").val("unicorns")
|
||||
@view.$(".action-upload").click()
|
||||
expect(@model.get("name")).toEqual("rainbows")
|
||||
expect(@model.get("asset_path")).toEqual("unicorns")
|
||||
|
||||
|
||||
describe "CMS.Views.UploadDialog", ->
|
||||
tpl = readFixtures("upload-dialog.underscore")
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
|
||||
CMS.URL.UPLOAD_ASSET = "/upload"
|
||||
|
||||
@model = new CMS.Models.FileUpload()
|
||||
@chapter = new CMS.Models.Chapter()
|
||||
@view = new CMS.Views.UploadDialog({model: @model, chapter: @chapter})
|
||||
spyOn(@view, 'remove').andCallThrough()
|
||||
|
||||
# create mock file input, so that we aren't subject to browser restrictions
|
||||
@mockFiles = []
|
||||
mockFileInput = jasmine.createSpy('mockFileInput')
|
||||
mockFileInput.files = @mockFiles
|
||||
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
|
||||
jqMockFileInput.get.andReturn(mockFileInput)
|
||||
realMethod = @view.$
|
||||
spyOn(@view, "$").andCallFake (selector) ->
|
||||
if selector == "input[type=file]"
|
||||
jqMockFileInput
|
||||
else
|
||||
realMethod.apply(this, arguments)
|
||||
|
||||
afterEach ->
|
||||
delete CMS.URL.UPLOAD_ASSET
|
||||
|
||||
describe "Basic", ->
|
||||
it "should be shown by default", ->
|
||||
expect(@view.options.shown).toBeTruthy()
|
||||
|
||||
it "should render without a file selected", ->
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$(".action-upload")).toHaveClass("disabled")
|
||||
|
||||
it "should render with a PDF selected", ->
|
||||
file = {name: "fake.pdf", "type": "application/pdf"}
|
||||
@mockFiles.push(file)
|
||||
@model.set("selectedFile", file)
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$el).not.toContain("#upload_error")
|
||||
expect(@view.$(".action-upload")).not.toHaveClass("disabled")
|
||||
|
||||
it "should render an error with an invalid file type selected", ->
|
||||
file = {name: "fake.png", "type": "image/png"}
|
||||
@mockFiles.push(file)
|
||||
@model.set("selectedFile", file)
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$el).toContain("#upload_error")
|
||||
expect(@view.$(".action-upload")).toHaveClass("disabled")
|
||||
|
||||
|
||||
it "adds body class on show()", ->
|
||||
@view.show()
|
||||
expect(@view.options.shown).toBeTruthy()
|
||||
# can't test: this blows up the spec runner
|
||||
# expect($("body")).toHaveClass("dialog-is-shown")
|
||||
|
||||
it "removes body class on hide()", ->
|
||||
@view.hide()
|
||||
expect(@view.options.shown).toBeFalsy()
|
||||
# can't test: this blows up the spec runner
|
||||
# expect($("body")).not.toHaveClass("dialog-is-shown")
|
||||
|
||||
describe "Uploads", ->
|
||||
beforeEach ->
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
@clock = sinon.useFakeTimers()
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
@clock.restore()
|
||||
|
||||
it "can upload correctly", ->
|
||||
@view.upload()
|
||||
expect(@model.get("uploading")).toBeTruthy()
|
||||
expect(@requests.length).toEqual(1)
|
||||
request = @requests[0]
|
||||
expect(request.url).toEqual("/upload")
|
||||
expect(request.method).toEqual("POST")
|
||||
|
||||
request.respond(200, {"Content-Type": "application/json"},
|
||||
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
|
||||
expect(@model.get("uploading")).toBeFalsy()
|
||||
expect(@model.get("finished")).toBeTruthy()
|
||||
expect(@chapter.get("name")).toEqual("starfish")
|
||||
expect(@chapter.get("asset_path")).toEqual("/uploaded/starfish.pdf")
|
||||
|
||||
it "can handle upload errors", ->
|
||||
@view.upload()
|
||||
@requests[0].respond(500)
|
||||
expect(@model.get("title")).toMatch(/error/)
|
||||
expect(@view.remove).not.toHaveBeenCalled()
|
||||
|
||||
it "removes itself after two seconds on successful upload", ->
|
||||
@view.upload()
|
||||
@requests[0].respond(200, {"Content-Type": "application/json"},
|
||||
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
|
||||
expect(@view.remove).not.toHaveBeenCalled()
|
||||
@clock.tick(2001)
|
||||
expect(@view.remove).toHaveBeenCalled()
|
||||
@@ -3,6 +3,8 @@ AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix)
|
||||
@CMS =
|
||||
Models: {}
|
||||
Views: {}
|
||||
Collections: {}
|
||||
URL: {}
|
||||
|
||||
prefix: $("meta[name='path_prefix']").attr('content')
|
||||
|
||||
@@ -17,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 |
@@ -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');
|
||||
}
|
||||
|
||||
@@ -23,9 +23,7 @@ CMS.Models.Section = Backbone.Model.extend({
|
||||
showNotification: function() {
|
||||
if(!this.msg) {
|
||||
this.msg = new CMS.Views.Notification.Saving({
|
||||
title: gettext("Saving…"),
|
||||
closeIcon: false,
|
||||
minShown: 1250
|
||||
title: gettext("Saving…")
|
||||
});
|
||||
}
|
||||
this.msg.show();
|
||||
|
||||
178
cms/static/js/models/textbook.js
Normal file
178
cms/static/js/models/textbook.js
Normal file
@@ -0,0 +1,178 @@
|
||||
CMS.Models.Textbook = Backbone.AssociatedModel.extend({
|
||||
defaults: function() {
|
||||
return {
|
||||
name: "",
|
||||
chapters: new CMS.Collections.ChapterSet([{}]),
|
||||
showChapters: false,
|
||||
editing: false
|
||||
};
|
||||
},
|
||||
relations: [{
|
||||
type: Backbone.Many,
|
||||
key: "chapters",
|
||||
relatedModel: "CMS.Models.Chapter",
|
||||
collectionType: "CMS.Collections.ChapterSet"
|
||||
}],
|
||||
initialize: function() {
|
||||
this.setOriginalAttributes();
|
||||
return this;
|
||||
},
|
||||
setOriginalAttributes: function() {
|
||||
this._originalAttributes = this.parse(this.toJSON());
|
||||
},
|
||||
reset: function() {
|
||||
this.set(this._originalAttributes, {parse: true});
|
||||
},
|
||||
isDirty: function() {
|
||||
return !_.isEqual(this._originalAttributes, this.parse(this.toJSON()));
|
||||
},
|
||||
isEmpty: function() {
|
||||
return !this.get('name') && this.get('chapters').isEmpty();
|
||||
},
|
||||
url: function() {
|
||||
if(this.isNew()) {
|
||||
return CMS.URL.TEXTBOOKS + "/new";
|
||||
} else {
|
||||
return CMS.URL.TEXTBOOKS + "/" + this.id;
|
||||
}
|
||||
},
|
||||
parse: function(response) {
|
||||
var ret = $.extend(true, {}, response);
|
||||
if("tab_title" in ret && !("name" in ret)) {
|
||||
ret.name = ret.tab_title;
|
||||
delete ret.tab_title;
|
||||
}
|
||||
if("url" in ret && !("chapters" in ret)) {
|
||||
ret.chapters = {"url": ret.url};
|
||||
delete ret.url;
|
||||
}
|
||||
_.each(ret.chapters, function(chapter, i) {
|
||||
chapter.order = chapter.order || i+1;
|
||||
});
|
||||
return ret;
|
||||
},
|
||||
toJSON: function() {
|
||||
return {
|
||||
tab_title: this.get('name'),
|
||||
chapters: this.get('chapters').toJSON()
|
||||
};
|
||||
},
|
||||
// NOTE: validation functions should return non-internationalized error
|
||||
// messages. The messages will be passed through gettext in the template.
|
||||
validate: function(attrs, options) {
|
||||
if (!attrs.name) {
|
||||
return {
|
||||
message: "Textbook name is required",
|
||||
attributes: {name: true}
|
||||
};
|
||||
}
|
||||
if (attrs.chapters.length === 0) {
|
||||
return {
|
||||
message: "Please add at least one chapter",
|
||||
attributes: {chapters: true}
|
||||
};
|
||||
} else {
|
||||
// validate all chapters
|
||||
var invalidChapters = [];
|
||||
attrs.chapters.each(function(chapter) {
|
||||
if(!chapter.isValid()) {
|
||||
invalidChapters.push(chapter);
|
||||
}
|
||||
});
|
||||
if(!_.isEmpty(invalidChapters)) {
|
||||
return {
|
||||
message: "All chapters must have a name and asset",
|
||||
attributes: {chapters: invalidChapters}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
CMS.Collections.TextbookSet = Backbone.Collection.extend({
|
||||
model: CMS.Models.Textbook,
|
||||
url: function() { return CMS.URL.TEXTBOOKS; },
|
||||
save: function(options) {
|
||||
return this.sync('update', this, options);
|
||||
}
|
||||
});
|
||||
CMS.Models.Chapter = Backbone.AssociatedModel.extend({
|
||||
defaults: function() {
|
||||
return {
|
||||
name: "",
|
||||
asset_path: "",
|
||||
order: this.collection ? this.collection.nextOrder() : 1
|
||||
};
|
||||
},
|
||||
isEmpty: function() {
|
||||
return !this.get('name') && !this.get('asset_path');
|
||||
},
|
||||
parse: function(response) {
|
||||
if("title" in response && !("name" in response)) {
|
||||
response.name = response.title;
|
||||
delete response.title;
|
||||
}
|
||||
if("url" in response && !("asset_path" in response)) {
|
||||
response.asset_path = response.url;
|
||||
delete response.url;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
toJSON: function() {
|
||||
return {
|
||||
title: this.get('name'),
|
||||
url: this.get('asset_path')
|
||||
};
|
||||
},
|
||||
// NOTE: validation functions should return non-internationalized error
|
||||
// messages. The messages will be passed through gettext in the template.
|
||||
validate: function(attrs, options) {
|
||||
if(!attrs.name && !attrs.asset_path) {
|
||||
return {
|
||||
message: "Chapter name and asset_path are both required",
|
||||
attributes: {name: true, asset_path: true}
|
||||
};
|
||||
} else if(!attrs.name) {
|
||||
return {
|
||||
message: "Chapter name is required",
|
||||
attributes: {name: true}
|
||||
};
|
||||
} else if (!attrs.asset_path) {
|
||||
return {
|
||||
message: "asset_path is required",
|
||||
attributes: {asset_path: true}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
CMS.Collections.ChapterSet = Backbone.Collection.extend({
|
||||
model: CMS.Models.Chapter,
|
||||
comparator: "order",
|
||||
nextOrder: function() {
|
||||
if(!this.length) return 1;
|
||||
return this.last().get('order') + 1;
|
||||
},
|
||||
isEmpty: function() {
|
||||
return this.length === 0 || this.every(function(m) { return m.isEmpty(); });
|
||||
}
|
||||
});
|
||||
CMS.Models.FileUpload = Backbone.Model.extend({
|
||||
defaults: {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"selectedFile": null,
|
||||
"uploading": false,
|
||||
"uploadedBytes": 0,
|
||||
"totalBytes": 0,
|
||||
"finished": false
|
||||
},
|
||||
// NOTE: validation functions should return non-internationalized error
|
||||
// messages. The messages will be passed through gettext in the template.
|
||||
validate: function(attrs, options) {
|
||||
if(attrs.selectedFile && attrs.selectedFile.type !== "application/pdf") {
|
||||
return {
|
||||
message: "Only PDF files can be uploaded. Please select a file ending in .pdf to upload.",
|
||||
attributes: {selectedFile: true}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -186,3 +186,9 @@ _.each(types, function(type) {
|
||||
klass[capitalCamel(intent)] = subklass;
|
||||
});
|
||||
});
|
||||
|
||||
// set more sensible defaults for Notification-Saving views
|
||||
var savingOptions = CMS.Views.Notification.Saving.prototype.options;
|
||||
savingOptions.minShown = 1250;
|
||||
savingOptions.closeIcon = false;
|
||||
|
||||
|
||||
362
cms/static/js/views/textbook.js
Normal file
362
cms/static/js/views/textbook.js
Normal file
@@ -0,0 +1,362 @@
|
||||
CMS.Views.ShowTextbook = Backbone.View.extend({
|
||||
initialize: function() {
|
||||
this.template = _.template($("#show-textbook-tpl").text());
|
||||
this.listenTo(this.model, "change", this.render);
|
||||
},
|
||||
tagName: "section",
|
||||
className: "textbook",
|
||||
events: {
|
||||
"click .edit": "editTextbook",
|
||||
"click .delete": "confirmDelete",
|
||||
"click .show-chapters": "showChapters",
|
||||
"click .hide-chapters": "hideChapters"
|
||||
},
|
||||
render: function() {
|
||||
var attrs = $.extend({}, this.model.attributes);
|
||||
attrs.bookindex = this.model.collection.indexOf(this.model);
|
||||
attrs.course = window.section.attributes;
|
||||
this.$el.html(this.template(attrs));
|
||||
return this;
|
||||
},
|
||||
editTextbook: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set("editing", true);
|
||||
},
|
||||
confirmDelete: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
var textbook = this.model, collection = this.model.collection;
|
||||
var msg = new CMS.Views.Prompt.Warning({
|
||||
title: _.str.sprintf(gettext("Delete “%s”?"),
|
||||
textbook.escape('name')),
|
||||
message: gettext("Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed."),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext("Delete"),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
var delmsg = new CMS.Views.Notification.Saving({
|
||||
title: gettext("Deleting…")
|
||||
}).show();
|
||||
textbook.destroy({
|
||||
complete: function() {
|
||||
delmsg.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
secondary: {
|
||||
text: gettext("Cancel"),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}).show();
|
||||
},
|
||||
showChapters: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set('showChapters', true);
|
||||
},
|
||||
hideChapters: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set('showChapters', false);
|
||||
}
|
||||
});
|
||||
CMS.Views.EditTextbook = Backbone.View.extend({
|
||||
initialize: function() {
|
||||
this.template = _.template($("#edit-textbook-tpl").text());
|
||||
this.listenTo(this.model, "invalid", this.render);
|
||||
var chapters = this.model.get('chapters');
|
||||
this.listenTo(chapters, "add", this.addOne);
|
||||
this.listenTo(chapters, "reset", this.addAll);
|
||||
this.listenTo(chapters, "all", this.render);
|
||||
},
|
||||
tagName: "section",
|
||||
className: "textbook",
|
||||
render: function() {
|
||||
this.$el.html(this.template({
|
||||
name: this.model.escape('name'),
|
||||
error: this.model.validationError
|
||||
}));
|
||||
this.addAll();
|
||||
return this;
|
||||
},
|
||||
events: {
|
||||
"change input[name=textbook-name]": "setName",
|
||||
"submit": "setAndClose",
|
||||
"click .action-cancel": "cancel",
|
||||
"click .action-add-chapter": "createChapter"
|
||||
},
|
||||
addOne: function(chapter) {
|
||||
var view = new CMS.Views.EditChapter({model: chapter});
|
||||
this.$("ol.chapters").append(view.render().el);
|
||||
return this;
|
||||
},
|
||||
addAll: function() {
|
||||
this.model.get('chapters').each(this.addOne, this);
|
||||
},
|
||||
createChapter: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.setValues();
|
||||
this.model.get('chapters').add([{}]);
|
||||
},
|
||||
setName: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set("name", this.$("#textbook-name-input").val(), {silent: true});
|
||||
},
|
||||
setValues: function() {
|
||||
this.setName();
|
||||
var that = this;
|
||||
_.each(this.$("li"), function(li, i) {
|
||||
var chapter = that.model.get('chapters').at(i);
|
||||
if(!chapter) { return; }
|
||||
chapter.set({
|
||||
"name": $(".chapter-name", li).val(),
|
||||
"asset_path": $(".chapter-asset-path", li).val()
|
||||
});
|
||||
});
|
||||
return this;
|
||||
},
|
||||
setAndClose: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.setValues();
|
||||
if(!this.model.isValid()) { return; }
|
||||
var saving = new CMS.Views.Notification.Saving({
|
||||
title: gettext("Saving…")
|
||||
}).show();
|
||||
var that = this;
|
||||
this.model.save({}, {
|
||||
success: function() {
|
||||
that.model.setOriginalAttributes();
|
||||
that.close();
|
||||
},
|
||||
complete: function() {
|
||||
saving.hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.reset();
|
||||
return this.close();
|
||||
},
|
||||
close: function() {
|
||||
var textbooks = this.model.collection;
|
||||
this.remove();
|
||||
if(this.model.isNew()) {
|
||||
// if the textbook has never been saved, remove it
|
||||
textbooks.remove(this.model);
|
||||
}
|
||||
// don't forget to tell the model that it's no longer being edited
|
||||
this.model.set("editing", false);
|
||||
return this;
|
||||
}
|
||||
});
|
||||
CMS.Views.ListTextbooks = Backbone.View.extend({
|
||||
initialize: function() {
|
||||
this.emptyTemplate = _.template($("#no-textbooks-tpl").text());
|
||||
this.listenTo(this.collection, 'all', this.render);
|
||||
this.listenTo(this.collection, 'destroy', this.handleDestroy);
|
||||
},
|
||||
tagName: "div",
|
||||
className: "textbooks-list",
|
||||
render: function() {
|
||||
var textbooks = this.collection;
|
||||
if(textbooks.length === 0) {
|
||||
this.$el.html(this.emptyTemplate());
|
||||
} else {
|
||||
this.$el.empty();
|
||||
var that = this;
|
||||
textbooks.each(function(textbook) {
|
||||
var view;
|
||||
if (textbook.get("editing")) {
|
||||
view = new CMS.Views.EditTextbook({model: textbook});
|
||||
} else {
|
||||
view = new CMS.Views.ShowTextbook({model: textbook});
|
||||
}
|
||||
that.$el.append(view.render().el);
|
||||
});
|
||||
}
|
||||
return this;
|
||||
},
|
||||
events: {
|
||||
"click .new-button": "addOne"
|
||||
},
|
||||
addOne: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.collection.add([{editing: true}]);
|
||||
},
|
||||
handleDestroy: function(model, collection, options) {
|
||||
collection.remove(model);
|
||||
}
|
||||
});
|
||||
CMS.Views.EditChapter = Backbone.View.extend({
|
||||
initialize: function() {
|
||||
this.template = _.template($("#edit-chapter-tpl").text());
|
||||
this.listenTo(this.model, "change", this.render);
|
||||
},
|
||||
tagName: "li",
|
||||
className: function() {
|
||||
return "field-group chapter chapter" + this.model.get('order');
|
||||
},
|
||||
render: function() {
|
||||
this.$el.html(this.template({
|
||||
name: this.model.escape('name'),
|
||||
asset_path: this.model.escape('asset_path'),
|
||||
order: this.model.get('order'),
|
||||
error: this.model.validationError
|
||||
}));
|
||||
return this;
|
||||
},
|
||||
events: {
|
||||
"change .chapter-name": "changeName",
|
||||
"change .chapter-asset-path": "changeAssetPath",
|
||||
"click .action-close": "removeChapter",
|
||||
"click .action-upload": "openUploadDialog",
|
||||
"submit": "uploadAsset"
|
||||
},
|
||||
changeName: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set({
|
||||
name: this.$(".chapter-name").val()
|
||||
}, {silent: true});
|
||||
return this;
|
||||
},
|
||||
changeAssetPath: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set({
|
||||
asset_path: this.$(".chapter-asset-path").val()
|
||||
}, {silent: true});
|
||||
return this;
|
||||
},
|
||||
removeChapter: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.collection.remove(this.model);
|
||||
return this.remove();
|
||||
},
|
||||
openUploadDialog: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set({
|
||||
name: this.$("input.chapter-name").val(),
|
||||
asset_path: this.$("input.chapter-asset-path").val()
|
||||
});
|
||||
var msg = new CMS.Models.FileUpload({
|
||||
title: _.str.sprintf(gettext("Upload a new asset to %s"),
|
||||
section.escape('name')),
|
||||
message: "Files must be in PDF format."
|
||||
});
|
||||
var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model});
|
||||
$(".wrapper-view").after(view.show().el);
|
||||
}
|
||||
});
|
||||
|
||||
CMS.Views.UploadDialog = Backbone.View.extend({
|
||||
options: {
|
||||
shown: true,
|
||||
successMessageTimeout: 2000 // 2 seconds
|
||||
},
|
||||
initialize: function() {
|
||||
this.template = _.template($("#upload-dialog-tpl").text());
|
||||
this.listenTo(this.model, "change", this.render);
|
||||
},
|
||||
render: function() {
|
||||
var isValid = this.model.isValid()
|
||||
var selectedFile = this.model.get('selectedFile');
|
||||
var oldInput = this.$("input[type=file]").get(0);
|
||||
this.$el.html(this.template({
|
||||
shown: this.options.shown,
|
||||
url: CMS.URL.UPLOAD_ASSET,
|
||||
title: this.model.escape('title'),
|
||||
message: this.model.escape('message'),
|
||||
selectedFile: selectedFile,
|
||||
uploading: this.model.get('uploading'),
|
||||
uploadedBytes: this.model.get('uploadedBytes'),
|
||||
totalBytes: this.model.get('totalBytes'),
|
||||
finished: this.model.get('finished'),
|
||||
error: this.model.validationError
|
||||
}));
|
||||
// Ideally, we'd like to tell the browser to pre-populate the
|
||||
// <input type="file"> with the selectedFile if we have one -- but
|
||||
// browser security prohibits that. So instead, we'll swap out the
|
||||
// new input (that has no file selected) with the old input (that
|
||||
// already has the selectedFile selected). However, we only want to do
|
||||
// this if the selected file is valid: if it isn't, we want to render
|
||||
// a blank input to prompt the user to upload a different (valid) file.
|
||||
if (selectedFile && isValid) {
|
||||
$(oldInput).removeClass("error");
|
||||
this.$('input[type=file]').replaceWith(oldInput);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
events: {
|
||||
"change input[type=file]": "selectFile",
|
||||
"click .action-cancel": "hideAndRemove",
|
||||
"click .action-upload": "upload"
|
||||
},
|
||||
selectFile: function(e) {
|
||||
this.model.set({
|
||||
selectedFile: e.target.files[0] || null
|
||||
});
|
||||
},
|
||||
show: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.options.shown = true;
|
||||
$body.addClass('dialog-is-shown');
|
||||
return this.render();
|
||||
},
|
||||
hide: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.options.shown = false;
|
||||
$body.removeClass('dialog-is-shown');
|
||||
return this.render();
|
||||
},
|
||||
hideAndRemove: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
return this.hide().remove();
|
||||
},
|
||||
upload: function(e) {
|
||||
this.model.set('uploading', true);
|
||||
this.$("form").ajaxSubmit({
|
||||
success: _.bind(this.success, this),
|
||||
error: _.bind(this.error, this),
|
||||
uploadProgress: _.bind(this.progress, this),
|
||||
data: {
|
||||
// don't show the generic error notification; we're in a modal,
|
||||
// and we're better off modifying it instead.
|
||||
notifyOnError: false
|
||||
}
|
||||
});
|
||||
},
|
||||
progress: function(event, position, total, percentComplete) {
|
||||
this.model.set({
|
||||
"uploadedBytes": position,
|
||||
"totalBytes": total
|
||||
});
|
||||
},
|
||||
success: function(response, statusText, xhr, form) {
|
||||
this.model.set({
|
||||
uploading: false,
|
||||
finished: true
|
||||
});
|
||||
var chapter = this.options.chapter;
|
||||
if(chapter) {
|
||||
var options = {};
|
||||
if(!chapter.get("name")) {
|
||||
options.name = response.displayname;
|
||||
}
|
||||
options.asset_path = response.url;
|
||||
chapter.set(options);
|
||||
}
|
||||
var that = this;
|
||||
this.removalTimeout = setTimeout(function() {
|
||||
that.hide().remove();
|
||||
}, this.options.successMessageTimeout);
|
||||
},
|
||||
error: function() {
|
||||
this.model.set({
|
||||
"uploading": false,
|
||||
"uploadedBytes": 0,
|
||||
"title": gettext("We're sorry, there was an error")
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
1
cms/static/sass/_mixins-inherited.scss
Symbolic link
1
cms/static/sass/_mixins-inherited.scss
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../common/static/sass/_mixins-inherited.scss
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
@import 'views/unit';
|
||||
@import 'views/users';
|
||||
@import 'views/checklists';
|
||||
@import 'views/textbooks';
|
||||
|
||||
// temp - inherited
|
||||
@import 'assets/content-types';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user