Merge branch 'master' into feature/marco/discussionhome
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -4,10 +4,12 @@
|
||||
*.swp
|
||||
*.orig
|
||||
*.DS_Store
|
||||
*.mo
|
||||
:2e_*
|
||||
:2e#
|
||||
.AppleDouble
|
||||
database.sqlite
|
||||
requirements/private.txt
|
||||
courseware/static/js/mathjax/*
|
||||
flushdb.sh
|
||||
build
|
||||
@@ -21,7 +23,10 @@ reports/
|
||||
*.egg-info
|
||||
Gemfile.lock
|
||||
.env/
|
||||
conf/locale/en/LC_MESSAGES/*.po
|
||||
!messages.po
|
||||
lms/static/sass/*.css
|
||||
lms/static/sass/application.scss
|
||||
cms/static/sass/*.css
|
||||
lms/lib/comment_client/python
|
||||
nosetests.xml
|
||||
@@ -31,3 +36,8 @@ cover_html/
|
||||
chromedriver.log
|
||||
/nbproject
|
||||
ghostdriver.log
|
||||
node_modules
|
||||
.pip_download_cache/
|
||||
.prereqs_cache
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "common/test/phantom-jasmine"]
|
||||
path = common/test/phantom-jasmine
|
||||
url = https://github.com/jcarver989/phantom-jasmine.git
|
||||
2
.reviewboardrc
Normal file
2
.reviewboardrc
Normal file
@@ -0,0 +1,2 @@
|
||||
REVIEWBOARD_URL = "https://rbcommons.com/s/edx/"
|
||||
GUESS_FIELDS = True
|
||||
1
.ruby-gemset
Normal file
1
.ruby-gemset
Normal file
@@ -0,0 +1 @@
|
||||
edx-platform
|
||||
26
.tx/config
Normal file
26
.tx/config
Normal file
@@ -0,0 +1,26 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-studio.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]
|
||||
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]
|
||||
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]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/messages.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/messages.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
75
AUTHORS
Normal file
75
AUTHORS
Normal file
@@ -0,0 +1,75 @@
|
||||
Piotr Mitros <pmitros@edx.org>
|
||||
Kyle Fiedler <kyle@kylefiedler.com>
|
||||
Ernie Park <eipark@mit.edu>
|
||||
Bridger Maxwell <bridger@mit.edu>
|
||||
Lyla Fischer <lyla@edx.org>
|
||||
David Ormsbee <dave@edx.org>
|
||||
Chris Terman <cjt@edx.org>
|
||||
Reda Lemeden <reda@thoughtbot.com>
|
||||
Anant Agarwal <agarwal@edx.org>
|
||||
Jean-Michel Claus <jmc@edx.org>
|
||||
Calen Pennington <calen.pennington@gmail.com>
|
||||
JM Van Thong <jm@edx.org>
|
||||
Prem Sichanugrist <psichanugrist@thoughtbot.com>
|
||||
Isaac Chuang <ichuang@mit.edu>
|
||||
Galen Frechette <galen@thoughtbot.com>
|
||||
Edward Loveall <edward@edwardloveall.com>
|
||||
Matt Jankowski <mjankowski@thoughtbot.com>
|
||||
John Jarvis <jarv@edx.org>
|
||||
Victor Shnayder <victor@edx.org>
|
||||
Matthew Mongeau <halogenandtoast@gmail.com>
|
||||
Tony Kim <kimth@edx.org>
|
||||
Arjun Singh <arjun810@gmail.com>
|
||||
John Hess <mgojohn@gmail.com>
|
||||
Carlos Andrés Rocha <rocha@edx.org>
|
||||
Mike Chen <ccp0101@gmail.com>
|
||||
Rocky Duan <dementrock@gmail.com>
|
||||
Sidhanth Rao <sidhanth@mitx.mit.edu>
|
||||
Brittany Cheng <bcheng42@gmail.com>
|
||||
Dhaval Adjodah <dhaval@mit.edu>
|
||||
Tom Giannattasio <tom@mitx.mit.edu>
|
||||
Ibrahim Awwal <ibrahim.awwal@gmail.com>
|
||||
Sarina Canelake <sarina@edx.org>
|
||||
Mark L. Chang <mark.chang@gmail.com>
|
||||
Dean Dieker <ddieker@gmail.com>
|
||||
Tommy MacWilliam <tmacwilliam@cs.harvard.edu>
|
||||
Nate Hardison <natehardison@gmail.com>
|
||||
Chris Dodge <cdodge@edx.org>
|
||||
Kevin Chugh <kevinchugh@edx.org>
|
||||
Ned Batchelder <ned@nedbatchelder.com>
|
||||
Alexander Kryklia <kryklia@gmail.com>
|
||||
Vik Paruchuri <vik@edx.org>
|
||||
Louis Sobel <sobel@edx.org>
|
||||
Brian Wilson <brian@edx.org>
|
||||
Ashley Penney <apenney@edx.org>
|
||||
Don Mitchell <dmitchell@edx.org>
|
||||
Aaron Culich <aculich@edx.org>
|
||||
Brian Talbot <btalbot@edx.org>
|
||||
Jay Zoldak <jzoldak@edx.org>
|
||||
Valera Rozuvan <valera.rozuvan@gmail.com>
|
||||
Diana Huang <dkh@edx.org>
|
||||
Marco Morales <marcotuts@gmail.com>
|
||||
Christina Roberts <christina@edx.org>
|
||||
Robert Chirwa <robert@edx.org>
|
||||
Ed Zarecor <ed@edx.org>
|
||||
Deena Wang <thedeenawang@gmail.com>
|
||||
Jean Manuel-Nater <jnater@edx.org>
|
||||
Emily Zhang <1800.ehz.hang@gmail.com>
|
||||
Jennifer Akana <jaakana@gmail.com>
|
||||
Peter Baratta <peter.baratta@gmail.com>
|
||||
Julian Arni <julian@edx.org>
|
||||
Arthur Barrett <abarrett@edx.org>
|
||||
Vasyl Nakvasiuk <vaxxxa@gmail.com>
|
||||
Will Daly <will@edx.org>
|
||||
James Tauber <jtauber@jtauber.com>
|
||||
Greg Price <gprice@edx.org>
|
||||
Joe Blaylock <jrbl@stanford.edu>
|
||||
Sef Kloninger <sef@kloninger.com>
|
||||
Anto Stupak <s2pak.anton@gmail.com>
|
||||
David Adams <dcadams@stanford.edu>
|
||||
Steve Strassmann <straz@edx.org>
|
||||
Giulio Gratta <giulio@giuliogratta.com>
|
||||
David Baumgold <david@davidbaumgold.com>
|
||||
Jason Bau <jbau@stanford.edu>
|
||||
Frances Botsford <frances@edx.org>
|
||||
Slater Victoroff <slater.r.victoroff@gmail.com>
|
||||
171
README.md
Normal file
171
README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
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
|
||||
============
|
||||
|
||||
There is a `scripts/create-dev-env.sh` that will attempt to set up a development
|
||||
environment.
|
||||
|
||||
If you want to better understand what the script is doing, keep reading.
|
||||
|
||||
Directory Hierarchy
|
||||
-------------------
|
||||
|
||||
This code assumes that it is checked out in a directory that has three sibling
|
||||
directories: `data` (used for XML course data), `db` (used to hold a
|
||||
[sqlite](https://sqlite.org/) database), and `log` (used to hold logs). If you
|
||||
clone the repository into a directory called `edx` inside of a directory
|
||||
called `dev`, here's an example of how the directory hierarchy should look:
|
||||
|
||||
* dev
|
||||
\
|
||||
* data
|
||||
* db
|
||||
* log
|
||||
* edx
|
||||
\
|
||||
README.md
|
||||
|
||||
Language Runtimes
|
||||
-----------------
|
||||
You'll need to be sure that you have Python 2.7, Ruby 1.9.3, and NodeJS
|
||||
(latest stable) installed on your system. Some of these you can install
|
||||
using your system's package manager: [homebrew](http://mxcl.github.io/homebrew/)
|
||||
for Mac, [apt](http://wiki.debian.org/Apt) for Debian-based systems
|
||||
(including Ubuntu), [rpm](http://www.rpm.org/) or [yum](http://yum.baseurl.org/)
|
||||
for Red Hat based systems (including CentOS).
|
||||
|
||||
If your system's package manager gives you the wrong version of a language
|
||||
runtime, then you'll need to use a versioning tool to install the correct version.
|
||||
Usually, you'll need to do this for Ruby: you can use
|
||||
[`rbenv`](https://github.com/sstephenson/rbenv) or [`rvm`](https://rvm.io/), but
|
||||
typically `rbenv` is simpler. For Python, you can use
|
||||
[`pythonz`](http://saghul.github.io/pythonz/),
|
||||
and for Node, you can use [`nvm`](https://github.com/creationix/nvm).
|
||||
|
||||
Virtual Environments
|
||||
--------------------
|
||||
Often, different projects will have conflicting dependencies: for example, two
|
||||
projects depending on two different, incompatible versions of a library. Clearly,
|
||||
you can't have both versions installed and used on your machine simultaneously.
|
||||
Virtual environments were created to solve this problem: by installing libraries
|
||||
into an isolated environment, only projects that live inside the environment
|
||||
will be able to see and use those libraries. Got incompatible dependencies? Use
|
||||
different virtual environments, and your problem is solved.
|
||||
|
||||
Remember, each language has a different implementation. Python has
|
||||
[`virtualenv`](http://www.virtualenv.org/), Ruby has
|
||||
[`bundler`](http://gembundler.com/), and Node's virtual environment support
|
||||
is built into [`npm`](https://npmjs.org/), its library management tool.
|
||||
For each language, decide if you want to use a virtual environment, or if you
|
||||
want to install all the language dependencies globally (and risk conflicts).
|
||||
I suggest you start with installing things globally until and unless things
|
||||
break; you can always switch over to a virtual environment later on.
|
||||
|
||||
Language Packages
|
||||
-----------------
|
||||
The Python libraries we use are listed in `requirements.txt`. The Ruby libraries
|
||||
we use are listed in `Gemfile`. The Node libraries we use are listed in
|
||||
`packages.json`. Python has a library installer called
|
||||
[`pip`](http://www.pip-installer.org/), Ruby has a library installer called
|
||||
[`gem`](https://rubygems.org/) (or `bundle` if you're using a virtual
|
||||
environment), and Node has a library installer called
|
||||
[`npm`](https://npmjs.org/).
|
||||
Once you've got your languages and virtual environments set up, install
|
||||
the libraries like so:
|
||||
|
||||
$ pip install -r requirements/edx/pre.txt
|
||||
$ pip install -r requirements/edx/base.txt
|
||||
$ pip install -r requirements/edx/post.txt
|
||||
$ bundle install
|
||||
$ npm install
|
||||
|
||||
You can also use [`rake`](http://rake.rubyforge.org/) to get all of the prerequisites (or to update)
|
||||
them if they've changed
|
||||
|
||||
$ rake install_prereqs
|
||||
|
||||
Other Dependencies
|
||||
------------------
|
||||
You'll also need to install [MongoDB](http://www.mongodb.org/), since our
|
||||
application uses it in addition to sqlite. You can install it through your
|
||||
system package manager, and I suggest that you configure it to start
|
||||
automatically when you boot up your system, so that you never have to worry
|
||||
about it again. For Mac, use
|
||||
[`launchd`](https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man8/launchd.8.html)
|
||||
(running `brew info mongodb` will give you some commands you can copy-paste.)
|
||||
For Linux, you can use [`upstart`](http://upstart.ubuntu.com/), `chkconfig`,
|
||||
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:
|
||||
|
||||
$ rake django-admin[syncdb]
|
||||
$ rake django-admin[migrate]
|
||||
$ rake django-admin[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_(programming)), 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]"`.
|
||||
|
||||
Run Your Project
|
||||
----------------
|
||||
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.
|
||||
|
||||
To run Studio, run:
|
||||
|
||||
$ rake cms
|
||||
|
||||
To run the LMS, run:
|
||||
|
||||
$ rake lms[cms.dev]
|
||||
|
||||
Studio runs on port 8001, while LMS runs on port 8000, so you can run both of
|
||||
these commands simultaneously, using two different terminal windows. To view
|
||||
Studio, visit `127.0.0.1:8001` in your web browser; to view the LMS, visit
|
||||
`127.0.0.1:8000`.
|
||||
|
||||
There's also an older version of the LMS that saves its information in XML files
|
||||
in the `data` directory, instead of in Mongo. To run this older version, run:
|
||||
|
||||
$ rake lms
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
The code in this repository is licensed under version 3 of the AGPL unless
|
||||
otherwise noted.
|
||||
|
||||
Please see ``LICENSE.txt`` for details.
|
||||
|
||||
How to Contribute
|
||||
-----------------
|
||||
|
||||
Contributions are very welcome. The easiest way is to fork this repo, and then
|
||||
make a pull request from your fork. The first time you make a pull request, you
|
||||
may be asked to sign a Contributor Agreement.
|
||||
|
||||
Reporting Security Issues
|
||||
-------------------------
|
||||
|
||||
Please do not report security issues in public. Please email security@edx.org
|
||||
|
||||
Mailing List and IRC Channel
|
||||
----------------------------
|
||||
|
||||
You can discuss this code on the [edx-code Google Group](https://groups.google.com/forum/#!forum/edx-code) or in the
|
||||
`edx-code` IRC channel on Freenode.
|
||||
@@ -1,6 +1,3 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
@@ -131,7 +128,7 @@ def remove_user_from_course_group(caller, user, location, role):
|
||||
raise PermissionDenied
|
||||
|
||||
# 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) == True:
|
||||
if is_user_in_course_group_role(user, location, role):
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
|
||||
group = Group.objects.get(name=groupname)
|
||||
|
||||
@@ -97,8 +97,7 @@ def update_course_updates(location, update, passed_id=None):
|
||||
if (len(new_html_parsed) == 1):
|
||||
content = new_html_parsed[0].tail
|
||||
else:
|
||||
content = "\n".join([html.tostring(ele)
|
||||
for ele in new_html_parsed[1:]])
|
||||
content = "\n".join([html.tostring(ele) for ele in new_html_parsed[1:]])
|
||||
|
||||
return {"id": passed_id,
|
||||
"date": update['date'],
|
||||
|
||||
@@ -11,6 +11,8 @@ Feature: Advanced (manual) course policy
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
Then the settings are alphabetized
|
||||
|
||||
# Skipped because Ubuntu ChromeDriver cannot click notification "Cancel"
|
||||
@skip
|
||||
Scenario: Test cancel editing key value
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key
|
||||
@@ -19,6 +21,8 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
# Skipped because Ubuntu ChromeDriver cannot click notification "Save"
|
||||
@skip
|
||||
Scenario: Test editing key value
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key and save
|
||||
@@ -26,6 +30,8 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then the policy key value is changed
|
||||
|
||||
# Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input
|
||||
@skip
|
||||
Scenario: Test how multi-line input appears
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value
|
||||
@@ -33,6 +39,8 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then it is displayed as formatted
|
||||
|
||||
# Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input
|
||||
@skip
|
||||
Scenario: Test automatic quoting of non-JSON values
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a non-JSON value not in quotes
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
import time
|
||||
from terrain.steps import reload_the_page
|
||||
|
||||
from nose.tools import assert_true, assert_false, assert_equal
|
||||
from nose.tools import assert_false, assert_equal
|
||||
|
||||
"""
|
||||
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
|
||||
@@ -18,13 +15,11 @@ VALUE_CSS = 'textarea.json'
|
||||
DISPLAY_NAME_KEY = "display_name"
|
||||
DISPLAY_NAME_VALUE = '"Robot Super Course"'
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select the Advanced Settings$')
|
||||
def i_select_advanced_settings(step):
|
||||
expand_icon_css = 'li.nav-course-settings i.icon-expand'
|
||||
if world.browser.is_element_present_by_css(expand_icon_css):
|
||||
world.css_click(expand_icon_css)
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-advanced a'
|
||||
world.css_click(link_css)
|
||||
|
||||
@@ -38,7 +33,7 @@ def i_am_on_advanced_course_settings(step):
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(step, name):
|
||||
css = 'a.%s-button' % name.lower()
|
||||
world.css_click_at(css)
|
||||
world.css_click(css)
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key$')
|
||||
@@ -52,7 +47,7 @@ def edit_the_value_of_a_policy_key(step):
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key and save$')
|
||||
def edit_the_value_of_a_policy_key(step):
|
||||
def edit_the_value_of_a_policy_key_and_save(step):
|
||||
change_display_name_value(step, '"foo"')
|
||||
|
||||
|
||||
@@ -90,7 +85,7 @@ def it_is_formatted(step):
|
||||
|
||||
|
||||
@step('it is displayed as a string')
|
||||
def it_is_formatted(step):
|
||||
def it_is_displayed_as_string(step):
|
||||
assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"'])
|
||||
|
||||
|
||||
|
||||
@@ -21,4 +21,3 @@ Feature: Course checklists
|
||||
Given I have opened Checklists
|
||||
When I select a link to help page
|
||||
Then I am brought to the help page in a new window
|
||||
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_equal
|
||||
from nose.tools import assert_true, assert_equal, assert_in
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.common.exceptions import StaleElementReferenceException
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select Checklists from the Tools menu$')
|
||||
def i_select_checklists(step):
|
||||
expand_icon_css = 'li.nav-course-tools i.icon-expand'
|
||||
if world.browser.is_element_present_by_css(expand_icon_css):
|
||||
world.css_click(expand_icon_css)
|
||||
world.click_tools()
|
||||
link_css = 'li.nav-course-tools-checklists a'
|
||||
world.css_click(link_css)
|
||||
|
||||
@@ -62,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_equal('Course Outline', world.css_find('.outline .title-1')[0].text)
|
||||
assert_in('Course Outline', world.css_find('.outline .page-header')[0].text)
|
||||
assert_equal(1, len(world.browser.windows))
|
||||
|
||||
|
||||
@@ -88,8 +87,6 @@ def i_am_brought_to_help_page_in_new_window(step):
|
||||
assert_equal('http://help.edge.edx.org/', world.browser.url)
|
||||
|
||||
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def verifyChecklist2Status(completed, total, percentage):
|
||||
def verify_count(driver):
|
||||
@@ -106,9 +103,11 @@ def verifyChecklist2Status(completed, total, percentage):
|
||||
|
||||
|
||||
def toggleTask(checklist, task):
|
||||
world.css_click('#course-checklist' + str(checklist) +'-task' + str(task))
|
||||
world.css_click('#course-checklist' + str(checklist) + '-task' + str(task))
|
||||
|
||||
|
||||
# TODO: figure out a way to do this in phantom and firefox
|
||||
# For now we will mark the scenerios that use this method as skipped
|
||||
def clickActionLink(checklist, task, actionText):
|
||||
# toggle checklist item to make sure that the link button is showing
|
||||
toggleTask(checklist, task)
|
||||
@@ -120,4 +119,3 @@ def clickActionLink(checklist, task, actionText):
|
||||
|
||||
world.wait_for(verify_action_link_text)
|
||||
action_link.click()
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ from lettuce import world, step
|
||||
from nose.tools import assert_true
|
||||
from nose.tools import assert_equal
|
||||
|
||||
from xmodule.modulestore.django import _MODULESTORES, modulestore
|
||||
from xmodule.templates import update_templates
|
||||
from auth.authz import get_user_by_email
|
||||
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
@@ -50,31 +48,31 @@ def i_press_the_category_delete_icon(step, category):
|
||||
|
||||
@step('I have opened a new course in Studio$')
|
||||
def i_have_opened_a_new_course(step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
####### HELPER FUNCTIONS ##############
|
||||
def open_new_course():
|
||||
world.clear_courses()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
|
||||
|
||||
####### HELPER FUNCTIONS ##############
|
||||
def create_studio_user(
|
||||
uname='robot',
|
||||
email='robot+studio@edx.org',
|
||||
password='test',
|
||||
is_staff=False):
|
||||
studio_user = world.UserFactory.build(
|
||||
studio_user = world.UserFactory(
|
||||
username=uname,
|
||||
email=email,
|
||||
password=password,
|
||||
is_staff=is_staff)
|
||||
studio_user.set_password(password)
|
||||
studio_user.save()
|
||||
|
||||
registration = world.RegistrationFactory(user=studio_user)
|
||||
registration.register(studio_user)
|
||||
registration.activate()
|
||||
|
||||
user_profile = world.UserProfileFactory(user=studio_user)
|
||||
|
||||
|
||||
def fill_in_course_info(
|
||||
name='Robot Super Course',
|
||||
@@ -153,4 +151,13 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
|
||||
world.css_fill(time_css, desired_time)
|
||||
e = world.css_find(time_css).first
|
||||
e._element.send_keys(Keys.TAB)
|
||||
time.sleep(float(1))
|
||||
time.sleep(float(1))
|
||||
|
||||
|
||||
@step('I have created a Video component$')
|
||||
def i_created_a_video_component(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-video-icon',
|
||||
'i4x://edx/templates/video/default',
|
||||
'.xmodule_VideoModule'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# disable missing docstring
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world
|
||||
from nose.tools import assert_equal
|
||||
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)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
step.given('I have added a new subsection')
|
||||
step.given('I expand the first section')
|
||||
world.css_click('a.new-unit-item')
|
||||
world.css_click(component_button_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_component_from_menu(instance_id, expected_css):
|
||||
elem_css = "a[data-location='%s']" % instance_id
|
||||
assert_equal(1, len(world.css_find(elem_css)))
|
||||
world.css_click(elem_css)
|
||||
assert_equal(1, len(world.css_find(expected_css)))
|
||||
|
||||
@world.absorb
|
||||
def edit_component_and_select_settings():
|
||||
world.css_click('a.edit-button')
|
||||
world.css_click('#settings-mode')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def verify_setting_entry(setting, display_name, value, explicitly_set):
|
||||
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value)
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
settingClearButton = setting.find_by_css('.setting-clear')[0]
|
||||
assert_equal(explicitly_set, settingClearButton.has_class('active'))
|
||||
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def verify_all_setting_entries(expected_entries):
|
||||
settings = world.browser.find_by_css('.wrapper-comp-setting')
|
||||
assert_equal(len(expected_entries), len(settings))
|
||||
for (counter, setting) in enumerate(settings):
|
||||
world.verify_setting_entry(
|
||||
setting, expected_entries[counter][0],
|
||||
expected_entries[counter][1], expected_entries[counter][2]
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_component_and_reopen(step):
|
||||
world.css_click("a.save-button")
|
||||
# We have a known issue that modifications are still shown within the edit window after cancel (though)
|
||||
# they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save.
|
||||
reload_the_page(step)
|
||||
edit_component_and_select_settings()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def cancel_component(step):
|
||||
world.css_click("a.cancel-button")
|
||||
# We have a known issue that modifications are still shown within the edit window after cancel (though)
|
||||
# they are not persisted. Refresh the browser to make sure the changes were not persisted.
|
||||
reload_the_page(step)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def revert_setting_entry(label):
|
||||
get_setting_entry(label).find_by_css('.setting-clear')[0].click()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry(label):
|
||||
settings = world.browser.find_by_css('.wrapper-comp-setting')
|
||||
for setting in settings:
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return setting
|
||||
return None
|
||||
@@ -25,9 +25,7 @@ DEFAULT_TIME = "00:00"
|
||||
############### ACTIONS ####################
|
||||
@step('I select Schedule and Details$')
|
||||
def test_i_select_schedule_and_details(step):
|
||||
expand_icon_css = 'li.nav-course-settings i.icon-expand'
|
||||
if world.browser.is_element_present_by_css(expand_icon_css):
|
||||
world.css_click(expand_icon_css)
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-schedule a'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@@ -62,4 +62,4 @@ def i_am_on_tab(step, tab_name):
|
||||
@step('I see a link for adding a new section$')
|
||||
def i_see_new_section_link(step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
assert world.css_has_text(link_css, '+ New Section')
|
||||
assert world.css_has_text(link_css, 'New Section')
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
Feature: Discussion Component Editor
|
||||
As a course author, I want to be able to create discussion components.
|
||||
|
||||
Scenario: User can view metadata
|
||||
Given I have created a Discussion Tag
|
||||
And I edit and select Settings
|
||||
Then I see three alphabetized settings and their expected values
|
||||
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Discussion Tag
|
||||
And I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
23
cms/djangoapps/contentstore/features/discussion-editor.py
Normal file
23
cms/djangoapps/contentstore/features/discussion-editor.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# disable missing docstring
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
|
||||
|
||||
@step('I have created a Discussion Tag$')
|
||||
def i_created_discussion_tag(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-discussion-icon',
|
||||
'i4x://edx/templates/discussion/Discussion_Tag',
|
||||
'.xmodule_DiscussionModule'
|
||||
)
|
||||
|
||||
|
||||
@step('I see three alphabetized settings and their expected values$')
|
||||
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]
|
||||
])
|
||||
13
cms/djangoapps/contentstore/features/html-editor.feature
Normal file
13
cms/djangoapps/contentstore/features/html-editor.feature
Normal file
@@ -0,0 +1,13 @@
|
||||
Feature: HTML Editor
|
||||
As a course author, I want to be able to create HTML blocks.
|
||||
|
||||
Scenario: User can view metadata
|
||||
Given I have created a Blank HTML Page
|
||||
And I edit and select Settings
|
||||
Then I see only the HTML display name setting
|
||||
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Blank HTML Page
|
||||
And I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
17
cms/djangoapps/contentstore/features/html-editor.py
Normal file
17
cms/djangoapps/contentstore/features/html-editor.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# disable missing docstring
|
||||
#pylint: disable=C0111
|
||||
|
||||
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',
|
||||
'.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]])
|
||||
67
cms/djangoapps/contentstore/features/problem-editor.feature
Normal file
67
cms/djangoapps/contentstore/features/problem-editor.feature
Normal file
@@ -0,0 +1,67 @@
|
||||
Feature: Problem Editor
|
||||
As a course author, I want to be able to create problems and edit their settings.
|
||||
|
||||
Scenario: User can view metadata
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then I see five alphabetized settings and their expected values
|
||||
And Edit High Level Source is not visible
|
||||
|
||||
Scenario: User can modify String values
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
Scenario: User can specify special characters in String values
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then I can specify special characters in the display name
|
||||
And my special characters and persisted on save
|
||||
|
||||
Scenario: User can revert display name to unset
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then I can revert the display name to unset
|
||||
And my display name is unset on save
|
||||
|
||||
Scenario: User can select values in a Select
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then I can select Per Student for Randomization
|
||||
And my change to randomization is persisted
|
||||
And I can revert to the default value for randomization
|
||||
|
||||
Scenario: User can modify float input values
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then I can set the weight to "3.5"
|
||||
And my change to weight is persisted
|
||||
And I can revert to the default value of unset for weight
|
||||
|
||||
Scenario: User cannot type letters in float number field
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then if I set the weight to "abc", it remains unset
|
||||
|
||||
Scenario: User cannot type decimal values integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234"
|
||||
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "1"
|
||||
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then I can set the weight to "3.5"
|
||||
And I can modify the display name
|
||||
Then If I press Cancel my changes are not persisted
|
||||
|
||||
Scenario: Edit High Level source is available for LaTeX problem
|
||||
Given I have created a LaTeX Problem
|
||||
And I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
187
cms/djangoapps/contentstore/features/problem-editor.py
Normal file
187
cms/djangoapps/contentstore/features/problem-editor.py
Normal file
@@ -0,0 +1,187 @@
|
||||
# disable missing docstring
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_equal
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
MAXIMUM_ATTEMPTS = "Maximum Attempts"
|
||||
PROBLEM_WEIGHT = "Problem Weight"
|
||||
RANDOMIZATION = 'Randomization'
|
||||
SHOW_ANSWER = "Show Answer"
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I have created a Blank Common Problem$')
|
||||
def i_created_blank_common_problem(step):
|
||||
world.create_component_instance(
|
||||
step,
|
||||
'.large-problem-icon',
|
||||
'i4x://edx/templates/problem/Blank_Common_Problem',
|
||||
'.xmodule_CapaModule'
|
||||
)
|
||||
|
||||
|
||||
@step('I edit and select Settings$')
|
||||
def i_edit_and_select_settings(step):
|
||||
world.edit_component_and_select_settings()
|
||||
|
||||
|
||||
@step('I see five alphabetized settings and their expected values$')
|
||||
def i_see_five_settings_with_values(step):
|
||||
world.verify_all_setting_entries(
|
||||
[
|
||||
[DISPLAY_NAME, "Blank Common Problem", True],
|
||||
[MAXIMUM_ATTEMPTS, "", False],
|
||||
[PROBLEM_WEIGHT, "", False],
|
||||
[RANDOMIZATION, "Never", True],
|
||||
[SHOW_ANSWER, "Finished", True]
|
||||
])
|
||||
|
||||
|
||||
@step('I can modify the display name')
|
||||
def i_can_modify_the_display_name(step):
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('modified')
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@step('my display name change is persisted on save')
|
||||
def my_display_name_change_is_persisted_on_save(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@step('I can specify special characters in the display name')
|
||||
def i_can_modify_the_display_name_with_special_chars(step):
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &")
|
||||
verify_modified_display_name_with_special_chars()
|
||||
|
||||
|
||||
@step('my special characters and persisted on save')
|
||||
def special_chars_persisted_on_save(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_display_name_with_special_chars()
|
||||
|
||||
|
||||
@step('I can revert the display name to unset')
|
||||
def can_revert_display_name_to_unset(step):
|
||||
world.revert_setting_entry(DISPLAY_NAME)
|
||||
verify_unset_display_name()
|
||||
|
||||
|
||||
@step('my display name is unset on save')
|
||||
def my_display_name_is_persisted_on_save(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_unset_display_name()
|
||||
|
||||
|
||||
@step('I can select Per Student for Randomization')
|
||||
def i_can_select_per_student_for_randomization(step):
|
||||
world.browser.select(RANDOMIZATION, "Per Student")
|
||||
verify_modified_randomization()
|
||||
|
||||
|
||||
@step('my change to randomization is persisted')
|
||||
def my_change_to_randomization_is_persisted(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_randomization()
|
||||
|
||||
|
||||
@step('I can revert to the default value for randomization')
|
||||
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)
|
||||
|
||||
|
||||
@step('I can set the weight to "(.*)"?')
|
||||
def i_can_set_weight(step, weight):
|
||||
set_weight(weight)
|
||||
verify_modified_weight()
|
||||
|
||||
|
||||
@step('my change to weight is persisted')
|
||||
def my_change_to_weight_is_persisted(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_weight()
|
||||
|
||||
|
||||
@step('I can revert to the default value of unset for weight')
|
||||
def i_can_revert_to_default_for_unset_weight(step):
|
||||
world.revert_setting_entry(PROBLEM_WEIGHT)
|
||||
world.save_component_and_reopen(step)
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
|
||||
|
||||
|
||||
@step('if I set the weight to "(.*)", it remains unset')
|
||||
def set_the_weight_to_abc(step, bad_weight):
|
||||
set_weight(bad_weight)
|
||||
# We show the clear button immediately on type, hence the "True" here.
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", True)
|
||||
world.save_component_and_reopen(step)
|
||||
# But no change was actually ever sent to the model, so on reopen, explicitly_set is False
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
|
||||
|
||||
|
||||
@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"')
|
||||
def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted):
|
||||
world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set)
|
||||
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True)
|
||||
world.save_component_and_reopen(step)
|
||||
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True)
|
||||
|
||||
|
||||
@step('Edit High Level Source is not visible')
|
||||
def edit_high_level_source_not_visible(step):
|
||||
verify_high_level_source(step, False)
|
||||
|
||||
|
||||
@step('Edit High Level Source is visible')
|
||||
def edit_high_level_source_visible(step):
|
||||
verify_high_level_source(step, True)
|
||||
|
||||
|
||||
@step('If I press Cancel my changes are not persisted')
|
||||
def cancel_does_not_save_changes(step):
|
||||
world.cancel_component(step)
|
||||
step.given("I edit and select Settings")
|
||||
step.given("I see five alphabetized settings and their expected values")
|
||||
|
||||
|
||||
@step('I have created a LaTeX Problem')
|
||||
def create_latex_problem(step):
|
||||
world.click_new_component_button(step, '.large-problem-icon')
|
||||
# Go to advanced tab (waiting for the tab to be visible)
|
||||
world.css_find('#ui-id-2')
|
||||
world.css_click('#ui-id-2')
|
||||
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
|
||||
|
||||
|
||||
def verify_high_level_source(step, visible):
|
||||
assert_equal(visible, world.is_css_present('.launch-latex-compiler'))
|
||||
world.cancel_component(step)
|
||||
assert_equal(visible, world.is_css_present('.upload-button'))
|
||||
|
||||
|
||||
def verify_modified_weight():
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "3.5", True)
|
||||
|
||||
|
||||
def verify_modified_randomization():
|
||||
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Per Student", True)
|
||||
|
||||
|
||||
def verify_modified_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'modified', True)
|
||||
|
||||
|
||||
def verify_modified_display_name_with_special_chars():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "updated ' \" &", True)
|
||||
|
||||
|
||||
def verify_unset_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False)
|
||||
|
||||
|
||||
def set_weight(weight):
|
||||
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight)
|
||||
@@ -26,7 +26,8 @@ Feature: Create Section
|
||||
And I save a new section release date
|
||||
Then the section release date is updated
|
||||
|
||||
@skip-phantom
|
||||
# Skipped because Ubuntu ChromeDriver hangs on alert
|
||||
@skip
|
||||
Scenario: Delete section
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
|
||||
@@ -62,7 +62,7 @@ def i_click_to_edit_section_name(step):
|
||||
|
||||
@step('I see the complete section name with a quote in the editor$')
|
||||
def i_see_complete_section_name_with_quote_in_editor(step):
|
||||
css = '.edit-section-name'
|
||||
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"')
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@ def i_fill_in_the_registration_form(step):
|
||||
@step('I press the Create My Account button on the registration form$')
|
||||
def i_press_the_button_on_the_registration_form(step):
|
||||
submit_css = 'form#register_form button#submit'
|
||||
# Workaround for click not working on ubuntu
|
||||
# for some unknown reason.
|
||||
e = world.css_find(submit_css)
|
||||
e.type(' ')
|
||||
world.css_click(submit_css)
|
||||
|
||||
|
||||
@step('I should see be on the studio home page$')
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
Feature: Overview Toggle Section
|
||||
In order to quickly view the details of a course's section or to scan the inventory of sections
|
||||
As a course author
|
||||
I want to toggle the visibility of each section's subsection details in the overview listing
|
||||
As a course author
|
||||
I want to toggle the visibility of each section's subsection details in the overview listing
|
||||
|
||||
Scenario: The default layout for the overview page is to show sections in expanded view
|
||||
Given I have a course with multiple sections
|
||||
When I navigate to the course overview page
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
When I navigate to the course overview page
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Expand /collapse for a course with no sections
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
When I navigate to the course overview page
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
|
||||
Scenario: Collapse link appears after creating first section of a course
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
And I add a section
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
When I navigate to the course overview page
|
||||
And I add a section
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
@skip-phantom
|
||||
# Skipped because Ubuntu ChromeDriver hangs on alert
|
||||
@skip
|
||||
Scenario: Collapse link is not removed after last section of a course is deleted
|
||||
Given I have a course with 1 section
|
||||
And I navigate to the course overview page
|
||||
When I press the "section" delete icon
|
||||
And I confirm the alert
|
||||
And I navigate to the course overview page
|
||||
When I press the "section" delete icon
|
||||
And I confirm the alert
|
||||
Then I see the "Collapse All Sections" link
|
||||
|
||||
Scenario: Collapsing all sections when all sections are expanded
|
||||
@@ -57,4 +58,4 @@ Feature: Overview Toggle Section
|
||||
When I expand the first section
|
||||
And I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
And all sections are expanded
|
||||
|
||||
@@ -3,13 +3,13 @@ Feature: Create Subsection
|
||||
As a course author
|
||||
I want to create and edit subsections
|
||||
|
||||
Scenario: Add a new subsection to a section
|
||||
Scenario: Add a new subsection to a section
|
||||
Given I have opened a new course section in Studio
|
||||
When I click the New Subsection link
|
||||
And I enter the subsection name and click save
|
||||
Then I see my subsection on the Courseware page
|
||||
|
||||
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
|
||||
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
|
||||
Given I have opened a new course section in Studio
|
||||
When I click the New Subsection link
|
||||
And I enter a subsection name with a quote and click save
|
||||
@@ -17,7 +17,7 @@ Feature: Create Subsection
|
||||
And I click to edit the subsection name
|
||||
Then I see the complete subsection name with a quote in the editor
|
||||
|
||||
Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
|
||||
Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I mark it as Homework
|
||||
@@ -25,20 +25,19 @@ Feature: Create Subsection
|
||||
And I reload the page
|
||||
Then I see it marked as Homework
|
||||
|
||||
Scenario: Set a due date in a different year (bug #256)
|
||||
Scenario: Set a due date in a different year (bug #256)
|
||||
Given I have opened a new subsection in Studio
|
||||
And I have set a release date and due date in different years
|
||||
Then I see the correct dates
|
||||
And I reload the page
|
||||
Then I see the correct dates
|
||||
|
||||
@skip-phantom
|
||||
Scenario: Delete a subsection
|
||||
# Skipped because Ubuntu ChromeDriver hangs on alert
|
||||
@skip
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I see my subsection on the Courseware page
|
||||
When I press the "subsection" delete icon
|
||||
And I confirm the alert
|
||||
Then the subsection does not exist
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ from nose.tools import assert_equal
|
||||
|
||||
@step('I have opened a new course section in Studio$')
|
||||
def i_have_opened_a_new_course_section(step):
|
||||
world.clear_courses()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
open_new_course()
|
||||
add_section()
|
||||
|
||||
|
||||
@@ -63,14 +61,6 @@ def test_have_set_dates_in_different_years(step):
|
||||
set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00')
|
||||
|
||||
|
||||
@step('I see the correct dates$')
|
||||
def i_see_the_correct_dates(step):
|
||||
assert_equal('12/25/2011', world.css_find('input#start_date').first.value)
|
||||
assert_equal('03:00', world.css_find('input#start_time').first.value)
|
||||
assert_equal('01/02/2012', world.css_find('input#due_date').first.value)
|
||||
assert_equal('04:00', world.css_find('input#due_time').first.value)
|
||||
|
||||
|
||||
@step('I mark it as Homework$')
|
||||
def i_mark_it_as_homework(step):
|
||||
world.css_click('a.menu-toggle')
|
||||
@@ -101,8 +91,20 @@ def the_subsection_does_not_exist(step):
|
||||
assert world.browser.is_element_not_present_by_css(css)
|
||||
|
||||
|
||||
@step('I see the correct dates$')
|
||||
def i_see_the_correct_dates(step):
|
||||
assert_equal('12/25/2011', get_date('input#start_date'))
|
||||
assert_equal('03:00', get_date('input#start_time'))
|
||||
assert_equal('01/02/2012', get_date('input#due_date'))
|
||||
assert_equal('04:00', get_date('input#due_time'))
|
||||
|
||||
|
||||
############ HELPER METHODS ###################
|
||||
|
||||
def get_date(css):
|
||||
return world.css_find(css).first.value.strip()
|
||||
|
||||
|
||||
def save_subsection_name(name):
|
||||
name_css = 'input.new-subsection-name-input'
|
||||
save_css = 'input.new-subsection-name-save'
|
||||
|
||||
13
cms/djangoapps/contentstore/features/video-editor.feature
Normal file
13
cms/djangoapps/contentstore/features/video-editor.feature
Normal file
@@ -0,0 +1,13 @@
|
||||
Feature: Video Component Editor
|
||||
As a course author, I want to be able to create video components.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
9
cms/djangoapps/contentstore/features/video-editor.py
Normal file
9
cms/djangoapps/contentstore/features/video-editor.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# disable missing docstring
|
||||
#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]])
|
||||
6
cms/djangoapps/contentstore/features/video.feature
Normal file
6
cms/djangoapps/contentstore/features/video.feature
Normal file
@@ -0,0 +1,6 @@
|
||||
Feature: Video Component
|
||||
As a course author, I want to be able to view my created videos in Studio.
|
||||
|
||||
Scenario: Autoplay is disabled in Studio
|
||||
Given I have created a Video component
|
||||
Then when I view the video it does not have autoplay enabled
|
||||
11
cms/djangoapps/contentstore/features/video.py
Normal file
11
cms/djangoapps/contentstore/features/video.py
Normal file
@@ -0,0 +1,11 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@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')
|
||||
@@ -59,7 +59,7 @@ class Command(BaseCommand):
|
||||
discussion_items = _get_discussion_items(course)
|
||||
|
||||
# now query all discussion items via get_items() and compare with the tree-traversal
|
||||
queried_discussion_items = store.get_items(['i4x', course.location.org, course.location.course,
|
||||
queried_discussion_items = store.get_items(['i4x', course.location.org, course.location.course,
|
||||
'discussion', None, None])
|
||||
|
||||
for item in queried_discussion_items:
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.store_utilities import clone_course
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from auth.authz import _copy_course_group
|
||||
@@ -16,8 +15,7 @@ from auth.authz import _copy_course_group
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
'''Clone a MongoDB backed course to another location'''
|
||||
help = 'Clone a MongoDB backed course to another location'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 2:
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from .prompt import query_yes_no
|
||||
|
||||
@@ -38,7 +37,7 @@ class Command(BaseCommand):
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
loc = CourseDescriptor.id_to_location(loc_str)
|
||||
if delete_course(ms, cs, loc, commit) == True:
|
||||
if delete_course(ms, cs, loc, commit):
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
if commit:
|
||||
|
||||
@@ -7,7 +7,6 @@ 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.modulestore import Location
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
|
||||
@@ -15,8 +14,7 @@ unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
'''Import the specified data directory into the default ModuleStore'''
|
||||
help = 'Import the specified data directory into the default ModuleStore'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 2:
|
||||
|
||||
@@ -12,8 +12,7 @@ unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
'''Import the specified data directory into the default ModuleStore'''
|
||||
help = 'Import the specified data directory into the default ModuleStore'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) == 0:
|
||||
@@ -28,4 +27,4 @@ class Command(BaseCommand):
|
||||
data=data_dir,
|
||||
courses=course_dirs)
|
||||
import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False,
|
||||
static_content_store=contentstore(), verbose=True)
|
||||
static_content_store=contentstore(), verbose=True)
|
||||
|
||||
@@ -11,8 +11,8 @@ def query_yes_no(question, default="yes"):
|
||||
|
||||
The "answer" return value is one of "yes" or "no".
|
||||
"""
|
||||
valid = {"yes":True, "y":True, "ye":True,
|
||||
"no":False, "n":False}
|
||||
valid = {"yes": True, "y": True, "ye": True,
|
||||
"no": False, "n": False}
|
||||
if default is None:
|
||||
prompt = " [y/n] "
|
||||
elif default == "yes":
|
||||
@@ -30,5 +30,4 @@ def query_yes_no(question, default="yes"):
|
||||
elif choice in valid:
|
||||
return valid[choice]
|
||||
else:
|
||||
sys.stdout.write("Please respond with 'yes' or 'no' "\
|
||||
"(or 'y' or 'n').\n")
|
||||
sys.stdout.write("Please respond with 'yes' or 'no' (or 'y' or 'n').\n")
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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'''
|
||||
help = 'Imports and updates the Studio component templates from the code pack and put in the DB'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
update_templates()
|
||||
update_templates(modulestore('direct'))
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_importer import perform_xlint
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
|
||||
unnamed_modules = 0
|
||||
@@ -9,10 +7,11 @@ unnamed_modules = 0
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
'''
|
||||
Verify the structure of courseware as to it's suitability for import
|
||||
To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)]
|
||||
'''
|
||||
'''
|
||||
Verify the structure of courseware as to it's suitability for import
|
||||
To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)]
|
||||
'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) == 0:
|
||||
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from static_replace import replace_static_urls
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
from django.http import Http404
|
||||
|
||||
|
||||
def get_module_info(store, location, parent_location=None, rewrite_static_links=False):
|
||||
@@ -76,11 +75,7 @@ def set_module_info(store, location, post_data):
|
||||
# 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():
|
||||
|
||||
# let's strip out any metadata fields from the postback which have been identified as system metadata
|
||||
# and therefore should not be user-editable, so we should accept them back from the client
|
||||
if metadata_key in module.system_metadata_fields:
|
||||
del posted_metadata[metadata_key]
|
||||
elif posted_metadata[metadata_key] is None:
|
||||
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 module._model_data:
|
||||
del module._model_data[metadata_key]
|
||||
|
||||
@@ -6,16 +6,17 @@ from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from path import path
|
||||
from tempdir import mkdtemp_clean
|
||||
from datetime import timedelta
|
||||
import json
|
||||
from fs.osfs import OSFS
|
||||
import copy
|
||||
from json import loads
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.dispatch import Signal
|
||||
from contentstore.utils import get_modulestore
|
||||
from contentstore.tests.utils import parse_json
|
||||
|
||||
from .utils import ModuleStoreTestCase, parse_json
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
@@ -33,19 +34,25 @@ from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
|
||||
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')
|
||||
|
||||
|
||||
class MongoCollectionFindWrapper(object):
|
||||
def __init__(self, original):
|
||||
self.original = original
|
||||
self.counter = 0
|
||||
|
||||
def find(self, query, *args, **kwargs):
|
||||
self.counter = self.counter+1
|
||||
self.counter = self.counter + 1
|
||||
return self.original(query, *args, **kwargs)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
|
||||
class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
"""
|
||||
@@ -70,8 +77,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.client = Client()
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
def test_advanced_components_in_edit_unit(self):
|
||||
store = modulestore('direct')
|
||||
import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
|
||||
course = store.get_item(Location(['i4x', 'edX', 'simple',
|
||||
'course', '2012_Fall', None]), depth=None)
|
||||
|
||||
course.advanced_modules = ADVANCED_COMPONENT_TYPES
|
||||
|
||||
store.update_metadata(course.location, own_metadata(course))
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
|
||||
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page
|
||||
# response HTML
|
||||
self.assertIn('Video Alpha', resp.content)
|
||||
self.assertIn('Word cloud', resp.content)
|
||||
self.assertIn('Annotation', resp.content)
|
||||
self.assertIn('Open Ended Response', resp.content)
|
||||
self.assertIn('Peer Grading Interface', resp.content)
|
||||
|
||||
def check_edit_unit(self, test_course_name):
|
||||
import_from_xml(modulestore(), 'common/test/data/', [test_course_name])
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name])
|
||||
|
||||
for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)):
|
||||
print "Checking ", descriptor.location.url()
|
||||
@@ -92,13 +124,40 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
return cnt
|
||||
|
||||
def test_get_items(self):
|
||||
'''
|
||||
This verifies a bug we had where the None setting in get_items() meant 'wildcard'
|
||||
Unfortunately, None = published for the revision field, so get_items() would return
|
||||
both draft and non-draft copies.
|
||||
'''
|
||||
store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
|
||||
# now query get_items() to get this location with revision=None, this should just
|
||||
# return back a single item (not 2)
|
||||
|
||||
items = store.get_items(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertFalse(getattr(items[0], 'is_draft', False))
|
||||
|
||||
# now refetch from the draft store. Note that even though we pass
|
||||
# None in the revision field, the draft store will replace that with 'draft'
|
||||
items = draft_store.get_items(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertTrue(getattr(items[0], 'is_draft', False))
|
||||
|
||||
def test_draft_metadata(self):
|
||||
'''
|
||||
This verifies a bug we had where inherited metadata was getting written to the
|
||||
module as 'own-metadata' when publishing. Also verifies the metadata inheritance is
|
||||
properly computed
|
||||
'''
|
||||
store = modulestore()
|
||||
store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
|
||||
@@ -156,39 +215,52 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
|
||||
|
||||
def test_get_depth_with_drafts(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', ['simple'])
|
||||
|
||||
course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'course', '2012_Fall', None]), depth=None)
|
||||
course = modulestore('draft').get_item(
|
||||
Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]),
|
||||
depth=None
|
||||
)
|
||||
|
||||
# make sure no draft items have been returned
|
||||
num_drafts = self._get_draft_counts(course)
|
||||
self.assertEqual(num_drafts, 0)
|
||||
|
||||
problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'problem', 'ps01-simple', None]))
|
||||
problem = modulestore('draft').get_item(
|
||||
Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])
|
||||
)
|
||||
|
||||
# put into draft
|
||||
modulestore('draft').clone_item(problem.location, problem.location)
|
||||
|
||||
# make sure we can query that item and verify that it is a draft
|
||||
draft_problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'problem', 'ps01-simple', None]))
|
||||
self.assertTrue(getattr(draft_problem,'is_draft', False))
|
||||
draft_problem = modulestore('draft').get_item(
|
||||
Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])
|
||||
)
|
||||
self.assertTrue(getattr(draft_problem, 'is_draft', False))
|
||||
|
||||
#now requery with depth
|
||||
course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'course', '2012_Fall', None]), depth=None)
|
||||
course = modulestore('draft').get_item(
|
||||
Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]),
|
||||
depth=None
|
||||
)
|
||||
|
||||
# make sure just one draft item have been returned
|
||||
num_drafts = self._get_draft_counts(course)
|
||||
self.assertEqual(num_drafts, 1)
|
||||
self.assertEqual(num_drafts, 1)
|
||||
|
||||
def test_import_textbook_as_content_element(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
|
||||
# reverse the ordering
|
||||
@@ -210,49 +282,47 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(reverse_tabs, course_tabs)
|
||||
|
||||
def test_import_polls(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
module_store = modulestore('direct')
|
||||
found = False
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
item = None
|
||||
items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None])
|
||||
found = len(items) > 0
|
||||
|
||||
self.assertTrue(found)
|
||||
# check that there's actually content in the 'question' field
|
||||
self.assertGreater(len(items[0].question),0)
|
||||
self.assertGreater(len(items[0].question), 0)
|
||||
|
||||
def test_xlint_fails(self):
|
||||
err_cnt = perform_xlint('common/test/data', ['full'])
|
||||
self.assertGreater(err_cnt, 0)
|
||||
|
||||
def test_delete(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
direct_store = modulestore('direct')
|
||||
import_from_xml(direct_store, 'common/test/data/', ['full'])
|
||||
|
||||
module_store = modulestore('direct')
|
||||
sequential = direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
|
||||
sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None]))
|
||||
|
||||
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None]))
|
||||
|
||||
# make sure the parent no longer points to the child object which was deleted
|
||||
# make sure the parent points to the child object which is to be deleted
|
||||
self.assertTrue(sequential.location.url() in chapter.children)
|
||||
|
||||
self.client.post(reverse('delete_item'),
|
||||
self.client.post(
|
||||
reverse('delete_item'),
|
||||
json.dumps({'id': sequential.location.url(), 'delete_children': 'true', 'delete_all_versions': 'true'}),
|
||||
"application/json")
|
||||
"application/json"
|
||||
)
|
||||
|
||||
found = False
|
||||
try:
|
||||
module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
|
||||
found = True
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
self.assertFalse(found)
|
||||
|
||||
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None]))
|
||||
chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None]))
|
||||
|
||||
# make sure the parent no longer points to the child object which was deleted
|
||||
self.assertFalse(sequential.location.url() in chapter.children)
|
||||
@@ -262,8 +332,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
|
||||
while there is a base definition in /about/effort.html
|
||||
'''
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
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]))
|
||||
self.assertEqual(effort.data, '6 hours')
|
||||
|
||||
@@ -272,10 +343,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(effort.data, 'TBD')
|
||||
|
||||
def test_remove_hide_progress_tab(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
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)
|
||||
@@ -288,16 +357,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
}
|
||||
}
|
||||
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
|
||||
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
|
||||
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
@@ -312,7 +381,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'vertical', None]))
|
||||
self.assertGreater(len(clone_items), 0)
|
||||
for descriptor in items:
|
||||
new_loc = descriptor.location._replace(org='MITx', course='999')
|
||||
new_loc = descriptor.location.replace(org='MITx', course='999')
|
||||
print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url())
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
@@ -322,9 +391,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_delete_course(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
content_store = contentstore()
|
||||
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
@@ -335,29 +404,60 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(len(items), 0)
|
||||
|
||||
def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''):
|
||||
fs = OSFS(root_dir / 'test_export')
|
||||
self.assertTrue(fs.exists(dirname))
|
||||
filesystem = OSFS(root_dir / 'test_export')
|
||||
self.assertTrue(filesystem.exists(dirname))
|
||||
|
||||
query_loc = Location('i4x', location.org, location.course, category_name, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
|
||||
for item in items:
|
||||
fs = OSFS(root_dir / ('test_export/' + dirname))
|
||||
self.assertTrue(fs.exists(item.location.name + filename_suffix))
|
||||
filesystem = OSFS(root_dir / ('test_export/' + dirname))
|
||||
self.assertTrue(filesystem.exists(item.location.name + filename_suffix))
|
||||
|
||||
def test_export_course(self):
|
||||
module_store = modulestore('direct')
|
||||
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)
|
||||
|
||||
# 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']))
|
||||
|
||||
for child in vertical.get_children():
|
||||
draft_store.clone_item(child.location, 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]))
|
||||
|
||||
# add private to list of children
|
||||
sequential = module_store.get_item(Location(['i4x', 'edX', 'full',
|
||||
'sequential', 'Administrivia_and_Circuit_Elements', 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]))
|
||||
|
||||
self.assertIn(private_location_no_draft.url(), sequential.children)
|
||||
|
||||
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')
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store)
|
||||
|
||||
# check for static tabs
|
||||
self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html')
|
||||
@@ -369,20 +469,20 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template')
|
||||
|
||||
# check for graiding_policy.json
|
||||
fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012')
|
||||
self.assertTrue(fs.exists('grading_policy.json'))
|
||||
filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012')
|
||||
self.assertTrue(filesystem.exists('grading_policy.json'))
|
||||
|
||||
course = module_store.get_item(location)
|
||||
# compare what's on disk compared to what we have in our course
|
||||
with fs.open('grading_policy.json', 'r') as grading_policy:
|
||||
with filesystem.open('grading_policy.json', 'r') as grading_policy:
|
||||
on_disk = loads(grading_policy.read())
|
||||
self.assertEqual(on_disk, course.grading_policy)
|
||||
|
||||
#check for policy.json
|
||||
self.assertTrue(fs.exists('policy.json'))
|
||||
self.assertTrue(filesystem.exists('policy.json'))
|
||||
|
||||
# compare what's on disk to what we have in the course module
|
||||
with fs.open('policy.json', 'r') as course_policy:
|
||||
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))
|
||||
@@ -391,20 +491,47 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
delete_course(module_store, content_store, location)
|
||||
|
||||
# reimport
|
||||
import_from_xml(module_store, root_dir, ['test_export'])
|
||||
import_from_xml(module_store, root_dir, ['test_export'], draft_store=draft_store)
|
||||
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
print "Checking {0}....".format(descriptor.location.url())
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# don't try to look at private verticals. Right now we're running
|
||||
# the service in non-draft aware
|
||||
if getattr(descriptor, 'is_draft', False):
|
||||
print "Checking {0}....".format(descriptor.location.url())
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
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)
|
||||
|
||||
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]))
|
||||
|
||||
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]))
|
||||
|
||||
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]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_course_handouts_rewrites(self):
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
|
||||
# import a test course
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
@@ -422,8 +549,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
|
||||
|
||||
def test_prefetch_children(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
wrapper = MongoCollectionFindWrapper(module_store.collection.find)
|
||||
@@ -437,11 +565,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
# 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)
|
||||
'Administrivia_and_Circuit_Elements', 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]) in course.system.module_data)
|
||||
self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', None])
|
||||
in course.system.module_data)
|
||||
|
||||
def test_export_course_with_unknown_metadata(self):
|
||||
module_store = modulestore('direct')
|
||||
@@ -463,14 +591,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
print 'Exporting to tempdir = {0}'.format(root_dir)
|
||||
|
||||
# export out to a tempdir
|
||||
exported = False
|
||||
try:
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_export')
|
||||
exported = True
|
||||
except Exception:
|
||||
pass
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_export')
|
||||
|
||||
self.assertTrue(exported)
|
||||
|
||||
class ContentStoreTest(ModuleStoreTestCase):
|
||||
"""
|
||||
@@ -506,7 +628,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
}
|
||||
}
|
||||
|
||||
def test_create_course(self):
|
||||
"""Test new course creation - happy path"""
|
||||
@@ -515,6 +637,14 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
|
||||
|
||||
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.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course'))
|
||||
|
||||
def test_create_course_duplicate_course(self):
|
||||
"""Test new course creation - error path"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
@@ -533,7 +663,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data['ErrMsg'],
|
||||
'There is already a course defined with the same organization and course number.')
|
||||
'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"""
|
||||
@@ -543,16 +673,18 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
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'.")
|
||||
"Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.")
|
||||
|
||||
def test_course_index_view_with_no_courses(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
# Create a course so there is something to view
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(resp,
|
||||
'<h1 class="title-1">My Courses</h1>',
|
||||
self.assertContains(
|
||||
resp,
|
||||
'<h1 class="page-header">My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
html=True
|
||||
)
|
||||
|
||||
def test_course_factory(self):
|
||||
"""Test that the course factory works correctly."""
|
||||
@@ -569,26 +701,30 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
"""Test viewing the index page with an existing course"""
|
||||
CourseFactory.create(display_name='Robot Super Educational Course')
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(resp,
|
||||
self.assertContains(
|
||||
resp,
|
||||
'<span class="class-name">Robot Super Educational Course</span>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
html=True
|
||||
)
|
||||
|
||||
def test_course_overview_view_with_course(self):
|
||||
"""Test viewing the course overview page with an existing course"""
|
||||
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
data = {
|
||||
'org': 'MITx',
|
||||
'course': '999',
|
||||
'name': Location.clean('Robot Super Course'),
|
||||
}
|
||||
'org': 'MITx',
|
||||
'course': '999',
|
||||
'name': Location.clean('Robot Super Course'),
|
||||
}
|
||||
|
||||
resp = self.client.get(reverse('course_index', kwargs=data))
|
||||
self.assertContains(resp,
|
||||
self.assertContains(
|
||||
resp,
|
||||
'<article class="courseware-overview" data-course-id="i4x://MITx/999/course/Robot_Super_Course">',
|
||||
status_code=200,
|
||||
html=True)
|
||||
html=True
|
||||
)
|
||||
|
||||
def test_clone_item(self):
|
||||
"""Test cloning an item. E.g. creating a new section"""
|
||||
@@ -598,14 +734,16 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template': 'i4x://edx/templates/chapter/Empty',
|
||||
'display_name': 'Section One',
|
||||
}
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), section_data)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertRegexpMatches(data['id'],
|
||||
'^i4x:\/\/MITx\/999\/chapter\/([0-9]|[a-f]){32}$')
|
||||
self.assertRegexpMatches(
|
||||
data['id'],
|
||||
r"^i4x://MITx/999/chapter/([0-9]|[a-f]){32}$"
|
||||
)
|
||||
|
||||
def test_capa_module(self):
|
||||
"""Test that a problem treats markdown specially."""
|
||||
@@ -614,7 +752,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
problem_data = {
|
||||
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template': 'i4x://edx/templates/problem/Blank_Common_Problem'
|
||||
}
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), problem_data)
|
||||
|
||||
@@ -633,7 +771,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
Import and walk through some common URL endpoints. This just verifies non-500 and no other
|
||||
correct behavior, so it is not a deep test
|
||||
"""
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', ['simple'])
|
||||
loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None])
|
||||
resp = self.client.get(reverse('course_index',
|
||||
kwargs={'org': loc.org,
|
||||
@@ -700,44 +838,46 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
# go look at a subsection page
|
||||
subsection_location = loc._replace(category='sequential', name='test_sequence')
|
||||
subsection_location = loc.replace(category='sequential', name='test_sequence')
|
||||
resp = self.client.get(reverse('edit_subsection',
|
||||
kwargs={'location': subsection_location.url()}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
# go look at the Edit page
|
||||
unit_location = loc._replace(category='vertical', name='test_vertical')
|
||||
unit_location = loc.replace(category='vertical', name='test_vertical')
|
||||
resp = self.client.get(reverse('edit_unit',
|
||||
kwargs={'location': unit_location.url()}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
# delete a component
|
||||
del_loc = loc._replace(category='html', name='test_html')
|
||||
del_loc = loc.replace(category='html', name='test_html')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
# delete a unit
|
||||
del_loc = loc._replace(category='vertical', name='test_vertical')
|
||||
del_loc = loc.replace(category='vertical', name='test_vertical')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
# delete a unit
|
||||
del_loc = loc._replace(category='sequential', name='test_sequence')
|
||||
del_loc = loc.replace(category='sequential', name='test_sequence')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
# delete a chapter
|
||||
del_loc = loc._replace(category='chapter', name='chapter_2')
|
||||
del_loc = loc.replace(category='chapter', name='chapter_2')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
|
||||
def test_import_metadata_with_attempts_empty_string(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['simple'])
|
||||
|
||||
did_load_item = False
|
||||
try:
|
||||
module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None]))
|
||||
@@ -748,10 +888,49 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
# make sure we found the item (e.g. it didn't error while loading)
|
||||
self.assertTrue(did_load_item)
|
||||
|
||||
def test_metadata_inheritance(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
def test_forum_id_generation(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
new_component_location = Location('i4x', 'edX', 'full', 'discussion', 'new_component')
|
||||
source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag')
|
||||
|
||||
# crate a new module and add it as a child to a vertical
|
||||
module_store.clone_item(source_template_location, new_component_location)
|
||||
|
||||
new_discussion_item = module_store.get_item(new_component_location)
|
||||
|
||||
self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$')
|
||||
|
||||
def test_update_modulestore_signal_did_fire(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
try:
|
||||
module_store.modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
|
||||
|
||||
self.got_signal = False
|
||||
|
||||
def _signal_hander(modulestore=None, course_id=None, location=None, **kwargs):
|
||||
self.got_signal = True
|
||||
|
||||
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')
|
||||
|
||||
# crate a new module
|
||||
module_store.clone_item(source_template_location, new_component_location)
|
||||
|
||||
finally:
|
||||
module_store.modulestore_update_signal = None
|
||||
|
||||
self.assertTrue(self.got_signal)
|
||||
|
||||
def test_metadata_inheritance(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
|
||||
verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None])
|
||||
@@ -807,7 +986,7 @@ class TemplateTestCase(ModuleStoreTestCase):
|
||||
self.assertIsNotNone(verify_create)
|
||||
|
||||
# now run cleanup
|
||||
update_templates()
|
||||
update_templates(modulestore('direct'))
|
||||
|
||||
# now try to find dangling template, it should not be in DB any longer
|
||||
asserted = False
|
||||
|
||||
@@ -23,14 +23,14 @@ class CachingTestCase(TestCase):
|
||||
def test_put_and_get(self):
|
||||
set_cached_content(self.mockAsset)
|
||||
self.assertEqual(self.mockAsset.content, get_cached_content(self.unicodeLocation).content,
|
||||
'should be stored in cache with unicodeLocation')
|
||||
'should be stored in cache with unicodeLocation')
|
||||
self.assertEqual(self.mockAsset.content, get_cached_content(self.nonUnicodeLocation).content,
|
||||
'should be stored in cache with nonUnicodeLocation')
|
||||
'should be stored in cache with nonUnicodeLocation')
|
||||
|
||||
def test_delete(self):
|
||||
set_cached_content(self.mockAsset)
|
||||
del_cached_content(self.nonUnicodeLocation)
|
||||
self.assertEqual(None, get_cached_content(self.unicodeLocation),
|
||||
'should not be stored in cache with unicodeLocation')
|
||||
'should not be stored in cache with unicodeLocation')
|
||||
self.assertEqual(None, get_cached_content(self.nonUnicodeLocation),
|
||||
'should not be stored in cache with nonUnicodeLocation')
|
||||
'should not be stored in cache with nonUnicodeLocation')
|
||||
|
||||
@@ -8,19 +8,18 @@ from django.core.urlresolvers import reverse
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from models.settings.course_details import (CourseDetails,
|
||||
CourseSettingsEncoder)
|
||||
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from contentstore.utils import get_modulestore
|
||||
|
||||
from .utils import ModuleStoreTestCase
|
||||
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):
|
||||
def setUp(self):
|
||||
"""
|
||||
@@ -47,12 +46,8 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
self.client = Client()
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
t = 'i4x://edx/templates/course/Empty'
|
||||
o = 'MITx'
|
||||
n = '999'
|
||||
dn = 'Robot Super Course'
|
||||
self.course_location = Location('i4x', o, n, 'course', 'Robot_Super_Course')
|
||||
CourseFactory.create(template=t, org=o, number=n, display_name=dn)
|
||||
course = CourseFactory.create(template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course')
|
||||
self.course_location = course.location
|
||||
|
||||
|
||||
class CourseDetailsTestCase(CourseTestCase):
|
||||
@@ -86,17 +81,25 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
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(CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
|
||||
jsondetails.syllabus, "After set syllabus")
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
|
||||
jsondetails.syllabus, "After set syllabus"
|
||||
)
|
||||
jsondetails.overview = "Overview"
|
||||
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).overview,
|
||||
jsondetails.overview, "After set overview")
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).overview,
|
||||
jsondetails.overview, "After set overview"
|
||||
)
|
||||
jsondetails.intro_video = "intro_video"
|
||||
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
|
||||
jsondetails.intro_video, "After set intro_video")
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
|
||||
jsondetails.intro_video, "After set intro_video"
|
||||
)
|
||||
jsondetails.effort = "effort"
|
||||
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).effort,
|
||||
jsondetails.effort, "After set effort")
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).effort,
|
||||
jsondetails.effort, "After set effort"
|
||||
)
|
||||
|
||||
|
||||
class CourseDetailsViewTest(CourseTestCase):
|
||||
@@ -150,9 +153,7 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
|
||||
@staticmethod
|
||||
def struct_to_datetime(struct_time):
|
||||
return datetime.datetime(struct_time.tm_year, struct_time.tm_mon,
|
||||
struct_time.tm_mday, struct_time.tm_hour,
|
||||
struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC())
|
||||
return datetime.datetime(*struct_time[:6], tzinfo=UTC())
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
if details[field] is not None:
|
||||
@@ -249,14 +250,14 @@ 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")
|
||||
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
def setUp(self):
|
||||
CourseTestCase.setUp(self)
|
||||
# add in the full class too
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
import_from_xml(get_modulestore(self.course_location), 'common/test/data/', ['full'])
|
||||
self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])
|
||||
|
||||
|
||||
def test_fetch_initial_fields(self):
|
||||
test_model = CourseMetadata.fetch(self.course_location)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
@@ -271,18 +272,20 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
||||
|
||||
def test_update_from_json(self):
|
||||
test_model = CourseMetadata.update_from_json(self.course_location,
|
||||
{ "advertised_start" : "start A",
|
||||
"testcenter_info" : { "c" : "test" },
|
||||
"days_early_for_beta" : 2})
|
||||
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)
|
||||
self.update_check(test_model)
|
||||
# now change some of the existing metadata
|
||||
test_model = CourseMetadata.update_from_json(self.course_location,
|
||||
{ "advertised_start" : "start B",
|
||||
"display_name" : "jolly roger"})
|
||||
test_model = CourseMetadata.update_from_json(self.course_location, {
|
||||
"advertised_start": "start B",
|
||||
"display_name": "jolly roger"}
|
||||
)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
|
||||
self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
|
||||
@@ -294,13 +297,12 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
|
||||
self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value")
|
||||
self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field')
|
||||
self.assertDictEqual(test_model['testcenter_info'], { "c" : "test" }, "testcenter_info not expected value")
|
||||
self.assertDictEqual(test_model['testcenter_info'], {"c": "test"}, "testcenter_info not expected value")
|
||||
self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
|
||||
self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value")
|
||||
|
||||
|
||||
def test_delete_key(self):
|
||||
test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']})
|
||||
test_model = CourseMetadata.delete_key(self.fullcourse_location, {'deleteKeys': ['doesnt_exist', 'showanswer', 'xqa_key']})
|
||||
# ensure no harm
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
|
||||
@@ -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,9 +20,9 @@ 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,
|
||||
'provided_id': ''})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
@@ -31,25 +31,25 @@ 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,
|
||||
'provided_id': payload['id']})
|
||||
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
|
||||
resp = self.client.post(first_update_url, json.dumps(payload),
|
||||
"application/json")
|
||||
"application/json")
|
||||
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'],
|
||||
"iframe w/ div")
|
||||
"iframe w/ div")
|
||||
|
||||
# now put in an evil update
|
||||
content = '<ol/>'
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
@@ -58,25 +58,24 @@ 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,
|
||||
'provided_id': ''})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
payload = json.loads(resp.content)
|
||||
self.assertTrue(len(payload) == 2)
|
||||
|
||||
# can't test non-json paylod b/c expect_json throws error
|
||||
# try json w/o required fields
|
||||
self.assertContains(
|
||||
self.client.post(url, json.dumps({'garbage': 1}),
|
||||
"application/json"),
|
||||
'Failed to save', status_code=400)
|
||||
self.assertContains(self.client.post(url, json.dumps({'garbage': 1}),
|
||||
"application/json"),
|
||||
'Failed to save', status_code=400)
|
||||
|
||||
# now try to update a non-existent update
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '9'})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '9'})
|
||||
content = 'blah blah'
|
||||
payload = {'content': content,
|
||||
'date': 'January 21, 2013'}
|
||||
@@ -89,8 +88,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,
|
||||
'provided_id': ''})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
self.assertContains(
|
||||
self.client.post(url, json.dumps(payload), "application/json"),
|
||||
@@ -101,8 +100,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,
|
||||
'provided_id': ''})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
payload = json.loads(resp.content)
|
||||
@@ -110,8 +109,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
|
||||
# now try to delete a non-existent update
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '19'})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '19'})
|
||||
payload = {'content': content,
|
||||
'date': 'January 21, 2013'}
|
||||
self.assertContains(self.client.delete(url), "delete", status_code=400)
|
||||
@@ -121,25 +120,25 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 28, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
payload = json.loads(resp.content)
|
||||
this_id = payload['id']
|
||||
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,
|
||||
'provided_id': ''})
|
||||
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,
|
||||
'provided_id': this_id})
|
||||
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)
|
||||
self.assertTrue(len(payload) == before_delete - 1)
|
||||
|
||||
96
cms/djangoapps/contentstore/tests/test_i18n.py
Normal file
96
cms/djangoapps/contentstore/tests/test_i18n.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from unittest import skip
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class InternationalizationTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests to validate Internationalization.
|
||||
"""
|
||||
|
||||
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.
|
||||
"""
|
||||
self.uname = 'testuser'
|
||||
self.email = 'test+courses@edx.org'
|
||||
self.password = 'foo'
|
||||
|
||||
# Create the use so we can log them in.
|
||||
self.user = User.objects.create_user(self.uname, self.email, self.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.course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
}
|
||||
|
||||
def test_course_plain_english(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
self.client = Client()
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(resp,
|
||||
'<h1 class="page-header">My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
def test_course_explicit_english(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
self.client = Client()
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
resp = self.client.get(reverse('index'),
|
||||
{},
|
||||
HTTP_ACCEPT_LANGUAGE='en'
|
||||
)
|
||||
|
||||
self.assertContains(resp,
|
||||
'<h1 class="page-header">My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
# ****
|
||||
# NOTE:
|
||||
# ****
|
||||
#
|
||||
# This test will break when we replace this fake 'test' language
|
||||
# with actual French. This test will need to be updated with
|
||||
# actual French at that time.
|
||||
# Test temporarily disable since it depends on creation of dummy strings
|
||||
@skip
|
||||
def test_course_with_accents(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
self.client = Client()
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
resp = self.client.get(reverse('index'),
|
||||
{},
|
||||
HTTP_ACCEPT_LANGUAGE='fr'
|
||||
)
|
||||
|
||||
TEST_STRING = u'<h1 class="title-1">' \
|
||||
+ u'My \xc7\xf6\xfcrs\xe9s L#' \
|
||||
+ u'</h1>'
|
||||
|
||||
self.assertContains(resp,
|
||||
TEST_STRING,
|
||||
status_code=200,
|
||||
html=True)
|
||||
29
cms/djangoapps/contentstore/tests/test_item.py
Normal file
29
cms/djangoapps/contentstore/tests/test_item.py
Normal file
@@ -0,0 +1,29 @@
|
||||
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
|
||||
|
||||
|
||||
class DeleteItem(CourseTestCase):
|
||||
def setUp(self):
|
||||
""" Creates the test course with a static page in it. """
|
||||
super(DeleteItem, self).setUp()
|
||||
self.course = CourseFactory.create(org='mitX', number='333', display_name='Dummy Course')
|
||||
|
||||
def testDeleteStaticPage(self):
|
||||
# Add static tab
|
||||
data = {
|
||||
'parent_location': 'i4x://mitX/333/course/Dummy_Course',
|
||||
'template': 'i4x://edx/templates/static_tab/Empty'
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), data)
|
||||
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).
|
||||
resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
""" Tests for utils. """
|
||||
from contentstore import utils
|
||||
import mock
|
||||
import collections
|
||||
import copy
|
||||
from django.test import TestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from .utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class LMSLinksTestCase(TestCase):
|
||||
@@ -24,13 +26,13 @@ class LMSLinksTestCase(TestCase):
|
||||
link = utils.get_lms_link_for_item(location, True)
|
||||
self.assertEquals(
|
||||
link,
|
||||
"//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us"
|
||||
"//preview/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us"
|
||||
)
|
||||
|
||||
|
||||
class UrlReverseTestCase(ModuleStoreTestCase):
|
||||
""" Tests for get_url_reverse """
|
||||
def test_CoursePageNames(self):
|
||||
def test_course_page_names(self):
|
||||
""" Test the defined course pages. """
|
||||
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
|
||||
|
||||
@@ -69,4 +71,80 @@ class UrlReverseTestCase(ModuleStoreTestCase):
|
||||
self.assertEquals(
|
||||
'https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about',
|
||||
utils.get_url_reverse('https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', course)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ExtraPanelTabTestCase(TestCase):
|
||||
""" Tests adding and removing extra course tabs. """
|
||||
|
||||
def get_tab_type_dicts(self, tab_types):
|
||||
""" Returns an array of tab dictionaries. """
|
||||
if tab_types:
|
||||
return [{'tab_type': tab_type} for tab_type in tab_types.split(',')]
|
||||
else:
|
||||
return []
|
||||
|
||||
def get_course_with_tabs(self, tabs=[]):
|
||||
""" Returns a mock course object with a tabs attribute. """
|
||||
course = collections.namedtuple('MockCourse', ['tabs'])
|
||||
if isinstance(tabs, basestring):
|
||||
course.tabs = self.get_tab_type_dicts(tabs)
|
||||
else:
|
||||
course.tabs = tabs
|
||||
return course
|
||||
|
||||
def test_add_extra_panel_tab(self):
|
||||
""" Tests if a tab can be added to a course tab list. """
|
||||
for tab_type in utils.EXTRA_TAB_PANELS.keys():
|
||||
tab = utils.EXTRA_TAB_PANELS.get(tab_type)
|
||||
|
||||
# test adding with changed = True
|
||||
for tab_setup in ['', 'x', 'x,y,z']:
|
||||
course = self.get_course_with_tabs(tab_setup)
|
||||
expected_tabs = copy.copy(course.tabs)
|
||||
expected_tabs.append(tab)
|
||||
changed, actual_tabs = utils.add_extra_panel_tab(tab_type, course)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(actual_tabs, expected_tabs)
|
||||
|
||||
# test adding with changed = False
|
||||
tab_test_setup = [
|
||||
[tab],
|
||||
[tab, self.get_tab_type_dicts('x,y,z')],
|
||||
[self.get_tab_type_dicts('x,y'), tab, self.get_tab_type_dicts('z')],
|
||||
[self.get_tab_type_dicts('x,y,z'), tab]]
|
||||
|
||||
for tab_setup in tab_test_setup:
|
||||
course = self.get_course_with_tabs(tab_setup)
|
||||
expected_tabs = copy.copy(course.tabs)
|
||||
changed, actual_tabs = utils.add_extra_panel_tab(tab_type, course)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(actual_tabs, expected_tabs)
|
||||
|
||||
def test_remove_extra_panel_tab(self):
|
||||
""" Tests if a tab can be removed from a course tab list. """
|
||||
for tab_type in utils.EXTRA_TAB_PANELS.keys():
|
||||
tab = utils.EXTRA_TAB_PANELS.get(tab_type)
|
||||
|
||||
# test removing with changed = True
|
||||
tab_test_setup = [
|
||||
[tab],
|
||||
[tab, self.get_tab_type_dicts('x,y,z')],
|
||||
[self.get_tab_type_dicts('x,y'), tab, self.get_tab_type_dicts('z')],
|
||||
[self.get_tab_type_dicts('x,y,z'), tab]]
|
||||
|
||||
for tab_setup in tab_test_setup:
|
||||
course = self.get_course_with_tabs(tab_setup)
|
||||
expected_tabs = [t for t in course.tabs if t != utils.EXTRA_TAB_PANELS.get(tab_type)]
|
||||
changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(actual_tabs, expected_tabs)
|
||||
|
||||
# test removing with changed = False
|
||||
for tab_setup in ['', 'x', 'x,y,z']:
|
||||
course = self.get_course_with_tabs(tab_setup)
|
||||
expected_tabs = copy.copy(course.tabs)
|
||||
changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(actual_tabs, expected_tabs)
|
||||
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
import json
|
||||
import shutil
|
||||
from django.test.client import Client
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from path import path
|
||||
import json
|
||||
from fs.osfs import OSFS
|
||||
import copy
|
||||
|
||||
from contentstore.utils import get_modulestore
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.store_utilities import clone_course
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.modulestore.django import modulestore, _MODULESTORES
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.templates import update_templates
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from .utils import ModuleStoreTestCase, parse_json, user, registration
|
||||
from .utils import parse_json, user, registration
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
@@ -84,6 +62,7 @@ class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
# Now make sure that the user is now actually activated
|
||||
self.assertTrue(user(email).is_active)
|
||||
|
||||
|
||||
class AuthTestCase(ContentStoreTestCase):
|
||||
"""Check that various permissions-related things work"""
|
||||
|
||||
@@ -101,9 +80,9 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
def test_public_pages_load(self):
|
||||
"""Make sure pages that don't require login load without error."""
|
||||
pages = (
|
||||
reverse('login'),
|
||||
reverse('signup'),
|
||||
)
|
||||
reverse('login'),
|
||||
reverse('signup'),
|
||||
)
|
||||
for page in pages:
|
||||
print "Checking '{0}'".format(page)
|
||||
self.check_page_get(page, 200)
|
||||
@@ -132,17 +111,29 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
# Now login should work
|
||||
self.login(self.email, self.pw)
|
||||
|
||||
def test_login_link_on_activation_age(self):
|
||||
self.create_account(self.username, self.email, self.pw)
|
||||
# we want to test the rendering of the activation page when the user isn't logged in
|
||||
self.client.logout()
|
||||
resp = self._activate_user(self.email)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# check the the HTML has links to the right login page. Note that this is merely a content
|
||||
# check and thus could be fragile should the wording change on this page
|
||||
expected = 'You can now <a href="' + reverse('login') + '">login</a>.'
|
||||
self.assertIn(expected, resp.content)
|
||||
|
||||
def test_private_pages_auth(self):
|
||||
"""Make sure pages that do require login work."""
|
||||
auth_pages = (
|
||||
reverse('index'),
|
||||
)
|
||||
)
|
||||
|
||||
# These are pages that should just load when the user is logged in
|
||||
# (no data needed)
|
||||
simple_auth_pages = (
|
||||
reverse('index'),
|
||||
)
|
||||
)
|
||||
|
||||
# need an activated user
|
||||
self.test_create_account()
|
||||
|
||||
@@ -2,112 +2,11 @@
|
||||
Utilities for contentstore tests
|
||||
'''
|
||||
|
||||
#pylint: disable=W0603
|
||||
|
||||
import json
|
||||
import copy
|
||||
from uuid import uuid4
|
||||
from django.test import TestCase
|
||||
from django.conf import settings
|
||||
|
||||
from student.models import Registration
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import xmodule.modulestore.django
|
||||
from xmodule.templates import update_templates
|
||||
|
||||
|
||||
class ModuleStoreTestCase(TestCase):
|
||||
""" Subclass for any test case that uses the mongodb
|
||||
module store. This populates a uniquely named modulestore
|
||||
collection with templates before running the TestCase
|
||||
and drops it they are finished. """
|
||||
|
||||
@staticmethod
|
||||
def flush_mongo_except_templates():
|
||||
'''
|
||||
Delete everything in the module store except templates
|
||||
'''
|
||||
modulestore = xmodule.modulestore.django.modulestore()
|
||||
|
||||
# This query means: every item in the collection
|
||||
# that is not a template
|
||||
query = {"_id.course": {"$ne": "templates"}}
|
||||
|
||||
# Remove everything except templates
|
||||
modulestore.collection.remove(query)
|
||||
|
||||
@staticmethod
|
||||
def load_templates_if_necessary():
|
||||
'''
|
||||
Load templates into the modulestore only if they do not already exist.
|
||||
We need the templates, because they are copied to create
|
||||
XModules such as sections and problems
|
||||
'''
|
||||
modulestore = xmodule.modulestore.django.modulestore()
|
||||
|
||||
# Count the number of templates
|
||||
query = {"_id.course": "templates"}
|
||||
num_templates = modulestore.collection.find(query).count()
|
||||
|
||||
if num_templates < 1:
|
||||
update_templates()
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
'''
|
||||
Flush the mongo store and set up templates
|
||||
'''
|
||||
|
||||
# Use a uuid to differentiate
|
||||
# the mongo collections on jenkins.
|
||||
cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE)
|
||||
test_modulestore = cls.orig_modulestore
|
||||
test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
|
||||
test_modulestore['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
|
||||
settings.MODULESTORE = test_modulestore
|
||||
|
||||
TestCase.setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
'''
|
||||
Revert to the old modulestore settings
|
||||
'''
|
||||
|
||||
# Clean up by dropping the collection
|
||||
modulestore = xmodule.modulestore.django.modulestore()
|
||||
modulestore.collection.drop()
|
||||
|
||||
# Restore the original modulestore settings
|
||||
settings.MODULESTORE = cls.orig_modulestore
|
||||
|
||||
def _pre_setup(self):
|
||||
'''
|
||||
Remove everything but the templates before each test
|
||||
'''
|
||||
|
||||
# Flush anything that is not a template
|
||||
ModuleStoreTestCase.flush_mongo_except_templates()
|
||||
|
||||
# Check that we have templates loaded; if not, load them
|
||||
ModuleStoreTestCase.load_templates_if_necessary()
|
||||
|
||||
# Call superclass implementation
|
||||
super(ModuleStoreTestCase, self)._pre_setup()
|
||||
|
||||
def _post_teardown(self):
|
||||
'''
|
||||
Flush everything we created except the templates
|
||||
'''
|
||||
# Flush anything that is not a template
|
||||
ModuleStoreTestCase.flush_mongo_except_templates()
|
||||
|
||||
# Call superclass implementation
|
||||
super(ModuleStoreTestCase, self)._post_teardown()
|
||||
|
||||
|
||||
def parse_json(response):
|
||||
"""Parse response, which is assumed to be json"""
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -9,7 +8,10 @@ import copy
|
||||
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
|
||||
|
||||
#In order to instantiate an open ended tab automatically, need to have this data
|
||||
OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"}
|
||||
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):
|
||||
"""
|
||||
@@ -86,11 +88,10 @@ def get_lms_link_for_item(location, preview=False, course_id=None):
|
||||
|
||||
if settings.LMS_BASE is not None:
|
||||
if preview:
|
||||
lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
|
||||
'preview.' + settings.LMS_BASE)
|
||||
lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
|
||||
else:
|
||||
lms_base = settings.LMS_BASE
|
||||
|
||||
|
||||
lms_link = "//{lms_base}/courses/{course_id}/jump_to/{location}".format(
|
||||
lms_base=lms_base,
|
||||
course_id=course_id,
|
||||
@@ -192,9 +193,11 @@ class CoursePageNames:
|
||||
CourseOutline = "course_index"
|
||||
Checklists = "checklists"
|
||||
|
||||
def add_open_ended_panel_tab(course):
|
||||
|
||||
def add_extra_panel_tab(tab_type, course):
|
||||
"""
|
||||
Used to add the open ended panel tab to a course if it does not exist.
|
||||
Used to add the panel tab to a course if it does not exist.
|
||||
@param tab_type: A string representing the tab type.
|
||||
@param course: A course object from the modulestore.
|
||||
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
|
||||
"""
|
||||
@@ -202,15 +205,19 @@ def add_open_ended_panel_tab(course):
|
||||
course_tabs = copy.copy(course.tabs)
|
||||
changed = False
|
||||
#Check to see if open ended panel is defined in the course
|
||||
if OPEN_ENDED_PANEL not in course_tabs:
|
||||
|
||||
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
|
||||
if tab_panel not in course_tabs:
|
||||
#Add panel to the tabs if it is not defined
|
||||
course_tabs.append(OPEN_ENDED_PANEL)
|
||||
course_tabs.append(tab_panel)
|
||||
changed = True
|
||||
return changed, course_tabs
|
||||
|
||||
def remove_open_ended_panel_tab(course):
|
||||
|
||||
def remove_extra_panel_tab(tab_type, course):
|
||||
"""
|
||||
Used to remove the open ended panel tab from a course if it exists.
|
||||
Used to remove the panel tab from a course if it exists.
|
||||
@param tab_type: A string representing the tab type.
|
||||
@param course: A course object from the modulestore.
|
||||
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
|
||||
"""
|
||||
@@ -218,8 +225,10 @@ def remove_open_ended_panel_tab(course):
|
||||
course_tabs = copy.copy(course.tabs)
|
||||
changed = False
|
||||
#Check to see if open ended panel is defined in the course
|
||||
if OPEN_ENDED_PANEL in course_tabs:
|
||||
|
||||
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
|
||||
if tab_panel in course_tabs:
|
||||
#Add panel to the tabs if it is not defined
|
||||
course_tabs = [ct for ct in course_tabs if ct!=OPEN_ENDED_PANEL]
|
||||
course_tabs = [ct for ct in course_tabs if ct != tab_panel]
|
||||
changed = True
|
||||
return changed, course_tabs
|
||||
|
||||
@@ -1,1697 +0,0 @@
|
||||
from util.json_request import expect_json
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import tarfile
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from uuid import uuid4
|
||||
from path import path
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from tempfile import mkdtemp
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
|
||||
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
|
||||
from PIL import Image
|
||||
|
||||
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.context_processors import csrf
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.core import Scope
|
||||
from xblock.runtime import KeyValueStore, DbModel, InvalidScopeError
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
import static_replace
|
||||
from external_auth.views import ssl_login_shortcut
|
||||
from xmodule.modulestore.mongo import MongoUsage
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from functools import partial
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
from auth.authz import is_user_in_course_group_role, 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 auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups
|
||||
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, \
|
||||
UnitState, get_course_for_item, get_url_reverse, add_open_ended_panel_tab, \
|
||||
remove_open_ended_panel_tab
|
||||
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from contentstore.course_info_model import get_course_updates, \
|
||||
update_course_updates, delete_course_update
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from contentstore.module_info_model import get_module_info, set_module_info
|
||||
from models.settings.course_details import CourseDetails, \
|
||||
CourseSettingsEncoder
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from contentstore.utils import get_modulestore
|
||||
from django.shortcuts import redirect
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
|
||||
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
|
||||
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable'] + OPEN_ENDED_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
# ==== Public views ==================================================
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def signup(request):
|
||||
"""
|
||||
Display the signup form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('signup.html', {'csrf': csrf_token})
|
||||
|
||||
|
||||
def old_login_redirect(request):
|
||||
'''
|
||||
Redirect to the active login url.
|
||||
'''
|
||||
return redirect('login', permanent=True)
|
||||
|
||||
|
||||
@ssl_login_shortcut
|
||||
@ensure_csrf_cookie
|
||||
def login_page(request):
|
||||
"""
|
||||
Display the login form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('login.html', {
|
||||
'csrf': csrf_token,
|
||||
'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE),
|
||||
})
|
||||
|
||||
|
||||
def howitworks(request):
|
||||
if request.user.is_authenticated():
|
||||
return index(request)
|
||||
else:
|
||||
return render_to_response('howitworks.html', {})
|
||||
|
||||
|
||||
# static/proof-of-concept views
|
||||
def ux_alerts(request):
|
||||
return render_to_response('ux-alerts.html', {})
|
||||
|
||||
|
||||
# ==== Views for any logged-in user ==================================
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def index(request):
|
||||
"""
|
||||
List all courses available to the logged in user
|
||||
"""
|
||||
courses = modulestore('direct').get_items(['i4x', None, None, 'course', None])
|
||||
|
||||
# filter out courses that we don't have access too
|
||||
def course_filter(course):
|
||||
return (has_access(request.user, course.location)
|
||||
and course.location.course != 'templates'
|
||||
and course.location.org != ''
|
||||
and course.location.course != ''
|
||||
and course.location.name != '')
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
# ==== Views with per-item permissions================================
|
||||
|
||||
|
||||
def has_access(user, location, role=STAFF_ROLE_NAME):
|
||||
'''
|
||||
Return True if user allowed to access this piece of data
|
||||
Note that the CMS permissions model is with respect to courses
|
||||
There is a super-admin permissions if user.is_staff is set
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
I'm presuming that the course instructor (formally known as admin)
|
||||
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
|
||||
has all the rights that STAFF do
|
||||
'''
|
||||
course_location = get_course_location_for_item(location)
|
||||
_has_access = is_user_in_course_group_role(user, course_location, role)
|
||||
# if we're not in STAFF, perhaps we're in INSTRUCTOR groups
|
||||
if not _has_access and role == STAFF_ROLE_NAME:
|
||||
_has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME)
|
||||
return _has_access
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable course overview.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
lms_link = get_lms_link_for_item(location)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course = modulestore().get_item(location, depth=3)
|
||||
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...
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
# check that we have permissions to edit this item
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
|
||||
preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
|
||||
|
||||
# make sure that location references a 'sequential', otherwise return BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
parent_locs = modulestore().get_parent_locations(location, None)
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error('Multiple (or none) parents have been found for {0}'.format(location))
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a more normalized UI
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in item.fields
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and
|
||||
field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
return render_to_response('edit_subsection.html',
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_unit(request, location):
|
||||
"""
|
||||
Display an editing page for the specified module.
|
||||
|
||||
Expects a GET request with the parameter 'id'.
|
||||
|
||||
id: A Location URL
|
||||
"""
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
# 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
|
||||
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
|
||||
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)
|
||||
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,
|
||||
template.cms.empty,
|
||||
))
|
||||
|
||||
components = [
|
||||
component.location.url()
|
||||
for component
|
||||
in item.get_children()
|
||||
]
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(location, None)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
|
||||
containing_section_locs = modulestore().get_parent_locations(containing_subsection.location, None)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
|
||||
'preview.' + settings.LMS_BASE)
|
||||
|
||||
preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index)
|
||||
|
||||
unit_state = compute_unit_state(item)
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'active_tab': 'courseware',
|
||||
'unit': item,
|
||||
'unit_location': location,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'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'),
|
||||
'unit_state': unit_state,
|
||||
'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
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()
|
||||
|
||||
component = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('component.html', {
|
||||
'preview': get_module_previews(request, component)[0],
|
||||
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
'''
|
||||
CRUD operations on assignment types for sections and subsections and anything else gradable.
|
||||
'''
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
raise 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")
|
||||
|
||||
|
||||
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
|
||||
def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
"""
|
||||
Dispatch an AJAX action to a preview XModule
|
||||
|
||||
Expects a POST request, and passes the arguments to the module
|
||||
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: The Location of the module to dispatch to
|
||||
dispatch: The action to execute
|
||||
"""
|
||||
|
||||
descriptor = modulestore().get_item(location)
|
||||
instance = load_preview_module(request, preview_id, descriptor)
|
||||
# Let the module handle the AJAX
|
||||
try:
|
||||
ajax_return = instance.handle_ajax(dispatch, request.POST)
|
||||
|
||||
except NotFoundError:
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
raise Http404
|
||||
|
||||
except ProcessingError:
|
||||
log.warning("Module raised an error while processing AJAX request",
|
||||
exc_info=True)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
except:
|
||||
log.exception("error processing ajax call")
|
||||
raise
|
||||
|
||||
return HttpResponse(ajax_return)
|
||||
|
||||
|
||||
def render_from_lms(template_name, dictionary, context=None, namespace='main'):
|
||||
"""
|
||||
Render a template using the LMS MAKO_TEMPLATES
|
||||
"""
|
||||
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
|
||||
|
||||
|
||||
class SessionKeyValueStore(KeyValueStore):
|
||||
def __init__(self, request, model_data):
|
||||
self._model_data = model_data
|
||||
self._session = request.session
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
return self._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
|
||||
except (KeyError, InvalidScopeError):
|
||||
self._session[tuple(key)] = value
|
||||
|
||||
def delete(self, key):
|
||||
try:
|
||||
del self._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
|
||||
|
||||
|
||||
def preview_module_system(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a ModuleSystem for the specified descriptor that is specialized for
|
||||
rendering module previews.
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
|
||||
def preview_model_data(descriptor):
|
||||
return DbModel(
|
||||
SessionKeyValueStore(request, descriptor._model_data),
|
||||
descriptor.module_class,
|
||||
preview_id,
|
||||
MongoUsage(preview_id, descriptor.location.url()),
|
||||
)
|
||||
|
||||
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?
|
||||
track_function=lambda type, event: None,
|
||||
filestore=descriptor.system.resources_fs,
|
||||
get_module=partial(get_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
|
||||
user=request.user,
|
||||
xblock_model_data=preview_model_data,
|
||||
)
|
||||
|
||||
|
||||
def get_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily
|
||||
from the set of preview data for the descriptor specified by Location
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: A Location
|
||||
"""
|
||||
|
||||
return load_preview_module(request, preview_id, descriptor)
|
||||
|
||||
|
||||
def load_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
instance_state: An instance state string
|
||||
shared_state: A shared state string
|
||||
"""
|
||||
system = preview_module_system(request, preview_id, descriptor)
|
||||
try:
|
||||
module = descriptor.xmodule(system)
|
||||
except:
|
||||
log.debug("Unable to load preview module", exc_info=True)
|
||||
module = ErrorDescriptor.from_descriptor(
|
||||
descriptor,
|
||||
error_msg=exc_info_to_str(sys.exc_info())
|
||||
).xmodule(system)
|
||||
|
||||
# cdodge: Special case
|
||||
if module.location.category == 'static_tab':
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_tab_display.html",
|
||||
)
|
||||
else:
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_display.html",
|
||||
)
|
||||
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
getattr(module, 'data_dir', module.location.course),
|
||||
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def get_module_previews(request, descriptor):
|
||||
"""
|
||||
Returns a list of preview XModule html contents. One preview is returned for each
|
||||
pair of states returned by get_sample_state() for the supplied descriptor.
|
||||
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
preview_html = []
|
||||
for idx, (instance_state, shared_state) in enumerate(descriptor.get_sample_state()):
|
||||
module = load_preview_module(request, str(idx), descriptor)
|
||||
preview_html.append(module.get_html())
|
||||
return preview_html
|
||||
|
||||
|
||||
def _xmodule_recurse(item, action):
|
||||
for child in item.get_children():
|
||||
_xmodule_recurse(child, action)
|
||||
|
||||
action(item)
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def delete_item(request):
|
||||
item_location = request.POST['id']
|
||||
item_loc = Location(item_location)
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# optional parameter to delete all children (default False)
|
||||
delete_children = request.POST.get('delete_children', False)
|
||||
delete_all_versions = request.POST.get('delete_all_versions', False)
|
||||
|
||||
item = modulestore().get_item(item_location)
|
||||
|
||||
store = get_modulestore(item_loc)
|
||||
|
||||
|
||||
# @TODO: this probably leaves draft items dangling. My preferance would be for the semantic to be
|
||||
# if item.location.revision=None, then delete both draft and published version
|
||||
# if caller wants to only delete the draft than the caller should put item.location.revision='draft'
|
||||
|
||||
if delete_children:
|
||||
_xmodule_recurse(item, lambda i: store.delete_item(i.location))
|
||||
else:
|
||||
store.delete_item(item.location)
|
||||
|
||||
# cdodge: this is a bit of a hack until I can talk with Cale about the
|
||||
# semantics of delete_item whereby the store is draft aware. Right now calling
|
||||
# delete_item on a vertical tries to delete the draft version leaving the
|
||||
# requested delete to never occur
|
||||
if item.location.revision is None and item.location.category == 'vertical' and delete_all_versions:
|
||||
modulestore('direct').delete_item(item.location)
|
||||
|
||||
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
|
||||
if delete_all_versions:
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
|
||||
|
||||
for parent_loc in parent_locs:
|
||||
parent = modulestore('direct').get_item(parent_loc)
|
||||
item_url = item_loc.url()
|
||||
if item_url in parent.children:
|
||||
children = parent.children
|
||||
children.remove(item_url)
|
||||
parent.children = children
|
||||
modulestore('direct').update_children(parent.location, parent.children)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def save_item(request):
|
||||
item_location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
store = get_modulestore(Location(item_location));
|
||||
|
||||
if request.POST.get('data') is not None:
|
||||
data = request.POST['data']
|
||||
store.update_item(item_location, data)
|
||||
|
||||
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
|
||||
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
|
||||
# deleting the children object from the children collection
|
||||
if 'children' in request.POST and request.POST['children'] is not None:
|
||||
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
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
|
||||
# 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():
|
||||
|
||||
# let's strip out any metadata fields from the postback which have been identified as system metadata
|
||||
# and therefore should not be user-editable, so we should accept them back from the client
|
||||
if metadata_key in existing_item.system_metadata_fields:
|
||||
del posted_metadata[metadata_key]
|
||||
elif 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]
|
||||
else:
|
||||
existing_item._model_data[metadata_key] = value
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# 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)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def unpublish_unit(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def clone_item(request):
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
template = Location(request.POST['template'])
|
||||
|
||||
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)
|
||||
|
||||
new_item = get_modulestore(template).clone_item(template, dest_location)
|
||||
|
||||
# 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
|
||||
|
||||
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
|
||||
|
||||
def upload_asset(request, org, course, coursename):
|
||||
'''
|
||||
cdodge: 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)
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its existance
|
||||
|
||||
try:
|
||||
item = modulestore().get_item(location)
|
||||
except:
|
||||
# no return it as a Bad Request response
|
||||
logging.error('Could not find course' + location)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# 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()
|
||||
|
||||
content_loc = StaticContent.compute_location(org, course, filename)
|
||||
content = StaticContent(content_loc, filename, mime_type, filedata)
|
||||
|
||||
# first let's see if a thumbnail can be created
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content)
|
||||
|
||||
# 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)
|
||||
# now store thumbnail location only if we could create it
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
# then commit the content
|
||||
contentstore().save(content)
|
||||
del_cached_content(content.location)
|
||||
|
||||
# readback the saved content - we need the database timestamp
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = HttpResponse(json.dumps(response_payload))
|
||||
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
|
||||
return response
|
||||
|
||||
|
||||
'''
|
||||
This view will return all CMS users who are editors for the specified course
|
||||
'''
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def manage_users(request, location):
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
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('/'),
|
||||
'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'),
|
||||
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
|
||||
'request_user_id': request.user.id
|
||||
})
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
'''
|
||||
This POST-back view will add a user - specified by email - to the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def add_user(request, location):
|
||||
email = request.POST["email"]
|
||||
|
||||
if email == '':
|
||||
return create_json_response('Please specify an email address.')
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# 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))
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
'''
|
||||
This POST-back view will remove a user - specified by email - from the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def remove_user(request, location):
|
||||
email = request.POST["email"]
|
||||
|
||||
# check that logged in user has admin permissions on this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# make sure we're not removing ourselves
|
||||
if user.id == request.user.id:
|
||||
raise PermissionDenied()
|
||||
|
||||
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
|
||||
|
||||
# points to the temporary course landing page with log in and sign up
|
||||
def landing(request, org, course, coursename):
|
||||
return render_to_response('temp-course-landing.html', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def static_pages(request, org, course, coursename):
|
||||
|
||||
location = get_location_and_verify_access(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', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def reorder_static_tabs(request):
|
||||
tabs = request.POST['tabs']
|
||||
course = get_course_for_item(tabs[0])
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# get list of existing static tabs in course
|
||||
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number
|
||||
# that we know about) otherwise we can drop some!
|
||||
|
||||
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# load all reference tabs, return BadRequest if we can't find any of them
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(Location(tab))
|
||||
if item is None:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name})
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
course_item = modulestore().get_item(location)
|
||||
static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
|
||||
components = [
|
||||
static_tab.location.url()
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course_item,
|
||||
'components': components
|
||||
})
|
||||
|
||||
|
||||
def not_found(request):
|
||||
return render_to_response('error.html', {'error': '404'})
|
||||
|
||||
|
||||
def server_error(request):
|
||||
return render_to_response('error.html', {'error': '500'})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info(request, org, course, name, provided_id=None):
|
||||
"""
|
||||
Send models and views as well as html for editing the course info to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
# get current updates
|
||||
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)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info_updates(request, org, course, provided_id=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
|
||||
"""
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
|
||||
# Possibly due to my removing the seemingly redundant pattern in urls.py
|
||||
if provided_id == '':
|
||||
provided_id = None
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
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':
|
||||
try:
|
||||
return HttpResponse(json.dumps(delete_course_update(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to delete",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'POST':
|
||||
try:
|
||||
return HttpResponse(json.dumps(update_course_updates(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to save",
|
||||
content_type="text/plain")
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def module_info(request, module_location):
|
||||
location = Location(module_location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
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))
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
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()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'details_url': reverse(course_settings_updates,
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"})
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_graders_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_location' : location,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_advanced_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the advanced course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location' : location,
|
||||
'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_settings_updates(request, org, course, name, section):
|
||||
"""
|
||||
restful CRUD operations on course settings. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
section: one of details, faculty, grading, problems, discussions
|
||||
"""
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if section == 'details':
|
||||
manager = CourseDetails
|
||||
elif section == 'grading':
|
||||
manager = CourseGradingModel
|
||||
else: return
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_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":
|
||||
# ??? 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")
|
||||
|
||||
|
||||
# # NB: expect_json failed on ["key", "key2"] and json payload
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
"""
|
||||
restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
|
||||
the payload is either a key or a list of keys to delete.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
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':
|
||||
# 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)
|
||||
#Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
#Check to see if the user instantiated any advanced components. This is a hack to add the open ended panel tab
|
||||
#to a course automatically if the user has indicated that they want to edit the combinedopenended or peergrading
|
||||
#module, and to remove it if they have removed the open ended elements.
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
|
||||
#Check to see if the user instantiated any open ended components
|
||||
found_oe_type = False
|
||||
#Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
for oe_type in OPEN_ENDED_COMPONENT_TYPES:
|
||||
if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
#Add an open ended tab to the course if needed
|
||||
changed, new_tabs = add_open_ended_panel_tab(course_module)
|
||||
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
#Set this flag to avoid the open ended tab removal code below.
|
||||
found_oe_type = True
|
||||
break
|
||||
#If we did not find an open ended module type in the advanced settings,
|
||||
# we may need to remove the open ended tab from the course.
|
||||
if not found_oe_type:
|
||||
#Remove open ended tab to the course if needed
|
||||
changed, new_tabs = remove_open_ended_panel_tab(course_module)
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs))
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def get_checklists(request, org, course, name):
|
||||
"""
|
||||
Send models, views, and html for displaying the course checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(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
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
'context_course': course_module,
|
||||
'checklists': checklists
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def update_checklist(request, org, course, name, checklist_index=None):
|
||||
"""
|
||||
restful CRUD operations on course checklists. The payload is a json rep of
|
||||
the modified checklist. For PUT or POST requests, the index of the
|
||||
checklist being modified must be included; the returned payload will
|
||||
be just that one checklist. For GET requests, the returned payload
|
||||
is a json representation of the list of all checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
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 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)
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
"Could not save checklist state because the checklist index was out of range or unspecified.",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'GET':
|
||||
# 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:
|
||||
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")
|
||||
|
||||
|
||||
def expand_checklist_action_urls(course_module):
|
||||
"""
|
||||
Gets the checklists out of the course module and expands their action urls
|
||||
if they have not yet been expanded.
|
||||
|
||||
Returns the checklists with modified urls, as well as a boolean
|
||||
indicating whether or not the checklists were modified.
|
||||
"""
|
||||
checklists = course_module.checklists
|
||||
modified = False
|
||||
for checklist in checklists:
|
||||
if not checklist.get('action_urls_expanded', False):
|
||||
for item in checklist.get('items'):
|
||||
item['action_url'] = get_url_reverse(item.get('action_url'), course_module)
|
||||
checklist['action_urls_expanded'] = True
|
||||
modified = True
|
||||
|
||||
return checklists, modified
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def asset_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable asset library
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
course_reference = StaticContent.compute_location(org, course, name)
|
||||
assets = contentstore().get_all_content_for_course(course_reference)
|
||||
|
||||
# sort in reverse upload date order
|
||||
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
|
||||
|
||||
thumbnails = contentstore().get_all_content_thumbnails_for_course(course_reference)
|
||||
asset_display = []
|
||||
for asset in assets:
|
||||
id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
|
||||
|
||||
asset_location = StaticContent.compute_location(id['org'], id['course'], id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
|
||||
display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
# points to the temporary edge page
|
||||
def edge(request):
|
||||
return render_to_response('university_profiles/edge.html', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
|
||||
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff:
|
||||
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')
|
||||
|
||||
try:
|
||||
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
|
||||
except InvalidLocationError as e:
|
||||
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" + display_name + "'.\n\n" + e.message}))
|
||||
|
||||
# see if the course already exists
|
||||
existing_course = None
|
||||
try:
|
||||
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.'}))
|
||||
|
||||
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.'}))
|
||||
|
||||
new_course = modulestore('direct').clone_item(template, dest_location)
|
||||
|
||||
if display_name is not None:
|
||||
new_course.display_name = display_name
|
||||
|
||||
# set a default start date to now
|
||||
new_course.start = time.gmtime()
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
create_all_course_groups(request.user, new_course.location)
|
||||
|
||||
return HttpResponse(json.dumps({'id': new_course.location.url()}))
|
||||
|
||||
|
||||
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
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"},
|
||||
{"type": "progress", "name": "Progress"}]
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'POST':
|
||||
filename = request.FILES['course-data'].name
|
||||
|
||||
if not filename.endswith('.tar.gz'):
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'We only support uploading a .tar.gz file.'}))
|
||||
|
||||
data_root = path(settings.GITHUB_REPO_ROOT)
|
||||
|
||||
course_subdir = "{0}-{1}-{2}".format(org, course, name)
|
||||
course_dir = data_root / course_subdir
|
||||
if not course_dir.isdir():
|
||||
os.mkdir(course_dir)
|
||||
|
||||
temp_filepath = course_dir / filename
|
||||
|
||||
logging.debug('importing course to {0}'.format(temp_filepath))
|
||||
|
||||
# stream out the uploaded files in chunks to disk
|
||||
temp_file = open(temp_filepath, 'wb+')
|
||||
for chunk in request.FILES['course-data'].chunks():
|
||||
temp_file.write(chunk)
|
||||
temp_file.close()
|
||||
|
||||
tf = tarfile.open(temp_filepath)
|
||||
tf.extractall(course_dir + '/')
|
||||
|
||||
# find the 'course.xml' file
|
||||
|
||||
for r, d, f in os.walk(course_dir):
|
||||
for files in f:
|
||||
if files == 'course.xml':
|
||||
break
|
||||
if files == 'course.xml':
|
||||
break
|
||||
|
||||
if files != '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(r))
|
||||
|
||||
if r != course_dir:
|
||||
for fname in os.listdir(r):
|
||||
shutil.move(r / fname, course_dir)
|
||||
|
||||
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))
|
||||
|
||||
# we can blow this away when we're done importing.
|
||||
shutil.rmtree(course_dir)
|
||||
|
||||
logging.debug('new course at {0}'.format(course_items[0].location))
|
||||
|
||||
create_all_course_groups(request.user, course_items[0].location)
|
||||
|
||||
return HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
else:
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'import',
|
||||
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
loc = Location(location)
|
||||
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
|
||||
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name)
|
||||
# filename = root_dir / name + '.tar.gz'
|
||||
|
||||
logging.debug('tar file being generated at {0}'.format(export_file.name))
|
||||
tf = tarfile.open(name=export_file.name, mode='w:gz')
|
||||
tf.add(root_dir / name, arcname=name)
|
||||
tf.close()
|
||||
|
||||
# remove temp dir
|
||||
shutil.rmtree(root_dir / name)
|
||||
|
||||
wrapper = FileWrapper(export_file)
|
||||
response = HttpResponse(wrapper, content_type='application/x-tgz')
|
||||
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
|
||||
response['Content-Length'] = os.path.getsize(export_file.name)
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def export_course(request, org, course, name):
|
||||
|
||||
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': ''
|
||||
})
|
||||
|
||||
|
||||
def event(request):
|
||||
'''
|
||||
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
|
||||
console logs don't get distracted :-)
|
||||
'''
|
||||
return HttpResponse(True)
|
||||
|
||||
|
||||
def render_404(request):
|
||||
return HttpResponseNotFound(render_to_string('404.html', {}))
|
||||
|
||||
|
||||
def render_500(request):
|
||||
return HttpResponseServerError(render_to_string('500.html', {}))
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
return location
|
||||
|
||||
|
||||
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
|
||||
15
cms/djangoapps/contentstore/views/__init__.py
Normal file
15
cms/djangoapps/contentstore/views/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# pylint: disable=W0401, W0511
|
||||
|
||||
# Disable warnings about import from wildcard
|
||||
# All files below declare exports with __all__
|
||||
from .assets import *
|
||||
from .checklist import *
|
||||
from .component import *
|
||||
from .course import *
|
||||
from .error import *
|
||||
from .item import *
|
||||
from .preview import *
|
||||
from .public import *
|
||||
from .user import *
|
||||
from .tabs import *
|
||||
from .requests import *
|
||||
36
cms/djangoapps/contentstore/views/access.py
Normal file
36
cms/djangoapps/contentstore/views/access.py
Normal file
@@ -0,0 +1,36 @@
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
return location
|
||||
|
||||
|
||||
def has_access(user, location, role=STAFF_ROLE_NAME):
|
||||
'''
|
||||
Return True if user allowed to access this piece of data
|
||||
Note that the CMS permissions model is with respect to courses
|
||||
There is a super-admin permissions if user.is_staff is set
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
I'm presuming that the course instructor (formally known as admin)
|
||||
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
|
||||
has all the rights that STAFF do
|
||||
'''
|
||||
course_location = get_course_location_for_item(location)
|
||||
_has_access = is_user_in_course_group_role(user, course_location, role)
|
||||
# if we're not in STAFF, perhaps we're in INSTRUCTOR groups
|
||||
if not _has_access and role == STAFF_ROLE_NAME:
|
||||
_has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME)
|
||||
return _has_access
|
||||
263
cms/djangoapps/contentstore/views/assets.py
Normal file
263
cms/djangoapps/contentstore/views/assets.py
Normal file
@@ -0,0 +1,263 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import shutil
|
||||
from tempfile import mkdtemp
|
||||
from path import path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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 mitxmako.shortcuts import render_to_response
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from auth.authz import create_all_course_groups
|
||||
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
from ..utils import get_url_reverse
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
|
||||
__all__ = ['asset_index', 'upload_asset', 'import_course', 'generate_export_course', 'export_course']
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def asset_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable asset library
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
course_reference = StaticContent.compute_location(org, course, name)
|
||||
assets = contentstore().get_all_content_for_course(course_reference)
|
||||
|
||||
# sort in reverse upload date order
|
||||
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
|
||||
|
||||
asset_display = []
|
||||
for asset in assets:
|
||||
asset_id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
|
||||
|
||||
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
|
||||
display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
def upload_asset(request, org, course, coursename):
|
||||
'''
|
||||
cdodge: 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)
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its existance
|
||||
|
||||
try:
|
||||
modulestore().get_item(location)
|
||||
except:
|
||||
# no return it as a Bad Request response
|
||||
logging.error('Could not find course' + location)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# 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()
|
||||
|
||||
content_loc = StaticContent.compute_location(org, course, filename)
|
||||
content = StaticContent(content_loc, filename, mime_type, filedata)
|
||||
|
||||
# first let's see if a thumbnail can be created
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content)
|
||||
|
||||
# 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)
|
||||
# now store thumbnail location only if we could create it
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
# then commit the content
|
||||
contentstore().save(content)
|
||||
del_cached_content(content.location)
|
||||
|
||||
# readback the saved content - we need the database timestamp
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = HttpResponse(json.dumps(response_payload))
|
||||
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'POST':
|
||||
filename = request.FILES['course-data'].name
|
||||
|
||||
if not filename.endswith('.tar.gz'):
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'We only support uploading a .tar.gz file.'}))
|
||||
|
||||
data_root = path(settings.GITHUB_REPO_ROOT)
|
||||
|
||||
course_subdir = "{0}-{1}-{2}".format(org, course, name)
|
||||
course_dir = data_root / course_subdir
|
||||
if not course_dir.isdir():
|
||||
os.mkdir(course_dir)
|
||||
|
||||
temp_filepath = course_dir / filename
|
||||
|
||||
logging.debug('importing course to {0}'.format(temp_filepath))
|
||||
|
||||
# stream out the uploaded files in chunks to disk
|
||||
temp_file = open(temp_filepath, 'wb+')
|
||||
for chunk in request.FILES['course-data'].chunks():
|
||||
temp_file.write(chunk)
|
||||
temp_file.close()
|
||||
|
||||
tar_file = tarfile.open(temp_filepath)
|
||||
tar_file.extractall(course_dir + '/')
|
||||
|
||||
# find the 'course.xml' file
|
||||
|
||||
for dirpath, _dirnames, filenames in os.walk(course_dir):
|
||||
for files in filenames:
|
||||
if files == 'course.xml':
|
||||
break
|
||||
if files == 'course.xml':
|
||||
break
|
||||
|
||||
if files != '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))
|
||||
|
||||
if dirpath != course_dir:
|
||||
for fname in os.listdir(dirpath):
|
||||
shutil.move(dirpath / fname, course_dir)
|
||||
|
||||
_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),
|
||||
draft_store=modulestore())
|
||||
|
||||
# we can blow this away when we're done importing.
|
||||
shutil.rmtree(course_dir)
|
||||
|
||||
logging.debug('new course at {0}'.format(course_items[0].location))
|
||||
|
||||
create_all_course_groups(request.user, course_items[0].location)
|
||||
|
||||
return HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
else:
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'import',
|
||||
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
loc = Location(location)
|
||||
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
|
||||
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
#filename = root_dir / name + '.tar.gz'
|
||||
|
||||
logging.debug('tar file being generated at {0}'.format(export_file.name))
|
||||
tar_file = tarfile.open(name=export_file.name, mode='w:gz')
|
||||
tar_file.add(root_dir / name, arcname=name)
|
||||
tar_file.close()
|
||||
|
||||
# remove temp dir
|
||||
shutil.rmtree(root_dir / name)
|
||||
|
||||
wrapper = FileWrapper(export_file)
|
||||
response = HttpResponse(wrapper, content_type='application/x-tgz')
|
||||
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
|
||||
response['Content-Length'] = os.path.getsize(export_file.name)
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def export_course(request, org, course, name):
|
||||
|
||||
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': ''
|
||||
})
|
||||
104
cms/djangoapps/contentstore/views/checklist.py
Normal file
104
cms/djangoapps/contentstore/views/checklist.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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
|
||||
|
||||
__all__ = ['get_checklists', 'update_checklist']
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def get_checklists(request, org, course, name):
|
||||
"""
|
||||
Send models, views, and html for displaying the course checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(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
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
'context_course': course_module,
|
||||
'checklists': checklists
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def update_checklist(request, org, course, name, checklist_index=None):
|
||||
"""
|
||||
restful CRUD operations on course checklists. The payload is a json rep of
|
||||
the modified checklist. For PUT or POST requests, the index of the
|
||||
checklist being modified must be included; the returned payload will
|
||||
be just that one checklist. For GET requests, the returned payload
|
||||
is a json representation of the list of all checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
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 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)
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
"Could not save checklist state because the checklist index was out of range or unspecified.",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'GET':
|
||||
# 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:
|
||||
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")
|
||||
|
||||
|
||||
def expand_checklist_action_urls(course_module):
|
||||
"""
|
||||
Gets the checklists out of the course module and expands their action urls
|
||||
if they have not yet been expanded.
|
||||
|
||||
Returns the checklists with modified urls, as well as a boolean
|
||||
indicating whether or not the checklists were modified.
|
||||
"""
|
||||
checklists = course_module.checklists
|
||||
modified = False
|
||||
for checklist in checklists:
|
||||
if not checklist.get('action_urls_expanded', False):
|
||||
for item in checklist.get('items'):
|
||||
item['action_url'] = get_url_reverse(item.get('action_url'), course_module)
|
||||
checklist['action_urls_expanded'] = True
|
||||
modified = True
|
||||
|
||||
return checklists, modified
|
||||
302
cms/djangoapps/contentstore/views/component.py
Normal file
302
cms/djangoapps/contentstore/views/component.py
Normal file
@@ -0,0 +1,302 @@
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
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 contentstore.module_info_model import get_module_info, set_module_info
|
||||
from contentstore.utils import get_modulestore, get_lms_link_for_item, \
|
||||
compute_unit_state, UnitState, get_course_for_item
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
from .requests import get_request_method, _xmodule_recurse
|
||||
from .access import has_access
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
'edit_subsection',
|
||||
'edit_unit',
|
||||
'assignment_type_update',
|
||||
'create_draft',
|
||||
'publish_draft',
|
||||
'unpublish_unit',
|
||||
'module_info']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
|
||||
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
NOTE_COMPONENT_TYPES = ['notes']
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud', 'videoalpha'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
# check that we have permissions to edit this item
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
|
||||
preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
|
||||
|
||||
# make sure that location references a 'sequential', otherwise return BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
parent_locs = modulestore().get_parent_locations(location, None)
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error('Multiple (or none) parents have been found for {0}'.format(location))
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a more normalized UI
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in item.fields
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
return render_to_response('edit_subsection.html',
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_unit(request, location):
|
||||
"""
|
||||
Display an editing page for the specified module.
|
||||
|
||||
Expects a GET request with the parameter 'id'.
|
||||
|
||||
id: A Location URL
|
||||
"""
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
# 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
|
||||
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
|
||||
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)
|
||||
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
|
||||
in item.get_children()
|
||||
]
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(location, None)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
|
||||
containing_section_locs = modulestore().get_parent_locations(containing_subsection.location, None)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
|
||||
|
||||
preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index)
|
||||
|
||||
unit_state = compute_unit_state(item)
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'active_tab': 'courseware',
|
||||
'unit': item,
|
||||
'unit_location': location,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'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'),
|
||||
'unit_state': unit_state,
|
||||
'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
'''
|
||||
CRUD operations on assignment types for sections and subsections and anything else gradable.
|
||||
'''
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
raise 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")
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# 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)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def unpublish_unit(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def module_info(request, module_location):
|
||||
location = Location(module_location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
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))
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
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()
|
||||
408
cms/djangoapps/contentstore/views/course.py
Normal file
408
cms/djangoapps/contentstore/views/course.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.core.urlresolvers import reverse
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
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 models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from auth.authz import create_all_course_groups
|
||||
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 django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
# TODO: should explicitly enumerate exports with __all__
|
||||
|
||||
__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']
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable course overview.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
lms_link = get_lms_link_for_item(location)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course = modulestore().get_item(location, depth=3)
|
||||
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...
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
|
||||
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff:
|
||||
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')
|
||||
|
||||
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}))
|
||||
|
||||
# see if the course already exists
|
||||
existing_course = None
|
||||
try:
|
||||
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.'}))
|
||||
|
||||
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.'}))
|
||||
|
||||
new_course = modulestore('direct').clone_item(template, 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 = time.gmtime()
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
create_all_course_groups(request.user, new_course.location)
|
||||
|
||||
# seed the forums
|
||||
seed_permissions_roles(new_course.location.course_id)
|
||||
|
||||
return HttpResponse(json.dumps({'id': new_course.location.url()}))
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info(request, org, course, name, provided_id=None):
|
||||
"""
|
||||
Send models and views as well as html for editing the course info to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
# get current updates
|
||||
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)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info_updates(request, org, course, provided_id=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
|
||||
"""
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
|
||||
# Possibly due to my removing the seemingly redundant pattern in urls.py
|
||||
if provided_id == '':
|
||||
provided_id = None
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
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':
|
||||
try:
|
||||
return HttpResponse(json.dumps(delete_course_update(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to delete",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'POST':
|
||||
try:
|
||||
return HttpResponse(json.dumps(update_course_updates(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to save",
|
||||
content_type="text/plain")
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'details_url': reverse(course_settings_updates,
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"})
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_graders_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_advanced_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the advanced course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_settings_updates(request, org, course, name, section):
|
||||
"""
|
||||
restful CRUD operations on course settings. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
section: one of details, faculty, grading, problems, discussions
|
||||
"""
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if section == 'details':
|
||||
manager = CourseDetails
|
||||
elif section == 'grading':
|
||||
manager = CourseGradingModel
|
||||
else:
|
||||
return
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_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":
|
||||
# ??? 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")
|
||||
|
||||
|
||||
# # NB: expect_json failed on ["key", "key2"] and json payload
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
"""
|
||||
restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
|
||||
the payload is either a key or a list of keys to delete.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
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':
|
||||
# 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)
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
|
||||
#Check to see if the user instantiated any advanced components. This is a hack
|
||||
#that does the following :
|
||||
# 1) adds/removes the open ended panel tab to a course automatically if the user
|
||||
# has indicated that they want to edit the combinedopendended or peergrading module
|
||||
# 2) adds/removes the notes panel tab to a course automatically if the user has
|
||||
# indicated that they want the notes module enabled in their course
|
||||
# TODO refactor the above into distinct advanced policy settings
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
|
||||
#Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
#Maps tab types to components
|
||||
tab_component_map = {
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes': NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
|
||||
#Check to see if the user instantiated any notes or open ended components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
for ac_type in component_types:
|
||||
if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
#Add tab to the course if needed
|
||||
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
|
||||
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
#Set this flag to avoid the tab removal code below.
|
||||
found_ac_type = True
|
||||
break
|
||||
#If we did not find a module type in the advanced settings,
|
||||
# we may need to remove the tab from the course.
|
||||
if not found_ac_type:
|
||||
#Remove tab from the course if needed
|
||||
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should *not* be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs))
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
20
cms/djangoapps/contentstore/views/error.py
Normal file
20
cms/djangoapps/contentstore/views/error.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.http import HttpResponseServerError, HttpResponseNotFound
|
||||
from mitxmako.shortcuts import render_to_string, render_to_response
|
||||
|
||||
__all__ = ['not_found', 'server_error', 'render_404', 'render_500']
|
||||
|
||||
|
||||
def not_found(request):
|
||||
return render_to_response('error.html', {'error': '404'})
|
||||
|
||||
|
||||
def server_error(request):
|
||||
return render_to_response('error.html', {'error': '500'})
|
||||
|
||||
|
||||
def render_404(request):
|
||||
return HttpResponseNotFound(render_to_string('404.html', {}))
|
||||
|
||||
|
||||
def render_500(request):
|
||||
return HttpResponseServerError(render_to_string('500.html', {}))
|
||||
138
cms/djangoapps/contentstore/views/item.py
Normal file
138
cms/djangoapps/contentstore/views/item.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from util.json_request import expect_json
|
||||
from ..utils import get_modulestore
|
||||
from .access import has_access
|
||||
from .requests import _xmodule_recurse
|
||||
|
||||
__all__ = ['save_item', 'clone_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):
|
||||
item_location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
store = get_modulestore(Location(item_location))
|
||||
|
||||
if request.POST.get('data') is not None:
|
||||
data = request.POST['data']
|
||||
store.update_item(item_location, data)
|
||||
|
||||
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
|
||||
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
|
||||
# deleting the children object from the children collection
|
||||
if 'children' in request.POST and request.POST['children'] is not None:
|
||||
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
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
|
||||
# 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():
|
||||
|
||||
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]
|
||||
else:
|
||||
existing_item._model_data[metadata_key] = value
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def clone_item(request):
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
template = Location(request.POST['template'])
|
||||
|
||||
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)
|
||||
|
||||
new_item = get_modulestore(template).clone_item(template, dest_location)
|
||||
|
||||
# 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
|
||||
|
||||
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def delete_item(request):
|
||||
item_location = request.POST['id']
|
||||
item_loc = Location(item_location)
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# optional parameter to delete all children (default False)
|
||||
delete_children = request.POST.get('delete_children', False)
|
||||
delete_all_versions = request.POST.get('delete_all_versions', False)
|
||||
|
||||
store = get_modulestore(item_location)
|
||||
|
||||
item = store.get_item(item_location)
|
||||
|
||||
if delete_children:
|
||||
_xmodule_recurse(item, lambda i: store.delete_item(i.location, delete_all_versions))
|
||||
else:
|
||||
store.delete_item(item.location, delete_all_versions)
|
||||
|
||||
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
|
||||
if delete_all_versions:
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
|
||||
|
||||
for parent_loc in parent_locs:
|
||||
parent = modulestore('direct').get_item(parent_loc)
|
||||
item_url = item_loc.url()
|
||||
if item_url in parent.children:
|
||||
children = parent.children
|
||||
children.remove(item_url)
|
||||
parent.children = children
|
||||
modulestore('direct').update_children(parent.location, parent.children)
|
||||
|
||||
return HttpResponse()
|
||||
177
cms/djangoapps/contentstore/views/preview.py
Normal file
177
cms/djangoapps/contentstore/views/preview.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import logging
|
||||
import sys
|
||||
from functools import partial
|
||||
|
||||
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
|
||||
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.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.mongo import MongoUsage
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xblock.runtime import DbModel
|
||||
|
||||
import static_replace
|
||||
from .session_kv_store import SessionKeyValueStore
|
||||
from .requests import render_from_lms
|
||||
from .access import has_access
|
||||
|
||||
__all__ = ['preview_dispatch', 'preview_component']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
"""
|
||||
Dispatch an AJAX action to a preview XModule
|
||||
|
||||
Expects a POST request, and passes the arguments to the module
|
||||
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: The Location of the module to dispatch to
|
||||
dispatch: The action to execute
|
||||
"""
|
||||
|
||||
descriptor = modulestore().get_item(location)
|
||||
instance = load_preview_module(request, preview_id, descriptor)
|
||||
# Let the module handle the AJAX
|
||||
try:
|
||||
ajax_return = instance.handle_ajax(dispatch, request.POST)
|
||||
|
||||
except NotFoundError:
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
raise Http404
|
||||
|
||||
except ProcessingError:
|
||||
log.warning("Module raised an error while processing AJAX request",
|
||||
exc_info=True)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
except:
|
||||
log.exception("error processing ajax call")
|
||||
raise
|
||||
|
||||
return HttpResponse(ajax_return)
|
||||
|
||||
|
||||
@login_required
|
||||
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()
|
||||
|
||||
component = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('component.html', {
|
||||
'preview': get_module_previews(request, component)[0],
|
||||
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
|
||||
})
|
||||
|
||||
|
||||
def preview_module_system(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a ModuleSystem for the specified descriptor that is specialized for
|
||||
rendering module previews.
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
|
||||
def preview_model_data(descriptor):
|
||||
return DbModel(
|
||||
SessionKeyValueStore(request, descriptor._model_data),
|
||||
descriptor.module_class,
|
||||
preview_id,
|
||||
MongoUsage(preview_id, descriptor.location.url()),
|
||||
)
|
||||
|
||||
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?
|
||||
track_function=lambda event_type, event: None,
|
||||
filestore=descriptor.system.resources_fs,
|
||||
get_module=partial(get_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
|
||||
user=request.user,
|
||||
xblock_model_data=preview_model_data,
|
||||
)
|
||||
|
||||
|
||||
def get_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily
|
||||
from the set of preview data for the descriptor specified by Location
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: A Location
|
||||
"""
|
||||
|
||||
return load_preview_module(request, preview_id, descriptor)
|
||||
|
||||
|
||||
def load_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
instance_state: An instance state string
|
||||
shared_state: A shared state string
|
||||
"""
|
||||
system = preview_module_system(request, preview_id, descriptor)
|
||||
try:
|
||||
module = descriptor.xmodule(system)
|
||||
except:
|
||||
log.debug("Unable to load preview module", exc_info=True)
|
||||
module = ErrorDescriptor.from_descriptor(
|
||||
descriptor,
|
||||
error_msg=exc_info_to_str(sys.exc_info())
|
||||
).xmodule(system)
|
||||
|
||||
# cdodge: Special case
|
||||
if module.location.category == 'static_tab':
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_tab_display.html",
|
||||
)
|
||||
else:
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_display.html",
|
||||
)
|
||||
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
getattr(module, 'data_dir', module.location.course),
|
||||
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def get_module_previews(request, descriptor):
|
||||
"""
|
||||
Returns a list of preview XModule html contents. One preview is returned for each
|
||||
pair of states returned by get_sample_state() for the supplied descriptor.
|
||||
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
preview_html = []
|
||||
for idx, (_instance_state, _shared_state) in enumerate(descriptor.get_sample_state()):
|
||||
module = load_preview_module(request, str(idx), descriptor)
|
||||
preview_html.append(module.get_html())
|
||||
return preview_html
|
||||
51
cms/djangoapps/contentstore/views/public.py
Normal file
51
cms/djangoapps/contentstore/views/public.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.context_processors import csrf
|
||||
from django.shortcuts import redirect
|
||||
from django.conf import settings
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from external_auth.views import ssl_login_shortcut
|
||||
from .user import index
|
||||
|
||||
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks']
|
||||
|
||||
"""
|
||||
Public views
|
||||
"""
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def signup(request):
|
||||
"""
|
||||
Display the signup form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('signup.html', {'csrf': csrf_token})
|
||||
|
||||
|
||||
def old_login_redirect(request):
|
||||
'''
|
||||
Redirect to the active login url.
|
||||
'''
|
||||
return redirect('login', permanent=True)
|
||||
|
||||
|
||||
@ssl_login_shortcut
|
||||
@ensure_csrf_cookie
|
||||
def login_page(request):
|
||||
"""
|
||||
Display the login form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('login.html', {
|
||||
'csrf': csrf_token,
|
||||
'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE),
|
||||
})
|
||||
|
||||
|
||||
def howitworks(request):
|
||||
if request.user.is_authenticated():
|
||||
return index(request)
|
||||
else:
|
||||
return render_to_response('howitworks.html', {})
|
||||
60
cms/djangoapps/contentstore/views/requests.py
Normal file
60
cms/djangoapps/contentstore/views/requests.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpResponse
|
||||
from mitxmako.shortcuts import render_to_string, render_to_response
|
||||
|
||||
__all__ = ['edge', 'event', 'landing']
|
||||
|
||||
|
||||
# points to the temporary course landing page with log in and sign up
|
||||
def landing(request, org, course, coursename):
|
||||
return render_to_response('temp-course-landing.html', {})
|
||||
|
||||
|
||||
# points to the temporary edge page
|
||||
def edge(request):
|
||||
return render_to_response('university_profiles/edge.html', {})
|
||||
|
||||
|
||||
def event(request):
|
||||
'''
|
||||
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
|
||||
console logs don't get distracted :-)
|
||||
'''
|
||||
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
|
||||
"""
|
||||
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
|
||||
|
||||
|
||||
def _xmodule_recurse(item, action):
|
||||
for child in item.get_children():
|
||||
_xmodule_recurse(child, action)
|
||||
|
||||
action(item)
|
||||
28
cms/djangoapps/contentstore/views/session_kv_store.py
Normal file
28
cms/djangoapps/contentstore/views/session_kv_store.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from xblock.runtime import KeyValueStore, InvalidScopeError
|
||||
|
||||
|
||||
class SessionKeyValueStore(KeyValueStore):
|
||||
def __init__(self, request, model_data):
|
||||
self._model_data = model_data
|
||||
self._session = request.session
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
return self._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
|
||||
except (KeyError, InvalidScopeError):
|
||||
self._session[tuple(key)] = value
|
||||
|
||||
def delete(self, key):
|
||||
try:
|
||||
del self._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
|
||||
132
cms/djangoapps/contentstore/views/tabs.py
Normal file
132
cms/djangoapps/contentstore/views/tabs.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from access import has_access
|
||||
from util.json_request import expect_json
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
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 xmodule.modulestore.django import modulestore
|
||||
from ..utils import get_course_for_item
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static']
|
||||
|
||||
|
||||
def initialize_course_tabs(course):
|
||||
# set up the default tabs
|
||||
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
# at least a list populated with the minimal times
|
||||
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"},
|
||||
{"type": "progress", "name": "Progress"}]
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def reorder_static_tabs(request):
|
||||
tabs = request.POST['tabs']
|
||||
course = get_course_for_item(tabs[0])
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# get list of existing static tabs in course
|
||||
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number
|
||||
# that we know about) otherwise we can drop some!
|
||||
|
||||
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# load all reference tabs, return BadRequest if we can't find any of them
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(Location(tab))
|
||||
if item is None:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name})
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
course_item = modulestore().get_item(location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
|
||||
components = [
|
||||
static_tab.location.url()
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course_item,
|
||||
'components': components
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def static_pages(request, org, course, coursename):
|
||||
|
||||
location = get_location_and_verify_access(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', {})
|
||||
144
cms/djangoapps/contentstore/views/user.py
Normal file
144
cms/djangoapps/contentstore/views/user.py
Normal file
@@ -0,0 +1,144 @@
|
||||
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_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.utils import get_url_reverse, get_lms_link_for_item
|
||||
from util.json_request import expect_json
|
||||
from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role
|
||||
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
|
||||
|
||||
from .access import has_access
|
||||
from .requests import create_json_response
|
||||
|
||||
|
||||
def user_author_string(user):
|
||||
'''Get an author string for commits by this user. Format:
|
||||
first last <email@email.com>.
|
||||
|
||||
If the first and last names are blank, uses the username instead.
|
||||
Assumes that the email is not blank.
|
||||
'''
|
||||
f = user.first_name
|
||||
l = user.last_name
|
||||
if f == '' and l == '':
|
||||
f = user.username
|
||||
return '{first} {last} <{email}>'.format(first=f,
|
||||
last=l,
|
||||
email=user.email)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def index(request):
|
||||
"""
|
||||
List all courses available to the logged in user
|
||||
"""
|
||||
courses = modulestore('direct').get_items(['i4x', None, None, 'course', None])
|
||||
|
||||
# filter out courses that we don't have access too
|
||||
def course_filter(course):
|
||||
return (has_access(request.user, course.location)
|
||||
and course.location.course != 'templates'
|
||||
and course.location.org != ''
|
||||
and course.location.course != ''
|
||||
and course.location.name != '')
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def manage_users(request, location):
|
||||
'''
|
||||
This view will return all CMS users who are editors for the specified course
|
||||
'''
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
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('/'),
|
||||
'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'),
|
||||
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
|
||||
'request_user_id': request.user.id
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
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"]
|
||||
|
||||
if email == '':
|
||||
return create_json_response('Please specify an email address.')
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# 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))
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def remove_user(request, location):
|
||||
'''
|
||||
This POST-back view will remove a user - specified by email - from the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
|
||||
email = request.POST["email"]
|
||||
|
||||
# check that logged in user has admin permissions on this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# make sure we're not removing ourselves
|
||||
if user.id == request.user.id:
|
||||
raise PermissionDenied()
|
||||
|
||||
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
@@ -174,7 +174,6 @@ class CourseDetails(object):
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# TODO move to a more general util? Is there a better way to do the isinstance model check?
|
||||
class CourseSettingsEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
|
||||
@@ -45,14 +45,13 @@ class CourseGradingModel(object):
|
||||
|
||||
# return empty model
|
||||
else:
|
||||
return {
|
||||
"id": index,
|
||||
return {"id": index,
|
||||
"type": "",
|
||||
"min_count": 0,
|
||||
"drop_count": 0,
|
||||
"short_label": None,
|
||||
"weight": 0
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def fetch_cutoffs(course_location):
|
||||
@@ -95,7 +94,6 @@ class CourseGradingModel(object):
|
||||
|
||||
return CourseGradingModel.fetch(course_location)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def update_grader_from_json(course_location, grader):
|
||||
"""
|
||||
@@ -137,7 +135,6 @@ class CourseGradingModel(object):
|
||||
|
||||
return cutoffs
|
||||
|
||||
|
||||
@staticmethod
|
||||
def update_grace_period_from_json(course_location, graceperiodjson):
|
||||
"""
|
||||
@@ -210,8 +207,7 @@ class CourseGradingModel(object):
|
||||
location = Location(location)
|
||||
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
return {
|
||||
"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
|
||||
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
|
||||
"location": location,
|
||||
"id": 99 # just an arbitrary value to
|
||||
}
|
||||
@@ -231,7 +227,6 @@ class CourseGradingModel(object):
|
||||
|
||||
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def convert_set_grace_period(descriptor):
|
||||
# 5 hours 59 minutes 59 seconds => converted to iso format
|
||||
@@ -262,13 +257,12 @@ class CourseGradingModel(object):
|
||||
@staticmethod
|
||||
def parse_grader(json_grader):
|
||||
# manual to clear out kruft
|
||||
result = {
|
||||
"type": json_grader["type"],
|
||||
"min_count": int(json_grader.get('min_count', 0)),
|
||||
"drop_count": int(json_grader.get('drop_count', 0)),
|
||||
"short_label": json_grader.get('short_label', None),
|
||||
"weight": float(json_grader.get('weight', 0)) / 100.0
|
||||
}
|
||||
result = {"type": json_grader["type"],
|
||||
"min_count": int(json_grader.get('min_count', 0)),
|
||||
"drop_count": int(json_grader.get('drop_count', 0)),
|
||||
"short_label": json_grader.get('short_label', None),
|
||||
"weight": float(json_grader.get('weight', 0)) / 100.0
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from xblock.core import Scope
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
import copy
|
||||
|
||||
|
||||
class CourseMetadata(object):
|
||||
'''
|
||||
For CRUD operations on metadata fields which do not have specific editors
|
||||
@@ -13,8 +14,14 @@ class CourseMetadata(object):
|
||||
The objects have no predefined attrs but instead are obj encodings of the
|
||||
editable metadata.
|
||||
'''
|
||||
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end',
|
||||
'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', 'checklists']
|
||||
FILTERED_LIST = ['xml_attributes',
|
||||
'start',
|
||||
'end',
|
||||
'enrollment_start',
|
||||
'enrollment_end',
|
||||
'tabs',
|
||||
'graceperiod',
|
||||
'checklists']
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
@@ -48,7 +55,7 @@ class CourseMetadata(object):
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
dirty = False
|
||||
|
||||
|
||||
#Copy the filtered list to avoid permanently changing the class attribute
|
||||
filtered_list = copy.copy(cls.FILTERED_LIST)
|
||||
#Don't filter on the tab attribute if filter_tabs is False
|
||||
@@ -71,7 +78,7 @@ class CourseMetadata(object):
|
||||
|
||||
if dirty:
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
own_metadata(descriptor))
|
||||
|
||||
# Could just generate and return a course obj w/o doing any db reads,
|
||||
# but I put the reads in as a means to confirm it persisted correctly
|
||||
@@ -92,6 +99,6 @@ class CourseMetadata(object):
|
||||
delattr(descriptor.lms, key)
|
||||
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
own_metadata(descriptor))
|
||||
|
||||
return cls.fetch(course_location)
|
||||
|
||||
@@ -2,33 +2,52 @@
|
||||
This config file extends the test environment configuration
|
||||
so that we can run the lettuce acceptance tests.
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
||||
# Show the courses that are in the data directory
|
||||
COURSES_ROOT = ENV_ROOT / "data"
|
||||
DATA_DIR = COURSES_ROOT
|
||||
# MODULESTORE = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
|
||||
# 'OPTIONS': {
|
||||
# 'data_dir': DATA_DIR,
|
||||
# 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# Disable warnings for acceptance tests, to make the logs readable
|
||||
import logging
|
||||
logging.disable(logging.ERROR)
|
||||
|
||||
MODULESTORE_OPTIONS = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'acceptance_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'direct': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'draft': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
}
|
||||
}
|
||||
# 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': ENV_ROOT / "db" / "test_mitx.db",
|
||||
'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db",
|
||||
'NAME': TEST_ROOT / "db" / "test_mitx.db",
|
||||
'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,3 +55,4 @@ DATABASES = {
|
||||
INSTALLED_APPS += ('lettuce.django',)
|
||||
LETTUCE_APPS = ('contentstore',)
|
||||
LETTUCE_SERVER_PORT = 8001
|
||||
LETTUCE_BROWSER = 'chrome'
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""
|
||||
This is the default template for our main set of AWS servers.
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
import json
|
||||
|
||||
from .common import *
|
||||
@@ -28,12 +33,55 @@ EMAIL_BACKEND = 'django_ses.SESBackend'
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage'
|
||||
|
||||
###################################### CELERY ################################
|
||||
|
||||
# Don't use a connection pool, since connections are dropped by ELB.
|
||||
BROKER_POOL_LIMIT = 0
|
||||
BROKER_CONNECTION_TIMEOUT = 1
|
||||
|
||||
# For the Result Store, use the django cache named 'celery'
|
||||
CELERY_RESULT_BACKEND = 'cache'
|
||||
CELERY_CACHE_BACKEND = 'celery'
|
||||
|
||||
# When the broker is behind an ELB, use a heartbeat to refresh the
|
||||
# connection and to detect if it has been dropped.
|
||||
BROKER_HEARTBEAT = 10.0
|
||||
BROKER_HEARTBEAT_CHECKRATE = 2
|
||||
|
||||
# Each worker should only fetch one message at a time
|
||||
CELERYD_PREFETCH_MULTIPLIER = 1
|
||||
|
||||
# Skip djcelery migrations, since we don't use the database as the broker
|
||||
SOUTH_MIGRATION_MODULES = {
|
||||
'djcelery': 'ignore',
|
||||
}
|
||||
|
||||
# Rename the exchange and queues for each variant
|
||||
|
||||
QUEUE_VARIANT = CONFIG_PREFIX.lower()
|
||||
|
||||
CELERY_DEFAULT_EXCHANGE = 'edx.{0}core'.format(QUEUE_VARIANT)
|
||||
|
||||
HIGH_PRIORITY_QUEUE = 'edx.{0}core.high'.format(QUEUE_VARIANT)
|
||||
DEFAULT_PRIORITY_QUEUE = 'edx.{0}core.default'.format(QUEUE_VARIANT)
|
||||
LOW_PRIORITY_QUEUE = 'edx.{0}core.low'.format(QUEUE_VARIANT)
|
||||
|
||||
CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE
|
||||
CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
|
||||
|
||||
CELERY_QUEUES = {
|
||||
HIGH_PRIORITY_QUEUE: {},
|
||||
LOW_PRIORITY_QUEUE: {},
|
||||
DEFAULT_PRIORITY_QUEUE: {}
|
||||
}
|
||||
|
||||
############# NON-SECURE ENV CONFIG ##############################
|
||||
# Things like server locations, ports, etc.
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
|
||||
ENV_TOKENS = json.load(env_file)
|
||||
|
||||
LMS_BASE = ENV_TOKENS.get('LMS_BASE')
|
||||
# Note that MITX_FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file.
|
||||
|
||||
SITE_NAME = ENV_TOKENS['SITE_NAME']
|
||||
|
||||
@@ -43,6 +91,16 @@ CACHES = ENV_TOKENS['CACHES']
|
||||
|
||||
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
|
||||
#Email overrides
|
||||
DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL)
|
||||
DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL)
|
||||
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
|
||||
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
|
||||
|
||||
#Timezone overrides
|
||||
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
|
||||
|
||||
|
||||
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
|
||||
MITX_FEATURES[feature] = value
|
||||
|
||||
@@ -67,4 +125,15 @@ MODULESTORE = AUTH_TOKENS['MODULESTORE']
|
||||
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
|
||||
|
||||
# Datadog for events!
|
||||
DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
|
||||
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_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)
|
||||
|
||||
@@ -19,12 +19,13 @@ Longer TODO:
|
||||
multiple sites, but we do need a way to map their data assets.
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
import sys
|
||||
import os.path
|
||||
import os
|
||||
import lms.envs.common
|
||||
from path import path
|
||||
from xmodule.static_content import write_descriptor_styles, write_descriptor_js, write_module_js, write_module_styles
|
||||
|
||||
############################ FEATURE CONFIGURATION #############################
|
||||
|
||||
@@ -35,8 +36,14 @@ MITX_FEATURES = {
|
||||
'AUTH_USE_MIT_CERTIFICATES': False,
|
||||
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
|
||||
'STAFF_EMAIL': '', # email address for staff (eg to request course creation)
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
'SEGMENT_IO': True,
|
||||
|
||||
# Enable URL that shows information about the status of various services
|
||||
'ENABLE_SERVICE_STATUS': False,
|
||||
|
||||
# Don't autoplay videos for course authors
|
||||
'AUTOPLAY_VIDEOS': False
|
||||
}
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
@@ -129,6 +136,9 @@ MIDDLEWARE_CLASSES = (
|
||||
'track.middleware.TrackMiddleware',
|
||||
'mitxmako.middleware.MakoMiddleware',
|
||||
|
||||
# Detects user-requested locale from 'accept-language' header in http request
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
|
||||
'django.middleware.transaction.TransactionMiddleware'
|
||||
)
|
||||
|
||||
@@ -152,6 +162,7 @@ IGNORABLE_404_ENDS = ('favicon.ico')
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
DEFAULT_FROM_EMAIL = 'registration@edx.org'
|
||||
DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org'
|
||||
SERVER_EMAIL = 'devops@edx.org'
|
||||
ADMINS = (
|
||||
('edX Admins', 'admin@edx.org'),
|
||||
)
|
||||
@@ -167,15 +178,19 @@ STATICFILES_DIRS = [
|
||||
PROJECT_ROOT / "static",
|
||||
|
||||
# This is how you would use the textbook images locally
|
||||
# ("book", ENV_ROOT / "book_images")
|
||||
# ("book", ENV_ROOT / "book_images")
|
||||
]
|
||||
|
||||
# Locale/Internationalization
|
||||
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
|
||||
# Localization strings (e.g. django.po) are under this directory
|
||||
LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # mitx/conf/locale/
|
||||
|
||||
# Tracking
|
||||
TRACK_MAX_EVENT = 10000
|
||||
|
||||
@@ -186,29 +201,7 @@ MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
|
||||
|
||||
STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
|
||||
|
||||
# Load javascript and css from all of the available descriptors, and
|
||||
# prep it for use in pipeline js
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from rooted_paths import rooted_glob, remove_root
|
||||
|
||||
write_descriptor_styles(PROJECT_ROOT / "static/sass/descriptor", [RawDescriptor, ErrorDescriptor])
|
||||
write_module_styles(PROJECT_ROOT / "static/sass/module", [RawDescriptor, ErrorDescriptor])
|
||||
|
||||
descriptor_js = remove_root(
|
||||
PROJECT_ROOT / 'static',
|
||||
write_descriptor_js(
|
||||
PROJECT_ROOT / "static/coffee/descriptor",
|
||||
[RawDescriptor, ErrorDescriptor]
|
||||
)
|
||||
)
|
||||
module_js = remove_root(
|
||||
PROJECT_ROOT / 'static',
|
||||
write_module_js(
|
||||
PROJECT_ROOT / "static/coffee/module",
|
||||
[RawDescriptor, ErrorDescriptor]
|
||||
)
|
||||
)
|
||||
from rooted_paths import rooted_glob
|
||||
|
||||
PIPELINE_CSS = {
|
||||
'base-style': {
|
||||
@@ -216,39 +209,38 @@ PIPELINE_CSS = {
|
||||
'js/vendor/CodeMirror/codemirror.css',
|
||||
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
|
||||
'css/vendor/jquery.qtip.min.css',
|
||||
'sass/base-style.scss'
|
||||
'sass/base-style.css',
|
||||
'xmodule/modules.css',
|
||||
'xmodule/descriptor.css',
|
||||
],
|
||||
'output_filename': 'css/cms-base-style.css',
|
||||
},
|
||||
}
|
||||
|
||||
PIPELINE_ALWAYS_RECOMPILE = ['sass/base-style.scss']
|
||||
|
||||
# test_order: Determines the position of this chunk of javascript on
|
||||
# the jasmine test page
|
||||
PIPELINE_JS = {
|
||||
'main': {
|
||||
'source_filenames': sorted(
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.coffee') +
|
||||
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.coffee')
|
||||
) + ['js/hesitate.js', 'js/base.js'],
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
|
||||
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
|
||||
) + ['js/hesitate.js', 'js/base.js',
|
||||
'js/models/feedback.js', 'js/views/feedback.js',
|
||||
'js/models/section.js', 'js/views/section.js',
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
},
|
||||
'module-js': {
|
||||
'source_filenames': descriptor_js + module_js,
|
||||
'source_filenames': (
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js') +
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/modules/js/*.js')
|
||||
),
|
||||
'output_filename': 'js/cms-modules.js',
|
||||
'test_order': 1
|
||||
},
|
||||
'spec': {
|
||||
'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.coffee')),
|
||||
'output_filename': 'js/cms-spec.js'
|
||||
}
|
||||
}
|
||||
|
||||
PIPELINE_COMPILERS = [
|
||||
'pipeline.compilers.sass.SASSCompiler',
|
||||
'pipeline.compilers.coffee.CoffeeScriptCompiler',
|
||||
]
|
||||
|
||||
PIPELINE_SASS_ARGUMENTS = '-t compressed -r {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT)
|
||||
|
||||
PIPELINE_CSS_COMPRESSOR = None
|
||||
PIPELINE_JS_COMPRESSOR = None
|
||||
|
||||
@@ -260,11 +252,51 @@ STATICFILES_IGNORE_PATTERNS = (
|
||||
)
|
||||
|
||||
PIPELINE_YUI_BINARY = 'yui-compressor'
|
||||
PIPELINE_SASS_BINARY = 'sass'
|
||||
PIPELINE_COFFEE_SCRIPT_BINARY = 'coffee'
|
||||
|
||||
# Setting that will only affect the MITx version of django-pipeline until our changes are merged upstream
|
||||
PIPELINE_COMPILE_INPLACE = True
|
||||
################################# CELERY ######################################
|
||||
|
||||
# Message configuration
|
||||
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
|
||||
CELERY_MESSAGE_COMPRESSION = 'gzip'
|
||||
|
||||
# Results configuration
|
||||
|
||||
CELERY_IGNORE_RESULT = False
|
||||
CELERY_STORE_ERRORS_EVEN_IF_IGNORED = True
|
||||
|
||||
# Events configuration
|
||||
|
||||
CELERY_TRACK_STARTED = True
|
||||
|
||||
CELERY_SEND_EVENTS = True
|
||||
CELERY_SEND_TASK_SENT_EVENT = True
|
||||
|
||||
# Exchange configuration
|
||||
|
||||
CELERY_DEFAULT_EXCHANGE = 'edx.core'
|
||||
CELERY_DEFAULT_EXCHANGE_TYPE = 'direct'
|
||||
|
||||
# Queues configuration
|
||||
|
||||
HIGH_PRIORITY_QUEUE = 'edx.core.high'
|
||||
DEFAULT_PRIORITY_QUEUE = 'edx.core.default'
|
||||
LOW_PRIORITY_QUEUE = 'edx.core.low'
|
||||
|
||||
CELERY_QUEUE_HA_POLICY = 'all'
|
||||
|
||||
CELERY_CREATE_MISSING_QUEUES = True
|
||||
|
||||
CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE
|
||||
CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
|
||||
|
||||
CELERY_QUEUES = {
|
||||
HIGH_PRIORITY_QUEUE: {},
|
||||
LOW_PRIORITY_QUEUE: {},
|
||||
DEFAULT_PRIORITY_QUEUE: {}
|
||||
}
|
||||
|
||||
############################ APPS #####################################
|
||||
|
||||
@@ -275,8 +307,12 @@ INSTALLED_APPS = (
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.messages',
|
||||
'djcelery',
|
||||
'south',
|
||||
|
||||
# Monitor the status of services
|
||||
'service_status',
|
||||
|
||||
# For CMS
|
||||
'contentstore',
|
||||
'auth',
|
||||
@@ -290,4 +326,11 @@ INSTALLED_APPS = (
|
||||
'pipeline',
|
||||
'staticfiles',
|
||||
'static_replace',
|
||||
|
||||
# comment common
|
||||
'django_comment_common',
|
||||
)
|
||||
|
||||
################# EDX MARKETING SITE ##################################
|
||||
|
||||
EDXMKTG_COOKIE_NAME = 'edxloggedin'
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""
|
||||
This config file runs the simplest dev environment"""
|
||||
|
||||
# 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 .common import *
|
||||
from logsettings import get_logger_config
|
||||
|
||||
@@ -51,6 +55,7 @@ DATABASES = {
|
||||
}
|
||||
|
||||
LMS_BASE = "localhost:8000"
|
||||
MITX_FEATURES['PREVIEW_LMS_BASE'] = "localhost:8000"
|
||||
|
||||
REPOS = {
|
||||
'edx4edx': {
|
||||
@@ -116,10 +121,14 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
||||
|
||||
PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT)
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
# By default don't use a worker, execute tasks as if they were local functions
|
||||
CELERY_ALWAYS_EAGER = True
|
||||
|
||||
################################ DEBUG TOOLBAR #################################
|
||||
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
|
||||
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',)
|
||||
MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
|
||||
INTERNAL_IPS = ('127.0.0.1',)
|
||||
|
||||
DEBUG_TOOLBAR_PANELS = (
|
||||
@@ -151,5 +160,8 @@ DEBUG_TOOLBAR_MONGO_STACKTRACES = True
|
||||
# disable NPS survey in dev mode
|
||||
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
|
||||
|
||||
# Enable URL that shows information about the status of variuous services
|
||||
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
|
||||
# segment-io key for dev
|
||||
SEGMENT_IO_KEY = 'mty8edrrsg'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
# dev environment for ichuang/mit
|
||||
|
||||
# FORCE_SCRIPT_NAME = '/cms'
|
||||
|
||||
39
cms/envs/dev_with_worker.py
Normal file
39
cms/envs/dev_with_worker.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
This config file follows the dev enviroment, but adds the
|
||||
requirement of a celery worker running in the background to process
|
||||
celery tasks.
|
||||
|
||||
The worker can be executed using:
|
||||
|
||||
django_admin.py celery worker
|
||||
"""
|
||||
|
||||
# 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 dev import *
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
# Requires a separate celery worker
|
||||
|
||||
CELERY_ALWAYS_EAGER = False
|
||||
|
||||
# Use django db as the broker and result store
|
||||
|
||||
BROKER_URL = 'django://'
|
||||
INSTALLED_APPS += ('djcelery.transport', )
|
||||
CELERY_RESULT_BACKEND = 'database'
|
||||
DJKOMBU_POLLING_INTERVAL = 1.0
|
||||
|
||||
# Disable transaction management because we are using a worker. Views
|
||||
# that request a task and wait for the result will deadlock otherwise.
|
||||
|
||||
MIDDLEWARE_CLASSES = tuple(
|
||||
c for c in MIDDLEWARE_CLASSES
|
||||
if c != 'django.middleware.transaction.TransactionMiddleware')
|
||||
|
||||
# Note: other alternatives for disabling transactions don't work in 1.4
|
||||
# https://code.djangoproject.com/ticket/2304
|
||||
# https://code.djangoproject.com/ticket/16039
|
||||
@@ -2,6 +2,10 @@
|
||||
This configuration is used for running jasmine tests
|
||||
"""
|
||||
|
||||
# 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 *
|
||||
from logsettings import get_logger_config
|
||||
|
||||
@@ -20,19 +24,30 @@ PIPELINE_JS['js-test-source'] = {
|
||||
'source_filenames': sum([
|
||||
pipeline_group['source_filenames']
|
||||
for group_name, pipeline_group
|
||||
in PIPELINE_JS.items()
|
||||
in sorted(PIPELINE_JS.items(), key=lambda item: item[1].get('test_order', 1e100))
|
||||
if group_name != 'spec'
|
||||
], []),
|
||||
'output_filename': 'js/cms-test-source.js'
|
||||
}
|
||||
|
||||
PIPELINE_JS['spec'] = {
|
||||
'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.coffee')),
|
||||
'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.js')),
|
||||
'output_filename': 'js/cms-spec.js'
|
||||
}
|
||||
|
||||
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
|
||||
JASMINE_REPORT_DIR = os.environ.get('JASMINE_REPORT_DIR', 'reports/cms/jasmine')
|
||||
|
||||
STATICFILES_DIRS.append(COMMON_ROOT / 'test' / 'phantom-jasmine' / 'lib')
|
||||
TEMPLATE_CONTEXT_PROCESSORS += ('settings_context_processor.context_processors.settings',)
|
||||
TEMPLATE_VISIBLE_SETTINGS = ('JASMINE_REPORT_DIR', )
|
||||
|
||||
INSTALLED_APPS += ('django_jasmine', )
|
||||
STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib')
|
||||
STATICFILES_DIRS.append(REPO_ROOT/'node_modules/jasmine-reporters/src')
|
||||
|
||||
# Remove the localization middleware class because it requires the test database
|
||||
# to be sync'd and migrated in order to run the jasmine tests interactively
|
||||
# with a browser
|
||||
MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \
|
||||
if e != 'django.middleware.locale.LocaleMiddleware')
|
||||
|
||||
INSTALLED_APPS += ('django_jasmine', 'settings_context_processor')
|
||||
|
||||
@@ -7,20 +7,21 @@ sessions. Assumes structure:
|
||||
/mitx # The location of this repo
|
||||
/log # Where we're going to write log files
|
||||
"""
|
||||
|
||||
# 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 .common import *
|
||||
import os
|
||||
from path import path
|
||||
|
||||
# Nose Test Runner
|
||||
INSTALLED_APPS += ('django_nose',)
|
||||
NOSE_ARGS = ['--with-xunit']
|
||||
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
|
||||
|
||||
TEST_ROOT = path('test_root')
|
||||
|
||||
# Makes the tests run much faster...
|
||||
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
|
||||
|
||||
# Want static files in the same dir for running on jenkins.
|
||||
STATIC_ROOT = TEST_ROOT / "staticfiles"
|
||||
|
||||
@@ -28,7 +29,7 @@ GITHUB_REPO_ROOT = TEST_ROOT / "data"
|
||||
COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
|
||||
|
||||
# Makes the tests run much faster...
|
||||
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
|
||||
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
|
||||
|
||||
# TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing
|
||||
STATICFILES_DIRS = [
|
||||
@@ -41,27 +42,27 @@ STATICFILES_DIRS += [
|
||||
if os.path.isdir(COMMON_TEST_DATA_ROOT / course_dir)
|
||||
]
|
||||
|
||||
modulestore_options = {
|
||||
MODULESTORE_OPTIONS = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': GITHUB_REPO_ROOT,
|
||||
'collection': 'test_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'direct': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'draft': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +77,12 @@ CONTENTSTORE = {
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ENV_ROOT / "db" / "cms.db",
|
||||
'NAME': TEST_ROOT / "db" / "cms.db",
|
||||
},
|
||||
}
|
||||
|
||||
LMS_BASE = "localhost:8000"
|
||||
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview"
|
||||
|
||||
CACHES = {
|
||||
# This is the cache used for most things. Askbot will not work without a
|
||||
@@ -112,6 +114,12 @@ CACHES = {
|
||||
}
|
||||
}
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
CELERY_ALWAYS_EAGER = True
|
||||
CELERY_RESULT_BACKEND = 'cache'
|
||||
BROKER_TRANSPORT = 'memory'
|
||||
|
||||
################### Make tests faster
|
||||
#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
|
||||
PASSWORD_HASHERS = (
|
||||
@@ -121,3 +129,8 @@ PASSWORD_HASHERS = (
|
||||
|
||||
# dummy segment-io key
|
||||
SEGMENT_IO_KEY = '***REMOVED***'
|
||||
|
||||
# disable NPS survey in test mode
|
||||
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
|
||||
|
||||
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from dogapi import dog_http_api, dog_stats_api
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from django.dispatch import Signal
|
||||
from request_cache.middleware import RequestCache
|
||||
|
||||
from django.core.cache import get_cache, InvalidCacheBackendError
|
||||
from django.core.cache import get_cache
|
||||
|
||||
cache = get_cache('mongo_metadata_inheritance')
|
||||
for store_name in settings.MODULESTORE:
|
||||
@@ -11,6 +12,8 @@ for store_name in settings.MODULESTORE:
|
||||
store.metadata_inheritance_cache_subsystem = cache
|
||||
store.request_cache = RequestCache.get_request_cache()
|
||||
|
||||
modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
|
||||
store.modulestore_update_signal = modulestore_update_signal
|
||||
if hasattr(settings, 'DATADOG_API'):
|
||||
dog_http_api.api_key = settings.DATADOG_API
|
||||
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<span class="int"><%= percentChecked %></span>% of checklist completed</span></span>
|
||||
<header>
|
||||
<h3 class="checklist-title title-2 is-selectable" title="Collapse/Expand this Checklist">
|
||||
<i class="ss-icon ss-symbolicons-standard icon-arrow ui-toggle-expansion">▾</i>
|
||||
<i class="icon-caret-down ui-toggle-expansion"></i>
|
||||
<%= checklistShortDescription %></h3>
|
||||
<span class="checklist-status status">
|
||||
Tasks Completed: <span class="status-count"><%= itemsChecked %></span>/<span class="status-amount"><%= items.length %></span>
|
||||
<i class="ss-icon ss-symbolicons-standard icon-confirm">✓</i>
|
||||
<i class="icon-ok"></i>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@@ -58,4 +58,4 @@
|
||||
<% taskIndex+=1; }) %>
|
||||
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
2
cms/static/coffee/.gitignore
vendored
2
cms/static/coffee/.gitignore
vendored
@@ -1,3 +1 @@
|
||||
*.js
|
||||
descriptor
|
||||
module
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"js/vendor/jquery.cookie.js",
|
||||
"js/vendor/json2.js",
|
||||
"js/vendor/underscore-min.js",
|
||||
"js/vendor/backbone-min.js"
|
||||
"js/vendor/backbone-min.js",
|
||||
"js/vendor/jquery.leanModal.min.js",
|
||||
"js/vendor/sinon-1.7.1.js",
|
||||
"js/test/i18n.js"
|
||||
]
|
||||
}
|
||||
|
||||
1
cms/static/coffee/fixtures/metadata-editor.underscore
Symbolic link
1
cms/static/coffee/fixtures/metadata-editor.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/metadata-editor.underscore
|
||||
1
cms/static/coffee/fixtures/metadata-number-entry.underscore
Symbolic link
1
cms/static/coffee/fixtures/metadata-number-entry.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/metadata-number-entry.underscore
|
||||
1
cms/static/coffee/fixtures/metadata-option-entry.underscore
Symbolic link
1
cms/static/coffee/fixtures/metadata-option-entry.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/metadata-option-entry.underscore
|
||||
1
cms/static/coffee/fixtures/metadata-string-entry.underscore
Symbolic link
1
cms/static/coffee/fixtures/metadata-string-entry.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/metadata-string-entry.underscore
|
||||
1
cms/static/coffee/fixtures/section-name-edit.underscore
Symbolic link
1
cms/static/coffee/fixtures/section-name-edit.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/section-name-edit.underscore
|
||||
1
cms/static/coffee/fixtures/system-feedback.underscore
Symbolic link
1
cms/static/coffee/fixtures/system-feedback.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/system-feedback.underscore
|
||||
@@ -1,3 +1,5 @@
|
||||
jasmine.getFixtures().fixturesPath = 'fixtures'
|
||||
|
||||
# Stub jQuery.cookie
|
||||
@stubCookies =
|
||||
csrftoken: "stubCSRFToken"
|
||||
|
||||
@@ -22,3 +22,37 @@ describe "main helper", ->
|
||||
|
||||
it "setup AJAX CSRF token", ->
|
||||
expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken")
|
||||
|
||||
describe "AJAX Errors", ->
|
||||
tpl = readFixtures('system-feedback.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures(sandbox({id: "page-notification"}))
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
|
||||
it "successful AJAX request does not pop an error notification", ->
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
$.ajax("/test")
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
@requests[0].respond(200)
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
|
||||
it "AJAX request with error should pop an error notification", ->
|
||||
$.ajax("/test")
|
||||
@requests[0].respond(500)
|
||||
expect($("#page-notification")).not.toBeEmpty()
|
||||
expect($("#page-notification")).toContain('div.wrapper-notification-error')
|
||||
|
||||
it "can override AJAX request with error so it does not pop an error notification", ->
|
||||
$.ajax
|
||||
url: "/test"
|
||||
notifyOnError: false
|
||||
@requests[0].respond(500)
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
|
||||
|
||||
34
cms/static/coffee/spec/models/feedback_spec.coffee
Normal file
34
cms/static/coffee/spec/models/feedback_spec.coffee
Normal file
@@ -0,0 +1,34 @@
|
||||
describe "CMS.Models.SystemFeedback", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.SystemFeedback()
|
||||
|
||||
it "should have an empty message by default", ->
|
||||
expect(@model.get("message")).toEqual("")
|
||||
|
||||
it "should have an empty title by default", ->
|
||||
expect(@model.get("title")).toEqual("")
|
||||
|
||||
it "should not have an intent set by default", ->
|
||||
expect(@model.get("intent")).toBeNull()
|
||||
|
||||
|
||||
describe "CMS.Models.WarningMessage", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.WarningMessage()
|
||||
|
||||
it "should have the correct intent", ->
|
||||
expect(@model.get("intent")).toEqual("warning")
|
||||
|
||||
describe "CMS.Models.ErrorMessage", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ErrorMessage()
|
||||
|
||||
it "should have the correct intent", ->
|
||||
expect(@model.get("intent")).toEqual("error")
|
||||
|
||||
describe "CMS.Models.ConfirmationMessage", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ConfirmationMessage()
|
||||
|
||||
it "should have the correct intent", ->
|
||||
expect(@model.get("intent")).toEqual("confirmation")
|
||||
58
cms/static/coffee/spec/models/metadata_spec.coffee
Normal file
58
cms/static/coffee/spec/models/metadata_spec.coffee
Normal file
@@ -0,0 +1,58 @@
|
||||
describe "CMS.Models.Metadata", ->
|
||||
it "knows when the value has not been modified", ->
|
||||
model = new CMS.Models.Metadata(
|
||||
{'value': 'original', 'explicitly_set': false})
|
||||
expect(model.isModified()).toBeFalsy()
|
||||
|
||||
model = new CMS.Models.Metadata(
|
||||
{'value': 'original', 'explicitly_set': true})
|
||||
model.setValue('original')
|
||||
expect(model.isModified()).toBeFalsy()
|
||||
|
||||
it "knows when the value has been modified", ->
|
||||
model = new CMS.Models.Metadata(
|
||||
{'value': 'original', 'explicitly_set': false})
|
||||
model.setValue('original')
|
||||
expect(model.isModified()).toBeTruthy()
|
||||
|
||||
model = new CMS.Models.Metadata(
|
||||
{'value': 'original', 'explicitly_set': true})
|
||||
model.setValue('modified')
|
||||
expect(model.isModified()).toBeTruthy()
|
||||
|
||||
it "tracks when values have been explicitly set", ->
|
||||
model = new CMS.Models.Metadata(
|
||||
{'value': 'original', 'explicitly_set': false})
|
||||
expect(model.isExplicitlySet()).toBeFalsy()
|
||||
model.setValue('original')
|
||||
expect(model.isExplicitlySet()).toBeTruthy()
|
||||
|
||||
it "has both 'display value' and a 'value' methods", ->
|
||||
model = new CMS.Models.Metadata(
|
||||
{'value': 'default', 'explicitly_set': false})
|
||||
expect(model.getValue()).toBeNull
|
||||
expect(model.getDisplayValue()).toBe('default')
|
||||
model.setValue('modified')
|
||||
expect(model.getValue()).toBe('modified')
|
||||
expect(model.getDisplayValue()).toBe('modified')
|
||||
|
||||
it "has a clear method for reverting to the default", ->
|
||||
model = new CMS.Models.Metadata(
|
||||
{'value': 'original', 'default_value' : 'default', 'explicitly_set': true})
|
||||
model.clear()
|
||||
expect(model.getValue()).toBeNull
|
||||
expect(model.getDisplayValue()).toBe('default')
|
||||
expect(model.isExplicitlySet()).toBeFalsy()
|
||||
|
||||
it "has a getter for field name", ->
|
||||
model = new CMS.Models.Metadata({'field_name': 'foo'})
|
||||
expect(model.getFieldName()).toBe('foo')
|
||||
|
||||
it "has a getter for options", ->
|
||||
model = new CMS.Models.Metadata({'options': ['foo', 'bar']})
|
||||
expect(model.getOptions()).toEqual(['foo', 'bar'])
|
||||
|
||||
it "has a getter for type", ->
|
||||
model = new CMS.Models.Metadata({'type': 'Integer'})
|
||||
expect(model.getType()).toBe(CMS.Models.Metadata.INTEGER_TYPE)
|
||||
|
||||
53
cms/static/coffee/spec/models/section_spec.coffee
Normal file
53
cms/static/coffee/spec/models/section_spec.coffee
Normal file
@@ -0,0 +1,53 @@
|
||||
describe "CMS.Models.Section", ->
|
||||
describe "basic", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.Section({
|
||||
id: 42,
|
||||
name: "Life, the Universe, and Everything"
|
||||
})
|
||||
|
||||
it "should take an id argument", ->
|
||||
expect(@model.get("id")).toEqual(42)
|
||||
|
||||
it "should take a name argument", ->
|
||||
expect(@model.get("name")).toEqual("Life, the Universe, and Everything")
|
||||
|
||||
it "should have a URL set", ->
|
||||
expect(@model.url).toEqual("/save_item")
|
||||
|
||||
it "should serialize to JSON correctly", ->
|
||||
expect(@model.toJSON()).toEqual({
|
||||
id: 42,
|
||||
metadata: {
|
||||
display_name: "Life, the Universe, and Everything"
|
||||
}
|
||||
})
|
||||
|
||||
describe "XHR", ->
|
||||
beforeEach ->
|
||||
spyOn(CMS.Models.Section.prototype, 'showNotification')
|
||||
spyOn(CMS.Models.Section.prototype, 'hideNotification')
|
||||
@model = new CMS.Models.Section({
|
||||
id: 42,
|
||||
name: "Life, the Universe, and Everything"
|
||||
})
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
|
||||
it "show/hide a notification when it saves to the server", ->
|
||||
@model.save()
|
||||
expect(CMS.Models.Section.prototype.showNotification).toHaveBeenCalled()
|
||||
@requests[0].respond(200)
|
||||
expect(CMS.Models.Section.prototype.hideNotification).toHaveBeenCalled()
|
||||
|
||||
it "don't hide notification when saving fails", ->
|
||||
# this is handled by the global AJAX error handler
|
||||
@model.save()
|
||||
@requests[0].respond(500)
|
||||
expect(CMS.Models.Section.prototype.hideNotification).not.toHaveBeenCalled()
|
||||
|
||||
|
||||
179
cms/static/coffee/spec/views/feedback_spec.coffee
Normal file
179
cms/static/coffee/spec/views/feedback_spec.coffee
Normal file
@@ -0,0 +1,179 @@
|
||||
tpl = readFixtures('system-feedback.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures(sandbox({id: "page-alert"}))
|
||||
appendSetFixtures(sandbox({id: "page-notification"}))
|
||||
appendSetFixtures(sandbox({id: "page-prompt"}))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(tpl))
|
||||
@addMatchers
|
||||
toBeShown: ->
|
||||
@actual.hasClass("is-shown") and not @actual.hasClass("is-hiding")
|
||||
toBeHiding: ->
|
||||
@actual.hasClass("is-hiding") and not @actual.hasClass("is-shown")
|
||||
toContainText: (text) ->
|
||||
# remove this when we upgrade jasmine-jquery
|
||||
trimmedText = $.trim(@actual.text())
|
||||
if text and $.isFunction(text.test)
|
||||
return text.test(trimmedText)
|
||||
else
|
||||
return trimmedText.indexOf(text) != -1;
|
||||
|
||||
describe "CMS.Views.Alert as base class", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ConfirmationMessage({
|
||||
title: "Portal"
|
||||
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
|
||||
})
|
||||
# it will be interesting to see when this.render is called, so lets spy on it
|
||||
spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough()
|
||||
|
||||
it "renders on initalize", ->
|
||||
view = new CMS.Views.Alert({model: @model})
|
||||
expect(view.render).toHaveBeenCalled()
|
||||
|
||||
it "renders the template", ->
|
||||
view = new CMS.Views.Alert({model: @model})
|
||||
expect(view.$(".action-close")).toBeDefined()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
expect(view.$el).toContainText(@model.get("title"))
|
||||
expect(view.$el).toContainText(@model.get("message"))
|
||||
|
||||
it "close button sends a .hide() message", ->
|
||||
spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough()
|
||||
|
||||
view = new CMS.Views.Alert({model: @model})
|
||||
view.$(".action-close").click()
|
||||
|
||||
expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
describe "CMS.Views.Prompt", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ConfirmationMessage({
|
||||
title: "Portal"
|
||||
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
|
||||
})
|
||||
|
||||
# for some reason, expect($("body")) blows up the test runner, so this test
|
||||
# just exercises the Prompt rather than asserting on anything. Best I can
|
||||
# do for now. :(
|
||||
it "changes class on body", ->
|
||||
# expect($("body")).not.toHaveClass("prompt-is-shown")
|
||||
view = new CMS.Views.Prompt({model: @model})
|
||||
# expect($("body")).toHaveClass("prompt-is-shown")
|
||||
view.hide()
|
||||
# expect($("body")).not.toHaveClass("prompt-is-shown")
|
||||
|
||||
describe "CMS.Views.Alert click events", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.WarningMessage(
|
||||
title: "Unsaved",
|
||||
message: "Your content is currently Unsaved.",
|
||||
actions:
|
||||
primary:
|
||||
text: "Save",
|
||||
class: "save-button",
|
||||
click: jasmine.createSpy('primaryClick')
|
||||
secondary: [{
|
||||
text: "Revert",
|
||||
class: "cancel-button",
|
||||
click: jasmine.createSpy('secondaryClick')
|
||||
}]
|
||||
|
||||
)
|
||||
|
||||
@view = new CMS.Views.Alert({model: @model})
|
||||
|
||||
it "should trigger the primary event on a primary click", ->
|
||||
@view.primaryClick()
|
||||
expect(@model.get('actions').primary.click).toHaveBeenCalled()
|
||||
|
||||
it "should trigger the secondary event on a secondary click", ->
|
||||
@view.secondaryClick()
|
||||
expect(@model.get('actions').secondary[0].click).toHaveBeenCalled()
|
||||
|
||||
it "should apply class to primary action", ->
|
||||
expect(@view.$(".action-primary")).toHaveClass("save-button")
|
||||
|
||||
it "should apply class to secondary action", ->
|
||||
expect(@view.$(".action-secondary")).toHaveClass("cancel-button")
|
||||
|
||||
describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.SystemFeedback(
|
||||
intent: "saving"
|
||||
title: "Saving"
|
||||
)
|
||||
spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough()
|
||||
spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough()
|
||||
@clock = sinon.useFakeTimers()
|
||||
|
||||
afterEach ->
|
||||
@clock.restore()
|
||||
|
||||
it "a minShown view should not hide too quickly", ->
|
||||
view = new CMS.Views.Notification({model: @model, minShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# call hide() on it, but the minShown should prevent it from hiding right away
|
||||
view.hide()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# wait for the minShown timeout to expire, and check again
|
||||
@clock.tick(1001)
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a maxShown view should hide by itself", ->
|
||||
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# wait for the maxShown timeout to expire, and check again
|
||||
@clock.tick(1001)
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a minShown view can stay visible longer", ->
|
||||
view = new CMS.Views.Notification({model: @model, minShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# wait for the minShown timeout to expire, and check again
|
||||
@clock.tick(1001)
|
||||
expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# can now hide immediately
|
||||
view.hide()
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a maxShown view can hide early", ->
|
||||
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# wait 50 milliseconds, and hide it early
|
||||
@clock.tick(50)
|
||||
view.hide()
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
# wait for timeout to expire, make sure it doesn't do anything weird
|
||||
@clock.tick(1000)
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a view can have both maxShown and minShown", ->
|
||||
view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000})
|
||||
|
||||
# can't hide early
|
||||
@clock.tick(50)
|
||||
view.hide()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
@clock.tick(1000)
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
# show it again, and let it hide automatically
|
||||
view.show()
|
||||
@clock.tick(1050)
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
@clock.tick(1000)
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
300
cms/static/coffee/spec/views/metadata_edit_spec.coffee
Normal file
300
cms/static/coffee/spec/views/metadata_edit_spec.coffee
Normal file
@@ -0,0 +1,300 @@
|
||||
describe "Test Metadata Editor", ->
|
||||
editorTemplate = readFixtures('metadata-editor.underscore')
|
||||
numberEntryTemplate = readFixtures('metadata-number-entry.underscore')
|
||||
stringEntryTemplate = readFixtures('metadata-string-entry.underscore')
|
||||
optionEntryTemplate = readFixtures('metadata-option-entry.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-option-entry", type: "text/template"}).text(optionEntryTemplate))
|
||||
|
||||
genericEntry = {
|
||||
default_value: 'default value',
|
||||
display_name: "Display Name",
|
||||
explicitly_set: true,
|
||||
field_name: "display_name",
|
||||
help: "Specifies the name for this component.",
|
||||
inheritable: false,
|
||||
options: [],
|
||||
type: CMS.Models.Metadata.GENERIC_TYPE,
|
||||
value: "Word cloud"
|
||||
}
|
||||
|
||||
selectEntry = {
|
||||
default_value: "answered",
|
||||
display_name: "Show Answer",
|
||||
explicitly_set: false,
|
||||
field_name: "show_answer",
|
||||
help: "When should you show the answer",
|
||||
inheritable: true,
|
||||
options: [
|
||||
{"display_name": "Always", "value": "always"},
|
||||
{"display_name": "Answered", "value": "answered"},
|
||||
{"display_name": "Never", "value": "never"}
|
||||
],
|
||||
type: CMS.Models.Metadata.SELECT_TYPE,
|
||||
value: "always"
|
||||
}
|
||||
|
||||
integerEntry = {
|
||||
default_value: 5,
|
||||
display_name: "Inputs",
|
||||
explicitly_set: false,
|
||||
field_name: "num_inputs",
|
||||
help: "Number of text boxes for student to input words/sentences.",
|
||||
inheritable: false,
|
||||
options: {min: 1},
|
||||
type: CMS.Models.Metadata.INTEGER_TYPE,
|
||||
value: 5
|
||||
}
|
||||
|
||||
floatEntry = {
|
||||
default_value: 2.7,
|
||||
display_name: "Weight",
|
||||
explicitly_set: true,
|
||||
field_name: "weight",
|
||||
help: "Weight for this problem",
|
||||
inheritable: true,
|
||||
options: {min: 1.3, max:100.2, step:0.1},
|
||||
type: CMS.Models.Metadata.FLOAT_TYPE,
|
||||
value: 10.2
|
||||
}
|
||||
|
||||
# Test for the editor that creates the individual views.
|
||||
describe "CMS.Views.Metadata.Editor creates editors for each field", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.MetadataCollection(
|
||||
[
|
||||
integerEntry,
|
||||
floatEntry,
|
||||
selectEntry,
|
||||
genericEntry,
|
||||
{
|
||||
default_value: null,
|
||||
display_name: "Unknown",
|
||||
explicitly_set: true,
|
||||
field_name: "unknown_type",
|
||||
help: "Mystery property.",
|
||||
inheritable: false,
|
||||
options: [
|
||||
{"display_name": "Always", "value": "always"},
|
||||
{"display_name": "Answered", "value": "answered"},
|
||||
{"display_name": "Never", "value": "never"}],
|
||||
type: "unknown type",
|
||||
value: null
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
it "creates child views on initialize, and sorts them alphabetically", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
childModels = view.collection.models
|
||||
expect(childModels.length).toBe(5)
|
||||
childViews = view.$el.find('.setting-input')
|
||||
expect(childViews.length).toBe(5)
|
||||
|
||||
verifyEntry = (index, display_name, type) ->
|
||||
expect(childModels[index].get('display_name')).toBe(display_name)
|
||||
expect(childViews[index].type).toBe(type)
|
||||
|
||||
verifyEntry(0, 'Display Name', 'text')
|
||||
verifyEntry(1, 'Inputs', 'number')
|
||||
verifyEntry(2, 'Show Answer', 'select-one')
|
||||
verifyEntry(3, 'Unknown', 'text')
|
||||
verifyEntry(4, 'Weight', 'number')
|
||||
|
||||
it "returns its display name", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
expect(view.getDisplayName()).toBe("Word cloud")
|
||||
|
||||
it "returns an empty string if there is no display name property with a valid value", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: new CMS.Models.MetadataCollection()})
|
||||
expect(view.getDisplayName()).toBe("")
|
||||
|
||||
view = new CMS.Views.Metadata.Editor({collection: new CMS.Models.MetadataCollection([
|
||||
{
|
||||
default_value: null,
|
||||
display_name: "Display Name",
|
||||
explicitly_set: false,
|
||||
field_name: "display_name",
|
||||
help: "",
|
||||
inheritable: false,
|
||||
options: [],
|
||||
type: CMS.Models.Metadata.GENERIC_TYPE,
|
||||
value: null
|
||||
|
||||
}])
|
||||
})
|
||||
expect(view.getDisplayName()).toBe("")
|
||||
|
||||
it "has no modified values by default", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
expect(view.getModifiedMetadataValues()).toEqual({})
|
||||
|
||||
it "returns modified values only", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
childModels = view.collection.models
|
||||
childModels[0].setValue('updated display name')
|
||||
childModels[1].setValue(20)
|
||||
expect(view.getModifiedMetadataValues()).toEqual({
|
||||
display_name : 'updated display name',
|
||||
num_inputs: 20
|
||||
})
|
||||
|
||||
# Tests for individual views.
|
||||
assertInputType = (view, expectedType) ->
|
||||
input = view.$el.find('.setting-input')
|
||||
expect(input.length).toBe(1)
|
||||
expect(input[0].type).toBe(expectedType)
|
||||
|
||||
assertValueInView = (view, expectedValue) ->
|
||||
expect(view.getValueFromEditor()).toBe(expectedValue)
|
||||
|
||||
assertCanUpdateView = (view, newValue) ->
|
||||
view.setValueInEditor(newValue)
|
||||
expect(view.getValueFromEditor()).toBe(newValue)
|
||||
|
||||
assertClear = (view, modelValue, editorValue=modelValue) ->
|
||||
view.clear()
|
||||
expect(view.model.getValue()).toBe(null)
|
||||
expect(view.model.getDisplayValue()).toBe(modelValue)
|
||||
expect(view.getValueFromEditor()).toBe(editorValue)
|
||||
|
||||
assertUpdateModel = (view, originalValue, newValue) ->
|
||||
view.setValueInEditor(newValue)
|
||||
expect(view.model.getValue()).toBe(originalValue)
|
||||
view.updateModel()
|
||||
expect(view.model.getValue()).toBe(newValue)
|
||||
|
||||
describe "CMS.Views.Metadata.String is a basic string input with clear functionality", ->
|
||||
beforeEach ->
|
||||
model = new CMS.Models.Metadata(genericEntry)
|
||||
@view = new CMS.Views.Metadata.String({model: model})
|
||||
|
||||
it "uses a text input type", ->
|
||||
assertInputType(@view, 'text')
|
||||
|
||||
it "returns the intial value upon initialization", ->
|
||||
assertValueInView(@view, 'Word cloud')
|
||||
|
||||
it "can update its value in the view", ->
|
||||
assertCanUpdateView(@view, "updated ' \" &")
|
||||
|
||||
it "has a clear method to revert to the model default", ->
|
||||
assertClear(@view, 'default value')
|
||||
|
||||
it "has an update model method", ->
|
||||
assertUpdateModel(@view, 'Word cloud', 'updated')
|
||||
|
||||
describe "CMS.Views.Metadata.Option is an option input type with clear functionality", ->
|
||||
beforeEach ->
|
||||
model = new CMS.Models.Metadata(selectEntry)
|
||||
@view = new CMS.Views.Metadata.Option({model: model})
|
||||
|
||||
it "uses a select input type", ->
|
||||
assertInputType(@view, 'select-one')
|
||||
|
||||
it "returns the intial value upon initialization", ->
|
||||
assertValueInView(@view, 'always')
|
||||
|
||||
it "can update its value in the view", ->
|
||||
assertCanUpdateView(@view, "never")
|
||||
|
||||
it "has a clear method to revert to the model default", ->
|
||||
assertClear(@view, 'answered')
|
||||
|
||||
it "has an update model method", ->
|
||||
assertUpdateModel(@view, null, 'never')
|
||||
|
||||
it "does not update to a value that is not an option", ->
|
||||
@view.setValueInEditor("not an option")
|
||||
expect(@view.getValueFromEditor()).toBe('always')
|
||||
|
||||
describe "CMS.Views.Metadata.Number supports integer or float type and has clear functionality", ->
|
||||
beforeEach ->
|
||||
integerModel = new CMS.Models.Metadata(integerEntry)
|
||||
@integerView = new CMS.Views.Metadata.Number({model: integerModel})
|
||||
|
||||
floatModel = new CMS.Models.Metadata(floatEntry)
|
||||
@floatView = new CMS.Views.Metadata.Number({model: floatModel})
|
||||
|
||||
it "uses a number input type", ->
|
||||
assertInputType(@integerView, 'number')
|
||||
assertInputType(@floatView, 'number')
|
||||
|
||||
it "returns the intial value upon initialization", ->
|
||||
assertValueInView(@integerView, '5')
|
||||
assertValueInView(@floatView, '10.2')
|
||||
|
||||
it "can update its value in the view", ->
|
||||
assertCanUpdateView(@integerView, "12")
|
||||
assertCanUpdateView(@floatView, "-2.4")
|
||||
|
||||
it "has a clear method to revert to the model default", ->
|
||||
assertClear(@integerView, 5, '5')
|
||||
assertClear(@floatView, 2.7, '2.7')
|
||||
|
||||
it "has an update model method", ->
|
||||
assertUpdateModel(@integerView, null, '90')
|
||||
assertUpdateModel(@floatView, 10.2, '-9.5')
|
||||
|
||||
it "knows the difference between integer and float", ->
|
||||
expect(@integerView.isIntegerField()).toBeTruthy()
|
||||
expect(@floatView.isIntegerField()).toBeFalsy()
|
||||
|
||||
it "sets attribtues related to min, max, and step", ->
|
||||
verifyAttributes = (view, min, step, max=null) ->
|
||||
inputEntry = view.$el.find('input')
|
||||
expect(Number(inputEntry.attr('min'))).toEqual(min)
|
||||
expect(Number(inputEntry.attr('step'))).toEqual(step)
|
||||
if max is not null
|
||||
expect(Number(inputEntry.attr('max'))).toEqual(max)
|
||||
|
||||
verifyAttributes(@integerView, 1, 1)
|
||||
verifyAttributes(@floatView, 1.3, .1, 100.2)
|
||||
|
||||
it "corrects values that are out of range", ->
|
||||
verifyValueAfterChanged = (view, value, expectedResult) ->
|
||||
view.setValueInEditor(value)
|
||||
view.changed()
|
||||
expect(view.getValueFromEditor()).toBe(expectedResult)
|
||||
|
||||
verifyValueAfterChanged(@integerView, '-4', '1')
|
||||
verifyValueAfterChanged(@integerView, '1', '1')
|
||||
verifyValueAfterChanged(@integerView, '0', '1')
|
||||
verifyValueAfterChanged(@integerView, '3001', '3001')
|
||||
|
||||
verifyValueAfterChanged(@floatView, '-4', '1.3')
|
||||
verifyValueAfterChanged(@floatView, '1.3', '1.3')
|
||||
verifyValueAfterChanged(@floatView, '1.2', '1.3')
|
||||
verifyValueAfterChanged(@floatView, '100.2', '100.2')
|
||||
verifyValueAfterChanged(@floatView, '100.3', '100.2')
|
||||
|
||||
it "disallows invalid characters", ->
|
||||
verifyValueAfterKeyPressed = (view, character, reject) ->
|
||||
event = {
|
||||
type : 'keypress',
|
||||
which : character.charCodeAt(0),
|
||||
keyCode: character.charCodeAt(0),
|
||||
preventDefault : () -> 'no op'
|
||||
}
|
||||
spyOn(event, 'preventDefault')
|
||||
view.$el.find('input').trigger(event)
|
||||
if (reject)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
else
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
|
||||
verifyDisallowedChars = (view) ->
|
||||
verifyValueAfterKeyPressed(view, 'a', true)
|
||||
verifyValueAfterKeyPressed(view, '.', view.isIntegerField())
|
||||
verifyValueAfterKeyPressed(view, '[', true)
|
||||
verifyValueAfterKeyPressed(view, '@', true)
|
||||
|
||||
for i in [0...9]
|
||||
verifyValueAfterKeyPressed(view, String(i), false)
|
||||
|
||||
verifyDisallowedChars(@integerView)
|
||||
verifyDisallowedChars(@floatView)
|
||||
@@ -72,3 +72,4 @@ describe "CMS.Views.ModuleEdit", ->
|
||||
it "loads the .xmodule-display inside the module editor", ->
|
||||
expect(XModule.loadModule).toHaveBeenCalled()
|
||||
expect(XModule.loadModule.mostRecentCall.args[0]).toBe($('.xmodule_display'))
|
||||
|
||||
|
||||
85
cms/static/coffee/spec/views/section_spec.coffee
Normal file
85
cms/static/coffee/spec/views/section_spec.coffee
Normal file
@@ -0,0 +1,85 @@
|
||||
describe "CMS.Views.SectionShow", ->
|
||||
describe "Basic", ->
|
||||
beforeEach ->
|
||||
spyOn(CMS.Views.SectionShow.prototype, "switchToEditView")
|
||||
.andCallThrough()
|
||||
@model = new CMS.Models.Section({
|
||||
id: 42
|
||||
name: "Life, the Universe, and Everything"
|
||||
})
|
||||
@view = new CMS.Views.SectionShow({model: @model})
|
||||
@view.render()
|
||||
|
||||
it "should contain the model name", ->
|
||||
expect(@view.$el).toHaveText(@model.get('name'))
|
||||
|
||||
it "should call switchToEditView when clicked", ->
|
||||
@view.$el.click()
|
||||
expect(@view.switchToEditView).toHaveBeenCalled()
|
||||
|
||||
it "should pass the same element to SectionEdit when switching views", ->
|
||||
spyOn(CMS.Views.SectionEdit.prototype, 'initialize').andCallThrough()
|
||||
@view.switchToEditView()
|
||||
expect(CMS.Views.SectionEdit.prototype.initialize).toHaveBeenCalled()
|
||||
expect(CMS.Views.SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el)
|
||||
|
||||
describe "CMS.Views.SectionEdit", ->
|
||||
describe "Basic", ->
|
||||
tpl = readFixtures('section-name-edit.underscore')
|
||||
feedback_tpl = readFixtures('system-feedback.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "section-name-edit-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedback_tpl))
|
||||
spyOn(CMS.Views.SectionEdit.prototype, "switchToShowView")
|
||||
.andCallThrough()
|
||||
spyOn(CMS.Views.SectionEdit.prototype, "showInvalidMessage")
|
||||
.andCallThrough()
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track'])
|
||||
window.course_location_analytics = jasmine.createSpy()
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
|
||||
@model = new CMS.Models.Section({
|
||||
id: 42
|
||||
name: "Life, the Universe, and Everything"
|
||||
})
|
||||
@view = new CMS.Views.SectionEdit({model: @model})
|
||||
@view.render()
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
delete window.analytics
|
||||
delete window.course_location_analytics
|
||||
|
||||
it "should have the model name as the default text value", ->
|
||||
expect(@view.$("input[type=text]").val()).toEqual(@model.get('name'))
|
||||
|
||||
it "should call switchToShowView when cancel button is clicked", ->
|
||||
@view.$("input.cancel-button").click()
|
||||
expect(@view.switchToShowView).toHaveBeenCalled()
|
||||
|
||||
it "should save model when save button is clicked", ->
|
||||
spyOn(@model, 'save')
|
||||
@view.$("input[type=submit]").click()
|
||||
expect(@model.save).toHaveBeenCalled()
|
||||
|
||||
it "should call switchToShowView when save() is successful", ->
|
||||
@view.$("input[type=submit]").click()
|
||||
@requests[0].respond(200)
|
||||
expect(@view.switchToShowView).toHaveBeenCalled()
|
||||
|
||||
it "should call showInvalidMessage when validation is unsuccessful", ->
|
||||
spyOn(@model, 'validate').andReturn("BLARRGH")
|
||||
@view.$("input[type=submit]").click()
|
||||
expect(@view.showInvalidMessage).toHaveBeenCalledWith(
|
||||
jasmine.any(Object), "BLARRGH", jasmine.any(Object))
|
||||
expect(@view.switchToShowView).not.toHaveBeenCalled()
|
||||
|
||||
it "should not save when validation is unsuccessful", ->
|
||||
spyOn(@model, 'validate').andReturn("BLARRGH")
|
||||
@view.$("input[type=text]").val("changed")
|
||||
@view.$("input[type=submit]").click()
|
||||
expect(@model.get('name')).not.toEqual("changed")
|
||||
|
||||
@@ -15,6 +15,15 @@ $ ->
|
||||
headers : { 'X-CSRFToken': $.cookie 'csrftoken' }
|
||||
dataType: 'json'
|
||||
|
||||
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
|
||||
if ajaxSettings.notifyOnError is false
|
||||
return
|
||||
msg = new CMS.Models.ErrorMessage(
|
||||
"title": gettext("Studio's having trouble saving your work")
|
||||
"message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
|
||||
)
|
||||
new CMS.Views.Notification({model: msg})
|
||||
|
||||
window.onTouchBasedDevice = ->
|
||||
navigator.userAgent.match /iPhone|iPod|iPad/i
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user