Merge branch 'master' into jkarni/docs-merge
Conflicts: docs/source/conf.py
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* -text
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ node_modules
|
||||
.prereqs_cache
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
.vagrant/
|
||||
logs
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-studio.django-partial]
|
||||
[edx-platform.django-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/django-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/django-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.djangojs]
|
||||
[edx-platform.djangojs]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.mako]
|
||||
[edx-platform.mako]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.messages]
|
||||
[edx-platform.messages]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/messages.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/messages.po
|
||||
source_lang = en
|
||||
|
||||
6
AUTHORS
6
AUTHORS
@@ -78,3 +78,9 @@ 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>
|
||||
Mukul Goyal <miki@edx.org>
|
||||
Robert Marks <rmarks@edx.org>
|
||||
Yarko Tymciurak <yarkot1@gmail.com>
|
||||
|
||||
|
||||
@@ -5,8 +5,77 @@ 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.
|
||||
|
||||
Studio: Send e-mails to new Studio users (on edge only) when their course creator
|
||||
status has changed. This will not be in use until the course creator table
|
||||
is enabled.
|
||||
|
||||
LMS: Added user preferences (arbitrary user/key/value tuples, for which
|
||||
which user/key is unique) and a REST API for reading users and
|
||||
preferences. Access to the REST API is restricted by use of the
|
||||
X-Edx-Api-Key HTTP header (which must match settings.EDX_API_KEY; if
|
||||
the setting is not present, the API is disabled).
|
||||
|
||||
LMS: Added endpoints for AJAX requests to enable/disable notifications
|
||||
(which are not yet implemented) and a one-click unsubscribe page.
|
||||
|
||||
Common: Add a manage.py that knows about edx-platform specific settings and projects
|
||||
|
||||
Common: Added *experimental* support for jsinput type.
|
||||
|
||||
Common: Added setting to specify Celery Broker vhost
|
||||
|
||||
Common: Utilize new XBlock bulk save API in LMS and CMS.
|
||||
|
||||
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.
|
||||
|
||||
Blades: Added functionality and tests for new capa input type: choicetextresponse.
|
||||
|
||||
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
|
||||
history of background tasks for a given problem and student.
|
||||
|
||||
Blades: Small UX fix on capa multiple-choice problems. Make labels only
|
||||
as wide as the text to reduce accidental choice selections.
|
||||
|
||||
Studio:
|
||||
- use xblock field defaults to initialize all new instances' fields and
|
||||
only use templates as override samples.
|
||||
- create new instances via in memory create_xmodule and related methods rather
|
||||
than cloning a db record.
|
||||
- have an explicit method for making a draft copy as distinct from making a new module.
|
||||
|
||||
Studio: Remove XML from the video component editor. All settings are
|
||||
moved to be edited as metadata.
|
||||
|
||||
XModule: Only write out assets files if the contents have changed.
|
||||
|
||||
Studio: Course settings are now saved explicitly.
|
||||
|
||||
XModule: Don't delete generated xmodule asset files when compiling (for
|
||||
instance, when XModule provides a coffeescript file, don't delete
|
||||
the associated javascript)
|
||||
@@ -45,6 +114,8 @@ setting now run entirely outside the Python sandbox.
|
||||
|
||||
Blades: Added tests for Video Alpha player.
|
||||
|
||||
Common: Have the capa module handle unicode better (especially errors)
|
||||
|
||||
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
|
||||
|
||||
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
|
||||
@@ -135,3 +206,5 @@ Common: Updated CodeJail.
|
||||
|
||||
Common: Allow setting of authentication session cookie name.
|
||||
|
||||
LMS: Option to email students when enroll/un-enroll them.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
226
README.md
226
README.md
@@ -2,8 +2,200 @@ 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/Downloads
|
||||
See http://docs.vagrantup.com/v2/providers/index.html for a list of supported
|
||||
Providers. You should use VirtualBox >= 4.2.12.
|
||||
(Windows: later/earlier VirtualBox versions than 4.2.12 have been reported to not work well with
|
||||
Vagrant. If this is still a problem, you can
|
||||
install 4.2.12 from http://download.virtualbox.org/virtualbox/4.2.12/).
|
||||
4. Install Vagrant: http://www.vagrantup.com/ (Vagrant 1.2.2 or later)
|
||||
5. Open a terminal
|
||||
6. Download the project: `git clone https://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. Create the development environment and start it: `vagrant up`
|
||||
|
||||
The initial `vagrant up` will download a Linux image, then boot and ask for your
|
||||
host machine's administrator password to setup file sharing between your computer and the VM.
|
||||
Once file sharing is established, `edx-platform/scripts/create-dev-env.sh` will
|
||||
install dependencies and configure the VM.
|
||||
This will take a while; go grab a coffee.
|
||||
|
||||
When complete, you should see a _"Success!"_ message.
|
||||
If not, refer to the
|
||||
[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting).
|
||||
|
||||
Your development environment is initialized only on the first bring-up.
|
||||
Subsequently `vagrant up` commands will boot your virtual machine normally.
|
||||
|
||||
Note: by default, the VM will get the IP `192.168.20.40`.
|
||||
You can change this in your `Vagrantfile` (the startup message will reflect your VM's actual IP).
|
||||
|
||||
Accessing the VM
|
||||
----------------
|
||||
|
||||
Once the installation is finished, to log into the virtual machine:
|
||||
|
||||
```
|
||||
$ vagrant ssh
|
||||
```
|
||||
|
||||
Note: This won't work from Windows. Instead, install PuTTY from
|
||||
http://www.chiark.greenend.org.uk/%7Esgtatham/putty/download.html. Then
|
||||
connect to 192.168.20.40, port 2222, using vagrant/vagrant as a user/password.
|
||||
|
||||
|
||||
Using edX
|
||||
---------
|
||||
|
||||
When you login to your VM, you are in
|
||||
`/opt/edx/edx-platform` by default, which is shared from your host workspace.
|
||||
Your host computer contains the edx-project development code and repository.
|
||||
Your VM runs edx-platform code mounted from your host, so
|
||||
you can develop by editing on your host.
|
||||
|
||||
After logging into your VM with `vagrant ssh`,
|
||||
start the _Studio_ and
|
||||
_Learning management system (LMS)_
|
||||
servers (run these from `/opt/edx/edx-platform`):
|
||||
|
||||
Learning management system (LMS):
|
||||
|
||||
```
|
||||
$ rake lms[cms.dev,0.0.0.0:8000]
|
||||
```
|
||||
|
||||
Studio (CMS):
|
||||
|
||||
```
|
||||
$ rake cms[dev,0.0.0.0:8001]
|
||||
```
|
||||
|
||||
The servers will come up to these URLs:
|
||||
|
||||
- LMS: http://192.168.20.40:8000/
|
||||
- CMS: http://192.168.20.40:8001/
|
||||
|
||||
Your VM's port 8000 is forwarded to host port 9000
|
||||
so you can also access the LMS with [http://localhost:9000/]().
|
||||
Similarly, VM port 8001 is forwarded to host port 9001.
|
||||
These are set in your `Vagrantfile`.
|
||||
|
||||
Note that when you register a new user through the web interface,
|
||||
by default the activiation email will be appear on your VM's terminal.
|
||||
Search for lines similar to:
|
||||
|
||||
```
|
||||
Subject: Your account for edX Studio
|
||||
From: registration@edx.org
|
||||
```
|
||||
|
||||
and find the activation URL.
|
||||
|
||||
See the [Frequently Asked Questions](https://github.com/edx/edx-platform/wiki/Frequently-Asked-Questions)
|
||||
for more usage tips.
|
||||
|
||||
Django admin & debug toolbar
|
||||
-----------------------------
|
||||
|
||||
You can enable admin logins and the debug_toolbar by editing
|
||||
`lms/envs/common.py`:
|
||||
|
||||
- enable ADMIN login page by setting:
|
||||
- ```
|
||||
'ENABLE_DJANGO_ADMIN_SITE': True
|
||||
```
|
||||
|
||||
|
||||
- enable debug toolbar by uncommenting:
|
||||
- ```
|
||||
# 'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
```
|
||||
|
||||
These are also defined in `lms/envs/dev.py`,
|
||||
and usually active on localhost.
|
||||
|
||||
To get at your VM's 127.0.0.1, explicitly forward one of VM's available localhost ports to your computer.
|
||||
Instead of `vagrant ssh`, login with:
|
||||
|
||||
```
|
||||
$ ssh -L 6080:127.0.0.1:8080 vagrant@192.168.20.40
|
||||
```
|
||||
|
||||
The password is _vagrant_.
|
||||
|
||||
From your VM, start the LMS as a localhost instance:
|
||||
|
||||
```
|
||||
$ rake lms[cms.dev,127.0.0.1:8080]
|
||||
```
|
||||
|
||||
You should see the debug toolbar now on [http:/localhost:6080/]().
|
||||
You should now also see a login on [http://localhost:6080/admin/]()
|
||||
You will need a privileged user for the admin login.
|
||||
You can create a CMS/LMS super-user with:
|
||||
```
|
||||
$ ./manage.py lms createsuperuser
|
||||
```
|
||||
|
||||
|
||||
Stopping & starting
|
||||
-------------------
|
||||
|
||||
|
||||
To stop the VM (from your `edx-platform/` directory):
|
||||
```
|
||||
$ vagrant halt
|
||||
```
|
||||
|
||||
To restart:
|
||||
|
||||
```
|
||||
$ vagrant up
|
||||
```
|
||||
|
||||
To suspend and resume tasks in progress on your VM:
|
||||
```
|
||||
$ vagrant suspend
|
||||
$ # and later...
|
||||
$ vagrant resume
|
||||
```
|
||||
|
||||
Your development environment is normally created once, on first `vagrant up`.
|
||||
You can continue to fetch changes in edx-platform
|
||||
as you work with your VM.
|
||||
To re-create your VM and create a fresh development environment:
|
||||
```
|
||||
$ vagrant destroy
|
||||
$ vagrant up # will make a new VM
|
||||
```
|
||||
|
||||
|
||||
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.
|
||||
@@ -101,24 +293,12 @@ or any other process management tool.
|
||||
|
||||
Configuring Your Project
|
||||
------------------------
|
||||
We use [`rake`](http://rake.rubyforge.org/) to execute common tasks in our
|
||||
project. The `rake` tasks are defined in the `rakefile`, or you can run `rake -T`
|
||||
to view a summary.
|
||||
|
||||
Before you run your project, you need to create a sqlite database, create
|
||||
tables in that database, run database migrations, and populate templates for
|
||||
CMS templates. Fortunately, `rake` will do all of this for you! Just run:
|
||||
tables in that database, and run database migrations. Fortunately, `django`
|
||||
will do all of this for you
|
||||
|
||||
$ rake django-admin[syncdb]
|
||||
$ rake django-admin[migrate]
|
||||
$ rake cms:update_templates
|
||||
|
||||
If you are running these commands using the [`zsh`](http://www.zsh.org/) shell,
|
||||
zsh will assume that you are doing
|
||||
[shell globbing](https://en.wikipedia.org/wiki/Glob_%28programming%29), search for
|
||||
a file in your directory named `django-adminsyncdb` or `django-adminmigrate`,
|
||||
and fail. To fix this, just surround the argument with quotation marks, so that
|
||||
you're running `rake "django-admin[syncdb]"`.
|
||||
$ ./manage.py lms syncdb --migrate
|
||||
$ ./manage.py cms syncdb --migrate
|
||||
|
||||
Run Your Project
|
||||
----------------
|
||||
@@ -126,6 +306,10 @@ edX has two components: Studio, the course authoring system; and the LMS
|
||||
(learning management system) used by students. These two systems communicate
|
||||
through the MongoDB database, which stores course information.
|
||||
|
||||
We use [`rake`](http://rake.rubyforge.org/) to execute common tasks in our
|
||||
project. The `rake` tasks are defined in the `rakefile`, or you can run `rake -T`
|
||||
to view a summary.
|
||||
|
||||
To run Studio, run:
|
||||
|
||||
$ rake cms
|
||||
@@ -152,6 +336,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
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
@@ -12,6 +13,9 @@ but this implementation should be data compatible with the LMS implementation
|
||||
INSTRUCTOR_ROLE_NAME = 'instructor'
|
||||
STAFF_ROLE_NAME = 'staff'
|
||||
|
||||
# This is the group of people who have permission to create new courses on edge or edx.
|
||||
COURSE_CREATOR_GROUP_NAME = "course_creator_group"
|
||||
|
||||
# we're just making a Django group for each location/role combo
|
||||
# to do this we're just creating a Group name which is a formatted string
|
||||
# of those two variables
|
||||
@@ -32,14 +36,14 @@ 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()
|
||||
|
||||
|
||||
'''
|
||||
Create all permission groups for a new course and subscribe the caller into those roles
|
||||
'''
|
||||
def create_all_course_groups(creator, location):
|
||||
"""
|
||||
Create all permission groups for a new course and subscribe the caller into those roles
|
||||
"""
|
||||
create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME)
|
||||
create_new_course_group(creator, location, STAFF_ROLE_NAME)
|
||||
|
||||
@@ -55,11 +59,12 @@ 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
|
||||
asserted permissions
|
||||
'''
|
||||
"""
|
||||
# remove all memberships
|
||||
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
|
||||
for user in instructors.user_set.all():
|
||||
@@ -71,11 +76,12 @@ 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
|
||||
asserted permissions to do this action
|
||||
'''
|
||||
"""
|
||||
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
|
||||
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
|
||||
for user in instructors.user_set.all():
|
||||
@@ -94,10 +100,34 @@ def add_user_to_course_group(caller, user, location, role):
|
||||
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied
|
||||
|
||||
if user.is_active and user.is_authenticated:
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
group = Group.objects.get(name=get_course_groupname_for_role(location, role))
|
||||
return _add_user_to_group(user, group)
|
||||
|
||||
group = Group.objects.get(name=groupname)
|
||||
|
||||
def add_user_to_creator_group(caller, user):
|
||||
"""
|
||||
Adds the user to the group of course creators.
|
||||
|
||||
The caller must have staff access to perform this operation.
|
||||
|
||||
Note that on the edX site, we currently limit course creators to edX staff, and this
|
||||
method is a no-op in that environment.
|
||||
"""
|
||||
if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
|
||||
raise PermissionDenied
|
||||
|
||||
(group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME)
|
||||
if created:
|
||||
group.save()
|
||||
return _add_user_to_group(user, group)
|
||||
|
||||
|
||||
def _add_user_to_group(user, group):
|
||||
"""
|
||||
This is to be called only by either a command line code path or through an app which has already
|
||||
asserted permissions to do this action
|
||||
"""
|
||||
if user.is_active and user.is_authenticated:
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
return True
|
||||
@@ -123,11 +153,29 @@ def remove_user_from_course_group(caller, user, location, role):
|
||||
|
||||
# see if the user is actually in that role, if not then we don't have to do anything
|
||||
if is_user_in_course_group_role(user, location, role):
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
_remove_user_from_group(user, get_course_groupname_for_role(location, role))
|
||||
|
||||
group = Group.objects.get(name=groupname)
|
||||
user.groups.remove(group)
|
||||
user.save()
|
||||
|
||||
def remove_user_from_creator_group(caller, user):
|
||||
"""
|
||||
Removes user from the course creator group.
|
||||
|
||||
The caller must have staff access to perform this operation.
|
||||
"""
|
||||
if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
|
||||
raise PermissionDenied
|
||||
|
||||
_remove_user_from_group(user, COURSE_CREATOR_GROUP_NAME)
|
||||
|
||||
|
||||
def _remove_user_from_group(user, group_name):
|
||||
"""
|
||||
This is to be called only by either a command line code path or through an app which has already
|
||||
asserted permissions to do this action
|
||||
"""
|
||||
group = Group.objects.get(name=group_name)
|
||||
user.groups.remove(group)
|
||||
user.save()
|
||||
|
||||
|
||||
def is_user_in_course_group_role(user, location, role):
|
||||
@@ -136,3 +184,52 @@ def is_user_in_course_group_role(user, location, role):
|
||||
return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_user_in_creator_group(user):
|
||||
"""
|
||||
Returns true if the user has permissions to create a course.
|
||||
|
||||
Will always return True if user.is_staff is True.
|
||||
|
||||
Note that on the edX site, we currently limit course creators to edX staff. On
|
||||
other sites, this method checks that the user is in the course creator group.
|
||||
"""
|
||||
if user.is_staff:
|
||||
return True
|
||||
|
||||
# On edx, we only allow edX staff to create courses. This may be relaxed in the future.
|
||||
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False):
|
||||
return False
|
||||
|
||||
# Feature flag for using the creator group setting. Will be removed once the feature is complete.
|
||||
if settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False):
|
||||
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
|
||||
|
||||
202
cms/djangoapps/auth/tests/test_authz.py
Normal file
202
cms/djangoapps/auth/tests/test_authz.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Tests authz.py
|
||||
"""
|
||||
import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth.models import User
|
||||
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, get_users_with_staff_role,\
|
||||
get_users_with_instructor_role
|
||||
|
||||
|
||||
class CreatorGroupTest(TestCase):
|
||||
"""
|
||||
Tests for the course creator group.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
""" Test case setup """
|
||||
self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo')
|
||||
self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo')
|
||||
self.admin.is_staff = True
|
||||
|
||||
def test_creator_group_not_enabled(self):
|
||||
"""
|
||||
Tests that is_user_in_creator_group always returns True if ENABLE_CREATOR_GROUP
|
||||
and DISABLE_COURSE_CREATION are both not turned on.
|
||||
"""
|
||||
self.assertTrue(is_user_in_creator_group(self.user))
|
||||
|
||||
def test_creator_group_enabled_but_empty(self):
|
||||
""" Tests creator group feature on, but group empty. """
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
self.assertFalse(is_user_in_creator_group(self.user))
|
||||
|
||||
# Make user staff. This will cause is_user_in_creator_group to return True.
|
||||
self.user.is_staff = True
|
||||
self.assertTrue(is_user_in_creator_group(self.user))
|
||||
|
||||
def test_creator_group_enabled_nonempty(self):
|
||||
""" Tests creator group feature on, user added. """
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
self.assertTrue(add_user_to_creator_group(self.admin, self.user))
|
||||
self.assertTrue(is_user_in_creator_group(self.user))
|
||||
|
||||
# check that a user who has not been added to the group still returns false
|
||||
user_not_added = User.objects.create_user('testuser2', 'test+courses2@edx.org', 'foo2')
|
||||
self.assertFalse(is_user_in_creator_group(user_not_added))
|
||||
|
||||
# remove first user from the group and verify that is_user_in_creator_group now returns false
|
||||
remove_user_from_creator_group(self.admin, self.user)
|
||||
self.assertFalse(is_user_in_creator_group(self.user))
|
||||
|
||||
def test_add_user_not_authenticated(self):
|
||||
"""
|
||||
Tests that adding to creator group fails if user is not authenticated
|
||||
"""
|
||||
self.user.is_authenticated = False
|
||||
self.assertFalse(add_user_to_creator_group(self.admin, self.user))
|
||||
|
||||
def test_add_user_not_active(self):
|
||||
"""
|
||||
Tests that adding to creator group fails if user is not active
|
||||
"""
|
||||
self.user.is_active = False
|
||||
self.assertFalse(add_user_to_creator_group(self.admin, self.user))
|
||||
|
||||
def test_course_creation_disabled(self):
|
||||
""" Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES',
|
||||
{'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}):
|
||||
# Add user to creator group.
|
||||
self.assertTrue(add_user_to_creator_group(self.admin, self.user))
|
||||
|
||||
# DISABLE_COURSE_CREATION overrides (user is not marked as staff).
|
||||
self.assertFalse(is_user_in_creator_group(self.user))
|
||||
|
||||
# Mark as staff. Now is_user_in_creator_group returns true.
|
||||
self.user.is_staff = True
|
||||
self.assertTrue(is_user_in_creator_group(self.user))
|
||||
|
||||
# Remove user from creator group. is_user_in_creator_group still returns true because is_staff=True
|
||||
remove_user_from_creator_group(self.admin, self.user)
|
||||
self.assertTrue(is_user_in_creator_group(self.user))
|
||||
|
||||
def test_add_user_to_group_requires_staff_access(self):
|
||||
with self.assertRaises(PermissionDenied):
|
||||
self.admin.is_staff = False
|
||||
add_user_to_creator_group(self.admin, self.user)
|
||||
|
||||
with self.assertRaises(PermissionDenied):
|
||||
add_user_to_creator_group(self.user, self.user)
|
||||
|
||||
def test_add_user_to_group_requires_active(self):
|
||||
with self.assertRaises(PermissionDenied):
|
||||
self.admin.is_active = False
|
||||
add_user_to_creator_group(self.admin, self.user)
|
||||
|
||||
def test_add_user_to_group_requires_authenticated(self):
|
||||
with self.assertRaises(PermissionDenied):
|
||||
self.admin.is_authenticated = False
|
||||
add_user_to_creator_group(self.admin, self.user)
|
||||
|
||||
def test_remove_user_from_group_requires_staff_access(self):
|
||||
with self.assertRaises(PermissionDenied):
|
||||
self.admin.is_staff = False
|
||||
remove_user_from_creator_group(self.admin, self.user)
|
||||
|
||||
def test_remove_user_from_group_requires_active(self):
|
||||
with self.assertRaises(PermissionDenied):
|
||||
self.admin.is_active = False
|
||||
remove_user_from_creator_group(self.admin, self.user)
|
||||
|
||||
def test_remove_user_from_group_requires_authenticated(self):
|
||||
with self.assertRaises(PermissionDenied):
|
||||
self.admin.is_authenticated = False
|
||||
remove_user_from_creator_group(self.admin, self.user)
|
||||
|
||||
|
||||
class CourseGroupTest(TestCase):
|
||||
"""
|
||||
Tests for instructor and staff groups for a particular course.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
""" Test case setup """
|
||||
self.creator = User.objects.create_user('testcreator', 'testcreator+courses@edx.org', 'foo')
|
||||
self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo')
|
||||
self.location = 'i4x', 'mitX', '101', 'course', 'test'
|
||||
|
||||
def test_add_user_to_course_group(self):
|
||||
"""
|
||||
Tests adding user to course group (happy path).
|
||||
"""
|
||||
# Create groups for a new course (and assign instructor role to the creator).
|
||||
self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME))
|
||||
create_all_course_groups(self.creator, self.location)
|
||||
self.assertTrue(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME))
|
||||
|
||||
# Add another user to the staff role.
|
||||
self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
|
||||
self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME))
|
||||
self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
|
||||
|
||||
def test_add_user_to_course_group_permission_denied(self):
|
||||
"""
|
||||
Verifies PermissionDenied if caller of add_user_to_course_group is not instructor role.
|
||||
"""
|
||||
create_all_course_groups(self.creator, self.location)
|
||||
with self.assertRaises(PermissionDenied):
|
||||
add_user_to_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME)
|
||||
|
||||
def test_remove_user_from_course_group(self):
|
||||
"""
|
||||
Tests removing user from course group (happy path).
|
||||
"""
|
||||
create_all_course_groups(self.creator, self.location)
|
||||
|
||||
self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME))
|
||||
self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
|
||||
|
||||
remove_user_from_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
|
||||
self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
|
||||
|
||||
remove_user_from_course_group(self.creator, self.creator, self.location, INSTRUCTOR_ROLE_NAME)
|
||||
self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME))
|
||||
|
||||
def test_remove_user_from_course_group_permission_denied(self):
|
||||
"""
|
||||
Verifies PermissionDenied if caller of remove_user_from_course_group is not instructor role.
|
||||
"""
|
||||
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())
|
||||
@@ -20,8 +20,8 @@ def get_course_updates(location):
|
||||
try:
|
||||
course_updates = modulestore('direct').get_item(location)
|
||||
except ItemNotFoundError:
|
||||
template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"])
|
||||
course_updates = modulestore('direct').clone_item(template, Location(location))
|
||||
modulestore('direct').create_and_save_xmodule(location)
|
||||
course_updates = modulestore('direct').get_item(location)
|
||||
|
||||
# current db rep: {"_id" : locationjson, "definition" : { "data" : "<ol>[<li><h2>date</h2>content</li>]</ol>"} "metadata" : ignored}
|
||||
location_base = course_updates.location.url()
|
||||
|
||||
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
|
||||
@@ -46,3 +46,9 @@ Feature: Advanced (manual) course policy
|
||||
Then it is displayed as a string
|
||||
And I reload the page
|
||||
Then it is displayed as a string
|
||||
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
|
||||
from common import type_in_codemirror
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches
|
||||
from common import type_in_codemirror, press_the_notification_button
|
||||
|
||||
KEY_CSS = '.key input.policy-key'
|
||||
VALUE_CSS = 'textarea.json'
|
||||
@@ -25,18 +25,6 @@ def i_am_on_advanced_course_settings(step):
|
||||
step.given('I select the Advanced Settings')
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(step, name):
|
||||
css = 'a.%s-button' % name.lower()
|
||||
|
||||
# Save was clicked if either the save notification bar is gone, or we have a error notification
|
||||
# overlaying it (expected in the case of typing Object into display_name).
|
||||
save_clicked = lambda: world.is_css_not_present('.is-shown.wrapper-notification-warning') or\
|
||||
world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
|
||||
assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.')
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key$')
|
||||
def edit_the_value_of_a_policy_key(step):
|
||||
type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X')
|
||||
@@ -102,25 +90,25 @@ def the_policy_key_value_is_changed(step):
|
||||
|
||||
############# HELPERS ###############
|
||||
def assert_policy_entries(expected_keys, expected_values):
|
||||
for counter in range(len(expected_keys)):
|
||||
index = get_index_of(expected_keys[counter])
|
||||
assert_false(index == -1, "Could not find key: " + expected_keys[counter])
|
||||
assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect")
|
||||
for key, value in zip(expected_keys, expected_values):
|
||||
index = get_index_of(key)
|
||||
assert_false(index == -1, "Could not find key: {key}".format(key=key))
|
||||
assert_equal(value, world.css_find(VALUE_CSS)[index].value, "value is incorrect")
|
||||
|
||||
|
||||
def get_index_of(expected_key):
|
||||
for counter in range(len(world.css_find(KEY_CSS))):
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
key = world.css_find(KEY_CSS)[counter].value
|
||||
for i, element in enumerate(world.css_find(KEY_CSS)):
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
key = world.css_value(KEY_CSS, index=i)
|
||||
if key == expected_key:
|
||||
return counter
|
||||
return i
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def get_display_name_value():
|
||||
index = get_index_of(DISPLAY_NAME_KEY)
|
||||
return world.css_find(VALUE_CSS)[index].value
|
||||
return world.css_value(VALUE_CSS, index=index)
|
||||
|
||||
|
||||
def change_display_name_value(step, new_value):
|
||||
|
||||
@@ -8,7 +8,7 @@ Feature: Course checklists
|
||||
Scenario: A course author can mark tasks as complete
|
||||
Given I have opened Checklists
|
||||
Then I can check and uncheck tasks in a checklist
|
||||
And They are correctly selected after I reload the page
|
||||
And They are correctly selected after reloading the page
|
||||
|
||||
Scenario: A task can link to a location within Studio
|
||||
Given I have opened Checklists
|
||||
|
||||
@@ -45,7 +45,7 @@ def i_can_check_and_uncheck_tasks(step):
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
|
||||
|
||||
@step('They are correctly selected after I reload the page$')
|
||||
@step('They are correctly selected after reloading the page$')
|
||||
def tasks_correctly_selected_after_reload(step):
|
||||
reload_the_page(step)
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
@@ -61,7 +61,7 @@ def i_select_a_link_to_the_course_outline(step):
|
||||
|
||||
@step('I am brought to the course outline page$')
|
||||
def i_am_brought_to_course_outline(step):
|
||||
assert_in('Course Outline', world.css_find('.outline .page-header')[0].text)
|
||||
assert_in('Course Outline', world.css_text('.outline .page-header'))
|
||||
assert_equal(1, len(world.browser.windows))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true
|
||||
from nose.tools import assert_equal
|
||||
|
||||
from auth.authz import get_user_by_email
|
||||
|
||||
@@ -13,8 +12,11 @@ import time
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
from terrain.browser import reset_data
|
||||
|
||||
########### STEP HELPERS ##############
|
||||
|
||||
|
||||
@step('I (?:visit|access|open) the Studio homepage$')
|
||||
def i_visit_the_studio_homepage(_step):
|
||||
# To make this go to port 8001, put
|
||||
@@ -51,9 +53,52 @@ def i_have_opened_a_new_course(_step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(_step, name):
|
||||
css = 'a.action-%s' % name.lower()
|
||||
|
||||
# The button was clicked if either the notification bar is gone,
|
||||
# or we see an error overlaying it (expected for invalid inputs).
|
||||
def button_clicked():
|
||||
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
|
||||
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
return confirmation_dismissed or error_showing
|
||||
|
||||
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name
|
||||
|
||||
|
||||
@step('I change the "(.*)" field to "(.*)"$')
|
||||
def i_change_field_to_value(_step, field, value):
|
||||
field_css = '#%s' % '-'.join([s.lower() for s in field.split()])
|
||||
ele = world.css_find(field_css).first
|
||||
ele.fill(value)
|
||||
ele._element.send_keys(Keys.ENTER)
|
||||
|
||||
|
||||
@step('I reset the database')
|
||||
def reset_the_db(_step):
|
||||
"""
|
||||
When running Lettuce tests using examples (i.e. "Confirmation is
|
||||
shown on save" in course-settings.feature), the normal hooks
|
||||
aren't called between examples. reset_data should run before each
|
||||
scenario to flush the test database. When this doesn't happen we
|
||||
get errors due to trying to insert a non-unique entry. So instead,
|
||||
we delete the database manually. This has the effect of removing
|
||||
any users and courses that have been created during the test run.
|
||||
"""
|
||||
reset_data(None)
|
||||
|
||||
|
||||
@step('I see a confirmation that my changes have been saved')
|
||||
def i_see_a_confirmation(step):
|
||||
confirmation_css = '#alert-confirmation'
|
||||
assert world.is_css_present(confirmation_css)
|
||||
|
||||
|
||||
####### HELPER FUNCTIONS ##############
|
||||
def open_new_course():
|
||||
world.clear_courses()
|
||||
create_studio_user()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
|
||||
@@ -73,10 +118,11 @@ def create_studio_user(
|
||||
registration.register(studio_user)
|
||||
registration.activate()
|
||||
|
||||
|
||||
def fill_in_course_info(
|
||||
name='Robot Super Course',
|
||||
org='MITx',
|
||||
num='101'):
|
||||
num='999'):
|
||||
world.css_fill('.new-course-name', name)
|
||||
world.css_fill('.new-course-org', org)
|
||||
world.css_fill('.new-course-number', num)
|
||||
@@ -85,10 +131,7 @@ def fill_in_course_info(
|
||||
def log_into_studio(
|
||||
uname='robot',
|
||||
email='robot+studio@edx.org',
|
||||
password='test',
|
||||
is_staff=False):
|
||||
|
||||
create_studio_user(uname=uname, email=email, is_staff=is_staff)
|
||||
password='test'):
|
||||
|
||||
world.browser.cookies.delete()
|
||||
world.visit('/')
|
||||
@@ -97,23 +140,30 @@ def log_into_studio(
|
||||
world.is_css_present(signin_css)
|
||||
world.css_click(signin_css)
|
||||
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(email)
|
||||
login_form.find_by_name('password').fill(password)
|
||||
login_form.find_by_name('submit').click()
|
||||
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(email)
|
||||
login_form.find_by_name('password').fill(password)
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
assert_true(world.is_css_present('.new-course-button'))
|
||||
world.scenario_dict['USER'] = get_user_by_email(email)
|
||||
|
||||
|
||||
def create_a_course():
|
||||
world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
world.scenario_dict['COURSE'] = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
# Add the user to the instructor group of the course
|
||||
# so they will have the permissions to see it in studio
|
||||
g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course')
|
||||
u = get_user_by_email('robot+studio@edx.org')
|
||||
u.groups.add(g)
|
||||
u.save()
|
||||
|
||||
course = world.GroupFactory.create(name='instructor_MITx/{}/{}'.format(world.scenario_dict['COURSE'].number,
|
||||
world.scenario_dict['COURSE'].display_name.replace(" ", "_")))
|
||||
if world.scenario_dict.get('USER') is None:
|
||||
user = world.scenario_dict['USER']
|
||||
else:
|
||||
user = get_user_by_email('robot+studio@edx.org')
|
||||
user.groups.add(course)
|
||||
user.save()
|
||||
world.browser.reload()
|
||||
|
||||
course_link_css = 'span.class-name'
|
||||
@@ -158,8 +208,9 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
|
||||
def i_created_a_video_component(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-video-icon',
|
||||
'i4x://edx/templates/video/default',
|
||||
'.xmodule_VideoModule'
|
||||
'video',
|
||||
'.xmodule_VideoModule',
|
||||
has_multiple_templates=False
|
||||
)
|
||||
|
||||
|
||||
@@ -171,6 +222,34 @@ def open_new_unit(step):
|
||||
world.css_click('a.new-unit-item')
|
||||
|
||||
|
||||
@step('when I view the video it (.*) show the captions')
|
||||
def shows_captions(step, show_captions):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
if show_captions == 'does not':
|
||||
assert world.css_has_class('.video', 'closed')
|
||||
else:
|
||||
assert world.is_css_not_present('.video.closed')
|
||||
|
||||
|
||||
@step('the save button is disabled$')
|
||||
def save_button_disabled(step):
|
||||
button_css = '.action-save'
|
||||
disabled = 'is-disabled'
|
||||
assert world.css_has_class(button_css, disabled)
|
||||
|
||||
|
||||
@step('I confirm the prompt')
|
||||
def confirm_the_prompt(step):
|
||||
prompt_css = 'a.button.action-primary'
|
||||
world.css_click(prompt_css)
|
||||
|
||||
|
||||
@step(u'I am shown a (.*)$')
|
||||
def i_am_shown_a_notification(step, notification_type):
|
||||
assert world.is_css_present('.wrapper-%s' % notification_type)
|
||||
|
||||
|
||||
def type_in_codemirror(index, text):
|
||||
world.css_click(".CodeMirror", index=index)
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
|
||||
87
cms/djangoapps/contentstore/features/component.feature
Normal file
87
cms/djangoapps/contentstore/features/component.feature
Normal file
@@ -0,0 +1,87 @@
|
||||
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
|
||||
|
||||
Scenario: I see a prompt on delete
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
And I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
And I delete a component
|
||||
Then I am shown a prompt
|
||||
|
||||
Scenario: I see a notification on save
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
And I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
And I edit and save a component
|
||||
Then I am shown a notification
|
||||
137
cms/djangoapps/contentstore/features/component.py
Normal file
137
cms/djangoapps/contentstore/features/component.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#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')
|
||||
|
||||
|
||||
@step(u'I delete a component')
|
||||
def delete_one_component(step):
|
||||
world.css_click('a.delete-button')
|
||||
|
||||
|
||||
@step(u'I edit and save a component')
|
||||
def edit_and_save_component(step):
|
||||
world.css_click('.edit-button')
|
||||
world.css_click('.save-button')
|
||||
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,16 @@ from terrain.steps import reload_the_page
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_component_instance(step, component_button_css, instance_id, expected_css):
|
||||
click_new_component_button(step, component_button_css)
|
||||
click_component_from_menu(instance_id, expected_css)
|
||||
def create_component_instance(step, component_button_css, category,
|
||||
expected_css, boilerplate=None,
|
||||
has_multiple_templates=True):
|
||||
|
||||
click_new_component_button(step, component_button_css)
|
||||
|
||||
if has_multiple_templates:
|
||||
click_component_from_menu(category, boilerplate, expected_css)
|
||||
|
||||
assert_equal(1, len(world.css_find(expected_css)))
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
@@ -19,7 +25,7 @@ def click_new_component_button(step, component_button_css):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_component_from_menu(instance_id, expected_css):
|
||||
def click_component_from_menu(category, boilerplate, expected_css):
|
||||
"""
|
||||
Creates a component from `instance_id`. For components with more
|
||||
than one template, clicks on `elem_css` to create the new
|
||||
@@ -27,12 +33,13 @@ def click_component_from_menu(instance_id, expected_css):
|
||||
as the user clicks the appropriate button, so we assert that the
|
||||
expected component is present.
|
||||
"""
|
||||
elem_css = "a[data-location='%s']" % instance_id
|
||||
if boilerplate:
|
||||
elem_css = "a[data-category='{}'][data-boilerplate='{}']".format(category, boilerplate)
|
||||
else:
|
||||
elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category)
|
||||
elements = world.css_find(elem_css)
|
||||
assert(len(elements) == 1)
|
||||
if elements[0]['id'] == instance_id: # If this is a component with multiple templates
|
||||
world.css_click(elem_css)
|
||||
assert_equal(1, len(world.css_find(expected_css)))
|
||||
assert_equal(len(elements), 1)
|
||||
world.css_click(elem_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Feature: Overview Toggle Section
|
||||
In order to quickly view the details of a course's section or to scan the inventory of sections
|
||||
Feature: Course Overview
|
||||
In order to quickly view the details of a course's section and set release dates and grading
|
||||
As a course author
|
||||
I want to toggle the visibility of each section's subsection details in the overview listing
|
||||
I want to use the course overview page
|
||||
|
||||
Scenario: The default layout for the overview page is to show sections in expanded view
|
||||
Given I have a course with multiple sections
|
||||
@@ -57,3 +57,9 @@ Feature: Overview Toggle Section
|
||||
And I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Notification is shown on grading status changes
|
||||
Given I have a course with 1 section
|
||||
When I navigate to the course overview page
|
||||
And I change an assignment's grading status
|
||||
Then I am shown a notification
|
||||
@@ -22,7 +22,7 @@ def have_a_course_with_1_section(step):
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
subsection1 = world.ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection One',)
|
||||
|
||||
|
||||
@@ -33,24 +33,25 @@ def have_a_course_with_two_sections(step):
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
subsection1 = world.ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection One',)
|
||||
section2 = world.ItemFactory.create(
|
||||
parent_location=course.location,
|
||||
display_name='Section Two',)
|
||||
subsection2 = world.ItemFactory.create(
|
||||
parent_location=section2.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection Alpha',)
|
||||
subsection3 = world.ItemFactory.create(
|
||||
parent_location=section2.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection Beta',)
|
||||
|
||||
|
||||
@step(u'I navigate to the course overview page$')
|
||||
def navigate_to_the_course_overview_page(step):
|
||||
log_into_studio(is_staff=True)
|
||||
create_studio_user(is_staff=True)
|
||||
log_into_studio()
|
||||
course_locator = '.class-name'
|
||||
world.css_click(course_locator)
|
||||
|
||||
@@ -91,7 +92,7 @@ def i_expand_a_section(step):
|
||||
def i_see_the_span_with_text(step, text):
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.is_css_present(span_locator))
|
||||
assert_equal(world.css_find(span_locator).value, text)
|
||||
assert_equal(world.css_value(span_locator), text)
|
||||
assert_true(world.css_visible(span_locator))
|
||||
|
||||
|
||||
@@ -107,13 +108,19 @@ def i_do_not_see_the_span_with_text(step, text):
|
||||
def all_sections_are_expanded(step):
|
||||
subsection_locator = 'div.subsection-list'
|
||||
subsections = world.css_find(subsection_locator)
|
||||
for s in subsections:
|
||||
assert_true(s.visible)
|
||||
for index in range(len(subsections)):
|
||||
assert_true(world.css_visible(subsection_locator, index=index))
|
||||
|
||||
|
||||
@step(u'all sections are collapsed$')
|
||||
def all_sections_are_collapsed(step):
|
||||
subsection_locator = 'div.subsection-list'
|
||||
subsections = world.css_find(subsection_locator)
|
||||
for s in subsections:
|
||||
assert_false(s.visible)
|
||||
for index in range(len(subsections)):
|
||||
assert_false(world.css_visible(subsection_locator, index=index))
|
||||
|
||||
|
||||
@step(u"I change an assignment's grading status")
|
||||
def change_grading_status(step):
|
||||
world.css_find('a.menu-toggle').click()
|
||||
world.css_find('.menu li').first.click()
|
||||
@@ -5,15 +5,18 @@ Feature: Course Settings
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I set course dates
|
||||
And I press the "Save" notification button
|
||||
Then I see the set dates on refresh
|
||||
|
||||
Scenario: User can clear previously set course dates (except start date)
|
||||
Given I have set course dates
|
||||
And I clear all the dates except start
|
||||
And I press the "Save" notification button
|
||||
Then I see cleared dates on refresh
|
||||
|
||||
Scenario: User cannot clear the course start date
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
And I clear the course start date
|
||||
Then I receive a warning about course start date
|
||||
And The previously set start date is shown on refresh
|
||||
@@ -21,5 +24,50 @@ Feature: Course Settings
|
||||
Scenario: User can correct the course start date warning
|
||||
Given I have tried to clear the course start
|
||||
And I have entered a new course start date
|
||||
And I press the "Save" notification button
|
||||
Then The warning about course start date goes away
|
||||
And My new course start date is shown on refresh
|
||||
|
||||
Scenario: Settings are only persisted when saved
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
When I change fields
|
||||
Then I do not see the new changes persisted on refresh
|
||||
|
||||
Scenario: Settings are reset on cancel
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
When I change fields
|
||||
And I press the "Cancel" notification button
|
||||
Then I do not see the changes
|
||||
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I change the "<field>" field to "<value>"
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
# Lettuce hooks don't get called between each example, so we need
|
||||
# to run the before.each_scenario hook manually to avoid database
|
||||
# errors.
|
||||
And I reset the database
|
||||
|
||||
Examples:
|
||||
| field | value |
|
||||
| Course Start Time | 11:00 |
|
||||
| Course Introduction Video | 4r7wHMg5Yjg |
|
||||
| Course Effort | 200:00 |
|
||||
|
||||
# Special case because we have to type in code mirror
|
||||
Scenario: Changes in Course Overview show a confirmation
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I change the course overview
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
|
||||
Scenario: User cannot save invalid settings
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I change the "Course Start Date" field to ""
|
||||
Then the save button is disabled
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
import time
|
||||
from common import type_in_codemirror
|
||||
|
||||
from nose.tools import assert_true, assert_false, assert_equal
|
||||
|
||||
@@ -47,22 +47,11 @@ def test_and_i_set_course_dates(step):
|
||||
set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
pause()
|
||||
|
||||
|
||||
@step('Then I see the set dates on refresh$')
|
||||
def test_then_i_see_the_set_dates_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
# Unset times get set to 12 AM once the corresponding date has been set.
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
i_see_the_set_dates()
|
||||
|
||||
|
||||
@step('And I clear all the dates except start$')
|
||||
@@ -71,8 +60,6 @@ def test_and_i_clear_all_the_dates_except_start(step):
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
pause()
|
||||
|
||||
|
||||
@step('Then I see cleared dates on refresh$')
|
||||
def test_then_i_see_cleared_dates_on_refresh(step):
|
||||
@@ -119,7 +106,6 @@ def test_i_have_tried_to_clear_the_course_start(step):
|
||||
@step('I have entered a new course start date$')
|
||||
def test_i_have_entered_a_new_course_start_date(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
|
||||
pause()
|
||||
|
||||
|
||||
@step('The warning about course start date goes away$')
|
||||
@@ -137,6 +123,30 @@ def test_my_new_course_start_date_is_shown_on_refresh(step):
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('I change fields$')
|
||||
def test_i_change_fields(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777')
|
||||
|
||||
|
||||
@step('I do not see the new changes persisted on refresh$')
|
||||
def test_changes_not_shown_on_refresh(step):
|
||||
step.then('Then I see the set dates on refresh')
|
||||
|
||||
|
||||
@step('I do not see the changes')
|
||||
def test_i_do_not_see_changes(_step):
|
||||
i_see_the_set_dates()
|
||||
|
||||
|
||||
@step('I change the course overview')
|
||||
def test_change_course_overview(_step):
|
||||
type_in_codemirror(0, "<h1>Overview</h1>")
|
||||
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def set_date_or_time(css, date_or_time):
|
||||
"""
|
||||
@@ -152,12 +162,20 @@ def verify_date_or_time(css, date_or_time):
|
||||
"""
|
||||
Verifies date or time field.
|
||||
"""
|
||||
assert_equal(date_or_time, world.css_find(css).first.value)
|
||||
assert_equal(date_or_time, world.css_value(css))
|
||||
|
||||
|
||||
def pause():
|
||||
def i_see_the_set_dates():
|
||||
"""
|
||||
Must sleep briefly to allow last time save to finish,
|
||||
else refresh of browser will fail.
|
||||
Ensure that each field has the value set in `test_and_i_set_course_dates`.
|
||||
"""
|
||||
time.sleep(float(1))
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
# Unset times get set to 12 AM once the corresponding date has been set.
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
34
cms/djangoapps/contentstore/features/course-team.feature
Normal file
34
cms/djangoapps/contentstore/features/course-team.feature
Normal file
@@ -0,0 +1,34 @@
|
||||
Feature: Course Team
|
||||
As a course author, I want to be able to add others to my team
|
||||
|
||||
Scenario: Users can add other users
|
||||
Given I have opened a new course in Studio
|
||||
And the user "alice" exists
|
||||
And I am viewing the course team settings
|
||||
When I add "alice" to the course team
|
||||
And "alice" logs in
|
||||
Then she does see the course on her page
|
||||
|
||||
Scenario: Added users cannot delete or add other users
|
||||
Given I have opened a new course in Studio
|
||||
And the user "bob" exists
|
||||
And I am viewing the course team settings
|
||||
When I add "bob" to the course team
|
||||
And "bob" logs in
|
||||
Then he cannot delete users
|
||||
And he cannot add users
|
||||
|
||||
Scenario: Users can delete other users
|
||||
Given I have opened a new course in Studio
|
||||
And the user "carol" exists
|
||||
And I am viewing the course team settings
|
||||
When I add "carol" to the course team
|
||||
And I delete "carol" from the course team
|
||||
And "carol" logs in
|
||||
Then she does not see the course on her page
|
||||
|
||||
Scenario: Users cannot add users that do not exist
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the course team settings
|
||||
When I add "dennis" to the course team
|
||||
Then I should see "Could not find user by email address" somewhere on the page
|
||||
67
cms/djangoapps/contentstore/features/course-team.py
Normal file
67
cms/djangoapps/contentstore/features/course-team.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import create_studio_user, log_into_studio
|
||||
|
||||
PASSWORD = 'test'
|
||||
EMAIL_EXTENSION = '@edx.org'
|
||||
|
||||
|
||||
@step(u'I am viewing the course team settings')
|
||||
def view_grading_settings(_step):
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-team a'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step(u'the user "([^"]*)" exists$')
|
||||
def create_other_user(_step, name):
|
||||
create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION))
|
||||
|
||||
|
||||
@step(u'I add "([^"]*)" to the course team')
|
||||
def add_other_user(_step, name):
|
||||
new_user_css = 'a.new-user-button'
|
||||
world.css_click(new_user_css)
|
||||
|
||||
email_css = 'input.email-input'
|
||||
f = world.css_find(email_css)
|
||||
f._element.send_keys(name, EMAIL_EXTENSION)
|
||||
|
||||
confirm_css = '#add_user'
|
||||
world.css_click(confirm_css)
|
||||
|
||||
|
||||
@step(u'I delete "([^"]*)" from the course team')
|
||||
def delete_other_user(_step, name):
|
||||
to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION)
|
||||
world.css_click(to_delete_css)
|
||||
|
||||
|
||||
@step(u'"([^"]*)" logs in$')
|
||||
def other_user_login(_step, name):
|
||||
log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION)
|
||||
|
||||
|
||||
@step(u's?he does( not)? see the course on (his|her) page')
|
||||
def see_course(_step, doesnt_see_course, gender):
|
||||
class_css = 'span.class-name'
|
||||
all_courses = world.css_find(class_css, wait_time=1)
|
||||
all_names = [item.html for item in all_courses]
|
||||
if doesnt_see_course:
|
||||
assert not world.scenario_dict['COURSE'].display_name in all_names
|
||||
else:
|
||||
assert world.scenario_dict['COURSE'].display_name in all_names
|
||||
|
||||
|
||||
@step(u's?he cannot delete users')
|
||||
def cannot_delete(_step):
|
||||
to_delete_css = 'a.remove-user'
|
||||
assert world.is_css_not_present(to_delete_css)
|
||||
|
||||
|
||||
@step(u's?he cannot add users')
|
||||
def cannot_add(_step):
|
||||
add_css = 'a.new-user'
|
||||
assert world.is_css_not_present(add_css)
|
||||
37
cms/djangoapps/contentstore/features/course-updates.feature
Normal file
37
cms/djangoapps/contentstore/features/course-updates.feature
Normal file
@@ -0,0 +1,37 @@
|
||||
Feature: Course updates
|
||||
As a course author, I want to be able to provide updates to my students
|
||||
|
||||
Scenario: Users can add updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I add a new update with the text "Hello"
|
||||
Then I should see the update "Hello"
|
||||
|
||||
Scenario: Users can edit updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I add a new update with the text "Hello"
|
||||
And I modify the text to "Goodbye"
|
||||
Then I should see the update "Goodbye"
|
||||
|
||||
Scenario: Users can delete updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
And I add a new update with the text "Hello"
|
||||
When I will confirm all alerts
|
||||
And I delete the update
|
||||
Then I should not see the update "Hello"
|
||||
|
||||
|
||||
Scenario: Users can edit update dates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
And I add a new update with the text "Hello"
|
||||
When I edit the date to "June 1, 2013"
|
||||
Then I should see the date "June 1, 2013"
|
||||
|
||||
Scenario: Users can change handouts
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I modify the handout to "<ol>Test</ol>"
|
||||
Then I see the handout "Test"
|
||||
82
cms/djangoapps/contentstore/features/course-updates.py
Normal file
82
cms/djangoapps/contentstore/features/course-updates.py
Normal file
@@ -0,0 +1,82 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from common import type_in_codemirror
|
||||
|
||||
|
||||
@step(u'I go to the course updates page')
|
||||
def go_to_updates(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
updates_css = 'li.nav-course-courseware-updates'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(updates_css)
|
||||
|
||||
|
||||
@step(u'I add a new update with the text "([^"]*)"$')
|
||||
def add_update(_step, text):
|
||||
update_css = 'a.new-update-button'
|
||||
world.css_click(update_css)
|
||||
change_text(text)
|
||||
|
||||
|
||||
@step(u'I should( not)? see the update "([^"]*)"$')
|
||||
def check_update(_step, doesnt_see_update, text):
|
||||
update_css = 'div.update-contents'
|
||||
update = world.css_find(update_css, wait_time=1)
|
||||
if doesnt_see_update:
|
||||
assert len(update) == 0 or not text in update.html
|
||||
else:
|
||||
assert text in update.html
|
||||
|
||||
|
||||
@step(u'I modify the text to "([^"]*)"$')
|
||||
def modify_update(_step, text):
|
||||
button_css = 'div.post-preview a.edit-button'
|
||||
world.css_click(button_css)
|
||||
change_text(text)
|
||||
|
||||
|
||||
@step(u'I delete the update$')
|
||||
def click_button(_step):
|
||||
button_css = 'div.post-preview a.delete-button'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I edit the date to "([^"]*)"$')
|
||||
def change_date(_step, new_date):
|
||||
button_css = 'div.post-preview a.edit-button'
|
||||
world.css_click(button_css)
|
||||
date_css = 'input.date'
|
||||
date = world.css_find(date_css)
|
||||
for i in range(len(date.value)):
|
||||
date._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
date._element.send_keys(new_date)
|
||||
save_css = 'a.save-button'
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
@step(u'I should see the date "([^"]*)"$')
|
||||
def check_date(_step, date):
|
||||
date_css = 'span.date-display'
|
||||
assert date == world.css_html(date_css)
|
||||
|
||||
|
||||
@step(u'I modify the handout to "([^"]*)"$')
|
||||
def edit_handouts(_step, text):
|
||||
edit_css = 'div.course-handouts > a.edit-button'
|
||||
world.css_click(edit_css)
|
||||
change_text(text)
|
||||
|
||||
|
||||
@step(u'I see the handout "([^"]*)"$')
|
||||
def check_handout(_step, handout):
|
||||
handout_css = 'div.handouts-content'
|
||||
assert handout in world.css_html(handout_css)
|
||||
|
||||
|
||||
def change_text(text):
|
||||
type_in_codemirror(0, text)
|
||||
save_css = 'a.save-button'
|
||||
world.css_click(save_css)
|
||||
@@ -10,6 +10,7 @@ from common import *
|
||||
@step('There are no courses$')
|
||||
def no_courses(step):
|
||||
world.clear_courses()
|
||||
create_studio_user()
|
||||
|
||||
|
||||
@step('I click the New Course button$')
|
||||
@@ -44,7 +45,7 @@ def courseware_page_has_loaded_in_studio(step):
|
||||
@step('I see the course listed in My Courses$')
|
||||
def i_see_the_course_in_my_courses(step):
|
||||
course_css = 'span.class-name'
|
||||
assert world.css_has_text(course_css, 'Robot Super Course')
|
||||
assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name)
|
||||
|
||||
|
||||
@step('I am on the "([^"]*)" tab$')
|
||||
|
||||
@@ -8,8 +8,9 @@ from lettuce import world, step
|
||||
def i_created_discussion_tag(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-discussion-icon',
|
||||
'i4x://edx/templates/discussion/Discussion_Tag',
|
||||
'.xmodule_DiscussionModule'
|
||||
'discussion',
|
||||
'.xmodule_DiscussionModule',
|
||||
has_multiple_templates=False
|
||||
)
|
||||
|
||||
|
||||
@@ -17,14 +18,14 @@ def i_created_discussion_tag(step):
|
||||
def i_see_only_the_settings_and_values(step):
|
||||
world.verify_all_setting_entries(
|
||||
[
|
||||
['Category', "Week 1", True],
|
||||
['Display Name', "Discussion Tag", True],
|
||||
['Subcategory', "Topic-Level Student-Visible Label", True]
|
||||
['Category', "Week 1", False],
|
||||
['Display Name', "Discussion", False],
|
||||
['Subcategory', "Topic-Level Student-Visible Label", False]
|
||||
])
|
||||
|
||||
|
||||
@step('creating a discussion takes a single click')
|
||||
def discussion_takes_a_single_click(step):
|
||||
assert(not world.is_css_present('.xmodule_DiscussionModule'))
|
||||
world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']")
|
||||
world.css_click("a[data-category='discussion']")
|
||||
assert(world.is_css_present('.xmodule_DiscussionModule'))
|
||||
|
||||
@@ -32,6 +32,7 @@ Feature: Course Grading
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Save" notification button
|
||||
And I go back to the main course page
|
||||
Then I do see the assignment name "New Type"
|
||||
And I do not see the assignment name "Homework"
|
||||
@@ -41,6 +42,7 @@ Feature: Course Grading
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I delete the assignment type "Homework"
|
||||
And I press the "Save" notification button
|
||||
And I go back to the main course page
|
||||
Then I do not see the assignment name "Homework"
|
||||
|
||||
@@ -49,5 +51,36 @@ Feature: Course Grading
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I add a new assignment type "New Type"
|
||||
And I press the "Save" notification button
|
||||
And I go back to the main course page
|
||||
Then I do see the assignment name "New Type"
|
||||
|
||||
Scenario: Settings are only persisted when saved
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
Then I do not see the changes persisted on refresh
|
||||
|
||||
Scenario: Settings are reset on cancel
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Cancel" notification button
|
||||
Then I see the assignment type "Homework"
|
||||
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
|
||||
Scenario: User cannot save invalid settings
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to ""
|
||||
Then the save button is disabled
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from terrain.steps import reload_the_page
|
||||
|
||||
|
||||
@step(u'I am viewing the grading settings')
|
||||
@@ -47,7 +48,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 "([^"]*)"$')
|
||||
@@ -63,7 +64,9 @@ def change_assignment_name(step, old_name, new_name):
|
||||
|
||||
@step(u'I go back to the main course page')
|
||||
def main_course_page(step):
|
||||
main_page_link_css = 'a[href="/MITx/999/course/Robot_Super_Course"]'
|
||||
main_page_link_css = 'a[href="/%s/%s/course/%s"]' % (world.scenario_dict['COURSE'].org,
|
||||
world.scenario_dict['COURSE'].number,
|
||||
world.scenario_dict['COURSE'].display_name.replace(' ', '_'),)
|
||||
world.css_click(main_page_link_css)
|
||||
|
||||
|
||||
@@ -89,8 +92,8 @@ def add_assignment_type(step, new_name):
|
||||
add_button_css = '.add-grading-data'
|
||||
world.css_click(add_button_css)
|
||||
name_id = '#course-grading-assignment-name'
|
||||
f = world.css_find(name_id)[4]
|
||||
f._element.send_keys(new_name)
|
||||
new_assignment = world.css_find(name_id)[-1]
|
||||
new_assignment._element.send_keys(new_name)
|
||||
|
||||
|
||||
@step(u'I have populated the course')
|
||||
@@ -99,10 +102,25 @@ def populate_course(step):
|
||||
step.given('I have added a new subsection')
|
||||
|
||||
|
||||
@step(u'I do not see the changes persisted on refresh$')
|
||||
def changes_not_persisted(step):
|
||||
reload_the_page(step)
|
||||
name_id = '#course-grading-assignment-name'
|
||||
assert(world.css_value(name_id) == 'Homework')
|
||||
|
||||
|
||||
@step(u'I see the assignment type "(.*)"$')
|
||||
def i_see_the_assignment_type(_step, name):
|
||||
assignment_css = '#course-grading-assignment-name'
|
||||
assignments = world.css_find(assignment_css)
|
||||
types = [ele['value'] for ele in assignments]
|
||||
assert name in types
|
||||
|
||||
|
||||
def get_type_index(name):
|
||||
name_id = '#course-grading-assignment-name'
|
||||
f = world.css_find(name_id)
|
||||
for i in range(len(f)):
|
||||
if f[i].value == name:
|
||||
return i
|
||||
all_types = world.css_find(name_id)
|
||||
for index in range(len(all_types)):
|
||||
if world.css_value(name_id, index=index) == name:
|
||||
return index
|
||||
return -1
|
||||
|
||||
@@ -7,11 +7,11 @@ from lettuce import world, step
|
||||
@step('I have created a Blank HTML Page$')
|
||||
def i_created_blank_html_page(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page',
|
||||
step, '.large-html-icon', 'html',
|
||||
'.xmodule_HtmlModule'
|
||||
)
|
||||
|
||||
|
||||
@step('I see only the HTML display name setting$')
|
||||
def i_see_only_the_html_display_name(step):
|
||||
world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]])
|
||||
world.verify_all_setting_entries([['Display Name', "Text", False]])
|
||||
|
||||
@@ -18,8 +18,9 @@ def i_created_blank_common_problem(step):
|
||||
world.create_component_instance(
|
||||
step,
|
||||
'.large-problem-icon',
|
||||
'i4x://edx/templates/problem/Blank_Common_Problem',
|
||||
'.xmodule_CapaModule'
|
||||
'problem',
|
||||
'.xmodule_CapaModule',
|
||||
'blank_common.yaml'
|
||||
)
|
||||
|
||||
|
||||
@@ -35,8 +36,8 @@ def i_see_five_settings_with_values(step):
|
||||
[DISPLAY_NAME, "Blank Common Problem", True],
|
||||
[MAXIMUM_ATTEMPTS, "", False],
|
||||
[PROBLEM_WEIGHT, "", False],
|
||||
[RANDOMIZATION, "Never", True],
|
||||
[SHOW_ANSWER, "Finished", True]
|
||||
[RANDOMIZATION, "Never", False],
|
||||
[SHOW_ANSWER, "Finished", False]
|
||||
])
|
||||
|
||||
|
||||
@@ -94,7 +95,7 @@ def my_change_to_randomization_is_persisted(step):
|
||||
def i_can_revert_to_default_for_randomization(step):
|
||||
world.revert_setting_entry(RANDOMIZATION)
|
||||
world.save_component_and_reopen(step)
|
||||
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False)
|
||||
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Never", False)
|
||||
|
||||
|
||||
@step('I can set the weight to "(.*)"?')
|
||||
@@ -156,7 +157,7 @@ def create_latex_problem(step):
|
||||
world.click_new_component_button(step, '.large-problem-icon')
|
||||
# Go to advanced tab.
|
||||
world.css_click('#ui-id-2')
|
||||
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
|
||||
world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule')
|
||||
|
||||
|
||||
@step('I edit and compile the High Level Source')
|
||||
@@ -169,7 +170,8 @@ def edit_latex_source(step):
|
||||
@step('my change to the High Level Source is persisted')
|
||||
def high_level_source_persisted(step):
|
||||
def verify_text(driver):
|
||||
return world.css_find('.problem').text == 'hi'
|
||||
css_sel = '.problem div>span'
|
||||
return world.css_text(css_sel) == 'hi'
|
||||
|
||||
world.wait_for(verify_text)
|
||||
|
||||
@@ -177,7 +179,7 @@ def high_level_source_persisted(step):
|
||||
@step('I view the High Level Source I see my changes')
|
||||
def high_level_source_in_editor(step):
|
||||
open_high_level_source()
|
||||
assert_equal('hi', world.css_find('.source-edit-box').value)
|
||||
assert_equal('hi', world.css_value('.source-edit-box'))
|
||||
|
||||
|
||||
def verify_high_level_source_links(step, visible):
|
||||
@@ -203,7 +205,7 @@ def verify_modified_display_name_with_special_chars():
|
||||
|
||||
|
||||
def verify_unset_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False)
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False)
|
||||
|
||||
|
||||
def set_weight(weight):
|
||||
|
||||
@@ -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
|
||||
@@ -25,10 +26,12 @@ Feature: Create Section
|
||||
When I click the Edit link for the release date
|
||||
And I save a new section release date
|
||||
Then the section release date is updated
|
||||
And I see a "saving" notification
|
||||
|
||||
Scenario: Delete section
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
When I will confirm all alerts
|
||||
And I press the "section" delete icon
|
||||
And I confirm the prompt
|
||||
Then the section does not exist
|
||||
|
||||
@@ -42,6 +42,12 @@ def i_save_a_new_section_release_date(_step):
|
||||
world.browser.click_link_by_text('Save')
|
||||
|
||||
|
||||
@step('I see a "saving" notification')
|
||||
def i_see_a_saving_notification(step):
|
||||
saving_css = '.wrapper-notification-mini'
|
||||
assert world.is_css_present(saving_css)
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
|
||||
@@ -64,7 +70,7 @@ def i_click_to_edit_section_name(_step):
|
||||
def i_see_complete_section_name_with_quote_in_editor(_step):
|
||||
css = '.section-name-edit input[type=text]'
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
|
||||
assert_equal(world.css_value(css), 'Section with "Quote"')
|
||||
|
||||
|
||||
@step('the section does not exist$')
|
||||
@@ -79,7 +85,7 @@ def i_see_a_release_date_for_my_section(_step):
|
||||
|
||||
css = 'span.published-status'
|
||||
assert world.is_css_present(css)
|
||||
status_text = world.browser.find_by_css(css).text
|
||||
status_text = world.css_text(css)
|
||||
|
||||
# e.g. 11/06/2012 at 16:25
|
||||
msg = 'Will Release:'
|
||||
|
||||
@@ -9,4 +9,21 @@ Feature: Sign in
|
||||
And I fill in the registration form
|
||||
And I press the Create My Account button on the registration form
|
||||
Then I should see be on the studio home page
|
||||
And I should see the message "please click on the activation link in your email."
|
||||
And I should see the message "complete your sign up we need you to verify your email address"
|
||||
|
||||
Scenario: Login with a valid redirect
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the url "/MITx/999/course/Robot_Super_Course"
|
||||
And I should see that the path is "/signin?next=/MITx/999/course/Robot_Super_Course"
|
||||
When I fill in and submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/MITx/999/course/Robot_Super_Course"
|
||||
|
||||
Scenario: Login with an invalid redirect
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the url "/signin?next=http://www.google.com/"
|
||||
When I fill in and submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/"
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
|
||||
|
||||
@step('I fill in the registration form$')
|
||||
def i_fill_in_the_registration_form(step):
|
||||
register_form = world.browser.find_by_css('form#register_form')
|
||||
register_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
register_form.find_by_name('password').fill('test')
|
||||
register_form.find_by_name('username').fill('robot-studio')
|
||||
register_form.find_by_name('name').fill('Robot Studio')
|
||||
register_form.find_by_name('terms_of_service').check()
|
||||
def fill_in_reg_form():
|
||||
register_form = world.css_find('form#register_form')
|
||||
register_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
register_form.find_by_name('password').fill('test')
|
||||
register_form.find_by_name('username').fill('robot-studio')
|
||||
register_form.find_by_name('name').fill('Robot Studio')
|
||||
register_form.find_by_name('terms_of_service').check()
|
||||
world.retry_on_exception(fill_in_reg_form)
|
||||
|
||||
|
||||
@step('I press the Create My Account button on the registration form$')
|
||||
@@ -23,9 +24,19 @@ def i_press_the_button_on_the_registration_form(step):
|
||||
|
||||
@step('I should see be on the studio home page$')
|
||||
def i_should_see_be_on_the_studio_home_page(step):
|
||||
assert world.browser.find_by_css('div.inner-wrapper')
|
||||
step.given('I should see the message "My Courses"')
|
||||
|
||||
|
||||
@step(u'I should see the message "([^"]*)"$')
|
||||
def i_should_see_the_message(step, msg):
|
||||
assert world.browser.is_text_present(msg, 5)
|
||||
|
||||
|
||||
@step(u'I fill in and submit the signin form$')
|
||||
def i_fill_in_the_signin_form(step):
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
login_form.find_by_name('password').fill('test')
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
|
||||
24
cms/djangoapps/contentstore/features/static-pages.feature
Normal file
24
cms/djangoapps/contentstore/features/static-pages.feature
Normal file
@@ -0,0 +1,24 @@
|
||||
Feature: Static Pages
|
||||
As a course author, I want to be able to add static pages
|
||||
|
||||
Scenario: Users can add static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
When I add a new page
|
||||
Then I should see a "Empty" static page
|
||||
|
||||
Scenario: Users can delete static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
When I will confirm all alerts
|
||||
And I "delete" the "Empty" page
|
||||
Then I should not see a "Empty" static page
|
||||
|
||||
Scenario: Users can edit static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
When I "edit" the "Empty" page
|
||||
And I change the name to "New"
|
||||
Then I should see a "New" static page
|
||||
59
cms/djangoapps/contentstore/features/static-pages.py
Normal file
59
cms/djangoapps/contentstore/features/static-pages.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
|
||||
@step(u'I go to the static pages page')
|
||||
def go_to_static(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
static_css = 'li.nav-course-courseware-pages'
|
||||
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_click(button_css)
|
||||
|
||||
|
||||
@step(u'I should( not)? see a "([^"]*)" static page$')
|
||||
def see_page(_step, doesnt, page):
|
||||
index = get_index(page)
|
||||
if doesnt:
|
||||
assert index == -1
|
||||
else:
|
||||
assert index != -1
|
||||
|
||||
|
||||
@step(u'I "([^"]*)" the "([^"]*)" page$')
|
||||
def click_edit_delete(_step, edit_delete, page):
|
||||
button_css = 'a.%s-button' % edit_delete
|
||||
index = get_index(page)
|
||||
assert index != -1
|
||||
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_click(settings_css)
|
||||
input_css = 'input.setting-input'
|
||||
name_input = world.css_find(input_css)
|
||||
old_name = name_input.value
|
||||
for count in range(len(old_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_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 world.css_html(page_name_css, index=i) == '\n {name}\n'.format(name=name):
|
||||
return i
|
||||
return -1
|
||||
@@ -38,4 +38,5 @@ Feature: Create Subsection
|
||||
And I see my subsection on the Courseware page
|
||||
When I will confirm all alerts
|
||||
And I press the "subsection" delete icon
|
||||
And I confirm the prompt
|
||||
Then the subsection does not exist
|
||||
|
||||
@@ -50,7 +50,7 @@ def i_click_to_edit_subsection_name(step):
|
||||
def i_see_complete_subsection_name_with_quote_in_editor(step):
|
||||
css = '.subsection-display-name-input'
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.css_find(css).value, 'Subsection With "Quote"')
|
||||
assert_equal(world.css_value(css), 'Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I have set a release date and due date in different years$')
|
||||
@@ -69,7 +69,7 @@ def i_mark_it_as_homework(step):
|
||||
|
||||
@step('I see it marked as Homework$')
|
||||
def i_see_it_marked__as_homework(step):
|
||||
assert_equal(world.css_find(".status-label").value, 'Homework')
|
||||
assert_equal(world.css_value(".status-label"), 'Homework')
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
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)
|
||||
39
cms/djangoapps/contentstore/features/upload.feature
Normal file
39
cms/djangoapps/contentstore/features/upload.feature
Normal file
@@ -0,0 +1,39 @@
|
||||
Feature: Upload Files
|
||||
As a course author, I want to be able to upload files for my students
|
||||
|
||||
Scenario: Users can upload files
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the files and uploads page
|
||||
When I upload the file "test"
|
||||
Then I should see the file "test" was uploaded
|
||||
And The url for the file "test" is valid
|
||||
|
||||
Scenario: Users can update files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
When I upload the file "test"
|
||||
And I upload the file "test"
|
||||
Then I should see only one "test"
|
||||
|
||||
Scenario: Users can delete uploaded files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
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
|
||||
And I go to the files and uploads page
|
||||
When I upload the file "test"
|
||||
Then I can download the correct "test" file
|
||||
|
||||
Scenario: Users can download updated files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
When I upload the file "test"
|
||||
And I modify "test"
|
||||
And I reload the page
|
||||
And I upload the file "test"
|
||||
Then I can download the correct "test" file
|
||||
114
cms/djangoapps/contentstore/features/upload.py
Normal file
114
cms/djangoapps/contentstore/features/upload.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from django.conf import settings
|
||||
import requests
|
||||
import string
|
||||
import random
|
||||
import os
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
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_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_click(upload_css)
|
||||
|
||||
file_css = 'input.file-input'
|
||||
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))
|
||||
|
||||
close_css = 'a.close-button'
|
||||
world.css_click(close_css)
|
||||
|
||||
|
||||
@step(u'I should( not)? see the file "([^"]*)" was uploaded$')
|
||||
def check_upload(_step, do_not_see_file, file_name):
|
||||
index = get_index(file_name)
|
||||
if do_not_see_file:
|
||||
assert index == -1
|
||||
else:
|
||||
assert index != -1
|
||||
|
||||
|
||||
@step(u'The url for the file "([^"]*)" is valid$')
|
||||
def check_url(_step, file_name):
|
||||
r = get_file(file_name)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@step(u'I delete the file "([^"]*)"$')
|
||||
def delete_file(_step, file_name):
|
||||
index = get_index(file_name)
|
||||
assert index != -1
|
||||
delete_css = "a.remove-asset-button"
|
||||
world.css_click(delete_css, index=index)
|
||||
|
||||
prompt_confirm_css = 'li.nav-item > a.action-primary'
|
||||
world.css_click(prompt_confirm_css)
|
||||
|
||||
|
||||
@step(u'I should see only one "([^"]*)"$')
|
||||
def no_duplicate(_step, file_name):
|
||||
names_css = 'td.name-col > a.filename'
|
||||
all_names = world.css_find(names_css)
|
||||
only_one = False
|
||||
for i in range(len(all_names)):
|
||||
if file_name == world.css_html(names_css, index=i):
|
||||
only_one = not only_one
|
||||
assert only_one
|
||||
|
||||
|
||||
@step(u'I can download the correct "([^"]*)" file$')
|
||||
def check_download(_step, file_name):
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
with open(os.path.abspath(path), 'r') as cur_file:
|
||||
cur_text = cur_file.read()
|
||||
r = get_file(file_name)
|
||||
downloaded_text = r.text
|
||||
assert cur_text == downloaded_text
|
||||
|
||||
|
||||
@step(u'I modify "([^"]*)"$')
|
||||
def modify_upload(_step, file_name):
|
||||
new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10))
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
with open(os.path.abspath(path), 'w') as cur_file:
|
||||
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 == world.css_html(names_css, index=i):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
def get_file(file_name):
|
||||
index = get_index(file_name)
|
||||
assert index != -1
|
||||
|
||||
url_css = 'input.embeddable-xml-input'
|
||||
url = world.css_find(url_css)[index].value
|
||||
return requests.get(HTTP_PREFIX + url)
|
||||
@@ -4,10 +4,20 @@ Feature: Video Component Editor
|
||||
Scenario: User can view metadata
|
||||
Given I have created a Video component
|
||||
And I edit and select Settings
|
||||
Then I see only the Video display name setting
|
||||
Then I see the correct settings and default values
|
||||
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Video component
|
||||
And I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
Scenario: Captions are hidden when "show captions" is false
|
||||
Given I have created a Video component
|
||||
And I have set "show captions" to False
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
Scenario: Captions are shown when "show captions" is true
|
||||
Given I have created a Video component
|
||||
And I have set "show captions" to True
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
# disable missing docstring
|
||||
#pylint: disable=C0111
|
||||
# pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
|
||||
|
||||
@step('I see only the video display name setting$')
|
||||
def i_see_only_the_video_display_name(step):
|
||||
world.verify_all_setting_entries([['Display Name', "default", True]])
|
||||
@step('I see the correct settings and default values$')
|
||||
def i_see_the_correct_settings_and_values(step):
|
||||
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
|
||||
['Display Name', 'Video', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['Speed: .75x', '', False],
|
||||
['Speed: 1.25x', '', False],
|
||||
['Speed: 1.5x', '', False]])
|
||||
|
||||
|
||||
@step('I have set "show captions" to (.*)')
|
||||
def set_show_captions(step, setting):
|
||||
world.css_click('a.edit-button')
|
||||
world.browser.select('Show Captions', setting)
|
||||
world.css_click('a.save-button')
|
||||
|
||||
@@ -9,7 +9,16 @@ Feature: Video Component
|
||||
Given I have clicked the new unit button
|
||||
Then creating a video takes a single click
|
||||
|
||||
Scenario: Captions are shown correctly
|
||||
Scenario: Captions are hidden correctly
|
||||
Given I have created a Video component
|
||||
And I have hidden captions
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
Scenario: Captions are shown correctly
|
||||
Given I have created a Video component
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
Scenario: Captions are toggled correctly
|
||||
Given I have created a Video component
|
||||
And I have toggled captions
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
@@ -8,21 +8,26 @@ from lettuce import world, step
|
||||
@step('when I view the video it does not have autoplay enabled')
|
||||
def does_not_autoplay(_step):
|
||||
assert world.css_find('.video')[0]['data-autoplay'] == 'False'
|
||||
assert world.css_find('.video_control')[0].has_class('play')
|
||||
assert world.css_has_class('.video_control', 'play')
|
||||
|
||||
|
||||
@step('creating a video takes a single click')
|
||||
def video_takes_a_single_click(_step):
|
||||
assert(not world.is_css_present('.xmodule_VideoModule'))
|
||||
world.css_click("a[data-location='i4x://edx/templates/video/default']")
|
||||
world.css_click("a[data-category='video']")
|
||||
assert(world.is_css_present('.xmodule_VideoModule'))
|
||||
|
||||
|
||||
@step('I have hidden captions')
|
||||
def set_show_captions_false(step):
|
||||
world.css_click('a.hide-subtitles')
|
||||
|
||||
|
||||
@step('when I view the video it does not show the captions')
|
||||
def does_not_show_captions(step):
|
||||
assert world.css_find('.video')[0].has_class('closed')
|
||||
@step('I have (hidden|toggled) captions')
|
||||
def hide_or_show_captions(step, shown):
|
||||
button_css = 'a.hide-subtitles'
|
||||
if shown == 'hidden':
|
||||
world.css_click(button_css)
|
||||
if shown == 'toggled':
|
||||
world.css_click(button_css)
|
||||
# When we click the first time, a tooltip shows up. We want to
|
||||
# click the button rather than the tooltip, so move the mouse
|
||||
# away to make it disappear.
|
||||
button = world.css_find(button_css)
|
||||
button.mouse_out()
|
||||
world.css_click(button_css)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from json import dumps
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from django.conf import settings
|
||||
|
||||
filter_list = ['xml_attributes', 'checklists']
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized
|
||||
in a JSON format. This can be used for analytics.'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) < 2 or len(args) > 3:
|
||||
raise CommandError("dump_course_structure requires two or more arguments: <location> <outfile> |<db>|")
|
||||
|
||||
course_id = args[0]
|
||||
outfile = args[1]
|
||||
|
||||
# use a user-specified database name, if present
|
||||
# this is useful for doing dumps from databases restored from prod backups
|
||||
if len(args) == 3:
|
||||
settings.MODULESTORE['direct']['OPTIONS']['db'] = args[2]
|
||||
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
|
||||
store = modulestore()
|
||||
|
||||
course = None
|
||||
try:
|
||||
course = store.get_item(loc, depth=4)
|
||||
except:
|
||||
print 'Could not find course at {0}'.format(course_id)
|
||||
return
|
||||
|
||||
info = {}
|
||||
|
||||
def dump_into_dict(module, info):
|
||||
filtered_metadata = dict((key, value) for key, value in own_metadata(module).iteritems()
|
||||
if key not in filter_list)
|
||||
info[module.location.url()] = {
|
||||
'category': module.location.category,
|
||||
'children': module.children if hasattr(module, 'children') else [],
|
||||
'metadata': filtered_metadata
|
||||
}
|
||||
|
||||
for child in module.get_children():
|
||||
dump_into_dict(child, info)
|
||||
|
||||
dump_into_dict(course, info)
|
||||
|
||||
with open(outfile, 'w') as f:
|
||||
f.write(dumps(info))
|
||||
@@ -14,11 +14,11 @@ unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Import the specified data directory into the default ModuleStore'
|
||||
help = 'Export the specified data directory into the default ModuleStore'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 2:
|
||||
raise CommandError("import requires two arguments: <course location> <output path>")
|
||||
raise CommandError("export requires two arguments: <course location> <output path>")
|
||||
|
||||
course_id = args[0]
|
||||
output_path = args[1]
|
||||
@@ -30,4 +30,4 @@ class Command(BaseCommand):
|
||||
root_dir = os.path.dirname(output_path)
|
||||
course_dir = os.path.splitext(os.path.basename(output_path))[0]
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir)
|
||||
export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir, modulestore())
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
###
|
||||
### Script for exporting all courseware from Mongo to a directory
|
||||
###
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
|
||||
unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Export all courses from mongo to the specified data directory'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1:
|
||||
raise CommandError("export requires one argument: <output path>")
|
||||
|
||||
output_path = args[0]
|
||||
|
||||
cs = contentstore()
|
||||
ms = modulestore('direct')
|
||||
root_dir = output_path
|
||||
courses = ms.get_courses()
|
||||
|
||||
print "%d courses to export:" % len(courses)
|
||||
cids = [x.id for x in courses]
|
||||
print cids
|
||||
|
||||
for course_id in cids:
|
||||
|
||||
print "-"*77
|
||||
print "Exporting course id = {0} to {1}".format(course_id, output_path)
|
||||
|
||||
if 1:
|
||||
try:
|
||||
location = CourseDescriptor.id_to_location(course_id)
|
||||
course_dir = course_id.replace('/', '...')
|
||||
export_to_xml(ms, cs, location, root_dir, course_dir, modulestore())
|
||||
except Exception as err:
|
||||
print "="*30 + "> Oops, failed to export %s" % course_id
|
||||
print "Error:"
|
||||
print err
|
||||
@@ -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(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()
|
||||
@@ -1,10 +0,0 @@
|
||||
from xmodule.templates import update_templates
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Imports and updates the Studio component templates from the code pack and put in the DB'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
update_templates(modulestore('direct'))
|
||||
@@ -3,16 +3,13 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
|
||||
def get_module_info(store, location, parent_location=None, rewrite_static_links=False):
|
||||
def get_module_info(store, location, rewrite_static_links=False):
|
||||
try:
|
||||
if location.revision is None:
|
||||
module = store.get_item(location)
|
||||
else:
|
||||
module = store.get_item(location)
|
||||
module = store.get_item(location)
|
||||
except ItemNotFoundError:
|
||||
# create a new one
|
||||
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
|
||||
module = store.clone_item(template_location, location)
|
||||
store.create_and_save_xmodule(location)
|
||||
module = store.get_item(location)
|
||||
|
||||
data = module.data
|
||||
if rewrite_static_links:
|
||||
@@ -32,7 +29,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
|
||||
'id': module.location.url(),
|
||||
'data': data,
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
'metadata': module._model_data._kvs._metadata
|
||||
# what's the intent here? all metadata incl inherited & namespaced?
|
||||
'metadata': module.xblock_kvs._metadata
|
||||
}
|
||||
|
||||
|
||||
@@ -40,14 +38,11 @@ def set_module_info(store, location, post_data):
|
||||
module = None
|
||||
try:
|
||||
module = store.get_item(location)
|
||||
except:
|
||||
pass
|
||||
|
||||
if module is None:
|
||||
# new module at this location
|
||||
# presume that we have an 'Empty' template
|
||||
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
|
||||
module = store.clone_item(template_location, location)
|
||||
except ItemNotFoundError:
|
||||
# new module at this location: almost always used for the course about pages; thus, no parent. (there
|
||||
# are quite a handful of about page types available for a course and only the overview is pre-created)
|
||||
store.create_and_save_xmodule(location)
|
||||
module = store.get_item(location)
|
||||
|
||||
if post_data.get('data') is not None:
|
||||
data = post_data['data']
|
||||
@@ -82,4 +77,4 @@ def set_module_info(store, location, post_data):
|
||||
|
||||
# commit to datastore
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
store.update_metadata(location, module._model_data._kvs._metadata)
|
||||
store.update_metadata(location, module.xblock_kvs._metadata)
|
||||
|
||||
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):
|
||||
@@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase):
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
return modulestore.get_item(self.course.location).checklists
|
||||
|
||||
|
||||
def compare_checklists(self, persisted, request):
|
||||
"""
|
||||
Handles url expansion as possible difference and descends into guts
|
||||
@@ -47,6 +46,8 @@ class ChecklistTestCase(CourseTestCase):
|
||||
# Now delete the checklists from the course and verify they get repopulated (for courses
|
||||
# created before checklists were introduced).
|
||||
self.course.checklists = None
|
||||
# Save the changed `checklists` to the underlying KeyValueStore before updating the modulestore
|
||||
self.course.save()
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
modulestore.update_metadata(self.course.location, own_metadata(self.course))
|
||||
self.assertEqual(self.get_persisted_checklists(), None)
|
||||
@@ -99,7 +100,6 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 2})
|
||||
|
||||
|
||||
def get_first_item(checklist):
|
||||
return checklist['items'][0]
|
||||
|
||||
@@ -119,4 +119,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,5 +1,8 @@
|
||||
#pylint: disable=E1101
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import mock
|
||||
from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
@@ -16,15 +19,16 @@ from django.dispatch import Signal
|
||||
from contentstore.utils import get_modulestore
|
||||
from contentstore.tests.utils import parse_json
|
||||
|
||||
from auth.authz import add_user_to_creator_group
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore import Location, mongo
|
||||
from xmodule.modulestore.store_utilities import clone_course
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.templates import update_templates
|
||||
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
@@ -43,10 +47,12 @@ from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
|
||||
TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
|
||||
TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
|
||||
class MongoCollectionFindWrapper(object):
|
||||
@@ -59,13 +65,16 @@ class MongoCollectionFindWrapper(object):
|
||||
return self.original(query, *args, **kwargs)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests that rely on the toy courses.
|
||||
TODO: refactor using CourseFactory so they do not.
|
||||
"""
|
||||
def setUp(self):
|
||||
|
||||
settings.MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
settings.MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
uname = 'testuser'
|
||||
email = 'test+courses@edx.org'
|
||||
password = 'foo'
|
||||
@@ -78,11 +87,18 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.user.is_active = True
|
||||
# Staff has access to view all courses
|
||||
self.user.is_staff = True
|
||||
|
||||
# Save the data that we've just changed to the db.
|
||||
self.user.save()
|
||||
|
||||
self.client = Client()
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
def tearDown(self):
|
||||
mongo = MongoClient()
|
||||
mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db'])
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
def check_components_on_page(self, component_types, expected_types):
|
||||
"""
|
||||
Ensure that the right types end up on the page.
|
||||
@@ -103,6 +119,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
course.advanced_modules = component_types
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
|
||||
store.update_metadata(course.location, own_metadata(course))
|
||||
|
||||
# just pick one vertical
|
||||
@@ -120,7 +140,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha',
|
||||
'Word cloud',
|
||||
'Annotation',
|
||||
'Open Ended Response',
|
||||
'Open Response Assessment',
|
||||
'Peer Grading Interface'])
|
||||
|
||||
def test_advanced_components_require_two_clicks(self):
|
||||
@@ -149,9 +169,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_edit_unit_toy(self):
|
||||
self.check_edit_unit('toy')
|
||||
|
||||
def test_edit_unit_full(self):
|
||||
self.check_edit_unit('full')
|
||||
|
||||
def _get_draft_counts(self, item):
|
||||
cnt = 1 if getattr(item, 'is_draft', False) else 0
|
||||
for child in item.get_children():
|
||||
@@ -171,7 +188,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
|
||||
# now query get_items() to get this location with revision=None, this should just
|
||||
# return back a single item (not 2)
|
||||
@@ -203,7 +220,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod)
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
|
||||
# refetch to check metadata
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
@@ -221,13 +238,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
|
||||
# put back in draft and change metadata and see if it's now marked as 'own_metadata'
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
new_graceperiod = timedelta(hours=1)
|
||||
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
html_module.lms.graceperiod = new_graceperiod
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
html_module.save()
|
||||
self.assertIn('graceperiod', own_metadata(html_module))
|
||||
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
|
||||
|
||||
@@ -243,7 +263,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
draft_store.publish(html_module.location, 0)
|
||||
|
||||
# and re-read and verify 'own-metadata'
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
self.assertIn('graceperiod', own_metadata(html_module))
|
||||
@@ -266,7 +286,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
)
|
||||
|
||||
# put into draft
|
||||
modulestore('draft').clone_item(problem.location, problem.location)
|
||||
modulestore('draft').convert_to_draft(problem.location)
|
||||
|
||||
# make sure we can query that item and verify that it is a draft
|
||||
draft_problem = modulestore('draft').get_item(
|
||||
@@ -286,41 +306,68 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
def test_import_textbook_as_content_element(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
def test_default_tabs_on_create_course(self):
|
||||
module_store = modulestore('direct')
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
expected_tabs = []
|
||||
expected_tabs.append({u'type': u'courseware'})
|
||||
expected_tabs.append({u'type': u'course_info', u'name': u'Course Info'})
|
||||
expected_tabs.append({u'type': u'textbooks'})
|
||||
expected_tabs.append({u'type': u'discussion', u'name': u'Discussion'})
|
||||
expected_tabs.append({u'type': u'wiki', u'name': u'Wiki'})
|
||||
expected_tabs.append({u'type': u'progress', u'name': u'Progress'})
|
||||
|
||||
self.assertEqual(course.tabs, expected_tabs)
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
ItemFactory.create(
|
||||
parent_location=course_location,
|
||||
category="static_tab",
|
||||
display_name="Static_1")
|
||||
ItemFactory.create(
|
||||
parent_location=course_location,
|
||||
category="static_tab",
|
||||
display_name="Static_2")
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]))
|
||||
|
||||
# reverse the ordering
|
||||
reverse_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reverse_tabs.insert(0, 'i4x://edX/full/static_tab/{0}'.format(tab['url_slug']))
|
||||
reverse_tabs.insert(0, 'i4x://edX/999/static_tab/{0}'.format(tab['url_slug']))
|
||||
|
||||
self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs': reverse_tabs}), "application/json")
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]))
|
||||
|
||||
# compare to make sure that the tabs information is in the expected order after the server call
|
||||
course_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
course_tabs.append('i4x://edX/full/static_tab/{0}'.format(tab['url_slug']))
|
||||
course_tabs.append('i4x://edX/999/static_tab/{0}'.format(tab['url_slug']))
|
||||
|
||||
self.assertEqual(reverse_tabs, course_tabs)
|
||||
|
||||
def test_import_polls(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None])
|
||||
items = module_store.get_items(['i4x', 'edX', 'toy', 'poll_question', None, None])
|
||||
found = len(items) > 0
|
||||
|
||||
self.assertTrue(found)
|
||||
@@ -328,16 +375,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertGreater(len(items[0].question), 0)
|
||||
|
||||
def test_xlint_fails(self):
|
||||
err_cnt = perform_xlint('common/test/data', ['full'])
|
||||
err_cnt = perform_xlint('common/test/data', ['toy'])
|
||||
self.assertGreater(err_cnt, 0)
|
||||
|
||||
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/toy/.*'])
|
||||
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/', ['toy'])
|
||||
|
||||
# also try a custom response which will trigger the 'is this course in whitelist' logic
|
||||
problem_module_location = Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', 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'])
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
sequential = direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
chapterloc = ItemFactory.create(parent_location=course_location, display_name="Chapter").location
|
||||
ItemFactory.create(parent_location=chapterloc, category='sequential', display_name="Sequential")
|
||||
|
||||
chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None]))
|
||||
sequential = direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None]))
|
||||
chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None]))
|
||||
|
||||
# make sure the parent points to the child object which is to be deleted
|
||||
self.assertTrue(sequential.location.url() in chapter.children)
|
||||
@@ -350,14 +414,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
found = False
|
||||
try:
|
||||
direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None]))
|
||||
found = True
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
self.assertFalse(found)
|
||||
|
||||
chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None]))
|
||||
chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None]))
|
||||
|
||||
# make sure the parent no longer points to the child object which was deleted
|
||||
self.assertFalse(sequential.location.url() in chapter.children)
|
||||
@@ -368,20 +432,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
while there is a base definition in /about/effort.html
|
||||
'''
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None]))
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
effort = module_store.get_item(Location(['i4x', 'edX', 'toy', 'about', 'effort', None]))
|
||||
self.assertEqual(effort.data, '6 hours')
|
||||
|
||||
# this one should be in a non-override folder
|
||||
effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None]))
|
||||
effort = module_store.get_item(Location(['i4x', 'edX', 'toy', 'about', 'end_date', None]))
|
||||
self.assertEqual(effort.data, 'TBD')
|
||||
|
||||
def test_remove_hide_progress_tab(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
course = module_store.get_item(source_location)
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
course = module_store.get_item(course_location)
|
||||
self.assertFalse(course.hide_progress_tab)
|
||||
|
||||
def test_asset_import(self):
|
||||
@@ -391,9 +454,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
content_store = contentstore()
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
course_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
self.assertIsNotNone(course)
|
||||
@@ -403,7 +466,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our contentstore
|
||||
all_thumbnails = content_store.get_all_content_thumbnails_for_course(course_location)
|
||||
content_store.get_all_content_thumbnails_for_course(course_location)
|
||||
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
@@ -414,7 +477,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
content = None
|
||||
try:
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
|
||||
content = content_store.find(location)
|
||||
except NotFoundError:
|
||||
pass
|
||||
@@ -442,11 +505,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
|
||||
# look up original (and thumbnail) in content store, should be there after import
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
thumbnail_location = content.thumbnail_location
|
||||
self.assertIsNotNone(content)
|
||||
@@ -459,11 +521,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/toy/asset/sample_static.txt'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
asset_location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
|
||||
|
||||
# now try to find it in store, but they should not be there any longer
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
@@ -482,7 +544,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
# let's restore the asset
|
||||
restore_asset_from_trashcan('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
restore_asset_from_trashcan('/c4x/edX/toy/asset/sample_static.txt')
|
||||
|
||||
# now try to find it in courseware store, and they should be back after restore
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
@@ -500,18 +562,18 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
course_location = CourseDescriptor.id_to_location('edX/toy/6.002_Spring_2012')
|
||||
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/toy/asset/sample_static.txt'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# make sure there's something in the trashcan
|
||||
@@ -519,7 +581,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our trashcan
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
_all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
@@ -533,21 +595,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
self.assertEqual(len(all_assets), 0)
|
||||
|
||||
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
self.assertEqual(len(all_thumbnails), 0)
|
||||
|
||||
def test_clone_course(self):
|
||||
|
||||
course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
}
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
@@ -556,16 +616,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
content_store = contentstore()
|
||||
|
||||
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
source_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course')
|
||||
|
||||
clone_course(module_store, content_store, source_location, dest_location)
|
||||
|
||||
# now loop through all the units in the course and verify that the clone can render them, which
|
||||
# means the objects are at least present
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'toy', 'poll_question', None]))
|
||||
self.assertGreater(len(items), 0)
|
||||
clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'vertical', None]))
|
||||
clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'poll_question', None]))
|
||||
self.assertGreater(len(clone_items), 0)
|
||||
for descriptor in items:
|
||||
new_loc = descriptor.location.replace(org='MITx', course='999')
|
||||
@@ -580,42 +640,60 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
location = Location('i4x://MITx/999/chapter/neuvo')
|
||||
self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty',
|
||||
location)
|
||||
direct_store.clone_item('i4x://edx/templates/chapter/Empty', location)
|
||||
self.assertRaises(InvalidVersionError, draft_store.clone_item, location,
|
||||
location)
|
||||
# Ensure draft mongo store does not allow us to create chapters either directly or via convert to draft
|
||||
self.assertRaises(InvalidVersionError, draft_store.create_and_save_xmodule, location)
|
||||
direct_store.create_and_save_xmodule(location)
|
||||
self.assertRaises(InvalidVersionError, draft_store.convert_to_draft, location)
|
||||
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_item, location,
|
||||
'chapter data')
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data')
|
||||
|
||||
# taking advantage of update_children and other functions never checking that the ids are valid
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_children, location,
|
||||
['i4x://MITx/999/problem/doesntexist'])
|
||||
['i4x://MITx/999/problem/doesntexist'])
|
||||
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_metadata, location,
|
||||
{'due': datetime.datetime.now(UTC)})
|
||||
{'due': datetime.datetime.now(UTC)})
|
||||
|
||||
self.assertRaises(InvalidVersionError, draft_store.unpublish, location)
|
||||
|
||||
|
||||
def test_bad_contentstore_request(self):
|
||||
resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
|
||||
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')
|
||||
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
|
||||
location = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course').location
|
||||
|
||||
# get a vertical (and components in it) to put into 'draft'
|
||||
vertical = module_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'vertical', 'vertical_test', None]), depth=1)
|
||||
|
||||
draft_store.convert_to_draft(vertical.location)
|
||||
for child in vertical.get_children():
|
||||
draft_store.convert_to_draft(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 = module_store.get_items(Location(['i4x', 'edX', '999', 'course', 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))
|
||||
@@ -632,38 +710,47 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
# 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)
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
# get a vertical (and components in it) to copy into an orphan sub dag
|
||||
vertical = module_store.get_item(
|
||||
Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
|
||||
depth=1
|
||||
)
|
||||
# We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case.
|
||||
draft_store.clone_item(vertical.location, Location(['i4x', 'edX', 'full',
|
||||
'vertical', 'no_references', 'draft']))
|
||||
vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references'))
|
||||
draft_store.save_xmodule(vertical)
|
||||
orphan_vertical = draft_store.get_item(vertical.location)
|
||||
self.assertEqual(orphan_vertical.location.name, 'no_references')
|
||||
|
||||
# get the original vertical (and components in it) to put into 'draft'
|
||||
vertical = module_store.get_item(
|
||||
Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
|
||||
depth=1)
|
||||
self.assertEqual(len(orphan_vertical.children), len(vertical.children))
|
||||
draft_store.convert_to_draft(vertical.location)
|
||||
for child in vertical.get_children():
|
||||
draft_store.clone_item(child.location, child.location)
|
||||
draft_store.convert_to_draft(child.location)
|
||||
|
||||
root_dir = path(mkdtemp_clean())
|
||||
|
||||
# now create a private vertical
|
||||
private_vertical = draft_store.clone_item(vertical.location,
|
||||
Location(['i4x', 'edX', 'full', 'vertical', 'a_private_vertical', None]))
|
||||
# now create a new/different private (draft only) vertical
|
||||
vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
|
||||
draft_store.save_xmodule(vertical)
|
||||
private_vertical = draft_store.get_item(vertical.location)
|
||||
vertical = None # blank out b/c i destructively manipulated its location 2 lines above
|
||||
|
||||
# add private to list of children
|
||||
sequential = module_store.get_item(Location(['i4x', 'edX', 'full',
|
||||
'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
# add the new private to list of children
|
||||
sequential = module_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'sequential', 'vertical_sequential', None]))
|
||||
private_location_no_draft = private_vertical.location.replace(revision=None)
|
||||
module_store.update_children(sequential.location, sequential.children +
|
||||
[private_location_no_draft.url()])
|
||||
|
||||
# read back the sequential, to make sure we have a pointer to
|
||||
sequential = module_store.get_item(Location(['i4x', 'edX', 'full',
|
||||
'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
sequential = module_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'sequential', 'vertical_sequential', None]))
|
||||
|
||||
self.assertIn(private_location_no_draft.url(), sequential.children)
|
||||
|
||||
@@ -675,17 +762,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# check for static tabs
|
||||
self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html')
|
||||
|
||||
# check for custom_tags
|
||||
self.verify_content_existence(module_store, root_dir, location, 'info', 'course_info', '.html')
|
||||
|
||||
# check for custom_tags
|
||||
self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template')
|
||||
|
||||
# check for about content
|
||||
self.verify_content_existence(module_store, root_dir, location, 'about', 'about', '.html')
|
||||
|
||||
# check for graiding_policy.json
|
||||
filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012')
|
||||
filesystem = OSFS(root_dir / 'test_export/policies/2012_Fall')
|
||||
self.assertTrue(filesystem.exists('grading_policy.json'))
|
||||
|
||||
course = module_store.get_item(location)
|
||||
@@ -700,8 +781,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# compare what's on disk to what we have in the course module
|
||||
with filesystem.open('policy.json', 'r') as course_policy:
|
||||
on_disk = loads(course_policy.read())
|
||||
self.assertIn('course/6.002_Spring_2012', on_disk)
|
||||
self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course))
|
||||
self.assertIn('course/2012_Fall', on_disk)
|
||||
self.assertEqual(on_disk['course/2012_Fall'], own_metadata(course))
|
||||
|
||||
# remove old course
|
||||
delete_course(module_store, content_store, location)
|
||||
@@ -709,7 +790,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# reimport
|
||||
import_from_xml(module_store, root_dir, ['test_export'], draft_store=draft_store)
|
||||
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'toy', 'vertical', None]))
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
# don't try to look at private verticals. Right now we're running
|
||||
@@ -720,54 +801,81 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# verify that we have the content in the draft store as well
|
||||
vertical = draft_store.get_item(Location(['i4x', 'edX', 'full',
|
||||
'vertical', 'vertical_66', None]), depth=1)
|
||||
vertical = draft_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'vertical', 'vertical_test', None]), depth=1)
|
||||
|
||||
self.assertTrue(getattr(vertical, 'is_draft', False))
|
||||
for child in vertical.get_children():
|
||||
self.assertTrue(getattr(child, 'is_draft', False))
|
||||
|
||||
# make sure that we don't have a sequential that is in draft mode
|
||||
sequential = draft_store.get_item(Location(['i4x', 'edX', 'full',
|
||||
'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
sequential = draft_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'sequential', 'vertical_sequential', None]))
|
||||
|
||||
self.assertFalse(getattr(sequential, 'is_draft', False))
|
||||
|
||||
# verify that we have the private vertical
|
||||
test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'full',
|
||||
'vertical', 'vertical_66', None]))
|
||||
test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'vertical', 'a_private_vertical', None]))
|
||||
|
||||
self.assertTrue(getattr(test_private_vertical, 'is_draft', False))
|
||||
|
||||
# make sure the textbook survived the export/import
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_export_course_with_metadata_only_video(self):
|
||||
module_store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
# create a new video module and add it as a child to a vertical
|
||||
# this re-creates a bug whereby since the video template doesn't have
|
||||
# anything in 'data' field, the export was blowing up
|
||||
verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None])
|
||||
|
||||
self.assertGreater(len(verticals), 0)
|
||||
|
||||
parent = verticals[0]
|
||||
|
||||
ItemFactory.create(parent_location=parent.location, category="video", display_name="untitled")
|
||||
|
||||
root_dir = path(mkdtemp_clean())
|
||||
|
||||
print 'Exporting to tempdir = {0}'.format(root_dir)
|
||||
|
||||
# export out to a tempdir
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_course_handouts_rewrites(self):
|
||||
module_store = modulestore('direct')
|
||||
|
||||
# import a test course
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
handout_location = Location(['i4x', 'edX', 'full', 'course_info', 'handouts'])
|
||||
handout_location = Location(['i4x', 'edX', 'toy', 'course_info', 'handouts'])
|
||||
|
||||
# get module info
|
||||
resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location}))
|
||||
|
||||
# make sure we got a successful response
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# check that /static/ has been converted to the full path
|
||||
# note, we know the link it should be because that's what in the 'full' course in the test data
|
||||
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
|
||||
# note, we know the link it should be because that's what in the 'toy' course in the test data
|
||||
self.assertContains(resp, '/c4x/edX/toy/asset/handouts_sample_handout.txt')
|
||||
|
||||
def test_prefetch_children(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
wrapper = MongoCollectionFindWrapper(module_store.collection.find)
|
||||
module_store.collection.find = wrapper.find
|
||||
@@ -779,19 +887,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(wrapper.counter, 4)
|
||||
|
||||
# make sure we pre-fetched a known sequential which should be at depth=2
|
||||
self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential',
|
||||
'Administrivia_and_Circuit_Elements', None]) in course.system.module_data)
|
||||
self.assertTrue(Location(['i4x', 'edX', 'toy', 'sequential',
|
||||
'vertical_sequential', None]) in course.system.module_data)
|
||||
|
||||
# make sure we don't have a specific vertical which should be at depth=3
|
||||
self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', None])
|
||||
self.assertFalse(Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None])
|
||||
in course.system.module_data)
|
||||
|
||||
def test_export_course_with_unknown_metadata(self):
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
root_dir = path(mkdtemp_clean())
|
||||
|
||||
@@ -801,6 +909,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# add a bool piece of unknown metadata so we can verify we don't throw an exception
|
||||
metadata['new_metadata'] = True
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
module_store.update_metadata(location, metadata)
|
||||
|
||||
print 'Exporting to tempdir = {0}'.format(root_dir)
|
||||
@@ -809,6 +920,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_export')
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class ContentStoreTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the CMS ContentStore application.
|
||||
@@ -839,14 +951,24 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
self.course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
mongo = MongoClient()
|
||||
mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db'])
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
def test_create_course(self):
|
||||
"""Test new course creation - happy path"""
|
||||
self.assert_created_course()
|
||||
|
||||
def assert_created_course(self):
|
||||
"""
|
||||
Checks that the course was created properly.
|
||||
"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
@@ -854,41 +976,72 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
def test_create_course_check_forum_seeding(self):
|
||||
"""Test new course creation and verify forum seeding """
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
|
||||
self.assert_created_course()
|
||||
self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course'))
|
||||
|
||||
def test_create_course_duplicate_course(self):
|
||||
"""Test new course creation - error path"""
|
||||
self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assert_course_creation_failed('There is already a course defined with this name.')
|
||||
|
||||
def assert_course_creation_failed(self, error_message):
|
||||
"""
|
||||
Checks that the course did not get created
|
||||
"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.')
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['ErrMsg'], error_message)
|
||||
|
||||
def test_create_course_duplicate_number(self):
|
||||
"""Test new course creation - error path"""
|
||||
self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.course_data['display_name'] = 'Robot Super Course Two'
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
data = parse_json(resp)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data['ErrMsg'],
|
||||
'There is already a course defined with the same organization and course number.')
|
||||
self.assert_course_creation_failed('There is already a course defined with the same organization and course number.')
|
||||
|
||||
def test_create_course_with_bad_organization(self):
|
||||
"""Test new course creation - error path for bad organization name"""
|
||||
self.course_data['org'] = 'University of California, Berkeley'
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
data = parse_json(resp)
|
||||
self.assert_course_creation_failed(
|
||||
"Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data['ErrMsg'],
|
||||
"Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.")
|
||||
def test_create_course_with_course_creation_disabled_staff(self):
|
||||
"""Test new course creation -- course creation disabled, but staff access."""
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}):
|
||||
self.assert_created_course()
|
||||
|
||||
def test_create_course_with_course_creation_disabled_not_staff(self):
|
||||
"""Test new course creation -- error path for course creation disabled, not staff access."""
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}):
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.assert_course_permission_denied()
|
||||
|
||||
def test_create_course_no_course_creators_staff(self):
|
||||
"""Test new course creation -- course creation group enabled, staff, group is empty."""
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}):
|
||||
self.assert_created_course()
|
||||
|
||||
def test_create_course_no_course_creators_not_staff(self):
|
||||
"""Test new course creation -- error path for course creator group enabled, not staff, group is empty."""
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.assert_course_permission_denied()
|
||||
|
||||
def test_create_course_with_course_creator(self):
|
||||
"""Test new course creation -- use course creator group"""
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
add_user_to_creator_group(self.user, self.user)
|
||||
self.assert_created_course()
|
||||
|
||||
def assert_course_permission_denied(self):
|
||||
"""
|
||||
Checks that the course did not get created due to a PermissionError.
|
||||
"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_course_index_view_with_no_courses(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
@@ -941,17 +1094,17 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
html=True
|
||||
)
|
||||
|
||||
def test_clone_item(self):
|
||||
def test_create_item(self):
|
||||
"""Test cloning an item. E.g. creating a new section"""
|
||||
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
section_data = {
|
||||
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template': 'i4x://edx/templates/chapter/Empty',
|
||||
'category': 'chapter',
|
||||
'display_name': 'Section One',
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), section_data)
|
||||
resp = self.client.post(reverse('create_item'), section_data)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
@@ -966,14 +1119,14 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
problem_data = {
|
||||
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template': 'i4x://edx/templates/problem/Blank_Common_Problem'
|
||||
'category': 'problem'
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), problem_data)
|
||||
resp = self.client.post(reverse('create_item'), problem_data)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = parse_json(resp)
|
||||
problem_loc = payload['id']
|
||||
problem_loc = Location(payload['id'])
|
||||
problem = get_modulestore(problem_loc).get_item(problem_loc)
|
||||
# should be a CapaDescriptor
|
||||
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
|
||||
@@ -1103,13 +1256,12 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
def test_forum_id_generation(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
|
||||
new_component_location = Location('i4x', 'edX', 'full', 'discussion', 'new_component')
|
||||
source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag')
|
||||
new_component_location = Location('i4x', 'edX', '999', 'discussion', 'new_component')
|
||||
|
||||
# crate a new module and add it as a child to a vertical
|
||||
module_store.clone_item(source_template_location, new_component_location)
|
||||
module_store.create_and_save_xmodule(new_component_location)
|
||||
|
||||
new_discussion_item = module_store.get_item(new_component_location)
|
||||
|
||||
@@ -1117,7 +1269,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
def test_update_modulestore_signal_did_fire(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
|
||||
try:
|
||||
module_store.modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
|
||||
@@ -1129,11 +1281,10 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
module_store.modulestore_update_signal.connect(_signal_hander)
|
||||
|
||||
new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component')
|
||||
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
|
||||
new_component_location = Location('i4x', 'edX', '999', 'html', 'new_component')
|
||||
|
||||
# crate a new module
|
||||
module_store.clone_item(source_template_location, new_component_location)
|
||||
module_store.create_and_save_xmodule(new_component_location)
|
||||
|
||||
finally:
|
||||
module_store.modulestore_update_signal = None
|
||||
@@ -1142,23 +1293,23 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
def test_metadata_inheritance(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None]))
|
||||
|
||||
verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None])
|
||||
verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None])
|
||||
|
||||
# let's assert on the metadata_inheritance on an existing vertical
|
||||
for vertical in verticals:
|
||||
self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key)
|
||||
self.assertEqual(course.start, vertical.lms.start)
|
||||
|
||||
self.assertGreater(len(verticals), 0)
|
||||
|
||||
new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component')
|
||||
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
|
||||
new_component_location = Location('i4x', 'edX', 'toy', 'html', 'new_component')
|
||||
|
||||
# crate a new module and add it as a child to a vertical
|
||||
module_store.clone_item(source_template_location, new_component_location)
|
||||
module_store.create_and_save_xmodule(new_component_location)
|
||||
parent = verticals[0]
|
||||
module_store.update_children(parent.location, parent.children + [new_component_location.url()])
|
||||
|
||||
@@ -1168,6 +1319,8 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
# check for grace period definition which should be defined at the course level
|
||||
self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod)
|
||||
self.assertEqual(parent.lms.start, new_module.lms.start)
|
||||
self.assertEqual(course.start, new_module.lms.start)
|
||||
|
||||
self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key)
|
||||
|
||||
@@ -1175,6 +1328,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
# now let's define an override at the leaf node level
|
||||
#
|
||||
new_module.lms.graceperiod = timedelta(1)
|
||||
new_module.save()
|
||||
module_store.update_metadata(new_module.location, own_metadata(new_module))
|
||||
|
||||
# flush the cache and refetch
|
||||
@@ -1183,29 +1337,85 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertEqual(timedelta(1), new_module.lms.graceperiod)
|
||||
|
||||
def test_default_metadata_inheritance(self):
|
||||
course = CourseFactory.create()
|
||||
vertical = ItemFactory.create(parent_location=course.location)
|
||||
course.children.append(vertical)
|
||||
# in memory
|
||||
self.assertIsNotNone(course.start)
|
||||
self.assertEqual(course.start, vertical.lms.start)
|
||||
self.assertEqual(course.textbooks, [])
|
||||
self.assertIn('GRADER', course.grading_policy)
|
||||
self.assertIn('GRADE_CUTOFFS', course.grading_policy)
|
||||
self.assertGreaterEqual(len(course.checklists), 4)
|
||||
|
||||
class TemplateTestCase(ModuleStoreTestCase):
|
||||
|
||||
def test_template_cleanup(self):
|
||||
# by fetching
|
||||
module_store = modulestore('direct')
|
||||
fetched_course = module_store.get_item(course.location)
|
||||
fetched_item = module_store.get_item(vertical.location)
|
||||
self.assertIsNotNone(fetched_course.start)
|
||||
self.assertEqual(course.start, fetched_course.start)
|
||||
self.assertEqual(fetched_course.start, fetched_item.lms.start)
|
||||
self.assertEqual(course.textbooks, fetched_course.textbooks)
|
||||
# is this test too strict? i.e., it requires the dicts to be ==
|
||||
self.assertEqual(course.checklists, fetched_course.checklists)
|
||||
|
||||
# insert a bogus template in the store
|
||||
bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus')
|
||||
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
|
||||
|
||||
module_store.clone_item(source_template_location, bogus_template_location)
|
||||
class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test that metadata is correctly decached.
|
||||
"""
|
||||
|
||||
verify_create = module_store.get_item(bogus_template_location)
|
||||
self.assertIsNotNone(verify_create)
|
||||
def setUp(self):
|
||||
sample_xml = '''
|
||||
<video display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
|
||||
show_captions="false"
|
||||
from="00:00:01"
|
||||
to="00:01:00">
|
||||
<source src="http://www.example.com/file.mp4"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</video>
|
||||
'''
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
# now run cleanup
|
||||
update_templates(modulestore('direct'))
|
||||
model_data = {'data': sample_xml}
|
||||
self.descriptor = ItemFactory.create(parent_location=course_location, category='video', data=model_data)
|
||||
|
||||
# now try to find dangling template, it should not be in DB any longer
|
||||
asserted = False
|
||||
try:
|
||||
verify_create = module_store.get_item(bogus_template_location)
|
||||
except ItemNotFoundError:
|
||||
asserted = True
|
||||
def test_metadata_persistence(self):
|
||||
"""
|
||||
Test that descriptors which set metadata fields in their
|
||||
constructor are correctly persisted.
|
||||
"""
|
||||
# We should start with a source field, from the XML's <source/> tag
|
||||
self.assertIn('source', own_metadata(self.descriptor))
|
||||
attrs_to_strip = {
|
||||
'show_captions',
|
||||
'youtube_id_1_0',
|
||||
'youtube_id_0_75',
|
||||
'youtube_id_1_25',
|
||||
'youtube_id_1_5',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'source',
|
||||
'track'
|
||||
}
|
||||
# We strip out all metadata fields to reproduce a bug where
|
||||
# constructors which set their fields (e.g. Video) didn't have
|
||||
# those changes persisted. So in the end we have the XML data
|
||||
# in `descriptor.data`, but not in the individual fields
|
||||
fields = self.descriptor.fields
|
||||
for field in fields:
|
||||
if field.name in attrs_to_strip:
|
||||
field.delete_from(self.descriptor)
|
||||
|
||||
self.assertTrue(asserted)
|
||||
# Assert that we correctly stripped the field
|
||||
self.assertNotIn('source', own_metadata(self.descriptor))
|
||||
get_modulestore(self.descriptor.location).update_metadata(
|
||||
self.descriptor.location,
|
||||
own_metadata(self.descriptor)
|
||||
)
|
||||
module = get_modulestore(self.descriptor.location).get_item(self.descriptor.location)
|
||||
# Assert that get_item correctly sets the metadata
|
||||
self.assertIn('source', own_metadata(module))
|
||||
|
||||
@@ -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
|
||||
@@ -16,46 +14,15 @@ from xmodule.modulestore import Location
|
||||
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.modulestore.django import modulestore
|
||||
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,27 +30,25 @@ 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))
|
||||
self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
|
||||
self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus))
|
||||
self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview)
|
||||
self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
|
||||
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 ")
|
||||
self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized")
|
||||
self.assertEqual(jsondetails['overview'], "", "overview somehow initialized")
|
||||
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
|
||||
self.assertIsNone(jsondetails['effort'], "effort 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,12 +94,22 @@ 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})
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
response = self.client.get(settings_details_url)
|
||||
@@ -150,9 +126,14 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertNotContains(response, "Requirements")
|
||||
|
||||
def test_regular_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})
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
|
||||
response = self.client.get(settings_details_url)
|
||||
@@ -190,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")
|
||||
|
||||
@@ -225,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:
|
||||
@@ -238,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")
|
||||
|
||||
@@ -294,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")
|
||||
|
||||
@@ -310,6 +290,71 @@ class CourseGradingTest(CourseTestCase):
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
|
||||
|
||||
def test_update_cutoffs_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
# Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json
|
||||
# simply returns the cutoffs you send into it, rather than returning the db contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
|
||||
|
||||
test_grader.grade_cutoffs['D'] = 0.3
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
|
||||
|
||||
test_grader.grade_cutoffs['Pass'] = 0.75
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
|
||||
|
||||
def test_delete_grace_period(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")
|
||||
|
||||
test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period")
|
||||
|
||||
test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0}
|
||||
# Now delete the grace period
|
||||
CourseGradingModel.delete_grace_period(test_grader.course_location)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
# Once deleted, the grace period should simply be None
|
||||
self.assertEqual(None, altered_grader.grace_period, "Delete grace period")
|
||||
|
||||
def test_update_section_grader_type(self):
|
||||
# Get the descriptor and the section_grader_type and assert they are the default values
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.lms.format)
|
||||
self.assertEqual(False, descriptor.lms.graded)
|
||||
|
||||
# Change the default grader type to Homework, which should also mark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Homework', section_grader_type['graderType'])
|
||||
self.assertEqual('Homework', descriptor.lms.format)
|
||||
self.assertEqual(True, descriptor.lms.graded)
|
||||
|
||||
# Change the grader type back to Not Graded, which should also unmark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.lms.format)
|
||||
self.assertEqual(False, descriptor.lms.graded)
|
||||
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
@@ -317,35 +362,34 @@ 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'])
|
||||
self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
self.fullcourse_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', 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")
|
||||
|
||||
test_model = CourseMetadata.fetch(self.fullcourse_location)
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
||||
self.assertIn('showanswer', test_model, 'showanswer field ')
|
||||
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"}
|
||||
)
|
||||
@@ -369,8 +413,40 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
# ensure no harm
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
||||
# check for deletion effectiveness
|
||||
self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual('finished', 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,13 +31,16 @@ 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
|
||||
# POST requests were coming in w/ these header values causing an error; so, repro error here
|
||||
resp = self.client.post(first_update_url, json.dumps(payload),
|
||||
"application/json")
|
||||
"application/json",
|
||||
HTTP_X_HTTP_METHOD_OVERRIDE="PUT",
|
||||
REQUEST_METHOD="POST")
|
||||
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'],
|
||||
"iframe w/ div")
|
||||
@@ -47,8 +50,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 +61,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 +76,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 +90,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 +102,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 +111,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 +122,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 +131,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)
|
||||
|
||||
186
cms/djangoapps/contentstore/tests/test_crud.py
Normal file
186
cms/djangoapps/contentstore/tests/test_crud.py
Normal file
@@ -0,0 +1,186 @@
|
||||
'''
|
||||
Created on May 7, 2013
|
||||
|
||||
@author: dmitchell
|
||||
'''
|
||||
import unittest
|
||||
from xmodule import templates
|
||||
from xmodule.modulestore.tests import persistent_factories
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
|
||||
|
||||
class TemplateTests(unittest.TestCase):
|
||||
"""
|
||||
Test finding and using the templates (boilerplates) for xblocks.
|
||||
"""
|
||||
|
||||
def test_get_templates(self):
|
||||
found = templates.all_templates()
|
||||
self.assertIsNotNone(found.get('course'))
|
||||
self.assertIsNotNone(found.get('about'))
|
||||
self.assertIsNotNone(found.get('html'))
|
||||
self.assertIsNotNone(found.get('problem'))
|
||||
self.assertEqual(len(found.get('course')), 0)
|
||||
self.assertEqual(len(found.get('about')), 1)
|
||||
self.assertGreaterEqual(len(found.get('html')), 2)
|
||||
self.assertGreaterEqual(len(found.get('problem')), 10)
|
||||
dropdown = None
|
||||
for template in found['problem']:
|
||||
self.assertIn('metadata', template)
|
||||
self.assertIn('display_name', template['metadata'])
|
||||
if template['metadata']['display_name'] == 'Dropdown':
|
||||
dropdown = template
|
||||
break
|
||||
self.assertIsNotNone(dropdown)
|
||||
self.assertIn('markdown', dropdown['metadata'])
|
||||
self.assertIn('data', dropdown)
|
||||
self.assertRegexpMatches(dropdown['metadata']['markdown'], r'^Dropdown.*')
|
||||
self.assertRegexpMatches(dropdown['data'], r'<problem>\s*<p>Dropdown.*')
|
||||
|
||||
def test_get_some_templates(self):
|
||||
self.assertEqual(len(SequenceDescriptor.templates()), 0)
|
||||
self.assertGreater(len(HtmlDescriptor.templates()), 0)
|
||||
self.assertIsNone(SequenceDescriptor.get_template('doesntexist.yaml'))
|
||||
self.assertIsNone(HtmlDescriptor.get_template('doesntexist.yaml'))
|
||||
self.assertIsNotNone(HtmlDescriptor.get_template('announcement.yaml'))
|
||||
|
||||
def test_factories(self):
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
self.assertIsInstance(test_course, CourseDescriptor)
|
||||
self.assertEqual(test_course.display_name, 'fun test course')
|
||||
index_info = modulestore('split').get_course_index_info(test_course.location)
|
||||
self.assertEqual(index_info['org'], 'testx')
|
||||
self.assertEqual(index_info['prettyid'], 'tempcourse')
|
||||
|
||||
test_chapter = persistent_factories.ItemFactory.create(display_name='chapter 1',
|
||||
parent_location=test_course.location)
|
||||
self.assertIsInstance(test_chapter, SequenceDescriptor)
|
||||
# refetch parent which should now point to child
|
||||
test_course = modulestore('split').get_course(test_chapter.location)
|
||||
self.assertIn(test_chapter.location.usage_id, test_course.children)
|
||||
|
||||
def test_temporary_xblocks(self):
|
||||
"""
|
||||
Test using load_from_json to create non persisted xblocks
|
||||
"""
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
|
||||
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
|
||||
'metadata': {'display_name': 'chapter n'}},
|
||||
test_course.system, parent_xblock=test_course)
|
||||
self.assertIsInstance(test_chapter, SequenceDescriptor)
|
||||
self.assertEqual(test_chapter.display_name, 'chapter n')
|
||||
self.assertIn(test_chapter, test_course.get_children())
|
||||
|
||||
# test w/ a definition (e.g., a problem)
|
||||
test_def_content = '<problem>boo</problem>'
|
||||
test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
|
||||
'definition': {'data': test_def_content}},
|
||||
test_course.system, parent_xblock=test_chapter)
|
||||
self.assertIsInstance(test_problem, CapaDescriptor)
|
||||
self.assertEqual(test_problem.data, test_def_content)
|
||||
self.assertIn(test_problem, test_chapter.get_children())
|
||||
test_problem.display_name = 'test problem'
|
||||
self.assertEqual(test_problem.display_name, 'test problem')
|
||||
|
||||
def test_persist_dag(self):
|
||||
"""
|
||||
try saving temporary xblocks
|
||||
"""
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
|
||||
'metadata': {'display_name': 'chapter n'}},
|
||||
test_course.system, parent_xblock=test_course)
|
||||
test_def_content = '<problem>boo</problem>'
|
||||
test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
|
||||
'definition': {'data': test_def_content}},
|
||||
test_course.system, parent_xblock=test_chapter)
|
||||
# better to pass in persisted parent over the subdag so
|
||||
# subdag gets the parent pointer (otherwise 2 ops, persist dag, update parent children,
|
||||
# persist parent
|
||||
persisted_course = modulestore('split').persist_xblock_dag(test_course, 'testbot')
|
||||
self.assertEqual(len(persisted_course.children), 1)
|
||||
persisted_chapter = persisted_course.get_children()[0]
|
||||
self.assertEqual(persisted_chapter.category, 'chapter')
|
||||
self.assertEqual(persisted_chapter.display_name, 'chapter n')
|
||||
self.assertEqual(len(persisted_chapter.children), 1)
|
||||
persisted_problem = persisted_chapter.get_children()[0]
|
||||
self.assertEqual(persisted_problem.category, 'problem')
|
||||
self.assertEqual(persisted_problem.data, test_def_content)
|
||||
|
||||
def test_delete_course(self):
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(
|
||||
org='testx',
|
||||
prettyid='edu.harvard.history.doomed',
|
||||
display_name='doomed test course',
|
||||
user_id='testbot')
|
||||
persistent_factories.ItemFactory.create(display_name='chapter 1',
|
||||
parent_location=test_course.location)
|
||||
|
||||
id_locator = CourseLocator(course_id=test_course.location.course_id, revision='draft')
|
||||
guid_locator = CourseLocator(version_guid=test_course.location.version_guid)
|
||||
# verify it can be retireved by id
|
||||
self.assertIsInstance(modulestore('split').get_course(id_locator), CourseDescriptor)
|
||||
# and by guid
|
||||
self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor)
|
||||
modulestore('split').delete_course(id_locator.course_id)
|
||||
# test can no longer retrieve by id
|
||||
self.assertRaises(ItemNotFoundError, modulestore('split').get_course, id_locator)
|
||||
# but can by guid
|
||||
self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor)
|
||||
|
||||
def test_block_generations(self):
|
||||
"""
|
||||
Test get_block_generations
|
||||
"""
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(
|
||||
org='testx',
|
||||
prettyid='edu.harvard.history.hist101',
|
||||
display_name='history test course',
|
||||
user_id='testbot')
|
||||
chapter = persistent_factories.ItemFactory.create(display_name='chapter 1',
|
||||
parent_location=test_course.location, user_id='testbot')
|
||||
sub = persistent_factories.ItemFactory.create(display_name='subsection 1',
|
||||
parent_location=chapter.location, user_id='testbot', category='vertical')
|
||||
first_problem = persistent_factories.ItemFactory.create(display_name='problem 1',
|
||||
parent_location=sub.location, user_id='testbot', category='problem', data="<problem></problem>")
|
||||
first_problem.max_attempts = 3
|
||||
updated_problem = modulestore('split').update_item(first_problem, 'testbot')
|
||||
updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot')
|
||||
|
||||
second_problem = persistent_factories.ItemFactory.create(display_name='problem 2',
|
||||
parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id),
|
||||
user_id='testbot', category='problem', data="<problem></problem>")
|
||||
|
||||
# course root only updated 2x
|
||||
version_history = modulestore('split').get_block_generations(test_course.location)
|
||||
self.assertEqual(version_history.locator.version_guid, test_course.location.version_guid)
|
||||
self.assertEqual(len(version_history.children), 1)
|
||||
self.assertEqual(version_history.children[0].children, [])
|
||||
self.assertEqual(version_history.children[0].locator.version_guid, chapter.location.version_guid)
|
||||
|
||||
# sub changed on add, add problem, delete problem, add problem in strict linear seq
|
||||
version_history = modulestore('split').get_block_generations(sub.location)
|
||||
self.assertEqual(len(version_history.children), 1)
|
||||
self.assertEqual(len(version_history.children[0].children), 1)
|
||||
self.assertEqual(len(version_history.children[0].children[0].children), 1)
|
||||
self.assertEqual(len(version_history.children[0].children[0].children[0].children), 0)
|
||||
|
||||
# first and second problem may show as same usage_id; so, need to ensure their histories are right
|
||||
version_history = modulestore('split').get_block_generations(updated_problem.location)
|
||||
self.assertEqual(version_history.locator.version_guid, first_problem.location.version_guid)
|
||||
self.assertEqual(len(version_history.children), 1) # updated max_attempts
|
||||
self.assertEqual(len(version_history.children[0].children), 0)
|
||||
|
||||
version_history = modulestore('split').get_block_generations(second_problem.location)
|
||||
self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid)
|
||||
@@ -35,7 +35,6 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
self.user.save()
|
||||
|
||||
self.course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from contentstore.utils import get_modulestore, get_url_reverse
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
import json
|
||||
from xmodule.modulestore.django import modulestore
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class DeleteItem(CourseTestCase):
|
||||
@@ -12,12 +16,13 @@ class DeleteItem(CourseTestCase):
|
||||
|
||||
def testDeleteStaticPage(self):
|
||||
# Add static tab
|
||||
data = {
|
||||
data = json.dumps({
|
||||
'parent_location': 'i4x://mitX/333/course/Dummy_Course',
|
||||
'template': 'i4x://edx/templates/static_tab/Empty'
|
||||
}
|
||||
'category': 'static_tab'
|
||||
})
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), data)
|
||||
resp = self.client.post(reverse('create_item'), data,
|
||||
content_type="application/json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore).
|
||||
@@ -25,5 +30,214 @@ class DeleteItem(CourseTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
class TestCreateItem(CourseTestCase):
|
||||
"""
|
||||
Test the create_item handler thoroughly
|
||||
"""
|
||||
def response_id(self, response):
|
||||
"""
|
||||
Get the id from the response payload
|
||||
:param response:
|
||||
"""
|
||||
parsed = json.loads(response.content)
|
||||
return parsed['id']
|
||||
|
||||
def test_create_nicely(self):
|
||||
"""
|
||||
Try the straightforward use cases
|
||||
"""
|
||||
# create a chapter
|
||||
display_name = 'Nicely created'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({
|
||||
'parent_location': self.course.location.url(),
|
||||
'display_name': display_name,
|
||||
'category': 'chapter'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# get the new item and check its category and display_name
|
||||
chap_location = self.response_id(resp)
|
||||
new_obj = modulestore().get_item(chap_location)
|
||||
self.assertEqual(new_obj.category, 'chapter')
|
||||
self.assertEqual(new_obj.display_name, display_name)
|
||||
self.assertEqual(new_obj.location.org, self.course.location.org)
|
||||
self.assertEqual(new_obj.location.course, self.course.location.course)
|
||||
|
||||
# get the course and ensure it now points to this one
|
||||
course = modulestore().get_item(self.course.location)
|
||||
self.assertIn(chap_location, course.children)
|
||||
|
||||
# use default display name
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({
|
||||
'parent_location': chap_location,
|
||||
'category': 'vertical'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
vert_location = self.response_id(resp)
|
||||
|
||||
# create problem w/ boilerplate
|
||||
template_id = 'multiplechoice.yaml'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({
|
||||
'parent_location': vert_location,
|
||||
'category': 'problem',
|
||||
'boilerplate': template_id
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
prob_location = self.response_id(resp)
|
||||
problem = modulestore('draft').get_item(prob_location)
|
||||
# ensure it's draft
|
||||
self.assertTrue(problem.is_draft)
|
||||
# check against the template
|
||||
template = CapaDescriptor.get_template(template_id)
|
||||
self.assertEqual(problem.data, template['data'])
|
||||
self.assertEqual(problem.display_name, template['metadata']['display_name'])
|
||||
self.assertEqual(problem.markdown, template['metadata']['markdown'])
|
||||
|
||||
def test_create_item_negative(self):
|
||||
"""
|
||||
Negative tests for create_item
|
||||
"""
|
||||
# non-existent boilerplate: creates a default
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps(
|
||||
{'parent_location': self.course.location.url(),
|
||||
'category': 'problem',
|
||||
'boilerplate': 'nosuchboilerplate.yaml'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
class TestEditItem(CourseTestCase):
|
||||
"""
|
||||
Test contentstore.views.item.save_item
|
||||
"""
|
||||
def response_id(self, response):
|
||||
"""
|
||||
Get the id from the response payload
|
||||
:param response:
|
||||
"""
|
||||
parsed = json.loads(response.content)
|
||||
return parsed['id']
|
||||
|
||||
def setUp(self):
|
||||
""" Creates the test course structure and a couple problems to 'edit'. """
|
||||
super(TestEditItem, self).setUp()
|
||||
# create a chapter
|
||||
display_name = 'chapter created'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps(
|
||||
{'parent_location': self.course.location.url(),
|
||||
'display_name': display_name,
|
||||
'category': 'chapter'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
chap_location = self.response_id(resp)
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps(
|
||||
{'parent_location': chap_location,
|
||||
'category': 'sequential'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.seq_location = self.response_id(resp)
|
||||
# create problem w/ boilerplate
|
||||
template_id = 'multiplechoice.yaml'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({'parent_location': self.seq_location,
|
||||
'category': 'problem',
|
||||
'boilerplate': template_id
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.problems = [self.response_id(resp)]
|
||||
|
||||
def test_delete_field(self):
|
||||
"""
|
||||
Sending null in for a field 'deletes' it
|
||||
"""
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.problems[0],
|
||||
'metadata': {'rerandomize': 'onreset'}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertEqual(problem.rerandomize, 'onreset')
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.problems[0],
|
||||
'metadata': {'rerandomize': None}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertEqual(problem.rerandomize, 'never')
|
||||
|
||||
|
||||
def test_null_field(self):
|
||||
"""
|
||||
Sending null in for a field 'deletes' it
|
||||
"""
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertIsNotNone(problem.markdown)
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.problems[0],
|
||||
'nullout': ['markdown']
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertIsNone(problem.markdown)
|
||||
|
||||
def test_date_fields(self):
|
||||
"""
|
||||
Test setting due & start dates on sequential
|
||||
"""
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertIsNone(sequential.lms.due)
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.seq_location,
|
||||
'metadata': {'due': '2010-11-22T04:00Z'}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.seq_location,
|
||||
'metadata': {'start': '2010-09-12T14:00Z'}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
|
||||
|
||||
|
||||
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)
|
||||
416
cms/djangoapps/contentstore/tests/test_textbooks.py
Normal file
416
cms/djangoapps/contentstore/tests/test_textbooks.py
Normal file
@@ -0,0 +1,416 @@
|
||||
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
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
self.course.save()
|
||||
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]
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
self.course.save()
|
||||
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))
|
||||
195
cms/djangoapps/contentstore/tests/test_users.py
Normal file
195
cms/djangoapps/contentstore/tests/test_users.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Tests for user.py.
|
||||
"""
|
||||
import json
|
||||
import mock
|
||||
from .utils import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from contentstore.views.user import _get_course_creator_status
|
||||
from course_creators.views import add_user_with_status_granted
|
||||
from course_creators.admin import CourseCreatorAdmin
|
||||
from course_creators.models import CourseCreator
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class IndexCourseCreatorTests(CourseTestCase):
|
||||
"""
|
||||
Tests the various permutations of course creator status.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(IndexCourseCreatorTests, self).setUp()
|
||||
|
||||
self.index_url = reverse("index")
|
||||
self.request_access_url = reverse("request_course_creator")
|
||||
|
||||
# Disable course creation takes precedence over enable creator group. I have enabled the
|
||||
# latter to make this clear.
|
||||
self.disable_course_creation = {
|
||||
"DISABLE_COURSE_CREATION": True,
|
||||
"ENABLE_CREATOR_GROUP": True,
|
||||
'STUDIO_REQUEST_EMAIL': 'mark@marky.mark',
|
||||
}
|
||||
|
||||
self.enable_creator_group = {"ENABLE_CREATOR_GROUP": True}
|
||||
|
||||
self.admin = User.objects.create_user('Mark', 'mark+courses@edx.org', 'foo')
|
||||
self.admin.is_staff = True
|
||||
|
||||
def test_get_course_creator_status_disable_creation(self):
|
||||
# DISABLE_COURSE_CREATION is True (this is the case on edx, where we have a marketing site).
|
||||
# Only edx staff can create courses.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation):
|
||||
self.assertTrue(self.user.is_staff)
|
||||
self.assertEquals('granted', _get_course_creator_status(self.user))
|
||||
self._set_user_non_staff()
|
||||
self.assertFalse(self.user.is_staff)
|
||||
self.assertEquals('disallowed_for_this_site', _get_course_creator_status(self.user))
|
||||
|
||||
def test_get_course_creator_status_default_cause(self):
|
||||
# Neither ENABLE_CREATOR_GROUP nor DISABLE_COURSE_CREATION are enabled. Anyone can create a course.
|
||||
self.assertEquals('granted', _get_course_creator_status(self.user))
|
||||
self._set_user_non_staff()
|
||||
self.assertEquals('granted', _get_course_creator_status(self.user))
|
||||
|
||||
def test_get_course_creator_status_creator_group(self):
|
||||
# ENABLE_CREATOR_GROUP is True. This is the case on edge.
|
||||
# Only staff members and users who have been granted access can create courses.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
|
||||
# Staff members can always create courses.
|
||||
self.assertEquals('granted', _get_course_creator_status(self.user))
|
||||
# Non-staff must request access.
|
||||
self._set_user_non_staff()
|
||||
self.assertEquals('unrequested', _get_course_creator_status(self.user))
|
||||
# Staff user requests access.
|
||||
self.client.post(self.request_access_url)
|
||||
self.assertEquals('pending', _get_course_creator_status(self.user))
|
||||
|
||||
def test_get_course_creator_status_creator_group_granted(self):
|
||||
# ENABLE_CREATOR_GROUP is True. This is the case on edge.
|
||||
# Check return value for a non-staff user who has been granted access.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
|
||||
self._set_user_non_staff()
|
||||
add_user_with_status_granted(self.admin, self.user)
|
||||
self.assertEquals('granted', _get_course_creator_status(self.user))
|
||||
|
||||
def test_get_course_creator_status_creator_group_denied(self):
|
||||
# ENABLE_CREATOR_GROUP is True. This is the case on edge.
|
||||
# Check return value for a non-staff user who has been denied access.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
|
||||
self._set_user_non_staff()
|
||||
self._set_user_denied()
|
||||
self.assertEquals('denied', _get_course_creator_status(self.user))
|
||||
|
||||
def test_disable_course_creation_enabled_non_staff(self):
|
||||
# Test index page content when DISABLE_COURSE_CREATION is True, non-staff member.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation):
|
||||
self._set_user_non_staff()
|
||||
self._assert_cannot_create()
|
||||
|
||||
def test_disable_course_creation_enabled_staff(self):
|
||||
# Test index page content when DISABLE_COURSE_CREATION is True, staff member.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation):
|
||||
resp = self._assert_can_create()
|
||||
self.assertFalse('Email staff to create course' in resp.content)
|
||||
|
||||
def test_can_create_by_default(self):
|
||||
# Test index page content with neither ENABLE_CREATOR_GROUP nor DISABLE_COURSE_CREATION enabled.
|
||||
# Anyone can create a course.
|
||||
self._assert_can_create()
|
||||
self._set_user_non_staff()
|
||||
self._assert_can_create()
|
||||
|
||||
def test_course_creator_group_enabled(self):
|
||||
# Test index page content with ENABLE_CREATOR_GROUP True.
|
||||
# Staff can always create a course, others must request access.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
|
||||
# Staff members can always create courses.
|
||||
self._assert_can_create()
|
||||
|
||||
# Non-staff case.
|
||||
self._set_user_non_staff()
|
||||
resp = self._assert_cannot_create()
|
||||
self.assertTrue(self.request_access_url in resp.content)
|
||||
|
||||
# Now request access.
|
||||
self.client.post(self.request_access_url)
|
||||
|
||||
# Still cannot create a course, but the "request access button" is no longer there.
|
||||
resp = self._assert_cannot_create()
|
||||
self.assertFalse(self.request_access_url in resp.content)
|
||||
self.assertTrue('has-status is-pending' in resp.content)
|
||||
|
||||
def test_course_creator_group_granted(self):
|
||||
# Test index page content with ENABLE_CREATOR_GROUP True, non-staff member with access granted.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
|
||||
self._set_user_non_staff()
|
||||
add_user_with_status_granted(self.admin, self.user)
|
||||
self._assert_can_create()
|
||||
|
||||
def test_course_creator_group_denied(self):
|
||||
# Test index page content with ENABLE_CREATOR_GROUP True, non-staff member with access denied.
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group):
|
||||
self._set_user_non_staff()
|
||||
self._set_user_denied()
|
||||
resp = self._assert_cannot_create()
|
||||
self.assertFalse(self.request_access_url in resp.content)
|
||||
self.assertTrue('has-status is-denied' in resp.content)
|
||||
|
||||
def _assert_can_create(self):
|
||||
"""
|
||||
Helper method that posts to the index page and checks that the user can create a course.
|
||||
|
||||
Returns the response from the post.
|
||||
"""
|
||||
resp = self.client.post(self.index_url)
|
||||
self.assertTrue('new-course-button' in resp.content)
|
||||
self.assertFalse(self.request_access_url in resp.content)
|
||||
self.assertFalse('Email staff to create course' in resp.content)
|
||||
return resp
|
||||
|
||||
def _assert_cannot_create(self):
|
||||
"""
|
||||
Helper method that posts to the index page and checks that the user cannot create a course.
|
||||
|
||||
Returns the response from the post.
|
||||
"""
|
||||
resp = self.client.post(self.index_url)
|
||||
self.assertFalse('new-course-button' in resp.content)
|
||||
return resp
|
||||
|
||||
def _set_user_non_staff(self):
|
||||
"""
|
||||
Sets user as non-staff.
|
||||
"""
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
def _set_user_denied(self):
|
||||
"""
|
||||
Sets course creator status to denied in admin table.
|
||||
"""
|
||||
self.table_entry = CourseCreator(user=self.user)
|
||||
self.table_entry.save()
|
||||
|
||||
self.deny_request = HttpRequest()
|
||||
self.deny_request.user = self.admin
|
||||
|
||||
self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite())
|
||||
|
||||
self.table_entry.state = CourseCreator.DENIED
|
||||
self.creator_admin.save_model(self.deny_request, self.table_entry, None, True)
|
||||
@@ -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,36 @@ 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(
|
||||
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
|
||||
@@ -7,23 +9,24 @@ import copy
|
||||
import logging
|
||||
import re
|
||||
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#In order to instantiate an open ended tab automatically, need to have this data
|
||||
OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"}
|
||||
NOTES_PANEL = {"name": "My Notes", "type": "notes"}
|
||||
# In order to instantiate an open ended tab automatically, need to have this data
|
||||
OPEN_ENDED_PANEL = {"name": _("Open Ended Panel"), "type": "open_ended"}
|
||||
NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
|
||||
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
|
||||
|
||||
|
||||
def get_modulestore(location):
|
||||
def get_modulestore(category_or_location):
|
||||
"""
|
||||
Returns the correct modulestore to use for modifying the specified location
|
||||
"""
|
||||
if not isinstance(location, Location):
|
||||
location = Location(location)
|
||||
if isinstance(category_or_location, Location):
|
||||
category_or_location = category_or_location.category
|
||||
|
||||
if location.category in DIRECT_ONLY_CATEGORIES:
|
||||
if category_or_location in DIRECT_ONLY_CATEGORIES:
|
||||
return modulestore('direct')
|
||||
else:
|
||||
return 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, require_http_methods
|
||||
|
||||
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
|
||||
|
||||
@@ -206,12 +249,15 @@ def remove_asset(request, org, course, name):
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@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':
|
||||
if request.method in ('POST', 'PUT'):
|
||||
filename = request.FILES['course-data'].name
|
||||
|
||||
if not filename.endswith('.tar.gz'):
|
||||
@@ -240,13 +286,13 @@ def import_course(request, org, course, name):
|
||||
# find the 'course.xml' file
|
||||
|
||||
for dirpath, _dirnames, filenames in os.walk(course_dir):
|
||||
for files in filenames:
|
||||
if files == 'course.xml':
|
||||
for filename in filenames:
|
||||
if filename == 'course.xml':
|
||||
break
|
||||
if files == 'course.xml':
|
||||
if filename == 'course.xml':
|
||||
break
|
||||
|
||||
if files != 'course.xml':
|
||||
if filename != 'course.xml':
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'}))
|
||||
|
||||
logging.debug('found course.xml at {0}'.format(dirpath))
|
||||
@@ -258,7 +304,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 +320,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 +327,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 +361,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,16 +1,17 @@
|
||||
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
|
||||
|
||||
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
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
__all__ = ['get_checklists', 'update_checklist']
|
||||
|
||||
@@ -27,17 +28,16 @@ def get_checklists(request, org, course, name):
|
||||
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
|
||||
template_module = modulestore.get_item(new_course_template)
|
||||
|
||||
# If course was created before checklists were introduced, copy them over from the template.
|
||||
copied = False
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = template_module.checklists
|
||||
course_module.checklists = CourseDescriptor.checklists.default
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
course_module.save()
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
@@ -46,6 +46,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 +63,16 @@ 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)
|
||||
course_module.save()
|
||||
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.",
|
||||
@@ -78,10 +81,9 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
# In the JavaScript view initialize method, we do a fetch to get all the checklists.
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
course_module.save()
|
||||
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,8 +24,10 @@ 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
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xblock.plugin import PluginMissingError
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
@@ -38,7 +41,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']
|
||||
@@ -99,7 +103,7 @@ def edit_subsection(request, location):
|
||||
return render_to_response('edit_subsection.html',
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'new_unit_category': 'vertical',
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
@@ -132,10 +136,26 @@ def edit_unit(request, location):
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
component_class = XModuleDescriptor.load_class(category)
|
||||
# add the default template
|
||||
component_templates[category].append((
|
||||
component_class.display_name.default or 'Blank',
|
||||
category,
|
||||
False, # No defaults have markdown (hardcoded current default)
|
||||
None # no boilerplate for overrides
|
||||
))
|
||||
# add boilerplates
|
||||
for template in component_class.templates():
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy. These modules
|
||||
# should be specified as a list of strings, where the strings are the names of the modules
|
||||
@@ -143,29 +163,29 @@ def edit_unit(request, location):
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
component_types = list(COMPONENT_TYPES)
|
||||
if isinstance(course_advanced_keys, list):
|
||||
course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
|
||||
if len(course_advanced_keys) > 0:
|
||||
component_types.append(ADVANCED_COMPONENT_CATEGORY)
|
||||
for category in course_advanced_keys:
|
||||
if category in ADVANCED_COMPONENT_TYPES:
|
||||
# Do I need to allow for boilerplates or just defaults on the class? i.e., can an advanced
|
||||
# have more than one entry in the menu? one for default and others for prefilled boilerplates?
|
||||
try:
|
||||
component_class = XModuleDescriptor.load_class(category)
|
||||
|
||||
component_templates['advanced'].append((
|
||||
component_class.display_name.default or category,
|
||||
category,
|
||||
False,
|
||||
None # don't override default data
|
||||
))
|
||||
except PluginMissingError:
|
||||
# dhm: I got this once but it can happen any time the course author configures
|
||||
# an advanced component which does not exist on the server. This code here merely
|
||||
# prevents any authors from trying to instantiate the non-existent component type
|
||||
# by not showing it in the menu
|
||||
pass
|
||||
else:
|
||||
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
|
||||
|
||||
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
|
||||
for template in templates:
|
||||
category = template.location.category
|
||||
|
||||
if category in course_advanced_keys:
|
||||
category = ADVANCED_COMPONENT_CATEGORY
|
||||
|
||||
if category in component_types:
|
||||
# This is a hack to create categories for different xmodules
|
||||
component_templates[category].append((
|
||||
template.display_name_with_default,
|
||||
template.location.url(),
|
||||
hasattr(template, 'markdown') and template.markdown is not None
|
||||
))
|
||||
|
||||
components = [
|
||||
component.location.url()
|
||||
for component
|
||||
@@ -208,7 +228,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,
|
||||
@@ -218,14 +237,15 @@ def edit_unit(request, location):
|
||||
'subsection': containing_subsection,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
|
||||
'section': containing_section,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'new_unit_category': 'vertical',
|
||||
'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
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
'''
|
||||
@@ -233,14 +253,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")
|
||||
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.get_section_grader_type(location))
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST))
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -254,7 +272,7 @@ def create_draft(request):
|
||||
|
||||
# This clones the existing item location to a draft location (the draft is implicit,
|
||||
# because modulestore is a Draft modulestore)
|
||||
modulestore().clone_item(location, location)
|
||||
modulestore().convert_to_draft(location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
@@ -290,6 +308,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 +318,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 +325,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))
|
||||
|
||||
@@ -2,44 +2,56 @@
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import string # pylint: disable=W0402
|
||||
|
||||
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
|
||||
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
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
from xmodule.html_module import AboutDescriptor
|
||||
__all__ = ['course_index', 'create_new_course', 'course_info',
|
||||
'course_info_updates', 'get_course_settings',
|
||||
'course_config_graders_page',
|
||||
'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,32 +76,28 @@ 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,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
|
||||
'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
|
||||
'new_section_category': 'chapter',
|
||||
'new_subsection_category': 'sequential',
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
|
||||
'new_unit_category': 'vertical',
|
||||
'category': 'vertical'
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
|
||||
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff:
|
||||
"""
|
||||
Create a new course
|
||||
"""
|
||||
if not is_user_in_creator_group(request.user):
|
||||
raise PermissionDenied()
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
# TODO: write a test that creates two courses, one with the factory and
|
||||
# the other with this method, then compare them to make sure they are
|
||||
# equivalent.
|
||||
template = Location(request.POST['template'])
|
||||
org = request.POST.get('org')
|
||||
number = request.POST.get('number')
|
||||
display_name = request.POST.get('display_name')
|
||||
@@ -97,8 +105,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
|
||||
@@ -106,29 +115,31 @@ def create_new_course(request):
|
||||
existing_course = modulestore('direct').get_item(dest_location)
|
||||
except ItemNotFoundError:
|
||||
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)
|
||||
# instantiate the CourseDescriptor and then persist it
|
||||
# note: no system to pass
|
||||
if display_name is None:
|
||||
metadata = {}
|
||||
else:
|
||||
metadata = {'display_name': display_name}
|
||||
modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata)
|
||||
new_course = modulestore('direct').get_item(dest_location)
|
||||
|
||||
# clone a default 'about' module as well
|
||||
|
||||
about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview'])
|
||||
dest_about_location = dest_location._replace(category='about', name='overview')
|
||||
modulestore('direct').clone_item(about_template_location, dest_about_location)
|
||||
|
||||
if display_name is not None:
|
||||
new_course.display_name = display_name
|
||||
|
||||
# set a default start date to now
|
||||
new_course.start = datetime.datetime.now(UTC())
|
||||
# clone a default 'about' overview module as well
|
||||
dest_about_location = dest_location.replace(category='about', name='overview')
|
||||
overview_template = AboutDescriptor.get_template('overview.yaml')
|
||||
modulestore('direct').create_and_save_xmodule(
|
||||
dest_about_location,
|
||||
system=new_course.system,
|
||||
definition_data=overview_template.get('data')
|
||||
)
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
@@ -137,7 +148,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 +164,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)),
|
||||
@@ -165,6 +175,7 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
|
||||
|
||||
@expect_json
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info_updates(request, org, course, provided_id=None):
|
||||
@@ -187,22 +198,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':
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
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 +299,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")
|
||||
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.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder)
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
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 +318,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 +342,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 +398,214 @@ 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 in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
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
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
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
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
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'): # can be either and sometimes django is rewriting one to the other
|
||||
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)
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
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
|
||||
course_module.save()
|
||||
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', {}))
|
||||
|
||||
@@ -13,16 +13,26 @@ from util.json_request import expect_json
|
||||
from ..utils import get_modulestore
|
||||
from .access import has_access
|
||||
from .requests import _xmodule_recurse
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
|
||||
__all__ = ['save_item', 'clone_item', 'delete_item']
|
||||
__all__ = ['save_item', 'create_item', 'delete_item']
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def save_item(request):
|
||||
"""
|
||||
Will carry a json payload with these possible fields
|
||||
:id (required): the id
|
||||
:data (optional): the new value for the data
|
||||
:metadata (optional): new values for the metadata fields.
|
||||
Any whose values are None will be deleted not set to None! Absent ones will be left alone
|
||||
:nullout (optional): which metadata fields to set to None
|
||||
"""
|
||||
# The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a
|
||||
# little smarter and able to pass something more akin to {unset: [field, field]}
|
||||
item_location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
@@ -42,59 +52,98 @@ def save_item(request):
|
||||
children = request.POST['children']
|
||||
store.update_children(item_location, children)
|
||||
|
||||
# cdodge: also commit any metadata which might have been passed along in the
|
||||
# POST from the client, if it is there
|
||||
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
|
||||
# not presented to the end-user for editing. So let's fetch the original and
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
if request.POST.get('metadata') is not None:
|
||||
posted_metadata = request.POST['metadata']
|
||||
# fetch original
|
||||
# cdodge: also commit any metadata which might have been passed along
|
||||
if request.POST.get('nullout') is not None or request.POST.get('metadata') is not None:
|
||||
# the postback is not the complete metadata, as there's system metadata which is
|
||||
# not presented to the end-user for editing. So let's fetch the original and
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
for metadata_key in request.POST.get('nullout', []):
|
||||
# [dhm] see comment on _get_xblock_field
|
||||
_get_xblock_field(existing_item, metadata_key).write_to(existing_item, None)
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
|
||||
for metadata_key, value in posted_metadata.items():
|
||||
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
|
||||
# the intent is to make it None, use the nullout field
|
||||
for metadata_key, value in request.POST.get('metadata', {}).items():
|
||||
# [dhm] see comment on _get_xblock_field
|
||||
field = _get_xblock_field(existing_item, metadata_key)
|
||||
|
||||
if posted_metadata[metadata_key] is None:
|
||||
# remove both from passed in collection as well as the collection read in from the modulestore
|
||||
if metadata_key in existing_item._model_data:
|
||||
del existing_item._model_data[metadata_key]
|
||||
del posted_metadata[metadata_key]
|
||||
if value is None:
|
||||
field.delete_from(existing_item)
|
||||
else:
|
||||
existing_item._model_data[metadata_key] = value
|
||||
|
||||
value = field.from_json(value)
|
||||
field.write_to(existing_item, value)
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
existing_item.save()
|
||||
# commit to datastore
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
store.update_metadata(item_location, own_metadata(existing_item))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
# [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level
|
||||
# fields in xblocks rather than requiring dereference through namespace but we'll need to consider whether there are
|
||||
# plausible use cases for distinct fields w/ same name in different namespaces on the same blocks.
|
||||
# The idea is that consumers of the xblock, and particularly the web client, shouldn't know about our internal
|
||||
# representation (namespaces as means of decorating all modules).
|
||||
# Given top-level access, the calls can simply be setattr(existing_item, field, value) ...
|
||||
# Really, this method should be elsewhere (e.g., xblock). We also need methods for has_value (v is_default)...
|
||||
def _get_xblock_field(xblock, field_name):
|
||||
"""
|
||||
A temporary function to get the xblock field either from the xblock or one of its namespaces by name.
|
||||
:param xblock:
|
||||
:param field_name:
|
||||
"""
|
||||
def find_field(fields):
|
||||
for field in fields:
|
||||
if field.name == field_name:
|
||||
return field
|
||||
|
||||
found = find_field(xblock.fields)
|
||||
if found:
|
||||
return found
|
||||
for namespace in xblock.namespaces:
|
||||
found = find_field(getattr(xblock, namespace).fields)
|
||||
if found:
|
||||
return found
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def clone_item(request):
|
||||
def create_item(request):
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
template = Location(request.POST['template'])
|
||||
category = request.POST['category']
|
||||
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
if not has_access(request.user, parent_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
parent = get_modulestore(template).get_item(parent_location)
|
||||
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
|
||||
parent = get_modulestore(category).get_item(parent_location)
|
||||
dest_location = parent_location.replace(category=category, name=uuid4().hex)
|
||||
|
||||
new_item = get_modulestore(template).clone_item(template, dest_location)
|
||||
# get the metadata, display_name, and definition from the request
|
||||
metadata = {}
|
||||
data = None
|
||||
template_id = request.POST.get('boilerplate')
|
||||
if template_id is not None:
|
||||
clz = XModuleDescriptor.load_class(category)
|
||||
if clz is not None:
|
||||
template = clz.get_template(template_id)
|
||||
if template is not None:
|
||||
metadata = template.get('metadata', {})
|
||||
data = template.get('data')
|
||||
|
||||
# replace the display name with an optional parameter passed in from the caller
|
||||
if display_name is not None:
|
||||
new_item.display_name = display_name
|
||||
metadata['display_name'] = display_name
|
||||
|
||||
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
|
||||
get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data,
|
||||
metadata=metadata, system=parent.system)
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
|
||||
if category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule, save_module # pylint: disable=F0401
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
@@ -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']
|
||||
|
||||
@@ -44,6 +47,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
# Let the module handle the AJAX
|
||||
try:
|
||||
ajax_return = instance.handle_ajax(dispatch, request.POST)
|
||||
# Save any module data that has changed to the underlying KeyValueStore
|
||||
instance.save()
|
||||
|
||||
except NotFoundError:
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
@@ -65,7 +70,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 +98,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 +111,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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -160,6 +168,11 @@ def load_preview_module(request, preview_id, descriptor):
|
||||
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
)
|
||||
|
||||
module.get_html = save_module(
|
||||
module.get_html,
|
||||
module
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,27 +2,27 @@ from xblock.runtime import KeyValueStore, InvalidScopeError
|
||||
|
||||
|
||||
class SessionKeyValueStore(KeyValueStore):
|
||||
def __init__(self, request, model_data):
|
||||
self._model_data = model_data
|
||||
def __init__(self, request, descriptor_model_data):
|
||||
self._descriptor_model_data = descriptor_model_data
|
||||
self._session = request.session
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
return self._model_data[key.field_name]
|
||||
return self._descriptor_model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
return self._session[tuple(key)]
|
||||
|
||||
def set(self, key, value):
|
||||
try:
|
||||
self._model_data[key.field_name] = value
|
||||
self._descriptor_model_data[key.field_name] = value
|
||||
except (KeyError, InvalidScopeError):
|
||||
self._session[tuple(key)] = value
|
||||
|
||||
def delete(self, key):
|
||||
try:
|
||||
del self._model_data[key.field_name]
|
||||
del self._descriptor_model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
del self._session[tuple(key)]
|
||||
|
||||
def has(self, key):
|
||||
return key in self._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']
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages']
|
||||
|
||||
|
||||
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.
|
||||
@@ -74,6 +76,9 @@ def reorder_static_tabs(request):
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
return HttpResponse()
|
||||
|
||||
@@ -82,7 +87,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 +114,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,10 +128,5 @@ 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,
|
||||
})
|
||||
|
||||
|
||||
def edit_static(request, org, course, coursename):
|
||||
return render_to_response('edit-static-page.html', {})
|
||||
|
||||
@@ -2,34 +2,20 @@ from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from django.core.context_processors import csrf
|
||||
|
||||
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 course_creators.views import get_course_creator_status, add_user_with_status_unrequested, user_requested_access
|
||||
|
||||
from .access import has_access
|
||||
from .requests import create_json_response
|
||||
|
||||
|
||||
def user_author_string(user):
|
||||
'''Get an author string for commits by this user. Format:
|
||||
first last <email@email.com>.
|
||||
|
||||
If the first and last names are blank, uses the username instead.
|
||||
Assumes that the email is not blank.
|
||||
'''
|
||||
f = user.first_name
|
||||
l = user.last_name
|
||||
if f == '' and l == '':
|
||||
f = user.username
|
||||
return '{first} {last} <{email}>'.format(first=f,
|
||||
last=l,
|
||||
email=user.email)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -43,6 +29,7 @@ def index(request):
|
||||
# filter out courses that we don't have access too
|
||||
def course_filter(course):
|
||||
return (has_access(request.user, course.location)
|
||||
# TODO remove this condition when templates purged from db
|
||||
and course.location.course != 'templates'
|
||||
and course.location.org != ''
|
||||
and course.location.course != ''
|
||||
@@ -50,16 +37,27 @@ def index(request):
|
||||
courses = filter(course_filter, courses)
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
|
||||
'courses': [(course.display_name,
|
||||
get_url_reverse('CourseOutline', course),
|
||||
get_lms_link_for_item(course.location, course_id=course.location.course_id))
|
||||
for course in courses],
|
||||
'user': request.user,
|
||||
'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff
|
||||
'request_course_creator_url': reverse('request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
'csrf': csrf(request)['csrf_token']
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def request_course_creator(request):
|
||||
"""
|
||||
User has requested course creation access.
|
||||
"""
|
||||
user_requested_access(request.user)
|
||||
return JsonResponse({"Status": "OK"})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def manage_users(request, location):
|
||||
@@ -73,7 +71,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 +88,17 @@ 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)
|
||||
|
||||
# remove leading/trailing whitespace if necessary
|
||||
email = email.strip()
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
@@ -104,16 +108,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 '{email}'.").format(email=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 {email} has registered but has not yet activated his/her account.').format(email=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 +145,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 '{email}'.").format(email=email),
|
||||
}
|
||||
return JsonResponse(msg, 404)
|
||||
|
||||
# make sure we're not removing ourselves
|
||||
if user.id == request.user.id:
|
||||
@@ -141,4 +157,29 @@ 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"})
|
||||
|
||||
|
||||
def _get_course_creator_status(user):
|
||||
"""
|
||||
Helper method for returning the course creator status for a particular user,
|
||||
taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP.
|
||||
|
||||
If the user passed in has not previously visited the index page, it will be
|
||||
added with status 'unrequested' if the course creator group is in use.
|
||||
"""
|
||||
if user.is_staff:
|
||||
course_creator_status = 'granted'
|
||||
elif settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False):
|
||||
course_creator_status = 'disallowed_for_this_site'
|
||||
elif settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False):
|
||||
course_creator_status = get_course_creator_status(user)
|
||||
if course_creator_status is None:
|
||||
# User not grandfathered in as an existing user, has not previously visited the dashboard page.
|
||||
# Add the user to the course creator admin table with status 'unrequested'.
|
||||
add_user_with_status_unrequested(user)
|
||||
course_creator_status = get_course_creator_status(user)
|
||||
else:
|
||||
course_creator_status = 'granted'
|
||||
|
||||
return course_creator_status
|
||||
|
||||
90
cms/djangoapps/course_creators/admin.py
Normal file
90
cms/djangoapps/course_creators/admin.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
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.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("studio.coursecreatoradmin")
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
user = kwargs['user']
|
||||
updated_state = kwargs['state']
|
||||
update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED)
|
||||
|
||||
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL','')
|
||||
context = {'studio_request_email': studio_request_email}
|
||||
|
||||
subject = render_to_string('emails/course_creator_subject.txt', context)
|
||||
subject = ''.join(subject.splitlines())
|
||||
if updated_state == CourseCreator.GRANTED:
|
||||
message_template = 'emails/course_creator_granted.txt'
|
||||
elif updated_state == CourseCreator.DENIED:
|
||||
message_template = 'emails/course_creator_denied.txt'
|
||||
else:
|
||||
# changed to unrequested or pending
|
||||
message_template = 'emails/course_creator_revoked.txt'
|
||||
message = render_to_string(message_template, context)
|
||||
|
||||
try:
|
||||
user.email_user(subject, message, studio_request_email)
|
||||
except:
|
||||
log.warning("Unable to send course creator status e-mail to %s", user.email)
|
||||
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']
|
||||
76
cms/djangoapps/course_creators/models.py
Normal file
76
cms/djangoapps/course_creators/models.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
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"{0} | {1} [{2}]".format(self.user, self.state, self.state_changed)
|
||||
|
||||
|
||||
@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 fire event to update course creator group, if appropriate.
|
||||
"""
|
||||
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:
|
||||
# If either old or new state is 'granted', we must manipulate the course creator
|
||||
# group maintained by authz. That requires staff permissions (stored admin).
|
||||
if instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED:
|
||||
assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group'
|
||||
update_creator_state.send(
|
||||
sender=sender,
|
||||
caller=instance.admin,
|
||||
user=instance.user,
|
||||
state=instance.state
|
||||
)
|
||||
|
||||
instance.state_changed = timezone.now()
|
||||
instance.orig_state = instance.state
|
||||
instance.save()
|
||||
108
cms/djangoapps/course_creators/tests/test_admin.py
Normal file
108
cms/djangoapps/course_creators/tests/test_admin.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def mock_render_to_string(template_name, context):
|
||||
"""Return a string that encodes template_name and context"""
|
||||
return str((template_name, context))
|
||||
|
||||
|
||||
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())
|
||||
|
||||
@mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
@mock.patch('django.contrib.auth.models.User.email_user')
|
||||
def test_change_status(self, email_user):
|
||||
"""
|
||||
Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent.
|
||||
"""
|
||||
STUDIO_REQUEST_EMAIL = 'mark@marky.mark'
|
||||
|
||||
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))
|
||||
|
||||
context = {'studio_request_email': STUDIO_REQUEST_EMAIL}
|
||||
if state == CourseCreator.GRANTED:
|
||||
template = 'emails/course_creator_granted.txt'
|
||||
elif state == CourseCreator.DENIED:
|
||||
template = 'emails/course_creator_denied.txt'
|
||||
else:
|
||||
template = 'emails/course_creator_revoked.txt'
|
||||
email_user.assert_called_with(
|
||||
mock_render_to_string('emails/course_creator_subject.txt', context),
|
||||
mock_render_to_string(template, context),
|
||||
STUDIO_REQUEST_EMAIL
|
||||
)
|
||||
|
||||
with mock.patch.dict(
|
||||
'django.conf.settings.MITX_FEATURES',
|
||||
{
|
||||
"ENABLE_CREATOR_GROUP": True,
|
||||
"STUDIO_REQUEST_EMAIL": STUDIO_REQUEST_EMAIL
|
||||
}):
|
||||
|
||||
# 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))
|
||||
92
cms/djangoapps/course_creators/tests/test_views.py
Normal file
92
cms/djangoapps/course_creators/tests/test_views.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
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, user_requested_access
|
||||
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 any method changing the course creator authz group must be called with staff permissions.
|
||||
"""
|
||||
with self.assertRaises(PermissionDenied):
|
||||
add_user_with_status_granted(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.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.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))
|
||||
|
||||
def test_user_requested_access(self):
|
||||
add_user_with_status_unrequested(self.user)
|
||||
self.assertEqual('unrequested', get_course_creator_status(self.user))
|
||||
user_requested_access(self.user)
|
||||
self.assertEqual('pending', get_course_creator_status(self.user))
|
||||
|
||||
def test_user_requested_already_granted(self):
|
||||
add_user_with_status_granted(self.admin, self.user)
|
||||
self.assertEqual('granted', get_course_creator_status(self.user))
|
||||
# Will not "downgrade" to pending because that would require removing the
|
||||
# user from the authz course creator group (and that can only be done by an admin).
|
||||
user_requested_access(self.user)
|
||||
self.assertEqual('granted', get_course_creator_status(self.user))
|
||||
|
||||
def test_add_user_unrequested_staff(self):
|
||||
# Users marked as is_staff will not be added to the course creator table.
|
||||
add_user_with_status_unrequested(self.admin)
|
||||
self.assertIsNone(get_course_creator_status(self.admin))
|
||||
|
||||
def test_add_user_granted_staff(self):
|
||||
# Users marked as is_staff will not be added to the course creator table.
|
||||
add_user_with_status_granted(self.admin, self.admin)
|
||||
self.assertIsNone(get_course_creator_status(self.admin))
|
||||
99
cms/djangoapps/course_creators/views.py
Normal file
99
cms/djangoapps/course_creators/views.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Methods for interacting programmatically with the user creator table.
|
||||
"""
|
||||
from course_creators.models import CourseCreator
|
||||
|
||||
from auth.authz import add_user_to_creator_group, remove_user_from_creator_group
|
||||
|
||||
|
||||
def add_user_with_status_unrequested(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).
|
||||
|
||||
If the user is marked as is_staff, this method is a no-op (user
|
||||
will not be added to table).
|
||||
"""
|
||||
_add_user(user, CourseCreator.UNREQUESTED)
|
||||
|
||||
|
||||
def add_user_with_status_granted(caller, user):
|
||||
"""
|
||||
Adds a user to the course creator table with status 'granted'.
|
||||
|
||||
If appropriate, this method also adds the user to the course creator group maintained by authz.py.
|
||||
Caller must have staff permissions.
|
||||
|
||||
If the user is already in the table, this method is a no-op
|
||||
(state will not be changed).
|
||||
|
||||
If the user is marked as is_staff, this method is a no-op (user
|
||||
will not be added to table, nor added to authz.py group).
|
||||
"""
|
||||
if _add_user(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 user_requested_access(user):
|
||||
"""
|
||||
User has requested course creator access.
|
||||
|
||||
This changes the user state to CourseCreator.PENDING, unless the user
|
||||
state is already CourseCreator.GRANTED, in which case this method is a no-op.
|
||||
"""
|
||||
user = CourseCreator.objects.get(user=user)
|
||||
if user.state != CourseCreator.GRANTED:
|
||||
user.state = CourseCreator.PENDING
|
||||
user.save()
|
||||
|
||||
|
||||
def _add_user(user, state):
|
||||
"""
|
||||
Adds a user to the course creator table with the specified state.
|
||||
|
||||
Returns True if user was added to table, else False.
|
||||
|
||||
If the user is already in the table, this method is a no-op
|
||||
(state will not be changed, method will return False).
|
||||
|
||||
If the user is marked as is_staff, this method is a no-op (False will be returned).
|
||||
"""
|
||||
if not user.is_staff and CourseCreator.objects.filter(user=user).count() == 0:
|
||||
entry = CourseCreator(user=user, state=state)
|
||||
entry.save()
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -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)
|
||||
|
||||
@@ -122,6 +122,10 @@ class CourseDetails(object):
|
||||
descriptor.enrollment_end = converted
|
||||
|
||||
if dirty:
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
|
||||
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
|
||||
|
||||
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
|
||||
@@ -153,9 +157,9 @@ class CourseDetails(object):
|
||||
if not raw_video:
|
||||
return None
|
||||
|
||||
keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
|
||||
keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
|
||||
if keystring_matcher is None:
|
||||
keystring_matcher = re.search('<?=\d+:[a-zA-Z0-9_-]+', raw_video)
|
||||
keystring_matcher = re.search(r'<?=\d+:[a-zA-Z0-9_-]+', raw_video)
|
||||
|
||||
if keystring_matcher:
|
||||
return keystring_matcher.group(0)
|
||||
|
||||
@@ -7,9 +7,12 @@ class CourseGradingModel(object):
|
||||
"""
|
||||
Basically a DAO and Model combo for CRUD operations pertaining to grading policy.
|
||||
"""
|
||||
# Within this class, allow access to protected members of client classes.
|
||||
# This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
|
||||
# pylint: disable=W0212
|
||||
def __init__(self, course_descriptor):
|
||||
self.course_location = course_descriptor.location
|
||||
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
|
||||
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
|
||||
self.grade_cutoffs = course_descriptor.grade_cutoffs
|
||||
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
|
||||
|
||||
@@ -81,15 +84,18 @@ class CourseGradingModel(object):
|
||||
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
|
||||
Probably not the usual path for updates as it's too coarse grained.
|
||||
"""
|
||||
course_location = jsondict['course_location']
|
||||
course_location = Location(jsondict['course_location'])
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
|
||||
|
||||
descriptor.raw_grader = graders_parsed
|
||||
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
|
||||
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data)
|
||||
|
||||
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
|
||||
|
||||
return CourseGradingModel.fetch(course_location)
|
||||
@@ -116,6 +122,9 @@ class CourseGradingModel(object):
|
||||
else:
|
||||
descriptor.raw_grader.append(grader)
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
|
||||
@@ -131,6 +140,10 @@ class CourseGradingModel(object):
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.grade_cutoffs = cutoffs
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
return cutoffs
|
||||
@@ -156,6 +169,10 @@ class CourseGradingModel(object):
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.lms.graceperiod = grace_timedelta
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
@@ -172,23 +189,12 @@ class CourseGradingModel(object):
|
||||
del descriptor.raw_grader[index]
|
||||
# force propagation to definition
|
||||
descriptor.raw_grader = descriptor.raw_grader
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
# NOTE cannot delete cutoffs. May be useful to reset
|
||||
@staticmethod
|
||||
def delete_cutoffs(course_location, cutoffs):
|
||||
"""
|
||||
Resets the cutoffs to the defaults
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS']
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
return descriptor.grade_cutoffs
|
||||
|
||||
@staticmethod
|
||||
def delete_grace_period(course_location):
|
||||
"""
|
||||
@@ -199,6 +205,10 @@ class CourseGradingModel(object):
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
del descriptor.lms.graceperiod
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
@@ -209,7 +219,7 @@ class CourseGradingModel(object):
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
|
||||
"location": location,
|
||||
"id": 99 # just an arbitrary value to
|
||||
"id": 99 # just an arbitrary value to
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -225,6 +235,9 @@ class CourseGradingModel(object):
|
||||
del descriptor.lms.format
|
||||
del descriptor.lms.graded
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
@@ -232,7 +245,7 @@ class CourseGradingModel(object):
|
||||
# 5 hours 59 minutes 59 seconds => converted to iso format
|
||||
rawgrace = descriptor.lms.graceperiod
|
||||
if rawgrace:
|
||||
hours_from_days = rawgrace.days*24
|
||||
hours_from_days = rawgrace.days * 24
|
||||
seconds = rawgrace.seconds
|
||||
hours_from_seconds = int(seconds / 3600)
|
||||
hours = hours_from_days + hours_from_seconds
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.core import Scope
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
@@ -62,21 +61,24 @@ 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:
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
|
||||
@@ -98,6 +100,10 @@ class CourseMetadata(object):
|
||||
elif hasattr(descriptor.lms, key):
|
||||
delattr(descriptor.lms, key)
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
|
||||
|
||||
@@ -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'
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'direct': {
|
||||
@@ -36,23 +42,38 @@ MODULESTORE = {
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'draft': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
}
|
||||
}
|
||||
|
||||
CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'OPTIONS': {
|
||||
'host': 'localhost',
|
||||
'db': 'acceptance_xcontent_%s' % seed(),
|
||||
},
|
||||
# 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",
|
||||
'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'
|
||||
@@ -80,6 +80,8 @@ CELERY_QUEUES = {
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
|
||||
ENV_TOKENS = json.load(env_file)
|
||||
|
||||
EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND)
|
||||
EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None)
|
||||
LMS_BASE = ENV_TOKENS.get('LMS_BASE')
|
||||
# Note that MITX_FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file.
|
||||
|
||||
@@ -90,6 +92,7 @@ LOG_DIR = ENV_TOKENS['LOG_DIR']
|
||||
CACHES = ENV_TOKENS['CACHES']
|
||||
|
||||
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE)
|
||||
|
||||
# allow for environments to specify what cookie name our login subsystem should use
|
||||
# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can
|
||||
@@ -105,6 +108,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)
|
||||
|
||||
@@ -118,6 +123,10 @@ LOGGING = get_logger_config(LOG_DIR,
|
||||
debug=False,
|
||||
service_variant=SERVICE_VARIANT)
|
||||
|
||||
#theming start:
|
||||
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX')
|
||||
|
||||
|
||||
################ SECURE AUTH ITEMS ###############################
|
||||
# Secret things: passwords, access keys, etc.
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
|
||||
@@ -142,10 +151,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)
|
||||
|
||||
@@ -21,7 +21,7 @@ Longer TODO:
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
# pylint: disable=W0401, W0611, W0614
|
||||
|
||||
import sys
|
||||
import lms.envs.common
|
||||
@@ -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': '',
|
||||
|
||||
|
||||
# email address for studio staff (eg to request course creation)
|
||||
'STUDIO_REQUEST_EMAIL': '',
|
||||
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
|
||||
|
||||
# Segment.io - must explicitly turn it on for production
|
||||
'SEGMENT_IO': False,
|
||||
|
||||
@@ -54,13 +54,14 @@ 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
|
||||
|
||||
# needed to use lms student app
|
||||
GENERATE_RANDOM_USER_CREDENTIALS = False
|
||||
|
||||
|
||||
############################# SET PATH INFORMATION #############################
|
||||
PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/cms
|
||||
@@ -104,9 +105,12 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
'django.core.context_processors.static',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.contrib.auth.context_processors.auth', # this is required for admin
|
||||
'django.core.context_processors.csrf', # necessary for csrf protection
|
||||
)
|
||||
|
||||
# add csrf support unless disabled for load testing
|
||||
if not MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING'):
|
||||
TEMPLATE_CONTEXT_PROCESSORS += ('django.core.context_processors.csrf',) # necessary for csrf protection
|
||||
|
||||
LMS_BASE = None
|
||||
|
||||
#################### CAPA External Code Evaluation #############################
|
||||
@@ -138,7 +142,7 @@ MIDDLEWARE_CLASSES = (
|
||||
'django.middleware.cache.UpdateCacheMiddleware',
|
||||
'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',
|
||||
@@ -153,6 +157,10 @@ MIDDLEWARE_CLASSES = (
|
||||
'django.middleware.transaction.TransactionMiddleware'
|
||||
)
|
||||
|
||||
# add in csrf middleware unless disabled for load testing
|
||||
if not MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING'):
|
||||
MIDDLEWARE_CLASSES = MIDDLEWARE_CLASSES + ('django.middleware.csrf.CsrfViewMiddleware',)
|
||||
|
||||
############################ SIGNAL HANDLERS ################################
|
||||
# This is imported to register the exception signal handling that logs exceptions
|
||||
import monitoring.exceptions # noqa
|
||||
@@ -238,7 +246,8 @@ 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/views/assets.js'],
|
||||
'js/models/textbook.js', 'js/views/textbook.js',
|
||||
'js/views/assets.js', 'js/utility.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
},
|
||||
@@ -320,6 +329,7 @@ INSTALLED_APPS = (
|
||||
'django.contrib.messages',
|
||||
'djcelery',
|
||||
'south',
|
||||
'method_override',
|
||||
|
||||
# Monitor the status of services
|
||||
'service_status',
|
||||
@@ -327,6 +337,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 +352,9 @@ INSTALLED_APPS = (
|
||||
|
||||
# comment common
|
||||
'django_comment_common',
|
||||
|
||||
# for course creator table
|
||||
'django.contrib.admin'
|
||||
)
|
||||
|
||||
################# EDX MARKETING SITE ##################################
|
||||
@@ -357,3 +371,5 @@ MKTG_URL_LINK_MAP = {
|
||||
'HONOR': 'honor',
|
||||
'PRIVACY': 'privacy_edx',
|
||||
}
|
||||
|
||||
COURSES_WITH_UNSAFE_CODE = []
|
||||
|
||||
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',
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user