Merge branch 'master' into jkarni/docs-merge

Conflicts:
	docs/source/conf.py
This commit is contained in:
Julian Arni
2013-07-30 10:23:35 -04:00
1075 changed files with 42550 additions and 25704 deletions

1
.gitattributes vendored Normal file
View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

10
LICENSE
View File

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

226
README.md
View File

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

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

View File

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

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

View File

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

View File

@@ -0,0 +1,22 @@
from django.core.files.uploadhandler import FileUploadHandler
import time
class DebugFileUploader(FileUploadHandler):
def __init__(self, request=None):
super(DebugFileUploader, self).__init__(request)
self.count = 0
def receive_data_chunk(self, raw_data, start):
time.sleep(1)
self.count = self.count + len(raw_data)
fail_at = None
if 'fail_at' in self.request.GET:
fail_at = int(self.request.GET.get('fail_at'))
if fail_at and self.count > fail_at:
raise Exception('Triggered fail')
return raw_data
def file_complete(self, file_size):
return None

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
Feature: Textbooks
Scenario: No textbooks
Given I have opened a new course in Studio
When I go to the textbooks page
Then I should see a message telling me to create a new textbook
Scenario: Create a textbook
Given I have opened a new course in Studio
And I go to the textbooks page
When I click on the New Textbook button
And I name my textbook "Economics"
And I name the first chapter "Chapter 1"
And I click the Upload Asset link for the first chapter
And I upload the textbook "textbook.pdf"
And I wait for "2" seconds
And I save the textbook
Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf"
And I reload the page
Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf"
Scenario: Create a textbook with multiple chapters
Given I have opened a new course in Studio
And I go to the textbooks page
When I click on the New Textbook button
And I name my textbook "History"
And I name the first chapter "Britain"
And I type in "britain.pdf" for the first chapter asset
And I click Add a Chapter
And I name the second chapter "America"
And I type in "america.pdf" for the second chapter asset
And I save the textbook
Then I should see a textbook named "History" with 2 chapters
And I click the textbook chapters
Then I should see a textbook named "History" with 2 chapters
And the first chapter should be named "Britain"
And the first chapter should have an asset called "britain.pdf"
And the second chapter should be named "America"
And the second chapter should have an asset called "america.pdf"
And I reload the page
Then I should see a textbook named "History" with 2 chapters
And I click the textbook chapters
Then I should see a textbook named "History" with 2 chapters
And the first chapter should be named "Britain"
And the first chapter should have an asset called "britain.pdf"
And the second chapter should be named "America"
And the second chapter should have an asset called "america.pdf"

View File

