PY-2016.1.4 <alisantang@C02RP0YSG8WM.tld Merge branch 'master'
This commit is contained in:
49
.coveragerc
Normal file
49
.coveragerc
Normal file
@@ -0,0 +1,49 @@
|
||||
# .coveragerc for edx-platform
|
||||
[run]
|
||||
data_file = reports/.coverage
|
||||
source =
|
||||
cms
|
||||
common/djangoapps
|
||||
common/lib/calc
|
||||
common/lib/capa
|
||||
common/lib/xmodule
|
||||
lms
|
||||
openedx/core/djangoapps
|
||||
pavelib
|
||||
|
||||
omit =
|
||||
cms/envs/*
|
||||
cms/manage.py
|
||||
cms/djangoapps/contentstore/views/dev.py
|
||||
cms/djangoapps/*/migrations/*
|
||||
cms/djangoapps/*/features/*
|
||||
lms/debug/*
|
||||
lms/envs/*
|
||||
lms/djangoapps/*/migrations/*
|
||||
lms/djangoapps/*/features/*
|
||||
common/djangoapps/terrain/*
|
||||
common/djangoapps/*/migrations/*
|
||||
openedx/core/djangoapps/*/migrations/*
|
||||
openedx/core/djangoapps/debug/*
|
||||
|
||||
[report]
|
||||
ignore_errors = True
|
||||
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
[html]
|
||||
title = edx-platform Python Test Coverage Report
|
||||
directory = reports/cover
|
||||
|
||||
[xml]
|
||||
output = reports/coverage.xml
|
||||
|
||||
[paths]
|
||||
jenkins_source =
|
||||
/home/jenkins/workspace/edx-platform-unit-coverage
|
||||
/home/jenkins/workspace/edx-platform-test-subset
|
||||
|
||||
devstack_source =
|
||||
/edx/app/edxapp/edx-platform
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* -text
|
||||
110
.gitignore
vendored
Normal file
110
.gitignore
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
# .gitignore for edx-platform.
|
||||
# There's a lot here, please try to keep it organized.
|
||||
|
||||
### Files private to developers
|
||||
|
||||
requirements/private.txt
|
||||
lms/envs/private.py
|
||||
cms/envs/private.py
|
||||
|
||||
### Python artifacts
|
||||
*.pyc
|
||||
|
||||
### Editor and IDE artifacts
|
||||
*~
|
||||
*.swp
|
||||
*.orig
|
||||
/nbproject
|
||||
.idea/
|
||||
.redcar/
|
||||
codekit-config.json
|
||||
.pycharm_helpers/
|
||||
|
||||
### NFS artifacts
|
||||
.nfs*
|
||||
|
||||
### OS X artifacts
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
:2e_*
|
||||
:2e#
|
||||
|
||||
### Internationalization artifacts
|
||||
*.mo
|
||||
*.po
|
||||
*.prob
|
||||
*.dup
|
||||
!django.po
|
||||
!django.mo
|
||||
!djangojs.po
|
||||
!djangojs.mo
|
||||
conf/locale/en/LC_MESSAGES/*.mo
|
||||
conf/locale/fake*/LC_MESSAGES/*.po
|
||||
conf/locale/fake*/LC_MESSAGES/*.mo
|
||||
# this was a mistake in i18n_tools, now fixed.
|
||||
conf/locale/messages.mo
|
||||
|
||||
### Testing artifacts
|
||||
.testids/
|
||||
.noseids
|
||||
nosetests.xml
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
cover/
|
||||
cover_html/
|
||||
reports/
|
||||
jscover.log
|
||||
jscover.log.*
|
||||
.tddium*
|
||||
common/test/data/test_unicode/static/
|
||||
test_root/courses/
|
||||
django-pyfs
|
||||
|
||||
### Installation artifacts
|
||||
*.egg-info
|
||||
.pip_download_cache/
|
||||
.prereqs_cache
|
||||
.vagrant/
|
||||
node_modules
|
||||
bin/
|
||||
|
||||
### Static assets pipeline artifacts
|
||||
*.scssc
|
||||
lms/static/css/
|
||||
lms/static/certificates/css/
|
||||
cms/static/css/
|
||||
common/static/common/js/vendor/
|
||||
|
||||
### Styling generated from templates
|
||||
lms/static/sass/*.css
|
||||
lms/static/sass/*.css.map
|
||||
lms/static/certificates/sass/*.css
|
||||
lms/static/themed_sass/
|
||||
cms/static/css/
|
||||
cms/static/sass/*.css
|
||||
cms/static/sass/*.css.map
|
||||
cms/static/themed_sass/
|
||||
themes/**/css/*.css
|
||||
|
||||
### Logging artifacts
|
||||
log/
|
||||
logs
|
||||
chromedriver.log
|
||||
ghostdriver.log
|
||||
|
||||
### Celery artifacts ###
|
||||
celerybeat-schedule
|
||||
|
||||
### Unknown artifacts
|
||||
database.sqlite
|
||||
courseware/static/js/mathjax/*
|
||||
flushdb.sh
|
||||
build
|
||||
/src/
|
||||
\#*\#
|
||||
.env/
|
||||
lms/lib/comment_client/python
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
dist
|
||||
7
.jshintignore
Normal file
7
.jshintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
**/vendor
|
||||
cms/static/js/i18n/**/*.js
|
||||
lms/static/js/i18n/**/*.js
|
||||
lms/static/lms/js/build.js
|
||||
lms/static/lms/js/spec/main.js
|
||||
node_modules
|
||||
venv
|
||||
154
.jshintrc
Normal file
154
.jshintrc
Normal file
@@ -0,0 +1,154 @@
|
||||
// --------------------------------------------------------------------
|
||||
// JSHint Configuration
|
||||
// --------------------------------------------------------------------
|
||||
//
|
||||
// http://www.jshint.com/
|
||||
// http://jshint.com/docs/options/
|
||||
{
|
||||
// == Enforcing Options ===============================================
|
||||
"bitwise" : false, // Prohibits the use of bitwise operators such as ^ (XOR), | (OR) and others. Bitwise operators are very rare in JavaScript programs and quite often & is simply a mistyped &&.
|
||||
"camelcase" : false, // Allows you to force all variable names to use either camelCase style or UPPER_CASE with underscores.
|
||||
"curly" : true, // Requires you to always put curly braces around blocks in loops and conditionals. JavaScript allows you to omit curly braces when the block consists of only one statement
|
||||
"eqeqeq" : true, // Prohibits the use of == and != in favor of === and !==.
|
||||
"es3" : false, // Tells JSHint that your code needs to adhere to ECMAScript 3 specification. Use this option if you need your program to be executable in older browsers—such as Internet Explorer 6/7/8/9.
|
||||
"forin" : true, // Requires all for in loops to filter object's items.
|
||||
"freeze" : true, // Prohibits overwriting prototypes of native objects such as Array, Date and so on.
|
||||
"immed" : true, // Prohibits the use of immediate function invocations without wrapping them in parentheses.
|
||||
// "indent" : 4, // Enforces specific tab width for your code. Has no effect when "white" option is not used.
|
||||
"latedef" : "nofunc", // Prohibits the use of a variable before it was defined. Setting this option to "nofunc" will allow function declarations to be ignored.
|
||||
"newcap" : false, // Requires you to capitalize names of constructor functions.
|
||||
"noarg" : true, // Prohibits the use of arguments.caller and arguments.callee.
|
||||
"noempty" : true, // Warns when you have an empty block in your code.
|
||||
"nonbsp" : true, // Warns about "non-breaking whitespace" characters.
|
||||
"nonew" : true, // Prohibits the use of constructor functions for side-effects.
|
||||
"plusplus" : false, // Prohibits the use of unary increment and decrement operators.
|
||||
"quotmark" : false, // Enforces the consistency of quotation marks used throughout your code. It accepts three values: true, "single", and "double".
|
||||
"undef" : true, // Prohibits the use of explicitly undeclared variables.
|
||||
"unused" : true, // Warns when you define and never use your variables.
|
||||
"strict" : true, // Requires all functions to run in ECMAScript 5's strict mode.
|
||||
"trailing" : true, // Makes it an error to leave a trailing whitespace in your code.
|
||||
"maxlen" : 120, // Lets you set the maximum length of a line.
|
||||
//"maxparams" : 4, // Lets you set the max number of formal parameters allowed per function.
|
||||
//"maxdepth" : 4, // Lets you control how nested do you want your blocks to be.
|
||||
//"maxstatements" : 4, // Lets you set the max number of statements allowed per function.
|
||||
//"maxcomplexity" : 4, // Lets you control cyclomatic complexity throughout your code.
|
||||
|
||||
|
||||
// == Relaxing Options ================================================
|
||||
"asi" : false, // Suppresses warnings about missing semicolons.
|
||||
"boss" : false, // Suppresses warnings about the use of assignments in cases where comparisons are expected.
|
||||
"debug" : false, // Suppresses warnings about the debugger statements in your code.
|
||||
"eqnull" : false, // Suppresses warnings about == null comparisons.
|
||||
"esnext" : false, // Tells JSHint that your code uses ECMAScript 6 specific syntax.
|
||||
"evil" : false, // Suppresses warnings about the use of eval.
|
||||
"expr" : false, // Suppresses warnings about the use of expressions where normally you would expect to see assignments or function calls.
|
||||
"funcscope" : false, // Suppresses warnings about declaring variables inside of control structures while accessing them later from the outside.
|
||||
"gcl" : false, // Makes JSHint compatible with Google Closure Compiler.
|
||||
"globalstrict" : false, // Suppresses warnings about the use of global strict mode.
|
||||
"iterator" : false, // Suppresses warnings about the __iterator__ property.
|
||||
"lastsemic" : false, // Suppresses warnings about missing semicolons, but only when the semicolon is omitted for the last statement in a one-line block.
|
||||
"laxbreak" : false, // Suppresses most of the warnings about possibly unsafe line breaks in your code.
|
||||
"laxcomma" : false, // Suppresses warnings about comma-first coding style.
|
||||
"loopfunc" : false, // Suppresses warnings about functions inside of loops.
|
||||
"maxerr" : 100, // Set the maximum amount of warnings JSHint will produce before giving up.
|
||||
"moz" : false, // Tells JSHint that your code uses Mozilla JavaScript extensions.
|
||||
"notypeof" : false, // Suppresses warnings about invalid typeof operator values.
|
||||
"proto" : false, // Suppresses warnings about the __proto__ property.
|
||||
"scripturl" : false, // Suppresses warnings about the use of script-targeted URLs—such as javascript:...
|
||||
"smarttabs" : false, // Suppresses warnings about mixed tabs and spaces when the latter are used for alignment only.
|
||||
"shadow" : false, // Suppresses warnings about variable shadowing i.e. declaring a variable that had been already declared somewhere in the outer scope.
|
||||
"sub" : false, // Suppresses warnings about using [] notation when it can be expressed in dot notation.
|
||||
"supernew" : false, // Suppresses warnings about "weird" constructions like new function () { ... } and new Object;.
|
||||
"validthis" : true, // Suppresses warnings about possible strict violations when the code is running in strict mode and you use this in a non-constructor function.
|
||||
"noyield" : false, // Suppresses warnings about generator functions with no yield statement in them.
|
||||
|
||||
|
||||
// == Environments ====================================================
|
||||
//
|
||||
// These options pre-define global variables that are exposed by
|
||||
// popular JavaScript libraries and runtime environments—such as
|
||||
// browser or node.js.
|
||||
"browser" : true, // Defines globals exposed by modern browsers: all the way from good old document and navigator to the HTML5 FileReader and other new developments in the browser world.
|
||||
"devel" : true, // Defines globals that are usually used for logging poor-man's debugging: console, alert, etc.
|
||||
// The rest should remain `false`. Please see explanation for the "predef" parameter below.
|
||||
"couch" : false, // Defines globals exposed by CouchDB.
|
||||
"dojo" : false, // Defines globals exposed by the Dojo Toolkit
|
||||
"jquery" : false, // Defines globals exposed by the jQuery JavaScript library.
|
||||
"mootools" : false, // Defines globals exposed by the MooTools JavaScript framework.
|
||||
"node" : false, // Defines globals available when your code is running inside of the Node runtime environment.
|
||||
"nonstandard" : false, // Defines non-standard but widely adopted globals such as escape and unescape.
|
||||
"phantom" : false, // Defines globals available when your core is running inside of the PhantomJS runtime environment.
|
||||
"prototypejs" : false, // Defines globals exposed by the Prototype JavaScript framework.
|
||||
"rhino" : false, // Defines globals available when your code is running inside of the Rhino runtime environment.
|
||||
"worker" : false, // Defines globals available when your code is running inside of a Web Worker.
|
||||
"wsh" : false, // Defines globals available when your code is running as a script for the Windows Script Host.
|
||||
"yui" : false, // Defines globals exposed by the YUI JavaScript framework.
|
||||
|
||||
|
||||
// == JSLint Legacy ===================================================
|
||||
//
|
||||
// These options are legacy from JSLint. Aside from bug fixes they will
|
||||
// not be improved in any way and might be removed at any point.
|
||||
// "nomen" : false, // Disallows the use of dangling _ in variables.
|
||||
// "onevar" : false, // Allows only one var statement per function.
|
||||
// "passfail" : false, // Makes JSHint stop on the first error or warning.
|
||||
// "white" : false, // make JSHint check your source code against Douglas Crockford's JavaScript coding style.
|
||||
|
||||
|
||||
// == Undocumented Options ============================================
|
||||
//
|
||||
// If you are using some global variable, for example `define`, or `$`, please
|
||||
// make it available within your JS file by passing it to the wrapper anonymous
|
||||
// function like so:
|
||||
//
|
||||
// (function (define, $) {
|
||||
// 'use strict';
|
||||
// // Your content goes here which uses `define`, and `$`.
|
||||
// }).call(this, window.define, window.jQuery);
|
||||
//
|
||||
// The parameter "predef" should remain empty for this configuration file
|
||||
// to remain as general as possible.
|
||||
"predef": [
|
||||
// jQuery globals
|
||||
"jQuery", "$",
|
||||
|
||||
// Underscore.js globals
|
||||
"_",
|
||||
|
||||
// RequireJS globals
|
||||
"define",
|
||||
"require",
|
||||
"RequireJS",
|
||||
|
||||
// Jasmine globals
|
||||
"jasmine",
|
||||
"describe", "xdescribe",
|
||||
"it", "xit",
|
||||
"spyOn",
|
||||
"beforeEach",
|
||||
"afterEach",
|
||||
"expect",
|
||||
"waitsFor",
|
||||
"runs",
|
||||
|
||||
// jQuery-Jasmine globals
|
||||
"loadFixtures",
|
||||
"appendLoadFixtures",
|
||||
"readFixtures",
|
||||
"setFixtures",
|
||||
"appendSetFixtures",
|
||||
"spyOnEvent",
|
||||
|
||||
// Django i18n catalog globals
|
||||
"interpolate",
|
||||
"gettext",
|
||||
"ngettext",
|
||||
|
||||
// Miscellaneous globals
|
||||
"JSON",
|
||||
|
||||
// edX globals
|
||||
"edx",
|
||||
"XBlock"
|
||||
]
|
||||
}
|
||||
56
.tx/config
Normal file
56
.tx/config
Normal file
@@ -0,0 +1,56 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-platform.django-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/django-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/django-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.django-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/django-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/django-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.djangojs-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.djangojs-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.mako]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.mako-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.underscore]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/underscore.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/underscore.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.underscore-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/underscore-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/underscore-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.wiki]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/wiki.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/wiki.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
273
AUTHORS
Normal file
273
AUTHORS
Normal file
@@ -0,0 +1,273 @@
|
||||
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@mit.edu>
|
||||
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 Náter <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>
|
||||
Anton 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>
|
||||
Jonah Stanley <Jonah_Stanley@brown.edu>
|
||||
Slater Victoroff <slater.r.victoroff@gmail.com>
|
||||
Peter Fogg <peter.p.fogg@gmail.com>
|
||||
Bethany LaPenta <lapentab@mit.edu>
|
||||
Renzo Lucioni <renzo@edx.org>
|
||||
Felix Sun <felixsun@mit.edu>
|
||||
Adam Palay <adam@edx.org>
|
||||
Ian Hoover <ihoover@edx.org>
|
||||
Mukul Goyal <miki@edx.org>
|
||||
Robert Marks <rmarks@edx.org>
|
||||
Yarko Tymciurak <yarkot1@gmail.com>
|
||||
Miles Steele <miles@milessteele.com>
|
||||
Kevin Luo <kevluo@edx.org>
|
||||
Akshay Jagadeesh <akjags@gmail.com>
|
||||
Nick Parlante <nick.parlante@cs.stanford.edu>
|
||||
Marko Seric <marko.seric@math.uzh.ch>
|
||||
Felipe Montoya <felipe.montoya@edunext.co>
|
||||
Julia Hansbrough <julia@edx.org>
|
||||
Pavel Yushchenko <pavelyushchenko@gmail.com>
|
||||
Nicolas Chevalier <nicolas.chevalier@epitech.eu>
|
||||
Gabe Mulley <gabe@edx.org>
|
||||
Iain Dunning <idunning@mit.edu>
|
||||
Olivier Marquez <oliviermarquez@gmail.com>
|
||||
Florian Dufour <neurolit@gmail.com>
|
||||
Manuel Freire <manuel.freire@fdi.ucm.es>
|
||||
Daniel Cebrián Robles <danielcebrianr@gmail.com>
|
||||
Carson Gee <cgee@mit.edu>
|
||||
Gang Chen <goncha@gmail.com>
|
||||
Bertrand Marron <bertrand.marron@ionis-group.com>
|
||||
Yihua Lou <supermouselyh@hotmail.com>
|
||||
Andy Armstrong <andya@edx.org>
|
||||
Matt Drayer <mattdrayer@edx.org>
|
||||
Cristian Salamea <cristian.salamea@iaen.edu.ec>
|
||||
Graham Lowe <graham.lowe@gmail.com>
|
||||
Matt Bachmann <bachmann.matt@gmail.com>
|
||||
Dave St.Germain <dstgermain@edx.org>
|
||||
Usman Khalid <2200617@gmail.com>
|
||||
John Kern <kern3020@gmail.com>
|
||||
John Orr <jorr@google.com>
|
||||
Mark Hoeber <hoeber@edx.org>
|
||||
Waheed Ahmed <waheed.ahmed@arbisoft.com>
|
||||
Javier Orts Peñarrocha <jorts@upv.es>
|
||||
Stephen Sanchez <steve@edx.org>
|
||||
Jim Abramson <jsa@edx.org>
|
||||
Chris Rossi <chris@archimedeanco.com>
|
||||
Oleg Marshev <oleh.marshev@gmail.com>
|
||||
Sylvia Pearce <spearce@edx.org>
|
||||
Olga Stroilova <olga@edx.org>
|
||||
Paul-Olivier Dehaye <paulolivier@gmail.com>
|
||||
Feanil Patel <feanil@edx.org>
|
||||
Zubair Afzal <zubair.afzal@arbisoft.com>
|
||||
Juho Kim <juhokim@edx.org>
|
||||
Alison Hodges <ahodges@edx.org>
|
||||
Jane Manning <jmanning@gmail.com>
|
||||
Toddi Norum <toddi@edx.org>
|
||||
Xavier Antoviaque <xavier@antoviaque.org>
|
||||
Ali Reza Sharafat <ali.sharafat@gmail.com>
|
||||
Avinash Sajjanshetty <avinashsajjan@gmail.com>
|
||||
David Glance <david.glance@gmail.com>
|
||||
Nimisha Asthagiri <nasthagiri@edx.org>
|
||||
Martyn James <mjames@edx.org>
|
||||
Han Su Kim <hkim823@gmail.com>
|
||||
Raees Chachar <raees.chachar@arbisoft.com>
|
||||
Muhammad Ammar <muhammad.ammar@arbisoft.com>
|
||||
William Desloge <william.desloge@ionis-group.com>
|
||||
Martin Segado <msegado@mit.edu>
|
||||
Marco Re <mrc.re@tiscali.it>
|
||||
Jonas Jelten <jelten@in.tum.de>
|
||||
Christine Lytwynec <clytwynec@edx.org>
|
||||
John Cox <johncox@google.com>
|
||||
Ben Weeks <benweeks@mit.edu>
|
||||
David Bodor <david.gabor.bodor@gmail.com>
|
||||
Sébastien Hinderer <Sebastien.Hinderer@inria.fr>
|
||||
Kristin Stephens <ksteph@cs.berkeley.edu>
|
||||
Ben Patterson <bpatterson@edx.org>
|
||||
Luis Duarte <lduarte1991@gmail.com>
|
||||
Steven Burch <stv@stanford.edu>
|
||||
Waqas Khalid <wkhalid@edx.org>
|
||||
Muhammad Ammar <mammar@edx.org>
|
||||
Abdallah Nassif <abdoosh00@gmail.com>
|
||||
Johnny Brown <johnnybrown7@gmail.com>
|
||||
Ben McMorran <bmcmorran@edx.org>
|
||||
Mat Peterson <mpeterson@edx.org>
|
||||
Tim Babych <tim.babych@gmail.com>
|
||||
Brandon DeRosier <btd@cheesekeg.com>
|
||||
Daniel Li <swli@edx.org>
|
||||
Daniel Friedman <dfriedman@edx.org>
|
||||
Zia Fazal <zia.fazal@arbisoft.com>
|
||||
Asad Iqbal <aiqbal@edx.org>
|
||||
Peter Pinch <pdpinch@mit.edu>
|
||||
Muhammad Shoaib <mshoaib@edx.org>
|
||||
Nicholas Dupoux <njdupoux1994@gmail.com>
|
||||
John Eskew <jeskew@edx.org>
|
||||
Juanan Pereira <juanan.pereira@ehu.es>
|
||||
Clinton Blackburn <cblackburn@edx.org>
|
||||
Dennis Jen <djen@edx.org>
|
||||
Filippo Valsorda <hi@filippo.io>
|
||||
Ivica Ceraj <ceraj@mit.edu>
|
||||
Jason Zhu <fmyzjs@gmail.com>
|
||||
Marceau Cnudde <marceau.cnudde@gmail.com>
|
||||
Braden MacDonald <mail@bradenm.com>
|
||||
Jonathan Piacenti <kelketek@gmail.com>
|
||||
Alasdair Swan <aswan@edx.org>
|
||||
Paul Medlock-Walton <paulmw@mit.edu>
|
||||
Henry Tareque <henry.tareque@gmail.com>
|
||||
Eugeny Kolpakov <eugeny.kolpakov@gmail.com>
|
||||
Omar Al-Ithawi <oithawi@qrf.org>
|
||||
Louis Pilfold <louis@lpil.uk>
|
||||
Akiva Leffert <akiva@edx.org>
|
||||
Mike Bifulco <mbifulco@aquent.com>
|
||||
Jim Zheng <jimzheng@stanford.edu>
|
||||
Afzal Wali <afzaledx@edx.org>
|
||||
Julien Romagnoli <julien.romagnoli@fbmx.net>
|
||||
Wenjie Wu <wuwenjie718@gmail.com>
|
||||
Aamir <aamir.nu.206@gmail.com>
|
||||
Steve Jackson <sjackso@ixoreus.net>
|
||||
Steffan Sluis <steffansluis@gmail.com>
|
||||
Siem Kok <siem@feedbackfruits.com>
|
||||
Régis Behmo <regis.behmo@openfun.fr>
|
||||
Mark Sadecki <msadecki@edx.org>
|
||||
Dino Cikatić <dcikatic@edx.org>
|
||||
Davorin Šego <dsego@edx.org>
|
||||
Marko Jevtić <mjevtic@edx.org>
|
||||
Ahsan Ulhaq <ahsan@edx.org>
|
||||
Mat Moore <mat@mooresoftware.co.uk>
|
||||
Muzaffar Yousaf <muzaffar@edx.org>
|
||||
Sylvain <sylvain@openfun.fr>
|
||||
Mayank Jain <mjmayank@gmail.com>
|
||||
Carlos de la Guardia <cguardia@yahoo.com>
|
||||
Matjaz Gregoric <mtyaka@gmail.com>
|
||||
Kyle Boots <indagation@gmail.com>
|
||||
John Espinosa <johncespinosa@gmail.com>
|
||||
Phil McGachey <phil_mcgachey@harvard.edu>
|
||||
Sri Harsha Pamu <kmitharsha@gmail.com>
|
||||
Cris Ewing <cris@crisewing.com>
|
||||
Carlos de La Guardia <carlos@jazkarta.com>
|
||||
Amir Qayyum Khan <amir.qayyum@arbisoft.com>
|
||||
Jolyon Bloomfield <jolyon@mit.edu>
|
||||
Kyle McCormick <kylemccor@gmail.com>
|
||||
Jim Cai <jimcai@stanford.edu>
|
||||
Richard Moch <richard.moch@gmail.com>
|
||||
Randy Ostler <rando305@gmail.com>
|
||||
Thomas Young <thomas@upcoder.com>
|
||||
Andrew Dekker <simultech@gmail.com>
|
||||
Christopher Lee <clee@edx.org>
|
||||
Mushtaq Ali <mushtaque.ali@arbisoft.com>
|
||||
Colin Fredericks <colin.fredericks@gmail.com>
|
||||
Xiaolu Xiong <beardeer@gmail.com>
|
||||
Tim Krones <t.krones@gmx.net>
|
||||
Linda Liu <lliu@edx.org>
|
||||
Alessandro Verdura <finalmente2@tin.it>
|
||||
Sven Marnach <sven@marnach.net>
|
||||
Richard Moch <richard.moch@gmail.com>
|
||||
Albert Liang <albertliangcode@gmail.com>
|
||||
Pan Luo <pan.luo@ubc.ca>
|
||||
Tyler Nickerson <nickersoft@gmail.com>
|
||||
Daniel Naranjo <daniel.naranjo@edunext.co>
|
||||
Vedran Karačić <vedran@edx.org>
|
||||
William Ono <william.ono@ubc.ca>
|
||||
Dongwook Yoon <dy252@cornell.edu>
|
||||
Sola Shirai <sola@edx.org>
|
||||
Awais Qureshi <awais.qureshi@arbisoft.com>
|
||||
Eric Fischer <efischer@edx.org>
|
||||
Brian Beggs <macdiesel@gmail.com>
|
||||
Bill DeRusha <bill@edx.org>
|
||||
Kevin Falcone <kevin@edx.org>
|
||||
Mirjam Škarica <mirjamskarica@gmail.com>
|
||||
Saleem Latif <saleem@edx.org>
|
||||
Julien Paillé <julien.paille@openfun.fr>
|
||||
Michael Frey <mfrey@edx.org>
|
||||
Hasnain Naveed <hasnain@edx.org>
|
||||
J. Cliff Dyer <cdyer@edx.org>
|
||||
Jamie Folsom <jfolsom@mit.edu>
|
||||
George Schneeloch <gschneel@mit.edu>
|
||||
Dustin Gadal <Dustin.Gadal@gmail.com>
|
||||
Ibrahim Ahmed <ibrahimahmed443@gmail.com>
|
||||
Robert Raposa <rraposa@edx.org>
|
||||
Giovanni Di Milia <gdimilia@mit.edu>
|
||||
Peter Wilkins <pwilkins@mit.edu>
|
||||
Justin Abrahms <abrahms@mit.edu>
|
||||
Arbab Nazar <arbab@edx.org>
|
||||
Douglas Hall <dhall@edx.org>
|
||||
Awais Jibran <awaisdar001@gmail.com>
|
||||
Muhammad Rehan <muhammadrehan69@gmail.com>
|
||||
Shawn Milochik <shawn@milochik.com>
|
||||
Afeef Janjua <janjua.afeef@gmail.com>
|
||||
Jacek Bzdak <jbzdak@gmail.com>
|
||||
Jillian Vogel <pomegranited@gmail.com>
|
||||
Dan Powell <dan@abakas.com>
|
||||
Mariana Araújo <simbelm.ne@gmail.com>
|
||||
Muhammad Ayub Khan <ayub.khan@arbisoft.com>
|
||||
Kaloian Doganov <doganov@gmail.com>
|
||||
Sanford Student <sstudent@edx.org>
|
||||
Florian Haas <florian@hastexo.com>
|
||||
Leonardo Quiñonez <leonardo.quinonez@edunext.co>
|
||||
Dmitry Viskov <dmitry.viskov@webenterprise.ru>
|
||||
Brian Jacobel <bjacobel@edx.org>
|
||||
Sigberto Alarcon <salarcon@stanford.edu>
|
||||
Sofiya Semenova <ssemenova@edx.org>
|
||||
5
CHANGELOG.rst
Normal file
5
CHANGELOG.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
We don't maintain a detailed changelog. For details of changes, please see
|
||||
either the `edX Release Notes`_ or the `GitHub commit history`_.
|
||||
|
||||
.. _edX Release Notes: http://edx.readthedocs.org/projects/edx-release-notes/en/latest/
|
||||
.. _GitHub commit history: https://github.com/edx/edx-platform/commits/master
|
||||
228
CONTRIBUTING.rst
Normal file
228
CONTRIBUTING.rst
Normal file
@@ -0,0 +1,228 @@
|
||||
############################
|
||||
Contributing to Open edX
|
||||
############################
|
||||
|
||||
Contributions to Open edX are very welcome, and strongly encouraged! We've
|
||||
put together `some documentation that describes our contribution process`_,
|
||||
but here's a step-by-step guide that should help you get started.
|
||||
|
||||
.. _some documentation that describes our contribution process: http://edx.readthedocs.org/projects/edx-developer-guide/en/latest/process/index.html
|
||||
|
||||
Step 0: Join the Conversation
|
||||
=============================
|
||||
|
||||
Got an idea for how to improve the codebase? Fantastic, we'd love to hear about
|
||||
it! Before you dive in and spend a lot of time and effort making a pull request,
|
||||
it's a good idea to discuss your idea with other interested developers and/or the
|
||||
edX product team. You may get some valuable feedback that changes how you think
|
||||
about your idea, or you may find other developers who have the same idea and want
|
||||
to work together.
|
||||
|
||||
JIRA
|
||||
----
|
||||
|
||||
If you've got an idea for a new feature or new functionality for an existing feature,
|
||||
please start a discussion on the `edx-code`_ mailing list to get feedback from
|
||||
the community about the idea and your implementation choices.
|
||||
|
||||
.. _edx-code: https://groups.google.com/forum/#!forum/edx-code
|
||||
|
||||
If you then plan to contribute your code upstream, please `start a discussion on JIRA`_
|
||||
(you may first need to `create a free JIRA account`_).
|
||||
Start a discussion by visiting the JIRA website and clicking the "Create" button at the
|
||||
top of the page. Choose the project "Open Source Pull Requests" and the issue type
|
||||
"Feature Proposal". In the description give us as much detail as you can for the feature
|
||||
or functionality you are thinking about implementing. Include a link to any relevant
|
||||
edx-code mailing list discussions about your idea. We encourage you to do this before
|
||||
you begin implementing your feature, in order to get valuable feedback from the edX
|
||||
product team early on in your journey and increase the likelihood of a successful
|
||||
pull request.
|
||||
|
||||
.. _start a discussion on JIRA: https://openedx.atlassian.net/secure/Dashboard.jspa
|
||||
.. _create a free JIRA account: https://openedx.atlassian.net/admin/users/sign-up
|
||||
|
||||
Slack
|
||||
-----
|
||||
|
||||
To talk with others in the Open edX community, join us on `Slack`_.
|
||||
`Sign up for a free account`_ and join the conversation!
|
||||
The group tends to be most active Monday through Friday
|
||||
between 13:00 and 21:00 UTC (9am to 5pm US Eastern time),
|
||||
but interesting conversations can happen at any time.
|
||||
There are many different channels available for different topics, including:
|
||||
|
||||
* ``#ops`` for installation help
|
||||
* ``#events`` for upcoming events related to Open edX
|
||||
* ``#content`` for discussions about course content and creating the best courses
|
||||
|
||||
And lots more! You can also make your own channels to discuss new topics.
|
||||
|
||||
.. _Slack: https://slack.com/
|
||||
.. _Sign up for a free account: https://openedx-slack-invite.herokuapp.com/
|
||||
|
||||
Mailing Lists
|
||||
-------------
|
||||
|
||||
For asynchronous conversation, we have several mailing lists on Google Groups:
|
||||
|
||||
* `openedx-ops`_: everything related to *running* Open edX. This includes
|
||||
installation issues, server management, cost analysis, and so on.
|
||||
* `openedx-translation`_: everything related to *translating* Open edX into
|
||||
other languages. This includes volunteer translators, our internationalization
|
||||
infrastructure, issues related to Transifex, and so on.
|
||||
* `openedx-analytics`_: everything related to *analytics* in Open edX.
|
||||
* `edx-code`_: everything related to the *code* in Open edX. This includes
|
||||
feature requests, idea proposals, refactorings, and so on.
|
||||
|
||||
.. _openedx-ops: https://groups.google.com/forum/#!forum/openedx-ops
|
||||
.. _openedx-translation: https://groups.google.com/forum/#!forum/openedx-translation
|
||||
.. _openedx-analytics: https://groups.google.com/forum/#!forum/openedx-analytics
|
||||
.. _edx-code: https://groups.google.com/forum/#!forum/edx-code
|
||||
|
||||
Byte-sized Tasks & Bugs
|
||||
-----------------------
|
||||
|
||||
If you are contributing for the first time and want a gentle introduction,
|
||||
or if you aren't sure what to work on, have a look at the list of
|
||||
`byte-sized bugs and tasks`_ in the tracker. These tasks are selected for their
|
||||
small size, and usually don't require a broad knowledge of the edX platform.
|
||||
It makes them good candidates for a first task, allowing you to focus on getting
|
||||
familiar with the development environment and the contribution process.
|
||||
|
||||
.. _byte-sized bugs and tasks: http://bit.ly/edxbugs
|
||||
|
||||
Once you have identified a bug or task, `create an account on the tracker`_ and
|
||||
then comment on the ticket to indicate that you are working on it. Don't hesitate
|
||||
to ask clarifying questions on the ticket as needed, too, if anything is unclear.
|
||||
|
||||
.. _create an account on the tracker: https://openedx.atlassian.net/admin/users/sign-up
|
||||
|
||||
Step 1: Sign a Contribution Agreement
|
||||
=====================================
|
||||
|
||||
Before edX can accept any code contributions from you, you'll need to sign
|
||||
the `individual contributor agreement`_ and send it in. This confirms
|
||||
that you have the authority to contribute the code in the pull request and
|
||||
ensures that edX can relicense it.
|
||||
|
||||
You should print out the agreement and sign it. Then scan (or photograph) the
|
||||
signed agreement and email it to the email address indicated on the agreement.
|
||||
Alternatively, you're also free to physically mail the agreement to the street
|
||||
address on the agreement. Once we have your agreement in hand, we can begin
|
||||
reviewing and merging your work.
|
||||
|
||||
You'll also need to add yourself to the `AUTHORS` file when you submit your
|
||||
first pull request. You should add your full name as well as the email address
|
||||
associated with your GitHub account. Please update `AUTHORS` in an individual
|
||||
commit, distinct from other changes in the pull request (it's OK for a pull
|
||||
request to contain multiple commits, including a commit to `AUTHORS`).
|
||||
Alternatively, you can open up a separate PR just to have your name added to
|
||||
the `AUTHORS` file, and link that PR to the PR with your changes.
|
||||
|
||||
Step 2: Fork, Commit, and Pull Request
|
||||
======================================
|
||||
GitHub has some great documentation on `how to fork a git repository`_. Once
|
||||
you've done that, make your changes and `send us a pull request`_! Be sure to
|
||||
include a detailed description for your pull request, so that a community
|
||||
manager can understand *what* change you're making, *why* you're making it, *how*
|
||||
it should work now, and how you can *test* that it's working correctly.
|
||||
|
||||
.. _how to fork a git repository: https://help.github.com/articles/fork-a-repo
|
||||
.. _send us a pull request: https://help.github.com/articles/creating-a-pull-request
|
||||
|
||||
Step 3: Meet PR Requirements
|
||||
============================
|
||||
|
||||
Our `contributor documentation`_ includes a long list of requirements that pull
|
||||
requests must meet in order to be reviewed by a core committer. These requirements
|
||||
include things like documentation and passing tests: see the
|
||||
`contributor documentation`_ page for the full list.
|
||||
|
||||
.. _contributor documentation: http://edx.readthedocs.org/projects/edx-developer-guide/en/latest/process/contributor.html
|
||||
|
||||
|
||||
Areas of particular concern with their own detailed guidelines are:
|
||||
|
||||
* `Accessibility`_: making sure our applications can
|
||||
be used by people with disabilities, in keeping with the edX
|
||||
`website accessibility policy`_.
|
||||
* `Internationalization`_: enabling translation for use
|
||||
around the world.
|
||||
|
||||
|
||||
.. _Accessibility: http://edx.readthedocs.org/projects/edx-developer-guide/en/latest/conventions/accessibility.html
|
||||
.. _website accessibility policy: https://www.edx.org/accessibility
|
||||
.. _Internationalization: http://edx.readthedocs.org/projects/edx-developer-guide/en/latest/conventions/internationalization/index.html
|
||||
|
||||
|
||||
Step 4: Approval by Community Manager and Product Owner
|
||||
=======================================================
|
||||
|
||||
A community manager will read the description of your pull request. If the
|
||||
description is understandable, the community manager will send the pull request
|
||||
to a product owner. The product owner will evaluate if the pull request is a
|
||||
good idea for Open edX, and if not, your pull request will be rejected. This
|
||||
is another good reason why you should discuss your ideas with other members
|
||||
of the community before working on a pull request!
|
||||
|
||||
Step 5: Code Review by Core Committer(s)
|
||||
========================================
|
||||
|
||||
If your pull request meets the requirements listed in the
|
||||
`contributor documentation`_, and it hasn't been rejected by a product owner,
|
||||
then it will be scheduled for code review by one or more core committers. This
|
||||
process sometimes takes awhile: most of the core committers on the project
|
||||
are employees of edX, and they have to balance their time between code review
|
||||
and new development.
|
||||
|
||||
Once the code review process has started, please be responsive to comments on
|
||||
the pull request, so we can keep the review process moving forward.
|
||||
If you are unable to respond for a few days, that's fine, but
|
||||
please add a comment informing us of that -- otherwise, it looks like you're
|
||||
abandoning your work!
|
||||
|
||||
Step 6: Merge!
|
||||
==============
|
||||
|
||||
Once the core committers are satisfied that your pull request is ready to go,
|
||||
one of them will merge it for you. Your code will end up on the edX production
|
||||
servers in the next release, which usually which happens every week. Congrats!
|
||||
|
||||
|
||||
############################
|
||||
Expectations We Have of You
|
||||
############################
|
||||
|
||||
By opening up a pull request, we expect the following things:
|
||||
|
||||
1. You've read and understand the instructions in this contributing file and
|
||||
the contribution process documentation.
|
||||
|
||||
2. You are ready to engage with the edX community. Engaging means you will be
|
||||
prompt in following up with review comments and critiques. Do not open up a
|
||||
pull request right before a vacation or heavy workload that will render you
|
||||
unable to participate in the review process.
|
||||
|
||||
3. If you have questions, you will ask them by either commenting on the pull
|
||||
request or asking us in IRC or on the mailing list.
|
||||
|
||||
4. If you do not respond to comments on your pull request within 7 days, we
|
||||
will close it. You are welcome to re-open it when you are ready to engage.
|
||||
|
||||
############################
|
||||
Expectations You Have of Us
|
||||
############################
|
||||
|
||||
1. Within a week of opening up a pull request, one of our community managers
|
||||
will triage it, starting the documented contribution process. (Please
|
||||
give us a little extra time if you open the PR on a weekend or
|
||||
around a US holiday! We may take a little longer getting to it.)
|
||||
|
||||
2. We promise to engage in an active dialogue with you from the time we begin
|
||||
reviewing until either the PR is merged (by a core committer), or we
|
||||
decide that, for whatever reason, it should be closed.
|
||||
|
||||
3. Once we have determined through visual review that your code is not
|
||||
malicious, we will run a Jenkins build on your branch.
|
||||
|
||||
.. _individual contributor agreement: http://open.edx.org/sites/default/files/wysiwyg/individual-contributor-agreement.pdf
|
||||
671
LICENSE
Normal file
671
LICENSE
Normal file
@@ -0,0 +1,671 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
EdX Inc. wishes to state, in clarification of the above license terms, that
|
||||
any public, independently available web service offered over the network and
|
||||
communicating with edX's copyrighted works by any form of inter-service
|
||||
communication, including but not limited to Remote Procedure Call (RPC)
|
||||
interfaces, is not a work based on our copyrighted work within the meaning
|
||||
of the license. "Corresponding Source" of this work, or works based on this
|
||||
work, as defined by the terms of this license do not include source code
|
||||
files for programs used solely to provide those public, independently
|
||||
available web services.
|
||||
126
README.rst
Normal file
126
README.rst
Normal file
@@ -0,0 +1,126 @@
|
||||
This is the main edX platform which consists of LMS and Studio.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Please refer to the following wiki pages in our `configuration repo`_ to
|
||||
install edX:
|
||||
|
||||
- `edX Developer Stack`_: These instructions are for developers who want
|
||||
to contribute or make changes to the edX source code.
|
||||
- `edX Full Stack`_: Using Vagrant/Virtualbox this will setup all edX
|
||||
services on a single server in a production like configuration.
|
||||
- `edX Ubuntu 12.04 64-bit Installation`_: This will install edX on an
|
||||
existing Ubuntu 12.04 server.
|
||||
|
||||
.. _configuration repo: https://github.com/edx/configuration
|
||||
.. _edX Developer Stack: https://openedx.atlassian.net/wiki/display/OpenOPS/Running+Devstack
|
||||
.. _edX Full Stack: https://openedx.atlassian.net/wiki/display/OpenOPS/Running+Fullstack
|
||||
.. _edX Ubuntu 12.04 64-bit Installation: https://openedx.atlassian.net/wiki/display/OpenOPS/Native+Open+edX+Ubuntu+12.04+64+bit+Installation
|
||||
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
The code in this repository is licensed under version 3 of the AGPL
|
||||
unless otherwise noted. Please see the `LICENSE`_ file for details.
|
||||
|
||||
.. _LICENSE: https://github.com/edx/edx-platform/blob/master/LICENSE
|
||||
|
||||
|
||||
The Open edX Portal
|
||||
---------------------
|
||||
|
||||
See the `Open edX Portal`_ to learn more about Open edX. You can find
|
||||
information about the edX roadmap, as well as about hosting, extending, and
|
||||
contributing to Open edX. In addition, the Open edX Portal provides product
|
||||
announcements, the Open edX blog, and other rich community resources.
|
||||
|
||||
To comment on blog posts or the edX roadmap, you must create an account and log
|
||||
in. If you do not have an account, follow these steps.
|
||||
|
||||
#. Visit `open.edx.org/user/register`_.
|
||||
#. Fill in your personal details.
|
||||
#. Select **Create New Account**. You are then logged in to the `Open edX
|
||||
Portal`_.
|
||||
|
||||
.. _Open edX Portal: https://open.edx.org
|
||||
.. _open.edx.org/user/register: https://open.edx.org/user/register
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
Documentation is managed in the `edx-documentation`_ repository. Documentation
|
||||
is built using `Sphinx`_: you can `view the built documentation on
|
||||
ReadTheDocs`_.
|
||||
|
||||
You can also check out `Confluence`_, our wiki system. Once you sign up for
|
||||
an account, you'll be able to create new pages and edit existing pages, just
|
||||
like in any other wiki system. You only need one account for both Confluence
|
||||
and `JIRA`_, our issue tracker.
|
||||
|
||||
.. _Sphinx: http://sphinx-doc.org/
|
||||
.. _view the built documentation on ReadTheDocs: http://docs.edx.org/
|
||||
.. _edx-documentation: https://github.com/edx/edx-documentation
|
||||
.. _Confluence: http://openedx.atlassian.net/wiki/
|
||||
.. _JIRA: https://openedx.atlassian.net/
|
||||
|
||||
|
||||
Getting Help
|
||||
------------
|
||||
|
||||
If you’re having trouble, we have several different mailing lists where
|
||||
you can ask for help:
|
||||
|
||||
- `openedx-ops`_: everything related to *running* Open edX. This
|
||||
includes installation issues, server management, cost analysis, and
|
||||
so on.
|
||||
- `openedx-translation`_: everything related to *translating* Open edX
|
||||
into other languages. This includes volunteer translators, our
|
||||
internationalization infrastructure, issues related to Transifex, and
|
||||
so on.
|
||||
- `openedx-analytics`_: everything related to *analytics* in Open edX.
|
||||
- `edx-code`_: anything else related to Open edX. This includes feature
|
||||
requests, idea proposals, refactorings, and so on.
|
||||
|
||||
You can also join our IRC channel: `#edx-code on Freenode`_.
|
||||
|
||||
.. _openedx-ops: https://groups.google.com/forum/#!forum/openedx-ops
|
||||
.. _openedx-translation: https://groups.google.com/forum/#!forum/openedx-translation
|
||||
.. _openedx-analytics: https://groups.google.com/forum/#!forum/openedx-analytics
|
||||
.. _edx-code: https://groups.google.com/forum/#!forum/edx-code
|
||||
.. _#edx-code on Freenode: http://webchat.freenode.net/?channels=edx-code
|
||||
|
||||
|
||||
Issue Tracker
|
||||
-------------
|
||||
|
||||
`We use JIRA for our issue tracker`_, not GitHub Issues. To file a bug
|
||||
or request a new feature, please make a free account on our JIRA and
|
||||
create a new issue! If you’re filing a bug, we’d appreciate it if you
|
||||
would follow `our guidelines for filing high-quality, actionable bug
|
||||
reports`_. Thanks!
|
||||
|
||||
.. _We use JIRA for our issue tracker: https://openedx.atlassian.net/
|
||||
.. _our guidelines for filing high-quality, actionable bug reports: https://openedx.atlassian.net/wiki/display/SUST/How+to+File+a+Quality+Bug+Report
|
||||
|
||||
|
||||
How to Contribute
|
||||
-----------------
|
||||
|
||||
Contributions are very welcome, but for legal reasons, you must submit a
|
||||
signed `individual contributor’s agreement`_ before we can accept your
|
||||
contribution. See our `CONTRIBUTING`_ file for more information – it
|
||||
also contains guidelines for how to maintain high code quality, which
|
||||
will make your contribution more likely to be accepted.
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
-------------------------
|
||||
|
||||
Please do not report security issues in public. Please email
|
||||
security@edx.org
|
||||
|
||||
.. _individual contributor’s agreement: http://open.edx.org/sites/default/files/wysiwyg/individual-contributor-agreement.pdf
|
||||
.. _CONTRIBUTING: https://github.com/edx/edx-platform/blob/master/CONTRIBUTING.rst
|
||||
68
circle.yml
Normal file
68
circle.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
machine:
|
||||
python:
|
||||
version: 2.7.10
|
||||
|
||||
general:
|
||||
artifacts:
|
||||
- "reports"
|
||||
- "test_root/log"
|
||||
|
||||
dependencies:
|
||||
override:
|
||||
- npm install
|
||||
|
||||
- pip install setuptools
|
||||
- pip install --exists-action w -r requirements/edx/paver.txt
|
||||
|
||||
# Mirror what paver install_prereqs does.
|
||||
# After a successful build, CircleCI will
|
||||
# cache the virtualenv at that state, so that
|
||||
# the next build will not need to install them
|
||||
# from scratch again.
|
||||
- pip install --exists-action w -r requirements/edx/pre.txt
|
||||
- pip install --exists-action w -r requirements/edx/github.txt
|
||||
- pip install --exists-action w -r requirements/edx/local.txt
|
||||
|
||||
# HACK: within base.txt stevedore had a
|
||||
# dependency on a version range of pbr.
|
||||
# Install a version which falls within that range.
|
||||
- pip install --exists-action w pbr==0.9.0
|
||||
- pip install --exists-action w -r requirements/edx/base.txt
|
||||
- pip install --exists-action w -r requirements/edx/paver.txt
|
||||
- if [ -e requirements/edx/post.txt ]; then pip install --exists-action w -r requirements/edx/post.txt ; fi
|
||||
|
||||
- pip install coveralls==1.0
|
||||
|
||||
# Output the installed python packages to the console to help
|
||||
# with troubleshooting any issues with python requirements.
|
||||
- pip freeze
|
||||
|
||||
test:
|
||||
override:
|
||||
# Run tests for the system.
|
||||
# all-tests.sh is the entry point for determining
|
||||
# which tests to run.
|
||||
# See the circleCI documentation regarding parallelism
|
||||
# to understand how multiple containers can be used to
|
||||
# run subsets of tests in parallel.
|
||||
- ./scripts/all-tests.sh:
|
||||
timeout: 900 # if a command runs this many seconds without output, kill it
|
||||
parallel: true
|
||||
|
||||
post:
|
||||
- mkdir -p $CIRCLE_TEST_REPORTS/junit
|
||||
# Copy the junit results up to be consumed by circleci,
|
||||
# but only do this if there actually are results.
|
||||
# Note that the greater than zero comparison is doing a
|
||||
# string compare, but that should be fine for our purposes here.
|
||||
# Do this on each of the containers that were used in
|
||||
# the build so that all results are consolidated.
|
||||
- "if [ $(find reports -type f | wc -l) -gt 0 ] ; then cp -r reports/. $CIRCLE_TEST_REPORTS/junit ; fi":
|
||||
parallel: true
|
||||
|
||||
# If you have enabled coveralls for your repo, configure your COVERALLS_REPO_TOKEN
|
||||
# as an Environment Variable in the Project Settings on CircleCI, and coverage
|
||||
# data will automatically be sent to coveralls. See https://coveralls.io/
|
||||
# If you have not set up set up coveralls then the following statement will
|
||||
# print a message but not affect the pass/fail status of the build.
|
||||
- if [ -z $COVERALLS_REPO_TOKEN ]; then echo "Coveralls token not defined."; else coveralls; fi
|
||||
4
cms/README.rst
Normal file
4
cms/README.rst
Normal file
@@ -0,0 +1,4 @@
|
||||
CMS (Content Management System)
|
||||
-------------------------------
|
||||
|
||||
This directory contains code relating to the course management portal for edX, also known as Studio.
|
||||
9
cms/__init__.py
Normal file
9
cms/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Celery needs to be loaded when the cms modules are so that task
|
||||
registration and discovery can work correctly.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import APP as CELERY_APP
|
||||
23
cms/celery.py
Normal file
23
cms/celery.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Import celery, load its settings from the django settings
|
||||
and auto discover tasks in all installed django apps.
|
||||
|
||||
Taken from: http://celery.readthedocs.org/en/latest/django/first-steps-with-django.html
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
# set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proj.settings')
|
||||
|
||||
APP = Celery('proj')
|
||||
|
||||
# Using a string here means the worker will not have to
|
||||
# pickle the object when using Windows.
|
||||
APP.config_from_object('django.conf:settings')
|
||||
APP.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
|
||||
0
cms/djangoapps/__init__.py
Normal file
0
cms/djangoapps/__init__.py
Normal file
0
cms/djangoapps/contentstore/__init__.py
Normal file
0
cms/djangoapps/contentstore/__init__.py
Normal file
11
cms/djangoapps/contentstore/admin.py
Normal file
11
cms/djangoapps/contentstore/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Admin site bindings for contentstore
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from config_models.admin import ConfigurationModelAdmin
|
||||
from contentstore.models import VideoUploadConfig, PushNotificationConfig
|
||||
|
||||
admin.site.register(VideoUploadConfig, ConfigurationModelAdmin)
|
||||
admin.site.register(PushNotificationConfig, ConfigurationModelAdmin)
|
||||
28
cms/djangoapps/contentstore/context_processors.py
Normal file
28
cms/djangoapps/contentstore/context_processors.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Django Template Context Processor for CMS Online Contextual Help
|
||||
"""
|
||||
import ConfigParser
|
||||
from django.conf import settings
|
||||
|
||||
from util.help_context_processor import common_doc_url
|
||||
|
||||
|
||||
# Open and parse the configuration file when the module is initialized
|
||||
CONFIG_FILE = open(settings.REPO_ROOT / "docs" / "cms_config.ini")
|
||||
CONFIG = ConfigParser.ConfigParser()
|
||||
CONFIG.readfp(CONFIG_FILE)
|
||||
|
||||
|
||||
def doc_url(request=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
This function is added in the list of TEMPLATES 'context_processors' OPTION, which is a django setting for
|
||||
a tuple of callables that take a request object as their argument and return a dictionary of items
|
||||
to be merged into the RequestContext.
|
||||
|
||||
This function returns a dict with get_online_help_info, making it directly available to all mako templates.
|
||||
|
||||
Args:
|
||||
request: Currently not used, but is passed by django to context processors.
|
||||
May be used in the future for determining the language of choice.
|
||||
"""
|
||||
return common_doc_url(request, CONFIG)
|
||||
364
cms/djangoapps/contentstore/course_group_config.py
Normal file
364
cms/djangoapps/contentstore/course_group_config.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Class for manipulating groups configuration on a course object.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from util.db import generate_int_id, MYSQL_MAX_INT
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore.utils import reverse_usage_url
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
from xmodule.split_test_module import get_split_user_partitions
|
||||
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
|
||||
|
||||
MINIMUM_GROUP_ID = 100
|
||||
|
||||
RANDOM_SCHEME = "random"
|
||||
COHORT_SCHEME = "cohort"
|
||||
|
||||
# Note: the following content group configuration strings are not
|
||||
# translated since they are not visible to users.
|
||||
CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can be mapped to cohort groups in the LMS.'
|
||||
|
||||
CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration'
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GroupConfigurationsValidationError(Exception):
|
||||
"""
|
||||
An error thrown when a group configurations input is invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class GroupConfiguration(object):
|
||||
"""
|
||||
Prepare Group Configuration for the course.
|
||||
"""
|
||||
def __init__(self, json_string, course, configuration_id=None):
|
||||
"""
|
||||
Receive group configuration as a json (`json_string`), deserialize it
|
||||
and validate.
|
||||
"""
|
||||
self.configuration = GroupConfiguration.parse(json_string)
|
||||
self.course = course
|
||||
self.assign_id(configuration_id)
|
||||
self.assign_group_ids()
|
||||
self.validate()
|
||||
|
||||
@staticmethod
|
||||
def parse(json_string):
|
||||
"""
|
||||
Deserialize given json that represents group configuration.
|
||||
"""
|
||||
try:
|
||||
configuration = json.loads(json_string)
|
||||
except ValueError:
|
||||
raise GroupConfigurationsValidationError(_("invalid JSON"))
|
||||
configuration["version"] = UserPartition.VERSION
|
||||
return configuration
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Validate group configuration representation.
|
||||
"""
|
||||
if not self.configuration.get("name"):
|
||||
raise GroupConfigurationsValidationError(_("must have name of the configuration"))
|
||||
if len(self.configuration.get('groups', [])) < 1:
|
||||
raise GroupConfigurationsValidationError(_("must have at least one group"))
|
||||
|
||||
def assign_id(self, configuration_id=None):
|
||||
"""
|
||||
Assign id for the json representation of group configuration.
|
||||
"""
|
||||
if configuration_id:
|
||||
self.configuration['id'] = int(configuration_id)
|
||||
else:
|
||||
self.configuration['id'] = generate_int_id(
|
||||
MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(self.course)
|
||||
)
|
||||
|
||||
def assign_group_ids(self):
|
||||
"""
|
||||
Assign ids for the group_configuration's groups.
|
||||
"""
|
||||
used_ids = [g.id for p in self.course.user_partitions for g in p.groups]
|
||||
# Assign ids to every group in configuration.
|
||||
for group in self.configuration.get('groups', []):
|
||||
if group.get('id') is None:
|
||||
group["id"] = generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, used_ids)
|
||||
used_ids.append(group["id"])
|
||||
|
||||
@staticmethod
|
||||
def get_used_ids(course):
|
||||
"""
|
||||
Return a list of IDs that already in use.
|
||||
"""
|
||||
return set([p.id for p in course.user_partitions])
|
||||
|
||||
def get_user_partition(self):
|
||||
"""
|
||||
Get user partition for saving in course.
|
||||
"""
|
||||
return UserPartition.from_json(self.configuration)
|
||||
|
||||
@staticmethod
|
||||
def _get_usage_info(course, unit, item, usage_info, group_id, scheme_name=None):
|
||||
"""
|
||||
Get usage info for unit/module.
|
||||
"""
|
||||
unit_url = reverse_usage_url(
|
||||
'container_handler',
|
||||
course.location.course_key.make_usage_key(unit.location.block_type, unit.location.name)
|
||||
)
|
||||
|
||||
usage_dict = {'label': u"{} / {}".format(unit.display_name, item.display_name), 'url': unit_url}
|
||||
if scheme_name == RANDOM_SCHEME:
|
||||
validation_summary = item.general_validation_message()
|
||||
usage_dict.update({'validation': validation_summary.to_json() if validation_summary else None})
|
||||
|
||||
usage_info[group_id].append(usage_dict)
|
||||
|
||||
return usage_info
|
||||
|
||||
@staticmethod
|
||||
def get_content_experiment_usage_info(store, course):
|
||||
"""
|
||||
Get usage information for all Group Configurations currently referenced by a split_test instance.
|
||||
"""
|
||||
split_tests = store.get_items(course.id, qualifiers={'category': 'split_test'})
|
||||
return GroupConfiguration._get_content_experiment_usage_info(store, course, split_tests)
|
||||
|
||||
@staticmethod
|
||||
def get_split_test_partitions_with_usage(store, course):
|
||||
"""
|
||||
Returns json split_test group configurations updated with usage information.
|
||||
"""
|
||||
usage_info = GroupConfiguration.get_content_experiment_usage_info(store, course)
|
||||
configurations = []
|
||||
for partition in get_split_user_partitions(course.user_partitions):
|
||||
configuration = partition.to_json()
|
||||
configuration['usage'] = usage_info.get(partition.id, [])
|
||||
configurations.append(configuration)
|
||||
return configurations
|
||||
|
||||
@staticmethod
|
||||
def _get_content_experiment_usage_info(store, course, split_tests): # pylint: disable=unused-argument
|
||||
"""
|
||||
Returns all units names, their urls and validation messages.
|
||||
|
||||
Returns:
|
||||
{'user_partition_id':
|
||||
[
|
||||
{
|
||||
'label': 'Unit 1 / Experiment 1',
|
||||
'url': 'url_to_unit_1',
|
||||
'validation': {'message': 'a validation message', 'type': 'warning'}
|
||||
},
|
||||
{
|
||||
'label': 'Unit 2 / Experiment 2',
|
||||
'url': 'url_to_unit_2',
|
||||
'validation': {'message': 'another validation message', 'type': 'error'}
|
||||
}
|
||||
],
|
||||
}
|
||||
"""
|
||||
usage_info = {}
|
||||
for split_test in split_tests:
|
||||
if split_test.user_partition_id not in usage_info:
|
||||
usage_info[split_test.user_partition_id] = []
|
||||
|
||||
unit = split_test.get_parent()
|
||||
if not unit:
|
||||
log.warning("Unable to find parent for split_test %s", split_test.location)
|
||||
continue
|
||||
|
||||
usage_info = GroupConfiguration._get_usage_info(
|
||||
course=course,
|
||||
unit=unit,
|
||||
item=split_test,
|
||||
usage_info=usage_info,
|
||||
group_id=split_test.user_partition_id,
|
||||
scheme_name=RANDOM_SCHEME
|
||||
)
|
||||
return usage_info
|
||||
|
||||
@staticmethod
|
||||
def get_content_groups_usage_info(store, course):
|
||||
"""
|
||||
Get usage information for content groups.
|
||||
"""
|
||||
items = store.get_items(course.id, settings={'group_access': {'$exists': True}}, include_orphans=False)
|
||||
|
||||
return GroupConfiguration._get_content_groups_usage_info(course, items)
|
||||
|
||||
@staticmethod
|
||||
def _get_content_groups_usage_info(course, items):
|
||||
"""
|
||||
Returns all units names and their urls.
|
||||
|
||||
This will return only groups for the cohort user partition.
|
||||
|
||||
Returns:
|
||||
{'group_id':
|
||||
[
|
||||
{
|
||||
'label': 'Unit 1 / Problem 1',
|
||||
'url': 'url_to_unit_1'
|
||||
},
|
||||
{
|
||||
'label': 'Unit 2 / Problem 2',
|
||||
'url': 'url_to_unit_2'
|
||||
}
|
||||
],
|
||||
}
|
||||
"""
|
||||
usage_info = {}
|
||||
for item, group_id in GroupConfiguration._iterate_items_and_content_group_ids(course, items):
|
||||
if group_id not in usage_info:
|
||||
usage_info[group_id] = []
|
||||
|
||||
unit = item.get_parent()
|
||||
if not unit:
|
||||
log.warning("Unable to find parent for component %s", item.location)
|
||||
continue
|
||||
|
||||
usage_info = GroupConfiguration._get_usage_info(
|
||||
course,
|
||||
unit=unit,
|
||||
item=item,
|
||||
usage_info=usage_info,
|
||||
group_id=group_id
|
||||
)
|
||||
|
||||
return usage_info
|
||||
|
||||
@staticmethod
|
||||
def get_content_groups_items_usage_info(store, course):
|
||||
"""
|
||||
Get usage information on items for content groups.
|
||||
"""
|
||||
items = store.get_items(course.id, settings={'group_access': {'$exists': True}})
|
||||
|
||||
return GroupConfiguration._get_content_groups_items_usage_info(course, items)
|
||||
|
||||
@staticmethod
|
||||
def _get_content_groups_items_usage_info(course, items):
|
||||
"""
|
||||
Returns all items names and their urls.
|
||||
|
||||
This will return only groups for the cohort user partition.
|
||||
|
||||
Returns:
|
||||
{'group_id':
|
||||
[
|
||||
{
|
||||
'label': 'Problem 1 / Problem 1',
|
||||
'url': 'url_to_item_1'
|
||||
},
|
||||
{
|
||||
'label': 'Problem 2 / Problem 2',
|
||||
'url': 'url_to_item_2'
|
||||
}
|
||||
],
|
||||
}
|
||||
"""
|
||||
usage_info = {}
|
||||
for item, group_id in GroupConfiguration._iterate_items_and_content_group_ids(course, items):
|
||||
if group_id not in usage_info:
|
||||
usage_info[group_id] = []
|
||||
|
||||
usage_info = GroupConfiguration._get_usage_info(
|
||||
course,
|
||||
unit=item,
|
||||
item=item,
|
||||
usage_info=usage_info,
|
||||
group_id=group_id
|
||||
)
|
||||
|
||||
return usage_info
|
||||
|
||||
@staticmethod
|
||||
def _iterate_items_and_content_group_ids(course, items):
|
||||
"""
|
||||
Iterate through items and content group IDs in a course.
|
||||
|
||||
This will yield group IDs *only* for cohort user partitions.
|
||||
|
||||
Yields: tuple of (item, group_id)
|
||||
"""
|
||||
content_group_configuration = get_cohorted_user_partition(course)
|
||||
if content_group_configuration is not None:
|
||||
for item in items:
|
||||
if hasattr(item, 'group_access') and item.group_access:
|
||||
group_ids = item.group_access.get(content_group_configuration.id, [])
|
||||
|
||||
for group_id in group_ids:
|
||||
yield item, group_id
|
||||
|
||||
@staticmethod
|
||||
def update_usage_info(store, course, configuration):
|
||||
"""
|
||||
Update usage information for particular Group Configuration.
|
||||
|
||||
Returns json of particular group configuration updated with usage information.
|
||||
"""
|
||||
configuration_json = None
|
||||
# Get all Experiments that use particular Group Configuration in course.
|
||||
if configuration.scheme.name == RANDOM_SCHEME:
|
||||
split_tests = store.get_items(
|
||||
course.id,
|
||||
category='split_test',
|
||||
content={'user_partition_id': configuration.id}
|
||||
)
|
||||
configuration_json = configuration.to_json()
|
||||
usage_information = GroupConfiguration._get_content_experiment_usage_info(store, course, split_tests)
|
||||
configuration_json['usage'] = usage_information.get(configuration.id, [])
|
||||
elif configuration.scheme.name == COHORT_SCHEME:
|
||||
# In case if scheme is "cohort"
|
||||
configuration_json = GroupConfiguration.update_content_group_usage_info(store, course, configuration)
|
||||
return configuration_json
|
||||
|
||||
@staticmethod
|
||||
def update_content_group_usage_info(store, course, configuration):
|
||||
"""
|
||||
Update usage information for particular Content Group Configuration.
|
||||
|
||||
Returns json of particular content group configuration updated with usage information.
|
||||
"""
|
||||
usage_info = GroupConfiguration.get_content_groups_usage_info(store, course)
|
||||
content_group_configuration = configuration.to_json()
|
||||
|
||||
for group in content_group_configuration['groups']:
|
||||
group['usage'] = usage_info.get(group['id'], [])
|
||||
|
||||
return content_group_configuration
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_content_group(store, course):
|
||||
"""
|
||||
Returns the first user partition from the course which uses the
|
||||
CohortPartitionScheme, or generates one if no such partition is
|
||||
found. The created partition is not saved to the course until
|
||||
the client explicitly creates a group within the partition and
|
||||
POSTs back.
|
||||
"""
|
||||
content_group_configuration = get_cohorted_user_partition(course)
|
||||
if content_group_configuration is None:
|
||||
content_group_configuration = UserPartition(
|
||||
id=generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(course)),
|
||||
name=CONTENT_GROUP_CONFIGURATION_NAME,
|
||||
description=CONTENT_GROUP_CONFIGURATION_DESCRIPTION,
|
||||
groups=[],
|
||||
scheme_id=COHORT_SCHEME
|
||||
)
|
||||
return content_group_configuration.to_json()
|
||||
|
||||
content_group_configuration = GroupConfiguration.update_content_group_usage_info(
|
||||
store,
|
||||
course,
|
||||
content_group_configuration
|
||||
)
|
||||
return content_group_configuration
|
||||
174
cms/djangoapps/contentstore/course_info_model.py
Normal file
174
cms/djangoapps/contentstore/course_info_model.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Views for viewing, adding, updating and deleting course updates.
|
||||
|
||||
Current db representation:
|
||||
{
|
||||
"_id" : locationjson,
|
||||
"definition" : {
|
||||
"data" : "<ol>[<li><h2>date</h2>content</li>]</ol>"},
|
||||
"items" : [{"id": ID, "date": DATE, "content": CONTENT}]
|
||||
"metadata" : ignored
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.html_module import CourseInfoModule
|
||||
|
||||
from openedx.core.lib.xblock_utils import get_course_update_items
|
||||
from cms.djangoapps.contentstore.push_notification import enqueue_push_course_update
|
||||
|
||||
# # This should be in a class which inherits from XmlDescriptor
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_course_updates(location, provided_id, user_id):
|
||||
"""
|
||||
Retrieve the relevant course_info updates and unpack into the model which the client expects:
|
||||
[{id : index, date : string, content : html string}]
|
||||
"""
|
||||
try:
|
||||
course_updates = modulestore().get_item(location)
|
||||
except ItemNotFoundError:
|
||||
course_updates = modulestore().create_item(user_id, location.course_key, location.block_type, location.block_id)
|
||||
|
||||
course_update_items = get_course_update_items(course_updates, _get_index(provided_id))
|
||||
return _get_visible_update(course_update_items)
|
||||
|
||||
|
||||
def update_course_updates(location, update, passed_id=None, user=None):
|
||||
"""
|
||||
Either add or update the given course update.
|
||||
Add:
|
||||
If the passed_id is absent or None, the course update is added.
|
||||
If push_notification_selected is set in the update, a celery task for the push notification is created.
|
||||
Update:
|
||||
It will update it if it has a passed_id which has a valid value.
|
||||
Until updates have distinct values, the passed_id is the location url + an index into the html structure.
|
||||
"""
|
||||
try:
|
||||
course_updates = modulestore().get_item(location)
|
||||
except ItemNotFoundError:
|
||||
course_updates = modulestore().create_item(user.id, location.course_key, location.block_type, location.block_id)
|
||||
|
||||
course_update_items = list(reversed(get_course_update_items(course_updates)))
|
||||
|
||||
if passed_id is not None:
|
||||
passed_index = _get_index(passed_id)
|
||||
# oldest update at start of list
|
||||
if 0 < passed_index <= len(course_update_items):
|
||||
course_update_dict = course_update_items[passed_index - 1]
|
||||
course_update_dict["date"] = update["date"]
|
||||
course_update_dict["content"] = update["content"]
|
||||
course_update_items[passed_index - 1] = course_update_dict
|
||||
else:
|
||||
return HttpResponseBadRequest(_("Invalid course update id."))
|
||||
else:
|
||||
course_update_dict = {
|
||||
"id": len(course_update_items) + 1,
|
||||
"date": update["date"],
|
||||
"content": update["content"],
|
||||
"status": CourseInfoModule.STATUS_VISIBLE
|
||||
}
|
||||
course_update_items.append(course_update_dict)
|
||||
enqueue_push_course_update(update, location.course_key)
|
||||
|
||||
# update db record
|
||||
save_course_update_items(location, course_updates, course_update_items, user)
|
||||
# remove status key
|
||||
if "status" in course_update_dict:
|
||||
del course_update_dict["status"]
|
||||
return course_update_dict
|
||||
|
||||
|
||||
def _make_update_dict(update):
|
||||
"""
|
||||
Return course update item as a dictionary with required keys ('id', "date" and "content").
|
||||
"""
|
||||
return {
|
||||
"id": update["id"],
|
||||
"date": update["date"],
|
||||
"content": update["content"],
|
||||
}
|
||||
|
||||
|
||||
def _get_visible_update(course_update_items):
|
||||
"""
|
||||
Filter course update items which have status "deleted".
|
||||
"""
|
||||
if isinstance(course_update_items, dict):
|
||||
# single course update item
|
||||
if course_update_items.get("status") != CourseInfoModule.STATUS_DELETED:
|
||||
return _make_update_dict(course_update_items)
|
||||
else:
|
||||
# requested course update item has been deleted (soft delete)
|
||||
return {"error": _("Course update not found."), "status": 404}
|
||||
|
||||
return ([_make_update_dict(update) for update in course_update_items
|
||||
if update.get("status") != CourseInfoModule.STATUS_DELETED])
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def delete_course_update(location, update, passed_id, user):
|
||||
"""
|
||||
Don't delete course update item from db.
|
||||
Delete the given course_info update by settings "status" flag to 'deleted'.
|
||||
Returns the resulting course_updates.
|
||||
"""
|
||||
if not passed_id:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
try:
|
||||
course_updates = modulestore().get_item(location)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
course_update_items = list(reversed(get_course_update_items(course_updates)))
|
||||
passed_index = _get_index(passed_id)
|
||||
|
||||
# delete update item from given index
|
||||
if 0 < passed_index <= len(course_update_items):
|
||||
course_update_item = course_update_items[passed_index - 1]
|
||||
# soft delete course update item
|
||||
course_update_item["status"] = CourseInfoModule.STATUS_DELETED
|
||||
course_update_items[passed_index - 1] = course_update_item
|
||||
|
||||
# update db record
|
||||
save_course_update_items(location, course_updates, course_update_items, user)
|
||||
return _get_visible_update(course_update_items)
|
||||
else:
|
||||
return HttpResponseBadRequest(_("Invalid course update id."))
|
||||
|
||||
|
||||
def _get_index(passed_id=None):
|
||||
"""
|
||||
From the url w/ index appended, get the index.
|
||||
"""
|
||||
if passed_id:
|
||||
index_matcher = re.search(r'.*?/?(\d+)$', passed_id)
|
||||
if index_matcher:
|
||||
return int(index_matcher.group(1))
|
||||
|
||||
# return 0 if no index found
|
||||
return 0
|
||||
|
||||
|
||||
def save_course_update_items(location, course_updates, course_update_items, user=None):
|
||||
"""
|
||||
Save list of course_updates data dictionaries in new field ("course_updates.items")
|
||||
and html related to course update in 'data' ("course_updates.data") field.
|
||||
"""
|
||||
course_updates.items = course_update_items
|
||||
course_updates.data = ""
|
||||
|
||||
# update db record
|
||||
modulestore().update_item(course_updates, user.id)
|
||||
|
||||
return course_updates
|
||||
661
cms/djangoapps/contentstore/courseware_index.py
Normal file
661
cms/djangoapps/contentstore/courseware_index.py
Normal file
@@ -0,0 +1,661 @@
|
||||
""" Code to allow module store to interface with courseware index """
|
||||
from __future__ import absolute_import
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import re
|
||||
from six import add_metaclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy, ugettext as _
|
||||
from django.core.urlresolvers import resolve
|
||||
|
||||
from contentstore.course_group_config import GroupConfiguration
|
||||
from course_modes.models import CourseMode
|
||||
from eventtracking import tracker
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
from search.search_engine_base import SearchEngine
|
||||
from xmodule.annotator_mixin import html_to_text
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.library_tools import normalize_key_for_search
|
||||
|
||||
# REINDEX_AGE is the default amount of time that we look back for changes
|
||||
# that might have happened. If we are provided with a time at which the
|
||||
# indexing is triggered, then we know it is safe to only index items
|
||||
# recently changed at that time. This is the time period that represents
|
||||
# how far back from the trigger point to look back in order to index
|
||||
REINDEX_AGE = timedelta(0, 60) # 60 seconds
|
||||
|
||||
log = logging.getLogger('edx.modulestore')
|
||||
|
||||
|
||||
def strip_html_content_to_text(html_content):
|
||||
""" Gets only the textual part for html content - useful for building text to be searched """
|
||||
# Removing HTML-encoded non-breaking space characters
|
||||
text_content = re.sub(r"(\s| |//)+", " ", html_to_text(html_content))
|
||||
# Removing HTML CDATA
|
||||
text_content = re.sub(r"<!\[CDATA\[.*\]\]>", "", text_content)
|
||||
# Removing HTML comments
|
||||
text_content = re.sub(r"<!--.*-->", "", text_content)
|
||||
|
||||
return text_content
|
||||
|
||||
|
||||
def indexing_is_enabled():
|
||||
"""
|
||||
Checks to see if the indexing feature is enabled
|
||||
"""
|
||||
return settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False)
|
||||
|
||||
|
||||
class SearchIndexingError(Exception):
|
||||
""" Indicates some error(s) occured during indexing """
|
||||
|
||||
def __init__(self, message, error_list):
|
||||
super(SearchIndexingError, self).__init__(message)
|
||||
self.error_list = error_list
|
||||
|
||||
|
||||
@add_metaclass(ABCMeta)
|
||||
class SearchIndexerBase(object):
|
||||
"""
|
||||
Base class to perform indexing for courseware or library search from different modulestores
|
||||
"""
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
INDEX_NAME = None
|
||||
DOCUMENT_TYPE = None
|
||||
ENABLE_INDEXING_KEY = None
|
||||
|
||||
INDEX_EVENT = {
|
||||
'name': None,
|
||||
'category': None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def indexing_is_enabled(cls):
|
||||
"""
|
||||
Checks to see if the indexing feature is enabled
|
||||
"""
|
||||
return settings.FEATURES.get(cls.ENABLE_INDEXING_KEY, False)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def normalize_structure_key(cls, structure_key):
|
||||
""" Normalizes structure key for use in indexing """
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _fetch_top_level(cls, modulestore, structure_key):
|
||||
""" Fetch the item from the modulestore location """
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _get_location_info(cls, normalized_structure_key):
|
||||
""" Builds location info dictionary """
|
||||
|
||||
@classmethod
|
||||
def _id_modifier(cls, usage_id):
|
||||
""" Modifies usage_id to submit to index """
|
||||
return usage_id
|
||||
|
||||
@classmethod
|
||||
def remove_deleted_items(cls, searcher, structure_key, exclude_items):
|
||||
"""
|
||||
remove any item that is present in the search index that is not present in updated list of indexed items
|
||||
as we find items we can shorten the set of items to keep
|
||||
"""
|
||||
response = searcher.search(
|
||||
doc_type=cls.DOCUMENT_TYPE,
|
||||
field_dictionary=cls._get_location_info(structure_key),
|
||||
exclude_dictionary={"id": list(exclude_items)}
|
||||
)
|
||||
result_ids = [result["data"]["id"] for result in response["results"]]
|
||||
searcher.remove(cls.DOCUMENT_TYPE, result_ids)
|
||||
|
||||
@classmethod
|
||||
def index(cls, modulestore, structure_key, triggered_at=None, reindex_age=REINDEX_AGE):
|
||||
"""
|
||||
Process course for indexing
|
||||
|
||||
Arguments:
|
||||
modulestore - modulestore object to use for operations
|
||||
|
||||
structure_key (CourseKey|LibraryKey) - course or library identifier
|
||||
|
||||
triggered_at (datetime) - provides time at which indexing was triggered;
|
||||
useful for index updates - only things changed recently from that date
|
||||
(within REINDEX_AGE above ^^) will have their index updated, others skip
|
||||
updating their index but are still walked through in order to identify
|
||||
which items may need to be removed from the index
|
||||
If None, then a full reindex takes place
|
||||
|
||||
Returns:
|
||||
Number of items that have been added to the index
|
||||
"""
|
||||
error_list = []
|
||||
searcher = SearchEngine.get_search_engine(cls.INDEX_NAME)
|
||||
if not searcher:
|
||||
return
|
||||
|
||||
structure_key = cls.normalize_structure_key(structure_key)
|
||||
location_info = cls._get_location_info(structure_key)
|
||||
|
||||
# Wrap counter in dictionary - otherwise we seem to lose scope inside the embedded function `prepare_item_index`
|
||||
indexed_count = {
|
||||
"count": 0
|
||||
}
|
||||
|
||||
# indexed_items is a list of all the items that we wish to remain in the
|
||||
# index, whether or not we are planning to actually update their index.
|
||||
# This is used in order to build a query to remove those items not in this
|
||||
# list - those are ready to be destroyed
|
||||
indexed_items = set()
|
||||
|
||||
# items_index is a list of all the items index dictionaries.
|
||||
# it is used to collect all indexes and index them using bulk API,
|
||||
# instead of per item index API call.
|
||||
items_index = []
|
||||
|
||||
def get_item_location(item):
|
||||
"""
|
||||
Gets the version agnostic item location
|
||||
"""
|
||||
return item.location.version_agnostic().replace(branch=None)
|
||||
|
||||
def prepare_item_index(item, skip_index=False, groups_usage_info=None):
|
||||
"""
|
||||
Add this item to the items_index and indexed_items list
|
||||
|
||||
Arguments:
|
||||
item - item to add to index, its children will be processed recursively
|
||||
|
||||
skip_index - simply walk the children in the tree, the content change is
|
||||
older than the REINDEX_AGE window and would have been already indexed.
|
||||
This should really only be passed from the recursive child calls when
|
||||
this method has determined that it is safe to do so
|
||||
|
||||
Returns:
|
||||
item_content_groups - content groups assigned to indexed item
|
||||
"""
|
||||
is_indexable = hasattr(item, "index_dictionary")
|
||||
item_index_dictionary = item.index_dictionary() if is_indexable else None
|
||||
# if it's not indexable and it does not have children, then ignore
|
||||
if not item_index_dictionary and not item.has_children:
|
||||
return
|
||||
|
||||
item_content_groups = None
|
||||
|
||||
if item.category == "split_test":
|
||||
split_partition = item.get_selected_partition()
|
||||
for split_test_child in item.get_children():
|
||||
if split_partition:
|
||||
for group in split_partition.groups:
|
||||
group_id = unicode(group.id)
|
||||
child_location = item.group_id_to_child.get(group_id, None)
|
||||
if child_location == split_test_child.location:
|
||||
groups_usage_info.update({
|
||||
unicode(get_item_location(split_test_child)): [group_id],
|
||||
})
|
||||
for component in split_test_child.get_children():
|
||||
groups_usage_info.update({
|
||||
unicode(get_item_location(component)): [group_id]
|
||||
})
|
||||
|
||||
if groups_usage_info:
|
||||
item_location = get_item_location(item)
|
||||
item_content_groups = groups_usage_info.get(unicode(item_location), None)
|
||||
|
||||
item_id = unicode(cls._id_modifier(item.scope_ids.usage_id))
|
||||
indexed_items.add(item_id)
|
||||
if item.has_children:
|
||||
# determine if it's okay to skip adding the children herein based upon how recently any may have changed
|
||||
skip_child_index = skip_index or \
|
||||
(triggered_at is not None and (triggered_at - item.subtree_edited_on) > reindex_age)
|
||||
children_groups_usage = []
|
||||
for child_item in item.get_children():
|
||||
if modulestore.has_published_version(child_item):
|
||||
children_groups_usage.append(
|
||||
prepare_item_index(
|
||||
child_item,
|
||||
skip_index=skip_child_index,
|
||||
groups_usage_info=groups_usage_info
|
||||
)
|
||||
)
|
||||
if None in children_groups_usage:
|
||||
item_content_groups = None
|
||||
|
||||
if skip_index or not item_index_dictionary:
|
||||
return
|
||||
|
||||
item_index = {}
|
||||
# if it has something to add to the index, then add it
|
||||
try:
|
||||
item_index.update(location_info)
|
||||
item_index.update(item_index_dictionary)
|
||||
item_index['id'] = item_id
|
||||
if item.start:
|
||||
item_index['start_date'] = item.start
|
||||
item_index['content_groups'] = item_content_groups if item_content_groups else None
|
||||
item_index.update(cls.supplemental_fields(item))
|
||||
items_index.append(item_index)
|
||||
indexed_count["count"] += 1
|
||||
return item_content_groups
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
# broad exception so that index operation does not fail on one item of many
|
||||
log.warning('Could not index item: %s - %r', item.location, err)
|
||||
error_list.append(_('Could not index item: {}').format(item.location))
|
||||
|
||||
try:
|
||||
with modulestore.branch_setting(ModuleStoreEnum.RevisionOption.published_only):
|
||||
structure = cls._fetch_top_level(modulestore, structure_key)
|
||||
groups_usage_info = cls.fetch_group_usage(modulestore, structure)
|
||||
|
||||
# First perform any additional indexing from the structure object
|
||||
cls.supplemental_index_information(modulestore, structure)
|
||||
|
||||
# Now index the content
|
||||
for item in structure.get_children():
|
||||
prepare_item_index(item, groups_usage_info=groups_usage_info)
|
||||
searcher.index(cls.DOCUMENT_TYPE, items_index)
|
||||
cls.remove_deleted_items(searcher, structure_key, indexed_items)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
# broad exception so that index operation does not prevent the rest of the application from working
|
||||
log.exception(
|
||||
"Indexing error encountered, courseware index may be out of date %s - %r",
|
||||
structure_key,
|
||||
err
|
||||
)
|
||||
error_list.append(_('General indexing error occurred'))
|
||||
|
||||
if error_list:
|
||||
raise SearchIndexingError('Error(s) present during indexing', error_list)
|
||||
|
||||
return indexed_count["count"]
|
||||
|
||||
@classmethod
|
||||
def _do_reindex(cls, modulestore, structure_key):
|
||||
"""
|
||||
(Re)index all content within the given structure (course or library),
|
||||
tracking the fact that a full reindex has taken place
|
||||
"""
|
||||
indexed_count = cls.index(modulestore, structure_key)
|
||||
if indexed_count:
|
||||
cls._track_index_request(cls.INDEX_EVENT['name'], cls.INDEX_EVENT['category'], indexed_count)
|
||||
return indexed_count
|
||||
|
||||
@classmethod
|
||||
def _track_index_request(cls, event_name, category, indexed_count):
|
||||
"""Track content index requests.
|
||||
|
||||
Arguments:
|
||||
event_name (str): Name of the event to be logged.
|
||||
category (str): category of indexed items
|
||||
indexed_count (int): number of indexed items
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
data = {
|
||||
"indexed_count": indexed_count,
|
||||
'category': category,
|
||||
}
|
||||
|
||||
tracker.emit(
|
||||
event_name,
|
||||
data
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fetch_group_usage(cls, modulestore, structure): # pylint: disable=unused-argument
|
||||
"""
|
||||
Base implementation of fetch group usage on course/library.
|
||||
"""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def supplemental_index_information(cls, modulestore, structure):
|
||||
"""
|
||||
Perform any supplemental indexing given that the structure object has
|
||||
already been loaded. Base implementation performs no operation.
|
||||
|
||||
Arguments:
|
||||
modulestore - modulestore object used during the indexing operation
|
||||
structure - structure object loaded during the indexing job
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def supplemental_fields(cls, item): # pylint: disable=unused-argument
|
||||
"""
|
||||
Any supplemental fields that get added to the index for the specified
|
||||
item. Base implementation returns an empty dictionary
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
class CoursewareSearchIndexer(SearchIndexerBase):
|
||||
"""
|
||||
Class to perform indexing for courseware search from different modulestores
|
||||
"""
|
||||
INDEX_NAME = "courseware_index"
|
||||
DOCUMENT_TYPE = "courseware_content"
|
||||
ENABLE_INDEXING_KEY = 'ENABLE_COURSEWARE_INDEX'
|
||||
|
||||
INDEX_EVENT = {
|
||||
'name': 'edx.course.index.reindexed',
|
||||
'category': 'courseware_index'
|
||||
}
|
||||
|
||||
UNNAMED_MODULE_NAME = ugettext_lazy("(Unnamed)")
|
||||
|
||||
@classmethod
|
||||
def normalize_structure_key(cls, structure_key):
|
||||
""" Normalizes structure key for use in indexing """
|
||||
return structure_key
|
||||
|
||||
@classmethod
|
||||
def _fetch_top_level(cls, modulestore, structure_key):
|
||||
""" Fetch the item from the modulestore location """
|
||||
return modulestore.get_course(structure_key, depth=None)
|
||||
|
||||
@classmethod
|
||||
def _get_location_info(cls, normalized_structure_key):
|
||||
""" Builds location info dictionary """
|
||||
return {"course": unicode(normalized_structure_key), "org": normalized_structure_key.org}
|
||||
|
||||
@classmethod
|
||||
def do_course_reindex(cls, modulestore, course_key):
|
||||
"""
|
||||
(Re)index all content within the given course, tracking the fact that a full reindex has taken place
|
||||
"""
|
||||
return cls._do_reindex(modulestore, course_key)
|
||||
|
||||
@classmethod
|
||||
def fetch_group_usage(cls, modulestore, structure):
|
||||
groups_usage_dict = {}
|
||||
groups_usage_info = GroupConfiguration.get_content_groups_usage_info(modulestore, structure).items()
|
||||
groups_usage_info.extend(
|
||||
GroupConfiguration.get_content_groups_items_usage_info(
|
||||
modulestore,
|
||||
structure
|
||||
).items()
|
||||
)
|
||||
if groups_usage_info:
|
||||
for name, group in groups_usage_info:
|
||||
for module in group:
|
||||
view, args, kwargs = resolve(module['url']) # pylint: disable=unused-variable
|
||||
usage_key_string = unicode(kwargs['usage_key_string'])
|
||||
if groups_usage_dict.get(usage_key_string, None):
|
||||
groups_usage_dict[usage_key_string].append(name)
|
||||
else:
|
||||
groups_usage_dict[usage_key_string] = [name]
|
||||
return groups_usage_dict
|
||||
|
||||
@classmethod
|
||||
def supplemental_index_information(cls, modulestore, structure):
|
||||
"""
|
||||
Perform additional indexing from loaded structure object
|
||||
"""
|
||||
CourseAboutSearchIndexer.index_about_information(modulestore, structure)
|
||||
|
||||
@classmethod
|
||||
def supplemental_fields(cls, item):
|
||||
"""
|
||||
Add location path to the item object
|
||||
|
||||
Once we've established the path of names, the first name is the course
|
||||
name, and the next 3 names are the navigable path within the edx
|
||||
application. Notice that we stop at that level because a full path to
|
||||
deep children would be confusing.
|
||||
"""
|
||||
location_path = []
|
||||
parent = item
|
||||
while parent is not None:
|
||||
path_component_name = parent.display_name
|
||||
if not path_component_name:
|
||||
path_component_name = unicode(cls.UNNAMED_MODULE_NAME)
|
||||
location_path.append(path_component_name)
|
||||
parent = parent.get_parent()
|
||||
location_path.reverse()
|
||||
return {
|
||||
"course_name": location_path[0],
|
||||
"location": location_path[1:4]
|
||||
}
|
||||
|
||||
|
||||
class LibrarySearchIndexer(SearchIndexerBase):
|
||||
"""
|
||||
Base class to perform indexing for library search from different modulestores
|
||||
"""
|
||||
INDEX_NAME = "library_index"
|
||||
DOCUMENT_TYPE = "library_content"
|
||||
ENABLE_INDEXING_KEY = 'ENABLE_LIBRARY_INDEX'
|
||||
|
||||
INDEX_EVENT = {
|
||||
'name': 'edx.library.index.reindexed',
|
||||
'category': 'library_index'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def normalize_structure_key(cls, structure_key):
|
||||
""" Normalizes structure key for use in indexing """
|
||||
return normalize_key_for_search(structure_key)
|
||||
|
||||
@classmethod
|
||||
def _fetch_top_level(cls, modulestore, structure_key):
|
||||
""" Fetch the item from the modulestore location """
|
||||
return modulestore.get_library(structure_key, depth=None)
|
||||
|
||||
@classmethod
|
||||
def _get_location_info(cls, normalized_structure_key):
|
||||
""" Builds location info dictionary """
|
||||
return {"library": unicode(normalized_structure_key)}
|
||||
|
||||
@classmethod
|
||||
def _id_modifier(cls, usage_id):
|
||||
""" Modifies usage_id to submit to index """
|
||||
return usage_id.replace(library_key=(usage_id.library_key.replace(version_guid=None, branch=None)))
|
||||
|
||||
@classmethod
|
||||
def do_library_reindex(cls, modulestore, library_key):
|
||||
"""
|
||||
(Re)index all content within the given library, tracking the fact that a full reindex has taken place
|
||||
"""
|
||||
return cls._do_reindex(modulestore, library_key)
|
||||
|
||||
|
||||
class AboutInfo(object):
|
||||
""" About info structure to contain
|
||||
1) Property name to use
|
||||
2) Where to add in the index (using flags above)
|
||||
3) Where to source the properties value
|
||||
"""
|
||||
# Bitwise Flags for where to index the information
|
||||
#
|
||||
# ANALYSE - states that the property text contains content that we wish to be able to find matched within
|
||||
# e.g. "joe" should yield a result for "I'd like to drink a cup of joe"
|
||||
#
|
||||
# PROPERTY - states that the property text should be a property of the indexed document, to be returned with the
|
||||
# results: search matches will only be made on exact string matches
|
||||
# e.g. "joe" will only match on "joe"
|
||||
#
|
||||
# We are using bitwise flags because one may want to add the property to EITHER or BOTH parts of the index
|
||||
# e.g. university name is desired to be analysed, so that a search on "Oxford" will match
|
||||
# property values "University of Oxford" and "Oxford Brookes University",
|
||||
# but it is also a useful property, because within a (future) filtered search a user
|
||||
# may have chosen to filter courses from "University of Oxford"
|
||||
#
|
||||
# see https://wiki.python.org/moin/BitwiseOperators for information about bitwise shift operator used below
|
||||
#
|
||||
ANALYSE = 1 << 0 # Add the information to the analysed content of the index
|
||||
PROPERTY = 1 << 1 # Add the information as a property of the object being indexed (not analysed)
|
||||
|
||||
def __init__(self, property_name, index_flags, source_from):
|
||||
self.property_name = property_name
|
||||
self.index_flags = index_flags
|
||||
self.source_from = source_from
|
||||
|
||||
def get_value(self, **kwargs):
|
||||
""" get the value for this piece of information, using the correct source """
|
||||
return self.source_from(self, **kwargs)
|
||||
|
||||
def from_about_dictionary(self, **kwargs):
|
||||
""" gets the value from the kwargs provided 'about_dictionary' """
|
||||
about_dictionary = kwargs.get('about_dictionary', None)
|
||||
if not about_dictionary:
|
||||
raise ValueError("Context dictionary does not contain expected argument 'about_dictionary'")
|
||||
|
||||
return about_dictionary.get(self.property_name, None)
|
||||
|
||||
def from_course_property(self, **kwargs):
|
||||
""" gets the value from the kwargs provided 'course' """
|
||||
course = kwargs.get('course', None)
|
||||
if not course:
|
||||
raise ValueError("Context dictionary does not contain expected argument 'course'")
|
||||
|
||||
return getattr(course, self.property_name, None)
|
||||
|
||||
def from_course_mode(self, **kwargs):
|
||||
""" fetches the available course modes from the CourseMode model """
|
||||
course = kwargs.get('course', None)
|
||||
if not course:
|
||||
raise ValueError("Context dictionary does not contain expected argument 'course'")
|
||||
|
||||
return [mode.slug for mode in CourseMode.modes_for_course(course.id)]
|
||||
|
||||
# Source location options - either from the course or the about info
|
||||
FROM_ABOUT_INFO = from_about_dictionary
|
||||
FROM_COURSE_PROPERTY = from_course_property
|
||||
FROM_COURSE_MODE = from_course_mode
|
||||
|
||||
|
||||
class CourseAboutSearchIndexer(object):
|
||||
"""
|
||||
Class to perform indexing of about information from course object
|
||||
"""
|
||||
DISCOVERY_DOCUMENT_TYPE = "course_info"
|
||||
INDEX_NAME = CoursewareSearchIndexer.INDEX_NAME
|
||||
|
||||
# List of properties to add to the index - each item in the list is an instance of AboutInfo object
|
||||
ABOUT_INFORMATION_TO_INCLUDE = [
|
||||
AboutInfo("advertised_start", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("announcement", AboutInfo.PROPERTY, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("start", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("end", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("effort", AboutInfo.PROPERTY, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("display_name", AboutInfo.ANALYSE, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("overview", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("title", AboutInfo.ANALYSE | AboutInfo.PROPERTY, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("university", AboutInfo.ANALYSE | AboutInfo.PROPERTY, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("number", AboutInfo.ANALYSE | AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("short_description", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("description", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("key_dates", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("video", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("course_staff_short", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("course_staff_extended", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("requirements", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("syllabus", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("textbook", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("faq", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("more_info", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("ocw_links", AboutInfo.ANALYSE, AboutInfo.FROM_ABOUT_INFO),
|
||||
AboutInfo("enrollment_start", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("enrollment_end", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("org", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
AboutInfo("modes", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_MODE),
|
||||
AboutInfo("language", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def index_about_information(cls, modulestore, course):
|
||||
"""
|
||||
Add the given course to the course discovery index
|
||||
|
||||
Arguments:
|
||||
modulestore - modulestore object to use for operations
|
||||
|
||||
course - course object from which to take properties, locate about information
|
||||
"""
|
||||
searcher = SearchEngine.get_search_engine(cls.INDEX_NAME)
|
||||
if not searcher:
|
||||
return
|
||||
|
||||
course_id = unicode(course.id)
|
||||
course_info = {
|
||||
'id': course_id,
|
||||
'course': course_id,
|
||||
'content': {},
|
||||
'image_url': course_image_url(course),
|
||||
}
|
||||
|
||||
# load data for all of the 'about' modules for this course into a dictionary
|
||||
about_dictionary = {
|
||||
item.location.name: item.data
|
||||
for item in modulestore.get_items(course.id, qualifiers={"category": "about"})
|
||||
}
|
||||
|
||||
about_context = {
|
||||
"course": course,
|
||||
"about_dictionary": about_dictionary,
|
||||
}
|
||||
|
||||
for about_information in cls.ABOUT_INFORMATION_TO_INCLUDE:
|
||||
# Broad exception handler so that a single bad property does not scupper the collection of others
|
||||
try:
|
||||
section_content = about_information.get_value(**about_context)
|
||||
except: # pylint: disable=bare-except
|
||||
section_content = None
|
||||
log.warning(
|
||||
"Course discovery could not collect property %s for course %s",
|
||||
about_information.property_name,
|
||||
course_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if section_content:
|
||||
if about_information.index_flags & AboutInfo.ANALYSE:
|
||||
analyse_content = section_content
|
||||
if isinstance(section_content, basestring):
|
||||
analyse_content = strip_html_content_to_text(section_content)
|
||||
course_info['content'][about_information.property_name] = analyse_content
|
||||
if about_information.index_flags & AboutInfo.PROPERTY:
|
||||
course_info[about_information.property_name] = section_content
|
||||
|
||||
# Broad exception handler to protect around and report problems with indexing
|
||||
try:
|
||||
searcher.index(cls.DISCOVERY_DOCUMENT_TYPE, [course_info])
|
||||
except: # pylint: disable=bare-except
|
||||
log.exception(
|
||||
"Course discovery indexing error encountered, course discovery index may be out of date %s",
|
||||
course_id,
|
||||
)
|
||||
raise
|
||||
|
||||
log.debug(
|
||||
"Successfully added %s course to the course discovery index",
|
||||
course_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_location_info(cls, normalized_structure_key):
|
||||
""" Builds location info dictionary """
|
||||
return {"course": unicode(normalized_structure_key), "org": normalized_structure_key.org}
|
||||
|
||||
@classmethod
|
||||
def remove_deleted_items(cls, structure_key):
|
||||
""" Remove item from Course About Search_index """
|
||||
searcher = SearchEngine.get_search_engine(cls.INDEX_NAME)
|
||||
if not searcher:
|
||||
return
|
||||
|
||||
response = searcher.search(
|
||||
doc_type=cls.DISCOVERY_DOCUMENT_TYPE,
|
||||
field_dictionary=cls._get_location_info(structure_key)
|
||||
)
|
||||
result_ids = [result["data"]["id"] for result in response["results"]]
|
||||
searcher.remove(cls.DISCOVERY_DOCUMENT_TYPE, result_ids)
|
||||
22
cms/djangoapps/contentstore/debug_file_uploader.py
Normal file
22
cms/djangoapps/contentstore/debug_file_uploader.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.core.files.uploadhandler import FileUploadHandler
|
||||
import time
|
||||
|
||||
|
||||
class DebugFileUploader(FileUploadHandler):
|
||||
def __init__(self, request=None):
|
||||
super(DebugFileUploader, self).__init__(request)
|
||||
self.count = 0
|
||||
|
||||
def receive_data_chunk(self, raw_data, start):
|
||||
time.sleep(1)
|
||||
self.count = self.count + len(raw_data)
|
||||
fail_at = None
|
||||
if 'fail_at' in self.request.GET:
|
||||
fail_at = int(self.request.GET.get('fail_at'))
|
||||
if fail_at and self.count > fail_at:
|
||||
raise Exception('Triggered fail')
|
||||
|
||||
return raw_data
|
||||
|
||||
def file_complete(self, file_size):
|
||||
return None
|
||||
0
cms/djangoapps/contentstore/features/__init__.py
Normal file
0
cms/djangoapps/contentstore/features/__init__.py
Normal file
@@ -0,0 +1,80 @@
|
||||
@shard_1
|
||||
Feature: CMS.Advanced (manual) course policy
|
||||
In order to specify course policy settings for which no custom user interface exists
|
||||
I want to be able to manually enter JSON key /value pairs
|
||||
|
||||
|
||||
Scenario: A course author sees default advanced settings
|
||||
Given I have opened a new course in Studio
|
||||
When I select the Advanced Settings
|
||||
Then I see default advanced settings
|
||||
|
||||
Scenario: Add new entries, and they appear alphabetically after save
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
Then the settings are alphabetized
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
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
|
||||
And I press the "Cancel" notification button
|
||||
Then the policy key value is unchanged
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
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
|
||||
Then the policy key value is changed
|
||||
And I reload the page
|
||||
Then the policy key value is changed
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
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 for "Discussion Topic Mapping"
|
||||
Then it is displayed as formatted
|
||||
And I reload the page
|
||||
Then it is displayed as formatted
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Test error if value supplied is of the wrong type
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value for "Course Display Name"
|
||||
Then I get an error on save
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
# This feature will work in Firefox only when Firefox is the active window
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
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
|
||||
Then it is displayed as a string
|
||||
And I reload the page
|
||||
Then it is displayed as a string
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
|
||||
Scenario: Deprecated Settings are not shown by default
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
Then deprecated settings are not shown
|
||||
|
||||
Scenario: Deprecated Settings can be toggled
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I toggle the display of deprecated settings
|
||||
Then deprecated settings are then shown
|
||||
And I toggle the display of deprecated settings
|
||||
Then deprecated settings are not shown
|
||||
153
cms/djangoapps/contentstore/features/advanced_settings.py
Normal file
153
cms/djangoapps/contentstore/features/advanced_settings.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches
|
||||
from common import type_in_codemirror, press_the_notification_button, get_codemirror_value
|
||||
|
||||
KEY_CSS = '.key h3.title'
|
||||
DISPLAY_NAME_KEY = "Course Display Name"
|
||||
DISPLAY_NAME_VALUE = '"Robot Super Course"'
|
||||
ADVANCED_MODULES_KEY = "Advanced Module List"
|
||||
# A few deprecated settings for testing toggling functionality.
|
||||
DEPRECATED_SETTINGS = ["CSS Class for Course Reruns", "Hide Progress Tab", "XQA Key"]
|
||||
|
||||
|
||||
@step('I select the Advanced Settings$')
|
||||
def i_select_advanced_settings(step):
|
||||
|
||||
world.click_course_settings()
|
||||
|
||||
# The click handlers are set up so that if you click <body>
|
||||
# the menu disappears. This means that if we're even a *little*
|
||||
# bit off on the last item ('Advanced Settings'), the menu
|
||||
# will close and the test will fail.
|
||||
# For this reason, we retrieve the link and visit it directly
|
||||
# This is what the browser *should* be doing, since it's just a native
|
||||
# link with no JavaScript involved.
|
||||
link_css = 'li.nav-course-settings-advanced a'
|
||||
world.wait_for_visible(link_css)
|
||||
link = world.css_find(link_css).first['href']
|
||||
world.visit(link)
|
||||
|
||||
|
||||
@step('I am on the Advanced Course Settings page in Studio$')
|
||||
def i_am_on_advanced_course_settings(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I select the Advanced Settings')
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key$')
|
||||
def edit_the_value_of_a_policy_key(step):
|
||||
type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X')
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key and save$')
|
||||
def edit_the_value_of_a_policy_key_and_save(step):
|
||||
change_display_name_value(step, '"foo"')
|
||||
|
||||
|
||||
@step('I create a JSON object as a value for "(.*)"$')
|
||||
def create_JSON_object(step, key):
|
||||
change_value(step, key, '{"key": "value", "key_2": "value_2"}')
|
||||
|
||||
|
||||
@step('I create a non-JSON value not in quotes$')
|
||||
def create_value_not_in_quotes(step):
|
||||
change_display_name_value(step, 'quote me')
|
||||
|
||||
|
||||
@step('I see default advanced settings$')
|
||||
def i_see_default_advanced_settings(step):
|
||||
# Test only a few of the existing properties (there are around 34 of them)
|
||||
assert_policy_entries(
|
||||
[ADVANCED_MODULES_KEY, DISPLAY_NAME_KEY, "Show Calculator"], ["[]", DISPLAY_NAME_VALUE, "false"])
|
||||
|
||||
|
||||
@step('the settings are alphabetized$')
|
||||
def they_are_alphabetized(step):
|
||||
key_elements = world.css_find(KEY_CSS)
|
||||
all_keys = []
|
||||
for key in key_elements:
|
||||
all_keys.append(key.value)
|
||||
|
||||
assert_equal(sorted(all_keys), all_keys, "policy keys were not sorted")
|
||||
|
||||
|
||||
@step('it is displayed as formatted$')
|
||||
def it_is_formatted(step):
|
||||
assert_policy_entries(['Discussion Topic Mapping'], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
|
||||
|
||||
|
||||
@step('I get an error on save$')
|
||||
def error_on_save(step):
|
||||
assert_regexp_matches(
|
||||
world.css_text('.error-item-message'),
|
||||
"Value stored in a .* must be .*, found .*"
|
||||
)
|
||||
|
||||
|
||||
@step('it is displayed as a string')
|
||||
def it_is_displayed_as_string(step):
|
||||
assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"'])
|
||||
|
||||
|
||||
@step(u'the policy key value is unchanged$')
|
||||
def the_policy_key_value_is_unchanged(step):
|
||||
assert_equal(get_display_name_value(), DISPLAY_NAME_VALUE)
|
||||
|
||||
|
||||
@step(u'the policy key value is changed$')
|
||||
def the_policy_key_value_is_changed(step):
|
||||
assert_equal(get_display_name_value(), '"foo"')
|
||||
|
||||
|
||||
@step(u'deprecated settings are (then|not) shown$')
|
||||
def verify_deprecated_settings_shown(_step, expected):
|
||||
for setting in DEPRECATED_SETTINGS:
|
||||
if expected == "not":
|
||||
assert_equal(-1, get_index_of(setting))
|
||||
else:
|
||||
world.wait_for(lambda _: get_index_of(setting) != -1)
|
||||
|
||||
|
||||
@step(u'I toggle the display of deprecated settings$')
|
||||
def toggle_deprecated_settings(_step):
|
||||
world.css_click(".deprecated-settings-label")
|
||||
|
||||
|
||||
def assert_policy_entries(expected_keys, expected_values):
|
||||
for key, value in zip(expected_keys, expected_values):
|
||||
index = get_index_of(key)
|
||||
assert_false(index == -1, "Could not find key: {key}".format(key=key))
|
||||
found_value = get_codemirror_value(index)
|
||||
assert_equal(
|
||||
value, found_value,
|
||||
"Expected {} to have value {} but found {}".format(key, value, found_value)
|
||||
)
|
||||
|
||||
|
||||
def get_index_of(expected_key):
|
||||
for i, element in enumerate(world.css_find(KEY_CSS)):
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
key = world.css_value(KEY_CSS, index=i)
|
||||
if key == expected_key:
|
||||
return i
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def get_display_name_value():
|
||||
index = get_index_of(DISPLAY_NAME_KEY)
|
||||
return get_codemirror_value(index)
|
||||
|
||||
|
||||
def change_display_name_value(step, new_value):
|
||||
change_value(step, DISPLAY_NAME_KEY, new_value)
|
||||
|
||||
|
||||
def change_value(step, key, new_value):
|
||||
index = get_index_of(key)
|
||||
type_in_codemirror(index, new_value)
|
||||
press_the_notification_button(step, "Save")
|
||||
world.wait_for_ajax_complete()
|
||||
406
cms/djangoapps/contentstore/features/common.py
Normal file
406
cms/djangoapps/contentstore/features/common.py
Normal file
@@ -0,0 +1,406 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
import os
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_in
|
||||
from django.conf import settings
|
||||
|
||||
from student.roles import CourseStaffRole, CourseInstructorRole, GlobalStaff
|
||||
from student.models import get_user
|
||||
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from logging import getLogger
|
||||
from student.tests.factories import AdminFactory
|
||||
from student import auth
|
||||
logger = getLogger(__name__)
|
||||
|
||||
from terrain.browser import reset_data
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
|
||||
@step('I (?:visit|access|open) the Studio homepage$')
|
||||
def i_visit_the_studio_homepage(_step):
|
||||
# To make this go to port 8001, put
|
||||
# LETTUCE_SERVER_PORT = 8001
|
||||
# in your settings.py file.
|
||||
world.visit('/')
|
||||
signin_css = 'a.action-signin'
|
||||
assert world.is_css_present(signin_css)
|
||||
|
||||
|
||||
@step('I am logged into Studio$')
|
||||
def i_am_logged_into_studio(_step):
|
||||
log_into_studio()
|
||||
|
||||
|
||||
@step('I confirm the alert$')
|
||||
def i_confirm_with_ok(_step):
|
||||
world.browser.get_alert().accept()
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" delete icon$')
|
||||
def i_press_the_category_delete_icon(_step, category):
|
||||
if category == 'section':
|
||||
css = 'a.action.delete-section-button'
|
||||
elif category == 'subsection':
|
||||
css = 'a.action.delete-subsection-button'
|
||||
else:
|
||||
assert False, 'Invalid category: %s' % category
|
||||
world.css_click(css)
|
||||
|
||||
|
||||
@step('I have opened a new course in Studio$')
|
||||
def i_have_opened_a_new_course(_step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
@step('I have populated a new course in Studio$')
|
||||
def i_have_populated_a_new_course(_step):
|
||||
world.clear_courses()
|
||||
course = world.CourseFactory.create()
|
||||
world.scenario_dict['COURSE'] = course
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
world.ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
category='sequential',
|
||||
display_name='Subsection One',
|
||||
)
|
||||
user = create_studio_user(is_staff=False)
|
||||
add_course_author(user, course)
|
||||
|
||||
log_into_studio()
|
||||
|
||||
world.css_click('a.course-link')
|
||||
world.wait_for_js_to_load()
|
||||
|
||||
|
||||
@step('(I select|s?he selects) the new course')
|
||||
def select_new_course(_step, whom):
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(_step, name):
|
||||
|
||||
# Because the notification uses a CSS transition,
|
||||
# Selenium will always report it as being visible.
|
||||
# This makes it very difficult to successfully click
|
||||
# the "Save" button at the UI level.
|
||||
# Instead, we use JavaScript to reliably click
|
||||
# the button.
|
||||
btn_css = 'div#page-notification button.action-%s' % name.lower()
|
||||
world.trigger_event(btn_css, event='focus')
|
||||
world.browser.execute_script("$('{}').click()".format(btn_css))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I change the "(.*)" field to "(.*)"$')
|
||||
def i_change_field_to_value(_step, field, value):
|
||||
field_css = '#%s' % '-'.join([s.lower() for s in field.split()])
|
||||
ele = world.css_find(field_css).first
|
||||
ele.fill(value)
|
||||
ele._element.send_keys(Keys.ENTER)
|
||||
|
||||
|
||||
@step('I reset the database')
|
||||
def reset_the_db(_step):
|
||||
"""
|
||||
When running Lettuce tests using examples (i.e. "Confirmation is
|
||||
shown on save" in course-settings.feature), the normal hooks
|
||||
aren't called between examples. reset_data should run before each
|
||||
scenario to flush the test database. When this doesn't happen we
|
||||
get errors due to trying to insert a non-unique entry. So instead,
|
||||
we delete the database manually. This has the effect of removing
|
||||
any users and courses that have been created during the test run.
|
||||
"""
|
||||
reset_data(None)
|
||||
|
||||
|
||||
@step('I see a confirmation that my changes have been saved')
|
||||
def i_see_a_confirmation(step):
|
||||
confirmation_css = '#alert-confirmation'
|
||||
assert world.is_css_present(confirmation_css)
|
||||
|
||||
|
||||
def open_new_course():
|
||||
world.clear_courses()
|
||||
create_studio_user()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
|
||||
|
||||
def create_studio_user(
|
||||
uname='robot',
|
||||
email='robot+studio@edx.org',
|
||||
password='test',
|
||||
is_staff=False):
|
||||
studio_user = world.UserFactory(
|
||||
username=uname,
|
||||
email=email,
|
||||
password=password,
|
||||
is_staff=is_staff)
|
||||
|
||||
registration = world.RegistrationFactory(user=studio_user)
|
||||
registration.register(studio_user)
|
||||
registration.activate()
|
||||
|
||||
return studio_user
|
||||
|
||||
|
||||
def fill_in_course_info(
|
||||
name='Robot Super Course',
|
||||
org='MITx',
|
||||
num='101',
|
||||
run='2013_Spring'):
|
||||
world.css_fill('.new-course-name', name)
|
||||
world.css_fill('.new-course-org', org)
|
||||
world.css_fill('.new-course-number', num)
|
||||
world.css_fill('.new-course-run', run)
|
||||
|
||||
|
||||
def log_into_studio(
|
||||
uname='robot',
|
||||
email='robot+studio@edx.org',
|
||||
password='test',
|
||||
name='Robot Studio'):
|
||||
|
||||
world.log_in(username=uname, password=password, email=email, name=name)
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
assert_in(uname, world.css_text('span.account-username', timeout=10))
|
||||
|
||||
|
||||
def add_course_author(user, course):
|
||||
"""
|
||||
Add the user to the instructor group of the course
|
||||
so they will have the permissions to see it in studio
|
||||
"""
|
||||
global_admin = AdminFactory()
|
||||
for role in (CourseStaffRole, CourseInstructorRole):
|
||||
auth.add_users(global_admin, role(course.id), user)
|
||||
|
||||
|
||||
def create_a_course():
|
||||
course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
world.scenario_dict['COURSE'] = course
|
||||
|
||||
user = world.scenario_dict.get("USER")
|
||||
if not user:
|
||||
user = get_user('robot+studio@edx.org')
|
||||
|
||||
add_course_author(user, course)
|
||||
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
course_title_css = 'span.course-title'
|
||||
assert_true(world.is_css_present(course_title_css))
|
||||
|
||||
|
||||
def add_section():
|
||||
world.css_click('.outline .button-new')
|
||||
assert_true(world.is_css_present('.outline-section .xblock-field-value'))
|
||||
|
||||
|
||||
def set_date_and_time(date_css, desired_date, time_css, desired_time, key=None):
|
||||
set_element_value(date_css, desired_date, key)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
set_element_value(time_css, desired_time, key)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
def set_element_value(element_css, element_value, key=None):
|
||||
element = world.css_find(element_css).first
|
||||
element.fill(element_value)
|
||||
# hit TAB or provided key to trigger save content
|
||||
if key is not None:
|
||||
element._element.send_keys(getattr(Keys, key)) # pylint: disable=protected-access
|
||||
else:
|
||||
element._element.send_keys(Keys.TAB) # pylint: disable=protected-access
|
||||
|
||||
|
||||
@step('I have enabled the (.*) advanced module$')
|
||||
def i_enabled_the_advanced_module(step, module):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
world.css_click('.nav-course-settings')
|
||||
world.css_click('.nav-course-settings-advanced a')
|
||||
type_in_codemirror(0, '["%s"]' % module)
|
||||
press_the_notification_button(step, 'Save')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_unit_from_course_outline():
|
||||
"""
|
||||
Expands the section and clicks on the New Unit link.
|
||||
The end result is the page where the user is editing the new unit.
|
||||
"""
|
||||
css_selectors = [
|
||||
'.outline-subsection .expand-collapse', '.outline-subsection .button-new'
|
||||
]
|
||||
for selector in css_selectors:
|
||||
world.css_click(selector)
|
||||
|
||||
world.wait_for_mathjax()
|
||||
world.wait_for_xmodule()
|
||||
world.wait_for_loading()
|
||||
|
||||
assert world.is_css_present('ul.new-component-type')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_loading():
|
||||
"""
|
||||
Waits for the loading indicator to be hidden.
|
||||
"""
|
||||
world.wait_for(lambda _driver: len(world.browser.find_by_css('div.ui-loading.is-hidden')) > 0)
|
||||
|
||||
|
||||
@step('I have clicked the new unit button$')
|
||||
@step(u'I am in Studio editing a new unit$')
|
||||
def edit_new_unit(step):
|
||||
step.given('I have populated a new course in Studio')
|
||||
create_unit_from_course_outline()
|
||||
|
||||
|
||||
@step('the save notification button is disabled')
|
||||
def save_button_disabled(step):
|
||||
button_css = '.action-save'
|
||||
disabled = 'is-disabled'
|
||||
assert world.css_has_class(button_css, disabled)
|
||||
|
||||
|
||||
@step('the "([^"]*)" button is disabled')
|
||||
def button_disabled(step, value):
|
||||
button_css = 'input[value="%s"]' % value
|
||||
assert world.css_has_class(button_css, 'is-disabled')
|
||||
|
||||
|
||||
def _do_studio_prompt_action(intent, action):
|
||||
"""
|
||||
Wait for a studio prompt to appear and press the specified action button
|
||||
See common/js/components/views/feedback_prompt.js for implementation
|
||||
"""
|
||||
assert intent in [
|
||||
'warning',
|
||||
'error',
|
||||
'confirmation',
|
||||
'announcement',
|
||||
'step-required',
|
||||
'help',
|
||||
'mini',
|
||||
]
|
||||
assert action in ['primary', 'secondary']
|
||||
|
||||
world.wait_for_present('div.wrapper-prompt.is-shown#prompt-{}'.format(intent))
|
||||
|
||||
action_css = 'li.nav-item > button.action-{}'.format(action)
|
||||
world.trigger_event(action_css, event='focus')
|
||||
world.browser.execute_script("$('{}').click()".format(action_css))
|
||||
|
||||
world.wait_for_ajax_complete()
|
||||
world.wait_for_present('div.wrapper-prompt.is-hiding#prompt-{}'.format(intent))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def confirm_studio_prompt():
|
||||
_do_studio_prompt_action('warning', 'primary')
|
||||
|
||||
|
||||
@step('I confirm the prompt')
|
||||
def confirm_the_prompt(step):
|
||||
confirm_studio_prompt()
|
||||
|
||||
|
||||
@step(u'I am shown a prompt$')
|
||||
def i_am_shown_a_notification(step):
|
||||
assert world.is_css_present('.wrapper-prompt')
|
||||
|
||||
|
||||
def type_in_codemirror(index, text, find_prefix="$"):
|
||||
script = """
|
||||
var cm = {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror;
|
||||
cm.getInputField().focus();
|
||||
cm.setValue(arguments[0]);
|
||||
cm.getInputField().blur();""".format(index=index, find_prefix=find_prefix)
|
||||
world.browser.driver.execute_script(script, str(text))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
def get_codemirror_value(index=0, find_prefix="$"):
|
||||
return world.browser.driver.execute_script(
|
||||
"""
|
||||
return {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror.getValue();
|
||||
""".format(index=index, find_prefix=find_prefix)
|
||||
)
|
||||
|
||||
|
||||
def attach_file(filename, sub_path):
|
||||
path = os.path.join(TEST_ROOT, sub_path, filename)
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
assert_true(os.path.exists(path))
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
|
||||
|
||||
def upload_file(filename, sub_path=''):
|
||||
# The file upload dialog is a faux modal, a div that takes over the display
|
||||
attach_file(filename, sub_path)
|
||||
modal_css = 'div.wrapper-modal-window-assetupload'
|
||||
button_css = '{} .action-upload'.format(modal_css)
|
||||
world.css_click(button_css)
|
||||
|
||||
# Clicking the Upload button triggers an AJAX POST.
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
# The modal stays up with a "File uploaded succeeded" confirmation message, then goes away.
|
||||
# It should take under 2 seconds, so wait up to 10.
|
||||
# Note that is_css_not_present will return as soon as the element is gone.
|
||||
assert world.is_css_not_present(modal_css, wait_time=10)
|
||||
|
||||
|
||||
@step(u'"([^"]*)" logs in$')
|
||||
def other_user_login(step, name):
|
||||
step.given('I log out')
|
||||
world.visit('/')
|
||||
|
||||
signin_css = 'a.action-signin'
|
||||
world.is_css_present(signin_css)
|
||||
world.css_click(signin_css)
|
||||
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(name + '@edx.org')
|
||||
login_form.find_by_name('password').fill("test")
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
assert_true(world.is_css_present('.new-course-button'))
|
||||
world.scenario_dict['USER'] = get_user(name + '@edx.org')
|
||||
|
||||
|
||||
@step(u'the user "([^"]*)" exists( as a course (admin|staff member|is_staff))?$')
|
||||
def create_other_user(_step, name, has_extra_perms, role_name):
|
||||
email = name + '@edx.org'
|
||||
user = create_studio_user(uname=name, password="test", email=email)
|
||||
if has_extra_perms:
|
||||
if role_name == "is_staff":
|
||||
GlobalStaff().add_users(user)
|
||||
else:
|
||||
if role_name == "admin":
|
||||
# admins get staff privileges, as well
|
||||
roles = (CourseStaffRole, CourseInstructorRole)
|
||||
else:
|
||||
roles = (CourseStaffRole,)
|
||||
course_key = world.scenario_dict["COURSE"].id
|
||||
global_admin = AdminFactory()
|
||||
for role in roles:
|
||||
auth.add_users(global_admin, role(course_key), user)
|
||||
|
||||
|
||||
@step('I log out')
|
||||
def log_out(_step):
|
||||
world.visit('logout')
|
||||
61
cms/djangoapps/contentstore/features/component.feature
Normal file
61
cms/djangoapps/contentstore/features/component.feature
Normal file
@@ -0,0 +1,61 @@
|
||||
@shard_1
|
||||
Feature: CMS.Component Adding
|
||||
As a course author, I want to be able to add a wide variety of components
|
||||
|
||||
Scenario: I can add HTML components
|
||||
Given I am in Studio editing a new unit
|
||||
When I add this type of HTML component:
|
||||
| Component |
|
||||
| Text |
|
||||
| Announcement |
|
||||
| Zooming Image Tool |
|
||||
| Raw HTML |
|
||||
Then I see HTML components in this order:
|
||||
| Component |
|
||||
| Text |
|
||||
| Announcement |
|
||||
| Zooming Image Tool |
|
||||
| Raw HTML |
|
||||
|
||||
Scenario: I can add Latex HTML components
|
||||
Given I am in Studio editing a new unit
|
||||
Given I have enabled latex compiler
|
||||
When I add this type of HTML component:
|
||||
| Component |
|
||||
| E-text Written in LaTeX |
|
||||
Then I see HTML components in this order:
|
||||
| Component |
|
||||
| E-text Written in LaTeX |
|
||||
|
||||
Scenario: I can add Common Problem components
|
||||
Given I am in Studio editing a new unit
|
||||
When I add this type of Problem component:
|
||||
| Component |
|
||||
| Blank Common Problem |
|
||||
| Checkboxes |
|
||||
| Dropdown |
|
||||
| Multiple Choice |
|
||||
| Numerical Input |
|
||||
| Text Input |
|
||||
Then I see Problem components in this order:
|
||||
| Component |
|
||||
| Blank Common Problem |
|
||||
| Checkboxes |
|
||||
| Dropdown |
|
||||
| Multiple Choice |
|
||||
| Numerical Input |
|
||||
| Text Input |
|
||||
|
||||
# Disabled 1/21/14 due to flakiness seen in master
|
||||
# Scenario: I can add Advanced Latex Problem components
|
||||
# Given I am in Studio editing a new unit
|
||||
# Given I have enabled latex compiler
|
||||
# When I add a "<Component>" "Advanced Problem" component
|
||||
# Then I see a "<Component>" Problem component
|
||||
# # Flush out the database before the next example executes
|
||||
# And I reset the database
|
||||
|
||||
# Examples:
|
||||
# | Component |
|
||||
# | Problem Written in LaTeX |
|
||||
# | Problem with Adaptive Hint in Latex |
|
||||
180
cms/djangoapps/contentstore/features/component.py
Normal file
180
cms/djangoapps/contentstore/features/component.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
# Lettuce formats proposed definitions for unimplemented steps with the
|
||||
# argument name "step" instead of "_step" and pylint does not like that.
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_in, assert_equal
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
|
||||
|
||||
@step(u'I add this type of single step component:$')
|
||||
def add_a_single_step_component(step):
|
||||
for step_hash in step.hashes:
|
||||
component = step_hash['Component']
|
||||
assert_in(component, ['Discussion', 'Video'])
|
||||
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='{}'.format(component.lower()),
|
||||
)
|
||||
|
||||
|
||||
@step(u'I see this type of single step component:$')
|
||||
def see_a_single_step_component(step):
|
||||
for step_hash in step.hashes:
|
||||
component = step_hash['Component']
|
||||
assert_in(component, ['Discussion', 'Video'])
|
||||
component_css = '.xmodule_{}Module'.format(component)
|
||||
assert_true(world.is_css_present(component_css),
|
||||
"{} couldn't be found".format(component))
|
||||
|
||||
|
||||
@step(u'I add this type of( Advanced)? (HTML|Problem) component:$')
|
||||
def add_a_multi_step_component(step, is_advanced, category):
|
||||
for step_hash in step.hashes:
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='{}'.format(category.lower()),
|
||||
component_type=step_hash['Component'],
|
||||
is_advanced=bool(is_advanced),
|
||||
)
|
||||
|
||||
|
||||
@step(u'I see (HTML|Problem) components in this order:')
|
||||
def see_a_multi_step_component(step, category):
|
||||
|
||||
# Wait for all components to finish rendering
|
||||
if category == 'HTML':
|
||||
selector = 'li.studio-xblock-wrapper div.xblock-student_view'
|
||||
else:
|
||||
selector = 'li.studio-xblock-wrapper div.xblock-author_view'
|
||||
world.wait_for(lambda _: len(world.css_find(selector)) == len(step.hashes))
|
||||
|
||||
for idx, step_hash in enumerate(step.hashes):
|
||||
if category == 'HTML':
|
||||
html_matcher = {
|
||||
'Text': '\n \n',
|
||||
'Announcement': '<h3 class="hd hd-2">Announcement Date</h3>',
|
||||
'Zooming Image Tool': '<h3 class="hd hd-2">Zooming Image Tool</h3>',
|
||||
'E-text Written in LaTeX': '<h3 class="hd hd-2">Example: E-text page</h3>',
|
||||
'Raw HTML': '<p>This template is similar to the Text template. The only difference is',
|
||||
}
|
||||
actual_html = world.css_html(selector, index=idx)
|
||||
assert_in(html_matcher[step_hash['Component']].strip(), actual_html.strip())
|
||||
else:
|
||||
actual_text = world.css_text(selector, index=idx)
|
||||
assert_in(step_hash['Component'], actual_text)
|
||||
|
||||
|
||||
@step(u'I see a "([^"]*)" Problem component$')
|
||||
def see_a_problem_component(step, category):
|
||||
component_css = '.xmodule_CapaModule'
|
||||
assert_true(world.is_css_present(component_css),
|
||||
'No problem was added to the unit.')
|
||||
|
||||
problem_css = '.studio-xblock-wrapper .xblock-student_view'
|
||||
# This view presents the given problem component in uppercase. Assert that the text matches
|
||||
# the component selected
|
||||
assert_true(world.css_contains_text(problem_css, category))
|
||||
|
||||
|
||||
@step(u'I add a "([^"]*)" "([^"]*)" component$')
|
||||
def add_component_category(step, component, category):
|
||||
assert category in ('single step', 'HTML', 'Problem', 'Advanced Problem')
|
||||
given_string = 'I add this type of {} component:'.format(category)
|
||||
step.given('{}\n{}\n{}'.format(given_string, '|Component|', '|{}|'.format(component)))
|
||||
|
||||
|
||||
@step(u'I delete all components$')
|
||||
def delete_all_components(step):
|
||||
count = len(world.css_find('.reorderable-container .studio-xblock-wrapper'))
|
||||
step.given('I delete "' + str(count) + '" component')
|
||||
|
||||
|
||||
@step(u'I delete "([^"]*)" component$')
|
||||
def delete_components(step, number):
|
||||
world.wait_for_xmodule()
|
||||
delete_btn_css = '.delete-button'
|
||||
prompt_css = '#prompt-warning'
|
||||
btn_css = '{} .action-primary'.format(prompt_css)
|
||||
saving_mini_css = '#page-notification .wrapper-notification-mini'
|
||||
for _ in range(int(number)):
|
||||
world.css_click(delete_btn_css)
|
||||
assert_true(
|
||||
world.is_css_present('{}.is-shown'.format(prompt_css)),
|
||||
msg='Waiting for the confirmation prompt to be shown')
|
||||
|
||||
# Pressing the button via css was not working reliably for the last component
|
||||
# when run in Chrome.
|
||||
if world.browser.driver_name is 'Chrome':
|
||||
world.browser.execute_script("$('{}').click()".format(btn_css))
|
||||
else:
|
||||
world.css_click(btn_css)
|
||||
|
||||
# Wait for the saving notification to pop up then disappear
|
||||
if world.is_css_present('{}.is-shown'.format(saving_mini_css)):
|
||||
world.css_find('{}.is-hiding'.format(saving_mini_css))
|
||||
|
||||
|
||||
@step(u'I see no components')
|
||||
def see_no_components(steps):
|
||||
assert world.is_css_not_present('li.studio-xblock-wrapper')
|
||||
|
||||
|
||||
@step(u'I delete a component')
|
||||
def delete_one_component(step):
|
||||
world.css_click('.delete-button')
|
||||
|
||||
|
||||
@step(u'I edit and save a component')
|
||||
def edit_and_save_component(step):
|
||||
world.css_click('.edit-button')
|
||||
world.css_click('.save-button')
|
||||
|
||||
|
||||
@step(u'I duplicate the (first|second|third) component$')
|
||||
def duplicated_component(step, ordinal):
|
||||
ord_map = {
|
||||
"first": 0,
|
||||
"second": 1,
|
||||
"third": 2,
|
||||
}
|
||||
index = ord_map[ordinal]
|
||||
duplicate_btn_css = '.duplicate-button'
|
||||
world.css_click(duplicate_btn_css, int(index))
|
||||
|
||||
|
||||
@step(u'I see a Problem component with display name "([^"]*)" in position "([^"]*)"$')
|
||||
def see_component_in_position(step, display_name, index):
|
||||
component_css = '.xmodule_CapaModule'
|
||||
|
||||
def find_problem(_driver):
|
||||
return world.css_text(component_css, int(index)).startswith(display_name)
|
||||
|
||||
world.wait_for(find_problem, timeout_msg='Did not find the duplicated problem')
|
||||
|
||||
|
||||
@step(u'I see the display name is "([^"]*)"')
|
||||
def check_component_display_name(step, display_name):
|
||||
# The display name for the unit uses the same structure, must differentiate by level-element.
|
||||
label = world.css_html(".level-element>header>div>div>span.xblock-display-name")
|
||||
assert_equal(display_name, label)
|
||||
|
||||
|
||||
@step(u'I change the display name to "([^"]*)"')
|
||||
def change_display_name(step, display_name):
|
||||
world.edit_component_and_select_settings()
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, display_name)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step(u'I unset the display name')
|
||||
def unset_display_name(step):
|
||||
world.edit_component_and_select_settings()
|
||||
world.revert_setting_entry(DISPLAY_NAME)
|
||||
world.save_component()
|
||||
@@ -0,0 +1,264 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
from lettuce import world
|
||||
from nose.tools import assert_equal, assert_in
|
||||
from terrain.steps import reload_the_page
|
||||
from common import type_in_codemirror
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_component_instance(step, category, component_type=None, is_advanced=False, advanced_component=None):
|
||||
"""
|
||||
Create a new component in a Unit.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category: component type (discussion, html, problem, video, advanced)
|
||||
component_type: for components with multiple templates, the link text in the menu
|
||||
is_advanced: for problems, is the desired component under the advanced menu?
|
||||
advanced_component: for advanced components, the related value of policy key 'advanced_modules'
|
||||
"""
|
||||
assert_in(category, ['advanced', 'problem', 'html', 'video', 'discussion'])
|
||||
|
||||
component_button_css = 'span.large-{}-icon'.format(category.lower())
|
||||
if category == 'problem':
|
||||
module_css = 'div.xmodule_CapaModule'
|
||||
elif category == 'advanced':
|
||||
module_css = 'div.xmodule_{}Module'.format(advanced_component.title())
|
||||
else:
|
||||
module_css = 'div.xmodule_{}Module'.format(category.title())
|
||||
|
||||
# Count how many of that module is on the page. Later we will
|
||||
# assert that one more was added.
|
||||
# We need to use world.browser.find_by_css instead of world.css_find
|
||||
# because it's ok if there are currently zero of them.
|
||||
module_count_before = len(world.browser.find_by_css(module_css))
|
||||
|
||||
# Disable the jquery animation for the transition to the menus.
|
||||
world.disable_jquery_animations()
|
||||
world.css_click(component_button_css)
|
||||
|
||||
if category in ('problem', 'html', 'advanced'):
|
||||
world.wait_for_invisible(component_button_css)
|
||||
click_component_from_menu(category, component_type, is_advanced)
|
||||
|
||||
expected_count = module_count_before + 1
|
||||
world.wait_for(
|
||||
lambda _: len(world.css_find(module_css)) == expected_count,
|
||||
timeout=20
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
step.given('I have clicked the new unit button')
|
||||
|
||||
world.css_click(component_button_css)
|
||||
|
||||
|
||||
def _click_advanced():
|
||||
css = 'ul.problem-type-tabs a[href="#tab2"]'
|
||||
world.css_click(css)
|
||||
|
||||
# Wait for the advanced tab items to be displayed
|
||||
tab2_css = 'div.ui-tabs-panel#tab2'
|
||||
world.wait_for_visible(tab2_css)
|
||||
|
||||
|
||||
def _find_matching_button(category, component_type):
|
||||
"""
|
||||
Find the button with the specified text. There should be one and only one.
|
||||
"""
|
||||
|
||||
# The tab shows buttons for the given category
|
||||
buttons = world.css_find('div.new-component-{} button'.format(category))
|
||||
|
||||
# Find the button whose text matches what you're looking for
|
||||
matched_buttons = [btn for btn in buttons if btn.text == component_type]
|
||||
|
||||
# There should be one and only one
|
||||
assert_equal(len(matched_buttons), 1)
|
||||
return matched_buttons[0]
|
||||
|
||||
|
||||
def click_component_from_menu(category, component_type, is_advanced):
|
||||
"""
|
||||
Creates a component for a category with more
|
||||
than one template, i.e. HTML and Problem.
|
||||
For some problem types, it is necessary to click to
|
||||
the Advanced tab.
|
||||
The component_type is the link text, e.g. "Blank Common Problem"
|
||||
"""
|
||||
if is_advanced:
|
||||
# Sometimes this click does not work if you go too fast.
|
||||
world.retry_on_exception(
|
||||
_click_advanced,
|
||||
ignored_exceptions=AssertionError,
|
||||
)
|
||||
|
||||
# Retry this in case the list is empty because you tried too fast.
|
||||
link = world.retry_on_exception(
|
||||
lambda: _find_matching_button(category, component_type),
|
||||
ignored_exceptions=AssertionError
|
||||
)
|
||||
|
||||
# Wait for the link to be clickable. If you go too fast it is not.
|
||||
world.retry_on_exception(lambda: link.click())
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component_and_select_settings():
|
||||
world.edit_component()
|
||||
world.ensure_settings_visible()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def ensure_settings_visible():
|
||||
# Select the 'settings' tab if there is one (it isn't displayed if it is the only option)
|
||||
settings_button = world.browser.find_by_css('.settings-button')
|
||||
if len(settings_button) > 0:
|
||||
world.css_click('.settings-button')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component(index=0):
|
||||
# Verify that the "loading" indication has been hidden.
|
||||
world.wait_for_loading()
|
||||
# Verify that the "edit" button is present.
|
||||
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
|
||||
world.css_click('a.edit-button', index)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def select_editor_tab(tab_name):
|
||||
editor_tabs = world.browser.find_by_css('.editor-tabs a')
|
||||
expected_tab_text = tab_name.strip().upper()
|
||||
matching_tabs = [tab for tab in editor_tabs if tab.text.upper() == expected_tab_text]
|
||||
assert len(matching_tabs) == 1
|
||||
tab = matching_tabs[0]
|
||||
tab.click()
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
def enter_xml_in_advanced_problem(step, text):
|
||||
"""
|
||||
Edits an advanced problem (assumes only on page),
|
||||
types the provided XML, and saves the component.
|
||||
"""
|
||||
world.edit_component()
|
||||
type_in_codemirror(0, text)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def verify_setting_entry(setting, display_name, value, explicitly_set):
|
||||
"""
|
||||
Verify the capa module fields are set as expected in the
|
||||
Advanced Settings editor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
setting: the WebDriverElement object found in the browser
|
||||
display_name: the string expected as the label
|
||||
html: the expected field value
|
||||
explicitly_set: True if the value is expected to have been explicitly set
|
||||
for the problem, rather than derived from the defaults. This is verified
|
||||
by the existence of a "Clear" button next to the field value.
|
||||
"""
|
||||
assert_equal(display_name, setting.find_by_css('.setting-label')[0].html.strip())
|
||||
|
||||
# Check if the web object is a list type
|
||||
# If so, we use a slightly different mechanism for determining its value
|
||||
if setting.has_class('metadata-list-enum') or setting.has_class('metadata-dict') or setting.has_class('metadata-video-translations'):
|
||||
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
elif setting.has_class('metadata-videolist-enum'):
|
||||
list_value = ', '.join(ele.find_by_css('input')[0].value for ele in setting.find_by_css('.videolist-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
else:
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
|
||||
# VideoList doesn't have clear button
|
||||
if not setting.has_class('metadata-videolist-enum'):
|
||||
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():
|
||||
world.css_click("a.action-save")
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_component_and_reopen(step):
|
||||
save_component()
|
||||
# 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.action-cancel")
|
||||
# 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):
|
||||
def get_setting():
|
||||
settings = world.css_find('.wrapper-comp-setting')
|
||||
for setting in settings:
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return setting
|
||||
return None
|
||||
return world.retry_on_exception(get_setting)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry_index(label):
|
||||
def get_index():
|
||||
settings = world.css_find('.metadata_edit .wrapper-comp-setting')
|
||||
for index, setting in enumerate(settings):
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return index
|
||||
return None
|
||||
return world.retry_on_exception(get_index)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def set_field_value(index, value):
|
||||
"""
|
||||
Set the field to the specified value.
|
||||
|
||||
Note: we cannot use css_fill here because the value is not set
|
||||
until after you move away from that field.
|
||||
Instead we will find the element, set its value, then hit the Tab key
|
||||
to get to the next field.
|
||||
"""
|
||||
elem = world.css_find('.metadata_edit div.wrapper-comp-setting input.setting-input')[index]
|
||||
elem.value = value
|
||||
elem.type(Keys.TAB)
|
||||
95
cms/djangoapps/contentstore/features/course-settings.feature
Normal file
95
cms/djangoapps/contentstore/features/course-settings.feature
Normal file
@@ -0,0 +1,95 @@
|
||||
@shard_2
|
||||
Feature: CMS.Course Settings
|
||||
As a course author, I want to be able to configure my course settings.
|
||||
|
||||
# Safari has trouble keeps dates on refresh
|
||||
@skip_safari
|
||||
Scenario: User can set course dates
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I set course dates
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the set dates
|
||||
|
||||
# IE has trouble with saving information
|
||||
@skip_internetexplorer
|
||||
Scenario: User can clear previously set course dates (except start date)
|
||||
Given I have set course dates
|
||||
And I clear all the dates except start
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see cleared dates
|
||||
|
||||
# IE has trouble with saving information
|
||||
@skip_internetexplorer
|
||||
Scenario: User cannot clear the course start date
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
And I clear the course start date
|
||||
Then I receive a warning about course start date
|
||||
And I reload the page
|
||||
And the previously set start date is shown
|
||||
|
||||
# IE has trouble with saving information
|
||||
# Safari gets CSRF token errors
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: User can correct the course start date warning
|
||||
Given I have tried to clear the course start
|
||||
And I have entered a new course start date
|
||||
And I press the "Save" notification button
|
||||
Then The warning about course start date goes away
|
||||
And I reload the page
|
||||
Then my new course start date is shown
|
||||
|
||||
# Safari does not save + refresh properly through sauce labs
|
||||
@skip_safari
|
||||
Scenario: Settings are only persisted when saved
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
When I change fields
|
||||
And I reload the page
|
||||
Then I do not see the changes
|
||||
|
||||
# Safari does not save + refresh properly through sauce labs
|
||||
@skip_safari
|
||||
Scenario: Settings are reset on cancel
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
When I change fields
|
||||
And I press the "Cancel" notification button
|
||||
Then I do not see the changes
|
||||
|
||||
# Safari gets CSRF token errors
|
||||
@skip_safari
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I change the "<field>" field to "<value>"
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
# Lettuce hooks don't get called between each example, so we need
|
||||
# to run the before.each_scenario hook manually to avoid database
|
||||
# errors.
|
||||
And I reset the database
|
||||
|
||||
Examples:
|
||||
| field | value |
|
||||
| Course Start Time | 11:00 |
|
||||
| Course Introduction Video | 4r7wHMg5Yjg |
|
||||
| Course Effort | 200:00 |
|
||||
|
||||
# Special case because we have to type in code mirror
|
||||
Scenario: Changes in Course Overview show a confirmation
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I change the course overview
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
|
||||
Scenario: User cannot save invalid settings
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I change the "Course Start Date" field to ""
|
||||
Then the save notification button is disabled
|
||||
170
cms/djangoapps/contentstore/features/course-settings.py
Normal file
170
cms/djangoapps/contentstore/features/course-settings.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import world, step
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from common import type_in_codemirror, upload_file
|
||||
from django.conf import settings
|
||||
|
||||
from nose.tools import assert_true, assert_false
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
COURSE_START_DATE_CSS = "#course-start-date"
|
||||
COURSE_END_DATE_CSS = "#course-end-date"
|
||||
ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date"
|
||||
ENROLLMENT_END_DATE_CSS = "#course-enrollment-end-date"
|
||||
|
||||
COURSE_START_TIME_CSS = "#course-start-time"
|
||||
COURSE_END_TIME_CSS = "#course-end-time"
|
||||
ENROLLMENT_START_TIME_CSS = "#course-enrollment-start-time"
|
||||
ENROLLMENT_END_TIME_CSS = "#course-enrollment-end-time"
|
||||
|
||||
DUMMY_TIME = "15:30"
|
||||
DEFAULT_TIME = "00:00"
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select Schedule and Details$')
|
||||
def test_i_select_schedule_and_details(step):
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-schedule a'
|
||||
world.css_click(link_css)
|
||||
world.wait_for_requirejs(
|
||||
["jquery", "js/models/course",
|
||||
"js/models/settings/course_details", "js/views/settings/main"])
|
||||
|
||||
|
||||
@step('I have set course dates$')
|
||||
def test_i_have_set_course_dates(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I select Schedule and Details')
|
||||
step.given('And I set course dates')
|
||||
|
||||
|
||||
@step('And I set course dates$')
|
||||
def test_and_i_set_course_dates(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '12/1/2013')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('And I clear all the dates except start$')
|
||||
def test_and_i_clear_all_the_dates_except_start(step):
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
|
||||
@step('Then I see cleared dates$')
|
||||
def test_then_i_see_cleared_dates(step):
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, '')
|
||||
|
||||
# Verify course start date (required) and time still there
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('I clear the course start date$')
|
||||
def test_i_clear_the_course_start_date(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '')
|
||||
|
||||
|
||||
@step('I receive a warning about course start date$')
|
||||
def test_i_receive_a_warning_about_course_start_date(step):
|
||||
assert_true(world.css_has_text('.message-error', 'The course must have an assigned start date.'))
|
||||
assert_true('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class'))
|
||||
assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
|
||||
|
||||
|
||||
@step('the previously set start date is shown$')
|
||||
def test_the_previously_set_start_date_is_shown(step):
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('Given I have tried to clear the course start$')
|
||||
def test_i_have_tried_to_clear_the_course_start(step):
|
||||
step.given("I have set course dates")
|
||||
step.given("I clear the course start date")
|
||||
step.given("I receive a warning about course start date")
|
||||
|
||||
|
||||
@step('I have entered a new course start date$')
|
||||
def test_i_have_entered_a_new_course_start_date(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
|
||||
|
||||
|
||||
@step('The warning about course start date goes away$')
|
||||
def test_the_warning_about_course_start_date_goes_away(step):
|
||||
assert world.is_css_not_present('.message-error')
|
||||
assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class'))
|
||||
assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
|
||||
|
||||
|
||||
@step('my new course start date is shown$')
|
||||
def new_course_start_date_is_shown(step):
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
|
||||
# Time should have stayed from before attempt to clear date.
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('I change fields$')
|
||||
def test_i_change_fields(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777')
|
||||
|
||||
|
||||
@step('I change the course overview')
|
||||
def test_change_course_overview(_step):
|
||||
type_in_codemirror(0, "<h1>Overview</h1>")
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def set_date_or_time(css, date_or_time):
|
||||
"""
|
||||
Sets date or time field.
|
||||
"""
|
||||
world.css_fill(css, date_or_time)
|
||||
e = world.css_find(css).first
|
||||
# hit Enter to apply the changes
|
||||
e._element.send_keys(Keys.ENTER)
|
||||
|
||||
|
||||
def verify_date_or_time(css, date_or_time):
|
||||
"""
|
||||
Verifies date or time field.
|
||||
"""
|
||||
# We need to wait for JavaScript to fill in the field, so we use
|
||||
# css_has_value(), which first checks that the field is not blank
|
||||
assert_true(world.css_has_value(css, date_or_time))
|
||||
|
||||
|
||||
@step('I do not see the changes')
|
||||
@step('I see the set dates')
|
||||
def i_see_the_set_dates(_step):
|
||||
"""
|
||||
Ensure that each field has the value set in `test_and_i_set_course_dates`.
|
||||
"""
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
# Unset times get set to 12 AM once the corresponding date has been set.
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
92
cms/djangoapps/contentstore/features/course-updates.feature
Normal file
92
cms/djangoapps/contentstore/features/course-updates.feature
Normal file
@@ -0,0 +1,92 @@
|
||||
@shard_2
|
||||
Feature: CMS.Course updates
|
||||
As a course author, I want to be able to provide updates to my students
|
||||
|
||||
# Internet explorer can't select all so the update appears weirdly
|
||||
@skip_internetexplorer
|
||||
Scenario: Users can add updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I add a new update with the text "Hello"
|
||||
Then I should see the update "Hello"
|
||||
And I see a "saving" notification
|
||||
|
||||
# Internet explorer can't select all so the update appears weirdly
|
||||
@skip_internetexplorer
|
||||
Scenario: Users can edit updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I add a new update with the text "Hello"
|
||||
And I modify the text to "Goodbye"
|
||||
Then I should see the update "Goodbye"
|
||||
And I see a "saving" notification
|
||||
|
||||
Scenario: Users can delete updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
And I add a new update with the text "Hello"
|
||||
And I delete the update
|
||||
And I confirm the prompt
|
||||
Then I should not see the update "Hello"
|
||||
And I see a "deleting" notification
|
||||
|
||||
Scenario: Users can edit update dates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
And I add a new update with the text "Hello"
|
||||
When I edit the date to "06/01/13"
|
||||
Then I should see the date "June 1, 2013"
|
||||
And I see a "saving" notification
|
||||
|
||||
# Internet explorer can't select all so the update appears weirdly
|
||||
@skip_internetexplorer
|
||||
Scenario: Users can change handouts
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I modify the handout to "<ol>Test</ol>"
|
||||
Then I see the handout "Test"
|
||||
And I see a "saving" notification
|
||||
|
||||
Scenario: Text outside of tags is preserved
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I add a new update with the text "before <strong>middle</strong> after"
|
||||
Then I should see the update "before <strong>middle</strong> after"
|
||||
And when I reload the page
|
||||
Then I should see the update "before <strong>middle</strong> after"
|
||||
|
||||
Scenario: Static links are rewritten when previewing a course update
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I add a new update with the text "<img src='/static/my_img.jpg'/>"
|
||||
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
|
||||
Then I should see the asset update to "my_img.jpg"
|
||||
And I change the update from "/static/my_img.jpg" to "<img src='/static/modified.jpg'/>"
|
||||
Then I should see the asset update to "modified.jpg"
|
||||
And when I reload the page
|
||||
Then I should see the asset update to "modified.jpg"
|
||||
|
||||
Scenario: Static links are rewritten when previewing handouts
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I modify the handout to "<ol><img src='/static/my_img.jpg'/></ol>"
|
||||
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
|
||||
Then I see the handout image link "my_img.jpg"
|
||||
And I change the handout from "/static/my_img.jpg" to "<img src='/static/modified.jpg'/>"
|
||||
Then I see the handout image link "modified.jpg"
|
||||
And when I reload the page
|
||||
Then I see the handout image link "modified.jpg"
|
||||
|
||||
Scenario: Users cannot save handouts with bad html until edit or update it properly
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I modify the handout to "<p><a href=>[LINK TEXT]</a></p>"
|
||||
Then I see the handout error text
|
||||
And I see handout save button disabled
|
||||
When I edit the handout to "<p><a href='https://www.google.com.pk/'>home</a></p>"
|
||||
Then I see handout save button re-enabled
|
||||
When I save handout edit
|
||||
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
|
||||
Then I see the handout "https://www.google.com.pk/"
|
||||
And when I reload the page
|
||||
Then I see the handout "https://www.google.com.pk/"
|
||||
155
cms/djangoapps/contentstore/features/course-updates.py
Normal file
155
cms/djangoapps/contentstore/features/course-updates.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
from lettuce import world, step
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from common import type_in_codemirror, get_codemirror_value
|
||||
from nose.tools import assert_in
|
||||
|
||||
|
||||
@step(u'I go to the course updates page')
|
||||
def go_to_updates(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
updates_css = 'li.nav-course-courseware-updates a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(updates_css)
|
||||
world.wait_for_visible('#course-handouts-view')
|
||||
|
||||
|
||||
@step(u'I add a new update with the text "([^"]*)"$')
|
||||
def add_update(_step, text):
|
||||
update_css = 'a.new-update-button'
|
||||
world.css_click(update_css)
|
||||
world.wait_for_visible('.CodeMirror')
|
||||
change_text(text)
|
||||
|
||||
|
||||
@step(u'I should see the update "([^"]*)"$')
|
||||
def check_update(_step, text):
|
||||
update_css = 'div.update-contents'
|
||||
update_html = world.css_find(update_css).html
|
||||
assert_in(text, update_html)
|
||||
|
||||
|
||||
@step(u'I should see the asset update to "([^"]*)"$')
|
||||
def check_asset_update(_step, asset_file):
|
||||
update_css = 'div.update-contents'
|
||||
update_html = world.css_find(update_css).html
|
||||
asset_key = world.scenario_dict['COURSE'].id.make_asset_key(asset_type='asset', path=asset_file)
|
||||
assert_in(unicode(asset_key), update_html)
|
||||
|
||||
|
||||
@step(u'I should not see the update "([^"]*)"$')
|
||||
def check_no_update(_step, text):
|
||||
update_css = 'div.update-contents'
|
||||
assert world.is_css_not_present(update_css)
|
||||
|
||||
|
||||
@step(u'I modify the text to "([^"]*)"$')
|
||||
def modify_update(_step, text):
|
||||
button_css = 'div.post-preview .edit-button'
|
||||
world.css_click(button_css)
|
||||
change_text(text)
|
||||
|
||||
|
||||
@step(u'I change the update from "([^"]*)" to "([^"]*)"$')
|
||||
def change_existing_update(_step, before, after):
|
||||
verify_text_in_editor_and_update('div.post-preview .edit-button', before, after)
|
||||
|
||||
|
||||
@step(u'I change the handout from "([^"]*)" to "([^"]*)"$')
|
||||
def change_existing_handout(_step, before, after):
|
||||
verify_text_in_editor_and_update('div.course-handouts .edit-button', before, after)
|
||||
|
||||
|
||||
@step(u'I delete the update$')
|
||||
def click_button(_step):
|
||||
button_css = 'div.post-preview .delete-button'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I edit the date to "([^"]*)"$')
|
||||
def change_date(_step, new_date):
|
||||
button_css = 'div.post-preview .edit-button'
|
||||
world.css_click(button_css)
|
||||
date_css = 'input.date'
|
||||
date = world.css_find(date_css)
|
||||
for i in range(len(date.value)):
|
||||
date._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
date._element.send_keys(new_date)
|
||||
save_css = '.save-button'
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
@step(u'I should see the date "([^"]*)"$')
|
||||
def check_date(_step, date):
|
||||
date_css = 'span.date-display'
|
||||
assert_in(date, world.css_html(date_css))
|
||||
|
||||
|
||||
@step(u'I modify the handout to "([^"]*)"$')
|
||||
def edit_handouts(_step, text):
|
||||
edit_css = 'div.course-handouts > .edit-button'
|
||||
world.css_click(edit_css)
|
||||
change_text(text)
|
||||
|
||||
|
||||
@step(u'I see the handout "([^"]*)"$')
|
||||
def check_handout(_step, handout):
|
||||
handout_css = 'div.handouts-content'
|
||||
assert_in(handout, world.css_html(handout_css))
|
||||
|
||||
|
||||
@step(u'I see the handout image link "([^"]*)"$')
|
||||
def check_handout_image_link(_step, image_file):
|
||||
handout_css = 'div.handouts-content'
|
||||
handout_html = world.css_html(handout_css)
|
||||
asset_key = world.scenario_dict['COURSE'].id.make_asset_key(asset_type='asset', path=image_file)
|
||||
assert_in(unicode(asset_key), handout_html)
|
||||
|
||||
|
||||
@step(u'I see the handout error text')
|
||||
def check_handout_error(_step):
|
||||
handout_error_css = 'div#handout_error'
|
||||
assert world.css_has_class(handout_error_css, 'is-shown')
|
||||
|
||||
|
||||
@step(u'I see handout save button disabled')
|
||||
def check_handout_error(_step):
|
||||
handout_save_button = 'form.edit-handouts-form .save-button'
|
||||
assert world.css_has_class(handout_save_button, 'is-disabled')
|
||||
|
||||
|
||||
@step(u'I edit the handout to "([^"]*)"$')
|
||||
def edit_handouts(_step, text):
|
||||
type_in_codemirror(0, text)
|
||||
|
||||
|
||||
@step(u'I see handout save button re-enabled')
|
||||
def check_handout_error(_step):
|
||||
handout_save_button = 'form.edit-handouts-form .save-button'
|
||||
assert not world.css_has_class(handout_save_button, 'is-disabled')
|
||||
|
||||
|
||||
@step(u'I save handout edit')
|
||||
def check_handout_error(_step):
|
||||
save_css = '.save-button'
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def change_text(text):
|
||||
type_in_codemirror(0, text)
|
||||
save_css = '.save-button'
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def verify_text_in_editor_and_update(button_css, before, after):
|
||||
world.css_click(button_css)
|
||||
text = get_codemirror_value()
|
||||
assert_in(before, text)
|
||||
change_text(after)
|
||||
|
||||
|
||||
@step('I see a "(saving|deleting)" notification')
|
||||
def i_see_a_mini_notification(_step, _type):
|
||||
saving_css = '.wrapper-notification-mini'
|
||||
assert world.is_css_present(saving_css)
|
||||
26
cms/djangoapps/contentstore/features/course_import.py
Normal file
26
cms/djangoapps/contentstore/features/course_import.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
import os
|
||||
from lettuce import world, step
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def import_file(filename):
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
path = os.path.join(settings.COMMON_TEST_DATA_ROOT, "imports", filename)
|
||||
world.browser.attach_file('course-data', os.path.abspath(path))
|
||||
world.css_click('input.submit-button')
|
||||
# Go to course outline
|
||||
world.click_course_content()
|
||||
outline_css = 'li.nav-course-courseware-outline a'
|
||||
world.css_click(outline_css)
|
||||
|
||||
|
||||
@step('I go to the import page$')
|
||||
def go_to_import(step):
|
||||
menu_css = 'li.nav-course-tools'
|
||||
import_css = 'li.nav-course-tools-import a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(import_css)
|
||||
21
cms/djangoapps/contentstore/features/courses.feature
Normal file
21
cms/djangoapps/contentstore/features/courses.feature
Normal file
@@ -0,0 +1,21 @@
|
||||
@shard_2
|
||||
Feature: CMS.Create Course
|
||||
In order offer a course on the edX platform
|
||||
As a course author
|
||||
I want to create courses
|
||||
|
||||
Scenario: Error message when org/course/run tuple is too long
|
||||
Given There are no courses
|
||||
And I am logged into Studio
|
||||
When I click the New Course button
|
||||
And I create a course with "course name", "012345678901234567890123456789", "012345678901234567890123456789", and "0123456"
|
||||
Then I see an error about the length of the org/course/run tuple
|
||||
And the "Create" button is disabled
|
||||
|
||||
Scenario: Course name is not included in the "too long" computation
|
||||
Given There are no courses
|
||||
And I am logged into Studio
|
||||
When I click the New Course button
|
||||
And I create a course with "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789", "org", "coursenum", and "run"
|
||||
And I press the "Create" button
|
||||
Then the Courseware page has loaded in Studio
|
||||
75
cms/djangoapps/contentstore/features/courses.py
Normal file
75
cms/djangoapps/contentstore/features/courses.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('There are no courses$')
|
||||
def no_courses(step):
|
||||
world.clear_courses()
|
||||
create_studio_user()
|
||||
|
||||
|
||||
@step('I click the New Course button$')
|
||||
def i_click_new_course(step):
|
||||
world.css_click('.new-course-button')
|
||||
|
||||
|
||||
@step('I fill in the new course information$')
|
||||
def i_fill_in_a_new_course_information(step):
|
||||
fill_in_course_info()
|
||||
|
||||
|
||||
@step('I create a course with "([^"]*)", "([^"]*)", "([^"]*)", and "([^"]*)"')
|
||||
def i_create_course(step, name, org, number, run):
|
||||
fill_in_course_info(name=name, org=org, num=number, run=run)
|
||||
|
||||
|
||||
@step('I create a new course$')
|
||||
def i_create_a_course(step):
|
||||
create_a_course()
|
||||
|
||||
|
||||
@step('I click the course link in Studio Home$')
|
||||
def i_click_the_course_link_in_studio_home(step): # pylint: disable=invalid-name
|
||||
course_css = 'a.course-link'
|
||||
world.css_click(course_css)
|
||||
|
||||
|
||||
@step('I see an error about the length of the org/course/run tuple')
|
||||
def i_see_error_about_length(step):
|
||||
assert world.css_has_text(
|
||||
'#course_creation_error',
|
||||
'The combined length of the organization, course number, '
|
||||
'and course run fields cannot be more than 65 characters.'
|
||||
)
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
|
||||
@step('the Courseware page has loaded in Studio$')
|
||||
def courseware_page_has_loaded_in_studio(step):
|
||||
course_title_css = 'span.course-title'
|
||||
assert world.is_css_present(course_title_css)
|
||||
|
||||
|
||||
@step('I see the course listed in Studio Home$')
|
||||
def i_see_the_course_in_studio_home(step):
|
||||
course_css = 'h3.class-title'
|
||||
assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name)
|
||||
|
||||
|
||||
@step('I am on the "([^"]*)" tab$')
|
||||
def i_am_on_tab(step, tab_name):
|
||||
header_css = 'div.inner-wrapper h1'
|
||||
assert world.css_has_text(header_css, tab_name)
|
||||
|
||||
|
||||
@step('I see a link for adding a new section$')
|
||||
def i_see_new_section_link(step):
|
||||
link_css = '.outline .button-new'
|
||||
assert world.css_has_text(link_css, 'New Section')
|
||||
@@ -0,0 +1,16 @@
|
||||
@shard_2
|
||||
Feature: CMS.Discussion Component Editor
|
||||
As a course author, I want to be able to create discussion components.
|
||||
|
||||
Scenario: User can view discussion component metadata
|
||||
Given I have created a Discussion Tag
|
||||
And I edit the component
|
||||
Then I see three alphabetized settings and their expected values
|
||||
|
||||
# Safari doesn't save the name properly
|
||||
@skip_safari
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Discussion Tag
|
||||
And I edit the component
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
28
cms/djangoapps/contentstore/features/discussion-editor.py
Normal file
28
cms/djangoapps/contentstore/features/discussion-editor.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
from lettuce import world, step
|
||||
|
||||
|
||||
@step('I have created a Discussion Tag$')
|
||||
def i_created_discussion_tag(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='discussion',
|
||||
)
|
||||
|
||||
|
||||
@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", False],
|
||||
['Display Name', "Discussion", False],
|
||||
['Subcategory', "Topic-Level Student-Visible Label", False]
|
||||
])
|
||||
|
||||
|
||||
@step('I edit the component$')
|
||||
def i_edit_and_select_settings(_step):
|
||||
world.edit_component()
|
||||
134
cms/djangoapps/contentstore/features/grading.feature
Normal file
134
cms/djangoapps/contentstore/features/grading.feature
Normal file
@@ -0,0 +1,134 @@
|
||||
@shard_1
|
||||
Feature: CMS.Course Grading
|
||||
As a course author, I want to be able to configure how my course is graded
|
||||
|
||||
Scenario: Users can add grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "1" new grade
|
||||
Then I see I now have "3" grades
|
||||
|
||||
Scenario: Users can only have up to 5 grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "6" new grades
|
||||
Then I see I now have "5" grades
|
||||
|
||||
Scenario: When user removes a grade the remaining grades should be consistent
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "2" new grade
|
||||
Then Grade list has "ABCF" grades
|
||||
And I delete a grade
|
||||
Then Grade list has "ABF" grades
|
||||
|
||||
# Cannot reliably make the delete button appear so using javascript instead
|
||||
Scenario: Users can delete grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "1" new grade
|
||||
And I delete a grade
|
||||
Then I see I now have "2" grades
|
||||
|
||||
# IE and Safari cannot reliably drag and drop through selenium
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: Users can move grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I move a grading section
|
||||
Then I see that the grade range has changed
|
||||
|
||||
Scenario: Users can modify Assignment types
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Save" notification button
|
||||
And I go back to the main course page
|
||||
Then I do see the assignment name "New Type"
|
||||
And I do not see the assignment name "Homework"
|
||||
|
||||
Scenario: Users can delete Assignment types
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I delete the assignment type "Homework"
|
||||
And I press the "Save" notification button
|
||||
And I go back to the main course page
|
||||
Then I do not see the assignment name "Homework"
|
||||
|
||||
Scenario: Users can add Assignment types
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add a new assignment type "New Type"
|
||||
And I press the "Save" notification button
|
||||
And I go back to the main course page
|
||||
Then I do see the assignment name "New Type"
|
||||
|
||||
# Note that "7" is a special weight because it revealed rounding errors (STUD-826).
|
||||
Scenario: Users can set weight to Assignment types
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add a new assignment type "New Type"
|
||||
And I set the assignment weight to "7"
|
||||
And I press the "Save" notification button
|
||||
Then the assignment weight is displayed as "7"
|
||||
And I reload the page
|
||||
Then the assignment weight is displayed as "7"
|
||||
|
||||
Scenario: Settings are only persisted when saved
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
Then I do not see the changes persisted on refresh
|
||||
|
||||
Scenario: Settings are reset on cancel
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Cancel" notification button
|
||||
Then I see the assignment type "Homework"
|
||||
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
|
||||
Scenario: User cannot save invalid settings
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to ""
|
||||
Then the save notification button is disabled
|
||||
|
||||
# IE and Safari cannot type in grade range name
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: User can edit grading range names
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change the highest grade range to "Good"
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the highest grade range is "Good"
|
||||
|
||||
Scenario: User cannot edit failing grade range name
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
Then I cannot edit the "Fail" grade range
|
||||
|
||||
Scenario: User can set a grace period greater than one day
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change the grace period to "48:00"
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the grace period is "48:00"
|
||||
|
||||
Scenario: Grace periods of more than 59 minutes are wrapped to the correct time
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change the grace period to "01:99"
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the grace period is "02:39"
|
||||
215
cms/djangoapps/contentstore/features/grading.py
Normal file
215
cms/djangoapps/contentstore/features/grading.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.common.exceptions import InvalidElementStateException
|
||||
from contentstore.utils import reverse_course_url
|
||||
from nose.tools import assert_in, assert_equal, assert_not_equal
|
||||
|
||||
|
||||
@step(u'I am viewing the grading settings')
|
||||
def view_grading_settings(step):
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-grading a'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step(u'I add "([^"]*)" new grade')
|
||||
def add_grade(step, many):
|
||||
grade_css = '.new-grade-button'
|
||||
for i in range(int(many)):
|
||||
world.css_click(grade_css)
|
||||
|
||||
|
||||
@step(u'I delete a grade')
|
||||
def delete_grade(step):
|
||||
#grade_css = 'li.grade-specific-bar > a.remove-button'
|
||||
#range_css = '.grade-specific-bar'
|
||||
#world.css_find(range_css)[1].mouseover()
|
||||
#world.css_click(grade_css)
|
||||
world.browser.execute_script('document.getElementsByClassName("remove-button")[0].click()')
|
||||
|
||||
|
||||
@step(u'Grade list has "([^"]*)" grades$')
|
||||
def check_grade_values(step, grade_list): # pylint: disable=unused-argument
|
||||
visible_list = ''.join(
|
||||
[grade.text for grade in world.css_find('.letter-grade')]
|
||||
)
|
||||
assert_equal(visible_list, grade_list, 'Grade lists should be equal')
|
||||
|
||||
|
||||
@step(u'I see I now have "([^"]*)" grades$')
|
||||
def view_grade_slider(step, how_many):
|
||||
grade_slider_css = '.grade-specific-bar'
|
||||
all_grades = world.css_find(grade_slider_css)
|
||||
assert_equal(len(all_grades), int(how_many))
|
||||
|
||||
|
||||
@step(u'I move a grading section')
|
||||
def move_grade_slider(step):
|
||||
moveable_css = '.ui-resizable-e'
|
||||
f = world.css_find(moveable_css).first
|
||||
f.action_chains.drag_and_drop_by_offset(f._element, 100, 0).perform()
|
||||
|
||||
|
||||
@step(u'I see that the grade range has changed')
|
||||
def confirm_change(step):
|
||||
range_css = '.range'
|
||||
all_ranges = world.css_find(range_css)
|
||||
for i in range(len(all_ranges)):
|
||||
assert_not_equal(world.css_html(range_css, index=i), '0-50')
|
||||
|
||||
|
||||
@step(u'I change assignment type "([^"]*)" to "([^"]*)"$')
|
||||
def change_assignment_name(step, old_name, new_name):
|
||||
name_id = '#course-grading-assignment-name'
|
||||
index = get_type_index(old_name)
|
||||
f = world.css_find(name_id)[index]
|
||||
assert_not_equal(index, -1)
|
||||
for __ in xrange(len(old_name)):
|
||||
f._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
f._element.send_keys(new_name)
|
||||
|
||||
|
||||
@step(u'I go back to the main course page')
|
||||
def main_course_page(step):
|
||||
main_page_link = reverse_course_url('course_handler', world.scenario_dict['COURSE'].id)
|
||||
|
||||
world.visit(main_page_link)
|
||||
assert_in('Course Outline', world.css_text('h1.page-header'))
|
||||
|
||||
|
||||
@step(u'I do( not)? see the assignment name "([^"]*)"$')
|
||||
def see_assignment_name(step, do_not, name):
|
||||
# TODO: rewrite this once grading has been added back to the course outline
|
||||
pass
|
||||
# assignment_menu_css = 'ul.menu > li > a'
|
||||
# # First assert that it is there, make take a bit to redraw
|
||||
# assert_true(
|
||||
# world.css_find(assignment_menu_css),
|
||||
# msg="Could not find assignment menu"
|
||||
# )
|
||||
#
|
||||
# assignment_menu = world.css_find(assignment_menu_css)
|
||||
# allnames = [item.html for item in assignment_menu]
|
||||
# if do_not:
|
||||
# assert_not_in(name, allnames)
|
||||
# else:
|
||||
# assert_in(name, allnames)
|
||||
|
||||
|
||||
@step(u'I delete the assignment type "([^"]*)"$')
|
||||
def delete_assignment_type(step, to_delete):
|
||||
delete_css = '.remove-grading-data'
|
||||
world.css_click(delete_css, index=get_type_index(to_delete))
|
||||
|
||||
|
||||
@step(u'I add a new assignment type "([^"]*)"$')
|
||||
def add_assignment_type(step, new_name):
|
||||
add_button_css = '.add-grading-data'
|
||||
world.css_click(add_button_css)
|
||||
name_id = '#course-grading-assignment-name'
|
||||
new_assignment = world.css_find(name_id)[-1]
|
||||
new_assignment._element.send_keys(new_name)
|
||||
|
||||
|
||||
@step(u'I set the assignment weight to "([^"]*)"$')
|
||||
def set_weight(step, weight):
|
||||
weight_id = '#course-grading-assignment-gradeweight'
|
||||
weight_field = world.css_find(weight_id)[-1]
|
||||
old_weight = world.css_value(weight_id, -1)
|
||||
for count in range(len(old_weight)):
|
||||
weight_field._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
weight_field._element.send_keys(weight)
|
||||
|
||||
|
||||
@step(u'the assignment weight is displayed as "([^"]*)"$')
|
||||
def verify_weight(step, weight):
|
||||
weight_id = '#course-grading-assignment-gradeweight'
|
||||
assert_equal(world.css_value(weight_id, -1), weight)
|
||||
|
||||
|
||||
@step(u'I do not see the changes persisted on refresh$')
|
||||
def changes_not_persisted(step):
|
||||
reload_the_page(step)
|
||||
name_id = '#course-grading-assignment-name'
|
||||
assert_equal(world.css_value(name_id), 'Homework')
|
||||
|
||||
|
||||
@step(u'I see the assignment type "(.*)"$')
|
||||
def i_see_the_assignment_type(_step, name):
|
||||
assignment_css = '#course-grading-assignment-name'
|
||||
assignments = world.css_find(assignment_css)
|
||||
types = [ele['value'] for ele in assignments]
|
||||
assert_in(name, types)
|
||||
|
||||
|
||||
@step(u'I change the highest grade range to "(.*)"$')
|
||||
def change_grade_range(_step, range_name):
|
||||
range_css = 'span.letter-grade'
|
||||
grade = world.css_find(range_css).first
|
||||
grade.value = range_name
|
||||
|
||||
|
||||
@step(u'I see the highest grade range is "(.*)"$')
|
||||
def i_see_highest_grade_range(_step, range_name):
|
||||
range_css = 'span.letter-grade'
|
||||
grade = world.css_find(range_css).first
|
||||
assert_equal(grade.value, range_name)
|
||||
|
||||
|
||||
@step(u'I cannot edit the "Fail" grade range$')
|
||||
def cannot_edit_fail(_step):
|
||||
range_css = 'span.letter-grade'
|
||||
ranges = world.css_find(range_css)
|
||||
assert_equal(len(ranges), 2)
|
||||
assert_not_equal(ranges.last.value, 'Failure')
|
||||
|
||||
# try to change the grade range -- this should throw an exception
|
||||
try:
|
||||
ranges.last.value = 'Failure'
|
||||
except InvalidElementStateException:
|
||||
pass # We should get this exception on failing to edit the element
|
||||
|
||||
# check to be sure that nothing has changed
|
||||
ranges = world.css_find(range_css)
|
||||
assert_equal(len(ranges), 2)
|
||||
assert_not_equal(ranges.last.value, 'Failure')
|
||||
|
||||
|
||||
@step(u'I change the grace period to "(.*)"$')
|
||||
def i_change_grace_period(_step, grace_period):
|
||||
grace_period_css = '#course-grading-graceperiod'
|
||||
ele = world.css_find(grace_period_css).first
|
||||
|
||||
# Sometimes it takes a moment for the JavaScript
|
||||
# to populate the field. If we don't wait for
|
||||
# this to happen, then we can end up with
|
||||
# an invalid value (e.g. "00:0048:00")
|
||||
# which prevents us from saving.
|
||||
assert_true(world.css_has_value(grace_period_css, "00:00"))
|
||||
|
||||
# Set the new grace period
|
||||
ele.value = grace_period
|
||||
|
||||
|
||||
@step(u'I see the grace period is "(.*)"$')
|
||||
def the_grace_period_is(_step, grace_period):
|
||||
grace_period_css = '#course-grading-graceperiod'
|
||||
|
||||
# The default value is 00:00
|
||||
# so we need to wait for it to change
|
||||
world.wait_for(
|
||||
lambda _: world.css_has_value(grace_period_css, grace_period)
|
||||
)
|
||||
|
||||
|
||||
def get_type_index(name):
|
||||
name_id = '#course-grading-assignment-name'
|
||||
all_types = world.css_find(name_id)
|
||||
for index in range(len(all_types)):
|
||||
if world.css_value(name_id, index=index) == name:
|
||||
return index
|
||||
return -1
|
||||
140
cms/djangoapps/contentstore/features/html-editor.feature
Normal file
140
cms/djangoapps/contentstore/features/html-editor.feature
Normal file
@@ -0,0 +1,140 @@
|
||||
@shard_2
|
||||
Feature: CMS.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 the HTML component settings
|
||||
|
||||
# Safari doesn't save the name properly
|
||||
@skip_safari
|
||||
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
|
||||
|
||||
Scenario: Edit High Level source is available for LaTeX html
|
||||
Given I have created an E-text Written in LaTeX
|
||||
When I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
Scenario: TinyMCE image plugin sets urls correctly
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And I add an image with static link "/static/image.jpg" via the Image Plugin Icon
|
||||
Then the src link is rewritten to the asset link "image.jpg"
|
||||
And the link is shown as "/static/image.jpg" in the Image Plugin
|
||||
|
||||
Scenario: TinyMCE link plugin sets urls correctly
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And I add a link with static link "/static/image.jpg" via the Link Plugin Icon
|
||||
Then the href link is rewritten to the asset link "image.jpg"
|
||||
And the link is shown as "/static/image.jpg" in the Link Plugin
|
||||
|
||||
Scenario: TinyMCE and CodeMirror preserve style tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<p class='title'>pages</p><style><!-- .title { color: red; } --></style>" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<p class="title">pages</p>
|
||||
<style><!--
|
||||
.title { color: red; }
|
||||
--></style>
|
||||
"""
|
||||
|
||||
Scenario: TinyMCE and CodeMirror preserve span tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<span>Test</span>" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<span>Test</span>
|
||||
"""
|
||||
|
||||
Scenario: TinyMCE and CodeMirror preserve math tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<math><msup><mi>x</mi><mn>2</mn></msup></math>" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<math><msup><mi>x</mi><mn>2</mn></msup></math>
|
||||
"""
|
||||
|
||||
Scenario: TinyMCE toolbar buttons are as expected
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
Then the expected toolbar buttons are displayed
|
||||
|
||||
Scenario: Static links are converted when switching between code editor and WYSIWYG views
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<img src="/static/image.jpg">" in the code editor and press OK
|
||||
Then the src link is rewritten to the asset link "image.jpg"
|
||||
And the code editor displays "<p><img src="/static/image.jpg" /></p>"
|
||||
|
||||
Scenario: Code format toolbar button wraps text with code tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And I set the text to "display as code" and I select the text
|
||||
And I select the code toolbar button
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<p><code>display as code</code></p>
|
||||
"""
|
||||
|
||||
Scenario: Raw HTML component does not change text
|
||||
Given I have created a raw HTML component
|
||||
When I edit the page
|
||||
And type "<li>zzzz<ol>" into the Raw Editor
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<li>zzzz<ol>
|
||||
"""
|
||||
And I edit the page
|
||||
Then the Raw Editor contains exactly:
|
||||
"""
|
||||
<li>zzzz<ol>
|
||||
"""
|
||||
|
||||
Scenario: Font selection dropdown contains Default font and tinyMCE builtin fonts
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And I click font selection dropdown
|
||||
Then I should see a list of available fonts
|
||||
And "Default" option sets "'Open Sans', Verdana, Arial, Helvetica, sans-serif" font family
|
||||
And all standard tinyMCE fonts should be available
|
||||
|
||||
# Skipping in master due to brittleness JZ 05/22/2014
|
||||
# Scenario: Can switch from Visual Editor to Raw
|
||||
# Given I have created a Blank HTML Page
|
||||
# When I edit the component and select the Raw Editor
|
||||
# And I save the page
|
||||
# When I edit the page
|
||||
# And type "fancy html" into the Raw Editor
|
||||
# And I save the page
|
||||
# Then the page text contains:
|
||||
# """
|
||||
# fancy html
|
||||
# """
|
||||
|
||||
# Skipping in master due to brittleness JZ 05/22/2014
|
||||
# Scenario: Can switch from Raw Editor to Visual
|
||||
# Given I have created a raw HTML component
|
||||
# And I edit the component and select the Visual Editor
|
||||
# And I save the page
|
||||
# When I edit the page
|
||||
# And type "less fancy html" in the code editor and press OK
|
||||
# And I save the page
|
||||
# Then the page text contains:
|
||||
# """
|
||||
# less fancy html
|
||||
# """
|
||||
312
cms/djangoapps/contentstore/features/html-editor.py
Normal file
312
cms/djangoapps/contentstore/features/html-editor.py
Normal file
@@ -0,0 +1,312 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_in, assert_false, assert_true, assert_equal
|
||||
from common import type_in_codemirror, get_codemirror_value
|
||||
|
||||
CODEMIRROR_SELECTOR_PREFIX = "$('iframe').contents().find"
|
||||
|
||||
|
||||
@step('I have created a Blank HTML Page$')
|
||||
def i_created_blank_html_page(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='html',
|
||||
component_type='Text'
|
||||
)
|
||||
|
||||
|
||||
@step('I have created a raw HTML component')
|
||||
def i_created_raw_html(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='html',
|
||||
component_type='Raw HTML'
|
||||
)
|
||||
|
||||
|
||||
@step('I see the HTML component settings$')
|
||||
def i_see_only_the_html_display_name(step):
|
||||
world.verify_all_setting_entries(
|
||||
[
|
||||
['Display Name', "Text", False],
|
||||
['Editor', "Visual", False]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@step('I have created an E-text Written in LaTeX$')
|
||||
def i_created_etext_in_latex(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
step.given('I have enabled latex compiler')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='html',
|
||||
component_type='E-text Written in LaTeX'
|
||||
)
|
||||
|
||||
|
||||
@step('I edit the page$')
|
||||
def i_click_on_edit_icon(step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step('I add an image with static link "(.*)" via the Image Plugin Icon$')
|
||||
def i_click_on_image_plugin_icon(step, path):
|
||||
use_plugin(
|
||||
'.mce-i-image',
|
||||
lambda: world.css_fill('.mce-textbox', path, 0)
|
||||
)
|
||||
|
||||
|
||||
@step('the link is shown as "(.*)" in the Image Plugin$')
|
||||
def check_link_in_image_plugin(step, path):
|
||||
use_plugin(
|
||||
'.mce-i-image',
|
||||
lambda: assert_equal(path, world.css_find('.mce-textbox')[0].value)
|
||||
)
|
||||
|
||||
|
||||
@step('I add a link with static link "(.*)" via the Link Plugin Icon$')
|
||||
def i_click_on_link_plugin_icon(step, path):
|
||||
def fill_in_link_fields():
|
||||
world.css_fill('.mce-textbox', path, 0)
|
||||
world.css_fill('.mce-textbox', 'picture', 1)
|
||||
|
||||
use_plugin('.mce-i-link', fill_in_link_fields)
|
||||
|
||||
|
||||
@step('the link is shown as "(.*)" in the Link Plugin$')
|
||||
def check_link_in_link_plugin(step, path):
|
||||
# Ensure caret position is within the link just created.
|
||||
script = """
|
||||
var editor = tinyMCE.activeEditor;
|
||||
editor.selection.select(editor.dom.select('a')[0]);"""
|
||||
world.browser.driver.execute_script(script)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
use_plugin(
|
||||
'.mce-i-link',
|
||||
lambda: assert_equal(path, world.css_find('.mce-textbox')[0].value)
|
||||
)
|
||||
|
||||
|
||||
@step('type "(.*)" in the code editor and press OK$')
|
||||
def type_in_codemirror_plugin(step, text):
|
||||
# Verify that raw code editor is not visible.
|
||||
assert_true(world.css_has_class('.CodeMirror', 'is-inactive'))
|
||||
# Verify that TinyMCE editor is present
|
||||
assert_true(world.is_css_present('.tiny-mce'))
|
||||
use_code_editor(
|
||||
lambda: type_in_codemirror(0, text, CODEMIRROR_SELECTOR_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
@step('and the code editor displays "(.*)"$')
|
||||
def verify_code_editor_text(step, text):
|
||||
use_code_editor(
|
||||
lambda: assert_equal(text, get_codemirror_value(0, CODEMIRROR_SELECTOR_PREFIX))
|
||||
)
|
||||
|
||||
|
||||
@step('I save the page$')
|
||||
def i_click_on_save(step):
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step('the page text contains:')
|
||||
def check_page_text(step):
|
||||
assert_in(step.multiline, world.css_find('.xmodule_HtmlModule').html)
|
||||
|
||||
|
||||
@step('the Raw Editor contains exactly:')
|
||||
def check_raw_editor_text(step):
|
||||
assert_equal(step.multiline, get_codemirror_value(0))
|
||||
|
||||
|
||||
@step('the src link is rewritten to the asset link "(.*)"$')
|
||||
def image_static_link_is_rewritten(step, path):
|
||||
# Find the TinyMCE iframe within the main window
|
||||
with world.browser.get_iframe('mce_0_ifr') as tinymce:
|
||||
image = tinymce.find_by_tag('img').first
|
||||
assert_in(unicode(world.scenario_dict['COURSE'].id.make_asset_key('asset', path)), image['src'])
|
||||
|
||||
|
||||
@step('the href link is rewritten to the asset link "(.*)"$')
|
||||
def link_static_link_is_rewritten(step, path):
|
||||
# Find the TinyMCE iframe within the main window
|
||||
with world.browser.get_iframe('mce_0_ifr') as tinymce:
|
||||
link = tinymce.find_by_tag('a').first
|
||||
assert_in(unicode(world.scenario_dict['COURSE'].id.make_asset_key('asset', path)), link['href'])
|
||||
|
||||
|
||||
@step('the expected toolbar buttons are displayed$')
|
||||
def check_toolbar_buttons(step):
|
||||
dropdowns = world.css_find('.mce-listbox')
|
||||
assert_equal(2, len(dropdowns))
|
||||
|
||||
# Format dropdown
|
||||
assert_equal('Paragraph', dropdowns[0].text)
|
||||
# Font dropdown
|
||||
assert_equal('Font Family', dropdowns[1].text)
|
||||
|
||||
buttons = world.css_find('.mce-ico')
|
||||
|
||||
# Note that the code editor icon is not present because we are now showing text instead of an icon.
|
||||
# However, other test points user the code editor, so we have already verified its presence.
|
||||
expected_buttons = [
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'forecolor',
|
||||
# This is our custom "code style" button, which uses an image instead of a class.
|
||||
'none',
|
||||
'bullist',
|
||||
'numlist',
|
||||
'outdent',
|
||||
'indent',
|
||||
'blockquote',
|
||||
'link',
|
||||
'unlink',
|
||||
'image'
|
||||
]
|
||||
|
||||
assert_equal(len(expected_buttons), len(buttons))
|
||||
|
||||
for index, button in enumerate(expected_buttons):
|
||||
class_names = buttons[index]._element.get_attribute('class')
|
||||
assert_equal("mce-ico mce-i-" + button, class_names)
|
||||
|
||||
|
||||
@step('I set the text to "(.*)" and I select the text$')
|
||||
def set_text_and_select(step, text):
|
||||
script = """
|
||||
var editor = tinyMCE.activeEditor;
|
||||
editor.setContent(arguments[0]);
|
||||
editor.selection.select(editor.dom.select('p')[0]);"""
|
||||
world.browser.driver.execute_script(script, str(text))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I select the code toolbar button$')
|
||||
def select_code_button(step):
|
||||
# This is our custom "code style" button. It uses an image instead of a class.
|
||||
world.css_click(".mce-i-none")
|
||||
|
||||
|
||||
@step('type "(.*)" into the Raw Editor$')
|
||||
def type_in_raw_editor(step, text):
|
||||
# Verify that CodeMirror editor is not hidden
|
||||
assert_false(world.css_has_class('.CodeMirror', 'is-inactive'))
|
||||
# Verify that TinyMCE Editor is not present
|
||||
assert_true(world.is_css_not_present('.tiny-mce'))
|
||||
type_in_codemirror(0, text)
|
||||
|
||||
|
||||
@step('I edit the component and select the (Raw|Visual) Editor$')
|
||||
def select_editor(step, editor):
|
||||
world.edit_component_and_select_settings()
|
||||
world.browser.select('Editor', editor)
|
||||
|
||||
|
||||
@step('I click font selection dropdown')
|
||||
def click_font_dropdown(step):
|
||||
dropdowns = [drop for drop in world.css_find('.mce-listbox') if drop.text == 'Font Family']
|
||||
assert_equal(len(dropdowns), 1)
|
||||
dropdowns[0].click()
|
||||
|
||||
|
||||
@step('I should see a list of available fonts')
|
||||
def font_selector_dropdown_is_shown(step):
|
||||
font_panel = get_fonts_list_panel(world)
|
||||
expected_fonts = list(CUSTOM_FONTS.keys()) + list(TINYMCE_FONTS.keys())
|
||||
actual_fonts = [font.strip() for font in font_panel.text.split('\n')]
|
||||
assert_equal(actual_fonts, expected_fonts)
|
||||
|
||||
|
||||
@step('"Default" option sets "(.*)" font family')
|
||||
def default_options_sets_expected_font_family(step, expected_font_family):
|
||||
fonts = get_available_fonts(get_fonts_list_panel(world))
|
||||
assert_equal(fonts.get("Default", None), expected_font_family)
|
||||
|
||||
|
||||
@step('all standard tinyMCE fonts should be available')
|
||||
def check_standard_tinyMCE_fonts(step):
|
||||
fonts = get_available_fonts(get_fonts_list_panel(world))
|
||||
for label, expected_font in TINYMCE_FONTS.items():
|
||||
assert_equal(fonts.get(label, None), expected_font)
|
||||
|
||||
TINYMCE_FONTS = OrderedDict([
|
||||
("Andale Mono", "'andale mono', times"),
|
||||
("Arial", "arial, helvetica, sans-serif"),
|
||||
("Arial Black", "'arial black', 'avant garde'"),
|
||||
("Book Antiqua", "'book antiqua', palatino"),
|
||||
("Comic Sans MS", "'comic sans ms', sans-serif"),
|
||||
("Courier New", "'courier new', courier"),
|
||||
("Georgia", "georgia, palatino"),
|
||||
("Helvetica", "helvetica"),
|
||||
("Impact", "impact, chicago"),
|
||||
("Symbol", "symbol"),
|
||||
("Tahoma", "tahoma, arial, helvetica, sans-serif"),
|
||||
("Terminal", "terminal, monaco"),
|
||||
("Times New Roman", "'times new roman', times"),
|
||||
("Trebuchet MS", "'trebuchet ms', geneva"),
|
||||
("Verdana", "verdana, geneva"),
|
||||
# tinyMCE does not set font-family on dropdown span for these two fonts
|
||||
("Webdings", ""), # webdings
|
||||
("Wingdings", ""), # wingdings, 'zapf dingbats'
|
||||
])
|
||||
|
||||
CUSTOM_FONTS = OrderedDict([
|
||||
('Default', "'Open Sans', Verdana, Arial, Helvetica, sans-serif"),
|
||||
])
|
||||
|
||||
|
||||
def use_plugin(button_class, action):
|
||||
# Click on plugin button
|
||||
world.css_click(button_class)
|
||||
perform_action_in_plugin(action)
|
||||
|
||||
|
||||
def use_code_editor(action):
|
||||
# Click on plugin button
|
||||
buttons = world.css_find('div.mce-widget>button')
|
||||
|
||||
code_editor = [button for button in buttons if button.text == 'HTML']
|
||||
assert_equal(1, len(code_editor))
|
||||
code_editor[0].click()
|
||||
|
||||
perform_action_in_plugin(action)
|
||||
|
||||
|
||||
def perform_action_in_plugin(action):
|
||||
# Wait for the plugin window to open.
|
||||
world.wait_for_visible('.mce-window')
|
||||
|
||||
# Trigger the action
|
||||
action()
|
||||
|
||||
# Click OK
|
||||
world.css_click('.mce-primary')
|
||||
|
||||
|
||||
def get_fonts_list_panel(world):
|
||||
menus = world.css_find('.mce-menu')
|
||||
return menus[0]
|
||||
|
||||
|
||||
def get_available_fonts(font_panel):
|
||||
font_spans = font_panel.find_by_css('.mce-text')
|
||||
return {font_span.text: get_font_family(font_span) for font_span in font_spans}
|
||||
|
||||
|
||||
def get_font_family(font_span):
|
||||
# get_attribute('style').replace('font-family: ', '').replace(';', '') is equivalent to
|
||||
# value_of_css_property('font-family'). However, for reason unknown value_of_css_property fails tests in CI
|
||||
# while works as expected in local development environment
|
||||
return font_span._element.get_attribute('style').replace('font-family: ', '').replace(';', '')
|
||||
62
cms/djangoapps/contentstore/features/pages.feature
Normal file
62
cms/djangoapps/contentstore/features/pages.feature
Normal file
@@ -0,0 +1,62 @@
|
||||
@shard_2
|
||||
Feature: CMS.Pages
|
||||
As a course author, I want to be able to add pages
|
||||
|
||||
Scenario: Users can add static pages
|
||||
Given I have opened the pages page in a new course
|
||||
Then I should not see any static pages
|
||||
When I add a new static page
|
||||
Then I should see a static page named "Empty"
|
||||
|
||||
Scenario: Users can delete static pages
|
||||
Given I have created a static page
|
||||
When I "delete" the static page
|
||||
Then I am shown a prompt
|
||||
When I confirm the prompt
|
||||
Then I should not see any static pages
|
||||
|
||||
# Safari won't update the name properly
|
||||
@skip_safari
|
||||
Scenario: Users can edit static pages
|
||||
Given I have created a static page
|
||||
When I "edit" the static page
|
||||
And I change the name to "New"
|
||||
Then I should see a static page named "New"
|
||||
|
||||
# Safari won't update the name properly
|
||||
@skip_safari
|
||||
Scenario: Users can reorder static pages
|
||||
Given I have created two different static pages
|
||||
When I drag the first static page to the last
|
||||
Then the static pages are switched
|
||||
And I reload the page
|
||||
Then the static pages are switched
|
||||
|
||||
Scenario: Users can reorder built-in pages
|
||||
Given I have opened the pages page in a new course
|
||||
Then the built-in pages are in the default order
|
||||
When I drag the first page to the last
|
||||
Then the built-in pages are switched
|
||||
And I reload the page
|
||||
Then the built-in pages are switched
|
||||
|
||||
Scenario: Users can reorder built-in pages amongst static pages
|
||||
Given I have created two different static pages
|
||||
Then the pages are in the default order
|
||||
When I drag the first page to the last
|
||||
Then the pages are switched
|
||||
And I reload the page
|
||||
Then the pages are switched
|
||||
|
||||
Scenario: Users can toggle visibility on hideable pages
|
||||
Given I have opened the pages page in a new course
|
||||
Then I should see the "wiki" page as "visible"
|
||||
When I toggle the visibility of the "wiki" page
|
||||
Then I should see the "wiki" page as "hidden"
|
||||
And I reload the page
|
||||
Then I should see the "wiki" page as "hidden"
|
||||
When I toggle the visibility of the "wiki" page
|
||||
Then I should see the "wiki" page as "visible"
|
||||
And I reload the page
|
||||
Then I should see the "wiki" page as "visible"
|
||||
|
||||
153
cms/djangoapps/contentstore/features/pages.py
Normal file
153
cms/djangoapps/contentstore/features/pages.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_equal, assert_in
|
||||
|
||||
|
||||
CSS_FOR_TAB_ELEMENT = "li[data-tab-id='{0}'] input.toggle-checkbox"
|
||||
|
||||
|
||||
@step(u'I go to the pages page$')
|
||||
def go_to_static(step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
static_css = 'li.nav-course-courseware-pages a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(static_css)
|
||||
|
||||
|
||||
@step(u'I add a new static page$')
|
||||
def add_page(step):
|
||||
button_css = 'a.new-button'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I should see a static page named "([^"]*)"$')
|
||||
def see_a_static_page_named_foo(step, name):
|
||||
pages_css = 'div.xmodule_StaticTabModule'
|
||||
page_name_html = world.css_html(pages_css)
|
||||
assert_equal(page_name_html.strip(), name)
|
||||
|
||||
|
||||
@step(u'I should not see any static pages$')
|
||||
def not_see_any_static_pages(step):
|
||||
pages_css = 'div.xmodule_StaticTabModule'
|
||||
assert world.is_css_not_present(pages_css, wait_time=30)
|
||||
|
||||
|
||||
@step(u'I "(edit|delete)" the static page$')
|
||||
def click_edit_or_delete(step, edit_or_delete):
|
||||
button_css = 'ul.component-actions a.%s-button' % edit_or_delete
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I change the name to "([^"]*)"$')
|
||||
def change_name(step, new_name):
|
||||
settings_css = '.settings-button'
|
||||
world.css_click(settings_css)
|
||||
input_css = 'input.setting-input'
|
||||
world.css_fill(input_css, new_name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step(u'I drag the first static page to the last$')
|
||||
def drag_first_static_page_to_last(step):
|
||||
drag_first_to_last_with_css('.component')
|
||||
|
||||
|
||||
@step(u'I have created a static page$')
|
||||
def create_static_page(step):
|
||||
step.given('I have opened the pages page in a new course')
|
||||
step.given('I add a new static page')
|
||||
|
||||
|
||||
@step(u'I have opened the pages page in a new course$')
|
||||
def open_pages_page_in_new_course(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I go to the pages page')
|
||||
|
||||
|
||||
@step(u'I have created two different static pages$')
|
||||
def create_two_pages(step):
|
||||
step.given('I have created a static page')
|
||||
step.given('I "edit" the static page')
|
||||
step.given('I change the name to "First"')
|
||||
step.given('I add a new static page')
|
||||
# Verify order of pages
|
||||
_verify_page_names('First', 'Empty')
|
||||
|
||||
|
||||
@step(u'the static pages are switched$')
|
||||
def static_pages_are_switched(step):
|
||||
_verify_page_names('Empty', 'First')
|
||||
|
||||
|
||||
def _verify_page_names(first, second):
|
||||
world.wait_for(
|
||||
func=lambda _: len(world.css_find('.xmodule_StaticTabModule')) == 2,
|
||||
timeout=200,
|
||||
timeout_msg="Timed out waiting for two pages to be present"
|
||||
)
|
||||
pages = world.css_find('.xmodule_StaticTabModule')
|
||||
assert_equal(pages[0].text, first)
|
||||
assert_equal(pages[1].text, second)
|
||||
|
||||
|
||||
@step(u'the built-in pages are in the default order$')
|
||||
def built_in_pages_in_default_order(step):
|
||||
expected_pages = ['Home', 'Course', 'Wiki', 'Progress']
|
||||
see_pages_in_expected_order(expected_pages)
|
||||
|
||||
|
||||
@step(u'the built-in pages are switched$')
|
||||
def built_in_pages_switched(step):
|
||||
expected_pages = ['Home', 'Course', 'Progress', 'Wiki']
|
||||
see_pages_in_expected_order(expected_pages)
|
||||
|
||||
|
||||
@step(u'the pages are in the default order$')
|
||||
def pages_in_default_order(step):
|
||||
expected_pages = ['Home', 'Course', 'Wiki', 'Progress', 'First', 'Empty']
|
||||
see_pages_in_expected_order(expected_pages)
|
||||
|
||||
|
||||
@step(u'the pages are switched$$')
|
||||
def pages_are_switched(step):
|
||||
expected_pages = ['Home', 'Course', 'Progress', 'First', 'Empty', 'Wiki']
|
||||
see_pages_in_expected_order(expected_pages)
|
||||
|
||||
|
||||
@step(u'I drag the first page to the last$')
|
||||
def drag_first_page_to_last(step):
|
||||
drag_first_to_last_with_css('.is-movable')
|
||||
|
||||
|
||||
@step(u'I should see the "([^"]*)" page as "(visible|hidden)"$')
|
||||
def page_is_visible_or_hidden(step, page_id, visible_or_hidden):
|
||||
hidden = visible_or_hidden == "hidden"
|
||||
assert_equal(world.css_find(CSS_FOR_TAB_ELEMENT.format(page_id)).checked, hidden)
|
||||
|
||||
|
||||
@step(u'I toggle the visibility of the "([^"]*)" page$')
|
||||
def page_toggle_visibility(step, page_id):
|
||||
world.css_find(CSS_FOR_TAB_ELEMENT.format(page_id))[0].click()
|
||||
|
||||
|
||||
def drag_first_to_last_with_css(css_class):
|
||||
# For some reason, the drag_and_drop method did not work in this case.
|
||||
draggables = world.css_find(css_class + ' .drag-handle')
|
||||
source = draggables.first
|
||||
target = draggables.last
|
||||
source.action_chains.click_and_hold(source._element).perform() # pylint: disable=protected-access
|
||||
source.action_chains.move_to_element_with_offset(target._element, 0, 50).perform() # pylint: disable=protected-access
|
||||
source.action_chains.release().perform()
|
||||
|
||||
|
||||
def see_pages_in_expected_order(page_names_in_expected_order):
|
||||
pages = world.css_find("li.course-tab")
|
||||
assert_equal(len(page_names_in_expected_order), len(pages))
|
||||
for i, page_name in enumerate(page_names_in_expected_order):
|
||||
assert_in(page_name, pages[i].text)
|
||||
103
cms/djangoapps/contentstore/features/problem-editor.feature
Normal file
103
cms/djangoapps/contentstore/features/problem-editor.feature
Normal file
@@ -0,0 +1,103 @@
|
||||
@shard_1
|
||||
Feature: CMS.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
|
||||
When I edit and select Settings
|
||||
Then I see the advanced settings and their expected values
|
||||
And Edit High Level Source is not visible
|
||||
|
||||
# Safari is having trouble saving the values on sauce
|
||||
@skip_safari
|
||||
Scenario: User can modify String values
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
# Safari is having trouble saving the values on sauce
|
||||
@skip_safari
|
||||
Scenario: User can specify special characters in String values
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can specify special characters in the display name
|
||||
And my special characters and persisted on save
|
||||
|
||||
Scenario: User can revert display name to unset
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can revert the display name to unset
|
||||
And my display name is unset on save
|
||||
|
||||
Scenario: User can specify html in display name and it will be escaped
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can specify html in the display name and save
|
||||
And the problem display name is "<script>alert('test')</script>"
|
||||
|
||||
# IE will not click the revert button properly
|
||||
@skip_internetexplorer
|
||||
Scenario: User can select values in a Select
|
||||
Given I have created a Blank Common Problem
|
||||
When 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
|
||||
|
||||
# Safari will input it as 35.
|
||||
@skip_safari
|
||||
Scenario: User can modify float input values
|
||||
Given I have created a Blank Common Problem
|
||||
When 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
|
||||
When I edit and select Settings
|
||||
Then if I set the weight to "abc", it remains unset
|
||||
|
||||
# Safari will input it as 234.
|
||||
@skip_safari
|
||||
Scenario: User cannot type decimal values integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "2.34", it will persist as a valid integer
|
||||
|
||||
# Safari will input it incorrectly
|
||||
@skip_safari
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it will persist as a valid integer
|
||||
|
||||
# Safari will input it as 35.
|
||||
@skip_safari
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
When 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
|
||||
When I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
Scenario: Cheat sheet visible on toggle
|
||||
Given I have created a Blank Common Problem
|
||||
And I can edit the problem
|
||||
Then I can see cheatsheet
|
||||
|
||||
Scenario: Reply on Annotation and Return to Annotation link works for Annotation problem
|
||||
Given I have created a unit with advanced module "annotatable"
|
||||
And I have created an advanced component "Annotation" of type "annotatable"
|
||||
And I have created an advanced problem of type "Blank Advanced Problem"
|
||||
And I edit first blank advanced problem for annotation response
|
||||
When I mouseover on "annotatable-span"
|
||||
Then I can see Reply to Annotation link
|
||||
And I see that page has scrolled "down" when I click on "annotatable-reply" link
|
||||
And I see that page has scrolled "up" when I click on "annotation-return" link
|
||||
385
cms/djangoapps/contentstore/features/problem-editor.py
Normal file
385
cms/djangoapps/contentstore/features/problem-editor.py
Normal file
@@ -0,0 +1,385 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
import json
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_equal, assert_true
|
||||
from common import type_in_codemirror, open_new_course
|
||||
from advanced_settings import change_value, ADVANCED_MODULES_KEY
|
||||
from course_import import import_file
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
MAXIMUM_ATTEMPTS = "Maximum Attempts"
|
||||
PROBLEM_WEIGHT = "Problem Weight"
|
||||
RANDOMIZATION = 'Randomization'
|
||||
SHOW_ANSWER = "Show Answer"
|
||||
SHOW_RESET_BUTTON = "Show Reset Button"
|
||||
TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts"
|
||||
MATLAB_API_KEY = "Matlab API key"
|
||||
|
||||
|
||||
@step('I have created a Blank Common Problem$')
|
||||
def i_created_blank_common_problem(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
step.given("I have created another Blank Common Problem")
|
||||
|
||||
|
||||
@step('I have created a unit with advanced module "(.*)"$')
|
||||
def i_created_unit_with_advanced_module(step, advanced_module):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
|
||||
url = world.browser.url
|
||||
step.given("I select the Advanced Settings")
|
||||
change_value(step, ADVANCED_MODULES_KEY, '["{}"]'.format(advanced_module))
|
||||
world.visit(url)
|
||||
world.wait_for_xmodule()
|
||||
|
||||
|
||||
@step('I have created an advanced component "(.*)" of type "(.*)"')
|
||||
def i_create_new_advanced_component(step, component_type, advanced_component):
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='advanced',
|
||||
component_type=component_type,
|
||||
advanced_component=advanced_component
|
||||
)
|
||||
|
||||
|
||||
@step('I have created another Blank Common Problem$')
|
||||
def i_create_new_common_problem(step):
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='problem',
|
||||
component_type='Blank Common Problem'
|
||||
)
|
||||
|
||||
|
||||
@step('when I mouseover on "(.*)"')
|
||||
def i_mouseover_on_html_component(step, element_class):
|
||||
action_css = '.{}'.format(element_class)
|
||||
world.trigger_event(action_css, event='mouseover')
|
||||
|
||||
|
||||
@step(u'I can see Reply to Annotation link$')
|
||||
def i_see_reply_to_annotation_link(_step):
|
||||
css_selector = 'a.annotatable-reply'
|
||||
world.wait_for_visible(css_selector)
|
||||
|
||||
|
||||
@step(u'I see that page has scrolled "(.*)" when I click on "(.*)" link$')
|
||||
def i_see_annotation_problem_page_scrolls(_step, scroll_direction, link_css):
|
||||
scroll_js = "$(window).scrollTop();"
|
||||
scroll_height_before = world.browser.evaluate_script(scroll_js)
|
||||
world.css_click("a.{}".format(link_css))
|
||||
scroll_height_after = world.browser.evaluate_script(scroll_js)
|
||||
if scroll_direction == "up":
|
||||
assert scroll_height_after < scroll_height_before
|
||||
elif scroll_direction == "down":
|
||||
assert scroll_height_after > scroll_height_before
|
||||
|
||||
|
||||
@step('I have created an advanced problem of type "(.*)"$')
|
||||
def i_create_new_advanced_problem(step, component_type):
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='problem',
|
||||
component_type=component_type,
|
||||
is_advanced=True
|
||||
)
|
||||
|
||||
|
||||
@step('I edit and select Settings$')
|
||||
def i_edit_and_select_settings(_step):
|
||||
world.edit_component_and_select_settings()
|
||||
|
||||
|
||||
@step('I see the advanced settings and their expected values$')
|
||||
def i_see_advanced_settings_with_values(step):
|
||||
world.verify_all_setting_entries(
|
||||
[
|
||||
[DISPLAY_NAME, "Blank Common Problem", True],
|
||||
[MATLAB_API_KEY, "", False],
|
||||
[MAXIMUM_ATTEMPTS, "", False],
|
||||
[PROBLEM_WEIGHT, "", False],
|
||||
[RANDOMIZATION, "Never", False],
|
||||
[SHOW_ANSWER, "Finished", False],
|
||||
[SHOW_RESET_BUTTON, "False", False],
|
||||
[TIMER_BETWEEN_ATTEMPTS, "0", False],
|
||||
])
|
||||
|
||||
|
||||
@step('I can modify the display name')
|
||||
def i_can_modify_the_display_name(_step):
|
||||
# Verifying that the display name can be a string containing a floating point value
|
||||
# (to confirm that we don't throw an error because it is of the wrong type).
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, '3.4')
|
||||
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('the problem display name is "(.*)"$')
|
||||
def verify_problem_display_name(step, name):
|
||||
"""
|
||||
name is uppercased because the heading styles are uppercase in css
|
||||
"""
|
||||
assert_equal(name, world.browser.find_by_css('.problem-header').text)
|
||||
|
||||
|
||||
@step('I can specify special characters in the display name')
|
||||
def i_can_modify_the_display_name_with_special_chars(_step):
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, "updated ' \" &")
|
||||
verify_modified_display_name_with_special_chars()
|
||||
|
||||
|
||||
@step('I can specify html in the display name and save')
|
||||
def i_can_modify_the_display_name_with_html(_step):
|
||||
"""
|
||||
If alert appear on save then UnexpectedAlertPresentException
|
||||
will occur and test will fail.
|
||||
"""
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, "<script>alert('test')</script>")
|
||||
verify_modified_display_name_with_html()
|
||||
world.save_component()
|
||||
|
||||
|
||||
@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, "Never", 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 will persist as a valid integer$')
|
||||
def set_the_max_attempts(step, max_attempts_set):
|
||||
# on firefox with selenium, the behavior is different.
|
||||
# eg 2.34 displays as 2.34 and is persisted as 2
|
||||
index = world.get_setting_entry_index(MAXIMUM_ATTEMPTS)
|
||||
world.set_field_value(index, max_attempts_set)
|
||||
world.save_component_and_reopen(step)
|
||||
value = world.css_value('input.setting-input', index=index)
|
||||
assert value != "", "max attempts is blank"
|
||||
assert int(value) >= 0
|
||||
|
||||
|
||||
@step('Edit High Level Source is not visible')
|
||||
def edit_high_level_source_not_visible(step):
|
||||
verify_high_level_source_links(step, False)
|
||||
|
||||
|
||||
@step('Edit High Level Source is visible')
|
||||
def edit_high_level_source_links_visible(step):
|
||||
verify_high_level_source_links(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 the advanced settings and their expected values")
|
||||
|
||||
|
||||
@step('I have enabled latex compiler')
|
||||
def enable_latex_compiler(step):
|
||||
url = world.browser.url
|
||||
step.given("I select the Advanced Settings")
|
||||
change_value(step, 'Enable LaTeX Compiler', 'true')
|
||||
world.visit(url)
|
||||
world.wait_for_xmodule()
|
||||
|
||||
|
||||
@step('I have created a LaTeX Problem')
|
||||
def create_latex_problem(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
step.given('I have enabled latex compiler')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='problem',
|
||||
component_type='Problem Written in LaTeX',
|
||||
is_advanced=True
|
||||
)
|
||||
|
||||
|
||||
@step('I edit and compile the High Level Source')
|
||||
def edit_latex_source(_step):
|
||||
open_high_level_source()
|
||||
type_in_codemirror(1, "hi")
|
||||
world.css_click('.hls-compile')
|
||||
|
||||
|
||||
@step('my change to the High Level Source is persisted')
|
||||
def high_level_source_persisted(_step):
|
||||
def verify_text(driver):
|
||||
css_sel = '.problem div>span'
|
||||
return world.css_text(css_sel) == 'hi'
|
||||
|
||||
world.wait_for(verify_text, timeout=10)
|
||||
|
||||
|
||||
@step('I view the High Level Source I see my changes')
|
||||
def high_level_source_in_editor(_step):
|
||||
open_high_level_source()
|
||||
assert_equal('hi', world.css_value('.source-edit-box'))
|
||||
|
||||
|
||||
@step(u'I have an empty course')
|
||||
def i_have_empty_course(step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
@step(u'I import the file "([^"]*)"$')
|
||||
def i_import_the_file(_step, filename):
|
||||
import_file(filename)
|
||||
|
||||
|
||||
@step(u'I go to the vertical "([^"]*)"$')
|
||||
def i_go_to_vertical(_step, vertical):
|
||||
world.css_click("span:contains('{0}')".format(vertical))
|
||||
|
||||
|
||||
@step(u'I go to the unit "([^"]*)"$')
|
||||
def i_go_to_unit(_step, unit):
|
||||
loc = "window.location = $(\"span:contains('{0}')\").closest('a').attr('href')".format(unit)
|
||||
world.browser.execute_script(loc)
|
||||
|
||||
|
||||
@step(u'I see a message that says "([^"]*)"$')
|
||||
def i_can_see_message(_step, msg):
|
||||
msg = json.dumps(msg) # escape quotes
|
||||
world.css_has_text("h2.title", msg)
|
||||
|
||||
|
||||
@step(u'I can edit the problem$')
|
||||
def i_can_edit_problem(_step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step(u'I edit first blank advanced problem for annotation response$')
|
||||
def i_edit_blank_problem_for_annotation_response(_step):
|
||||
world.edit_component(1)
|
||||
text = """
|
||||
<problem>
|
||||
<annotationresponse>
|
||||
<annotationinput><text>Text of annotation</text></annotationinput>
|
||||
</annotationresponse>
|
||||
</problem>"""
|
||||
type_in_codemirror(0, text)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step(u'I can see cheatsheet$')
|
||||
def verify_cheat_sheet_displaying(_step):
|
||||
world.css_click(".cheatsheet-toggle")
|
||||
css_selector = '.simple-editor-cheatsheet'
|
||||
world.wait_for_visible(css_selector)
|
||||
|
||||
|
||||
def verify_high_level_source_links(step, visible):
|
||||
if visible:
|
||||
assert_true(world.is_css_present('.launch-latex-compiler'),
|
||||
msg="Expected to find the latex button but it is not present.")
|
||||
else:
|
||||
assert_true(world.is_css_not_present('.launch-latex-compiler'),
|
||||
msg="Expected not to find the latex button but it is present.")
|
||||
|
||||
world.cancel_component(step)
|
||||
|
||||
|
||||
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, '3.4', 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_modified_display_name_with_html():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "<script>alert('test')</script>", True)
|
||||
|
||||
|
||||
def verify_unset_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False)
|
||||
|
||||
|
||||
def set_weight(weight):
|
||||
index = world.get_setting_entry_index(PROBLEM_WEIGHT)
|
||||
world.set_field_value(index, weight)
|
||||
|
||||
|
||||
def open_high_level_source():
|
||||
world.edit_component()
|
||||
world.css_click('.launch-latex-compiler > a')
|
||||
44
cms/djangoapps/contentstore/features/signup.feature
Normal file
44
cms/djangoapps/contentstore/features/signup.feature
Normal file
@@ -0,0 +1,44 @@
|
||||
@shard_2
|
||||
Feature: CMS.Sign in
|
||||
In order to use the edX content
|
||||
As a new user
|
||||
I want to signup for a student account
|
||||
|
||||
Scenario: Sign up from the homepage
|
||||
Given I visit the Studio homepage
|
||||
When I click the link with the text "Sign Up"
|
||||
And I fill in the registration form
|
||||
And I press the Create My Account button on the registration form
|
||||
Then I should see an email verification prompt
|
||||
|
||||
Scenario: Login with a valid redirect
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the url "/course/slashes:MITx+999+Robot_Super_Course"
|
||||
And I should see that the path is "/signin?next=/course/slashes%3AMITx%2B999%2BRobot_Super_Course"
|
||||
When I fill in and submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/course/slashes:MITx+999+Robot_Super_Course"
|
||||
|
||||
Scenario: Login with an invalid redirect
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the url "/signin?next=http://www.google.com/"
|
||||
When I fill in and submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/home/"
|
||||
|
||||
Scenario: Login with mistyped credentials
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the Studio homepage
|
||||
When I click the link with the text "Sign In"
|
||||
Then I should see that the path is "/signin"
|
||||
And I should not see a login error message
|
||||
And I fill in and submit the signin form incorrectly
|
||||
Then I should see a login error message
|
||||
And I edit the password field
|
||||
Then I should not see a login error message
|
||||
And I submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/home/"
|
||||
72
cms/djangoapps/contentstore/features/signup.py
Normal file
72
cms/djangoapps/contentstore/features/signup.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_false
|
||||
|
||||
|
||||
@step('I fill in the registration form$')
|
||||
def i_fill_in_the_registration_form(step):
|
||||
def fill_in_reg_form():
|
||||
register_form = world.css_find('form#register_form')
|
||||
register_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
register_form.find_by_name('password').fill('test')
|
||||
register_form.find_by_name('username').fill('robot-studio')
|
||||
register_form.find_by_name('name').fill('Robot Studio')
|
||||
register_form.find_by_name('terms_of_service').click()
|
||||
world.retry_on_exception(fill_in_reg_form)
|
||||
|
||||
|
||||
@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'
|
||||
world.css_click(submit_css)
|
||||
|
||||
|
||||
@step('I should see an email verification prompt')
|
||||
def i_should_see_an_email_verification_prompt(step):
|
||||
world.css_has_text('h1.page-header', u'Studio Home')
|
||||
world.css_has_text('div.msg h3.title', u'We need to verify your email address')
|
||||
|
||||
|
||||
@step(u'I fill in and submit the signin form$')
|
||||
def i_fill_in_the_signin_form(step):
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
login_form.find_by_name('password').fill('test')
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
|
||||
|
||||
@step(u'I should( not)? see a login error message$')
|
||||
def i_should_see_a_login_error(step, should_not_see):
|
||||
if should_not_see:
|
||||
# the login error may be absent or invisible. Check absence first,
|
||||
# because css_visible will throw an exception if the element is not present
|
||||
if world.is_css_present('div#login_error'):
|
||||
assert_false(world.css_visible('div#login_error'))
|
||||
else:
|
||||
assert_true(world.css_visible('div#login_error'))
|
||||
|
||||
|
||||
@step(u'I fill in and submit the signin form incorrectly$')
|
||||
def i_goof_in_the_signin_form(step):
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
login_form.find_by_name('password').fill('oops')
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
|
||||
|
||||
@step(u'I edit the password field$')
|
||||
def i_edit_the_password_field(step):
|
||||
password_css = 'form#login_form input#password'
|
||||
world.css_fill(password_css, 'test')
|
||||
|
||||
|
||||
@step(u'I submit the signin form$')
|
||||
def i_submit_the_signin_form(step):
|
||||
submit_css = 'form#login_form button#submit'
|
||||
world.css_click(submit_css)
|
||||
29
cms/djangoapps/contentstore/features/textbooks.feature
Normal file
29
cms/djangoapps/contentstore/features/textbooks.feature
Normal file
@@ -0,0 +1,29 @@
|
||||
@shard_2
|
||||
Feature: CMS.Textbooks
|
||||
|
||||
Scenario: Create a textbook with multiple chapters
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the textbooks page
|
||||
When I click on the New Textbook button
|
||||
And I name my textbook "History"
|
||||
And I name the first chapter "Britain"
|
||||
And I type in "britain.pdf" for the first chapter asset
|
||||
And I click Add a Chapter
|
||||
And I name the second chapter "America"
|
||||
And I type in "america.pdf" for the second chapter asset
|
||||
And I save the textbook
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And I click the textbook chapters
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And the first chapter should be named "Britain"
|
||||
And the first chapter should have an asset called "britain.pdf"
|
||||
And the second chapter should be named "America"
|
||||
And the second chapter should have an asset called "america.pdf"
|
||||
And I reload the page
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And I click the textbook chapters
|
||||
Then I should see a textbook named "History" with 2 chapters
|
||||
And the first chapter should be named "Britain"
|
||||
And the first chapter should have an asset called "britain.pdf"
|
||||
And the second chapter should be named "America"
|
||||
And the second chapter should have an asset called "america.pdf"
|
||||
125
cms/djangoapps/contentstore/features/textbooks.py
Normal file
125
cms/djangoapps/contentstore/features/textbooks.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
from lettuce import world, step
|
||||
from django.conf import settings
|
||||
from common import upload_file
|
||||
from nose.tools import assert_equal
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
|
||||
@step(u'I go to the textbooks page')
|
||||
def go_to_uploads(_step):
|
||||
world.wait_for_js_to_load()
|
||||
world.click_course_content()
|
||||
menu_css = 'li.nav-course-courseware-textbooks a'
|
||||
world.css_click(menu_css)
|
||||
|
||||
|
||||
@step(u'I should see a message telling me to create a new textbook')
|
||||
def assert_create_new_textbook_msg(_step):
|
||||
css = ".wrapper-content .no-textbook-content"
|
||||
assert world.is_css_present(css)
|
||||
no_tb = world.css_find(css)
|
||||
assert "You haven't added any textbooks" in no_tb.text
|
||||
|
||||
|
||||
@step(u'I upload the textbook "([^"]*)"$')
|
||||
def upload_textbook(_step, file_name):
|
||||
upload_file(file_name, sub_path="uploads/")
|
||||
|
||||
|
||||
@step(u'I click (on )?the New Textbook button')
|
||||
def click_new_textbook(_step, on):
|
||||
button_css = ".nav-actions .new-button"
|
||||
button = world.css_find(button_css)
|
||||
button.click()
|
||||
|
||||
|
||||
@step(u'I name my textbook "([^"]*)"')
|
||||
def name_textbook(_step, name):
|
||||
input_css = ".textbook input[name=textbook-name]"
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I name the (first|second|third) chapter "([^"]*)"')
|
||||
def name_chapter(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-name".format(i=index + 1)
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I type in "([^"]*)" for the (first|second|third) chapter asset')
|
||||
def asset_chapter(_step, name, ordinal):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index + 1)
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I click the Upload Asset link for the (first|second|third) chapter')
|
||||
def click_upload_asset(_step, ordinal):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
button_css = ".textbook .chapter{i} .action-upload".format(i=index + 1)
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I click Add a Chapter')
|
||||
def click_add_chapter(_step):
|
||||
button_css = ".textbook .action-add-chapter"
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I save the textbook')
|
||||
def save_textbook(_step):
|
||||
submit_css = "form.edit-textbook button[type=submit]"
|
||||
world.css_click(submit_css)
|
||||
|
||||
|
||||
@step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"')
|
||||
def check_textbook(_step, textbook_name, chapter_name):
|
||||
title = world.css_text(".textbook h3.textbook-title", index=0)
|
||||
chapter = world.css_text(".textbook .wrap-textbook p", index=0)
|
||||
assert_equal(title, textbook_name)
|
||||
assert_equal(chapter, chapter_name)
|
||||
|
||||
|
||||
@step(u'I should see a textbook named "([^"]*)" with (\d+) chapters')
|
||||
def check_textbook_chapters(_step, textbook_name, num_chapters_str):
|
||||
num_chapters = int(num_chapters_str)
|
||||
title = world.css_text(".textbook .view-textbook h3.textbook-title", index=0)
|
||||
toggle_text = world.css_text(".textbook .view-textbook .chapter-toggle", index=0)
|
||||
assert_equal(title, textbook_name)
|
||||
assert_equal(
|
||||
toggle_text,
|
||||
"{num} PDF Chapters".format(num=num_chapters),
|
||||
"Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle_text)
|
||||
)
|
||||
|
||||
|
||||
@step(u'I click the textbook chapters')
|
||||
def click_chapters(_step):
|
||||
world.css_click(".textbook a.chapter-toggle")
|
||||
|
||||
|
||||
@step(u'the (first|second|third) chapter should be named "([^"]*)"')
|
||||
def check_chapter_name(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
|
||||
element = chapter.find_by_css(".chapter-name")
|
||||
assert element.text == name, "Expected chapter named {expected}, found chapter named {actual}".format(
|
||||
expected=name, actual=element.text)
|
||||
|
||||
|
||||
@step(u'the (first|second|third) chapter should have an asset called "([^"]*)"')
|
||||
def check_chapter_asset(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index]
|
||||
element = chapter.find_by_css(".chapter-asset-path")
|
||||
assert element.text == name, "Expected chapter with asset {expected}, found chapter with asset {actual}".format(
|
||||
expected=name, actual=element.text)
|
||||
84
cms/djangoapps/contentstore/features/transcripts.feature
Normal file
84
cms/djangoapps/contentstore/features/transcripts.feature
Normal file
@@ -0,0 +1,84 @@
|
||||
@shard_1 @requires_stub_youtube
|
||||
Feature: CMS Transcripts
|
||||
As a course author, I want to be able to create video components
|
||||
|
||||
# For transcripts acceptance tests there are 3 available caption
|
||||
# files. They can be used to test various transcripts features. Two of
|
||||
# them can be imported from YouTube.
|
||||
#
|
||||
# The length of each file name is 11 characters. This is because the
|
||||
# YouTube's ID length is 11 characters. If file name is not of length 11,
|
||||
# front-end validation will not pass.
|
||||
#
|
||||
# t__eq_exist - this file exists on YouTube, and can be imported
|
||||
# via the transcripts menu; after import, this file will
|
||||
# be equal to the one stored locally
|
||||
# t_neq_exist - same as above, except local file will differ from the
|
||||
# one stored on YouTube
|
||||
# t_not_exist - this file does not exist on YouTube; it exists locally
|
||||
|
||||
#3
|
||||
Scenario: Youtube id only: check "not found" and "import" states
|
||||
Given I have created a Video component with subtitles
|
||||
And I edit the component
|
||||
|
||||
# Not found: w/o local or server subs
|
||||
And I remove "t_not_exist" transcripts id from store
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "Default Timed Transcript"
|
||||
|
||||
# Import: w/o local but with server subs
|
||||
And I remove "t__eq_exist" transcripts id from store
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I click transcript button "import"
|
||||
Then I see status message "found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see button "download_to_edit"
|
||||
And I see value "t__eq_exist" in the field "Default Timed Transcript"
|
||||
|
||||
|
||||
# Disabled 1/29/14 due to flakiness observed in master
|
||||
#10
|
||||
#Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o subs
|
||||
# Given I have created a Video component
|
||||
# And I edit the component
|
||||
#
|
||||
# And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
# Then I see status message "not found on edx"
|
||||
# And I see button "import"
|
||||
# And I click transcript button "import"
|
||||
# Then I see status message "found"
|
||||
#
|
||||
# And I enter a "t_not_exist.mp4" source to field number 2
|
||||
# Then I see status message "found"
|
||||
# And I see value "t__eq_exist" in the field "Default Timed Transcript"
|
||||
|
||||
# Flaky test fails occasionally in master. https://openedx.atlassian.net/browse/BLD-892
|
||||
#21
|
||||
#Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save - > change it to another one HTML5 source w/o #transcripts - click on use existing - > change it to another one HTML5 source w/o transcripts - click on use existing
|
||||
# Given I have created a Video component with subtitles "t_not_exist"
|
||||
# And I edit the component
|
||||
#
|
||||
# And I enter a "t_not_exist.mp4" source to field number 1
|
||||
# Then I see status message "found"
|
||||
# And I see button "download_to_edit"
|
||||
# And I see button "upload_new_timed_transcripts"
|
||||
# And I see value "t_not_exist" in the field "Default Timed Transcript"
|
||||
#
|
||||
# And I save changes
|
||||
# And I edit the component
|
||||
#
|
||||
# And I enter a "video_name_2.mp4" source to field number 1
|
||||
# Then I see status message "use existing"
|
||||
# And I see button "use_existing"
|
||||
# And I click transcript button "use_existing"
|
||||
# And I see value "video_name_2" in the field "Default Timed Transcript"
|
||||
#
|
||||
# And I enter a "video_name_3.mp4" source to field number 1
|
||||
# Then I see status message "use existing"
|
||||
# And I see button "use_existing"
|
||||
# And I click transcript button "use_existing"
|
||||
# And I see value "video_name_3" in the field "Default Timed Transcript"
|
||||
261
cms/djangoapps/contentstore/features/transcripts.py
Normal file
261
cms/djangoapps/contentstore/features/transcripts.py
Normal file
@@ -0,0 +1,261 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
import os
|
||||
from lettuce import world, step
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from splinter.request_handler.request_handler import RequestHandler
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
# We should wait 300 ms for event handler invocation + 200ms for safety.
|
||||
DELAY = 0.5
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
'url_format': u'Incorrect url format.',
|
||||
'file_type': u'Link types should be unique.',
|
||||
'links_duplication': u'Links should be unique.',
|
||||
}
|
||||
|
||||
STATUSES = {
|
||||
'found': u'Timed Transcript Found',
|
||||
'not found on edx': u'No EdX Timed Transcript',
|
||||
'not found': u'No Timed Transcript',
|
||||
'replace': u'Timed Transcript Conflict',
|
||||
'uploaded_successfully': u'Timed Transcript Uploaded Successfully',
|
||||
'use existing': u'Confirm Timed Transcript',
|
||||
}
|
||||
|
||||
SELECTORS = {
|
||||
'error_bar': '.transcripts-error-message',
|
||||
'url_inputs': '.videolist-settings-item input.input',
|
||||
'collapse_link': '.collapse-action.collapse-setting',
|
||||
'collapse_bar': '.videolist-extra-videos',
|
||||
'status_bar': '.transcripts-message-status',
|
||||
}
|
||||
|
||||
# button type , button css selector, button message
|
||||
TRANSCRIPTS_BUTTONS = {
|
||||
'import': ('.setting-import', 'Import YouTube Transcript'),
|
||||
'download_to_edit': ('.setting-download', 'Download Transcript for Editing'),
|
||||
'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download Transcript for Editing'),
|
||||
'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Transcript'),
|
||||
'replace': ('.setting-replace', 'Yes, replace the edX transcript with the YouTube transcript'),
|
||||
'choose': ('.setting-choose', 'Timed Transcript from {}'),
|
||||
'use_existing': ('.setting-use-existing', 'Use Current Transcript'),
|
||||
}
|
||||
|
||||
|
||||
@step('I clear fields$')
|
||||
def clear_fields(_step):
|
||||
|
||||
# Clear the input fields and trigger an 'input' event
|
||||
script = """
|
||||
$('{selector}')
|
||||
.prop('disabled', false)
|
||||
.removeClass('is-disabled')
|
||||
.attr('aria-disabled', false)
|
||||
.val('')
|
||||
.trigger('input');
|
||||
""".format(selector=SELECTORS['url_inputs'])
|
||||
world.browser.execute_script(script)
|
||||
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I clear field number (.+)$')
|
||||
def clear_field(_step, index):
|
||||
index = int(index) - 1
|
||||
world.css_fill(SELECTORS['url_inputs'], '', index)
|
||||
|
||||
# For some reason ChromeDriver doesn't trigger an 'input' event after filling
|
||||
# the field with an empty value. That's why we trigger it manually via jQuery.
|
||||
world.trigger_event(SELECTORS['url_inputs'], event='input', index=index)
|
||||
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I expect (.+) inputs are disabled$')
|
||||
def inputs_are_disabled(_step, indexes):
|
||||
index_list = [int(i.strip()) - 1 for i in indexes.split(',')]
|
||||
for index in index_list:
|
||||
el = world.css_find(SELECTORS['url_inputs'])[index]
|
||||
|
||||
assert el['disabled']
|
||||
|
||||
|
||||
@step('I expect inputs are enabled$')
|
||||
def inputs_are_enabled(_step):
|
||||
for index in range(3):
|
||||
el = world.css_find(SELECTORS['url_inputs'])[index]
|
||||
|
||||
assert not el['disabled']
|
||||
|
||||
|
||||
@step('I do not see error message$')
|
||||
def i_do_not_see_error_message(_step):
|
||||
assert not world.css_visible(SELECTORS['error_bar'])
|
||||
|
||||
|
||||
@step('I see error message "([^"]*)"$')
|
||||
def i_see_error_message(_step, error):
|
||||
assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error])
|
||||
|
||||
|
||||
@step('I do not see status message$')
|
||||
def i_do_not_see_status_message(_step):
|
||||
assert not world.css_visible(SELECTORS['status_bar'])
|
||||
|
||||
|
||||
@step('I see status message "([^"]*)"$')
|
||||
def i_see_status_message(_step, status):
|
||||
assert not world.css_visible(SELECTORS['error_bar'])
|
||||
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status])
|
||||
|
||||
DOWNLOAD_BUTTON = TRANSCRIPTS_BUTTONS["download_to_edit"][0]
|
||||
if world.is_css_present(DOWNLOAD_BUTTON, wait_time=1) and not world.css_find(DOWNLOAD_BUTTON)[0].has_class('is-disabled'):
|
||||
assert _transcripts_are_downloaded()
|
||||
|
||||
|
||||
@step('I (.*)see button "([^"]*)"$')
|
||||
def i_see_button(_step, not_see, button_type):
|
||||
button = button_type.strip()
|
||||
|
||||
if not_see.strip():
|
||||
assert world.is_css_not_present(TRANSCRIPTS_BUTTONS[button][0])
|
||||
else:
|
||||
assert world.css_has_text(TRANSCRIPTS_BUTTONS[button][0], TRANSCRIPTS_BUTTONS[button][1])
|
||||
|
||||
|
||||
@step('I (.*)see (.*)button "([^"]*)" number (\d+)$')
|
||||
def i_see_button_with_custom_text(_step, not_see, button_type, custom_text, index):
|
||||
button = button_type.strip()
|
||||
custom_text = custom_text.strip()
|
||||
index = int(index.strip()) - 1
|
||||
|
||||
if not_see.strip():
|
||||
assert world.is_css_not_present(TRANSCRIPTS_BUTTONS[button][0])
|
||||
else:
|
||||
assert world.css_has_text(TRANSCRIPTS_BUTTONS[button][0], TRANSCRIPTS_BUTTONS[button][1].format(custom_text), index)
|
||||
|
||||
|
||||
@step('I click transcript button "([^"]*)"$')
|
||||
def click_button_transcripts_variant(_step, button_type):
|
||||
button = button_type.strip()
|
||||
world.css_click(TRANSCRIPTS_BUTTONS[button][0])
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I click transcript button "([^"]*)" number (\d+)$')
|
||||
def click_button_index(_step, button_type, index):
|
||||
button = button_type.strip()
|
||||
index = int(index.strip()) - 1
|
||||
|
||||
world.css_click(TRANSCRIPTS_BUTTONS[button][0], index)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I remove "([^"]+)" transcripts id from store')
|
||||
def remove_transcripts_from_store(_step, subs_id):
|
||||
"""Remove from store, if transcripts content exists."""
|
||||
filename = 'subs_{0}.srt.sjson'.format(subs_id.strip())
|
||||
content_location = StaticContent.compute_location(
|
||||
world.scenario_dict['COURSE'].id,
|
||||
filename
|
||||
)
|
||||
try:
|
||||
content = contentstore().find(content_location)
|
||||
contentstore().delete(content.location)
|
||||
print 'Transcript file was removed from store.'
|
||||
except NotFoundError:
|
||||
print 'Transcript file was NOT found and not removed.'
|
||||
|
||||
|
||||
@step('I enter a "([^"]+)" source to field number (\d+)$')
|
||||
def i_enter_a_source(_step, link, index):
|
||||
index = int(index) - 1
|
||||
|
||||
if index is not 0 and not world.css_visible(SELECTORS['collapse_bar']):
|
||||
world.css_click(SELECTORS['collapse_link'])
|
||||
|
||||
assert world.css_visible(SELECTORS['collapse_bar'])
|
||||
|
||||
world.css_fill(SELECTORS['url_inputs'], link, index)
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I upload the transcripts file "([^"]*)"$')
|
||||
def upload_file(_step, file_name):
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name.strip())
|
||||
world.browser.execute_script("$('form.file-chooser').show()")
|
||||
world.browser.attach_file('transcript-file', os.path.abspath(path))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I see "([^"]*)" text in the captions')
|
||||
def check_text_in_the_captions(_step, text):
|
||||
world.wait_for_present('.video.is-captions-rendered')
|
||||
world.wait_for(lambda _: world.css_text('.subtitles'), timeout=30)
|
||||
actual_text = world.css_text('.subtitles')
|
||||
assert text in actual_text
|
||||
|
||||
|
||||
@step('I see value "([^"]*)" in the field "([^"]*)"$')
|
||||
def check_transcripts_field(_step, values, field_name):
|
||||
world.select_editor_tab('Advanced')
|
||||
tab = world.css_find('#settings-tab').first
|
||||
field_id = '#' + tab.find_by_xpath('.//label[text()="%s"]' % field_name.strip())[0]['for']
|
||||
values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')]
|
||||
assert any(values_list)
|
||||
world.select_editor_tab('Basic')
|
||||
|
||||
|
||||
@step('I save changes$')
|
||||
def save_changes(_step):
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step('I open tab "([^"]*)"$')
|
||||
def open_tab(_step, tab_name):
|
||||
world.select_editor_tab(tab_name)
|
||||
|
||||
|
||||
@step('I set value "([^"]*)" to the field "([^"]*)"$')
|
||||
def set_value_transcripts_field(_step, value, field_name):
|
||||
tab = world.css_find('#settings-tab').first
|
||||
XPATH = './/label[text()="{name}"]'.format(name=field_name)
|
||||
SELECTOR = '#' + tab.find_by_xpath(XPATH)[0]['for']
|
||||
element = world.css_find(SELECTOR).first
|
||||
if element['type'] == 'text':
|
||||
SCRIPT = '$("{selector}").val("{value}").change()'.format(
|
||||
selector=SELECTOR,
|
||||
value=value
|
||||
)
|
||||
world.browser.execute_script(SCRIPT)
|
||||
assert world.css_has_value(SELECTOR, value)
|
||||
else:
|
||||
assert False, 'Incorrect element type.'
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I revert the transcript field "([^"]*)"$')
|
||||
def revert_transcripts_field(_step, field_name):
|
||||
world.revert_setting_entry(field_name)
|
||||
|
||||
|
||||
def _transcripts_are_downloaded():
|
||||
world.wait_for_ajax_complete()
|
||||
request = RequestHandler()
|
||||
DOWNLOAD_BUTTON = world.css_find(TRANSCRIPTS_BUTTONS["download_to_edit"][0]).first
|
||||
url = DOWNLOAD_BUTTON['href']
|
||||
request.connect(url)
|
||||
|
||||
return request.status_code.is_success()
|
||||
118
cms/djangoapps/contentstore/features/upload.feature
Normal file
118
cms/djangoapps/contentstore/features/upload.feature
Normal file
@@ -0,0 +1,118 @@
|
||||
@shard_2
|
||||
Feature: CMS.Upload Files
|
||||
As a course author, I want to be able to upload files for my students
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can upload files
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the file "test" by clicking "Upload your first asset"
|
||||
Then I should see the file "test" was uploaded
|
||||
And The url for the file "test" is valid
|
||||
When I upload the file "test2"
|
||||
Then I should see the file "test2" was uploaded
|
||||
And The url for the file "test2" is valid
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can upload multiple files
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the files "test,test2"
|
||||
Then I should see the file "test" was uploaded
|
||||
And I should see the file "test2" was uploaded
|
||||
And The url for the file "test2" is valid
|
||||
And The url for the file "test" is valid
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can update files
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the file "test"
|
||||
And I upload the file "test"
|
||||
Then I should see only one "test"
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can delete uploaded files
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the file "test"
|
||||
And I delete the file "test"
|
||||
Then I should not see the file "test" was uploaded
|
||||
And I see a confirmation that the file was deleted
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can download files
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the file "test"
|
||||
Then I can download the correct "test" file
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can download updated files
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the file "test"
|
||||
And I modify "test"
|
||||
And I reload the page
|
||||
And I upload the file "test"
|
||||
Then I can download the correct "test" file
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can lock assets through asset index
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload an asset
|
||||
And I lock the asset
|
||||
Then the asset is locked
|
||||
And I see a "saving" notification
|
||||
And I reload the page
|
||||
Then the asset is locked
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can unlock assets through asset index
|
||||
Given I have created a course with a locked asset
|
||||
When I unlock the asset
|
||||
Then the asset is unlocked
|
||||
And I see a "saving" notification
|
||||
And I reload the page
|
||||
Then the asset is unlocked
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Locked assets can't be viewed if logged in as an unregistered user
|
||||
Given I have created a course with a locked asset
|
||||
And the user "bob" exists
|
||||
When "bob" logs in
|
||||
Then the asset is protected
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Locked assets can be viewed if logged in as a registered user
|
||||
Given I have created a course with a locked asset
|
||||
And the user "bob" exists
|
||||
And the user "bob" is enrolled in the course
|
||||
When "bob" logs in
|
||||
Then the asset is viewable
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Locked assets can't be viewed if logged out
|
||||
Given I have created a course with a locked asset
|
||||
When I log out
|
||||
Then the asset is protected
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Locked assets can be viewed with is_staff account
|
||||
Given I have created a course with a locked asset
|
||||
And the user "staff" exists as a course is_staff
|
||||
When "staff" logs in
|
||||
Then the asset is viewable
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Unlocked assets can be viewed by anyone
|
||||
Given I have created a course with a unlocked asset
|
||||
When I log out
|
||||
Then the asset is viewable
|
||||
228
cms/djangoapps/contentstore/features/upload.py
Normal file
228
cms/djangoapps/contentstore/features/upload.py
Normal file
@@ -0,0 +1,228 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
from django.conf import settings
|
||||
import requests
|
||||
import string
|
||||
import random
|
||||
import os
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import CourseEnrollment
|
||||
from nose.tools import assert_equal, assert_not_equal
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
ASSET_NAMES_CSS = 'td.name-col > span.title > a.filename'
|
||||
|
||||
|
||||
@step(u'I go to the files and uploads page$')
|
||||
def go_to_uploads(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
uploads_css = 'li.nav-course-courseware-uploads a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(uploads_css)
|
||||
|
||||
|
||||
@step(u'I upload the( test)? file "([^"]*)"$')
|
||||
def upload_file(_step, is_test_file, file_name, button_text=None):
|
||||
if button_text:
|
||||
world.click_link(button_text)
|
||||
else:
|
||||
world.click_link('Upload New File')
|
||||
|
||||
if not is_test_file:
|
||||
_write_test_file(file_name, "test file")
|
||||
|
||||
# uploading the file itself
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
close_css = 'a.close-button'
|
||||
world.css_click(close_css)
|
||||
|
||||
|
||||
@step(u'I upload the file "([^"]*)" by clicking "([^"]*)"')
|
||||
def upload_file_on_button_press(_step, file_name, button_text=None):
|
||||
upload_file(_step, '', file_name, button_text)
|
||||
|
||||
|
||||
@step(u'I upload the files "([^"]*)"$')
|
||||
def upload_files(_step, files_string):
|
||||
# files_string should be comma separated with no spaces.
|
||||
files = files_string.split(",")
|
||||
upload_css = 'a.upload-button'
|
||||
world.css_click(upload_css)
|
||||
|
||||
# uploading the files
|
||||
for filename in files:
|
||||
_write_test_file(filename, "test file")
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', filename)
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
|
||||
close_css = 'a.close-button'
|
||||
world.css_click(close_css)
|
||||
|
||||
|
||||
@step(u'I should not see the file "([^"]*)" was uploaded$')
|
||||
def check_not_there(_step, file_name):
|
||||
# Either there are no files, or there are files but
|
||||
# not the one I expect not to exist.
|
||||
|
||||
# Since our only test for deletion right now deletes
|
||||
# the only file that was uploaded, our success criteria
|
||||
# will be that there are no files.
|
||||
# In the future we can refactor if necessary.
|
||||
assert world.is_css_not_present(ASSET_NAMES_CSS)
|
||||
|
||||
|
||||
@step(u'I should see the file "([^"]*)" was uploaded$')
|
||||
def check_upload(_step, file_name):
|
||||
index = get_index(file_name)
|
||||
assert_not_equal(index, -1)
|
||||
|
||||
|
||||
@step(u'The url for the file "([^"]*)" is valid$')
|
||||
def check_url(_step, file_name):
|
||||
r = get_file(file_name)
|
||||
assert_equal(r.status_code, 200)
|
||||
|
||||
|
||||
@step(u'I delete the file "([^"]*)"$')
|
||||
def delete_file(_step, file_name):
|
||||
index = get_index(file_name)
|
||||
assert index != -1
|
||||
delete_css = "a.remove-asset-button"
|
||||
world.css_click(delete_css, index=index)
|
||||
world.confirm_studio_prompt()
|
||||
|
||||
|
||||
@step(u'I should see only one "([^"]*)"$')
|
||||
def no_duplicate(_step, file_name):
|
||||
all_names = world.css_find(ASSET_NAMES_CSS)
|
||||
only_one = False
|
||||
for i in range(len(all_names)):
|
||||
if file_name == world.css_html(ASSET_NAMES_CSS, index=i):
|
||||
only_one = not only_one
|
||||
assert only_one
|
||||
|
||||
|
||||
@step(u'I can download the correct "([^"]*)" file$')
|
||||
def check_download(_step, file_name):
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
with open(os.path.abspath(path), 'r') as cur_file:
|
||||
cur_text = cur_file.read()
|
||||
r = get_file(file_name)
|
||||
downloaded_text = r.text
|
||||
assert cur_text == downloaded_text
|
||||
# resetting the file back to its original state
|
||||
_write_test_file(file_name, "This is an arbitrary file for testing uploads")
|
||||
|
||||
|
||||
def _write_test_file(file_name, text):
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
# resetting the file back to its original state
|
||||
with open(os.path.abspath(path), 'w') as cur_file:
|
||||
cur_file.write(text)
|
||||
|
||||
|
||||
@step(u'I modify "([^"]*)"$')
|
||||
def modify_upload(_step, file_name):
|
||||
new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10))
|
||||
_write_test_file(file_name, new_text)
|
||||
|
||||
|
||||
@step(u'I upload an asset$')
|
||||
def upload_an_asset(step):
|
||||
step.given('I upload the file "asset.html"')
|
||||
|
||||
|
||||
@step(u'I (lock|unlock) the asset$')
|
||||
def lock_unlock_file(_step, _lock_state):
|
||||
index = get_index('asset.html')
|
||||
assert index != -1, 'Expected to find an asset but could not.'
|
||||
|
||||
# Warning: this is a misnomer, it really only toggles the
|
||||
# lock state. TODO: fix it.
|
||||
lock_css = "input.lock-checkbox"
|
||||
world.css_find(lock_css)[index].click()
|
||||
|
||||
|
||||
@step(u'the user "([^"]*)" is enrolled in the course$')
|
||||
def user_foo_is_enrolled_in_the_course(step, name):
|
||||
world.create_user(name, 'test')
|
||||
user = User.objects.get(username=name)
|
||||
|
||||
course_id = world.scenario_dict['COURSE'].id
|
||||
CourseEnrollment.enroll(user, course_id)
|
||||
|
||||
|
||||
@step(u'Then the asset is (locked|unlocked)$')
|
||||
def verify_lock_unlock_file(_step, lock_state):
|
||||
index = get_index('asset.html')
|
||||
assert index != -1, 'Expected to find an asset but could not.'
|
||||
lock_css = "input.lock-checkbox"
|
||||
checked = world.css_find(lock_css)[index]._element.get_attribute('checked')
|
||||
assert_equal(lock_state == "locked", bool(checked))
|
||||
|
||||
|
||||
@step(u'I am at the files and upload page of a Studio course')
|
||||
def at_upload_page(step):
|
||||
step.given('I have opened a new course in studio')
|
||||
step.given('I go to the files and uploads page')
|
||||
|
||||
|
||||
@step(u'I have created a course with a (locked|unlocked) asset$')
|
||||
def open_course_with_locked(step, lock_state):
|
||||
step.given('I am at the files and upload page of a Studio course')
|
||||
step.given('I upload the file "asset.html"')
|
||||
|
||||
if lock_state == "locked":
|
||||
step.given('I lock the asset')
|
||||
step.given('I reload the page')
|
||||
|
||||
|
||||
@step(u'Then the asset is (viewable|protected)$')
|
||||
def view_asset(_step, status):
|
||||
asset_loc = world.scenario_dict['COURSE'].id.make_asset_key(asset_type='asset', path='asset.html')
|
||||
svr_loc = django_url()
|
||||
asset_url = unicode(asset_loc)
|
||||
divider = '/'
|
||||
if asset_url[0] == '/':
|
||||
divider = ''
|
||||
url = '{}{}{}'.format(svr_loc, divider, asset_url)
|
||||
if status == 'viewable':
|
||||
expected_text = 'test file'
|
||||
else:
|
||||
expected_text = 'Unauthorized'
|
||||
|
||||
# Note that world.visit would trigger a 403 error instead of displaying "Unauthorized"
|
||||
# Instead, we can drop back into the selenium driver get command.
|
||||
world.browser.driver.get(url)
|
||||
assert_equal(world.css_text('body'), expected_text)
|
||||
|
||||
|
||||
@step('I see a confirmation that the file was deleted$')
|
||||
def i_see_a_delete_confirmation(_step):
|
||||
alert_css = '#notification-confirmation'
|
||||
assert world.is_css_present(alert_css)
|
||||
|
||||
|
||||
def get_index(file_name):
|
||||
all_names = world.css_find(ASSET_NAMES_CSS)
|
||||
for i in range(len(all_names)):
|
||||
if file_name == world.css_html(ASSET_NAMES_CSS, index=i):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
def get_file(file_name):
|
||||
index = get_index(file_name)
|
||||
assert index != -1
|
||||
url_css = 'a.filename'
|
||||
|
||||
def get_url():
|
||||
return world.css_find(url_css)[index]._element.get_attribute('href')
|
||||
url = world.retry_on_exception(get_url)
|
||||
return requests.get(url)
|
||||
67
cms/djangoapps/contentstore/features/video.py
Normal file
67
cms/djangoapps/contentstore/features/video.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
from lettuce import world, step
|
||||
|
||||
SELECTORS = {
|
||||
'spinner': '.video-wrapper .spinner',
|
||||
'controls': 'section.video-controls',
|
||||
}
|
||||
|
||||
# We should wait 300 ms for event handler invocation + 200ms for safety.
|
||||
DELAY = 0.5
|
||||
|
||||
|
||||
@step('I have uploaded subtitles "([^"]*)"$')
|
||||
def i_have_uploaded_subtitles(_step, sub_id):
|
||||
_step.given('I go to the files and uploads page')
|
||||
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id.strip()))
|
||||
|
||||
|
||||
@step('I have created a Video component$')
|
||||
def i_created_a_video_component(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='video',
|
||||
)
|
||||
|
||||
world.wait_for_xmodule()
|
||||
world.disable_jquery_animations()
|
||||
|
||||
world.wait_for_present('.is-initialized')
|
||||
world.wait(DELAY)
|
||||
world.wait_for_invisible(SELECTORS['spinner'])
|
||||
if not world.youtube.config.get('youtube_api_blocked'):
|
||||
world.wait_for_visible(SELECTORS['controls'])
|
||||
|
||||
|
||||
@step('I have created a Video component with subtitles$')
|
||||
def i_created_a_video_with_subs(_step):
|
||||
_step.given('I have created a Video component with subtitles "3_yD_cEKoCk"')
|
||||
|
||||
|
||||
@step('I have created a Video component with subtitles "([^"]*)"$')
|
||||
def i_created_a_video_with_subs_with_name(_step, sub_id):
|
||||
_step.given('I have created a Video component')
|
||||
|
||||
# Store the current URL so we can return here
|
||||
video_url = world.browser.url
|
||||
|
||||
# Upload subtitles for the video using the upload interface
|
||||
_step.given('I have uploaded subtitles "{}"'.format(sub_id))
|
||||
|
||||
# Return to the video
|
||||
world.visit(video_url)
|
||||
|
||||
world.wait_for_xmodule()
|
||||
|
||||
# update .sub filed with proper subs name (which mimics real Studio/XML behavior)
|
||||
# this is needed only for that videos which are created in acceptance tests.
|
||||
_step.given('I edit the component')
|
||||
world.wait_for_ajax_complete()
|
||||
_step.given('I save changes')
|
||||
|
||||
world.disable_jquery_animations()
|
||||
|
||||
world.wait_for_present('.is-initialized')
|
||||
world.wait_for_invisible(SELECTORS['spinner'])
|
||||
183
cms/djangoapps/contentstore/git_export_utils.py
Normal file
183
cms/djangoapps/contentstore/git_export_utils.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Utilities for export a course's XML into a git repository,
|
||||
committing and pushing the changes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from urlparse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml_exporter import export_course_to_xml
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
GIT_REPO_EXPORT_DIR = getattr(settings, 'GIT_REPO_EXPORT_DIR', None)
|
||||
GIT_EXPORT_DEFAULT_IDENT = getattr(settings, 'GIT_EXPORT_DEFAULT_IDENT',
|
||||
{'name': 'STUDIO_EXPORT_TO_GIT',
|
||||
'email': 'STUDIO_EXPORT_TO_GIT@example.com'})
|
||||
|
||||
|
||||
class GitExportError(Exception):
|
||||
"""
|
||||
Convenience exception class for git export error conditions.
|
||||
"""
|
||||
|
||||
def __init__(self, message):
|
||||
# Force the lazy i18n values to turn into actual unicode objects
|
||||
super(GitExportError, self).__init__(unicode(message))
|
||||
|
||||
NO_EXPORT_DIR = _("GIT_REPO_EXPORT_DIR not set or path {0} doesn't exist, "
|
||||
"please create it, or configure a different path with "
|
||||
"GIT_REPO_EXPORT_DIR").format(GIT_REPO_EXPORT_DIR)
|
||||
URL_BAD = _('Non writable git url provided. Expecting something like:'
|
||||
' git@github.com:mitocw/edx4edx_lite.git')
|
||||
URL_NO_AUTH = _('If using http urls, you must provide the username '
|
||||
'and password in the url. Similar to '
|
||||
'https://user:pass@github.com/user/course.')
|
||||
DETACHED_HEAD = _('Unable to determine branch, repo in detached HEAD mode')
|
||||
CANNOT_PULL = _('Unable to update or clone git repository.')
|
||||
XML_EXPORT_FAIL = _('Unable to export course to xml.')
|
||||
CONFIG_ERROR = _('Unable to configure git username and password')
|
||||
CANNOT_COMMIT = _('Unable to commit changes. This is usually '
|
||||
'because there are no changes to be committed')
|
||||
CANNOT_PUSH = _('Unable to push changes. This is usually '
|
||||
'because the remote repository cannot be contacted')
|
||||
BAD_COURSE = _('Bad course location provided')
|
||||
MISSING_BRANCH = _('Missing branch on fresh clone')
|
||||
|
||||
|
||||
def cmd_log(cmd, cwd):
|
||||
"""
|
||||
Helper function to redirect stderr to stdout and log the command
|
||||
used along with the output. Will raise subprocess.CalledProcessError if
|
||||
command doesn't return 0, and returns the command's output.
|
||||
"""
|
||||
output = subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT)
|
||||
log.debug('Command was: {0!r}. '
|
||||
'Working directory was: {1!r}'.format(' '.join(cmd), cwd))
|
||||
log.debug('Command output was: {0!r}'.format(output))
|
||||
return output
|
||||
|
||||
|
||||
def export_to_git(course_id, repo, user='', rdir=None):
|
||||
"""Export a course to git."""
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
if not GIT_REPO_EXPORT_DIR:
|
||||
raise GitExportError(GitExportError.NO_EXPORT_DIR)
|
||||
|
||||
if not os.path.isdir(GIT_REPO_EXPORT_DIR):
|
||||
raise GitExportError(GitExportError.NO_EXPORT_DIR)
|
||||
|
||||
# Check for valid writable git url
|
||||
if not (repo.endswith('.git') or
|
||||
repo.startswith(('http:', 'https:', 'file:'))):
|
||||
raise GitExportError(GitExportError.URL_BAD)
|
||||
|
||||
# Check for username and password if using http[s]
|
||||
if repo.startswith('http:') or repo.startswith('https:'):
|
||||
parsed = urlparse(repo)
|
||||
if parsed.username is None or parsed.password is None:
|
||||
raise GitExportError(GitExportError.URL_NO_AUTH)
|
||||
if rdir:
|
||||
rdir = os.path.basename(rdir)
|
||||
else:
|
||||
rdir = repo.rsplit('/', 1)[-1].rsplit('.git', 1)[0]
|
||||
|
||||
log.debug("rdir = %s", rdir)
|
||||
|
||||
# Pull or clone repo before exporting to xml
|
||||
# and update url in case origin changed.
|
||||
rdirp = '{0}/{1}'.format(GIT_REPO_EXPORT_DIR, rdir)
|
||||
branch = None
|
||||
if os.path.exists(rdirp):
|
||||
log.info('Directory already exists, doing a git reset and pull '
|
||||
'instead of git clone.')
|
||||
cwd = rdirp
|
||||
# Get current branch
|
||||
cmd = ['git', 'symbolic-ref', '--short', 'HEAD']
|
||||
try:
|
||||
branch = cmd_log(cmd, cwd).strip('\n')
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Failed to get branch: %r', ex.output)
|
||||
raise GitExportError(GitExportError.DETACHED_HEAD)
|
||||
|
||||
cmds = [
|
||||
['git', 'remote', 'set-url', 'origin', repo],
|
||||
['git', 'fetch', 'origin'],
|
||||
['git', 'reset', '--hard', 'origin/{0}'.format(branch)],
|
||||
['git', 'pull'],
|
||||
['git', 'clean', '-d', '-f'],
|
||||
]
|
||||
else:
|
||||
cmds = [['git', 'clone', repo]]
|
||||
cwd = GIT_REPO_EXPORT_DIR
|
||||
|
||||
cwd = os.path.abspath(cwd)
|
||||
for cmd in cmds:
|
||||
try:
|
||||
cmd_log(cmd, cwd)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Failed to pull git repository: %r', ex.output)
|
||||
raise GitExportError(GitExportError.CANNOT_PULL)
|
||||
|
||||
# export course as xml before commiting and pushing
|
||||
root_dir = os.path.dirname(rdirp)
|
||||
course_dir = os.path.basename(rdirp).rsplit('.git', 1)[0]
|
||||
try:
|
||||
export_course_to_xml(modulestore(), contentstore(), course_id,
|
||||
root_dir, course_dir)
|
||||
except (EnvironmentError, AttributeError):
|
||||
log.exception('Failed export to xml')
|
||||
raise GitExportError(GitExportError.XML_EXPORT_FAIL)
|
||||
|
||||
# Get current branch if not already set
|
||||
if not branch:
|
||||
cmd = ['git', 'symbolic-ref', '--short', 'HEAD']
|
||||
try:
|
||||
branch = cmd_log(cmd, os.path.abspath(rdirp)).strip('\n')
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Failed to get branch from freshly cloned repo: %r',
|
||||
ex.output)
|
||||
raise GitExportError(GitExportError.MISSING_BRANCH)
|
||||
|
||||
# Now that we have fresh xml exported, set identity, add
|
||||
# everything to git, commit, and push to the right branch.
|
||||
ident = {}
|
||||
try:
|
||||
user = User.objects.get(username=user)
|
||||
ident['name'] = user.username
|
||||
ident['email'] = user.email
|
||||
except User.DoesNotExist:
|
||||
# That's ok, just use default ident
|
||||
ident = GIT_EXPORT_DEFAULT_IDENT
|
||||
time_stamp = timezone.now()
|
||||
cwd = os.path.abspath(rdirp)
|
||||
commit_msg = "Export from Studio at {time_stamp}".format(
|
||||
time_stamp=time_stamp,
|
||||
)
|
||||
try:
|
||||
cmd_log(['git', 'config', 'user.email', ident['email']], cwd)
|
||||
cmd_log(['git', 'config', 'user.name', ident['name']], cwd)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Error running git configure commands: %r', ex.output)
|
||||
raise GitExportError(GitExportError.CONFIG_ERROR)
|
||||
try:
|
||||
cmd_log(['git', 'add', '.'], cwd)
|
||||
cmd_log(['git', 'commit', '-a', '-m', commit_msg], cwd)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Unable to commit changes: %r', ex.output)
|
||||
raise GitExportError(GitExportError.CANNOT_COMMIT)
|
||||
try:
|
||||
cmd_log(['git', 'push', '-q', 'origin', branch], cwd)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Error running git push command: %r', ex.output)
|
||||
raise GitExportError(GitExportError.CANNOT_PUSH)
|
||||
0
cms/djangoapps/contentstore/management/__init__.py
Normal file
0
cms/djangoapps/contentstore/management/__init__.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
A single-use management command that provides an interactive way to remove
|
||||
erroneous certificate names.
|
||||
"""
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
Result = namedtuple("Result", ["course_key", "cert_name_short", "cert_name_long", "should_clean"])
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
A management command that provides an interactive way to remove erroneous cert_name_long and
|
||||
cert_name_short course attributes across both the Split and Mongo modulestores.
|
||||
"""
|
||||
help = 'Allows manual clean-up of invalid cert_name_short and cert_name_long entries on CourseModules'
|
||||
|
||||
def _mongo_results(self):
|
||||
"""
|
||||
Return Result objects for any mongo-modulestore backend course that has
|
||||
cert_name_short or cert_name_long set.
|
||||
"""
|
||||
# N.B. This code breaks many abstraction barriers. That's ok, because
|
||||
# it's a one-time cleanup command.
|
||||
# pylint: disable=protected-access
|
||||
mongo_modulestore = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo)
|
||||
old_mongo_courses = mongo_modulestore.collection.find({
|
||||
"_id.category": "course",
|
||||
"$or": [
|
||||
{"metadata.cert_name_short": {"$exists": 1}},
|
||||
{"metadata.cert_name_long": {"$exists": 1}},
|
||||
]
|
||||
}, {
|
||||
"_id": True,
|
||||
"metadata.cert_name_short": True,
|
||||
"metadata.cert_name_long": True,
|
||||
})
|
||||
|
||||
return [
|
||||
Result(
|
||||
mongo_modulestore.make_course_key(
|
||||
course['_id']['org'],
|
||||
course['_id']['course'],
|
||||
course['_id']['name'],
|
||||
),
|
||||
course['metadata'].get('cert_name_short'),
|
||||
course['metadata'].get('cert_name_long'),
|
||||
True
|
||||
) for course in old_mongo_courses
|
||||
]
|
||||
|
||||
def _split_results(self):
|
||||
"""
|
||||
Return Result objects for any split-modulestore backend course that has
|
||||
cert_name_short or cert_name_long set.
|
||||
"""
|
||||
# N.B. This code breaks many abstraction barriers. That's ok, because
|
||||
# it's a one-time cleanup command.
|
||||
# pylint: disable=protected-access
|
||||
split_modulestore = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split)
|
||||
active_version_collection = split_modulestore.db_connection.course_index
|
||||
structure_collection = split_modulestore.db_connection.structures
|
||||
|
||||
branches = active_version_collection.aggregate([{
|
||||
'$group': {
|
||||
'_id': 1,
|
||||
'draft': {'$push': '$versions.draft-branch'},
|
||||
'published': {'$push': '$versions.published-branch'}
|
||||
}
|
||||
}, {
|
||||
'$project': {
|
||||
'_id': 1,
|
||||
'branches': {'$setUnion': ['$draft', '$published']}
|
||||
}
|
||||
}])['result'][0]['branches']
|
||||
|
||||
structures = list(
|
||||
structure_collection.find({
|
||||
'_id': {'$in': branches},
|
||||
'blocks': {'$elemMatch': {
|
||||
'$and': [
|
||||
{"block_type": "course"},
|
||||
{'$or': [
|
||||
{'fields.cert_name_long': {'$exists': True}},
|
||||
{'fields.cert_name_short': {'$exists': True}}
|
||||
]}
|
||||
]
|
||||
}}
|
||||
}, {
|
||||
'_id': True,
|
||||
'blocks.fields.cert_name_long': True,
|
||||
'blocks.fields.cert_name_short': True,
|
||||
})
|
||||
)
|
||||
|
||||
structure_map = {struct['_id']: struct for struct in structures}
|
||||
structure_ids = [struct['_id'] for struct in structures]
|
||||
|
||||
split_mongo_courses = list(active_version_collection.find({
|
||||
'$or': [
|
||||
{"versions.draft-branch": {'$in': structure_ids}},
|
||||
{"versions.published": {'$in': structure_ids}},
|
||||
]
|
||||
}, {
|
||||
'org': True,
|
||||
'course': True,
|
||||
'run': True,
|
||||
'versions': True,
|
||||
}))
|
||||
|
||||
for course in split_mongo_courses:
|
||||
draft = course['versions'].get('draft-branch')
|
||||
if draft in structure_map:
|
||||
draft_fields = structure_map[draft]['blocks'][0].get('fields', {})
|
||||
else:
|
||||
draft_fields = {}
|
||||
|
||||
published = course['versions'].get('published')
|
||||
if published in structure_map:
|
||||
published_fields = structure_map[published]['blocks'][0].get('fields', {})
|
||||
else:
|
||||
published_fields = {}
|
||||
|
||||
for fields in (draft_fields, published_fields):
|
||||
for field in ('cert_name_short', 'cert_name_long'):
|
||||
if field in fields:
|
||||
course[field] = fields[field]
|
||||
|
||||
return [
|
||||
Result(
|
||||
split_modulestore.make_course_key(
|
||||
course['org'],
|
||||
course['course'],
|
||||
course['run'],
|
||||
),
|
||||
course.get('cert_name_short'),
|
||||
course.get('cert_name_long'),
|
||||
True
|
||||
) for course in split_mongo_courses
|
||||
]
|
||||
|
||||
def _display(self, results):
|
||||
"""
|
||||
Render a list of Result objects as a nicely formatted table.
|
||||
"""
|
||||
headers = ["Course Key", "cert_name_short", "cert_name_short", "Should clean?"]
|
||||
col_widths = [
|
||||
max(len(unicode(result[col])) for result in results + [headers])
|
||||
for col in range(len(results[0]))
|
||||
]
|
||||
id_format = "{{:>{}}} |".format(len(unicode(len(results))))
|
||||
col_format = "| {{:>{}}} |"
|
||||
|
||||
self.stdout.write(id_format.format(""), ending='')
|
||||
for header, width in zip(headers, col_widths):
|
||||
self.stdout.write(col_format.format(width).format(header), ending='')
|
||||
|
||||
self.stdout.write('')
|
||||
|
||||
for idx, result in enumerate(results):
|
||||
self.stdout.write(id_format.format(idx), ending='')
|
||||
for col, width in zip(result, col_widths):
|
||||
self.stdout.write(col_format.format(width).format(unicode(col)), ending='')
|
||||
self.stdout.write("")
|
||||
|
||||
def _commit(self, results):
|
||||
"""
|
||||
For each Result in ``results``, if ``should_clean`` is True, remove cert_name_long
|
||||
and cert_name_short from the course and save in the backing modulestore.
|
||||
"""
|
||||
for result in results:
|
||||
if not result.should_clean:
|
||||
continue
|
||||
course = modulestore().get_course(result.course_key)
|
||||
del course.cert_name_short
|
||||
del course.cert_name_long
|
||||
modulestore().update_item(course, ModuleStoreEnum.UserID.mgmt_command)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
results = self._mongo_results() + self._split_results()
|
||||
|
||||
self.stdout.write("Type the index of a row to toggle whether it will be cleaned, "
|
||||
"'commit' to remove all cert_name_short and cert_name_long values "
|
||||
"from any rows marked for cleaning, or 'quit' to quit.")
|
||||
|
||||
while True:
|
||||
self._display(results)
|
||||
command = raw_input("<index>|commit|quit: ").strip()
|
||||
|
||||
if command == 'quit':
|
||||
return
|
||||
elif command == 'commit':
|
||||
self._commit(results)
|
||||
return
|
||||
elif command == '':
|
||||
continue
|
||||
else:
|
||||
index = int(command)
|
||||
results[index] = results[index]._replace(should_clean=not results[index].should_clean)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Script for removing all redundant Mac OS metadata files (with filename ".DS_Store"
|
||||
or with filename which starts with "._") for all courses
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Remove all Mac OS related redundant files for all courses in contentstore
|
||||
"""
|
||||
help = 'Remove all Mac OS related redundant file/files for all courses in contentstore'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Execute the command
|
||||
"""
|
||||
content_store = contentstore()
|
||||
success = False
|
||||
|
||||
log.info(u"-" * 80)
|
||||
log.info(u"Cleaning up assets for all courses")
|
||||
try:
|
||||
# Remove all redundant Mac OS metadata files
|
||||
assets_deleted = content_store.remove_redundant_content_for_courses()
|
||||
success = True
|
||||
except Exception as err:
|
||||
log.info(u"=" * 30 + u"> failed to cleanup")
|
||||
log.info(u"Error:")
|
||||
log.info(err)
|
||||
|
||||
if success:
|
||||
log.info(u"=" * 80)
|
||||
log.info(u"Total number of assets deleted: {0}".format(assets_deleted))
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Script for cloning a course
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
|
||||
#
|
||||
# To run from command line: ./manage.py cms clone_course --settings=dev master/300/cough edx/111/foo
|
||||
#
|
||||
class Command(BaseCommand):
|
||||
"""Clone a MongoDB-backed course to another location"""
|
||||
help = 'Clone a MongoDB backed course to another location'
|
||||
|
||||
def course_key_from_arg(self, arg):
|
||||
"""
|
||||
Convert the command line arg into a course key
|
||||
"""
|
||||
try:
|
||||
return CourseKey.from_string(arg)
|
||||
except InvalidKeyError:
|
||||
return SlashSeparatedCourseKey.from_deprecated_string(arg)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) != 2:
|
||||
raise CommandError("clone requires 2 arguments: <source-course_id> <dest-course_id>")
|
||||
|
||||
source_course_id = self.course_key_from_arg(args[0])
|
||||
dest_course_id = self.course_key_from_arg(args[1])
|
||||
|
||||
mstore = modulestore()
|
||||
|
||||
print "Cloning course {0} to {1}".format(source_course_id, dest_course_id)
|
||||
|
||||
with mstore.bulk_operations(dest_course_id):
|
||||
if mstore.clone_course(source_course_id, dest_course_id, ModuleStoreEnum.UserID.mgmt_command):
|
||||
print "copying User permissions..."
|
||||
# purposely avoids auth.add_user b/c it doesn't have a caller to authorize
|
||||
CourseInstructorRole(dest_course_id).add_users(
|
||||
*CourseInstructorRole(source_course_id).users_with_role()
|
||||
)
|
||||
CourseStaffRole(dest_course_id).add_users(
|
||||
*CourseStaffRole(source_course_id).users_with_role()
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Django management command to create a course in a specific modulestore
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth.models import User
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from contentstore.views.course import create_new_course_in_store
|
||||
from contentstore.management.commands.utils import user_from_str
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Create a course in a specific modulestore.
|
||||
"""
|
||||
|
||||
# can this query modulestore for the list of write accessible stores or does that violate command pattern?
|
||||
help = "Create a course in one of {}".format([ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split])
|
||||
args = "modulestore user org course run"
|
||||
|
||||
def parse_args(self, *args):
|
||||
"""
|
||||
Return a tuple of passed in values for (modulestore, user, org, course, run).
|
||||
"""
|
||||
if len(args) != 5:
|
||||
raise CommandError(
|
||||
"create_course requires 5 arguments: "
|
||||
"a modulestore, user, org, course, run. Modulestore is one of {}".format(
|
||||
[ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split]
|
||||
)
|
||||
)
|
||||
|
||||
if args[0] not in [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split]:
|
||||
raise CommandError(
|
||||
"Modulestore (first arg) must be one of {}".format(
|
||||
[ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split]
|
||||
)
|
||||
)
|
||||
storetype = args[0]
|
||||
|
||||
try:
|
||||
user = user_from_str(args[1])
|
||||
except User.DoesNotExist:
|
||||
raise CommandError(
|
||||
"No user {user} found: expected args are {args}".format(
|
||||
user=args[1],
|
||||
args=self.args,
|
||||
),
|
||||
)
|
||||
|
||||
org = args[2]
|
||||
course = args[3]
|
||||
run = args[4]
|
||||
|
||||
return storetype, user, org, course, run
|
||||
|
||||
def handle(self, *args, **options):
|
||||
storetype, user, org, course, run = self.parse_args(*args)
|
||||
new_course = create_new_course_in_store(storetype, user, org, course, run, {})
|
||||
self.stdout.write(u"Created {}".format(unicode(new_course.id)))
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Command for deleting courses
|
||||
|
||||
Arguments:
|
||||
arg1 (str): Course key of the course to delete
|
||||
arg2 (str): 'commit'
|
||||
|
||||
Returns:
|
||||
none
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from .prompt import query_yes_no
|
||||
from contentstore.utils import delete_course_and_groups
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Delete a MongoDB backed course
|
||||
"""
|
||||
help = '''Delete a MongoDB backed course'''
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_key', help="ID of the course to delete.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
course_key = CourseKey.from_string(options['course_key'])
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid course_key: '%s'." % options['course_key'])
|
||||
|
||||
if not modulestore().get_course(course_key):
|
||||
raise CommandError("Course with '%s' key not found." % options['course_key'])
|
||||
|
||||
print 'Going to delete the %s course from DB....' % options['course_key']
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(course_key), default="no"):
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
delete_course_and_groups(course_key, ModuleStoreEnum.UserID.mgmt_command)
|
||||
print "Deleted course {}".format(course_key)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Script for deleting orphans"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from contentstore.views.item import _delete_orphans
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Command for deleting orphans"""
|
||||
help = '''
|
||||
Delete orphans from a MongoDB backed course. Takes two arguments:
|
||||
<course_id>: the course id of the course whose orphans you want to delete
|
||||
|--commit|: optional argument. If not provided, will dry run delete
|
||||
'''
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_id')
|
||||
parser.add_argument('--commit', action='store_true', help='Commit to deleting the orphans')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
course_key = CourseKey.from_string(options['course_id'])
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid course key.")
|
||||
|
||||
if options['commit']:
|
||||
print 'Deleting orphans from the course:'
|
||||
deleted_items = _delete_orphans(
|
||||
course_key, ModuleStoreEnum.UserID.mgmt_command, options['commit']
|
||||
)
|
||||
print "Success! Deleted the following orphans from the course:"
|
||||
print "\n".join(deleted_items)
|
||||
else:
|
||||
print 'Dry run. The following orphans would have been deleted from the course:'
|
||||
deleted_items = _delete_orphans(
|
||||
course_key, ModuleStoreEnum.UserID.mgmt_command, options['commit']
|
||||
)
|
||||
print "\n".join(deleted_items)
|
||||
@@ -0,0 +1,98 @@
|
||||
###
|
||||
### Script for editing the course's tabs
|
||||
###
|
||||
|
||||
#
|
||||
# Run it this way:
|
||||
# ./manage.py cms --settings dev edit_course_tabs --course Stanford/CS99/2013_spring
|
||||
#
|
||||
from optparse import make_option
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from .prompt import query_yes_no
|
||||
|
||||
from courseware.courses import get_course_by_id
|
||||
|
||||
from contentstore.views import tabs
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
|
||||
def print_course(course):
|
||||
"Prints out the course id and a numbered list of tabs."
|
||||
print course.id
|
||||
print 'num type name'
|
||||
for index, item in enumerate(course.tabs):
|
||||
print index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"'
|
||||
|
||||
|
||||
# course.tabs looks like this
|
||||
# [{u'type': u'courseware'}, {u'type': u'course_info', u'name': u'Course Info'}, {u'type': u'textbooks'},
|
||||
# {u'type': u'discussion', u'name': u'Discussion'}, {u'type': u'wiki', u'name': u'Wiki'},
|
||||
# {u'type': u'progress', u'name': u'Progress'}]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """See and edit a course's tabs list. Only supports insertion
|
||||
and deletion. Move and rename etc. can be done with a delete
|
||||
followed by an insert. The tabs are numbered starting with 1.
|
||||
Tabs 1 and 2 cannot be changed, and tabs of type static_tab cannot
|
||||
be edited (use Studio for those).
|
||||
|
||||
As a first step, run the command with a courseid like this:
|
||||
--course Stanford/CS99/2013_spring
|
||||
This will print the existing tabs types and names. Then run the
|
||||
command again, adding --insert or --delete to edit the list.
|
||||
"""
|
||||
# Making these option objects separately, so can refer to their .help below
|
||||
course_option = make_option('--course',
|
||||
action='store',
|
||||
dest='course',
|
||||
default=False,
|
||||
help='--course <id> required, e.g. Stanford/CS99/2013_spring')
|
||||
delete_option = make_option('--delete',
|
||||
action='store_true',
|
||||
dest='delete',
|
||||
default=False,
|
||||
help='--delete <tab-number>')
|
||||
insert_option = make_option('--insert',
|
||||
action='store_true',
|
||||
dest='insert',
|
||||
default=False,
|
||||
help='--insert <tab-number> <type> <name>, e.g. 2 "course_info" "Course Info"')
|
||||
|
||||
option_list = BaseCommand.option_list + (course_option, delete_option, insert_option)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not options['course']:
|
||||
raise CommandError(Command.course_option.help)
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(options['course'])
|
||||
except InvalidKeyError:
|
||||
course_key = SlashSeparatedCourseKey.from_deprecated_string(options['course'])
|
||||
|
||||
course = get_course_by_id(course_key)
|
||||
|
||||
print 'Warning: this command directly edits the list of course tabs in mongo.'
|
||||
print 'Tabs before any changes:'
|
||||
print_course(course)
|
||||
|
||||
try:
|
||||
if options['delete']:
|
||||
if len(args) != 1:
|
||||
raise CommandError(Command.delete_option.help)
|
||||
num = int(args[0])
|
||||
if query_yes_no('Deleting tab {0} Confirm?'.format(num), default='no'):
|
||||
tabs.primitive_delete(course, num - 1) # -1 for 0-based indexing
|
||||
elif options['insert']:
|
||||
if len(args) != 3:
|
||||
raise CommandError(Command.insert_option.help)
|
||||
num = int(args[0])
|
||||
tab_type = args[1]
|
||||
name = args[2]
|
||||
if query_yes_no('Inserting tab {0} "{1}" "{2}" Confirm?'.format(num, tab_type, name), default='no'):
|
||||
tabs.primitive_insert(course, num - 1, tab_type, name) # -1 as above
|
||||
except ValueError as e:
|
||||
# Cute: translate to CommandError so the CLI error prints nicely.
|
||||
raise CommandError(e)
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.contentstore.utils import empty_asset_trashcan
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from .prompt import query_yes_no
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("empty_asset_trashcan requires one or no arguments: |<course_id>|")
|
||||
|
||||
if len(args) == 1:
|
||||
try:
|
||||
course_key = CourseKey.from_string(args[0])
|
||||
except InvalidKeyError:
|
||||
course_key = SlashSeparatedCourseKey.from_deprecated_string(args[0])
|
||||
|
||||
course_ids = [course_key]
|
||||
else:
|
||||
course_ids = [course.id for course in modulestore().get_courses()]
|
||||
|
||||
if query_yes_no("Emptying trashcan. Confirm?", default="no"):
|
||||
empty_asset_trashcan(course_ids)
|
||||
48
cms/djangoapps/contentstore/management/commands/export.py
Normal file
48
cms/djangoapps/contentstore/management/commands/export.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Script for exporting courseware from Mongo to a tar.gz file
|
||||
"""
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_exporter import export_course_to_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Export the specified data directory into the default ModuleStore
|
||||
"""
|
||||
help = 'Export the specified data directory into the default ModuleStore'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_id')
|
||||
parser.add_argument('output_path')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute the command"""
|
||||
try:
|
||||
course_key = CourseKey.from_string(options['course_id'])
|
||||
except InvalidKeyError:
|
||||
try:
|
||||
course_key = SlashSeparatedCourseKey.from_deprecated_string(options['course_id'])
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid course_key: '%s'." % options['course_id'])
|
||||
|
||||
if not modulestore().get_course(course_key):
|
||||
raise CommandError("Course with %s key not found." % options['course_id'])
|
||||
|
||||
output_path = options['output_path']
|
||||
|
||||
print "Exporting course id = {0} to {1}".format(course_key, output_path)
|
||||
|
||||
if not output_path.endswith('/'):
|
||||
output_path += '/'
|
||||
|
||||
root_dir = os.path.dirname(output_path)
|
||||
course_dir = os.path.splitext(os.path.basename(output_path))[0]
|
||||
|
||||
export_course_to_xml(modulestore(), contentstore(), course_key, root_dir, course_dir)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Script for exporting all courseware from Mongo to a directory and listing the courses which failed to export
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_exporter import export_course_to_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Export all courses from mongo to the specified data directory and list the courses which failed to export
|
||||
"""
|
||||
help = 'Export all courses from mongo to the specified data directory and list the courses which failed to export'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Execute the command
|
||||
"""
|
||||
if len(args) != 1:
|
||||
raise CommandError("export requires one argument: <output path>")
|
||||
|
||||
output_path = args[0]
|
||||
courses, failed_export_courses = export_courses_to_output_path(output_path)
|
||||
|
||||
print "=" * 80
|
||||
print u"=" * 30 + u"> Export summary"
|
||||
print u"Total number of courses to export: {0}".format(len(courses))
|
||||
print u"Total number of courses which failed to export: {0}".format(len(failed_export_courses))
|
||||
print u"List of export failed courses ids:"
|
||||
print u"\n".join(failed_export_courses)
|
||||
print "=" * 80
|
||||
|
||||
|
||||
def export_courses_to_output_path(output_path):
|
||||
"""
|
||||
Export all courses to target directory and return the list of courses which failed to export
|
||||
"""
|
||||
content_store = contentstore()
|
||||
module_store = modulestore()
|
||||
root_dir = output_path
|
||||
courses = module_store.get_courses()
|
||||
|
||||
course_ids = [x.id for x in courses]
|
||||
failed_export_courses = []
|
||||
|
||||
for course_id in course_ids:
|
||||
print u"-" * 80
|
||||
print u"Exporting course id = {0} to {1}".format(course_id, output_path)
|
||||
try:
|
||||
course_dir = course_id.to_deprecated_string().replace('/', '...')
|
||||
export_course_to_xml(module_store, content_store, course_id, root_dir, course_dir)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
failed_export_courses.append(unicode(course_id))
|
||||
print u"=" * 30 + u"> Oops, failed to export {0}".format(course_id)
|
||||
print u"Error:"
|
||||
print err
|
||||
|
||||
return courses, failed_export_courses
|
||||
111
cms/djangoapps/contentstore/management/commands/export_olx.py
Normal file
111
cms/djangoapps/contentstore/management/commands/export_olx.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
A Django command that exports a course to a tar.gz file.
|
||||
|
||||
If <filename> is '-', it pipes the file to stdout.
|
||||
|
||||
This is used by Analytics research exports to provide researchers
|
||||
with course content.
|
||||
|
||||
At present, it differs from Studio exports in several ways:
|
||||
|
||||
* It does not include static content.
|
||||
* The top-level directory in the resulting tarball is a "safe"
|
||||
(i.e. ascii) version of the course_key, rather than the word "course".
|
||||
* It only supports the export of courses. It does not export libraries.
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
from tempfile import mktemp, mkdtemp
|
||||
from textwrap import dedent
|
||||
|
||||
from path import Path as path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml_exporter import export_course_to_xml
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Export a course to XML. The output is compressed as a tar.gz file.
|
||||
|
||||
"""
|
||||
help = dedent(__doc__).strip()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_id')
|
||||
parser.add_argument('--output', default=None)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
course_id = options['course_id']
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Unparsable course_id")
|
||||
except IndexError:
|
||||
raise CommandError("Insufficient arguments")
|
||||
|
||||
filename = options['output']
|
||||
pipe_results = False
|
||||
if filename is None:
|
||||
filename = mktemp()
|
||||
pipe_results = True
|
||||
|
||||
export_course_to_tarfile(course_key, filename)
|
||||
|
||||
results = self._get_results(filename) if pipe_results else None
|
||||
|
||||
self.stdout.write(results, ending="")
|
||||
|
||||
def _get_results(self, filename):
|
||||
"""Load results from file"""
|
||||
with open(filename) as f:
|
||||
results = f.read()
|
||||
os.remove(filename)
|
||||
return results
|
||||
|
||||
|
||||
def export_course_to_tarfile(course_key, filename):
|
||||
"""Exports a course into a tar.gz file"""
|
||||
tmp_dir = mkdtemp()
|
||||
try:
|
||||
course_dir = export_course_to_directory(course_key, tmp_dir)
|
||||
compress_directory(course_dir, filename)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def export_course_to_directory(course_key, root_dir):
|
||||
"""Export course into a directory"""
|
||||
store = modulestore()
|
||||
course = store.get_course(course_key)
|
||||
if course is None:
|
||||
raise CommandError("Invalid course_id")
|
||||
|
||||
# The safest characters are A-Z, a-z, 0-9, <underscore>, <period> and <hyphen>.
|
||||
# We represent the first four with \w.
|
||||
# TODO: Once we support courses with unicode characters, we will need to revisit this.
|
||||
replacement_char = u'-'
|
||||
course_dir = replacement_char.join([course.id.org, course.id.course, course.id.run])
|
||||
course_dir = re.sub(r'[^\w\.\-]', replacement_char, course_dir)
|
||||
|
||||
export_course_to_xml(store, None, course.id, root_dir, course_dir)
|
||||
|
||||
export_dir = path(root_dir) / course_dir
|
||||
return export_dir
|
||||
|
||||
|
||||
def compress_directory(directory, filename):
|
||||
"""Compress a directory into a tar.gz file"""
|
||||
mode = 'w:gz'
|
||||
name = path(directory).name
|
||||
with tarfile.open(filename, mode) as tar_file:
|
||||
tar_file.add(directory, arcname=name)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Script for fixing the item not found errors in a course
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
# To run from command line: ./manage.py cms fix_not_found course-v1:org+course+run
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Fix a course's item not found errors"""
|
||||
help = "Fix a course's ItemNotFound errors"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_id')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute the command"""
|
||||
course_id = options.get('course_id', None)
|
||||
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
# for now only support on split mongo
|
||||
# pylint: disable=protected-access
|
||||
owning_store = modulestore()._get_modulestore_for_courselike(course_key)
|
||||
if hasattr(owning_store, 'fix_not_found'):
|
||||
owning_store.fix_not_found(course_key, ModuleStoreEnum.UserID.mgmt_command)
|
||||
else:
|
||||
raise CommandError("The owning modulestore does not support this command.")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Script for force publishing a course
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from .prompt import query_yes_no
|
||||
from .utils import get_course_versions
|
||||
|
||||
# To run from command line: ./manage.py cms force_publish course-v1:org+course+run
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Force publish a course"""
|
||||
help = '''
|
||||
Force publish a course. Takes two arguments:
|
||||
<course_id>: the course id of the course you want to publish forcefully
|
||||
--commit: do the force publish
|
||||
|
||||
If you do not specify '--commit', the command will print out what changes would be made.
|
||||
'''
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_key', help="ID of the Course to force publish")
|
||||
parser.add_argument('--commit', action='store_true', help="Pull updated metadata from external IDPs")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute the command"""
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(options['course_key'])
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid course key.")
|
||||
|
||||
if not modulestore().get_course(course_key):
|
||||
raise CommandError("Course not found.")
|
||||
|
||||
# for now only support on split mongo
|
||||
owning_store = modulestore()._get_modulestore_for_courselike(course_key) # pylint: disable=protected-access
|
||||
if hasattr(owning_store, 'force_publish_course'):
|
||||
versions = get_course_versions(options['course_key'])
|
||||
print "Course versions : {0}".format(versions)
|
||||
|
||||
if options['commit']:
|
||||
if query_yes_no("Are you sure to publish the {0} course forcefully?".format(course_key), default="no"):
|
||||
# publish course forcefully
|
||||
updated_versions = owning_store.force_publish_course(
|
||||
course_key, ModuleStoreEnum.UserID.mgmt_command, options['commit']
|
||||
)
|
||||
if updated_versions:
|
||||
# if publish and draft were different
|
||||
if versions['published-branch'] != versions['draft-branch']:
|
||||
print "Success! Published the course '{0}' forcefully.".format(course_key)
|
||||
print "Updated course versions : \n{0}".format(updated_versions)
|
||||
else:
|
||||
print "Course '{0}' is already in published state.".format(course_key)
|
||||
else:
|
||||
print "Error! Could not publish course {0}.".format(course_key)
|
||||
else:
|
||||
# if publish and draft were different
|
||||
if versions['published-branch'] != versions['draft-branch']:
|
||||
print "Dry run. Following would have been changed : "
|
||||
print "Published branch version {0} changed to draft branch version {1}".format(
|
||||
versions['published-branch'], versions['draft-branch']
|
||||
)
|
||||
else:
|
||||
print "Dry run. Course '{0}' is already in published state.".format(course_key)
|
||||
else:
|
||||
raise CommandError("The owning modulestore does not support this command.")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
This command exports a course from CMS to a git repository.
|
||||
It takes as arguments the course id to export (i.e MITx/999/2020 ) and
|
||||
the repository to commit too. It takes username as an option for identifying
|
||||
the commit, as well as a directory path to place the git repository.
|
||||
|
||||
By default it will use settings.GIT_REPO_EXPORT_DIR/repo_name as the cloned
|
||||
directory. It is branch aware, but will reset all local changes to the
|
||||
repository before attempting to export the XML, add, and commit changes if
|
||||
any have taken place.
|
||||
|
||||
This functionality is also available as an export view in studio if the giturl
|
||||
attribute is set and the FEATURE['ENABLE_EXPORT_GIT'] is set.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from optparse import make_option
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
import contentstore.git_export_utils as git_export_utils
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from contentstore.git_export_utils import GitExportError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Take a course from studio and export it to a git repository.
|
||||
"""
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--username', '-u', dest='user',
|
||||
help=('Specify a username from LMS/Studio to be used '
|
||||
'as the commit author.')),
|
||||
make_option('--repo_dir', '-r', dest='repo',
|
||||
help='Specify existing git repo directory.'),
|
||||
)
|
||||
|
||||
help = _('Take the specified course and attempt to '
|
||||
'export it to a git repository\n. Course directory '
|
||||
'must already be a git repository. Usage: '
|
||||
' git_export <course_loc> <git_url>')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Checks arguments and runs export function if they are good
|
||||
"""
|
||||
|
||||
if len(args) != 2:
|
||||
raise CommandError('This script requires exactly two arguments: '
|
||||
'course_loc and git_url')
|
||||
|
||||
# Rethrow GitExportError as CommandError for SystemExit
|
||||
try:
|
||||
course_key = CourseKey.from_string(args[0])
|
||||
except InvalidKeyError:
|
||||
try:
|
||||
course_key = SlashSeparatedCourseKey.from_deprecated_string(args[0])
|
||||
except InvalidKeyError:
|
||||
raise CommandError(unicode(GitExportError.BAD_COURSE))
|
||||
|
||||
try:
|
||||
git_export_utils.export_to_git(
|
||||
course_key,
|
||||
args[1],
|
||||
options.get('user', ''),
|
||||
options.get('rdir', None)
|
||||
)
|
||||
except git_export_utils.GitExportError as ex:
|
||||
raise CommandError(unicode(ex.message))
|
||||
55
cms/djangoapps/contentstore/management/commands/import.py
Normal file
55
cms/djangoapps/contentstore/management/commands/import.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Script for importing courseware from XML format
|
||||
"""
|
||||
from optparse import make_option
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django_comment_common.utils import (seed_permissions_roles,
|
||||
are_permissions_roles_seeded)
|
||||
from xmodule.modulestore.xml_importer import import_course_from_xml
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Import the specified data directory into the default ModuleStore
|
||||
"""
|
||||
help = 'Import the specified data directory into the default ModuleStore'
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--nostatic',
|
||||
action='store_true',
|
||||
help='Skip import of static content'),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) == 0:
|
||||
raise CommandError("import requires at least one argument: <data directory> [--nostatic] [<course dir>...]")
|
||||
|
||||
data_dir = args[0]
|
||||
do_import_static = not options.get('nostatic', False)
|
||||
if len(args) > 1:
|
||||
source_dirs = args[1:]
|
||||
else:
|
||||
source_dirs = None
|
||||
self.stdout.write("Importing. Data_dir={data}, source_dirs={courses}\n".format(
|
||||
data=data_dir,
|
||||
courses=source_dirs,
|
||||
))
|
||||
mstore = modulestore()
|
||||
|
||||
course_items = import_course_from_xml(
|
||||
mstore, ModuleStoreEnum.UserID.mgmt_command, data_dir, source_dirs, load_error_modules=False,
|
||||
static_content_store=contentstore(), verbose=True,
|
||||
do_import_static=do_import_static,
|
||||
create_if_not_present=True,
|
||||
)
|
||||
|
||||
for course in course_items:
|
||||
course_id = course.id
|
||||
if not are_permissions_roles_seeded(course_id):
|
||||
self.stdout.write('Seeding forum roles for course {0}\n'.format(course_id))
|
||||
seed_permissions_roles(course_id)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Django management command to migrate a course from the old Mongo modulestore
|
||||
to the new split-Mongo modulestore.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth.models import User
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.split_migrator import SplitMigrator
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from contentstore.management.commands.utils import user_from_str
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Migrate a course from old-Mongo to split-Mongo. It reuses the old course id except where overridden.
|
||||
"""
|
||||
|
||||
help = "Migrate a course from old-Mongo to split-Mongo. The new org, course, and run will default to the old one unless overridden"
|
||||
args = "course_key email <new org> <new course> <new run>"
|
||||
|
||||
def parse_args(self, *args):
|
||||
"""
|
||||
Return a 5-tuple of passed in values for (course_key, user, org, course, run).
|
||||
"""
|
||||
if len(args) < 2:
|
||||
raise CommandError(
|
||||
"migrate_to_split requires at least two arguments: "
|
||||
"a course_key and a user identifier (email or ID)"
|
||||
)
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(args[0])
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid location string")
|
||||
|
||||
try:
|
||||
user = user_from_str(args[1])
|
||||
except User.DoesNotExist:
|
||||
raise CommandError("No user found identified by {}".format(args[1]))
|
||||
|
||||
org = course = run = None
|
||||
try:
|
||||
org = args[2]
|
||||
course = args[3]
|
||||
run = args[4]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
return course_key, user.id, org, course, run
|
||||
|
||||
def handle(self, *args, **options):
|
||||
course_key, user, org, course, run = self.parse_args(*args)
|
||||
|
||||
migrator = SplitMigrator(
|
||||
source_modulestore=modulestore(),
|
||||
split_modulestore=modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split),
|
||||
)
|
||||
|
||||
migrator.migrate_mongo_course(course_key, user, org, course, run)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Script for granting existing course instructors course creator privileges.
|
||||
|
||||
This script is only intended to be run once on a given environment.
|
||||
"""
|
||||
from course_creators.views import add_user_with_status_granted, add_user_with_status_unrequested
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.utils import IntegrityError
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole
|
||||
|
||||
#------------ to run: ./manage.py cms populate_creators --settings=dev
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Script for granting existing course instructors course creator privileges.
|
||||
"""
|
||||
help = 'Grants all users with INSTRUCTOR role permission to create courses'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
The logic of the command.
|
||||
"""
|
||||
username = 'populate_creators_command'
|
||||
email = 'grant+creator+access@edx.org'
|
||||
try:
|
||||
admin = User.objects.create_user(username, email, 'foo')
|
||||
admin.is_staff = True
|
||||
admin.save()
|
||||
except IntegrityError:
|
||||
# If the script did not complete the last time it was run,
|
||||
# the admin user will already exist.
|
||||
admin = User.objects.get(username=username, email=email)
|
||||
|
||||
for user in get_users_with_role(CourseInstructorRole.ROLE):
|
||||
add_user_with_status_granted(admin, user)
|
||||
|
||||
# Some users will be both staff and instructors. Those folks have been
|
||||
# added with status granted above, and add_user_with_status_unrequested
|
||||
# will not try to add them again if they already exist in the course creator database.
|
||||
for user in get_users_with_role(CourseStaffRole.ROLE):
|
||||
add_user_with_status_unrequested(user)
|
||||
|
||||
# There could be users who are not in either staff or instructor (they've
|
||||
# never actually done anything in Studio). I plan to add those as unrequested
|
||||
# when they first go to their dashboard.
|
||||
|
||||
admin.delete()
|
||||
|
||||
|
||||
#=============================================================================================================
|
||||
# Because these are expensive and far-reaching, I moved them here
|
||||
def get_users_with_role(role_prefix):
|
||||
"""
|
||||
An expensive operation which finds all users in the db with the given role prefix
|
||||
"""
|
||||
return User.objects.filter(groups__name__startswith=role_prefix)
|
||||
38
cms/djangoapps/contentstore/management/commands/prompt.py
Normal file
38
cms/djangoapps/contentstore/management/commands/prompt.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import sys
|
||||
|
||||
|
||||
def query_yes_no(question, default="yes"):
|
||||
"""Ask a yes/no question via raw_input() and return their answer.
|
||||
|
||||
"question" is a string that is presented to the user.
|
||||
"default" is the presumed answer if the user just hits <Enter>.
|
||||
It must be "yes" (the default), "no" or None (meaning
|
||||
an answer is required of the user).
|
||||
|
||||
The "answer" return value is one of "yes" or "no".
|
||||
"""
|
||||
valid = {
|
||||
"yes": True,
|
||||
"y": True,
|
||||
"ye": True,
|
||||
"no": False,
|
||||
"n": False,
|
||||
}
|
||||
if default is None:
|
||||
prompt = " [y/n] "
|
||||
elif default == "yes":
|
||||
prompt = " [Y/n] "
|
||||
elif default == "no":
|
||||
prompt = " [y/N] "
|
||||
else:
|
||||
raise ValueError("invalid default answer: '%s'" % default)
|
||||
|
||||
while True:
|
||||
sys.stdout.write(question + prompt)
|
||||
choice = raw_input().lower()
|
||||
if default is not None and choice == '':
|
||||
return valid[default]
|
||||
elif choice in valid:
|
||||
return valid[choice]
|
||||
else:
|
||||
sys.stdout.write("Please respond with 'yes' or 'no' (or 'y' or 'n').\n")
|
||||
@@ -0,0 +1,115 @@
|
||||
""" Management command to update courses' search index """
|
||||
import logging
|
||||
from django.core.management import BaseCommand, CommandError
|
||||
from optparse import make_option
|
||||
from textwrap import dedent
|
||||
|
||||
from contentstore.courseware_index import CoursewareSearchIndexer
|
||||
from search.search_engine_base import SearchEngine
|
||||
from elasticsearch import exceptions
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from .prompt import query_yes_no
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Command to re-index courses
|
||||
|
||||
Examples:
|
||||
|
||||
./manage.py reindex_course <course_id_1> <course_id_2> - reindexes courses with keys course_id_1 and course_id_2
|
||||
./manage.py reindex_course --all - reindexes all available courses
|
||||
./manage.py reindex_course --setup - reindexes all courses for devstack setup
|
||||
"""
|
||||
help = dedent(__doc__)
|
||||
|
||||
can_import_settings = True
|
||||
|
||||
args = "<course_id course_id ...>"
|
||||
|
||||
all_option = make_option('--all',
|
||||
action='store_true',
|
||||
dest='all',
|
||||
default=False,
|
||||
help='Reindex all courses')
|
||||
|
||||
setup_option = make_option('--setup',
|
||||
action='store_true',
|
||||
dest='setup',
|
||||
default=False,
|
||||
help='Reindex all courses on developers stack setup')
|
||||
|
||||
option_list = BaseCommand.option_list + (all_option, setup_option)
|
||||
|
||||
CONFIRMATION_PROMPT = u"Re-indexing all courses might be a time consuming operation. Do you want to continue?"
|
||||
|
||||
def _parse_course_key(self, raw_value):
|
||||
""" Parses course key from string """
|
||||
try:
|
||||
result = CourseKey.from_string(raw_value)
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid course_key: '%s'." % raw_value)
|
||||
|
||||
if not isinstance(result, CourseLocator):
|
||||
raise CommandError(u"Argument {0} is not a course key".format(raw_value))
|
||||
|
||||
return result
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
By convention set by Django developers, this method actually executes command's actions.
|
||||
So, there could be no better docstring than emphasize this once again.
|
||||
"""
|
||||
all_option = options.get('all', False)
|
||||
setup_option = options.get('setup', False)
|
||||
index_all_courses_option = all_option or setup_option
|
||||
|
||||
if len(args) == 0 and not index_all_courses_option:
|
||||
raise CommandError(u"reindex_course requires one or more arguments: <course_id>")
|
||||
|
||||
store = modulestore()
|
||||
|
||||
if index_all_courses_option:
|
||||
index_name = CoursewareSearchIndexer.INDEX_NAME
|
||||
doc_type = CoursewareSearchIndexer.DOCUMENT_TYPE
|
||||
if setup_option:
|
||||
try:
|
||||
# try getting the ElasticSearch engine
|
||||
searcher = SearchEngine.get_search_engine(index_name)
|
||||
except exceptions.ElasticsearchException as exc:
|
||||
logging.exception('Search Engine error - %s', unicode(exc))
|
||||
return
|
||||
|
||||
index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access
|
||||
doc_type_exists = searcher._es.indices.exists_type( # pylint: disable=protected-access
|
||||
index=index_name,
|
||||
doc_type=doc_type
|
||||
)
|
||||
|
||||
index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access
|
||||
index=index_name,
|
||||
doc_type=doc_type
|
||||
) if index_exists and doc_type_exists else {}
|
||||
|
||||
if index_exists and index_mapping:
|
||||
return
|
||||
|
||||
# if reindexing is done during devstack setup step, don't prompt the user
|
||||
if setup_option or query_yes_no(self.CONFIRMATION_PROMPT, default="no"):
|
||||
# in case of --setup or --all, get the list of course keys from all courses
|
||||
# that are stored in the modulestore
|
||||
course_keys = [course.id for course in modulestore().get_courses()]
|
||||
else:
|
||||
return
|
||||
else:
|
||||
# in case course keys are provided as arguments
|
||||
course_keys = map(self._parse_course_key, args)
|
||||
|
||||
for course_key in course_keys:
|
||||
CoursewareSearchIndexer.do_course_reindex(store, course_key)
|
||||
@@ -0,0 +1,75 @@
|
||||
""" Management command to update libraries' search index """
|
||||
from django.core.management import BaseCommand, CommandError
|
||||
from optparse import make_option
|
||||
from textwrap import dedent
|
||||
|
||||
from contentstore.courseware_index import LibrarySearchIndexer
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
|
||||
from .prompt import query_yes_no
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Command to reindex content libraries (single, multiple or all available)
|
||||
|
||||
Examples:
|
||||
|
||||
./manage.py reindex_library lib1 lib2 - reindexes libraries with keys lib1 and lib2
|
||||
./manage.py reindex_library --all - reindexes all available libraries
|
||||
"""
|
||||
help = dedent(__doc__)
|
||||
|
||||
can_import_settings = True
|
||||
|
||||
args = "<library_id library_id ...>"
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option(
|
||||
'--all',
|
||||
action='store_true',
|
||||
dest='all',
|
||||
default=False,
|
||||
help='Reindex all libraries'
|
||||
),)
|
||||
|
||||
CONFIRMATION_PROMPT = u"Reindexing all libraries might be a time consuming operation. Do you want to continue?"
|
||||
|
||||
def _parse_library_key(self, raw_value):
|
||||
""" Parses library key from string """
|
||||
try:
|
||||
result = CourseKey.from_string(raw_value)
|
||||
except InvalidKeyError:
|
||||
result = SlashSeparatedCourseKey.from_deprecated_string(raw_value)
|
||||
|
||||
if not isinstance(result, LibraryLocator):
|
||||
raise CommandError(u"Argument {0} is not a library key".format(raw_value))
|
||||
|
||||
return result
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
By convention set by django developers, this method actually executes command's actions.
|
||||
So, there could be no better docstring than emphasize this once again.
|
||||
"""
|
||||
if len(args) == 0 and not options.get('all', False):
|
||||
raise CommandError(u"reindex_library requires one or more arguments: <library_id>")
|
||||
|
||||
store = modulestore()
|
||||
|
||||
if options.get('all', False):
|
||||
if query_yes_no(self.CONFIRMATION_PROMPT, default="no"):
|
||||
library_keys = [library.location.library_key.replace(branch=None) for library in store.get_libraries()]
|
||||
else:
|
||||
return
|
||||
else:
|
||||
library_keys = map(self._parse_library_key, args)
|
||||
|
||||
for library_key in library_keys:
|
||||
LibrarySearchIndexer.do_library_reindex(store, library_key)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.contentstore.utils import restore_asset_from_trashcan
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Restore a deleted asset from the trashcan back to it's original course'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("restore_asset_from_trashcan requires one argument: <location>")
|
||||
|
||||
restore_asset_from_trashcan(args[0])
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Test for assets cleanup of courses for Mac OS metadata files (with filename ".DS_Store"
|
||||
or with filename which starts with "._")
|
||||
"""
|
||||
from django.core.management import call_command
|
||||
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from xmodule.contentstore.content import XASSET_LOCATION_TAG
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.mongo.base import location_to_query
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.xml_importer import import_course_from_xml
|
||||
from django.conf import settings
|
||||
|
||||
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
|
||||
class ExportAllCourses(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests assets cleanup for all courses.
|
||||
"""
|
||||
def setUp(self):
|
||||
""" Common setup. """
|
||||
super(ExportAllCourses, self).setUp()
|
||||
|
||||
self.content_store = contentstore()
|
||||
# pylint: disable=protected-access
|
||||
self.module_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo)
|
||||
|
||||
def test_export_all_courses(self):
|
||||
"""
|
||||
This test validates that redundant Mac metadata files ('._example.txt', '.DS_Store') are
|
||||
cleaned up on import
|
||||
"""
|
||||
import_course_from_xml(
|
||||
self.module_store,
|
||||
'**replace_user**',
|
||||
TEST_DATA_DIR,
|
||||
['dot-underscore'],
|
||||
static_content_store=self.content_store,
|
||||
do_import_static=True,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
course = self.module_store.get_course(SlashSeparatedCourseKey('edX', 'dot-underscore', '2014_Fall'))
|
||||
self.assertIsNotNone(course)
|
||||
|
||||
# check that there are two assets ['example.txt', '.example.txt'] in contentstore for imported course
|
||||
all_assets, count = self.content_store.get_all_content_for_course(course.id)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(set([asset['_id']['name'] for asset in all_assets]), set([u'.example.txt', u'example.txt']))
|
||||
|
||||
# manually add redundant assets (file ".DS_Store" and filename starts with "._")
|
||||
course_filter = course.id.make_asset_key("asset", None)
|
||||
query = location_to_query(course_filter, wildcard=True, tag=XASSET_LOCATION_TAG)
|
||||
query['_id.name'] = all_assets[0]['_id']['name']
|
||||
asset_doc = self.content_store.fs_files.find_one(query)
|
||||
asset_doc['_id']['name'] = u'._example_test.txt'
|
||||
self.content_store.fs_files.insert(asset_doc)
|
||||
asset_doc['_id']['name'] = u'.DS_Store'
|
||||
self.content_store.fs_files.insert(asset_doc)
|
||||
|
||||
# check that now course has four assets
|
||||
all_assets, count = self.content_store.get_all_content_for_course(course.id)
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(
|
||||
set([asset['_id']['name'] for asset in all_assets]),
|
||||
set([u'.example.txt', u'example.txt', u'._example_test.txt', u'.DS_Store'])
|
||||
)
|
||||
# now call asset_cleanup command and check that there is only two proper assets in contentstore for the course
|
||||
call_command('cleanup_assets')
|
||||
all_assets, count = self.content_store.get_all_content_for_course(course.id)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(set([asset['_id']['name'] for asset in all_assets]), set([u'.example.txt', u'example.txt']))
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Unittests for creating a course in an chosen modulestore
|
||||
"""
|
||||
import unittest
|
||||
import ddt
|
||||
from django.core.management import CommandError, call_command
|
||||
|
||||
from contentstore.management.commands.create_course import Command
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class TestArgParsing(unittest.TestCase):
|
||||
"""
|
||||
Tests for parsing arguments for the `create_course` management command
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestArgParsing, self).setUp()
|
||||
|
||||
self.command = Command()
|
||||
|
||||
def test_no_args(self):
|
||||
errstring = "create_course requires 5 arguments"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle('create_course')
|
||||
|
||||
def test_invalid_store(self):
|
||||
with self.assertRaises(CommandError):
|
||||
self.command.handle("foo", "user@foo.org", "org", "course", "run")
|
||||
|
||||
def test_nonexistent_user_id(self):
|
||||
errstring = "No user 99 found"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("split", "99", "org", "course", "run")
|
||||
|
||||
def test_nonexistent_user_email(self):
|
||||
errstring = "No user fake@example.com found"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("mongo", "fake@example.com", "org", "course", "run")
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestCreateCourse(ModuleStoreTestCase):
|
||||
"""
|
||||
Unit tests for creating a course in either old mongo or split mongo via command line
|
||||
"""
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_all_stores_user_email(self, store):
|
||||
call_command(
|
||||
"create_course",
|
||||
store,
|
||||
str(self.user.email),
|
||||
"org", "course", "run"
|
||||
)
|
||||
new_key = modulestore().make_course_key("org", "course", "run")
|
||||
self.assertTrue(
|
||||
modulestore().has_course(new_key),
|
||||
"Could not find course in {}".format(store)
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(store, modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type())
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Unittests for deleting a course in an chosen modulestore
|
||||
"""
|
||||
|
||||
import mock
|
||||
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from django.core.management import call_command, CommandError
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class DeleteCourseTest(CourseTestCase):
|
||||
"""
|
||||
Test for course deleting functionality of the 'delete_course' command
|
||||
"""
|
||||
|
||||
YESNO_PATCH_LOCATION = 'contentstore.management.commands.delete_course.query_yes_no'
|
||||
|
||||
def setUp(self):
|
||||
super(DeleteCourseTest, self).setUp()
|
||||
|
||||
org = 'TestX'
|
||||
course_number = 'TS01'
|
||||
course_run = '2015_Q1'
|
||||
|
||||
# Create a course using split modulestore
|
||||
self.course = CourseFactory.create(
|
||||
org=org,
|
||||
number=course_number,
|
||||
run=course_run
|
||||
)
|
||||
|
||||
def test_invalid_key_not_found(self):
|
||||
"""
|
||||
Test for when a course key is malformed
|
||||
"""
|
||||
errstring = "Invalid course_key: 'foo/TestX/TS01/2015_Q7'."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('delete_course', 'foo/TestX/TS01/2015_Q7')
|
||||
|
||||
def test_course_key_not_found(self):
|
||||
"""
|
||||
Test for when a non-existing course key is entered
|
||||
"""
|
||||
errstring = "Course with 'TestX/TS01/2015_Q7' key not found."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('delete_course', 'TestX/TS01/2015_Q7')
|
||||
|
||||
def test_course_deleted(self):
|
||||
"""
|
||||
Testing if the entered course was deleted
|
||||
"""
|
||||
|
||||
#Test if the course that is about to be deleted exists
|
||||
self.assertIsNotNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1")))
|
||||
|
||||
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
|
||||
patched_yes_no.return_value = True
|
||||
call_command('delete_course', 'TestX/TS01/2015_Q1')
|
||||
self.assertIsNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1")))
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests running the delete_orphan command"""
|
||||
|
||||
import ddt
|
||||
from django.core.management import call_command, CommandError
|
||||
from contentstore.tests.test_orphan import TestOrphanBase
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestDeleteOrphan(TestOrphanBase):
|
||||
"""
|
||||
Tests for running the delete_orphan management command.
|
||||
Inherits from TestOrphan in order to use its setUp method.
|
||||
"""
|
||||
def test_no_args(self):
|
||||
"""
|
||||
Test delete_orphans command with no arguments
|
||||
"""
|
||||
with self.assertRaisesRegexp(CommandError, 'Error: too few arguments'):
|
||||
call_command('delete_orphans')
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
|
||||
def test_delete_orphans_no_commit(self, default_store):
|
||||
"""
|
||||
Tests that running the command without a '--commit' argument
|
||||
results in no orphans being deleted
|
||||
"""
|
||||
course = self.create_course_with_orphans(default_store)
|
||||
call_command('delete_orphans', unicode(course.id))
|
||||
self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html')))
|
||||
self.assertTrue(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert')))
|
||||
self.assertTrue(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter')))
|
||||
self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml')))
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
|
||||
def test_delete_orphans_commit(self, default_store):
|
||||
"""
|
||||
Tests that running the command WITH the '--commit' argument
|
||||
results in the orphans being deleted
|
||||
"""
|
||||
course = self.create_course_with_orphans(default_store)
|
||||
|
||||
call_command('delete_orphans', unicode(course.id), '--commit')
|
||||
|
||||
# make sure this module wasn't deleted
|
||||
self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html')))
|
||||
|
||||
# and make sure that these were
|
||||
self.assertFalse(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert')))
|
||||
self.assertFalse(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter')))
|
||||
self.assertFalse(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml')))
|
||||
|
||||
def test_delete_orphans_published_branch_split(self):
|
||||
"""
|
||||
Tests that if there are orphans only on the published branch,
|
||||
running delete orphans with a course key that specifies
|
||||
the published branch will delete the published orphan
|
||||
"""
|
||||
course, orphan = self.create_split_course_with_published_orphan()
|
||||
published_branch = course.id.for_branch(ModuleStoreEnum.BranchName.published)
|
||||
|
||||
items_in_published = self.store.get_items(published_branch)
|
||||
items_in_draft_preferred = self.store.get_items(course.id)
|
||||
|
||||
# call delete orphans, specifying the published branch
|
||||
# of the course
|
||||
call_command('delete_orphans', unicode(published_branch), '--commit')
|
||||
|
||||
# now all orphans should be deleted
|
||||
self.assertOrphanCount(course.id, 0)
|
||||
self.assertOrphanCount(published_branch, 0)
|
||||
self.assertNotIn(orphan, self.store.get_items(published_branch))
|
||||
# we should have one fewer item in the published branch of the course
|
||||
self.assertEqual(
|
||||
len(items_in_published) - 1,
|
||||
len(self.store.get_items(published_branch)),
|
||||
)
|
||||
# and the same amount of items in the draft branch of the course
|
||||
self.assertEqual(
|
||||
len(items_in_draft_preferred),
|
||||
len(self.store.get_items(course.id)),
|
||||
)
|
||||
|
||||
def create_split_course_with_published_orphan(self):
|
||||
"""
|
||||
Helper to create a split course with a published orphan
|
||||
"""
|
||||
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
|
||||
# create an orphan
|
||||
orphan = self.store.create_item(
|
||||
self.user.id, course.id, 'html', "PublishedOnlyOrphan"
|
||||
)
|
||||
self.store.publish(orphan.location, self.user.id)
|
||||
|
||||
# grab the published branch of the course
|
||||
published_branch = course.id.for_branch(
|
||||
ModuleStoreEnum.BranchName.published
|
||||
)
|
||||
|
||||
# assert that this orphan is present in both branches
|
||||
self.assertOrphanCount(course.id, 1)
|
||||
self.assertOrphanCount(published_branch, 1)
|
||||
|
||||
# delete this orphan from the draft branch without
|
||||
# auto-publishing this change to the published branch
|
||||
self.store.delete_item(
|
||||
orphan.location, self.user.id, skip_auto_publish=True
|
||||
)
|
||||
|
||||
# now there should be no orphans in the draft branch, but
|
||||
# there should be one in published
|
||||
self.assertOrphanCount(course.id, 0)
|
||||
self.assertOrphanCount(published_branch, 1)
|
||||
self.assertIn(orphan, self.store.get_items(published_branch))
|
||||
|
||||
return course, orphan
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Tests for exporting courseware to the desired path
|
||||
"""
|
||||
import unittest
|
||||
import shutil
|
||||
import ddt
|
||||
from django.core.management import CommandError, call_command
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class TestArgParsingCourseExport(unittest.TestCase):
|
||||
"""
|
||||
Tests for parsing arguments for the `export` management command
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestArgParsingCourseExport, self).setUp()
|
||||
|
||||
def test_no_args(self):
|
||||
"""
|
||||
Test export command with no arguments
|
||||
"""
|
||||
errstring = "Error: too few arguments"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('export')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestCourseExport(ModuleStoreTestCase):
|
||||
"""
|
||||
Test exporting a course
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestCourseExport, self).setUp()
|
||||
|
||||
# Temp directories (temp_dir_1: relative path, temp_dir_2: absolute path)
|
||||
self.temp_dir_1 = mkdtemp()
|
||||
self.temp_dir_2 = mkdtemp(dir="")
|
||||
|
||||
# Clean temp directories
|
||||
self.addCleanup(shutil.rmtree, self.temp_dir_1)
|
||||
self.addCleanup(shutil.rmtree, self.temp_dir_2)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_export_course_with_directory_name(self, store):
|
||||
"""
|
||||
Create a new course try exporting in a path specified
|
||||
"""
|
||||
course = CourseFactory.create(default_store=store)
|
||||
course_id = unicode(course.id)
|
||||
self.assertTrue(
|
||||
modulestore().has_course(course.id),
|
||||
"Could not find course in {}".format(store)
|
||||
)
|
||||
# Test `export` management command with invalid course_id
|
||||
errstring = "Invalid course_key: 'InvalidCourseID'."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('export', "InvalidCourseID", self.temp_dir_1)
|
||||
|
||||
# Test `export` management command with correct course_id
|
||||
for output_dir in [self.temp_dir_1, self.temp_dir_2]:
|
||||
call_command('export', course_id, output_dir)
|
||||
|
||||
def test_course_key_not_found(self):
|
||||
"""
|
||||
Test export command with a valid course key that doesn't exist
|
||||
"""
|
||||
errstring = "Course with x/y/z key not found."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('export', "x/y/z", self.temp_dir_1)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Test for export all courses.
|
||||
"""
|
||||
import shutil
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from contentstore.management.commands.export_all_courses import export_courses_to_output_path
|
||||
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class ExportAllCourses(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests exporting all courses.
|
||||
"""
|
||||
def setUp(self):
|
||||
""" Common setup. """
|
||||
super(ExportAllCourses, self).setUp()
|
||||
self.store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo)
|
||||
self.temp_dir = mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.temp_dir)
|
||||
self.first_course = CourseFactory.create(
|
||||
org="test", course="course1", display_name="run1", default_store=ModuleStoreEnum.Type.mongo
|
||||
)
|
||||
self.second_course = CourseFactory.create(
|
||||
org="test", course="course2", display_name="run2", default_store=ModuleStoreEnum.Type.mongo
|
||||
)
|
||||
|
||||
def test_export_all_courses(self):
|
||||
"""
|
||||
Test exporting good and faulty courses
|
||||
"""
|
||||
# check that both courses exported successfully
|
||||
courses, failed_export_courses = export_courses_to_output_path(self.temp_dir)
|
||||
self.assertEqual(len(courses), 2)
|
||||
self.assertEqual(len(failed_export_courses), 0)
|
||||
|
||||
# manually make second course faulty and check that it fails on export
|
||||
second_course_id = self.second_course.id
|
||||
self.store.collection.update(
|
||||
{'_id.org': second_course_id.org, '_id.course': second_course_id.course, '_id.name': second_course_id.run},
|
||||
{'$set': {'metadata.tags': 'crash'}}
|
||||
)
|
||||
courses, failed_export_courses = export_courses_to_output_path(self.temp_dir)
|
||||
self.assertEqual(len(courses), 2)
|
||||
self.assertEqual(len(failed_export_courses), 1)
|
||||
self.assertEqual(failed_export_courses[0], unicode(second_course_id))
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Tests for exporting OLX content.
|
||||
"""
|
||||
|
||||
import ddt
|
||||
from path import Path as path
|
||||
import shutil
|
||||
from StringIO import StringIO
|
||||
import tarfile
|
||||
from tempfile import mkdtemp
|
||||
import unittest
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class TestArgParsingCourseExportOlx(unittest.TestCase):
|
||||
"""
|
||||
Tests for parsing arguments for the `export_olx` management command
|
||||
"""
|
||||
def test_no_args(self):
|
||||
"""
|
||||
Test export command with no arguments
|
||||
"""
|
||||
errstring = "Error: too few arguments"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('export_olx')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestCourseExportOlx(ModuleStoreTestCase):
|
||||
"""
|
||||
Test exporting OLX content from a course or library.
|
||||
"""
|
||||
|
||||
def test_invalid_course_key(self):
|
||||
"""
|
||||
Test export command with an invalid course key.
|
||||
"""
|
||||
errstring = "Unparsable course_id"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('export_olx', 'InvalidCourseID')
|
||||
|
||||
def test_course_key_not_found(self):
|
||||
"""
|
||||
Test export command with a valid course key that doesn't exist.
|
||||
"""
|
||||
errstring = "Invalid course_id"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('export_olx', 'x/y/z')
|
||||
|
||||
def create_dummy_course(self, store_type):
|
||||
"""Create small course."""
|
||||
course = CourseFactory.create(default_store=store_type)
|
||||
self.assertTrue(
|
||||
modulestore().has_course(course.id),
|
||||
"Could not find course in {}".format(store_type)
|
||||
)
|
||||
return course.id
|
||||
|
||||
def check_export_file(self, tar_file, course_key):
|
||||
"""Check content of export file."""
|
||||
names = tar_file.getnames()
|
||||
dirname = "{0.org}-{0.course}-{0.run}".format(course_key)
|
||||
self.assertIn(dirname, names)
|
||||
# Check if some of the files are present, without being exhaustive.
|
||||
self.assertIn("{}/about".format(dirname), names)
|
||||
self.assertIn("{}/about/overview.html".format(dirname), names)
|
||||
self.assertIn("{}/assets/assets.xml".format(dirname), names)
|
||||
self.assertIn("{}/policies".format(dirname), names)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_export_course(self, store_type):
|
||||
test_course_key = self.create_dummy_course(store_type)
|
||||
tmp_dir = path(mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, tmp_dir)
|
||||
filename = tmp_dir / 'test.tar.gz'
|
||||
call_command('export_olx', '--output', filename, unicode(test_course_key))
|
||||
with tarfile.open(filename) as tar_file:
|
||||
self.check_export_file(tar_file, test_course_key)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_export_course_stdout(self, store_type):
|
||||
test_course_key = self.create_dummy_course(store_type)
|
||||
out = StringIO()
|
||||
call_command('export_olx', unicode(test_course_key), stdout=out)
|
||||
out.seek(0)
|
||||
output = out.read()
|
||||
with tarfile.open(fileobj=StringIO(output)) as tar_file:
|
||||
self.check_export_file(tar_file, test_course_key)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Tests for the fix_not_found management command
|
||||
"""
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
|
||||
class TestFixNotFound(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the fix_not_found management command
|
||||
"""
|
||||
def test_no_args(self):
|
||||
"""
|
||||
Test fix_not_found command with no arguments
|
||||
"""
|
||||
with self.assertRaisesRegexp(CommandError, "Error: too few arguments"):
|
||||
call_command('fix_not_found')
|
||||
|
||||
def test_fix_not_found_non_split(self):
|
||||
"""
|
||||
The management command doesn't work on non split courses
|
||||
"""
|
||||
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.mongo)
|
||||
with self.assertRaisesRegexp(CommandError, "The owning modulestore does not support this command."):
|
||||
call_command("fix_not_found", unicode(course.id))
|
||||
|
||||
def test_fix_not_found(self):
|
||||
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
|
||||
ItemFactory.create(category='chapter', parent_location=course.location)
|
||||
|
||||
# get course again in order to update its children list
|
||||
course = self.store.get_course(course.id)
|
||||
|
||||
# create a dangling usage key that we'll add to the course's children list
|
||||
dangling_pointer = course.id.make_usage_key('chapter', 'DanglingPointer')
|
||||
|
||||
course.children.append(dangling_pointer)
|
||||
self.store.update_item(course, self.user.id)
|
||||
|
||||
# the course block should now point to two children, one of which
|
||||
# doesn't actually exist
|
||||
self.assertEqual(len(course.children), 2)
|
||||
self.assertIn(dangling_pointer, course.children)
|
||||
|
||||
call_command("fix_not_found", unicode(course.id))
|
||||
|
||||
# make sure the dangling pointer was removed from
|
||||
# the course block's children
|
||||
course = self.store.get_course(course.id)
|
||||
self.assertEqual(len(course.children), 1)
|
||||
self.assertNotIn(dangling_pointer, course.children)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Tests for the force_publish management command
|
||||
"""
|
||||
import mock
|
||||
from django.core.management import call_command, CommandError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from contentstore.management.commands.force_publish import Command
|
||||
from contentstore.management.commands.utils import get_course_versions
|
||||
|
||||
|
||||
class TestForcePublish(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the force_publish management command
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestForcePublish, cls).setUpClass()
|
||||
cls.course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
|
||||
cls.test_user_id = ModuleStoreEnum.UserID.test
|
||||
cls.command = Command()
|
||||
|
||||
def test_no_args(self):
|
||||
"""
|
||||
Test 'force_publish' command with no arguments
|
||||
"""
|
||||
errstring = "Error: too few arguments"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('force_publish')
|
||||
|
||||
def test_invalid_course_key(self):
|
||||
"""
|
||||
Test 'force_publish' command with invalid course key
|
||||
"""
|
||||
errstring = "Invalid course key."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('force_publish', 'TestX/TS01')
|
||||
|
||||
def test_too_many_arguments(self):
|
||||
"""
|
||||
Test 'force_publish' command with more than 2 arguments
|
||||
"""
|
||||
errstring = "Error: unrecognized arguments: invalid-arg"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('force_publish', unicode(self.course.id), '--commit', 'invalid-arg')
|
||||
|
||||
def test_course_key_not_found(self):
|
||||
"""
|
||||
Test 'force_publish' command with non-existing course key
|
||||
"""
|
||||
errstring = "Course not found."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('force_publish', unicode('course-v1:org+course+run'))
|
||||
|
||||
def test_force_publish_non_split(self):
|
||||
"""
|
||||
Test 'force_publish' command doesn't work on non split courses
|
||||
"""
|
||||
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.mongo)
|
||||
errstring = 'The owning modulestore does not support this command.'
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('force_publish', unicode(course.id))
|
||||
|
||||
|
||||
class TestForcePublishModifications(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the force_publish management command that modify the courseware
|
||||
during the test.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestForcePublishModifications, self).setUp()
|
||||
self.course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
|
||||
self.test_user_id = ModuleStoreEnum.UserID.test
|
||||
self.command = Command()
|
||||
|
||||
def test_force_publish(self):
|
||||
"""
|
||||
Test 'force_publish' command
|
||||
"""
|
||||
# Add some changes to course
|
||||
chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
|
||||
self.store.create_child(
|
||||
self.test_user_id,
|
||||
chapter.location,
|
||||
'html',
|
||||
block_id='html_component'
|
||||
)
|
||||
|
||||
# verify that course has changes.
|
||||
self.assertTrue(self.store.has_changes(self.store.get_item(self.course.location)))
|
||||
|
||||
# get draft and publish branch versions
|
||||
versions = get_course_versions(unicode(self.course.id))
|
||||
draft_version = versions['draft-branch']
|
||||
published_version = versions['published-branch']
|
||||
|
||||
# verify that draft and publish point to different versions
|
||||
self.assertNotEqual(draft_version, published_version)
|
||||
|
||||
with mock.patch('contentstore.management.commands.force_publish.query_yes_no') as patched_yes_no:
|
||||
patched_yes_no.return_value = True
|
||||
|
||||
# force publish course
|
||||
call_command('force_publish', unicode(self.course.id), '--commit')
|
||||
|
||||
# verify that course has no changes
|
||||
self.assertFalse(self.store.has_changes(self.store.get_item(self.course.location)))
|
||||
|
||||
# get new draft and publish branch versions
|
||||
versions = get_course_versions(unicode(self.course.id))
|
||||
new_draft_version = versions['draft-branch']
|
||||
new_published_version = versions['published-branch']
|
||||
|
||||
# verify that the draft branch didn't change while the published branch did
|
||||
self.assertEqual(draft_version, new_draft_version)
|
||||
self.assertNotEqual(published_version, new_published_version)
|
||||
|
||||
# verify that draft and publish point to same versions now
|
||||
self.assertEqual(new_draft_version, new_published_version)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Unittests for exporting to git via management command.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import shutil
|
||||
import StringIO
|
||||
import subprocess
|
||||
import unittest
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
import contentstore.git_export_utils as git_export_utils
|
||||
from contentstore.git_export_utils import GitExportError
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
FEATURES_WITH_EXPORT_GIT = settings.FEATURES.copy()
|
||||
FEATURES_WITH_EXPORT_GIT['ENABLE_EXPORT_GIT'] = True
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
@override_settings(FEATURES=FEATURES_WITH_EXPORT_GIT)
|
||||
class TestGitExport(CourseTestCase):
|
||||
"""
|
||||
Excercise the git_export django management command with various inputs.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create/reinitialize bare repo and folders needed
|
||||
"""
|
||||
super(TestGitExport, self).setUp()
|
||||
|
||||
if not os.path.isdir(git_export_utils.GIT_REPO_EXPORT_DIR):
|
||||
os.mkdir(git_export_utils.GIT_REPO_EXPORT_DIR)
|
||||
self.addCleanup(shutil.rmtree, git_export_utils.GIT_REPO_EXPORT_DIR)
|
||||
|
||||
self.bare_repo_dir = '{0}/data/test_bare.git'.format(
|
||||
os.path.abspath(settings.TEST_ROOT))
|
||||
if not os.path.isdir(self.bare_repo_dir):
|
||||
os.mkdir(self.bare_repo_dir)
|
||||
self.addCleanup(shutil.rmtree, self.bare_repo_dir)
|
||||
subprocess.check_output(['git', '--bare', 'init'],
|
||||
cwd=self.bare_repo_dir)
|
||||
|
||||
def test_command(self):
|
||||
"""
|
||||
Test that the command interface works. Ignore stderr for clean
|
||||
test output.
|
||||
"""
|
||||
with self.assertRaisesRegexp(CommandError, 'This script requires.*'):
|
||||
call_command('git_export', 'blah', 'blah', 'blah', stderr=StringIO.StringIO())
|
||||
|
||||
with self.assertRaisesRegexp(CommandError, 'This script requires.*'):
|
||||
call_command('git_export', stderr=StringIO.StringIO())
|
||||
|
||||
# Send bad url to get course not exported
|
||||
with self.assertRaisesRegexp(CommandError, unicode(GitExportError.URL_BAD)):
|
||||
call_command('git_export', 'foo/bar/baz', 'silly', stderr=StringIO.StringIO())
|
||||
|
||||
# Send bad course_id to get course not exported
|
||||
with self.assertRaisesRegexp(CommandError, unicode(GitExportError.BAD_COURSE)):
|
||||
call_command('git_export', 'foo/bar:baz', 'silly', stderr=StringIO.StringIO())
|
||||
|
||||
def test_error_output(self):
|
||||
"""
|
||||
Verify that error output is actually resolved as the correct string
|
||||
"""
|
||||
with self.assertRaisesRegexp(CommandError, unicode(GitExportError.BAD_COURSE)):
|
||||
call_command(
|
||||
'git_export', 'foo/bar:baz', 'silly'
|
||||
)
|
||||
|
||||
with self.assertRaisesRegexp(CommandError, unicode(GitExportError.URL_BAD)):
|
||||
call_command(
|
||||
'git_export', 'foo/bar/baz', 'silly'
|
||||
)
|
||||
|
||||
def test_bad_git_url(self):
|
||||
"""
|
||||
Test several bad URLs for validation
|
||||
"""
|
||||
course_key = SlashSeparatedCourseKey('org', 'course', 'run')
|
||||
with self.assertRaisesRegexp(GitExportError, unicode(GitExportError.URL_BAD)):
|
||||
git_export_utils.export_to_git(course_key, 'Sillyness')
|
||||
|
||||
with self.assertRaisesRegexp(GitExportError, unicode(GitExportError.URL_BAD)):
|
||||
git_export_utils.export_to_git(course_key, 'example.com:edx/notreal')
|
||||
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
unicode(GitExportError.URL_NO_AUTH)):
|
||||
git_export_utils.export_to_git(course_key, 'http://blah')
|
||||
|
||||
def test_bad_git_repos(self):
|
||||
"""
|
||||
Test invalid git repos
|
||||
"""
|
||||
test_repo_path = '{}/test_repo'.format(git_export_utils.GIT_REPO_EXPORT_DIR)
|
||||
self.assertFalse(os.path.isdir(test_repo_path))
|
||||
course_key = SlashSeparatedCourseKey('foo', 'blah', '100-')
|
||||
# Test bad clones
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
unicode(GitExportError.CANNOT_PULL)):
|
||||
git_export_utils.export_to_git(
|
||||
course_key,
|
||||
'https://user:blah@example.com/test_repo.git')
|
||||
self.assertFalse(os.path.isdir(test_repo_path))
|
||||
|
||||
# Setup good repo with bad course to test xml export
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
unicode(GitExportError.XML_EXPORT_FAIL)):
|
||||
git_export_utils.export_to_git(
|
||||
course_key,
|
||||
'file://{0}'.format(self.bare_repo_dir))
|
||||
|
||||
# Test bad git remote after successful clone
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
unicode(GitExportError.CANNOT_PULL)):
|
||||
git_export_utils.export_to_git(
|
||||
course_key,
|
||||
'https://user:blah@example.com/r.git')
|
||||
|
||||
@unittest.skipIf(os.environ.get('GIT_CONFIG') or
|
||||
os.environ.get('GIT_AUTHOR_EMAIL') or
|
||||
os.environ.get('GIT_AUTHOR_NAME') or
|
||||
os.environ.get('GIT_COMMITTER_EMAIL') or
|
||||
os.environ.get('GIT_COMMITTER_NAME'),
|
||||
'Global git override set')
|
||||
def test_git_ident(self):
|
||||
"""
|
||||
Test valid course with and without user specified.
|
||||
|
||||
Test skipped if git global config override environment variable GIT_CONFIG
|
||||
is set.
|
||||
"""
|
||||
git_export_utils.export_to_git(
|
||||
self.course.id,
|
||||
'file://{0}'.format(self.bare_repo_dir),
|
||||
'enigma'
|
||||
)
|
||||
expect_string = '{0}|{1}\n'.format(
|
||||
git_export_utils.GIT_EXPORT_DEFAULT_IDENT['name'],
|
||||
git_export_utils.GIT_EXPORT_DEFAULT_IDENT['email']
|
||||
)
|
||||
cwd = os.path.abspath(git_export_utils.GIT_REPO_EXPORT_DIR / 'test_bare')
|
||||
git_log = subprocess.check_output(['git', 'log', '-1',
|
||||
'--format=%an|%ae'], cwd=cwd)
|
||||
self.assertEqual(expect_string, git_log)
|
||||
|
||||
# Make changes to course so there is something to commit
|
||||
self.populate_course()
|
||||
git_export_utils.export_to_git(
|
||||
self.course.id,
|
||||
'file://{0}'.format(self.bare_repo_dir),
|
||||
self.user.username
|
||||
)
|
||||
expect_string = '{0}|{1}\n'.format(
|
||||
self.user.username,
|
||||
self.user.email,
|
||||
)
|
||||
git_log = subprocess.check_output(
|
||||
['git', 'log', '-1', '--format=%an|%ae'], cwd=cwd)
|
||||
self.assertEqual(expect_string, git_log)
|
||||
|
||||
def test_no_change(self):
|
||||
"""
|
||||
Test response if there are no changes
|
||||
"""
|
||||
git_export_utils.export_to_git(
|
||||
self.course.id,
|
||||
'file://{0}'.format(self.bare_repo_dir)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
unicode(GitExportError.CANNOT_COMMIT)):
|
||||
git_export_utils.export_to_git(
|
||||
self.course.id, 'file://{0}'.format(self.bare_repo_dir))
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Unittests for importing a course via management command
|
||||
"""
|
||||
|
||||
import os
|
||||
from path import Path as path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from django.core.management import call_command
|
||||
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class TestImport(ModuleStoreTestCase):
|
||||
"""
|
||||
Unit tests for importing a course from command line
|
||||
"""
|
||||
|
||||
def create_course_xml(self, content_dir, course_id):
|
||||
directory = tempfile.mkdtemp(dir=content_dir)
|
||||
os.makedirs(os.path.join(directory, "course"))
|
||||
with open(os.path.join(directory, "course.xml"), "w+") as f:
|
||||
f.write('<course url_name="{0.run}" org="{0.org}" '
|
||||
'course="{0.course}"/>'.format(course_id))
|
||||
|
||||
with open(os.path.join(directory, "course", "{0.run}.xml".format(course_id)), "w+") as f:
|
||||
f.write('<course><chapter name="Test Chapter"></chapter></course>')
|
||||
|
||||
return directory
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Build course XML for importing
|
||||
"""
|
||||
super(TestImport, self).setUp()
|
||||
self.content_dir = path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.content_dir)
|
||||
|
||||
self.base_course_key = self.store.make_course_key(u'edX', u'test_import_course', u'2013_Spring')
|
||||
self.truncated_key = self.store.make_course_key(u'edX', u'test_import', u'2014_Spring')
|
||||
|
||||
# Create good course xml
|
||||
self.good_dir = self.create_course_xml(self.content_dir, self.base_course_key)
|
||||
|
||||
# Create course XML where TRUNCATED_COURSE.org == BASE_COURSE_ID.org
|
||||
# and BASE_COURSE_ID.startswith(TRUNCATED_COURSE.course)
|
||||
self.course_dir = self.create_course_xml(self.content_dir, self.truncated_key)
|
||||
|
||||
def test_forum_seed(self):
|
||||
"""
|
||||
Tests that forum roles were created with import.
|
||||
"""
|
||||
self.assertFalse(are_permissions_roles_seeded(self.base_course_key))
|
||||
call_command('import', self.content_dir, self.good_dir)
|
||||
self.assertTrue(are_permissions_roles_seeded(self.base_course_key))
|
||||
|
||||
def test_truncated_course_with_url(self):
|
||||
"""
|
||||
Check to make sure an import only blocks true duplicates: new
|
||||
courses with similar but not unique org/course combinations aren't
|
||||
blocked if the original course's course starts with the new course's
|
||||
org/course combinations (i.e. EDx/0.00x/Spring -> EDx/0.00/Spring)
|
||||
"""
|
||||
# Load up base course and verify it is available
|
||||
call_command('import', self.content_dir, self.good_dir)
|
||||
store = modulestore()
|
||||
self.assertIsNotNone(store.get_course(self.base_course_key))
|
||||
|
||||
# Now load up the course with a similar course_id and verify it loads
|
||||
call_command('import', self.content_dir, self.course_dir)
|
||||
self.assertIsNotNone(store.get_course(self.truncated_key))
|
||||
|
||||
def test_existing_course_with_different_modulestore(self):
|
||||
"""
|
||||
Checks that a course that originally existed in old mongo can be re-imported when
|
||||
split is the default modulestore.
|
||||
"""
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.mongo):
|
||||
call_command('import', self.content_dir, self.good_dir)
|
||||
|
||||
# Clear out the modulestore mappings, else when the next import command goes to create a destination
|
||||
# course_key, it will find the existing course and return the mongo course_key. To reproduce TNL-1362,
|
||||
# the destination course_key needs to be the one for split modulestore.
|
||||
modulestore().mappings = {}
|
||||
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
call_command('import', self.content_dir, self.good_dir)
|
||||
course = modulestore().get_course(self.base_course_key)
|
||||
# With the bug, this fails because the chapter's course_key is the split mongo form,
|
||||
# while the course's course_key is the old mongo form.
|
||||
self.assertEqual(unicode(course.location.course_key), unicode(course.children[0].course_key))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Unittests for migrating a course to split mongo
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from contentstore.management.commands.migrate_to_split import Command
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class TestArgParsing(unittest.TestCase):
|
||||
"""
|
||||
Tests for parsing arguments for the `migrate_to_split` management command
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestArgParsing, self).setUp()
|
||||
self.command = Command()
|
||||
|
||||
def test_no_args(self):
|
||||
"""
|
||||
Test the arg length error
|
||||
"""
|
||||
errstring = "migrate_to_split requires at least two arguments"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle()
|
||||
|
||||
def test_invalid_location(self):
|
||||
"""
|
||||
Test passing an unparsable course id
|
||||
"""
|
||||
errstring = "Invalid location string"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("foo", "bar")
|
||||
|
||||
def test_nonexistent_user_id(self):
|
||||
"""
|
||||
Test error for using an unknown user primary key
|
||||
"""
|
||||
errstring = "No user found identified by 99"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("org/course/name", "99")
|
||||
|
||||
def test_nonexistent_user_email(self):
|
||||
"""
|
||||
Test error for using an unknown user email
|
||||
"""
|
||||
errstring = "No user found identified by fake@example.com"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("org/course/name", "fake@example.com")
|
||||
|
||||
|
||||
# pylint: disable=no-member, protected-access
|
||||
class TestMigrateToSplit(ModuleStoreTestCase):
|
||||
"""
|
||||
Unit tests for migrating a course from old mongo to split mongo
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestMigrateToSplit, self).setUp()
|
||||
self.course = CourseFactory(default_store=ModuleStoreEnum.Type.mongo)
|
||||
|
||||
def test_user_email(self):
|
||||
"""
|
||||
Test migration for real as well as testing using an email addr to id the user
|
||||
"""
|
||||
call_command(
|
||||
"migrate_to_split",
|
||||
str(self.course.id),
|
||||
str(self.user.email),
|
||||
)
|
||||
split_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split)
|
||||
new_key = split_store.make_course_key(self.course.id.org, self.course.id.course, self.course.id.run)
|
||||
self.assertTrue(
|
||||
split_store.has_course(new_key),
|
||||
"Could not find course"
|
||||
)
|
||||
# I put this in but realized that the migrator doesn't make the new course the
|
||||
# default mapping in mixed modulestore. I left the test here so we can debate what it ought to do.
|
||||
# self.assertEqual(
|
||||
# ModuleStoreEnum.Type.split,
|
||||
# modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type(),
|
||||
# "Split is not the new default for the course"
|
||||
# )
|
||||
|
||||
def test_user_id(self):
|
||||
"""
|
||||
Test that the command accepts the user's primary key
|
||||
"""
|
||||
# lack of error implies success
|
||||
call_command(
|
||||
"migrate_to_split",
|
||||
str(self.course.id),
|
||||
str(self.user.id),
|
||||
)
|
||||
|
||||
def test_locator_string(self):
|
||||
"""
|
||||
Test importing to a different course id
|
||||
"""
|
||||
call_command(
|
||||
"migrate_to_split",
|
||||
str(self.course.id),
|
||||
str(self.user.id),
|
||||
"org.dept", "name", "run",
|
||||
)
|
||||
split_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split)
|
||||
locator = split_store.make_course_key(self.course.id.org, self.course.id.course, self.course.id.run)
|
||||
course_from_split = modulestore().get_course(locator)
|
||||
self.assertIsNotNone(course_from_split)
|
||||
@@ -0,0 +1,124 @@
|
||||
""" Tests for course reindex command """
|
||||
import ddt
|
||||
from django.core.management import call_command, CommandError
|
||||
import mock
|
||||
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
|
||||
|
||||
from contentstore.management.commands.reindex_course import Command as ReindexCommand
|
||||
from contentstore.courseware_index import SearchIndexingError
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestReindexCourse(ModuleStoreTestCase):
|
||||
""" Tests for course reindex command """
|
||||
def setUp(self):
|
||||
""" Setup method - create courses """
|
||||
super(TestReindexCourse, self).setUp()
|
||||
self.store = modulestore()
|
||||
self.first_lib = LibraryFactory.create(
|
||||
org="test", library="lib1", display_name="run1", default_store=ModuleStoreEnum.Type.split
|
||||
)
|
||||
self.second_lib = LibraryFactory.create(
|
||||
org="test", library="lib2", display_name="run2", default_store=ModuleStoreEnum.Type.split
|
||||
)
|
||||
|
||||
self.first_course = CourseFactory.create(
|
||||
org="test", course="course1", display_name="run1"
|
||||
)
|
||||
self.second_course = CourseFactory.create(
|
||||
org="test", course="course2", display_name="run1"
|
||||
)
|
||||
|
||||
REINDEX_PATH_LOCATION = 'contentstore.management.commands.reindex_course.CoursewareSearchIndexer.do_course_reindex'
|
||||
MODULESTORE_PATCH_LOCATION = 'contentstore.management.commands.reindex_course.modulestore'
|
||||
YESNO_PATCH_LOCATION = 'contentstore.management.commands.reindex_course.query_yes_no'
|
||||
|
||||
def _get_lib_key(self, library):
|
||||
""" Get's library key as it is passed to indexer """
|
||||
return library.location.library_key
|
||||
|
||||
def _build_calls(self, *courses):
|
||||
""" Builds a list of mock.call instances representing calls to reindexing method """
|
||||
return [mock.call(self.store, course.id) for course in courses]
|
||||
|
||||
def test_given_no_arguments_raises_command_error(self):
|
||||
""" Test that raises CommandError for incorrect arguments """
|
||||
with self.assertRaisesRegexp(CommandError, ".* requires one or more arguments.*"):
|
||||
call_command('reindex_course')
|
||||
|
||||
@ddt.data('qwerty', 'invalid_key', 'xblockv1:qwerty')
|
||||
def test_given_invalid_course_key_raises_not_found(self, invalid_key):
|
||||
""" Test that raises InvalidKeyError for invalid keys """
|
||||
err_string = "Invalid course_key: '{0}'".format(invalid_key)
|
||||
with self.assertRaisesRegexp(CommandError, err_string):
|
||||
call_command('reindex_course', invalid_key)
|
||||
|
||||
def test_given_library_key_raises_command_error(self):
|
||||
""" Test that raises CommandError if library key is passed """
|
||||
with self.assertRaisesRegexp(CommandError, ".* is not a course key"):
|
||||
call_command('reindex_course', unicode(self._get_lib_key(self.first_lib)))
|
||||
|
||||
with self.assertRaisesRegexp(CommandError, ".* is not a course key"):
|
||||
call_command('reindex_course', unicode(self._get_lib_key(self.second_lib)))
|
||||
|
||||
with self.assertRaisesRegexp(CommandError, ".* is not a course key"):
|
||||
call_command(
|
||||
'reindex_course',
|
||||
unicode(self.second_course.id),
|
||||
unicode(self._get_lib_key(self.first_lib))
|
||||
)
|
||||
|
||||
def test_given_id_list_indexes_courses(self):
|
||||
""" Test that reindexes courses when given single course key or a list of course keys """
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_course', unicode(self.first_course.id))
|
||||
self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_course))
|
||||
patched_index.reset_mock()
|
||||
|
||||
call_command('reindex_course', unicode(self.second_course.id))
|
||||
self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_course))
|
||||
patched_index.reset_mock()
|
||||
|
||||
call_command(
|
||||
'reindex_course',
|
||||
unicode(self.first_course.id),
|
||||
unicode(self.second_course.id)
|
||||
)
|
||||
expected_calls = self._build_calls(self.first_course, self.second_course)
|
||||
self.assertEqual(patched_index.mock_calls, expected_calls)
|
||||
|
||||
def test_given_all_key_prompts_and_reindexes_all_courses(self):
|
||||
""" Test that reindexes all courses when --all key is given and confirmed """
|
||||
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
|
||||
patched_yes_no.return_value = True
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_course', all=True)
|
||||
|
||||
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
|
||||
expected_calls = self._build_calls(self.first_course, self.second_course)
|
||||
self.assertItemsEqual(patched_index.mock_calls, expected_calls)
|
||||
|
||||
def test_given_all_key_prompts_and_reindexes_all_courses_cancelled(self):
|
||||
""" Test that does not reindex anything when --all key is given and cancelled """
|
||||
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
|
||||
patched_yes_no.return_value = False
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_course', all=True)
|
||||
|
||||
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
|
||||
patched_index.assert_not_called()
|
||||
|
||||
def test_fail_fast_if_reindex_fails(self):
|
||||
""" Test that fails on first reindexing exception """
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index:
|
||||
patched_index.side_effect = SearchIndexingError("message", [])
|
||||
|
||||
with self.assertRaises(SearchIndexingError):
|
||||
call_command('reindex_course', unicode(self.second_course.id))
|
||||
@@ -0,0 +1,125 @@
|
||||
""" Tests for library reindex command """
|
||||
import ddt
|
||||
from django.core.management import call_command, CommandError
|
||||
import mock
|
||||
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
from contentstore.management.commands.reindex_library import Command as ReindexCommand
|
||||
from contentstore.courseware_index import SearchIndexingError
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestReindexLibrary(ModuleStoreTestCase):
|
||||
""" Tests for library reindex command """
|
||||
def setUp(self):
|
||||
""" Setup method - create libraries and courses """
|
||||
super(TestReindexLibrary, self).setUp()
|
||||
self.store = modulestore()
|
||||
self.first_lib = LibraryFactory.create(
|
||||
org="test", library="lib1", display_name="run1", default_store=ModuleStoreEnum.Type.split
|
||||
)
|
||||
self.second_lib = LibraryFactory.create(
|
||||
org="test", library="lib2", display_name="run2", default_store=ModuleStoreEnum.Type.split
|
||||
)
|
||||
|
||||
self.first_course = CourseFactory.create(
|
||||
org="test", course="course1", display_name="run1", default_store=ModuleStoreEnum.Type.split
|
||||
)
|
||||
self.second_course = CourseFactory.create(
|
||||
org="test", course="course2", display_name="run1", default_store=ModuleStoreEnum.Type.split
|
||||
)
|
||||
|
||||
REINDEX_PATH_LOCATION = 'contentstore.management.commands.reindex_library.LibrarySearchIndexer.do_library_reindex'
|
||||
MODULESTORE_PATCH_LOCATION = 'contentstore.management.commands.reindex_library.modulestore'
|
||||
YESNO_PATCH_LOCATION = 'contentstore.management.commands.reindex_library.query_yes_no'
|
||||
|
||||
def _get_lib_key(self, library):
|
||||
""" Get's library key as it is passed to indexer """
|
||||
return library.location.library_key
|
||||
|
||||
def _build_calls(self, *libraries):
|
||||
""" BUilds a list of mock.call instances representing calls to reindexing method """
|
||||
return [mock.call(self.store, self._get_lib_key(lib)) for lib in libraries]
|
||||
|
||||
def test_given_no_arguments_raises_command_error(self):
|
||||
""" Test that raises CommandError for incorrect arguments """
|
||||
with self.assertRaisesRegexp(CommandError, ".* requires one or more arguments.*"):
|
||||
call_command('reindex_library')
|
||||
|
||||
@ddt.data('qwerty', 'invalid_key', 'xblock-v1:qwe+rty')
|
||||
def test_given_invalid_lib_key_raises_not_found(self, invalid_key):
|
||||
""" Test that raises InvalidKeyError for invalid keys """
|
||||
with self.assertRaises(InvalidKeyError):
|
||||
call_command('reindex_library', invalid_key)
|
||||
|
||||
def test_given_course_key_raises_command_error(self):
|
||||
""" Test that raises CommandError if course key is passed """
|
||||
with self.assertRaisesRegexp(CommandError, ".* is not a library key"):
|
||||
call_command('reindex_library', unicode(self.first_course.id))
|
||||
|
||||
with self.assertRaisesRegexp(CommandError, ".* is not a library key"):
|
||||
call_command('reindex_library', unicode(self.second_course.id))
|
||||
|
||||
with self.assertRaisesRegexp(CommandError, ".* is not a library key"):
|
||||
call_command(
|
||||
'reindex_library',
|
||||
unicode(self.second_course.id),
|
||||
unicode(self._get_lib_key(self.first_lib))
|
||||
)
|
||||
|
||||
def test_given_id_list_indexes_libraries(self):
|
||||
""" Test that reindexes libraries when given single library key or a list of library keys """
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_library', unicode(self._get_lib_key(self.first_lib)))
|
||||
self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_lib))
|
||||
patched_index.reset_mock()
|
||||
|
||||
call_command('reindex_library', unicode(self._get_lib_key(self.second_lib)))
|
||||
self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_lib))
|
||||
patched_index.reset_mock()
|
||||
|
||||
call_command(
|
||||
'reindex_library',
|
||||
unicode(self._get_lib_key(self.first_lib)),
|
||||
unicode(self._get_lib_key(self.second_lib))
|
||||
)
|
||||
expected_calls = self._build_calls(self.first_lib, self.second_lib)
|
||||
self.assertEqual(patched_index.mock_calls, expected_calls)
|
||||
|
||||
def test_given_all_key_prompts_and_reindexes_all_libraries(self):
|
||||
""" Test that reindexes all libraries when --all key is given and confirmed """
|
||||
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
|
||||
patched_yes_no.return_value = True
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_library', all=True)
|
||||
|
||||
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
|
||||
expected_calls = self._build_calls(self.first_lib, self.second_lib)
|
||||
self.assertItemsEqual(patched_index.mock_calls, expected_calls)
|
||||
|
||||
def test_given_all_key_prompts_and_reindexes_all_libraries_cancelled(self):
|
||||
""" Test that does not reindex anything when --all key is given and cancelled """
|
||||
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
|
||||
patched_yes_no.return_value = False
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_library', all=True)
|
||||
|
||||
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
|
||||
patched_index.assert_not_called()
|
||||
|
||||
def test_fail_fast_if_reindex_fails(self):
|
||||
""" Test that fails on first reindexing exception """
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index:
|
||||
patched_index.side_effect = SearchIndexingError("message", [])
|
||||
|
||||
with self.assertRaises(SearchIndexingError):
|
||||
call_command('reindex_library', unicode(self._get_lib_key(self.second_lib)))
|
||||
39
cms/djangoapps/contentstore/management/commands/utils.py
Normal file
39
cms/djangoapps/contentstore/management/commands/utils.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Common methods for cms commands to use
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
def user_from_str(identifier):
|
||||
"""
|
||||
Return a user identified by the given string. The string could be an email
|
||||
address, or a stringified integer corresponding to the ID of the user in
|
||||
the database. If no user could be found, a User.DoesNotExist exception
|
||||
will be raised.
|
||||
"""
|
||||
try:
|
||||
user_id = int(identifier)
|
||||
except ValueError:
|
||||
return User.objects.get(email=identifier)
|
||||
|
||||
return User.objects.get(id=user_id)
|
||||
|
||||
|
||||
def get_course_versions(course_key):
|
||||
"""
|
||||
Fetches the latest course versions
|
||||
:param course_key:
|
||||
:return: { 'draft-branch' : value1, 'published-branch' : value2}
|
||||
"""
|
||||
course_locator = CourseKey.from_string(course_key)
|
||||
store = modulestore()._get_modulestore_for_courselike(course_locator) # pylint: disable=protected-access
|
||||
index_entry = store.get_course_index(course_locator)
|
||||
if index_entry is not None:
|
||||
return {
|
||||
'draft-branch': index_entry['versions']['draft-branch'],
|
||||
'published-branch': index_entry['versions']['published-branch']
|
||||
}
|
||||
|
||||
return None
|
||||
25
cms/djangoapps/contentstore/management/commands/xlint.py
Normal file
25
cms/djangoapps/contentstore/management/commands/xlint.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Verify the structure of courseware as to it's suitability for import
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_importer import perform_xlint
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Verify the structure of courseware as to it's suitability for import"""
|
||||
help = "Verify the structure of courseware as to it's suitability for import"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) == 0:
|
||||
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
|
||||
|
||||
data_dir = args[0]
|
||||
if len(args) > 1:
|
||||
source_dirs = args[1:]
|
||||
else:
|
||||
source_dirs = None
|
||||
print("Importing. Data_dir={data}, source_dirs={courses}".format(
|
||||
data=data_dir,
|
||||
courses=source_dirs))
|
||||
perform_xlint(data_dir, source_dirs, load_error_modules=False)
|
||||
43
cms/djangoapps/contentstore/migrations/0001_initial.py
Normal file
43
cms/djangoapps/contentstore/migrations/0001_initial.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PushNotificationConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
|
||||
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-change_date',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VideoUploadConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
|
||||
('profile_whitelist', models.TextField(help_text=b'A comma-separated list of names of profiles to include in video encoding downloads.', blank=True)),
|
||||
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-change_date',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user