PY-2016.1.4 <alisantang@C02RP0YSG8WM.tld Merge branch 'master'

This commit is contained in:
alisan617
2016-06-07 11:07:24 -04:00
6987 changed files with 1535425 additions and 0 deletions

49
.coveragerc Normal file
View 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
View File

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

110
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 youre 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 youre filing a bug, wed 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 contributors 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 contributors 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
View 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
View 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
View 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
View 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)

View File

View File

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

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

View 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

View 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

View 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|&nbsp;|//)+", " ", 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)

View File

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

View File

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

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

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

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

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

View File

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

View 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

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

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

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

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

View 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

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

View File

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

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

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

View 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

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

View 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(';', '')

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

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

View 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

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

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

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

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

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

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

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

View 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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

View 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