@@ -0,0 +1,121 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from django.conf import settings
import os
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
@step(u'I go to the textbooks page')
def go_to_uploads(_step):
world.click_course_content()
menu_css = 'li.nav-course-courseware-textbooks'
world.css_find(menu_css).click()
@step(u'I should see a message telling me to create a new textbook')
def assert_create_new_textbook_msg(_step):
css = ".wrapper-content .no-textbook-content"
assert world.is_css_present(css)
no_tb = world.css_find(css)
assert "You haven't added any textbooks" in no_tb.text
@step(u'I upload the textbook "([^"]*)"$')
def upload_file(_step, file_name):
file_css = '.upload-dialog input[type=file]'
upload = world.css_find(file_css)
# uploading the file itself
path = os.path.join(TEST_ROOT, 'uploads', file_name)
upload._element.send_keys(os.path.abspath(path))
button_css = ".upload-dialog .action-upload"
world.css_click(button_css)
@step(u'I click (on )?the New Textbook button')
def click_new_textbook(_step, on):
button_css = ".nav-actions .new-button"
button = world.css_find(button_css)
button.click()
@step(u'I name my textbook "([^"]*)"')
def name_textbook(_step, name):
input_css = ".textbook input[name=textbook-name]"
world.css_fill(input_css, name)
@step(u'I name the (first|second|third) chapter "([^"]*)"')
def name_chapter(_step, ordinal, name):
index = ["first", "second", "third"].index(ordinal)
input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1)
world.css_fill(input_css, name)
@step(u'I type in "([^"]*)" for the (first|second|third) chapter asset')
def asset_chapter(_step, name, ordinal):
index = ["first", "second", "third"].index(ordinal)
input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1)
world.css_fill(input_css, name)
@step(u'I click the Upload Asset link for the (first|second|third) chapter')
def click_upload_asset(_step, ordinal):
index = ["first", "second", "third"].index(ordinal)
button_css = ".textbook .chapter{i} .action-upload".format(i=index+1)
world.css_click(button_css)
@step(u'I click Add a Chapter')
def click_add_chapter(_step):
button_css = ".textbook .action-add-chapter"
world.css_click(button_css)
@step(u'I save the textbook')
def save_textbook(_step):
submit_css = "form.edit-textbook button[type=submit]"
world.css_click(submit_css)
@step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"')
def check_textbook(_step, textbook_name, chapter_name):
title = world.css_find(".textbook h3.textbook-title")
chapter = world.css_find(".textbook .wrap-textbook p")
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
assert chapter.text == chapter_name, "{} != {}".format(chapter.text, chapter_name)
@step(u'I should see a textbook named "([^"]*)" with (\d+) chapters')
def check_textbook_chapters(_step, textbook_name, num_chapters_str):
num_chapters = int(num_chapters_str)
title = world.css_find(".textbook .view-textbook h3.textbook-title")
toggle = world.css_find(".textbook .view-textbook .chapter-toggle")
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
assert toggle.text == "{num} PDF Chapters".format(num=num_chapters), \
"Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle.text)
@step(u'I click the textbook chapters')
def click_chapters(_step):
world.css_click(".textbook a.chapter-toggle")
@step(u'the (first|second|third) chapter should be named "([^"]*)"')
def check_chapter_name(_step, ordinal, name):
index = ["first", "second", "third"].index(ordinal)
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
element = chapter.find_by_css(".chapter-name")
assert element.text == name, "Expected chapter named {expected}, found chapter named {actual}".format(
expected=name, actual=element.text)
@step(u'the (first|second|third) chapter should have an asset called "([^"]*)"')
def check_chapter_asset(_step, ordinal, name):
index = ["first", "second", "third"].index(ordinal)
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
element = chapter.find_by_css(".chapter-asset-path")
assert element.text == name, "Expected chapter with asset {expected}, found chapter with asset {actual}".format(
expected=name, actual=element.text)

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
"""
Unit tests for the asset upload endpoint.
"""
import json
from datetime import datetime
from io import BytesIO
from pytz import UTC
from unittest import TestCase, skip
from .utils import CourseTestCase
from django.core.urlresolvers import reverse
from contentstore.views import assets
class AssetsTestCase(CourseTestCase):
def setUp(self):
super(AssetsTestCase, self).setUp()
self.url = reverse("asset_index", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
})
def test_basic(self):
resp = self.client.get(self.url)
self.assertEquals(resp.status_code, 200)
def test_json(self):
resp = self.client.get(
self.url,
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEquals(resp.status_code, 200)
content = json.loads(resp.content)
self.assertIsInstance(content, list)
class UploadTestCase(CourseTestCase):
"""
Unit tests for uploading a file
"""
def setUp(self):
super(UploadTestCase, self).setUp()
self.url = reverse("upload_asset", kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'coursename': self.course.location.name,
})
@skip("CorruptGridFile error on continuous integration server")
def test_happy_path(self):
file = BytesIO("sample content")
file.name = "sample.txt"
resp = self.client.post(self.url, {"name": "my-name", "file": file})
self.assert2XX(resp.status_code)
def test_no_file(self):
resp = self.client.post(self.url, {"name": "file.txt"})
self.assert4XX(resp.status_code)
def test_get(self):
resp = self.client.get(self.url)
self.assertEquals(resp.status_code, 405)
class AssetsToJsonTestCase(TestCase):
"""
Unit tests for transforming the results of a database call into something
we can send out to the client via JSON.
"""
def test_basic(self):
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
asset = {
"displayname": "foo",
"chunkSize": 512,
"filename": "foo.png",
"length": 100,
"uploadDate": upload_date,
"_id": {
"course": "course",
"org": "org",
"revision": 12,
"category": "category",
"name": "name",
"tag": "tag",
}
}
output = assets.assets_to_json_dict([asset])
self.assertEquals(len(output), 1)
compare = output[0]
self.assertEquals(compare["name"], "foo")
self.assertEquals(compare["path"], "foo.png")
self.assertEquals(compare["uploaded"], upload_date.isoformat())
self.assertEquals(compare["id"], "/tag/org/course/12/category/name")

View File

@@ -1,10 +1,10 @@
""" Unit tests for checklist methods in views.py. """
from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
import json
from .utils import CourseTestCase
class ChecklistTestCase(CourseTestCase):
@@ -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)

View File

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

View File

@@ -6,8 +6,6 @@ import json
import copy
import mock
from django.contrib.auth.models import User
from django.test.client import Client
from django.core.urlresolvers import reverse
from django.utils.timezone import UTC
from django.test.utils import override_settings
@@ -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)

View File

@@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase):
'''Go through each interface and ensure it works.'''
# first get the update to force the creation
url = reverse('course_info',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'name': self.course_location.name})
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name})
self.client.get(url)
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
@@ -20,8 +20,8 @@ class CourseUpdateTest(CourseTestCase):
payload = {'content': content,
'date': 'January 8, 2013'}
url = reverse('course_info_json',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json")
@@ -31,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)

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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))

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

View File

@@ -10,11 +10,13 @@ from pytz import UTC
class ContentStoreTestCase(ModuleStoreTestCase):
def _login(self, email, pw):
"""Login. View should always return 200. The success/fail is in the
returned json"""
def _login(self, email, password):
"""
Login. View should always return 200. The success/fail is in the
returned json
"""
resp = self.client.post(reverse('login_post'),
{'email': email, 'password': pw})
{'email': email, 'password': password})
self.assertEqual(resp.status_code, 200)
return resp
@@ -25,12 +27,12 @@ class ContentStoreTestCase(ModuleStoreTestCase):
self.assertTrue(data['success'])
return resp
def _create_account(self, username, email, pw):
def _create_account(self, username, email, password):
"""Try to create an account. No error checking"""
resp = self.client.post('/create_account', {
'username': username,
'email': email,
'password': pw,
'password': password,
'location': 'home',
'language': 'Franglish',
'name': 'Fred Weasley',
@@ -39,9 +41,9 @@ class ContentStoreTestCase(ModuleStoreTestCase):
})
return resp
def create_account(self, username, email, pw):
def create_account(self, username, email, password):
"""Create the account and check that it worked"""
resp = self._create_account(username, email, pw)
resp = self._create_account(username, email, password)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertEqual(data['success'], True)
@@ -88,7 +90,7 @@ class AuthTestCase(ContentStoreTestCase):
reverse('signup'),
)
for page in pages:
print "Checking '{0}'".format(page)
print("Checking '{0}'".format(page))
self.check_page_get(page, 200)
def test_create_account_errors(self):
@@ -146,17 +148,17 @@ class AuthTestCase(ContentStoreTestCase):
self.client = Client()
# Not logged in. Should redirect to login.
print 'Not logged in'
print('Not logged in')
for page in auth_pages:
print "Checking '{0}'".format(page)
print("Checking '{0}'".format(page))
self.check_page_get(page, expected=302)
# Logged in should work.
self.login(self.email, self.pw)
print 'Logged in'
print('Logged in')
for page in simple_auth_pages:
print "Checking '{0}'".format(page)
print("Checking '{0}'".format(page))
self.check_page_get(page, expected=200)
def test_index_auth(self):

View File

@@ -6,6 +6,10 @@ import json
from student.models import Registration
from django.contrib.auth.models import User
from django.test.client import Client
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
def parse_json(response):
@@ -21,3 +25,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',
)

View File

@@ -1,3 +1,5 @@
#pylint: disable=E1103, E1101
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
@@ -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()

View File

@@ -1,5 +1,7 @@
# pylint: disable=W0401, W0511
"All view functions for contentstore, broken out into submodules"
# Disable warnings about import from wildcard
# All files below declare exports with __all__
from .assets import *

View File

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

View File

@@ -13,6 +13,7 @@ from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile
from django.views.decorators.http import require_POST, 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': ''
})

View File

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

View File

@@ -4,6 +4,7 @@ from collections import defaultdict
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied
from django_future.csrf import ensure_csrf_cookie
from django.conf import settings
@@ -15,7 +16,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.util.date_utils import get_default_time_display
from xblock.core import Scope
from util.json_request import expect_json
from util.json_request import expect_json, JsonResponse
from contentstore.module_info_model import get_module_info, set_module_info
from contentstore.utils import get_modulestore, get_lms_link_for_item, \
@@ -23,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))

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import json
from django.http import HttpResponse
from mitxmako.shortcuts import render_to_string, render_to_response
@@ -24,28 +22,6 @@ def event(request):
return HttpResponse(status=204)
def get_request_method(request):
"""
Using HTTP_X_HTTP_METHOD_OVERRIDE, in the request metadata, determine
what type of request came from the client, and return it.
"""
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
return real_method
def create_json_response(errmsg=None):
if errmsg is not None:
resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg': errmsg}))
else:
resp = HttpResponse(json.dumps({'Status': 'OK'}))
return resp
def render_from_lms(template_name, dictionary, context=None, namespace='main'):
"""
Render a template using the LMS MAKO_TEMPLATES

View File

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

View File

@@ -10,18 +10,20 @@ from mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.django import modulestore
from ..utils import get_course_for_item
from ..utils import get_course_for_item, get_modulestore
from .access import get_location_and_verify_access
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static']
__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', {})

View File

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

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

View File

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

View File

@@ -0,0 +1,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()

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

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

View 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

View File

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

View File

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

View File

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

View File

@@ -16,19 +16,25 @@ DEBUG = True
# Disable warnings for acceptance tests, to make the logs readable
import logging
logging.disable(logging.ERROR)
import os
import random
def seed():
return os.getppid()
MODULESTORE_OPTIONS = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'acceptance_modulestore',
'db': 'acceptance_xmodule',
'collection': 'acceptance_modulestore_%s' % seed(),
'fs_root': TEST_ROOT / "data",
'render_template': 'mitxmako.shortcuts.render_to_string'
'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'

View File

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

View File

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

View File

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

@@ -0,0 +1,8 @@
#pylint: disable=W0614, W0401
from .dev import *
FILE_UPLOAD_HANDLERS = (
'contentstore.debug_file_uploader.DebugFileUploader',
'django.core.files.uploadhandler.MemoryFileUploadHandler',
'django.core.files.uploadhandler.TemporaryFileUploadHandler',
)

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