Merge branch 'master' of github.com:edx/edx-platform into bugfix/ichuang/make-edit-link-use-static-asset-path
Conflicts: common/djangoapps/xmodule_modifiers.py
This commit is contained in:
88
.gitignore
vendored
88
.gitignore
vendored
@@ -1,49 +1,73 @@
|
||||
*.pyc
|
||||
*~
|
||||
*.scssc
|
||||
*.swp
|
||||
*.orig
|
||||
*.DS_Store
|
||||
*.mo
|
||||
:2e_*
|
||||
:2e#
|
||||
.AppleDouble
|
||||
database.sqlite
|
||||
# .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
|
||||
courseware/static/js/mathjax/*
|
||||
flushdb.sh
|
||||
build
|
||||
|
||||
### Python artifacts
|
||||
*.pyc
|
||||
|
||||
### Editor and IDE artifacts
|
||||
*~
|
||||
*.swp
|
||||
*.orig
|
||||
/nbproject
|
||||
.idea/
|
||||
.redcar/
|
||||
|
||||
### OS X artifacts
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
:2e_*
|
||||
:2e#
|
||||
|
||||
### Internationalization artifacts
|
||||
*.mo
|
||||
conf/locale/en/LC_MESSAGES/*.po
|
||||
!messages.po
|
||||
|
||||
### Testing artifacts
|
||||
.testids/
|
||||
.noseids
|
||||
nosetests.xml
|
||||
.coverage
|
||||
coverage.xml
|
||||
cover/
|
||||
log/
|
||||
cover_html/
|
||||
reports/
|
||||
/src/
|
||||
\#*\#
|
||||
|
||||
### Installation artifacts
|
||||
*.egg-info
|
||||
Gemfile.lock
|
||||
.env/
|
||||
conf/locale/en/LC_MESSAGES/*.po
|
||||
!messages.po
|
||||
.pip_download_cache/
|
||||
.prereqs_cache
|
||||
.vagrant/
|
||||
node_modules
|
||||
|
||||
### Static assets pipeline artifacts
|
||||
*.scssc
|
||||
lms/static/sass/*.css
|
||||
lms/static/sass/application.scss
|
||||
lms/static/sass/course.scss
|
||||
cms/static/sass/*.css
|
||||
lms/lib/comment_client/python
|
||||
nosetests.xml
|
||||
cover_html/
|
||||
.idea/
|
||||
.redcar/
|
||||
|
||||
### Logging artifacts
|
||||
log/
|
||||
logs
|
||||
chromedriver.log
|
||||
/nbproject
|
||||
ghostdriver.log
|
||||
node_modules
|
||||
.pip_download_cache/
|
||||
.prereqs_cache
|
||||
|
||||
### Unknown artifacts
|
||||
database.sqlite
|
||||
courseware/static/js/mathjax/*
|
||||
flushdb.sh
|
||||
build
|
||||
/src/
|
||||
\#*\#
|
||||
.env/
|
||||
lms/lib/comment_client/python
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
.vagrant/
|
||||
logs
|
||||
.testids/
|
||||
|
||||
5
AUTHORS
5
AUTHORS
@@ -84,3 +84,8 @@ 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>
|
||||
|
||||
100
CHANGELOG.rst
100
CHANGELOG.rst
@@ -5,6 +5,59 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
LMS: Disable data download buttons on the instructor dashboard for large courses
|
||||
|
||||
LMS: Refactor and clean student dashboard templates.
|
||||
|
||||
LMS: Fix issue with CourseMode expiration dates
|
||||
|
||||
CMS: Add text_customization Dict to advanced settings which can support
|
||||
string customization at particular spots in the UI. At first just customizing
|
||||
the Check/Final Check buttons with keys: custom_check and custom_final_check
|
||||
|
||||
LMS: Add PaidCourseRegistration mode, where payment is required before course
|
||||
registration.
|
||||
|
||||
Studio: Switched to loading Javascript using require.js
|
||||
|
||||
Studio: Better feedback during the course import process
|
||||
|
||||
LMS: Add split testing functionality for internal use.
|
||||
|
||||
CMS: Add edit_course_tabs management command, providing a primitive
|
||||
editing capability for a course's list of tabs.
|
||||
|
||||
Studio and LMS: add ability to lock assets (cannot be viewed unless registered
|
||||
for class).
|
||||
|
||||
LMS: First round of improvements to New (beta) Instructor Dash:
|
||||
improvements, fixes, and internationalization to the Student Info section.
|
||||
|
||||
LMS: Improved accessibility of parts of forum navigation sidebar.
|
||||
|
||||
LMS: enhanced accessibility labeling and aria support for the discussion forum
|
||||
new post dropdown as well as response and comment area labeling.
|
||||
|
||||
LMS: enhanced shib support, including detection of linked shib account
|
||||
at login page and support for the ?next= GET parameter.
|
||||
|
||||
LMS: Experimental feature using the ICE change tracker JS pkg to allow peer
|
||||
assessors to edit the original submitter's work.
|
||||
|
||||
LMS: Fixed a bug that caused links from forum user profile pages to
|
||||
threads to lead to 404s if the course id contained a '-' character.
|
||||
|
||||
Studio/LMS: Added ability to set due date formatting through Studio's Advanced
|
||||
Settings. The key is due_date_display_format, and the value should be a format
|
||||
supported by Python's strftime function.
|
||||
|
||||
Common: Added configurable backends for tracking events. Tracking events using
|
||||
the python logging module is the default backend. Support for MongoDB and a
|
||||
Django database is also available.
|
||||
|
||||
Blades: Added Learning Tools Interoperability (LTI) blade. Now LTI components
|
||||
can be included to courses.
|
||||
|
||||
LMS: Added alphabetical sorting of forum categories and subcategories.
|
||||
It is hidden behind a false defaulted course level flag.
|
||||
|
||||
@@ -33,6 +86,9 @@ logic has been consolidated into the model -- you should use new class methods
|
||||
to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating
|
||||
CourseEnrollment objects or querying them directly.
|
||||
|
||||
LMS: Added bulk email for course feature, with option to optout of individual
|
||||
course emails.
|
||||
|
||||
Studio: Email will be sent to admin address when a user requests course creator
|
||||
privileges for Studio (edge only).
|
||||
|
||||
@@ -76,7 +132,8 @@ LMS: Added endpoints for AJAX requests to enable/disable notifications
|
||||
Studio: Allow instructors of a course to designate other staff as instructors;
|
||||
this allows instructors to hand off management of a course to someone else.
|
||||
|
||||
Common: Add a manage.py that knows about edx-platform specific settings and projects
|
||||
Common: Add a manage.py that knows about edx-platform specific settings and
|
||||
projects
|
||||
|
||||
Common: Added *experimental* support for jsinput type.
|
||||
|
||||
@@ -97,19 +154,23 @@ XModule: Added *experimental* crowdsource hinting module.
|
||||
|
||||
Studio: Added support for uploading and managing PDF textbooks
|
||||
|
||||
Common: Student information is now passed to the tracking log via POST instead of GET.
|
||||
Common: Student information is now passed to the tracking log via POST instead
|
||||
of GET.
|
||||
|
||||
Blades: Added functionality and tests for new capa input type: choicetextresponse.
|
||||
Blades: Added functionality and tests for new capa input type:
|
||||
choicetextresponse.
|
||||
|
||||
Common: Add tests for documentation generation to test suite
|
||||
|
||||
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
|
||||
Blades: User answer now preserved (and changeable) after clicking "show answer"
|
||||
in choice problems
|
||||
|
||||
LMS: Removed press releases
|
||||
|
||||
Common: Updated Sass and Bourbon libraries, added Neat library
|
||||
|
||||
LMS: Add a MixedModuleStore to aggregate the XMLModuleStore and MongoMonduleStore
|
||||
LMS: Add a MixedModuleStore to aggregate the XMLModuleStore and
|
||||
MongoMonduleStore
|
||||
|
||||
LMS: Users are no longer auto-activated if they click "reset password"
|
||||
This is now done when they click on the link in the reset password
|
||||
@@ -127,10 +188,11 @@ as wide as the text to reduce accidental choice selections.
|
||||
|
||||
Studio:
|
||||
- use xblock field defaults to initialize all new instances' fields and
|
||||
only use templates as override samples.
|
||||
only use templates as override samples.
|
||||
- create new instances via in memory create_xmodule and related methods rather
|
||||
than cloning a db record.
|
||||
- have an explicit method for making a draft copy as distinct from making a new module.
|
||||
than cloning a db record.
|
||||
- have an explicit method for making a draft copy as distinct from making a
|
||||
new module.
|
||||
|
||||
Studio: Remove XML from the video component editor. All settings are
|
||||
moved to be edited as metadata.
|
||||
@@ -169,8 +231,9 @@ value of lms.start in `lms/djangoapps/django_comment_client/utils.py`
|
||||
|
||||
Studio, LMS: Make ModelTypes more strict about their expected content (for
|
||||
instance, Boolean, Integer, String), but also allow them to hold either the
|
||||
typed value, or a String that can be converted to their typed value. For example,
|
||||
an Integer can contain 3 or '3'. This changed an update to the xblock library.
|
||||
typed value, or a String that can be converted to their typed value. For
|
||||
example, an Integer can contain 3 or '3'. This changed an update to the xblock
|
||||
library.
|
||||
|
||||
LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django
|
||||
setting now run entirely outside the Python sandbox.
|
||||
@@ -181,21 +244,22 @@ Common: Have the capa module handle unicode better (especially errors)
|
||||
|
||||
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
|
||||
|
||||
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
|
||||
captions.
|
||||
Blades: Additional event tracking added to Video Alpha: fullscreen switch,
|
||||
show/hide captions.
|
||||
|
||||
CMS: Allow editors to delete uploaded files/assets
|
||||
|
||||
XModules: `XModuleDescriptor.__init__` and `XModule.__init__` dropped the
|
||||
`location` parameter (and added it as a field), and renamed `system` to `runtime`,
|
||||
to accord more closely to `XBlock.__init__`
|
||||
`location` parameter (and added it as a field), and renamed `system` to
|
||||
`runtime`, to accord more closely to `XBlock.__init__`
|
||||
|
||||
LMS: Some errors handling Non-ASCII data in XML courses have been fixed.
|
||||
|
||||
LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and
|
||||
SEGMENT_IO_LMS feature flag is on)
|
||||
|
||||
Blades: Simplify calc.py (which is used for the Numerical/Formula responses); add trig/other functions.
|
||||
Blades: Simplify calc.py (which is used for the Numerical/Formula responses);
|
||||
add trig/other functions.
|
||||
|
||||
LMS: Background colors on login, register, and courseware have been corrected
|
||||
back to white.
|
||||
@@ -214,8 +278,8 @@ Blades: Staff debug info is now accessible for Graphical Slider Tool problems.
|
||||
Blades: For Video Alpha the events ready, play, pause, seek, and speed change
|
||||
are logged on the server (in the logs).
|
||||
|
||||
Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive
|
||||
datetimes.
|
||||
Common: all dates and times are not time zone aware datetimes. No code should
|
||||
create or use struct_times nor naive datetimes.
|
||||
|
||||
Common: Developers can now have private Django settings files.
|
||||
|
||||
@@ -281,3 +345,5 @@ Common: Allow setting of authentication session cookie name.
|
||||
|
||||
LMS: Option to email students when enroll/un-enroll them.
|
||||
|
||||
Blades: Added WAI-ARIA markup to the video player controls. These are now fully
|
||||
accessible by screen readers.
|
||||
|
||||
22
CONTRIBUTING.md
Normal file
22
CONTRIBUTING.md
Normal file
@@ -0,0 +1,22 @@
|
||||
Contributions are very welcome. The easiest way is to fork the repo and then
|
||||
make a pull request from your fork. Before your pull request is merged, it will
|
||||
be reviewed by at least one person. There may be feedback so expect comments on
|
||||
the pull request. Add yourself to the AUTHORS file in your first pull request.
|
||||
|
||||
Please review:
|
||||
|
||||
* [Python Guidelines](https://github.com/edx/edx-platform/wiki/Python-Guidelines)
|
||||
* [Javascript Guidelines](https://github.com/edx/edx-platform/wiki/Javascript-Guidelines)
|
||||
* [Testing](https://github.com/edx/edx-platform/blob/master/docs/internal/testing.md)
|
||||
|
||||
Coding conventions should be followed and your commit should *increase* test
|
||||
coverage, not decrease it. For more involved contributions, you may want to
|
||||
discuss your intentions on the mailing list *before* you start coding.
|
||||
|
||||
Before your first pull request is merged, you'll need to sign the
|
||||
[individual contributor agreement](http://code.edx.org/individual-contributor-agreement.pdf)
|
||||
and send it in. This confirms you have the authority to contribute the code in
|
||||
the pull request and ensures we can relicense it.
|
||||
|
||||
If you have any questions, please ask on the
|
||||
[mailing list](https://groups.google.com/forum/#!forum/edx-code).
|
||||
5
Gemfile
5
Gemfile
@@ -6,3 +6,8 @@ gem 'neat', '~> 1.3.0'
|
||||
gem 'colorize', '~> 0.5.8'
|
||||
gem 'launchy', '~> 2.1.2'
|
||||
gem 'sys-proctable', '~> 0.9.3'
|
||||
# These gems aren't actually required; they are used by Linux and Mac to
|
||||
# detect when files change. If these gems are not installed, the system
|
||||
# will fall back to polling files.
|
||||
gem 'rb-inotify', '~> 0.9'
|
||||
gem 'rb-fsevent', '~> 0.9.3'
|
||||
|
||||
@@ -345,9 +345,9 @@ with `overview.md` to get an introduction to the architecture of the system.
|
||||
How to Contribute
|
||||
-----------------
|
||||
|
||||
Contributions are very welcome. The easiest way is to fork this repo, and then
|
||||
make a pull request from your fork. The first time you make a pull request, you
|
||||
may be asked to sign a Contributor Agreement.
|
||||
Contributions are very welcome.
|
||||
|
||||
Please read [How To Contribute](https://github.com/edx/edx-platform/wiki/How-To-Contribute) for details.
|
||||
|
||||
Reporting Security Issues
|
||||
-------------------------
|
||||
|
||||
3
Vagrantfile
vendored
3
Vagrantfile
vendored
@@ -22,12 +22,13 @@ Vagrant.configure("2") do |config|
|
||||
|
||||
config.vm.provider :virtualbox do |vb|
|
||||
# Use VBoxManage to customize the VM. For example to change memory:
|
||||
vb.customize ["modifyvm", :id, "--memory", "1024"]
|
||||
vb.customize ["modifyvm", :id, "--memory", "2048"]
|
||||
|
||||
# This setting makes it so that network access from inside the vagrant guest
|
||||
# is able to resolve DNS using the hosts VPN connection.
|
||||
vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
|
||||
end
|
||||
|
||||
config.vm.provision :shell, :path => "scripts/install-acceptance-req.sh"
|
||||
config.vm.provision :shell, :path => "scripts/vagrant-provisioning.sh"
|
||||
end
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Advanced (manual) course policy
|
||||
@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
|
||||
|
||||
|
||||
@@ -11,12 +11,16 @@ DISPLAY_NAME_KEY = "display_name"
|
||||
DISPLAY_NAME_VALUE = '"Robot Super Course"'
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select the Advanced Settings$')
|
||||
def i_select_advanced_settings(step):
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-advanced a'
|
||||
world.css_click(link_css)
|
||||
world.wait_for_requirejs(
|
||||
["jquery", "js/models/course", "js/models/settings/advanced",
|
||||
"js/views/settings/advanced", "codemirror"])
|
||||
# this shouldn't be necessary, but we experience sporadic failures otherwise
|
||||
world.wait(1)
|
||||
|
||||
|
||||
@step('I am on the Advanced Course Settings page in Studio$')
|
||||
@@ -45,7 +49,6 @@ def create_value_not_in_quotes(step):
|
||||
change_display_name_value(step, 'quote me')
|
||||
|
||||
|
||||
############### RESULTS ####################
|
||||
@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)
|
||||
@@ -88,12 +91,15 @@ def the_policy_key_value_is_changed(step):
|
||||
assert_equal(get_display_name_value(), '"foo"')
|
||||
|
||||
|
||||
############# HELPERS ###############
|
||||
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))
|
||||
assert_equal(value, world.css_find(VALUE_CSS)[index].value, "value is incorrect")
|
||||
found_value = world.css_find(VALUE_CSS)[index].value
|
||||
assert_equal(
|
||||
value, found_value,
|
||||
"Expected {} to have value {} but found {}".format(key, value, found_value)
|
||||
)
|
||||
|
||||
|
||||
def get_index_of(expected_key):
|
||||
@@ -117,4 +123,6 @@ def change_display_name_value(step, new_value):
|
||||
|
||||
def change_value(step, key, new_value):
|
||||
type_in_codemirror(get_index_of(key), new_value)
|
||||
world.wait(0.5)
|
||||
press_the_notification_button(step, "Save")
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Course checklists
|
||||
@shard_1
|
||||
Feature: CMS.Course checklists
|
||||
|
||||
Scenario: A course author sees checklists defined by edX
|
||||
Given I have opened a new course in Studio
|
||||
@@ -8,7 +9,8 @@ Feature: Course checklists
|
||||
Scenario: A course author can mark tasks as complete
|
||||
Given I have opened Checklists
|
||||
Then I can check and uncheck tasks in a checklist
|
||||
And They are correctly selected after reloading the page
|
||||
And I reload the page
|
||||
Then the tasks are correctly selected
|
||||
|
||||
# There are issues getting link to be active in browsers other than chrome
|
||||
@skip_firefox
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_equal, assert_in # pylint: disable=E0611
|
||||
from nose.tools import assert_true, assert_equal # pylint: disable=E0611
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.common.exceptions import StaleElementReferenceException
|
||||
|
||||
@@ -45,11 +45,11 @@ def i_can_check_and_uncheck_tasks(step):
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
|
||||
|
||||
@step('They are correctly selected after reloading the page$')
|
||||
def tasks_correctly_selected_after_reload(step):
|
||||
reload_the_page(step)
|
||||
@step('the tasks are correctly selected$')
|
||||
def tasks_correctly_selected(step):
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
# verify that task 7 is still selected by toggling its checkbox state and making sure that it deselects
|
||||
world.browser.execute_script("window.scrollBy(0,1000)")
|
||||
toggleTask(1, 6)
|
||||
verifyChecklist2Status(1, 7, 14)
|
||||
|
||||
@@ -61,7 +61,7 @@ def i_select_a_link_to_the_course_outline(step):
|
||||
|
||||
@step('I am brought to the course outline page$')
|
||||
def i_am_brought_to_course_outline(step):
|
||||
assert_in('Course Outline', world.css_text('.outline .page-header'))
|
||||
assert world.is_css_present('body.view-outline')
|
||||
assert_equal(1, len(world.browser.windows))
|
||||
|
||||
|
||||
@@ -109,13 +109,15 @@ def toggleTask(checklist, task):
|
||||
# TODO: figure out a way to do this in phantom and firefox
|
||||
# For now we will mark the scenerios that use this method as skipped
|
||||
def clickActionLink(checklist, task, actionText):
|
||||
# toggle checklist item to make sure that the link button is showing
|
||||
toggleTask(checklist, task)
|
||||
action_link = world.css_find('#course-checklist' + str(checklist) + ' a')[task]
|
||||
|
||||
# text will be empty initially, wait for it to populate
|
||||
def verify_action_link_text(driver):
|
||||
return world.css_text('#course-checklist' + str(checklist) + ' a', index=task) == actionText
|
||||
actualText = world.css_text('#course-checklist' + str(checklist) + ' a', index=task)
|
||||
if actualText == actionText:
|
||||
return True
|
||||
else:
|
||||
# toggle checklist item to make sure that the link button is showing
|
||||
toggleTask(checklist, task)
|
||||
return False
|
||||
|
||||
world.wait_for(verify_action_link_text)
|
||||
world.css_click('#course-checklist' + str(checklist) + ' a', index=task)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true # pylint: disable=E0611
|
||||
from nose.tools import assert_true, assert_equal, assert_in, assert_false # pylint: disable=E0611
|
||||
|
||||
from auth.authz import get_user_by_email, get_course_groupname_for_role
|
||||
from django.conf import settings
|
||||
@@ -19,8 +19,6 @@ from terrain.browser import reset_data
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
########### STEP HELPERS ##############
|
||||
|
||||
|
||||
@step('I (?:visit|access|open) the Studio homepage$')
|
||||
def i_visit_the_studio_homepage(_step):
|
||||
@@ -66,20 +64,17 @@ def select_new_course(_step, whom):
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(_step, name):
|
||||
css = 'a.action-%s' % name.lower()
|
||||
|
||||
# The button was clicked if either the notification bar is gone,
|
||||
# or we see an error overlaying it (expected for invalid inputs).
|
||||
def button_clicked():
|
||||
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
|
||||
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
return confirmation_dismissed or error_showing
|
||||
if world.is_firefox():
|
||||
# This is done to explicitly make the changes save on firefox. It will remove focus from the previously focused element
|
||||
world.trigger_event(css, event='focus')
|
||||
world.browser.execute_script("$('{}').click()".format(css))
|
||||
else:
|
||||
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % 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 a.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 "(.*)"$')
|
||||
@@ -110,7 +105,6 @@ def i_see_a_confirmation(step):
|
||||
assert world.is_css_present(confirmation_css)
|
||||
|
||||
|
||||
####### HELPER FUNCTIONS ##############
|
||||
def open_new_course():
|
||||
world.clear_courses()
|
||||
create_studio_user()
|
||||
@@ -156,8 +150,20 @@ def log_into_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('h2.title', 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
|
||||
"""
|
||||
for role in ("staff", "instructor"):
|
||||
groupname = get_course_groupname_for_role(course.location, role)
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
|
||||
assert uname in world.css_text('h2.title', max_attempts=15)
|
||||
|
||||
def create_a_course():
|
||||
course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
@@ -167,13 +173,7 @@ def create_a_course():
|
||||
if not user:
|
||||
user = get_user_by_email('robot+studio@edx.org')
|
||||
|
||||
# Add the user to the instructor group of the course
|
||||
# so they will have the permissions to see it in studio
|
||||
for role in ("staff", "instructor"):
|
||||
groupname = get_course_groupname_for_role(course.location, role)
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
add_course_author(user, course)
|
||||
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
@@ -229,20 +229,42 @@ def open_new_unit(step):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
step.given('I have added a new subsection')
|
||||
step.given('I expand the first section')
|
||||
old_url = world.browser.url
|
||||
world.css_click('a.new-unit-item')
|
||||
world.wait_for(lambda x: world.browser.url != old_url)
|
||||
|
||||
|
||||
@step('the save button is disabled$')
|
||||
@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')
|
||||
|
||||
|
||||
@step('I confirm the prompt')
|
||||
def confirm_the_prompt(step):
|
||||
prompt_css = 'a.button.action-primary'
|
||||
world.css_click(prompt_css, success_condition=lambda: not world.css_visible(prompt_css))
|
||||
|
||||
def click_button(btn_css):
|
||||
world.css_click(btn_css)
|
||||
return world.css_find(btn_css).visible == False
|
||||
|
||||
prompt_css = 'div.prompt.has-actions'
|
||||
world.wait_for_visible(prompt_css)
|
||||
|
||||
btn_css = 'a.button.action-primary'
|
||||
world.wait_for_visible(btn_css)
|
||||
|
||||
# Sometimes you can do a click before the prompt is up.
|
||||
# Thus we need some retry logic here.
|
||||
world.wait_for(lambda _driver: click_button(btn_css))
|
||||
|
||||
assert_false(world.css_find(btn_css).visible)
|
||||
|
||||
|
||||
@step(u'I am shown a (.*)$')
|
||||
@@ -251,6 +273,7 @@ def i_am_shown_a_notification(step, notification_type):
|
||||
|
||||
|
||||
def type_in_codemirror(index, text):
|
||||
world.wait(1) # For now, slow this down so that it works. TODO: fix it.
|
||||
world.css_click("div.CodeMirror-lines", index=index)
|
||||
world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')")
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
@@ -262,6 +285,7 @@ def type_in_codemirror(index, text):
|
||||
g._element.send_keys(text)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('div.CodeMirror', index=index, event='blur')
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
def upload_file(filename):
|
||||
@@ -270,3 +294,48 @@ def upload_file(filename):
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
button_css = '.upload-dialog .action-upload'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@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_by_email(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":
|
||||
user.is_staff = True
|
||||
else:
|
||||
if role_name == "admin":
|
||||
# admins get staff privileges, as well
|
||||
roles = ("staff", "instructor")
|
||||
else:
|
||||
roles = ("staff",)
|
||||
location = world.scenario_dict["COURSE"].location
|
||||
for role in roles:
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
|
||||
|
||||
@step('I log out')
|
||||
def log_out(_step):
|
||||
world.visit('logout')
|
||||
|
||||
@@ -1,87 +1,88 @@
|
||||
Feature: Component Adding
|
||||
@shard_1
|
||||
Feature: CMS.Component Adding
|
||||
As a course author, I want to be able to add a wide variety of components
|
||||
|
||||
@skip
|
||||
Scenario: I can add components
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
When I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
| Blank HTML |
|
||||
| LaTex |
|
||||
| Blank Problem|
|
||||
| Dropdown |
|
||||
| Multi Choice |
|
||||
| Numerical |
|
||||
| Text Input |
|
||||
| Advanced |
|
||||
| Circuit |
|
||||
| Custom Python|
|
||||
| Image Mapped |
|
||||
| Math Input |
|
||||
| Problem LaTex|
|
||||
| Adaptive Hint|
|
||||
| Video |
|
||||
Then I see the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
| Blank HTML |
|
||||
| LaTex |
|
||||
| Blank Problem|
|
||||
| Dropdown |
|
||||
| Multi Choice |
|
||||
| Numerical |
|
||||
| Text Input |
|
||||
| Advanced |
|
||||
| Circuit |
|
||||
| Custom Python|
|
||||
| Image Mapped |
|
||||
| Math Input |
|
||||
| Problem LaTex|
|
||||
| Adaptive Hint|
|
||||
| Video |
|
||||
Scenario: I can add single step components
|
||||
Given I am in Studio editing a new unit
|
||||
When I add this type of single step component:
|
||||
| Component |
|
||||
| Discussion |
|
||||
| Video |
|
||||
Then I see this type of single step component:
|
||||
| Component |
|
||||
| Discussion |
|
||||
| Video |
|
||||
|
||||
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 |
|
||||
| E-text Written in LaTeX |
|
||||
Then I see HTML components in this order:
|
||||
| Component |
|
||||
| Text |
|
||||
| Announcement |
|
||||
| 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 |
|
||||
| Dropdown |
|
||||
| Multiple Choice |
|
||||
| Numerical Input |
|
||||
| Text Input |
|
||||
Then I see Problem components in this order:
|
||||
| Component |
|
||||
| Blank Common Problem |
|
||||
| Dropdown |
|
||||
| Multiple Choice |
|
||||
| Numerical Input |
|
||||
| Text Input |
|
||||
|
||||
Scenario: I can add Advanced Problem components
|
||||
Given I am in Studio editing a new unit
|
||||
When I add this type of Advanced Problem component:
|
||||
| Component |
|
||||
| Blank Advanced Problem |
|
||||
| Circuit Schematic Builder |
|
||||
| Custom Python-Evaluated Input |
|
||||
| Drag and Drop |
|
||||
| Image Mapped Input |
|
||||
| Math Expression Input |
|
||||
| Problem Written in LaTeX |
|
||||
| Problem with Adaptive Hint |
|
||||
Then I see Problem components in this order:
|
||||
| Component |
|
||||
| Blank Advanced Problem |
|
||||
| Circuit Schematic Builder |
|
||||
| Custom Python-Evaluated Input |
|
||||
| Drag and Drop |
|
||||
| Image Mapped Input |
|
||||
| Math Expression Input |
|
||||
| Problem Written in LaTeX |
|
||||
| Problem with Adaptive Hint |
|
||||
|
||||
Scenario: I see a prompt on delete
|
||||
Given I am in Studio editing a new unit
|
||||
And I add a "Discussion" "single step" component
|
||||
And I delete a component
|
||||
Then I am shown a prompt
|
||||
|
||||
@skip
|
||||
Scenario: I can delete Components
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
And I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
| Blank HTML |
|
||||
| LaTex |
|
||||
| Blank Problem|
|
||||
| Dropdown |
|
||||
| Multi Choice |
|
||||
| Numerical |
|
||||
| Text Input |
|
||||
| Advanced |
|
||||
| Circuit |
|
||||
| Custom Python|
|
||||
| Image Mapped |
|
||||
| Math Input |
|
||||
| Problem LaTex|
|
||||
| Adaptive Hint|
|
||||
| Video |
|
||||
When I will confirm all alerts
|
||||
Given I am in Studio editing a new unit
|
||||
And I add a "Discussion" "single step" component
|
||||
And I add a "Text" "HTML" component
|
||||
And I add a "Blank Common Problem" "Problem" component
|
||||
And I add a "Blank Advanced Problem" "Advanced Problem" component
|
||||
And I delete all components
|
||||
Then I see no components
|
||||
|
||||
Scenario: I see a prompt on delete
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
And I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
And I delete a component
|
||||
Then I am shown a prompt
|
||||
|
||||
Scenario: I see a notification on save
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
And I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
Scenario: I see a notification on save
|
||||
Given I am in Studio editing a new unit
|
||||
And I add a "Discussion" "single step" component
|
||||
And I edit and save a component
|
||||
Then I am shown a notification
|
||||
|
||||
@@ -2,38 +2,147 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true # pylint: disable=E0611
|
||||
|
||||
DATA_LOCATION = 'i4x://edx/templates'
|
||||
from nose.tools import assert_true, assert_in, assert_equal # pylint: disable=E0611
|
||||
from common import create_studio_user, add_course_author, log_into_studio
|
||||
|
||||
|
||||
@step(u'I am editing a new unit')
|
||||
@step(u'I am in Studio editing a new unit$')
|
||||
def add_unit(step):
|
||||
css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item',
|
||||
'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item']
|
||||
world.clear_courses()
|
||||
course = world.CourseFactory.create()
|
||||
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.wait_for_requirejs([
|
||||
"jquery", "js/models/course", "coffee/src/models/module",
|
||||
"coffee/src/views/unit", "jquery.ui",
|
||||
])
|
||||
world.wait_for_mathjax()
|
||||
css_selectors = [
|
||||
'a.course-link', 'div.section-item a.expand-collapse-icon',
|
||||
'a.new-unit-item',
|
||||
]
|
||||
for selector in css_selectors:
|
||||
world.css_click(selector)
|
||||
|
||||
|
||||
@step(u'I add the following components:')
|
||||
def add_components(step):
|
||||
for component in [step_hash['Component'] for step_hash in step.hashes]:
|
||||
assert component in COMPONENT_DICTIONARY
|
||||
for css in COMPONENT_DICTIONARY[component]['steps']:
|
||||
world.css_click(css)
|
||||
@step(u'I add this type of single step component:$')
|
||||
def add_a_single_step_component(step):
|
||||
world.wait_for_xmodule()
|
||||
for step_hash in step.hashes:
|
||||
component = step_hash['Component']
|
||||
assert_in(component, ['Discussion', 'Video'])
|
||||
css_selector = 'a[data-type="{}"]'.format(component.lower())
|
||||
world.css_click(css_selector)
|
||||
|
||||
|
||||
@step(u'I see the following components')
|
||||
def check_components(step):
|
||||
for component in [step_hash['Component'] for step_hash in step.hashes]:
|
||||
assert component in COMPONENT_DICTIONARY
|
||||
assert_true(COMPONENT_DICTIONARY[component]['found_func'](), "{} couldn't be found".format(component))
|
||||
@step(u'I 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 = 'section.xmodule_{}Module'.format(component)
|
||||
assert_true(world.is_css_present(component_css),
|
||||
"{} couldn't be found".format(component))
|
||||
|
||||
|
||||
@step(u'I delete all components')
|
||||
@step(u'I add this type of( Advanced)? (HTML|Problem) component:$')
|
||||
def add_a_multi_step_component(step, is_advanced, category):
|
||||
def click_advanced():
|
||||
css = 'ul.problem-type-tabs a[href="#tab2"]'
|
||||
world.css_click(css)
|
||||
my_css = 'ul.problem-type-tabs li.ui-state-active a[href="#tab2"]'
|
||||
assert(world.css_find(my_css))
|
||||
|
||||
def find_matching_link():
|
||||
"""
|
||||
Find the link with the specified text. There should be one and only one.
|
||||
"""
|
||||
# The tab shows links for the given category
|
||||
links = world.css_find('div.new-component-{} a'.format(category))
|
||||
|
||||
# Find the link whose text matches what you're looking for
|
||||
matched_links = [link for link in links if link.text == step_hash['Component']]
|
||||
|
||||
# There should be one and only one
|
||||
assert_equal(len(matched_links), 1)
|
||||
return matched_links[0]
|
||||
|
||||
def click_link():
|
||||
link.click()
|
||||
|
||||
world.wait_for_xmodule()
|
||||
category = category.lower()
|
||||
for step_hash in step.hashes:
|
||||
css_selector = 'a[data-type="{}"]'.format(category)
|
||||
world.css_click(css_selector)
|
||||
world.wait_for_invisible(css_selector)
|
||||
|
||||
if is_advanced:
|
||||
# Sometimes this click does not work if you go too fast.
|
||||
world.retry_on_exception(click_advanced, max_attempts=5, ignored_exceptions=AssertionError)
|
||||
|
||||
# Retry this in case the list is empty because you tried too fast.
|
||||
link = world.retry_on_exception(func=find_matching_link, ignored_exceptions=AssertionError)
|
||||
|
||||
# Wait for the link to be clickable. If you go too fast it is not.
|
||||
world.retry_on_exception(click_link)
|
||||
|
||||
|
||||
@step(u'I see (HTML|Problem) components in this order:')
|
||||
def see_a_multi_step_component(step, category):
|
||||
components = world.css_find('li.component section.xmodule_display')
|
||||
for idx, step_hash in enumerate(step.hashes):
|
||||
if category == 'HTML':
|
||||
html_matcher = {
|
||||
'Text':
|
||||
'\n \n',
|
||||
'Announcement':
|
||||
'<p> Words of encouragement! This is a short note that most students will read. </p>',
|
||||
'E-text Written in LaTeX':
|
||||
'<h2>Example: E-text page</h2>',
|
||||
}
|
||||
assert_in(html_matcher[step_hash['Component']], components[idx].html)
|
||||
else:
|
||||
assert_in(step_hash['Component'].upper(), components[idx].text)
|
||||
|
||||
|
||||
@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):
|
||||
for _ in range(len(COMPONENT_DICTIONARY)):
|
||||
world.css_click('a.delete-button')
|
||||
world.wait_for_xmodule()
|
||||
delete_btn_css = 'a.delete-button'
|
||||
prompt_css = 'div#prompt-warning'
|
||||
btn_css = '{} a.button.action-primary'.format(prompt_css)
|
||||
saving_mini_css = 'div#page-notification .wrapper-notification-mini'
|
||||
count = len(world.css_find('ol.components li.component'))
|
||||
for _ in range(int(count)):
|
||||
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')
|
||||
@@ -50,88 +159,3 @@ def delete_one_component(step):
|
||||
def edit_and_save_component(step):
|
||||
world.css_click('.edit-button')
|
||||
world.css_click('.save-button')
|
||||
|
||||
|
||||
def step_selector_list(data_type, path, index=1):
|
||||
selector_list = ['a[data-type="{}"]'.format(data_type)]
|
||||
if index != 1:
|
||||
selector_list.append('a[id="ui-id-{}"]'.format(index))
|
||||
if path is not None:
|
||||
selector_list.append('a[data-location="{}/{}/{}"]'.format(DATA_LOCATION, data_type, path))
|
||||
return selector_list
|
||||
|
||||
|
||||
def found_text_func(text):
|
||||
return lambda: world.browser.is_text_present(text)
|
||||
|
||||
|
||||
def found_css_func(css):
|
||||
return lambda: world.is_css_present(css, wait_time=2)
|
||||
|
||||
COMPONENT_DICTIONARY = {
|
||||
'Discussion': {
|
||||
'steps': step_selector_list('discussion', None),
|
||||
'found_func': found_css_func('section.xmodule_DiscussionModule')
|
||||
},
|
||||
'Blank HTML': {
|
||||
'steps': step_selector_list('html', 'Blank_HTML_Page'),
|
||||
#this one is a blank html so a more refined search is being done
|
||||
'found_func': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')]
|
||||
},
|
||||
'LaTex': {
|
||||
'steps': step_selector_list('html', 'E-text_Written_in_LaTeX'),
|
||||
'found_func': found_text_func('EXAMPLE: E-TEXT PAGE')
|
||||
},
|
||||
'Blank Problem': {
|
||||
'steps': step_selector_list('problem', 'Blank_Common_Problem'),
|
||||
'found_func': found_text_func('BLANK COMMON PROBLEM')
|
||||
},
|
||||
'Dropdown': {
|
||||
'steps': step_selector_list('problem', 'Dropdown'),
|
||||
'found_func': found_text_func('DROPDOWN')
|
||||
},
|
||||
'Multi Choice': {
|
||||
'steps': step_selector_list('problem', 'Multiple_Choice'),
|
||||
'found_func': found_text_func('MULTIPLE CHOICE')
|
||||
},
|
||||
'Numerical': {
|
||||
'steps': step_selector_list('problem', 'Numerical_Input'),
|
||||
'found_func': found_text_func('NUMERICAL INPUT')
|
||||
},
|
||||
'Text Input': {
|
||||
'steps': step_selector_list('problem', 'Text_Input'),
|
||||
'found_func': found_text_func('TEXT INPUT')
|
||||
},
|
||||
'Advanced': {
|
||||
'steps': step_selector_list('problem', 'Blank_Advanced_Problem', index=2),
|
||||
'found_func': found_text_func('BLANK ADVANCED PROBLEM')
|
||||
},
|
||||
'Circuit': {
|
||||
'steps': step_selector_list('problem', 'Circuit_Schematic_Builder', index=2),
|
||||
'found_func': found_text_func('CIRCUIT SCHEMATIC BUILDER')
|
||||
},
|
||||
'Custom Python': {
|
||||
'steps': step_selector_list('problem', 'Custom_Python-Evaluated_Input', index=2),
|
||||
'found_func': found_text_func('CUSTOM PYTHON-EVALUATED INPUT')
|
||||
},
|
||||
'Image Mapped': {
|
||||
'steps': step_selector_list('problem', 'Image_Mapped_Input', index=2),
|
||||
'found_func': found_text_func('IMAGE MAPPED INPUT')
|
||||
},
|
||||
'Math Input': {
|
||||
'steps': step_selector_list('problem', 'Math_Expression_Input', index=2),
|
||||
'found_func': found_text_func('MATH EXPRESSION INPUT')
|
||||
},
|
||||
'Problem LaTex': {
|
||||
'steps': step_selector_list('problem', 'Problem_Written_in_LaTeX', index=2),
|
||||
'found_func': found_text_func('PROBLEM WRITTEN IN LATEX')
|
||||
},
|
||||
'Adaptive Hint': {
|
||||
'steps': step_selector_list('problem', 'Problem_with_Adaptive_Hint', index=2),
|
||||
'found_func': found_text_func('PROBLEM WITH ADAPTIVE HINT')
|
||||
},
|
||||
'Video': {
|
||||
'steps': step_selector_list('video', None),
|
||||
'found_func': found_css_func('section.xmodule_VideoModule')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world
|
||||
from nose.tools import assert_equal # pylint: disable=E0611
|
||||
from nose.tools import assert_equal, assert_true # pylint: disable=E0611
|
||||
from terrain.steps import reload_the_page
|
||||
|
||||
|
||||
@@ -12,24 +12,31 @@ def create_component_instance(step, component_button_css, category,
|
||||
has_multiple_templates=True):
|
||||
|
||||
click_new_component_button(step, component_button_css)
|
||||
|
||||
if category in ('problem', 'html'):
|
||||
|
||||
def animation_done(_driver):
|
||||
return world.browser.evaluate_script("$('div.new-component').css('display')") == 'none'
|
||||
script = "$('div.new-component').css('display')"
|
||||
return world.browser.evaluate_script(script) == 'none'
|
||||
|
||||
world.wait_for(animation_done)
|
||||
|
||||
if has_multiple_templates:
|
||||
click_component_from_menu(category, boilerplate, expected_css)
|
||||
|
||||
assert_equal(
|
||||
1,
|
||||
len(world.css_find(expected_css)),
|
||||
"Component instance with css {css} was not created successfully".format(css=expected_css))
|
||||
if category in ('video',):
|
||||
world.wait_for_xmodule()
|
||||
|
||||
assert_true(world.is_css_present(expected_css))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
step.given('I have clicked the new unit button')
|
||||
world.wait_for_requirejs(
|
||||
["jquery", "js/models/course", "coffee/src/models/module",
|
||||
"coffee/src/views/unit", "jquery.ui"]
|
||||
)
|
||||
world.css_click(component_button_css)
|
||||
|
||||
|
||||
@@ -48,8 +55,7 @@ def click_component_from_menu(category, boilerplate, expected_css):
|
||||
elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category)
|
||||
elements = world.css_find(elem_css)
|
||||
assert_equal(len(elements), 1)
|
||||
world.wait_for(lambda _driver: world.css_visible(elem_css))
|
||||
world.css_click(elem_css, success_condition=lambda: 1 == len(world.css_find(expected_css)))
|
||||
world.css_click(elem_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -67,13 +73,29 @@ def edit_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
|
||||
value: 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].value)
|
||||
# Check specifically for the list type; it has a different structure
|
||||
|
||||
# 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'):
|
||||
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
else:
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
|
||||
settingClearButton = setting.find_by_css('.setting-clear')[0]
|
||||
assert_equal(explicitly_set, settingClearButton.has_class('active'))
|
||||
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
|
||||
@@ -93,6 +115,7 @@ def verify_all_setting_entries(expected_entries):
|
||||
@world.absorb
|
||||
def save_component_and_reopen(step):
|
||||
world.css_click("a.save-button")
|
||||
world.wait_for_ajax_complete()
|
||||
# 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)
|
||||
@@ -122,6 +145,7 @@ def get_setting_entry(label):
|
||||
return None
|
||||
return world.retry_on_exception(get_setting)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry_index(label):
|
||||
def get_index():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Course Overview
|
||||
@shard_1
|
||||
Feature: CMS.Course Overview
|
||||
In order to quickly view the details of a course's section and set release dates and grading
|
||||
As a course author
|
||||
I want to use the course overview page
|
||||
@@ -68,7 +69,7 @@ Feature: Course Overview
|
||||
# Safari does not have moveMouseTo implemented
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: Notification is shown on subsection reorder
|
||||
Scenario: Notification is shown on subsection reorder
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I have added a new subsection
|
||||
|
||||
@@ -72,7 +72,7 @@ def i_click_the_text_span(step, text):
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.browser.is_element_present_by_css(span_locator))
|
||||
# first make sure that the expand/collapse text is the one you expected
|
||||
assert_equal(world.browser.find_by_css(span_locator).value, text)
|
||||
assert_true(world.css_has_value(span_locator, text))
|
||||
world.css_click(span_locator)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Course Settings
|
||||
@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
|
||||
@@ -8,7 +9,8 @@ Feature: Course Settings
|
||||
When I select Schedule and Details
|
||||
And I set course dates
|
||||
And I press the "Save" notification button
|
||||
Then I see the set dates on refresh
|
||||
And I reload the page
|
||||
Then I see the set dates
|
||||
|
||||
# IE has trouble with saving information
|
||||
@skip_internetexplorer
|
||||
@@ -16,7 +18,8 @@ Feature: Course Settings
|
||||
Given I have set course dates
|
||||
And I clear all the dates except start
|
||||
And I press the "Save" notification button
|
||||
Then I see cleared dates on refresh
|
||||
And I reload the page
|
||||
Then I see cleared dates
|
||||
|
||||
# IE has trouble with saving information
|
||||
@skip_internetexplorer
|
||||
@@ -25,7 +28,8 @@ Feature: Course Settings
|
||||
And I press the "Save" notification button
|
||||
And I clear the course start date
|
||||
Then I receive a warning about course start date
|
||||
And The previously set start date is shown on refresh
|
||||
And I reload the page
|
||||
And the previously set start date is shown
|
||||
|
||||
# IE has trouble with saving information
|
||||
# Safari gets CSRF token errors
|
||||
@@ -36,7 +40,8 @@ Feature: Course Settings
|
||||
And I have entered a new course start date
|
||||
And I press the "Save" notification button
|
||||
Then The warning about course start date goes away
|
||||
And My new course start date is shown on refresh
|
||||
And I reload the page
|
||||
Then my new course start date is shown
|
||||
|
||||
# Safari does not save + refresh properly through sauce labs
|
||||
@skip_safari
|
||||
@@ -44,7 +49,8 @@ Feature: Course Settings
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
When I change fields
|
||||
Then I do not see the new changes persisted on refresh
|
||||
And I reload the page
|
||||
Then I do not see the changes
|
||||
|
||||
# Safari does not save + refresh properly through sauce labs
|
||||
@skip_safari
|
||||
@@ -87,7 +93,7 @@ Feature: Course Settings
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I change the "Course Start Date" field to ""
|
||||
Then the save button is disabled
|
||||
Then the save notification button is disabled
|
||||
|
||||
Scenario: User can upload course image
|
||||
Given I have opened a new course in Studio
|
||||
|
||||
@@ -31,6 +31,9 @@ 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$')
|
||||
@@ -51,12 +54,6 @@ def test_and_i_set_course_dates(step):
|
||||
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('Then I see the set dates on refresh$')
|
||||
def test_then_i_see_the_set_dates_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
i_see_the_set_dates()
|
||||
|
||||
|
||||
@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, '')
|
||||
@@ -64,9 +61,8 @@ def test_and_i_clear_all_the_dates_except_start(step):
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
|
||||
@step('Then I see cleared dates on refresh$')
|
||||
def test_then_i_see_cleared_dates_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
@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, '')
|
||||
@@ -92,9 +88,8 @@ def test_i_receive_a_warning_about_course_start_date(step):
|
||||
assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
|
||||
|
||||
|
||||
@step('The previously set start date is shown on refresh$')
|
||||
def test_the_previously_set_start_date_is_shown_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
@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)
|
||||
|
||||
@@ -113,14 +108,13 @@ def test_i_have_entered_a_new_course_start_date(step):
|
||||
|
||||
@step('The warning about course start date goes away$')
|
||||
def test_the_warning_about_course_start_date_goes_away(step):
|
||||
assert_equal(0, len(world.css_find('.message-error')))
|
||||
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 on refresh$')
|
||||
def test_my_new_course_start_date_is_shown_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
@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)
|
||||
@@ -134,16 +128,6 @@ def test_i_change_fields(step):
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777')
|
||||
|
||||
|
||||
@step('I do not see the new changes persisted on refresh$')
|
||||
def test_changes_not_shown_on_refresh(step):
|
||||
step.then('Then I see the set dates on refresh')
|
||||
|
||||
|
||||
@step('I do not see the changes')
|
||||
def test_i_do_not_see_changes(_step):
|
||||
i_see_the_set_dates()
|
||||
|
||||
|
||||
@step('I change the course overview')
|
||||
def test_change_course_overview(_step):
|
||||
type_in_codemirror(0, "<h1>Overview</h1>")
|
||||
@@ -168,11 +152,8 @@ def i_see_new_course_image(_step):
|
||||
img = images[0]
|
||||
expected_src = '/c4x/MITx/999/asset/image.jpg'
|
||||
# Don't worry about the domain in the URL
|
||||
try:
|
||||
assert img['src'].endswith(expected_src)
|
||||
except AssertionError as e:
|
||||
e.args += ('Was looking for {}'.format(expected_src), 'Found {}'.format(img['src']))
|
||||
raise
|
||||
assert img['src'].endswith(expected_src), "Was looking for {expected}, found {actual}".format(
|
||||
expected=expected_src, actual=img['src'])
|
||||
|
||||
|
||||
@step('the image URL should be present in the field')
|
||||
@@ -200,7 +181,9 @@ def verify_date_or_time(css, date_or_time):
|
||||
assert_equal(date_or_time, world.css_value(css))
|
||||
|
||||
|
||||
def i_see_the_set_dates():
|
||||
@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`.
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Course Team
|
||||
@shard_2
|
||||
Feature: CMS.Course Team
|
||||
As a course author, I want to be able to add others to my team
|
||||
|
||||
Scenario: Admins can add other users
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import create_studio_user
|
||||
from django.contrib.auth.models import Group
|
||||
from auth.authz import get_course_groupname_for_role, get_user_by_email
|
||||
from nose.tools import assert_true # pylint: disable=E0611
|
||||
|
||||
PASSWORD = 'test'
|
||||
EMAIL_EXTENSION = '@edx.org'
|
||||
from nose.tools import assert_true, assert_in # pylint: disable=E0611
|
||||
|
||||
|
||||
@step(u'(I am viewing|s?he views) the course team settings')
|
||||
@@ -18,24 +13,6 @@ def view_grading_settings(_step, whom):
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step(u'the user "([^"]*)" exists( as a course (admin|staff member))?$')
|
||||
def create_other_user(_step, name, has_extra_perms, role_name):
|
||||
email = name + EMAIL_EXTENSION
|
||||
user = create_studio_user(uname=name, password=PASSWORD, email=email)
|
||||
if has_extra_perms:
|
||||
location = world.scenario_dict["COURSE"].location
|
||||
if role_name == "admin":
|
||||
# admins get staff privileges, as well
|
||||
roles = ("staff", "instructor")
|
||||
else:
|
||||
roles = ("staff",)
|
||||
for role in roles:
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
|
||||
|
||||
@step(u'I add "([^"]*)" to the course team')
|
||||
def add_other_user(_step, name):
|
||||
new_user_css = 'a.create-user-button'
|
||||
@@ -43,7 +20,7 @@ def add_other_user(_step, name):
|
||||
world.wait(0.5)
|
||||
|
||||
email_css = 'input#user-email-input'
|
||||
world.css_fill(email_css, name + EMAIL_EXTENSION)
|
||||
world.css_fill(email_css, name + '@edx.org')
|
||||
if world.is_firefox():
|
||||
world.trigger_event(email_css)
|
||||
confirm_css = 'form.create-user button.action-primary'
|
||||
@@ -53,7 +30,7 @@ def add_other_user(_step, name):
|
||||
@step(u'I delete "([^"]*)" from the course team')
|
||||
def delete_other_user(_step, name):
|
||||
to_delete_css = '.user-item .item-actions a.remove-user[data-id="{email}"]'.format(
|
||||
email="{0}{1}".format(name, EMAIL_EXTENSION))
|
||||
email="{0}{1}".format(name, '@edx.org'))
|
||||
world.css_click(to_delete_css)
|
||||
# confirm prompt
|
||||
# need to wait for the animation to be done, there isn't a good success condition that won't work both on latest chrome and jenkins
|
||||
@@ -74,7 +51,7 @@ def other_delete_self(_step):
|
||||
@step(u'I make "([^"]*)" a course team admin')
|
||||
def make_course_team_admin(_step, name):
|
||||
admin_btn_css = '.user-item[data-email="{email}"] .user-actions .add-admin-role'.format(
|
||||
email=name+EMAIL_EXTENSION)
|
||||
email=name+'@edx.org')
|
||||
world.css_click(admin_btn_css)
|
||||
|
||||
|
||||
@@ -83,63 +60,44 @@ def remove_course_team_admin(_step, outer_capture, name):
|
||||
if outer_capture == "myself":
|
||||
email = world.scenario_dict["USER"].email
|
||||
else:
|
||||
email = name + EMAIL_EXTENSION
|
||||
email = name + '@edx.org'
|
||||
admin_btn_css = '.user-item[data-email="{email}"] .user-actions .remove-admin-role'.format(
|
||||
email=email)
|
||||
world.css_click(admin_btn_css)
|
||||
|
||||
|
||||
@step(u'"([^"]*)" logs in$')
|
||||
def other_user_login(_step, name):
|
||||
world.visit('logout')
|
||||
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 + EMAIL_EXTENSION)
|
||||
login_form.find_by_name('password').fill(PASSWORD)
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
assert_true(world.is_css_present('.new-course-button'))
|
||||
world.scenario_dict['USER'] = get_user_by_email(name + EMAIL_EXTENSION)
|
||||
|
||||
|
||||
@step(u'I( do not)? see the course on my page')
|
||||
@step(u's?he does( not)? see the course on (his|her) page')
|
||||
def see_course(_step, inverted, gender='self'):
|
||||
def see_course(_step, do_not_see, gender='self'):
|
||||
class_css = 'h3.course-title'
|
||||
all_courses = world.css_find(class_css, wait_time=1)
|
||||
all_names = [item.html for item in all_courses]
|
||||
if inverted:
|
||||
assert not world.scenario_dict['COURSE'].display_name in all_names
|
||||
if do_not_see:
|
||||
assert world.is_css_not_present(class_css)
|
||||
else:
|
||||
assert world.scenario_dict['COURSE'].display_name in all_names
|
||||
all_courses = world.css_find(class_css)
|
||||
all_names = [item.html for item in all_courses]
|
||||
assert_in(world.scenario_dict['COURSE'].display_name, all_names)
|
||||
|
||||
|
||||
@step(u'"([^"]*)" should( not)? be marked as an admin')
|
||||
def marked_as_admin(_step, name, inverted):
|
||||
def marked_as_admin(_step, name, not_marked_admin):
|
||||
flag_css = '.user-item[data-email="{email}"] .flag-role.flag-role-admin'.format(
|
||||
email=name+EMAIL_EXTENSION)
|
||||
if inverted:
|
||||
email=name+'@edx.org')
|
||||
if not_marked_admin:
|
||||
assert world.is_css_not_present(flag_css)
|
||||
else:
|
||||
assert world.is_css_present(flag_css)
|
||||
|
||||
|
||||
@step(u'I should( not)? be marked as an admin')
|
||||
def self_marked_as_admin(_step, inverted):
|
||||
return marked_as_admin(_step, "robot+studio", inverted)
|
||||
def self_marked_as_admin(_step, not_marked_admin):
|
||||
return marked_as_admin(_step, "robot+studio", not_marked_admin)
|
||||
|
||||
|
||||
@step(u'I can(not)? delete users')
|
||||
@step(u's?he can(not)? delete users')
|
||||
def can_delete_users(_step, inverted):
|
||||
def can_delete_users(_step, can_not_delete):
|
||||
to_delete_css = 'a.remove-user'
|
||||
if inverted:
|
||||
if can_not_delete:
|
||||
assert world.is_css_not_present(to_delete_css)
|
||||
else:
|
||||
assert world.is_css_present(to_delete_css)
|
||||
@@ -147,9 +105,9 @@ def can_delete_users(_step, inverted):
|
||||
|
||||
@step(u'I can(not)? add users')
|
||||
@step(u's?he can(not)? add users')
|
||||
def can_add_users(_step, inverted):
|
||||
def can_add_users(_step, can_not_add):
|
||||
add_css = 'a.create-user-button'
|
||||
if inverted:
|
||||
if can_not_add:
|
||||
assert world.is_css_not_present(add_css)
|
||||
else:
|
||||
assert world.is_css_present(add_css)
|
||||
@@ -157,13 +115,13 @@ def can_add_users(_step, inverted):
|
||||
|
||||
@step(u'I can(not)? make ("([^"]*)"|myself) a course team admin')
|
||||
@step(u's?he can(not)? make ("([^"]*)"|me) a course team admin')
|
||||
def can_make_course_admin(_step, inverted, outer_capture, name):
|
||||
def can_make_course_admin(_step, can_not_make_admin, outer_capture, name):
|
||||
if outer_capture == "myself":
|
||||
email = world.scenario_dict["USER"].email
|
||||
else:
|
||||
email = name + EMAIL_EXTENSION
|
||||
email = name + '@edx.org'
|
||||
add_button_css = '.user-item[data-email="{email}"] .add-admin-role'.format(email=email)
|
||||
if inverted:
|
||||
if can_not_make_admin:
|
||||
assert world.is_css_not_present(add_button_css)
|
||||
else:
|
||||
assert world.is_css_present(add_button_css)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Course updates
|
||||
@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
|
||||
@@ -45,3 +46,25 @@ Feature: Course updates
|
||||
When I modify the handout to "<ol>Test</ol>"
|
||||
Then I see the handout "Test"
|
||||
And I see a "saving" notification
|
||||
|
||||
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 update "/c4x/MITx/999/asset/my_img.jpg"
|
||||
And I change the update from "/static/my_img.jpg" to "<img src='/static/modified.jpg'/>"
|
||||
Then I should see the update "/c4x/MITx/999/asset/modified.jpg"
|
||||
And when I reload the page
|
||||
Then I should see the update "/c4x/MITx/999/asset/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 "/c4x/MITx/999/asset/my_img.jpg"
|
||||
And I change the handout from "/static/my_img.jpg" to "<img src='/static/modified.jpg'/>"
|
||||
Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
|
||||
And when I reload the page
|
||||
Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from lettuce import world, step
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from common import type_in_codemirror
|
||||
from nose.tools import assert_in # pylint: disable=E0611
|
||||
|
||||
|
||||
@step(u'I go to the course updates page')
|
||||
@@ -21,14 +22,17 @@ def add_update(_step, text):
|
||||
change_text(text)
|
||||
|
||||
|
||||
@step(u'I should( not)? see the update "([^"]*)"$')
|
||||
def check_update(_step, doesnt_see_update, text):
|
||||
@step(u'I should see the update "([^"]*)"$')
|
||||
def check_update(_step, text):
|
||||
update_css = 'div.update-contents'
|
||||
update = world.css_find(update_css, wait_time=1)
|
||||
if doesnt_see_update:
|
||||
assert len(update) == 0 or not text in update.html
|
||||
else:
|
||||
assert text in update.html
|
||||
update_html = world.css_find(update_css).html
|
||||
assert_in(text, 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 "([^"]*)"$')
|
||||
@@ -38,6 +42,16 @@ def modify_update(_step, text):
|
||||
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 a.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 a.edit-button', before, after)
|
||||
|
||||
|
||||
@step(u'I delete the update$')
|
||||
def click_button(_step):
|
||||
button_css = 'div.post-preview a.delete-button'
|
||||
@@ -80,3 +94,10 @@ def change_text(text):
|
||||
type_in_codemirror(0, text)
|
||||
save_css = 'a.save-button'
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def verify_text_in_editor_and_update(button_css, before, after):
|
||||
world.css_click(button_css)
|
||||
text = world.css_find(".cm-string").html
|
||||
assert before in text
|
||||
change_text(after)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Create Course
|
||||
@shard_2
|
||||
Feature: CMS.Create Course
|
||||
In order offer a course on the edX platform
|
||||
As a course author
|
||||
I want to create courses
|
||||
@@ -11,3 +12,19 @@ Feature: Create Course
|
||||
And I press the "Create" button
|
||||
Then the Courseware page has loaded in Studio
|
||||
And I see a link for adding a new section
|
||||
|
||||
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
|
||||
|
||||
@@ -23,6 +23,11 @@ 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()
|
||||
@@ -33,6 +38,11 @@ def i_click_the_course_link_in_my_courses(step):
|
||||
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 ###################
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Discussion Component Editor
|
||||
@shard_2
|
||||
Feature: CMS.Discussion Component Editor
|
||||
As a course author, I want to be able to create discussion components.
|
||||
|
||||
Scenario: User can view metadata
|
||||
|
||||
@@ -26,6 +26,8 @@ def i_see_only_the_settings_and_values(step):
|
||||
|
||||
@step('creating a discussion takes a single click')
|
||||
def discussion_takes_a_single_click(step):
|
||||
assert(not world.is_css_present('.xmodule_DiscussionModule'))
|
||||
component_css = '.xmodule_DiscussionModule'
|
||||
assert world.is_css_not_present(component_css)
|
||||
|
||||
world.css_click("a[data-category='discussion']")
|
||||
assert(world.is_css_present('.xmodule_DiscussionModule'))
|
||||
assert world.is_css_present(component_css)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Course Grading
|
||||
@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
|
||||
@@ -86,7 +87,7 @@ Feature: Course Grading
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to ""
|
||||
Then the save button is disabled
|
||||
Then the save notification button is disabled
|
||||
|
||||
# IE and Safari cannot type in grade range name
|
||||
@skip_internetexplorer
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.common.exceptions import InvalidElementStateException
|
||||
from selenium.common.exceptions import (
|
||||
InvalidElementStateException, WebDriverException)
|
||||
from nose.tools import assert_in, assert_not_in, assert_equal, assert_not_equal # pylint: disable=E0611
|
||||
|
||||
|
||||
@step(u'I am viewing the grading settings')
|
||||
@@ -34,7 +36,7 @@ def delete_grade(step):
|
||||
def view_grade_slider(step, how_many):
|
||||
grade_slider_css = '.grade-specific-bar'
|
||||
all_grades = world.css_find(grade_slider_css)
|
||||
assert len(all_grades) == int(how_many)
|
||||
assert_equal(len(all_grades), int(how_many))
|
||||
|
||||
|
||||
@step(u'I move a grading section')
|
||||
@@ -49,7 +51,7 @@ def confirm_change(step):
|
||||
range_css = '.range'
|
||||
all_ranges = world.css_find(range_css)
|
||||
for i in range(len(all_ranges)):
|
||||
assert world.css_html(range_css, index=i) != '0-50'
|
||||
assert_not_equal(world.css_html(range_css, index=i), '0-50')
|
||||
|
||||
|
||||
@step(u'I change assignment type "([^"]*)" to "([^"]*)"$')
|
||||
@@ -57,7 +59,7 @@ 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 index != -1
|
||||
assert_not_equal(index, -1)
|
||||
for count in range(len(old_name)):
|
||||
f._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
f._element.send_keys(new_name)
|
||||
@@ -65,21 +67,28 @@ def change_assignment_name(step, old_name, new_name):
|
||||
|
||||
@step(u'I go back to the main course page')
|
||||
def main_course_page(step):
|
||||
main_page_link_css = 'a[href="/%s/%s/course/%s"]' % (world.scenario_dict['COURSE'].org,
|
||||
world.scenario_dict['COURSE'].number,
|
||||
world.scenario_dict['COURSE'].display_name.replace(' ', '_'),)
|
||||
world.css_click(main_page_link_css)
|
||||
main_page_link = '/{}/{}/course/{}'.format(world.scenario_dict['COURSE'].org,
|
||||
world.scenario_dict['COURSE'].number,
|
||||
world.scenario_dict['COURSE'].display_name.replace(' ', '_'),)
|
||||
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):
|
||||
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 name in allnames
|
||||
assert_not_in(name, allnames)
|
||||
else:
|
||||
assert name in allnames
|
||||
assert_in(name, allnames)
|
||||
|
||||
|
||||
@step(u'I delete the assignment type "([^"]*)"$')
|
||||
@@ -107,7 +116,7 @@ def populate_course(step):
|
||||
def changes_not_persisted(step):
|
||||
reload_the_page(step)
|
||||
name_id = '#course-grading-assignment-name'
|
||||
assert(world.css_value(name_id) == 'Homework')
|
||||
assert_equal(world.css_value(name_id), 'Homework')
|
||||
|
||||
|
||||
@step(u'I see the assignment type "(.*)"$')
|
||||
@@ -115,7 +124,7 @@ def i_see_the_assignment_type(_step, name):
|
||||
assignment_css = '#course-grading-assignment-name'
|
||||
assignments = world.css_find(assignment_css)
|
||||
types = [ele['value'] for ele in assignments]
|
||||
assert name in types
|
||||
assert_in(name, types)
|
||||
|
||||
|
||||
@step(u'I change the highest grade range to "(.*)"$')
|
||||
@@ -129,26 +138,41 @@ def change_grade_range(_step, range_name):
|
||||
def i_see_highest_grade_range(_step, range_name):
|
||||
range_css = 'span.letter-grade'
|
||||
grade = world.css_find(range_css).first
|
||||
assert grade.value == range_name
|
||||
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 len(ranges) == 2
|
||||
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'
|
||||
assert False, "Should not be able to edit failing range"
|
||||
except InvalidElementStateException:
|
||||
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
|
||||
|
||||
|
||||
@@ -156,7 +180,7 @@ def i_change_grace_period(_step, grace_period):
|
||||
def the_grace_period_is(_step, grace_period):
|
||||
grace_period_css = '#course-grading-graceperiod'
|
||||
ele = world.css_find(grace_period_css).first
|
||||
assert ele.value == grace_period
|
||||
assert_equal(ele.value, grace_period)
|
||||
|
||||
|
||||
def get_type_index(name):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: HTML Editor
|
||||
@shard_3
|
||||
Feature: CMS.HTML Editor
|
||||
As a course author, I want to be able to create HTML blocks.
|
||||
|
||||
Scenario: User can view metadata
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
Feature: Problem Editor
|
||||
@shard_3
|
||||
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 five alphabetized settings and their expected values
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_equal # pylint: disable=E0611
|
||||
from nose.tools import assert_equal, assert_true # pylint: disable=E0611
|
||||
from common import type_in_codemirror
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
@@ -12,7 +12,6 @@ RANDOMIZATION = 'Randomization'
|
||||
SHOW_ANSWER = "Show Answer"
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I have created a Blank Common Problem$')
|
||||
def i_created_blank_common_problem(step):
|
||||
world.create_component_instance(
|
||||
@@ -29,15 +28,15 @@ def i_edit_and_select_settings(step):
|
||||
world.edit_component_and_select_settings()
|
||||
|
||||
|
||||
@step('I see five alphabetized settings and their expected values$')
|
||||
def i_see_five_settings_with_values(step):
|
||||
@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],
|
||||
[MAXIMUM_ATTEMPTS, "", False],
|
||||
[PROBLEM_WEIGHT, "", False],
|
||||
[RANDOMIZATION, "Never", False],
|
||||
[SHOW_ANSWER, "Finished", False]
|
||||
[SHOW_ANSWER, "Finished", False],
|
||||
])
|
||||
|
||||
|
||||
@@ -141,8 +140,9 @@ def set_the_max_attempts(step, max_attempts_set):
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
|
||||
world.save_component_and_reopen(step)
|
||||
value = int(world.css_value('input.setting-input', index=index))
|
||||
assert value >= 0
|
||||
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')
|
||||
@@ -159,7 +159,7 @@ def edit_high_level_source_links_visible(step):
|
||||
def cancel_does_not_save_changes(step):
|
||||
world.cancel_component(step)
|
||||
step.given("I edit and select Settings")
|
||||
step.given("I see five alphabetized settings and their expected values")
|
||||
step.given("I see the advanced settings and their expected values")
|
||||
|
||||
|
||||
@step('I have created a LaTeX Problem')
|
||||
@@ -187,7 +187,7 @@ def high_level_source_persisted(step):
|
||||
css_sel = '.problem div>span'
|
||||
return world.css_text(css_sel) == 'hi'
|
||||
|
||||
world.wait_for(verify_text)
|
||||
world.wait_for(verify_text, timeout=10)
|
||||
|
||||
|
||||
@step('I view the High Level Source I see my changes')
|
||||
@@ -197,9 +197,20 @@ def high_level_source_in_editor(step):
|
||||
|
||||
|
||||
def verify_high_level_source_links(step, visible):
|
||||
assert_equal(visible, world.is_css_present('.launch-latex-compiler'))
|
||||
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)
|
||||
assert_equal(visible, world.is_css_present('.upload-button'))
|
||||
if visible:
|
||||
assert_true(world.is_css_present('.upload-button'),
|
||||
msg="Expected to find the upload button but it is not present.")
|
||||
else:
|
||||
assert_true(world.is_css_not_present('.upload-button'),
|
||||
msg="Expected not to find the upload button but it is present.")
|
||||
|
||||
|
||||
def verify_modified_weight():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Create Section
|
||||
@shard_2
|
||||
Feature: CMS.Create Section
|
||||
In order offer a course on the edX platform
|
||||
As a course author
|
||||
I want to create and edit sections
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Sign in
|
||||
@shard_3
|
||||
Feature: CMS.Sign in
|
||||
In order to use the edX content
|
||||
As a new user
|
||||
I want to signup for a student account
|
||||
|
||||
@@ -12,7 +12,7 @@ def i_fill_in_the_registration_form(step):
|
||||
register_form.find_by_name('password').fill('test')
|
||||
register_form.find_by_name('username').fill('robot-studio')
|
||||
register_form.find_by_name('name').fill('Robot Studio')
|
||||
register_form.find_by_name('terms_of_service').check()
|
||||
register_form.find_by_name('terms_of_service').click()
|
||||
world.retry_on_exception(fill_in_reg_form)
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
Feature: Static Pages
|
||||
@shard_3
|
||||
Feature: CMS.Static Pages
|
||||
As a course author, I want to be able to add static pages
|
||||
|
||||
Scenario: Users can add static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
When I add a new page
|
||||
Then I should see a "Empty" static page
|
||||
Then I should see a static page named "Empty"
|
||||
|
||||
Scenario: Users can delete static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
And I "delete" the "Empty" page
|
||||
And I "delete" the static page
|
||||
Then I am shown a prompt
|
||||
When I confirm the prompt
|
||||
Then I should not see a "Empty" static page
|
||||
Then I should not see any static pages
|
||||
|
||||
# Safari won't update the name properly
|
||||
@skip_safari
|
||||
@@ -22,6 +23,6 @@ Feature: Static Pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
When I "edit" the "Empty" page
|
||||
When I "edit" the static page
|
||||
And I change the name to "New"
|
||||
Then I should see a "New" static page
|
||||
Then I should see a static page named "New"
|
||||
|
||||
@@ -2,42 +2,44 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from nose.tools import assert_equal # pylint: disable=E0611
|
||||
|
||||
|
||||
@step(u'I go to the static pages page')
|
||||
def go_to_static(_step):
|
||||
@step(u'I go to the static pages page$')
|
||||
def go_to_static(step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
static_css = 'li.nav-course-courseware-pages a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(static_css)
|
||||
|
||||
|
||||
@step(u'I add a new page')
|
||||
def add_page(_step):
|
||||
@step(u'I add a new page$')
|
||||
def add_page(step):
|
||||
button_css = 'a.new-button'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I should( not)? see a "([^"]*)" static page$')
|
||||
def see_page(_step, doesnt, page):
|
||||
index = get_index(page)
|
||||
if doesnt:
|
||||
assert index == -1
|
||||
else:
|
||||
assert index != -1
|
||||
@step(u'I should see a static page named "([^"]*)"$')
|
||||
def see_a_static_page_named_foo(step, name):
|
||||
pages_css = 'section.xmodule_StaticTabModule'
|
||||
page_name_html = world.css_html(pages_css)
|
||||
assert_equal(page_name_html, '\n {name}\n'.format(name=name))
|
||||
|
||||
|
||||
@step(u'I "([^"]*)" the "([^"]*)" page$')
|
||||
def click_edit_delete(_step, edit_delete, page):
|
||||
button_css = 'a.%s-button' % edit_delete
|
||||
index = get_index(page)
|
||||
assert index != -1
|
||||
world.css_click(button_css, index=index)
|
||||
@step(u'I should not see any static pages$')
|
||||
def not_see_any_static_pages(step):
|
||||
pages_css = 'section.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 = 'div.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):
|
||||
def change_name(step, new_name):
|
||||
settings_css = '#settings-mode a'
|
||||
world.css_click(settings_css)
|
||||
input_css = 'input.setting-input'
|
||||
@@ -46,12 +48,3 @@ def change_name(_step, new_name):
|
||||
world.trigger_event(input_css)
|
||||
save_button = 'a.save-button'
|
||||
world.css_click(save_button)
|
||||
|
||||
|
||||
def get_index(name):
|
||||
page_name_css = 'section[data-type="HTMLModule"]'
|
||||
all_pages = world.css_find(page_name_css)
|
||||
for i in range(len(all_pages)):
|
||||
if world.css_html(page_name_css, index=i) == '\n {name}\n'.format(name=name):
|
||||
return i
|
||||
return -1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Create Subsection
|
||||
@shard_2
|
||||
Feature: CMS.Create Subsection
|
||||
In order offer a course on the edX platform
|
||||
As a course author
|
||||
I want to create and edit subsections
|
||||
|
||||
@@ -109,7 +109,7 @@ def i_see_my_subsection_name_with_quote_on_the_courseware_page(step):
|
||||
@step('the subsection does not exist$')
|
||||
def the_subsection_does_not_exist(step):
|
||||
css = 'span.subsection-name'
|
||||
assert world.browser.is_element_not_present_by_css(css)
|
||||
assert world.is_css_not_present(css)
|
||||
|
||||
|
||||
@step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Textbooks
|
||||
@shard_3
|
||||
Feature: CMS.Textbooks
|
||||
|
||||
Scenario: No textbooks
|
||||
Given I have opened a new course in Studio
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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
|
||||
|
||||
@@ -82,20 +83,23 @@ def save_textbook(_step):
|
||||
|
||||
@step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"')
|
||||
def check_textbook(_step, textbook_name, chapter_name):
|
||||
title = world.css_find(".textbook h3.textbook-title")
|
||||
chapter = world.css_find(".textbook .wrap-textbook p")
|
||||
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
|
||||
assert chapter.text == chapter_name, "{} != {}".format(chapter.text, chapter_name)
|
||||
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_find(".textbook .view-textbook h3.textbook-title")
|
||||
toggle = world.css_find(".textbook .view-textbook .chapter-toggle")
|
||||
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name)
|
||||
assert toggle.text == "{num} PDF Chapters".format(num=num_chapters), \
|
||||
"Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle.text)
|
||||
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')
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
Feature: Upload Files
|
||||
@shard_3
|
||||
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 have opened a new course in Studio
|
||||
And I go to the files and uploads page
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the file "test"
|
||||
Then I should see the file "test" was uploaded
|
||||
And The url for the file "test" 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 have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
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"
|
||||
@@ -22,8 +31,7 @@ Feature: Upload Files
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can delete uploaded files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
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
|
||||
@@ -32,18 +40,76 @@ Feature: Upload Files
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can download files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
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 have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
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
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
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 # pylint: disable=E0611
|
||||
|
||||
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')
|
||||
@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'
|
||||
@@ -19,11 +24,15 @@ def go_to_uploads(_step):
|
||||
world.css_click(uploads_css)
|
||||
|
||||
|
||||
@step(u'I upload the file "([^"]*)"$')
|
||||
def upload_file(_step, file_name):
|
||||
@step(u'I upload the( test)? file "([^"]*)"$')
|
||||
def upload_file(_step, is_test_file, file_name):
|
||||
upload_css = 'a.upload-button'
|
||||
world.css_click(upload_css)
|
||||
#uploading the file itself
|
||||
|
||||
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))
|
||||
@@ -31,19 +40,46 @@ def upload_file(_step, file_name):
|
||||
world.css_click(close_css)
|
||||
|
||||
|
||||
@step(u'I should( not)? see the file "([^"]*)" was uploaded$')
|
||||
def check_upload(_step, do_not_see_file, file_name):
|
||||
@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)
|
||||
if do_not_see_file:
|
||||
assert index == -1
|
||||
else:
|
||||
assert index != -1
|
||||
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 r.status_code == 200
|
||||
assert_equal(r.status_code, 200)
|
||||
|
||||
|
||||
@step(u'I delete the file "([^"]*)"$')
|
||||
@@ -53,17 +89,18 @@ def delete_file(_step, file_name):
|
||||
delete_css = "a.remove-asset-button"
|
||||
world.css_click(delete_css, index=index)
|
||||
|
||||
world.wait_for_present(".wrapper-prompt.is-shown")
|
||||
world.wait(0.2) # wait for css animation
|
||||
prompt_confirm_css = 'li.nav-item > a.action-primary'
|
||||
world.css_click(prompt_confirm_css, success_condition=lambda: not world.css_visible(prompt_confirm_css))
|
||||
world.css_click(prompt_confirm_css)
|
||||
|
||||
|
||||
@step(u'I should see only one "([^"]*)"$')
|
||||
def no_duplicate(_step, file_name):
|
||||
names_css = 'td.name-col > a.filename'
|
||||
all_names = world.css_find(names_css)
|
||||
all_names = world.css_find(ASSET_NAMES_CSS)
|
||||
only_one = False
|
||||
for i in range(len(all_names)):
|
||||
if file_name == world.css_html(names_css, index=i):
|
||||
if file_name == world.css_html(ASSET_NAMES_CSS, index=i):
|
||||
only_one = not only_one
|
||||
assert only_one
|
||||
|
||||
@@ -76,30 +113,97 @@ def check_download(_step, file_name):
|
||||
r = get_file(file_name)
|
||||
downloaded_text = r.text
|
||||
assert cur_text == downloaded_text
|
||||
#resetting the file back to its original state
|
||||
# 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("This is an arbitrary file for testing uploads")
|
||||
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))
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
with open(os.path.abspath(path), 'w') as cur_file:
|
||||
cur_file.write(new_text)
|
||||
_write_test_file(file_name, new_text)
|
||||
|
||||
|
||||
@step('I see a confirmation that the file was deleted')
|
||||
@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'].location.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):
|
||||
url = django_url('/c4x/MITx/999/asset/asset.html')
|
||||
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):
|
||||
names_css = 'td.name-col > a.filename'
|
||||
all_names = world.css_find(names_css)
|
||||
all_names = world.css_find(ASSET_NAMES_CSS)
|
||||
for i in range(len(all_names)):
|
||||
if file_name == world.css_html(names_css, index=i):
|
||||
if file_name == world.css_html(ASSET_NAMES_CSS, index=i):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Video Component Editor
|
||||
@shard_3
|
||||
Feature: CMS.Video Component Editor
|
||||
As a course author, I want to be able to create video components.
|
||||
|
||||
Scenario: User can view Video metadata
|
||||
@@ -17,13 +18,13 @@ Feature: Video Component Editor
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are hidden when "show captions" is false
|
||||
Given I have created a Video component
|
||||
Given I have created a Video component with subtitles
|
||||
And I have set "show captions" to False
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are shown when "show captions" is true
|
||||
Given I have created a Video component
|
||||
Given I have created a Video component with subtitles
|
||||
And I have set "show captions" to True
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
@@ -7,42 +7,56 @@ from terrain.steps import reload_the_page
|
||||
|
||||
@step('I have set "show captions" to (.*)$')
|
||||
def set_show_captions(step, setting):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
|
||||
world.css_click('a.edit-button')
|
||||
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
|
||||
world.browser.select('Show Captions', setting)
|
||||
world.css_click('a.save-button')
|
||||
|
||||
|
||||
@step('when I view the (video.*) it (.*) show the captions$')
|
||||
def shows_captions(_step, video_type, show_captions):
|
||||
@step('when I view the video it (.*) show the captions$')
|
||||
def shows_captions(_step, show_captions):
|
||||
world.wait_for_js_variable_truthy("Video")
|
||||
world.wait(0.5)
|
||||
if show_captions == 'does not':
|
||||
assert world.is_css_present('div.video.closed')
|
||||
else:
|
||||
assert world.is_css_not_present('div.video.closed')
|
||||
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
if show_captions == 'does not':
|
||||
assert world.css_has_class('.%s' % video_type, 'closed')
|
||||
else:
|
||||
assert world.is_css_not_present('.%s.closed' % video_type)
|
||||
world.browser.cookies.delete('current_player_mode')
|
||||
|
||||
|
||||
@step('I see the correct video settings and default values$')
|
||||
def correct_video_settings(_step):
|
||||
world.verify_all_setting_entries([['Display Name', 'Video', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['End Time', '0', False],
|
||||
['HTML5 Timed Transcript', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['Start Time', '0', False],
|
||||
['Video Sources', '', False],
|
||||
['Youtube ID', 'OEoXaMPEzfM', False],
|
||||
['Youtube ID for .75x speed', '', False],
|
||||
['Youtube ID for 1.25x speed', '', False],
|
||||
['Youtube ID for 1.5x speed', '', False]])
|
||||
expected_entries = [
|
||||
['Display Name', 'Video', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['End Time', '0', False],
|
||||
['HTML5 Timed Transcript', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['Start Time', '0', False],
|
||||
['Video Sources', '', False],
|
||||
['Youtube ID', 'OEoXaMPEzfM', False],
|
||||
['Youtube ID for .75x speed', '', False],
|
||||
['Youtube ID for 1.25x speed', '', False],
|
||||
['Youtube ID for 1.5x speed', '', False]
|
||||
]
|
||||
world.verify_all_setting_entries(expected_entries)
|
||||
|
||||
|
||||
@step('my video display name change is persisted on save$')
|
||||
def video_name_persisted(step):
|
||||
world.css_click('a.save-button')
|
||||
reload_the_page(step)
|
||||
world.wait_for_xmodule()
|
||||
world.edit_component()
|
||||
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)
|
||||
|
||||
world.verify_setting_entry(
|
||||
world.get_setting_entry('Display Name'),
|
||||
'Display Name', '3.4', True
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Video Component
|
||||
@shard_3
|
||||
Feature: CMS.Video Component
|
||||
As a course author, I want to be able to view my created videos in Studio.
|
||||
|
||||
# Video Alpha Features will work in Firefox only when Firefox is the active window
|
||||
@@ -13,23 +14,24 @@ Feature: Video Component
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are hidden correctly
|
||||
Given I have created a Video component
|
||||
Given I have created a Video component with subtitles
|
||||
And I have hidden captions
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are shown correctly
|
||||
Given I have created a Video component
|
||||
Given I have created a Video component with subtitles
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are toggled correctly
|
||||
Given I have created a Video component
|
||||
Given I have created a Video component with subtitles
|
||||
And I have toggled captions
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
Scenario: Video data is shown correctly
|
||||
Given I have created a video with only XML data
|
||||
And I reload the page
|
||||
Then the correct Youtube video is shown
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('I have created a Video component$')
|
||||
def i_created_a_video_component(step):
|
||||
world.create_component_instance(
|
||||
@@ -19,17 +15,49 @@ def i_created_a_video_component(step):
|
||||
)
|
||||
|
||||
|
||||
@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 "OEoXaMPEzfM"')
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@step('I have uploaded subtitles "([^"]*)"$')
|
||||
def i_have_uploaded_subtitles(_step, sub_id):
|
||||
_step.given('I go to the files and uploads page')
|
||||
|
||||
sub_id = sub_id.strip()
|
||||
if not sub_id:
|
||||
sub_id = 'OEoXaMPEzfM'
|
||||
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id))
|
||||
|
||||
|
||||
@step('when I view the (.*) it does not have autoplay enabled$')
|
||||
def does_not_autoplay(_step, video_type):
|
||||
world.wait_for_xmodule()
|
||||
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
|
||||
assert world.css_has_class('.video_control', 'play')
|
||||
|
||||
|
||||
@step('creating a video takes a single click$')
|
||||
def video_takes_a_single_click(_step):
|
||||
assert(not world.is_css_present('.xmodule_VideoModule'))
|
||||
component_css = '.xmodule_VideoModule'
|
||||
assert world.is_css_not_present(component_css)
|
||||
|
||||
world.css_click("a[data-category='video']")
|
||||
assert(world.is_css_present('.xmodule_VideoModule'))
|
||||
assert world.is_css_present(component_css)
|
||||
|
||||
|
||||
@step('I edit the component$')
|
||||
@@ -39,6 +67,7 @@ def i_edit_the_component(_step):
|
||||
|
||||
@step('I have (hidden|toggled) captions$')
|
||||
def hide_or_show_captions(step, shown):
|
||||
world.wait_for_xmodule()
|
||||
button_css = 'a.hide-subtitles'
|
||||
if shown == 'hidden':
|
||||
world.css_click(button_css)
|
||||
@@ -74,18 +103,15 @@ def xml_only_video(step):
|
||||
# Create a new Video component, but ensure that it doesn't have
|
||||
# metadata. This allows us to test that we are correctly parsing
|
||||
# out XML
|
||||
video = world.ItemFactory.create(
|
||||
world.ItemFactory.create(
|
||||
parent_location=parent_location,
|
||||
category='video',
|
||||
data='<video youtube="1.00:%s"></video>' % youtube_id
|
||||
)
|
||||
|
||||
# Refresh to see the new video
|
||||
reload_the_page(step)
|
||||
|
||||
|
||||
@step('The correct Youtube video is shown$')
|
||||
def the_youtube_video_is_shown(_step):
|
||||
world.wait_for_xmodule()
|
||||
ele = world.css_find('.video').first
|
||||
assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID']
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@ class Command(BaseCommand):
|
||||
for item in queried_discussion_items:
|
||||
if item.location.url() not in discussion_items:
|
||||
print 'Found dangling discussion module = {0}'.format(item.location.url())
|
||||
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
### Script for cloning a course
|
||||
###
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from .prompt import query_yes_no
|
||||
|
||||
from auth.authz import _delete_course_group
|
||||
from contentstore.utils import delete_course_and_groups
|
||||
|
||||
|
||||
#
|
||||
@@ -30,20 +25,6 @@ class Command(BaseCommand):
|
||||
if commit:
|
||||
print 'Actually going to delete the course from DB....'
|
||||
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
org, course_num, run = course_id.split("/")
|
||||
ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(course_id), default="no"):
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
if delete_course(ms, cs, loc, commit):
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
if commit:
|
||||
try:
|
||||
_delete_course_group(loc)
|
||||
except Exception as err:
|
||||
print("Error in deleting course groups for {0}: {1}".format(loc, err))
|
||||
delete_course_and_groups(course_id, commit)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
###
|
||||
### Script for editing the course's tabs
|
||||
###
|
||||
|
||||
#
|
||||
# Run it this way:
|
||||
# ./manage.py cms --settings dev edit_course_tabs --course Stanford/CS99/2013_spring
|
||||
# Or via rake:
|
||||
# rake django-admin[edit_course_tabs,cms,dev,"--course Stanford/CS99/2013_spring --delete 4"]
|
||||
#
|
||||
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
|
||||
|
||||
|
||||
def print_course(course):
|
||||
"Prints out the course id and a numbered list of tabs."
|
||||
print course.id
|
||||
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).
|
||||
"""
|
||||
# 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)
|
||||
|
||||
course = get_course_by_id(options['course'])
|
||||
|
||||
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)
|
||||
@@ -64,11 +64,11 @@ def set_module_info(store, location, post_data):
|
||||
|
||||
if posted_metadata[metadata_key] is None:
|
||||
# remove both from passed in collection as well as the collection read in from the modulestore
|
||||
if metadata_key in module._model_data:
|
||||
del module._model_data[metadata_key]
|
||||
if module._field_data.has(module, metadata_key):
|
||||
module._field_data.delete(module, metadata_key)
|
||||
del posted_metadata[metadata_key]
|
||||
else:
|
||||
module._model_data[metadata_key] = value
|
||||
module._field_data.set(module, metadata_key, value)
|
||||
|
||||
# commit to datastore
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
Unit tests for the asset upload endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
#pylint: disable=W0212
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pytz import UTC
|
||||
@@ -12,6 +15,10 @@ from django.core.urlresolvers import reverse
|
||||
from contentstore.views import assets
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
import json
|
||||
|
||||
|
||||
class AssetsTestCase(CourseTestCase):
|
||||
@@ -27,22 +34,27 @@ class AssetsTestCase(CourseTestCase):
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
|
||||
def test_json(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
content = json.loads(resp.content)
|
||||
self.assertIsInstance(content, list)
|
||||
|
||||
def test_static_url_generation(self):
|
||||
location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg'])
|
||||
path = StaticContent.get_static_path_from_location(location)
|
||||
self.assertEquals(path, '/static/my_file_name.jpg')
|
||||
|
||||
|
||||
class AssetsToyCourseTestCase(CourseTestCase):
|
||||
"""
|
||||
Tests the assets returned from asset_index for the toy test course.
|
||||
"""
|
||||
def test_toy_assets(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=contentstore(), verbose=True)
|
||||
url = reverse("asset_index", kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'})
|
||||
|
||||
resp = self.client.get(url)
|
||||
# Test a small portion of the asset data passed to the client.
|
||||
self.assertContains(resp, "new AssetCollection([{")
|
||||
self.assertContains(resp, "/c4x/edX/toy/asset/handouts_sample_handout.txt")
|
||||
|
||||
|
||||
class UploadTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for uploading a file
|
||||
@@ -71,32 +83,67 @@ class UploadTestCase(CourseTestCase):
|
||||
self.assertEquals(resp.status_code, 405)
|
||||
|
||||
|
||||
class AssetsToJsonTestCase(TestCase):
|
||||
class AssetToJsonTestCase(TestCase):
|
||||
"""
|
||||
Unit tests for transforming the results of a database call into something
|
||||
Unit test for transforming asset information into something
|
||||
we can send out to the client via JSON.
|
||||
"""
|
||||
def test_basic(self):
|
||||
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
|
||||
asset = {
|
||||
"displayname": "foo",
|
||||
"chunkSize": 512,
|
||||
"filename": "foo.png",
|
||||
"length": 100,
|
||||
"uploadDate": upload_date,
|
||||
"_id": {
|
||||
"course": "course",
|
||||
"org": "org",
|
||||
"revision": 12,
|
||||
"category": "category",
|
||||
"name": "name",
|
||||
"tag": "tag",
|
||||
}
|
||||
}
|
||||
output = assets.assets_to_json_dict([asset])
|
||||
self.assertEquals(len(output), 1)
|
||||
compare = output[0]
|
||||
self.assertEquals(compare["name"], "foo")
|
||||
self.assertEquals(compare["path"], "foo.png")
|
||||
self.assertEquals(compare["uploaded"], upload_date.isoformat())
|
||||
self.assertEquals(compare["id"], "/tag/org/course/12/category/name")
|
||||
|
||||
location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg'])
|
||||
thumbnail_location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name_thumb.jpg'])
|
||||
|
||||
output = assets._get_asset_json("my_file", upload_date, location, thumbnail_location, True)
|
||||
|
||||
self.assertEquals(output["display_name"], "my_file")
|
||||
self.assertEquals(output["date_added"], "Jun 01, 2013 at 10:30 UTC")
|
||||
self.assertEquals(output["url"], "/i4x/foo/bar/asset/my_file_name.jpg")
|
||||
self.assertEquals(output["portable_url"], "/static/my_file_name.jpg")
|
||||
self.assertEquals(output["thumbnail"], "/i4x/foo/bar/asset/my_file_name_thumb.jpg")
|
||||
self.assertEquals(output["id"], output["url"])
|
||||
self.assertEquals(output['locked'], True)
|
||||
|
||||
output = assets._get_asset_json("name", upload_date, location, None, False)
|
||||
self.assertIsNone(output["thumbnail"])
|
||||
|
||||
|
||||
class LockAssetTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit test for locking and unlocking an asset.
|
||||
"""
|
||||
|
||||
def test_locking(self):
|
||||
"""
|
||||
Tests a simple locking and unlocking of an asset in the toy course.
|
||||
"""
|
||||
def verify_asset_locked_state(locked):
|
||||
""" Helper method to verify lock state in the contentstore """
|
||||
asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
|
||||
content = contentstore().find(asset_location)
|
||||
self.assertEqual(content.locked, locked)
|
||||
|
||||
def post_asset_update(lock):
|
||||
""" Helper method for posting asset update. """
|
||||
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
|
||||
location = Location(['c4x', 'edX', 'toy', 'asset', 'sample_static.txt'])
|
||||
url = reverse('update_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'})
|
||||
|
||||
resp = self.client.post(url, json.dumps(assets._get_asset_json("sample_static.txt", upload_date, location, None, lock)), "application/json")
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
return json.loads(resp.content)
|
||||
|
||||
# Load the toy course.
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=contentstore(), verbose=True)
|
||||
verify_asset_locked_state(False)
|
||||
|
||||
# Lock the asset
|
||||
resp_asset = post_asset_update(True)
|
||||
self.assertTrue(resp_asset['locked'])
|
||||
verify_asset_locked_state(True)
|
||||
|
||||
# Unlock the asset
|
||||
resp_asset = post_asset_update(False)
|
||||
self.assertFalse(resp_asset['locked'])
|
||||
verify_asset_locked_state(False)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
""" Unit tests for checklist methods in views.py. """
|
||||
from contentstore.utils import get_modulestore
|
||||
from contentstore.views.checklist import expand_checklist_action_url
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -22,20 +23,16 @@ class ChecklistTestCase(CourseTestCase):
|
||||
def compare_checklists(self, persisted, request):
|
||||
"""
|
||||
Handles url expansion as possible difference and descends into guts
|
||||
:param persisted:
|
||||
:param request:
|
||||
"""
|
||||
self.assertEqual(persisted['short_description'], request['short_description'])
|
||||
compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded'))
|
||||
pers, req = None, None
|
||||
for pers, req in zip(persisted['items'], request['items']):
|
||||
expanded_checklist = expand_checklist_action_url(self.course, persisted)
|
||||
for pers, req in zip(expanded_checklist['items'], request['items']):
|
||||
self.assertEqual(pers['short_description'], req['short_description'])
|
||||
self.assertEqual(pers['long_description'], req['long_description'])
|
||||
self.assertEqual(pers['is_checked'], req['is_checked'])
|
||||
if compare_urls:
|
||||
self.assertEqual(pers['long_description'], req['long_description'])
|
||||
self.assertEqual(pers['is_checked'], req['is_checked'])
|
||||
self.assertEqual(pers['action_url'], req['action_url'])
|
||||
self.assertEqual(pers['action_text'], req['action_text'])
|
||||
self.assertEqual(pers['action_external'], req['action_external'])
|
||||
self.assertEqual(pers['action_text'], req['action_text'])
|
||||
self.assertEqual(pers['action_external'], req['action_external'])
|
||||
|
||||
def test_get_checklists(self):
|
||||
""" Tests the get checklists method. """
|
||||
@@ -46,6 +43,11 @@ class ChecklistTestCase(CourseTestCase):
|
||||
})
|
||||
response = self.client.get(checklists_url)
|
||||
self.assertContains(response, "Getting Started With Studio")
|
||||
# Verify expansion of action URL happened.
|
||||
self.assertContains(response, '/mitX/333/team/Checklists_Course')
|
||||
# Verify persisted checklist does NOT have expanded URL.
|
||||
checklist_0 = self.get_persisted_checklists()[0]
|
||||
self.assertEqual('ManageUsers', get_action_url(checklist_0, 0))
|
||||
payload = response.content
|
||||
|
||||
# Now delete the checklists from the course and verify they get repopulated (for courses
|
||||
@@ -67,7 +69,11 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name})
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
|
||||
# Verify that persisted checklists do not have expanded action URLs.
|
||||
# compare_checklists will verify that returned_checklists DO have expanded action URLs.
|
||||
pers = self.get_persisted_checklists()
|
||||
self.assertEqual('CourseOutline', get_first_item(pers[1]).get('action_url'))
|
||||
for pay, resp in zip(pers, returned_checklists):
|
||||
self.compare_checklists(pay, resp)
|
||||
|
||||
def test_update_checklists_index_ignored_on_get(self):
|
||||
@@ -103,19 +109,21 @@ class ChecklistTestCase(CourseTestCase):
|
||||
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 2})
|
||||
'checklist_index': 1})
|
||||
|
||||
def get_first_item(checklist):
|
||||
return checklist['items'][0]
|
||||
|
||||
payload = self.course.checklists[2]
|
||||
payload = self.course.checklists[1]
|
||||
self.assertFalse(get_first_item(payload).get('is_checked'))
|
||||
self.assertEqual('CourseOutline', get_first_item(payload).get('action_url'))
|
||||
get_first_item(payload)['is_checked'] = True
|
||||
|
||||
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
|
||||
self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
|
||||
pers = self.get_persisted_checklists()
|
||||
self.compare_checklists(pers[2], returned_checklist)
|
||||
persisted_checklist = self.get_persisted_checklists()[1]
|
||||
# Verify that persisted checklist does not have expanded action URLs.
|
||||
# compare_checklists will verify that returned_checklist DOES have expanded action URLs.
|
||||
self.assertEqual('CourseOutline', get_first_item(persisted_checklist).get('action_url'))
|
||||
self.compare_checklists(persisted_checklist, returned_checklist)
|
||||
|
||||
|
||||
def test_update_checklists_delete_unsupported(self):
|
||||
""" Delete operation is not supported. """
|
||||
@@ -125,3 +133,36 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'checklist_index': 100})
|
||||
response = self.client.delete(update_url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_expand_checklist_action_url(self):
|
||||
"""
|
||||
Tests the method to expand checklist action url.
|
||||
"""
|
||||
|
||||
def test_expansion(checklist, index, stored, expanded):
|
||||
"""
|
||||
Tests that the expected expanded value is returned for the item at the given index.
|
||||
|
||||
Also verifies that the original checklist is not modified.
|
||||
"""
|
||||
self.assertEqual(get_action_url(checklist, index), stored)
|
||||
expanded_checklist = expand_checklist_action_url(self.course, checklist)
|
||||
self.assertEqual(get_action_url(expanded_checklist, index), expanded)
|
||||
# Verify no side effect in the original list.
|
||||
self.assertEqual(get_action_url(checklist, index), stored)
|
||||
|
||||
test_expansion(self.course.checklists[0], 0, 'ManageUsers', '/mitX/333/team/Checklists_Course')
|
||||
test_expansion(self.course.checklists[1], 1, 'CourseOutline', '/mitX/333/course/Checklists_Course')
|
||||
test_expansion(self.course.checklists[2], 0, 'http://help.edge.edx.org/', 'http://help.edge.edx.org/')
|
||||
|
||||
|
||||
def get_first_item(checklist):
|
||||
""" Returns the first item from the checklist. """
|
||||
return checklist['items'][0]
|
||||
|
||||
|
||||
def get_action_url(checklist, index):
|
||||
"""
|
||||
Returns the action_url for the item at the specified index in the given checklist.
|
||||
"""
|
||||
return checklist['items'][index]['action_url']
|
||||
|
||||
@@ -55,6 +55,8 @@ from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from contentstore.utils import delete_course_and_groups
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
@@ -168,6 +170,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def lockAnAsset(self, content_store, course_location):
|
||||
"""
|
||||
Lock an arbitrary asset in the course
|
||||
:param course_location:
|
||||
"""
|
||||
course_assets = content_store.get_all_content_for_course(course_location)
|
||||
self.assertGreater(len(course_assets), 0, "No assets to lock")
|
||||
content_store.set_attr(course_assets[0]['_id'], 'locked', True)
|
||||
return course_assets[0]['_id']
|
||||
|
||||
def test_edit_unit_toy(self):
|
||||
self.check_edit_unit('toy')
|
||||
|
||||
@@ -219,7 +231,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
'course', '2012_Fall', None]), depth=None)
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod)
|
||||
self.assertEqual(html_module.graceperiod, course.graceperiod)
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
@@ -227,7 +239,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# refetch to check metadata
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod)
|
||||
self.assertEqual(html_module.graceperiod, course.graceperiod)
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
|
||||
# publish module
|
||||
@@ -236,7 +248,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# refetch to check metadata
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod)
|
||||
self.assertEqual(html_module.graceperiod, course.graceperiod)
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
|
||||
# put back in draft and change metadata and see if it's now marked as 'own_metadata'
|
||||
@@ -246,12 +258,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
new_graceperiod = timedelta(hours=1)
|
||||
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
html_module.lms.graceperiod = new_graceperiod
|
||||
html_module.graceperiod = new_graceperiod
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
html_module.save()
|
||||
self.assertIn('graceperiod', own_metadata(html_module))
|
||||
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
|
||||
self.assertEqual(html_module.graceperiod, new_graceperiod)
|
||||
|
||||
draft_store.update_metadata(html_module.location, own_metadata(html_module))
|
||||
|
||||
@@ -259,7 +271,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
self.assertIn('graceperiod', own_metadata(html_module))
|
||||
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
|
||||
self.assertEqual(html_module.graceperiod, new_graceperiod)
|
||||
|
||||
# republish
|
||||
draft_store.publish(html_module.location, 0)
|
||||
@@ -269,7 +281,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
self.assertIn('graceperiod', own_metadata(html_module))
|
||||
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
|
||||
self.assertEqual(html_module.graceperiod, new_graceperiod)
|
||||
|
||||
def test_get_depth_with_drafts(self):
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', ['simple'])
|
||||
@@ -348,6 +360,43 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertEqual(course.tabs, expected_tabs)
|
||||
|
||||
def test_create_static_tab_and_rename(self):
|
||||
module_store = modulestore('direct')
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
item = ItemFactory.create(parent_location=course_location, category='static_tab', display_name="My Tab")
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
expected_tabs = []
|
||||
expected_tabs.append({u'type': u'courseware'})
|
||||
expected_tabs.append({u'type': u'course_info', u'name': u'Course Info'})
|
||||
expected_tabs.append({u'type': u'textbooks'})
|
||||
expected_tabs.append({u'type': u'discussion', u'name': u'Discussion'})
|
||||
expected_tabs.append({u'type': u'wiki', u'name': u'Wiki'})
|
||||
expected_tabs.append({u'type': u'progress', u'name': u'Progress'})
|
||||
expected_tabs.append({u'type': u'static_tab', u'name': u'My Tab', u'url_slug': u'My_Tab'})
|
||||
|
||||
self.assertEqual(course.tabs, expected_tabs)
|
||||
|
||||
item.display_name = 'Updated'
|
||||
item.save()
|
||||
module_store.update_metadata(item.location, own_metadata(item))
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
expected_tabs = []
|
||||
expected_tabs.append({u'type': u'courseware'})
|
||||
expected_tabs.append({u'type': u'course_info', u'name': u'Course Info'})
|
||||
expected_tabs.append({u'type': u'textbooks'})
|
||||
expected_tabs.append({u'type': u'discussion', u'name': u'Discussion'})
|
||||
expected_tabs.append({u'type': u'wiki', u'name': u'Wiki'})
|
||||
expected_tabs.append({u'type': u'progress', u'name': u'Progress'})
|
||||
expected_tabs.append({u'type': u'static_tab', u'name': u'Updated', u'url_slug': u'My_Tab'})
|
||||
|
||||
self.assertEqual(course.tabs, expected_tabs)
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
module_store = modulestore('direct')
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
@@ -554,9 +603,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/toy/asset/sample_static.txt'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
url = reverse('update_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall', 'asset_id': '/c4x/edX/toy/asset/sample_static.txt'})
|
||||
resp = self.client.delete(url)
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
|
||||
asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
|
||||
|
||||
@@ -589,7 +638,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
def test_empty_trashcan(self):
|
||||
'''
|
||||
This test will exercise the empting of the asset trashcan
|
||||
This test will exercise the emptying of the asset trashcan
|
||||
'''
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
@@ -605,9 +654,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/toy/asset/sample_static.txt'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
url = reverse('update_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall', 'asset_id': '/c4x/edX/toy/asset/sample_static.txt'})
|
||||
resp = self.client.delete(url)
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
|
||||
# make sure there's something in the trashcan
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
@@ -868,7 +917,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
# get a vertical (and components in it) to copy into an orphan sub dag
|
||||
@@ -878,6 +927,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
)
|
||||
# We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case.
|
||||
vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references'))
|
||||
|
||||
draft_store.save_xmodule(vertical)
|
||||
orphan_vertical = draft_store.get_item(vertical.location)
|
||||
self.assertEqual(orphan_vertical.location.name, 'no_references')
|
||||
@@ -912,6 +962,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertIn(private_location_no_draft.url(), sequential.children)
|
||||
|
||||
locked_asset = self.lockAnAsset(content_store, location)
|
||||
locked_asset_attrs = content_store.get_attrs(locked_asset)
|
||||
# the later import will reupload
|
||||
del locked_asset_attrs['uploadDate']
|
||||
|
||||
print 'Exporting to tempdir = {0}'.format(root_dir)
|
||||
|
||||
# export out to a tempdir
|
||||
@@ -943,12 +998,34 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(on_disk['course/2012_Fall'], own_metadata(course))
|
||||
|
||||
# remove old course
|
||||
delete_course(module_store, content_store, location)
|
||||
delete_course(module_store, content_store, location, commit=True)
|
||||
# reimport over old course
|
||||
stub_location = Location(['i4x', 'edX', 'toy', None, None])
|
||||
course_location = course.location
|
||||
self.check_import(
|
||||
module_store, root_dir, draft_store, content_store, stub_location, course_location,
|
||||
locked_asset, locked_asset_attrs
|
||||
)
|
||||
# import to different course id
|
||||
stub_location = Location(['i4x', 'anotherX', 'anotherToy', None, None])
|
||||
course_location = stub_location.replace(category='course', name='Someday')
|
||||
self.check_import(
|
||||
module_store, root_dir, draft_store, content_store, stub_location, course_location,
|
||||
locked_asset, locked_asset_attrs
|
||||
)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def check_import(self, module_store, root_dir, draft_store, content_store, stub_location, course_location,
|
||||
locked_asset, locked_asset_attrs):
|
||||
# reimport
|
||||
import_from_xml(module_store, root_dir, ['test_export'], draft_store=draft_store)
|
||||
import_from_xml(
|
||||
module_store, root_dir, ['test_export'], draft_store=draft_store,
|
||||
static_content_store=content_store,
|
||||
target_location_namespace=course_location
|
||||
)
|
||||
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'toy', 'vertical', None]))
|
||||
items = module_store.get_items(stub_location.replace(category='vertical', name=None))
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
# don't try to look at private verticals. Right now we're running
|
||||
@@ -959,13 +1036,15 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# verify that we have the content in the draft store as well
|
||||
vertical = draft_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'vertical', 'vertical_test', None]), depth=1)
|
||||
vertical = draft_store.get_item(
|
||||
stub_location.replace(category='vertical', name='vertical_test', revision=None),
|
||||
depth=1
|
||||
)
|
||||
|
||||
self.assertTrue(getattr(vertical, 'is_draft', False))
|
||||
self.assertNotIn('index_in_children_list', child.xml_attributes)
|
||||
self.assertNotIn('index_in_children_list', vertical.xml_attributes)
|
||||
self.assertNotIn('parent_sequential_url', vertical.xml_attributes)
|
||||
|
||||
|
||||
for child in vertical.get_children():
|
||||
self.assertTrue(getattr(child, 'is_draft', False))
|
||||
self.assertNotIn('index_in_children_list', child.xml_attributes)
|
||||
@@ -976,23 +1055,34 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertNotIn('parent_sequential_url', child.data)
|
||||
|
||||
# make sure that we don't have a sequential that is in draft mode
|
||||
sequential = draft_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'sequential', 'vertical_sequential', None]))
|
||||
sequential = draft_store.get_item(
|
||||
stub_location.replace(category='sequential', name='vertical_sequential', revision=None)
|
||||
)
|
||||
|
||||
self.assertFalse(getattr(sequential, 'is_draft', False))
|
||||
|
||||
# verify that we have the private vertical
|
||||
test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'vertical', 'a_private_vertical', None]))
|
||||
test_private_vertical = draft_store.get_item(
|
||||
stub_location.replace(category='vertical', name='a_private_vertical', revision=None)
|
||||
)
|
||||
|
||||
self.assertTrue(getattr(test_private_vertical, 'is_draft', False))
|
||||
|
||||
# make sure the textbook survived the export/import
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None]))
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
locked_asset['course'] = stub_location.course
|
||||
locked_asset['org'] = stub_location.org
|
||||
new_attrs = content_store.get_attrs(locked_asset)
|
||||
for key, value in locked_asset_attrs.iteritems():
|
||||
if key == '_id':
|
||||
self.assertEqual(value['name'], new_attrs[key]['name'])
|
||||
elif key == 'filename':
|
||||
pass
|
||||
else:
|
||||
self.assertEqual(value, new_attrs[key])
|
||||
|
||||
def test_export_course_with_metadata_only_video(self):
|
||||
module_store = modulestore('direct')
|
||||
@@ -1252,6 +1342,28 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data)))
|
||||
|
||||
def test_forum_unseeding_on_delete(self):
|
||||
"""Test new course creation and verify forum unseeding """
|
||||
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data)))
|
||||
course_id = self._get_course_id(test_course_data)
|
||||
delete_course_and_groups(course_id, commit=True)
|
||||
self.assertFalse(are_permissions_roles_seeded(course_id))
|
||||
|
||||
def test_forum_unseeding_with_multiple_courses(self):
|
||||
"""Test new course creation and verify forum unseeding when there are multiple courses"""
|
||||
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
second_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
|
||||
# unseed the forums for the first course
|
||||
course_id = self._get_course_id(test_course_data)
|
||||
delete_course_and_groups(course_id, commit=True)
|
||||
self.assertFalse(are_permissions_roles_seeded(course_id))
|
||||
|
||||
second_course_id = self._get_course_id(second_course_data)
|
||||
# permissions should still be there for the other course
|
||||
self.assertTrue(are_permissions_roles_seeded(second_course_id))
|
||||
|
||||
def _get_course_id(self, test_course_data):
|
||||
"""Returns the course ID (org/number/run)."""
|
||||
return "{org}/{number}/{run}".format(**test_course_data)
|
||||
@@ -1628,8 +1740,8 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
# let's assert on the metadata_inheritance on an existing vertical
|
||||
for vertical in verticals:
|
||||
self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key)
|
||||
self.assertEqual(course.start, vertical.lms.start)
|
||||
self.assertEqual(course.xqa_key, vertical.xqa_key)
|
||||
self.assertEqual(course.start, vertical.start)
|
||||
|
||||
self.assertGreater(len(verticals), 0)
|
||||
|
||||
@@ -1645,16 +1757,16 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
new_module = module_store.get_item(new_component_location)
|
||||
|
||||
# check for grace period definition which should be defined at the course level
|
||||
self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod)
|
||||
self.assertEqual(parent.lms.start, new_module.lms.start)
|
||||
self.assertEqual(course.start, new_module.lms.start)
|
||||
self.assertEqual(parent.graceperiod, new_module.graceperiod)
|
||||
self.assertEqual(parent.start, new_module.start)
|
||||
self.assertEqual(course.start, new_module.start)
|
||||
|
||||
self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key)
|
||||
self.assertEqual(course.xqa_key, new_module.xqa_key)
|
||||
|
||||
#
|
||||
# now let's define an override at the leaf node level
|
||||
#
|
||||
new_module.lms.graceperiod = timedelta(1)
|
||||
new_module.graceperiod = timedelta(1)
|
||||
new_module.save()
|
||||
module_store.update_metadata(new_module.location, own_metadata(new_module))
|
||||
|
||||
@@ -1662,7 +1774,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
module_store.refresh_cached_metadata_inheritance_tree(new_component_location)
|
||||
new_module = module_store.get_item(new_component_location)
|
||||
|
||||
self.assertEqual(timedelta(1), new_module.lms.graceperiod)
|
||||
self.assertEqual(timedelta(1), new_module.graceperiod)
|
||||
|
||||
def test_default_metadata_inheritance(self):
|
||||
course = CourseFactory.create()
|
||||
@@ -1670,7 +1782,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
course.children.append(vertical)
|
||||
# in memory
|
||||
self.assertIsNotNone(course.start)
|
||||
self.assertEqual(course.start, vertical.lms.start)
|
||||
self.assertEqual(course.start, vertical.start)
|
||||
self.assertEqual(course.textbooks, [])
|
||||
self.assertIn('GRADER', course.grading_policy)
|
||||
self.assertIn('GRADE_CUTOFFS', course.grading_policy)
|
||||
@@ -1682,7 +1794,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
fetched_item = module_store.get_item(vertical.location)
|
||||
self.assertIsNotNone(fetched_course.start)
|
||||
self.assertEqual(course.start, fetched_course.start)
|
||||
self.assertEqual(fetched_course.start, fetched_item.lms.start)
|
||||
self.assertEqual(fetched_course.start, fetched_item.start)
|
||||
self.assertEqual(course.textbooks, fetched_course.textbooks)
|
||||
# is this test too strict? i.e., it requires the dicts to be ==
|
||||
self.assertEqual(course.checklists, fetched_course.checklists)
|
||||
@@ -1755,12 +1867,10 @@ class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
'track'
|
||||
}
|
||||
|
||||
fields = self.video_descriptor.fields
|
||||
location = self.video_descriptor.location
|
||||
|
||||
for field in fields:
|
||||
if field.name in attrs_to_strip:
|
||||
field.delete_from(self.video_descriptor)
|
||||
for field_name in attrs_to_strip:
|
||||
delattr(self.video_descriptor, field_name)
|
||||
|
||||
self.assertNotIn('html5_sources', own_metadata(self.video_descriptor))
|
||||
get_modulestore(location).update_metadata(
|
||||
|
||||
@@ -118,16 +118,20 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
response = self.client.get(settings_details_url)
|
||||
self.assertContains(response, "Course Summary Page")
|
||||
self.assertNotContains(response, "Course Summary Page")
|
||||
self.assertNotContains(response, "Send a note to students via email")
|
||||
self.assertContains(response, "course summary page will not be viewable")
|
||||
|
||||
self.assertContains(response, "Course Start Date")
|
||||
self.assertContains(response, "Course End Date")
|
||||
self.assertNotContains(response, "Enrollment Start Date")
|
||||
self.assertNotContains(response, "Enrollment End Date")
|
||||
self.assertContains(response, "Enrollment Start Date")
|
||||
self.assertContains(response, "Enrollment End Date")
|
||||
self.assertContains(response, "not the dates shown on your course summary page")
|
||||
|
||||
self.assertNotContains(response, "Introducing Your Course")
|
||||
self.assertContains(response, "Introducing Your Course")
|
||||
self.assertContains(response, "Course Image")
|
||||
self.assertNotContains(response,"Course Overview")
|
||||
self.assertNotContains(response,"Course Introduction Video")
|
||||
self.assertNotContains(response, "Requirements")
|
||||
|
||||
def test_regular_site_fetch(self):
|
||||
@@ -143,6 +147,7 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
|
||||
response = self.client.get(settings_details_url)
|
||||
self.assertContains(response, "Course Summary Page")
|
||||
self.assertContains(response, "Send a note to students via email")
|
||||
self.assertNotContains(response, "course summary page will not be viewable")
|
||||
|
||||
self.assertContains(response, "Course Start Date")
|
||||
@@ -152,6 +157,9 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertNotContains(response, "not the dates shown on your course summary page")
|
||||
|
||||
self.assertContains(response, "Introducing Your Course")
|
||||
self.assertContains(response, "Course Image")
|
||||
self.assertContains(response,"Course Overview")
|
||||
self.assertContains(response,"Course Introduction Video")
|
||||
self.assertContains(response, "Requirements")
|
||||
|
||||
|
||||
@@ -341,8 +349,8 @@ class CourseGradingTest(CourseTestCase):
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.lms.format)
|
||||
self.assertEqual(False, descriptor.lms.graded)
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
|
||||
# Change the default grader type to Homework, which should also mark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
|
||||
@@ -350,8 +358,8 @@ class CourseGradingTest(CourseTestCase):
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Homework', section_grader_type['graderType'])
|
||||
self.assertEqual('Homework', descriptor.lms.format)
|
||||
self.assertEqual(True, descriptor.lms.graded)
|
||||
self.assertEqual('Homework', descriptor.format)
|
||||
self.assertEqual(True, descriptor.graded)
|
||||
|
||||
# Change the grader type back to Not Graded, which should also unmark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
|
||||
@@ -359,8 +367,8 @@ class CourseGradingTest(CourseTestCase):
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.lms.format)
|
||||
self.assertEqual(False, descriptor.lms.graded)
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
|
||||
@@ -2,7 +2,7 @@ import unittest
|
||||
from xmodule import templates
|
||||
from xmodule.modulestore.tests import persistent_factories
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.django import modulestore, loc_mapper
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator
|
||||
@@ -191,6 +191,26 @@ class TemplateTests(unittest.TestCase):
|
||||
version_history = modulestore('split').get_block_generations(second_problem.location)
|
||||
self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid)
|
||||
|
||||
def test_split_inject_loc_mapper(self):
|
||||
"""
|
||||
Test that creating a loc_mapper causes it to automatically attach to the split mongo store
|
||||
"""
|
||||
# instantiate location mapper before split
|
||||
mapper = loc_mapper()
|
||||
# split must inject the location mapper itself since the mapper existed before it did
|
||||
self.assertEqual(modulestore('split').loc_mapper, mapper)
|
||||
|
||||
def test_loc_inject_into_split(self):
|
||||
"""
|
||||
Test that creating a loc_mapper causes it to automatically attach to the split mongo store
|
||||
"""
|
||||
# force instantiation of split modulestore before there's a location mapper and verify
|
||||
# it has no pointer to loc mapper
|
||||
self.assertIsNone(modulestore('split').loc_mapper)
|
||||
# force instantiation of location mapper which must inject itself into the split
|
||||
mapper = loc_mapper()
|
||||
self.assertEqual(modulestore('split').loc_mapper, mapper)
|
||||
|
||||
# ================================= JSON PARSING ===========================
|
||||
# These are example methods for creating xmodules in memory w/o persisting them.
|
||||
# They were in x_module but since xblock is not planning to support them but will
|
||||
@@ -218,20 +238,16 @@ class TemplateTests(unittest.TestCase):
|
||||
)
|
||||
usage_id = json_data.get('_id', None)
|
||||
if not '_inherited_settings' in json_data and parent_xblock is not None:
|
||||
json_data['_inherited_settings'] = parent_xblock.xblock_kvs.get_inherited_settings().copy()
|
||||
json_data['_inherited_settings'] = parent_xblock.xblock_kvs.inherited_settings.copy()
|
||||
json_fields = json_data.get('fields', {})
|
||||
for field in inheritance.INHERITABLE_METADATA:
|
||||
if field in json_fields:
|
||||
json_data['_inherited_settings'][field] = json_fields[field]
|
||||
for field_name in inheritance.InheritanceMixin.fields:
|
||||
if field_name in json_fields:
|
||||
json_data['_inherited_settings'][field_name] = json_fields[field_name]
|
||||
|
||||
new_block = system.xblock_from_json(class_, usage_id, json_data)
|
||||
if parent_xblock is not None:
|
||||
children = parent_xblock.children
|
||||
children.append(new_block)
|
||||
# trigger setter method by using top level field access
|
||||
parent_xblock.children = children
|
||||
# decache pending children field settings (Note, truly persisting at this point would break b/c
|
||||
# persistence assumes children is a list of ids not actual xblocks)
|
||||
parent_xblock.children.append(new_block.scope_ids.usage_id)
|
||||
# decache pending children field settings
|
||||
parent_xblock.save()
|
||||
return new_block
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import copy
|
||||
from path import path
|
||||
import json
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
|
||||
@@ -19,6 +22,8 @@ from xmodule.contentstore.django import _CONTENTSTORE
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class ImportTestCase(CourseTestCase):
|
||||
"""
|
||||
@@ -32,7 +37,7 @@ class ImportTestCase(CourseTestCase):
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
})
|
||||
self.content_dir = tempfile.mkdtemp()
|
||||
self.content_dir = path(tempfile.mkdtemp())
|
||||
|
||||
def touch(name):
|
||||
""" Equivalent to shell's 'touch'"""
|
||||
@@ -60,11 +65,15 @@ class ImportTestCase(CourseTestCase):
|
||||
with tarfile.open(self.bad_tar, "w:gz") as btar:
|
||||
btar.add(bad_dir)
|
||||
|
||||
self.unsafe_common_dir = path(tempfile.mkdtemp(dir=self.content_dir))
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.content_dir)
|
||||
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db'])
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
|
||||
def test_no_coursexml(self):
|
||||
"""
|
||||
Check that the response for a tar.gz import without a course.xml is
|
||||
@@ -78,6 +87,17 @@ class ImportTestCase(CourseTestCase):
|
||||
"course-data": [btar]
|
||||
})
|
||||
self.assertEquals(resp.status_code, 415)
|
||||
# Check that `import_status` returns the appropriate stage (i.e., the
|
||||
# stage at which import failed).
|
||||
status_url = reverse("import_status", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': os.path.split(self.bad_tar)[1],
|
||||
})
|
||||
resp_status = self.client.get(status_url)
|
||||
log.debug(str(self.client.session["import_status"]))
|
||||
self.assertEquals(json.loads(resp_status.content)["ImportStatus"], 2)
|
||||
|
||||
|
||||
def test_with_coursexml(self):
|
||||
"""
|
||||
@@ -92,3 +112,99 @@ class ImportTestCase(CourseTestCase):
|
||||
"course-data": [gtar]
|
||||
})
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
|
||||
## Unsafe tar methods #####################################################
|
||||
# Each of these methods creates a tarfile with a single type of unsafe
|
||||
# content.
|
||||
|
||||
def _fifo_tar(self):
|
||||
"""
|
||||
Tar file with FIFO
|
||||
"""
|
||||
fifop = self.unsafe_common_dir / "fifo.file"
|
||||
fifo_tar = self.unsafe_common_dir / "fifo.tar.gz"
|
||||
os.mkfifo(fifop)
|
||||
with tarfile.open(fifo_tar, "w:gz") as tar:
|
||||
tar.add(fifop)
|
||||
|
||||
return fifo_tar
|
||||
|
||||
def _symlink_tar(self):
|
||||
"""
|
||||
Tarfile with symlink to path outside directory.
|
||||
"""
|
||||
outsidep = self.unsafe_common_dir / "unsafe_file.txt"
|
||||
symlinkp = self.unsafe_common_dir / "symlink.txt"
|
||||
symlink_tar = self.unsafe_common_dir / "symlink.tar.gz"
|
||||
outsidep.symlink(symlinkp)
|
||||
with tarfile.open(symlink_tar, "w:gz" ) as tar:
|
||||
tar.add(symlinkp)
|
||||
|
||||
return symlink_tar
|
||||
|
||||
def _outside_tar(self):
|
||||
"""
|
||||
Tarfile with file that extracts to outside directory.
|
||||
|
||||
Extracting this tarfile in directory <dir> will put its contents
|
||||
directly in <dir> (rather than <dir/tarname>).
|
||||
"""
|
||||
outside_tar = self.unsafe_common_dir / "unsafe_file.tar.gz"
|
||||
with tarfile.open(outside_tar, "w:gz") as tar:
|
||||
tar.addfile(tarfile.TarInfo(str(self.content_dir / "a_file")))
|
||||
|
||||
return outside_tar
|
||||
|
||||
def _outside_tar2(self):
|
||||
"""
|
||||
Tarfile with file that extracts to outside directory.
|
||||
|
||||
The path here matches the basename (`self.unsafe_common_dir`), but
|
||||
then "cd's out". E.g. "/usr/../etc" == "/etc", but the naive basename
|
||||
of the first (but not the second) is "/usr"
|
||||
|
||||
Extracting this tarfile in directory <dir> will also put its contents
|
||||
directly in <dir> (rather than <dir/tarname>).
|
||||
"""
|
||||
outside_tar = self.unsafe_common_dir / "unsafe_file.tar.gz"
|
||||
with tarfile.open(outside_tar, "w:gz") as tar:
|
||||
tar.addfile(tarfile.TarInfo(str(self.unsafe_common_dir / "../a_file")))
|
||||
|
||||
return outside_tar
|
||||
|
||||
def test_unsafe_tar(self):
|
||||
"""
|
||||
Check that safety measure work.
|
||||
|
||||
This includes:
|
||||
'tarbombs' which include files or symlinks with paths
|
||||
outside or directly in the working directory,
|
||||
'special files' (character device, block device or FIFOs),
|
||||
|
||||
all raise exceptions/400s.
|
||||
"""
|
||||
|
||||
def try_tar(tarpath):
|
||||
with open(tarpath) as tar:
|
||||
resp = self.client.post(
|
||||
self.url,
|
||||
{ "name": tarpath, "course-data": [tar] }
|
||||
)
|
||||
self.assertEquals(resp.status_code, 400)
|
||||
self.assertTrue("SuspiciousFileOperation" in resp.content)
|
||||
|
||||
try_tar(self._fifo_tar())
|
||||
try_tar(self._symlink_tar())
|
||||
try_tar(self._outside_tar())
|
||||
try_tar(self._outside_tar2())
|
||||
# Check that `import_status` returns the appropriate stage (i.e.,
|
||||
# either 3, indicating all previous steps are completed, or 0,
|
||||
# indicating no upload in progress)
|
||||
status_url = reverse("import_status", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': os.path.split(self.good_tar)[1],
|
||||
})
|
||||
resp_status = self.client.get(status_url)
|
||||
import_status = json.loads(resp_status.content)["ImportStatus"]
|
||||
self.assertIn(import_status, (0, 3))
|
||||
|
||||
@@ -97,9 +97,9 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
# make sure course.lms.static_asset_path is correct
|
||||
print "static_asset_path = {0}".format(course.lms.static_asset_path)
|
||||
self.assertEqual(course.lms.static_asset_path, 'test_import_course')
|
||||
# make sure course.static_asset_path is correct
|
||||
print "static_asset_path = {0}".format(course.static_asset_path)
|
||||
self.assertEqual(course.static_asset_path, 'test_import_course')
|
||||
|
||||
def test_asset_import_nostatic(self):
|
||||
'''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
@@ -69,7 +69,7 @@ class TestCreateItem(CourseTestCase):
|
||||
# get the new item and check its category and display_name
|
||||
chap_location = self.response_id(resp)
|
||||
new_obj = modulestore().get_item(chap_location)
|
||||
self.assertEqual(new_obj.category, 'chapter')
|
||||
self.assertEqual(new_obj.scope_ids.block_type, 'chapter')
|
||||
self.assertEqual(new_obj.display_name, display_name)
|
||||
self.assertEqual(new_obj.location.org, self.course.location.org)
|
||||
self.assertEqual(new_obj.location.course, self.course.location.course)
|
||||
@@ -226,7 +226,7 @@ class TestEditItem(CourseTestCase):
|
||||
Test setting due & start dates on sequential
|
||||
"""
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertIsNone(sequential.lms.due)
|
||||
self.assertIsNone(sequential.due)
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
@@ -236,7 +236,7 @@ class TestEditItem(CourseTestCase):
|
||||
content_type="application/json"
|
||||
)
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.assertEqual(sequential.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
@@ -246,5 +246,5 @@ class TestEditItem(CourseTestCase):
|
||||
content_type="application/json"
|
||||
)
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
|
||||
self.assertEqual(sequential.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.assertEqual(sequential.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
|
||||
|
||||
41
cms/djangoapps/contentstore/tests/test_tabs.py
Normal file
41
cms/djangoapps/contentstore/tests/test_tabs.py
Normal file
@@ -0,0 +1,41 @@
|
||||
""" Tests for tab functions (just primitive). """
|
||||
|
||||
from contentstore.views import tabs
|
||||
from django.test import TestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from courseware.courses import get_course_by_id
|
||||
|
||||
|
||||
class PrimitiveTabEdit(TestCase):
|
||||
"""Tests for the primitive tab edit data manipulations"""
|
||||
|
||||
def test_delete(self):
|
||||
"""Test primitive tab deletion."""
|
||||
course = CourseFactory.create(org='edX', course='999')
|
||||
with self.assertRaises(ValueError):
|
||||
tabs.primitive_delete(course, 0)
|
||||
with self.assertRaises(ValueError):
|
||||
tabs.primitive_delete(course, 1)
|
||||
with self.assertRaises(IndexError):
|
||||
tabs.primitive_delete(course, 6)
|
||||
tabs.primitive_delete(course, 2)
|
||||
self.assertFalse({u'type': u'textbooks'} in course.tabs)
|
||||
# Check that discussion has shifted down
|
||||
self.assertEquals(course.tabs[2], {'type': 'discussion', 'name': 'Discussion'})
|
||||
|
||||
def test_insert(self):
|
||||
"""Test primitive tab insertion."""
|
||||
course = CourseFactory.create(org='edX', course='999')
|
||||
tabs.primitive_insert(course, 2, 'atype', 'aname')
|
||||
self.assertEquals(course.tabs[2], {'type': 'atype', 'name': 'aname'})
|
||||
with self.assertRaises(ValueError):
|
||||
tabs.primitive_insert(course, 0, 'atype', 'aname')
|
||||
with self.assertRaises(ValueError):
|
||||
tabs.primitive_insert(course, 3, 'static_tab', 'aname')
|
||||
|
||||
def test_save(self):
|
||||
"""Test course saving."""
|
||||
course = CourseFactory.create(org='edX', course='999')
|
||||
tabs.primitive_insert(course, 3, 'atype', 'aname')
|
||||
course2 = get_course_by_id(course.id)
|
||||
self.assertEquals(course2.tabs[3], {'type': 'atype', 'name': 'aname'})
|
||||
@@ -5,12 +5,16 @@ from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from django.core.urlresolvers import reverse
|
||||
from xmodule.contentstore.django import contentstore
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_comment_common.utils import unseed_permissions_roles
|
||||
from auth.authz import _delete_course_group
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +24,31 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
|
||||
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
|
||||
|
||||
|
||||
def delete_course_and_groups(course_id, commit=False):
|
||||
"""
|
||||
This deletes the courseware associated with a course_id as well as cleaning update_item
|
||||
the various user table stuff (groups, permissions, etc.)
|
||||
"""
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
|
||||
org, course_num, run = course_id.split("/")
|
||||
module_store.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
if delete_course(module_store, content_store, loc, commit):
|
||||
print 'removing forums permissions and roles...'
|
||||
unseed_permissions_roles(course_id)
|
||||
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
if commit:
|
||||
try:
|
||||
_delete_course_group(loc)
|
||||
except Exception as err:
|
||||
log.error("Error in deleting course groups for {0}: {1}".format(loc, err))
|
||||
|
||||
|
||||
def get_modulestore(category_or_location):
|
||||
"""
|
||||
Returns the correct modulestore to use for modifying the specified location
|
||||
|
||||
@@ -1,76 +1,32 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import shutil
|
||||
import cgi
|
||||
import re
|
||||
from functools import partial
|
||||
from tempfile import mkdtemp
|
||||
from path import path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
from django.views.decorators.http import require_POST, require_http_methods
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from auth.authz import create_all_course_groups
|
||||
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore import InvalidLocationError
|
||||
from xmodule.exceptions import NotFoundError, SerializationError
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from .access import get_location_and_verify_access
|
||||
from util.json_request import JsonResponse
|
||||
import json
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
|
||||
__all__ = ['asset_index', 'upload_asset']
|
||||
|
||||
def assets_to_json_dict(assets):
|
||||
"""
|
||||
Transform the results of a contentstore query into something appropriate
|
||||
for output via JSON.
|
||||
"""
|
||||
ret = []
|
||||
for asset in assets:
|
||||
obj = {
|
||||
"name": asset.get("displayname", ""),
|
||||
"chunkSize": asset.get("chunkSize", 0),
|
||||
"path": asset.get("filename", ""),
|
||||
"length": asset.get("length", 0),
|
||||
}
|
||||
uploaded = asset.get("uploadDate")
|
||||
if uploaded:
|
||||
obj["uploaded"] = uploaded.isoformat()
|
||||
thumbnail = asset.get("thumbnail_location")
|
||||
if thumbnail:
|
||||
obj["thumbnail"] = thumbnail
|
||||
id_info = asset.get("_id")
|
||||
if id_info:
|
||||
obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}" \
|
||||
.format(
|
||||
org=id_info.get("org", ""),
|
||||
course=id_info.get("course", ""),
|
||||
revision=id_info.get("revision", ""),
|
||||
tag=id_info.get("tag", ""),
|
||||
category=id_info.get("category", ""),
|
||||
name=id_info.get("name", ""),
|
||||
)
|
||||
ret.append(obj)
|
||||
return ret
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -96,32 +52,22 @@ def asset_index(request, org, course, name):
|
||||
# sort in reverse upload date order
|
||||
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
|
||||
|
||||
if request.META.get('HTTP_ACCEPT', "").startswith("application/json"):
|
||||
return JsonResponse(assets_to_json_dict(assets))
|
||||
|
||||
asset_display = []
|
||||
asset_json = []
|
||||
for asset in assets:
|
||||
asset_id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'])
|
||||
|
||||
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
display_info['portable_url'] = StaticContent.get_static_path_from_location(asset_location)
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
|
||||
display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None
|
||||
|
||||
asset_display.append(display_info)
|
||||
asset_locked = asset.get('locked', False)
|
||||
asset_json.append(_get_asset_json(asset['displayname'], asset['uploadDate'], asset_location, thumbnail_location, asset_locked))
|
||||
|
||||
return render_to_response('asset_index.html', {
|
||||
'context_course': course_module,
|
||||
'assets': asset_display,
|
||||
'asset_list': json.dumps(asset_json),
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'remove_asset_callback_url': reverse('remove_asset', kwargs={
|
||||
'update_asset_callback_url': reverse('update_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'name': name
|
||||
@@ -171,9 +117,6 @@ def upload_asset(request, org, course, coursename):
|
||||
content = sc_partial(upload_file.read())
|
||||
tempfile_path = None
|
||||
|
||||
thumbnail_content = None
|
||||
thumbnail_location = None
|
||||
|
||||
# first let's see if a thumbnail can be created
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(
|
||||
content,
|
||||
@@ -194,68 +137,90 @@ def upload_asset(request, org, course, coursename):
|
||||
# readback the saved content - we need the database timestamp
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
locked = getattr(content, 'locked', False)
|
||||
response_payload = {
|
||||
'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'portable_url': StaticContent.get_static_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location)
|
||||
if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
'asset': _get_asset_json(content.name, readback.last_modified_at, content.location, content.thumbnail_location, locked),
|
||||
'msg': _('Upload completed')
|
||||
}
|
||||
|
||||
response = JsonResponse(response_payload)
|
||||
return response
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("DELETE", "POST", "PUT"))
|
||||
@login_required
|
||||
def remove_asset(request, org, course, name):
|
||||
'''
|
||||
This method will perform a 'soft-delete' of an asset, which is basically to
|
||||
copy the asset from the main GridFS collection and into a Trashcan
|
||||
'''
|
||||
@ensure_csrf_cookie
|
||||
def update_asset(request, org, course, name, asset_id):
|
||||
"""
|
||||
restful CRUD operations for a course asset.
|
||||
Currently only DELETE, POST, and PUT methods are implemented.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
asset_id: the URL of the asset (used by Backbone as the id)
|
||||
"""
|
||||
def get_asset_location(asset_id):
|
||||
""" Helper method to get the location (and verify it is valid). """
|
||||
try:
|
||||
return StaticContent.get_location_from_path(asset_id)
|
||||
except InvalidLocationError as err:
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
return JsonResponse({"error": err.message}, status=400)
|
||||
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
location = request.POST['location']
|
||||
|
||||
# make sure the location is valid
|
||||
try:
|
||||
loc = StaticContent.get_location_from_path(location)
|
||||
except InvalidLocationError:
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
response = HttpResponse()
|
||||
response.status_code = 400
|
||||
return response
|
||||
|
||||
# also make sure the item to delete actually exists
|
||||
try:
|
||||
content = contentstore().find(loc)
|
||||
except NotFoundError:
|
||||
response = HttpResponse()
|
||||
response.status_code = 404
|
||||
return response
|
||||
|
||||
# ok, save the content into the trashcan
|
||||
contentstore('trashcan').save(content)
|
||||
|
||||
# see if there is a thumbnail as well, if so move that as well
|
||||
if content.thumbnail_location is not None:
|
||||
if request.method == 'DELETE':
|
||||
loc = get_asset_location(asset_id)
|
||||
# Make sure the item to delete actually exists.
|
||||
try:
|
||||
thumbnail_content = contentstore().find(content.thumbnail_location)
|
||||
contentstore('trashcan').save(thumbnail_content)
|
||||
# hard delete thumbnail from origin
|
||||
contentstore().delete(thumbnail_content.get_id())
|
||||
# remove from any caching
|
||||
del_cached_content(thumbnail_content.location)
|
||||
except:
|
||||
pass # OK if this is left dangling
|
||||
content = contentstore().find(loc)
|
||||
except NotFoundError:
|
||||
return JsonResponse(status=404)
|
||||
|
||||
# delete the original
|
||||
contentstore().delete(content.get_id())
|
||||
# remove from cache
|
||||
del_cached_content(content.location)
|
||||
# ok, save the content into the trashcan
|
||||
contentstore('trashcan').save(content)
|
||||
|
||||
return HttpResponse()
|
||||
# see if there is a thumbnail as well, if so move that as well
|
||||
if content.thumbnail_location is not None:
|
||||
try:
|
||||
thumbnail_content = contentstore().find(content.thumbnail_location)
|
||||
contentstore('trashcan').save(thumbnail_content)
|
||||
# hard delete thumbnail from origin
|
||||
contentstore().delete(thumbnail_content.get_id())
|
||||
# remove from any caching
|
||||
del_cached_content(thumbnail_content.location)
|
||||
except:
|
||||
logging.warning('Could not delete thumbnail: ' + content.thumbnail_location)
|
||||
|
||||
# delete the original
|
||||
contentstore().delete(content.get_id())
|
||||
# remove from cache
|
||||
del_cached_content(content.location)
|
||||
return JsonResponse()
|
||||
|
||||
elif request.method in ('PUT', 'POST'):
|
||||
# We don't support creation of new assets through this
|
||||
# method-- just changing the locked state.
|
||||
modified_asset = json.loads(request.body)
|
||||
asset_id = modified_asset['url']
|
||||
location = get_asset_location(asset_id)
|
||||
contentstore().set_attr(location, 'locked', modified_asset['locked'])
|
||||
# Delete the asset from the cache so we check the lock status the next time it is requested.
|
||||
del_cached_content(location)
|
||||
|
||||
return JsonResponse(modified_asset, status=201)
|
||||
|
||||
|
||||
def _get_asset_json(display_name, date, location, thumbnail_location, locked):
|
||||
"""
|
||||
Helper method for formatting the asset information to send to client.
|
||||
"""
|
||||
asset_url = StaticContent.get_url_path_from_location(location)
|
||||
return {
|
||||
'display_name': display_name,
|
||||
'date_added': get_default_time_display(date),
|
||||
'url': asset_url,
|
||||
'portable_url': StaticContent.get_static_path_from_location(location),
|
||||
'thumbnail': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None,
|
||||
'locked': locked,
|
||||
# Needed for Backbone delete/update.
|
||||
'id': asset_url
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import copy
|
||||
|
||||
from util.json_request import JsonResponse
|
||||
from django.http import HttpResponseBadRequest
|
||||
@@ -32,19 +33,16 @@ def get_checklists(request, org, course, name):
|
||||
|
||||
# If course was created before checklists were introduced, copy them over
|
||||
# from the template.
|
||||
copied = False
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = CourseDescriptor.checklists.default
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
course_module.save()
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
|
||||
expanded_checklists = expand_all_action_urls(course_module)
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
'context_course': course_module,
|
||||
'checklists': checklists
|
||||
'checklists': expanded_checklists
|
||||
})
|
||||
|
||||
|
||||
@@ -68,14 +66,20 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
if request.method in ("POST", "PUT"):
|
||||
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
|
||||
index = int(checklist_index)
|
||||
course_module.checklists[index] = json.loads(request.body)
|
||||
persisted_checklist = course_module.checklists[index]
|
||||
modified_checklist = json.loads(request.body)
|
||||
# Only thing the user can modify is the "checked" state.
|
||||
# We don't want to persist what comes back from the client because it will
|
||||
# include the expanded action URLs (which are non-portable).
|
||||
for item_index, item in enumerate(modified_checklist.get('items')):
|
||||
persisted_checklist['items'][item_index]['is_checked'] = item['is_checked']
|
||||
# seeming noop which triggers kvs to record that the metadata is
|
||||
# not default
|
||||
course_module.checklists = course_module.checklists
|
||||
checklists, _ = expand_checklist_action_urls(course_module)
|
||||
course_module.save()
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return JsonResponse(checklists[index])
|
||||
expanded_checklist = expand_checklist_action_url(course_module, persisted_checklist)
|
||||
return JsonResponse(expanded_checklist)
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
( "Could not save checklist state because the checklist index "
|
||||
@@ -85,23 +89,30 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
elif request.method == 'GET':
|
||||
# In the JavaScript view initialize method, we do a fetch to get all
|
||||
# the checklists.
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
course_module.save()
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return JsonResponse(checklists)
|
||||
expanded_checklists = expand_all_action_urls(course_module)
|
||||
return JsonResponse(expanded_checklists)
|
||||
|
||||
|
||||
def expand_checklist_action_urls(course_module):
|
||||
def expand_all_action_urls(course_module):
|
||||
"""
|
||||
Gets the checklists out of the course module and expands their action urls
|
||||
if they have not yet been expanded.
|
||||
Gets the checklists out of the course module and expands their action urls.
|
||||
|
||||
Returns the checklists with modified urls, as well as a boolean
|
||||
indicating whether or not the checklists were modified.
|
||||
Returns a copy of the checklists with modified urls, without modifying the persisted version
|
||||
of the checklists.
|
||||
"""
|
||||
checklists = course_module.checklists
|
||||
modified = False
|
||||
expanded_checklists = []
|
||||
for checklist in course_module.checklists:
|
||||
expanded_checklists.append(expand_checklist_action_url(course_module, checklist))
|
||||
return expanded_checklists
|
||||
|
||||
|
||||
def expand_checklist_action_url(course_module, checklist):
|
||||
"""
|
||||
Expands the action URLs for a given checklist and returns the modified version.
|
||||
|
||||
The method does a copy of the input checklist and does not modify the input argument.
|
||||
"""
|
||||
expanded_checklist = copy.deepcopy(checklist)
|
||||
urlconf_map = {
|
||||
"ManageUsers": "manage_users",
|
||||
"SettingsDetails": "settings_details",
|
||||
@@ -109,19 +120,15 @@ def expand_checklist_action_urls(course_module):
|
||||
"CourseOutline": "course_index",
|
||||
"Checklists": "checklists",
|
||||
}
|
||||
for checklist in checklists:
|
||||
if not checklist.get('action_urls_expanded', False):
|
||||
for item in checklist.get('items'):
|
||||
action_url = item.get('action_url')
|
||||
if action_url not in urlconf_map:
|
||||
continue
|
||||
urlconf_name = urlconf_map[action_url]
|
||||
item['action_url'] = reverse(urlconf_name, kwargs={
|
||||
'org': course_module.location.org,
|
||||
'course': course_module.location.course,
|
||||
'name': course_module.location.name,
|
||||
})
|
||||
checklist['action_urls_expanded'] = True
|
||||
modified = True
|
||||
for item in expanded_checklist.get('items'):
|
||||
action_url = item.get('action_url')
|
||||
if action_url not in urlconf_map:
|
||||
continue
|
||||
urlconf_name = urlconf_map[action_url]
|
||||
item['action_url'] = reverse(urlconf_name, kwargs={
|
||||
'org': course_module.location.org,
|
||||
'course': course_module.location.course,
|
||||
'name': course_module.location.name,
|
||||
})
|
||||
|
||||
return checklists, modified
|
||||
return expanded_checklist
|
||||
|
||||
@@ -2,27 +2,27 @@ import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.http import ( HttpResponse, HttpResponseBadRequest,
|
||||
HttpResponseForbidden )
|
||||
from django.http import (HttpResponse, HttpResponseBadRequest,
|
||||
HttpResponseForbidden)
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore.exceptions import ( ItemNotFoundError,
|
||||
InvalidLocationError )
|
||||
from xmodule.modulestore.exceptions import (ItemNotFoundError,
|
||||
InvalidLocationError)
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
from xblock.core import Scope
|
||||
from xblock.fields import Scope
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
|
||||
from contentstore.module_info_model import get_module_info, set_module_info
|
||||
from contentstore.utils import ( get_modulestore, get_lms_link_for_item,
|
||||
compute_unit_state, UnitState, get_course_for_item )
|
||||
from contentstore.utils import (get_modulestore, get_lms_link_for_item,
|
||||
compute_unit_state, UnitState, get_course_for_item)
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
@@ -30,6 +30,7 @@ from .requests import _xmodule_recurse
|
||||
from .access import has_access
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xblock.plugin import PluginMissingError
|
||||
from xblock.runtime import Mixologist
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
@@ -51,7 +52,8 @@ NOTE_COMPONENT_TYPES = ['notes']
|
||||
ADVANCED_COMPONENT_TYPES = [
|
||||
'annotatable',
|
||||
'word_cloud',
|
||||
'graphical_slider_tool'
|
||||
'graphical_slider_tool',
|
||||
'lti',
|
||||
] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
@@ -91,7 +93,7 @@ def edit_subsection(request, location):
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error(
|
||||
'Multiple (or none) parents have been found for %',
|
||||
'Multiple (or none) parents have been found for %s',
|
||||
location
|
||||
)
|
||||
|
||||
@@ -99,12 +101,14 @@ def edit_subsection(request, location):
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a
|
||||
# more normalized UI
|
||||
# more normalized UI. We only want to display the XBlocks fields, not
|
||||
# the fields from any mixins that have been added
|
||||
fields = getattr(item, 'unmixed_class', item.__class__).fields
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in item.fields
|
||||
in fields.values()
|
||||
if field.name not in ['display_name', 'start', 'due', 'format']
|
||||
and field.scope == Scope.settings
|
||||
)
|
||||
@@ -135,6 +139,15 @@ def edit_subsection(request, location):
|
||||
)
|
||||
|
||||
|
||||
def load_mixed_class(category):
|
||||
"""
|
||||
Load an XBlock by category name, and apply all defined mixins
|
||||
"""
|
||||
component_class = XModuleDescriptor.load_class(category)
|
||||
mixologist = Mixologist(settings.XBLOCK_MIXINS)
|
||||
return mixologist.mix(component_class)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_unit(request, location):
|
||||
"""
|
||||
@@ -163,22 +176,29 @@ def edit_unit(request, location):
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
component_class = XModuleDescriptor.load_class(category)
|
||||
component_class = load_mixed_class(category)
|
||||
# add the default template
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
if hasattr(component_class, 'display_name'):
|
||||
display_name = component_class.display_name.default or 'Blank'
|
||||
else:
|
||||
display_name = 'Blank'
|
||||
component_templates[category].append((
|
||||
component_class.display_name.default or 'Blank',
|
||||
display_name,
|
||||
category,
|
||||
False, # No defaults have markdown (hardcoded current default)
|
||||
None # no boilerplate for overrides
|
||||
))
|
||||
# add boilerplates
|
||||
for template in component_class.templates():
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
if hasattr(component_class, 'templates'):
|
||||
for template in component_class.templates():
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy.
|
||||
# These modules should be specified as a list of strings, where the strings
|
||||
@@ -194,7 +214,7 @@ def edit_unit(request, location):
|
||||
# class? i.e., can an advanced have more than one entry in the
|
||||
# menu? one for default and others for prefilled boilerplates?
|
||||
try:
|
||||
component_class = XModuleDescriptor.load_class(category)
|
||||
component_class = load_mixed_class(category)
|
||||
|
||||
component_templates['advanced'].append((
|
||||
component_class.display_name.default or category,
|
||||
@@ -272,13 +292,17 @@ def edit_unit(request, location):
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start)
|
||||
if containing_subsection.lms.start is not None else None,
|
||||
'release_date': (
|
||||
get_default_time_display(containing_subsection.start)
|
||||
if containing_subsection.start is not None else None
|
||||
),
|
||||
'section': containing_section,
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': unit_state,
|
||||
'published_date': get_default_time_display(item.cms.published_date)
|
||||
if item.cms.published_date is not None else None
|
||||
'published_date': (
|
||||
get_default_time_display(item.published_date)
|
||||
if item.published_date is not None else None
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
from xmodule.modulestore.exceptions import (
|
||||
ItemNotFoundError, InvalidLocationError)
|
||||
@@ -123,29 +124,33 @@ def create_new_course(request):
|
||||
pass
|
||||
if existing_course is not None:
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'organization, course number, and course run. Please '
|
||||
'change either organization or course number to be '
|
||||
'unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'organization, course number, and course run. Please '
|
||||
'change either organization or course number to be '
|
||||
'unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
})
|
||||
|
||||
course_search_location = ['i4x', dest_location.org, dest_location.course,
|
||||
'course', None
|
||||
course_search_location = [
|
||||
'i4x',
|
||||
dest_location.org,
|
||||
dest_location.course,
|
||||
'course',
|
||||
None
|
||||
]
|
||||
courses = modulestore().get_items(course_search_location)
|
||||
if len(courses) > 0:
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'organization and course number. Please '
|
||||
'change at least one field to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'organization and course number. Please '
|
||||
'change at least one field to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
})
|
||||
|
||||
# instantiate the CourseDescriptor and then persist it
|
||||
@@ -155,15 +160,15 @@ def create_new_course(request):
|
||||
else:
|
||||
metadata = {'display_name': display_name}
|
||||
modulestore('direct').create_and_save_xmodule(
|
||||
dest_location,
|
||||
metadata=metadata
|
||||
dest_location,
|
||||
metadata=metadata
|
||||
)
|
||||
new_course = modulestore('direct').get_item(dest_location)
|
||||
|
||||
# clone a default 'about' overview module as well
|
||||
dest_about_location = dest_location.replace(
|
||||
category='about',
|
||||
name='overview'
|
||||
category='about',
|
||||
name='overview'
|
||||
)
|
||||
overview_template = AboutDescriptor.get_template('overview.yaml')
|
||||
modulestore('direct').create_and_save_xmodule(
|
||||
@@ -202,12 +207,16 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
# get current updates
|
||||
location = Location(['i4x', org, course, 'course_info', "updates"])
|
||||
|
||||
return render_to_response('course_info.html', {
|
||||
'context_course': course_module,
|
||||
'url_base': "/" + org + "/" + course + "/",
|
||||
'course_updates': json.dumps(get_course_updates(location)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() })
|
||||
|
||||
return render_to_response(
|
||||
'course_info.html',
|
||||
{
|
||||
'context_course': course_module,
|
||||
'url_base': "/" + org + "/" + course + "/",
|
||||
'course_updates': json.dumps(get_course_updates(location)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url(),
|
||||
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(location) + '/'
|
||||
}
|
||||
)
|
||||
|
||||
@expect_json
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@@ -243,7 +252,7 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
content_type="text/plain"
|
||||
)
|
||||
# can be either and sometimes django is rewriting one to the other:
|
||||
elif request.method in ('POST', 'PUT'):
|
||||
elif request.method in ('POST', 'PUT'):
|
||||
try:
|
||||
return JsonResponse(update_course_updates(location, request.POST, provided_id))
|
||||
except:
|
||||
@@ -378,7 +387,7 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return JsonResponse(CourseGradingModel.fetch_grader(
|
||||
Location(location), grader_index
|
||||
Location(location), grader_index
|
||||
))
|
||||
elif request.method == "DELETE":
|
||||
# ??? Should this return anything? Perhaps success fail?
|
||||
@@ -386,8 +395,8 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
return JsonResponse()
|
||||
else: # post or put, doesn't matter.
|
||||
return JsonResponse(CourseGradingModel.update_grader_from_json(
|
||||
Location(location),
|
||||
request.POST
|
||||
Location(location),
|
||||
request.POST
|
||||
))
|
||||
|
||||
|
||||
@@ -409,8 +418,8 @@ def course_advanced_updates(request, org, course, name):
|
||||
return JsonResponse(CourseMetadata.fetch(location))
|
||||
elif request.method == 'DELETE':
|
||||
return JsonResponse(CourseMetadata.delete_key(
|
||||
location,
|
||||
json.loads(request.body)
|
||||
location,
|
||||
json.loads(request.body)
|
||||
))
|
||||
else:
|
||||
# NOTE: request.POST is messed up because expect_json
|
||||
@@ -477,9 +486,9 @@ def course_advanced_updates(request, org, course, name):
|
||||
filter_tabs = False
|
||||
try:
|
||||
return JsonResponse(CourseMetadata.update_from_json(
|
||||
location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs
|
||||
location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs
|
||||
))
|
||||
except (TypeError, ValueError) as err:
|
||||
return HttpResponseBadRequest(
|
||||
@@ -583,8 +592,8 @@ def textbook_index(request, org, course, name):
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
store.update_metadata(
|
||||
course_module.location,
|
||||
own_metadata(course_module)
|
||||
course_module.location,
|
||||
own_metadata(course_module)
|
||||
)
|
||||
return JsonResponse(course_module.pdf_textbooks)
|
||||
else:
|
||||
|
||||
@@ -9,7 +9,6 @@ import shutil
|
||||
import re
|
||||
from tempfile import mkdtemp
|
||||
from path import path
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
@@ -18,7 +17,9 @@ from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.views.decorators.http import require_http_methods, require_GET
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from auth.authz import create_all_course_groups
|
||||
@@ -32,9 +33,10 @@ from xmodule.exceptions import SerializationError
|
||||
|
||||
from .access import get_location_and_verify_access
|
||||
from util.json_request import JsonResponse
|
||||
from extract_tar import safetar_extractall
|
||||
|
||||
|
||||
__all__ = ['import_course', 'generate_export_course', 'export_course']
|
||||
__all__ = ['import_course', 'import_status', 'generate_export_course', 'export_course']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,20 +55,6 @@ def import_course(request, org, course, name):
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
@contextmanager
|
||||
def wfile(filename, dirname):
|
||||
"""
|
||||
A with-context that creates `filename` on entry and removes it on exit.
|
||||
`filename` is truncted on creation. Additionally removes dirname on
|
||||
exit.
|
||||
"""
|
||||
open("file", "w").close()
|
||||
try:
|
||||
yield filename
|
||||
finally:
|
||||
os.remove(filename)
|
||||
shutil.rmtree(dirname)
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
data_root = path(settings.GITHUB_REPO_ROOT)
|
||||
@@ -76,7 +64,10 @@ def import_course(request, org, course, name):
|
||||
filename = request.FILES['course-data'].name
|
||||
if not filename.endswith('.tar.gz'):
|
||||
return JsonResponse(
|
||||
{'ErrMsg': 'We only support uploading a .tar.gz file.'},
|
||||
{
|
||||
'ErrMsg': _('We only support uploading a .tar.gz file.'),
|
||||
'Stage': 1
|
||||
},
|
||||
status=415
|
||||
)
|
||||
temp_filepath = course_dir / filename
|
||||
@@ -90,7 +81,7 @@ def import_course(request, org, course, name):
|
||||
try:
|
||||
matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"])
|
||||
content_range = matches.groupdict()
|
||||
except KeyError: # Single chunk
|
||||
except KeyError: # Single chunk
|
||||
# no Content-Range header, so make one that will work
|
||||
content_range = {'start': 0, 'stop': 1, 'end': 2}
|
||||
|
||||
@@ -110,7 +101,10 @@ def import_course(request, org, course, name):
|
||||
size
|
||||
)
|
||||
return JsonResponse(
|
||||
{'ErrMsg': 'File upload corrupted. Please try again'},
|
||||
{
|
||||
'ErrMsg': _('File upload corrupted. Please try again'),
|
||||
'Stage': 1
|
||||
},
|
||||
status=409
|
||||
)
|
||||
# The last request sometimes comes twice. This happens because
|
||||
@@ -143,25 +137,35 @@ def import_course(request, org, course, name):
|
||||
|
||||
else: # This was the last chunk.
|
||||
|
||||
# 'Lock' with status info.
|
||||
status_file = data_root / (course + filename + ".lock")
|
||||
# Use sessions to keep info about import progress
|
||||
session_status = request.session.setdefault("import_status", {})
|
||||
key = org + course + filename
|
||||
session_status[key] = 1
|
||||
request.session.modified = True
|
||||
|
||||
# Do everything from now on in a with-context, to be sure we've
|
||||
# properly cleaned up.
|
||||
with wfile(status_file, course_dir):
|
||||
|
||||
with open(status_file, 'w+') as sf:
|
||||
sf.write("Extracting")
|
||||
# Do everything from now on in a try-finally block to make sure
|
||||
# everything is properly cleaned up.
|
||||
try:
|
||||
|
||||
tar_file = tarfile.open(temp_filepath)
|
||||
tar_file.extractall(course_dir + '/')
|
||||
try:
|
||||
safetar_extractall(tar_file, (course_dir + '/').encode('utf-8'))
|
||||
except SuspiciousOperation as exc:
|
||||
return JsonResponse(
|
||||
{
|
||||
'ErrMsg': 'Unsafe tar file. Aborting import.',
|
||||
'SuspiciousFileOperationMsg': exc.args[0],
|
||||
'Stage': 1
|
||||
},
|
||||
status=400
|
||||
)
|
||||
finally:
|
||||
tar_file.close()
|
||||
|
||||
with open(status_file, 'w+') as sf:
|
||||
sf.write("Verifying")
|
||||
session_status[key] = 2
|
||||
request.session.modified = True
|
||||
|
||||
# find the 'course.xml' file
|
||||
dirpath = None
|
||||
|
||||
def get_all_files(directory):
|
||||
"""
|
||||
For each file in the directory, yield a 2-tuple of (file-name,
|
||||
@@ -188,7 +192,10 @@ def import_course(request, org, course, name):
|
||||
|
||||
if not dirpath:
|
||||
return JsonResponse(
|
||||
{'ErrMsg': 'Could not find the course.xml file in the package.'},
|
||||
{
|
||||
'ErrMsg': _('Could not find the course.xml file in the package.'),
|
||||
'Stage': 2
|
||||
},
|
||||
status=415
|
||||
)
|
||||
|
||||
@@ -210,12 +217,25 @@ def import_course(request, org, course, name):
|
||||
|
||||
logging.debug('new course at {0}'.format(course_items[0].location))
|
||||
|
||||
with open(status_file, 'w') as sf:
|
||||
sf.write("Updating course")
|
||||
session_status[key] = 3
|
||||
request.session.modified = True
|
||||
|
||||
create_all_course_groups(request.user, course_items[0].location)
|
||||
logging.debug('created all course groups at {0}'.format(course_items[0].location))
|
||||
|
||||
# Send errors to client with stage at which error occured.
|
||||
except Exception as exception: # pylint: disable=W0703
|
||||
return JsonResponse(
|
||||
{
|
||||
'ErrMsg': str(exception),
|
||||
'Stage': session_status[key]
|
||||
},
|
||||
status=400
|
||||
)
|
||||
|
||||
finally:
|
||||
shutil.rmtree(course_dir)
|
||||
|
||||
return JsonResponse({'Status': 'OK'})
|
||||
else:
|
||||
course_module = modulestore().get_item(location)
|
||||
@@ -230,6 +250,29 @@ def import_course(request, org, course, name):
|
||||
})
|
||||
|
||||
|
||||
@require_GET
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_status(request, org, course, name):
|
||||
"""
|
||||
Returns an integer corresponding to the status of a file import. These are:
|
||||
|
||||
0 : No status info found (import done or upload still in progress)
|
||||
1 : Extracting file
|
||||
2 : Validating.
|
||||
3 : Importing to mongo
|
||||
|
||||
"""
|
||||
|
||||
try:
|
||||
session_status = request.session["import_status"]
|
||||
status = session_status[org + course + name]
|
||||
except KeyError:
|
||||
status = 0
|
||||
|
||||
return JsonResponse({"ImportStatus": status})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseBadRequest
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -18,6 +20,7 @@ __all__ = ['save_item', 'create_item', 'delete_item']
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
@@ -32,7 +35,25 @@ def save_item(request):
|
||||
"""
|
||||
# The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a
|
||||
# little smarter and able to pass something more akin to {unset: [field, field]}
|
||||
item_location = request.POST['id']
|
||||
|
||||
try:
|
||||
item_location = request.POST['id']
|
||||
except KeyError:
|
||||
import inspect
|
||||
|
||||
log.exception(
|
||||
'''Request missing required attribute 'id'.
|
||||
Request info:
|
||||
%s
|
||||
Caller:
|
||||
Function %s in file %s
|
||||
''',
|
||||
request.META,
|
||||
inspect.currentframe().f_back.f_code.co_name,
|
||||
inspect.currentframe().f_back.f_code.co_filename
|
||||
)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
@@ -58,18 +79,21 @@ def save_item(request):
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
for metadata_key in request.POST.get('nullout', []):
|
||||
_get_xblock_field(existing_item, metadata_key).write_to(existing_item, None)
|
||||
setattr(existing_item, metadata_key, None)
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
|
||||
# the intent is to make it None, use the nullout field
|
||||
for metadata_key, value in request.POST.get('metadata', {}).items():
|
||||
field = _get_xblock_field(existing_item, metadata_key)
|
||||
field = existing_item.fields[metadata_key]
|
||||
|
||||
if value is None:
|
||||
field.delete_from(existing_item)
|
||||
else:
|
||||
value = field.from_json(value)
|
||||
try:
|
||||
value = field.from_json(value)
|
||||
except ValueError:
|
||||
return JsonResponse({"error": "Invalid data"}, 400)
|
||||
field.write_to(existing_item, value)
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
@@ -80,16 +104,6 @@ def save_item(request):
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
def _get_xblock_field(xblock, field_name):
|
||||
"""
|
||||
A temporary function to get the xblock field either from the xblock or one of its namespaces by name.
|
||||
:param xblock:
|
||||
:param field_name:
|
||||
"""
|
||||
for field in xblock.iterfields():
|
||||
if field.name == field_name:
|
||||
return field
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_item(request):
|
||||
|
||||
@@ -2,21 +2,22 @@ import logging
|
||||
import sys
|
||||
from functools import partial
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule, save_module # pylint: disable=F0401
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.mongo import MongoUsage
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xblock.runtime import DbModel
|
||||
|
||||
from lms.xblock.field_data import lms_field_data
|
||||
|
||||
from util.sandboxing import can_execute_unsafe_code
|
||||
|
||||
import static_replace
|
||||
@@ -74,12 +75,9 @@ def preview_component(request, location):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
component = modulestore().get_item(location)
|
||||
|
||||
component.get_html = wrap_xmodule(
|
||||
component.get_html,
|
||||
component,
|
||||
'xmodule_edit.html'
|
||||
)
|
||||
# Wrap the generated fragment in the xmodule_editor div so that the javascript
|
||||
# can bind to it correctly
|
||||
component.runtime.wrappers.append(partial(wrap_xmodule, 'xmodule_edit.html'))
|
||||
|
||||
return render_to_response('component.html', {
|
||||
'preview': get_preview_html(request, component, 0),
|
||||
@@ -97,29 +95,48 @@ def preview_module_system(request, preview_id, descriptor):
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
|
||||
def preview_model_data(descriptor):
|
||||
def preview_field_data(descriptor):
|
||||
"Helper method to create a DbModel from a descriptor"
|
||||
return DbModel(
|
||||
SessionKeyValueStore(request, descriptor._model_data),
|
||||
descriptor.module_class,
|
||||
preview_id,
|
||||
MongoUsage(preview_id, descriptor.location.url()),
|
||||
)
|
||||
student_data = DbModel(SessionKeyValueStore(request))
|
||||
return lms_field_data(descriptor._field_data, student_data)
|
||||
|
||||
course_id = get_course_for_item(descriptor.location).location.course_id
|
||||
|
||||
if descriptor.location.category == 'static_tab':
|
||||
wrapper_template = 'xmodule_tab_display.html'
|
||||
else:
|
||||
wrapper_template = 'xmodule_display.html'
|
||||
|
||||
return ModuleSystem(
|
||||
static_url=settings.STATIC_URL,
|
||||
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
track_function=lambda event_type, event: None,
|
||||
filestore=descriptor.system.resources_fs,
|
||||
filestore=descriptor.runtime.resources_fs,
|
||||
get_module=partial(load_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
|
||||
user=request.user,
|
||||
xblock_model_data=preview_model_data,
|
||||
xmodule_field_data=preview_field_data,
|
||||
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
|
||||
mixins=settings.XBLOCK_MIXINS,
|
||||
course_id=course_id,
|
||||
anonymous_student_id='student',
|
||||
|
||||
# Set up functions to modify the fragment produced by student_view
|
||||
wrappers=(
|
||||
# This wrapper wraps the module in the template specified above
|
||||
partial(wrap_xmodule, wrapper_template),
|
||||
|
||||
# This wrapper replaces urls in the output that start with /static
|
||||
# with the correct course-specific url for the static content
|
||||
partial(
|
||||
replace_static_urls,
|
||||
getattr(descriptor, 'data_dir', descriptor.location.course),
|
||||
course_id=descriptor.location.org + '/' + descriptor.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE',
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -141,33 +158,6 @@ def load_preview_module(request, preview_id, descriptor):
|
||||
error_msg=exc_info_to_str(sys.exc_info())
|
||||
).xmodule(system)
|
||||
|
||||
# cdodge: Special case
|
||||
if module.location.category == 'static_tab':
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_tab_display.html",
|
||||
)
|
||||
else:
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_display.html",
|
||||
)
|
||||
|
||||
# we pass a partially bogus course_id as we don't have the RUN information passed yet
|
||||
# through the CMS. Also the contentstore is also not RUN-aware at this point in time.
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
getattr(module, 'data_dir', module.location.course),
|
||||
course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
|
||||
)
|
||||
|
||||
module.get_html = save_module(
|
||||
module.get_html,
|
||||
module
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
from xblock.runtime import KeyValueStore, InvalidScopeError
|
||||
"""
|
||||
An :class:`~xblock.runtime.KeyValueStore` that stores data in the django session
|
||||
"""
|
||||
|
||||
from xblock.runtime import KeyValueStore
|
||||
|
||||
class SessionKeyValueStore(KeyValueStore):
|
||||
def __init__(self, request, descriptor_model_data):
|
||||
self._descriptor_model_data = descriptor_model_data
|
||||
def __init__(self, request):
|
||||
self._session = request.session
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
return self._descriptor_model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
return self._session[tuple(key)]
|
||||
return self._session[tuple(key)]
|
||||
|
||||
def set(self, key, value):
|
||||
try:
|
||||
self._descriptor_model_data[key.field_name] = value
|
||||
except (KeyError, InvalidScopeError):
|
||||
self._session[tuple(key)] = value
|
||||
self._session[tuple(key)] = value
|
||||
|
||||
def delete(self, key):
|
||||
try:
|
||||
del self._descriptor_model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
del self._session[tuple(key)]
|
||||
del self._session[tuple(key)]
|
||||
|
||||
def has(self, key):
|
||||
return key.field_name in self._descriptor_model_data or tuple(key) in self._session
|
||||
return tuple(key) in self._session
|
||||
|
||||
@@ -9,13 +9,14 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from ..utils import get_course_for_item, get_modulestore
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages']
|
||||
|
||||
|
||||
@@ -84,6 +85,7 @@ def reorder_static_tabs(request):
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
# TODO: above two lines are used for the primitive-save case. Maybe factor them out?
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@@ -136,3 +138,43 @@ def static_pages(request, org, course, coursename):
|
||||
return render_to_response('static-pages.html', {
|
||||
'context_course': course,
|
||||
})
|
||||
|
||||
|
||||
# "primitive" tab edit functions driven by the command line.
|
||||
# These should be replaced/deleted by a more capable GUI someday.
|
||||
# Note that the command line UI identifies the tabs with 1-based
|
||||
# indexing, but this implementation code is standard 0-based.
|
||||
|
||||
def validate_args(num, tab_type):
|
||||
"Throws for the disallowed cases."
|
||||
if num <= 1:
|
||||
raise ValueError('Tabs 1 and 2 cannot be edited')
|
||||
if tab_type == 'static_tab':
|
||||
raise ValueError('Tabs of type static_tab cannot be edited here (use Studio)')
|
||||
|
||||
|
||||
def primitive_delete(course, num):
|
||||
"Deletes the given tab number (0 based)."
|
||||
tabs = course.tabs
|
||||
validate_args(num, tabs[num].get('type', ''))
|
||||
del tabs[num]
|
||||
# Note for future implementations: if you delete a static_tab, then Chris Dodge
|
||||
# points out that there's other stuff to delete beyond this element.
|
||||
# This code happens to not delete static_tab so it doesn't come up.
|
||||
primitive_save(course)
|
||||
|
||||
|
||||
def primitive_insert(course, num, tab_type, name):
|
||||
"Inserts a new tab at the given number (0 based)."
|
||||
validate_args(num, tab_type)
|
||||
new_tab = {u'type': unicode(tab_type), u'name': unicode(name)}
|
||||
tabs = course.tabs
|
||||
tabs.insert(num, new_tab)
|
||||
primitive_save(course)
|
||||
|
||||
|
||||
def primitive_save(course):
|
||||
"Saves the course back to modulestore."
|
||||
# This code copied from reorder_static_tabs above
|
||||
course.save()
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
|
||||
@@ -125,7 +125,7 @@ class CourseGradingModel(object):
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
|
||||
|
||||
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
|
||||
|
||||
@@ -144,7 +144,7 @@ class CourseGradingModel(object):
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
|
||||
|
||||
return cutoffs
|
||||
|
||||
@@ -168,12 +168,12 @@ class CourseGradingModel(object):
|
||||
grace_timedelta = timedelta(**graceperiodjson)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.lms.graceperiod = grace_timedelta
|
||||
descriptor.graceperiod = grace_timedelta
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
def delete_grader(course_location, index):
|
||||
@@ -193,7 +193,7 @@ class CourseGradingModel(object):
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
|
||||
|
||||
@staticmethod
|
||||
def delete_grace_period(course_location):
|
||||
@@ -204,12 +204,12 @@ class CourseGradingModel(object):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
del descriptor.lms.graceperiod
|
||||
del descriptor.graceperiod
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
def get_section_grader_type(location):
|
||||
@@ -217,7 +217,7 @@ class CourseGradingModel(object):
|
||||
location = Location(location)
|
||||
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
|
||||
return {"graderType": descriptor.format if descriptor.format is not None else 'Not Graded',
|
||||
"location": location,
|
||||
"id": 99 # just an arbitrary value to
|
||||
}
|
||||
@@ -229,21 +229,21 @@ class CourseGradingModel(object):
|
||||
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
|
||||
descriptor.lms.format = jsondict.get('graderType')
|
||||
descriptor.lms.graded = True
|
||||
descriptor.format = jsondict.get('graderType')
|
||||
descriptor.graded = True
|
||||
else:
|
||||
del descriptor.lms.format
|
||||
del descriptor.lms.graded
|
||||
del descriptor.format
|
||||
del descriptor.graded
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
|
||||
get_modulestore(location).update_metadata(location, descriptor._field_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
def convert_set_grace_period(descriptor):
|
||||
# 5 hours 59 minutes 59 seconds => converted to iso format
|
||||
rawgrace = descriptor.lms.graceperiod
|
||||
rawgrace = descriptor.graceperiod
|
||||
if rawgrace:
|
||||
hours_from_days = rawgrace.days * 24
|
||||
seconds = rawgrace.seconds
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.core import Scope
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
import copy
|
||||
from xblock.fields import Scope
|
||||
from cms.xmodule_namespace import CmsBlockMixin
|
||||
|
||||
|
||||
class CourseMetadata(object):
|
||||
@@ -20,7 +19,9 @@ class CourseMetadata(object):
|
||||
'enrollment_end',
|
||||
'tabs',
|
||||
'graceperiod',
|
||||
'checklists']
|
||||
'checklists',
|
||||
'show_timezone'
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
@@ -35,12 +36,17 @@ class CourseMetadata(object):
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
for field in descriptor.fields + descriptor.lms.fields:
|
||||
for field in descriptor.fields.values():
|
||||
if field.name in CmsBlockMixin.fields:
|
||||
continue
|
||||
|
||||
if field.scope != Scope.settings:
|
||||
continue
|
||||
|
||||
if field.name not in cls.FILTERED_LIST:
|
||||
course[field.name] = field.read_json(descriptor)
|
||||
if field.name in cls.FILTERED_LIST:
|
||||
continue
|
||||
|
||||
course[field.name] = field.read_json(descriptor)
|
||||
|
||||
return course
|
||||
|
||||
@@ -55,9 +61,9 @@ class CourseMetadata(object):
|
||||
|
||||
dirty = False
|
||||
|
||||
#Copy the filtered list to avoid permanently changing the class attribute
|
||||
filtered_list = copy.copy(cls.FILTERED_LIST)
|
||||
#Don't filter on the tab attribute if filter_tabs is False
|
||||
# Copy the filtered list to avoid permanently changing the class attribute.
|
||||
filtered_list = list(cls.FILTERED_LIST)
|
||||
# Don't filter on the tab attribute if filter_tabs is False.
|
||||
if not filter_tabs:
|
||||
filtered_list.remove("tabs")
|
||||
|
||||
@@ -68,12 +74,8 @@ class CourseMetadata(object):
|
||||
|
||||
if hasattr(descriptor, key) and getattr(descriptor, key) != val:
|
||||
dirty = True
|
||||
value = getattr(CourseDescriptor, key).from_json(val)
|
||||
value = descriptor.fields[key].from_json(val)
|
||||
setattr(descriptor, key, value)
|
||||
elif hasattr(descriptor.lms, key) and getattr(descriptor.lms, key) != key:
|
||||
dirty = True
|
||||
value = getattr(CourseDescriptor.lms, key).from_json(val)
|
||||
setattr(descriptor.lms, key, value)
|
||||
|
||||
if dirty:
|
||||
# Save the data that we've just changed to the underlying
|
||||
@@ -97,8 +99,6 @@ class CourseMetadata(object):
|
||||
for key in payload['deleteKeys']:
|
||||
if hasattr(descriptor, key):
|
||||
delattr(descriptor, key)
|
||||
elif hasattr(descriptor.lms, key):
|
||||
delattr(descriptor.lms, key)
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
|
||||
@@ -24,14 +24,17 @@ from random import choice, randint
|
||||
def seed():
|
||||
return os.getppid()
|
||||
|
||||
MODULESTORE_OPTIONS = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
DOC_STORE_CONFIG = {
|
||||
'host': 'localhost',
|
||||
'db': 'acceptance_xmodule',
|
||||
'collection': 'acceptance_modulestore_%s' % seed(),
|
||||
}
|
||||
|
||||
MODULESTORE_OPTIONS = dict({
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
}, **DOC_STORE_CONFIG)
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
@@ -68,8 +71,8 @@ CONTENTSTORE = {
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': TEST_ROOT / "db" / "test_mitx_%s.db" % seed(),
|
||||
'TEST_NAME': TEST_ROOT / "db" / "test_mitx_%s.db" % seed(),
|
||||
'NAME': TEST_ROOT / "db" / "test_edx.db",
|
||||
'TEST_NAME': TEST_ROOT / "db" / "test_edx.db"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,5 +87,26 @@ USE_I18N = True
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
INSTALLED_APPS += ('lettuce.django',)
|
||||
LETTUCE_APPS = ('contentstore',)
|
||||
LETTUCE_SERVER_PORT = choice(PORTS) if SAUCE.get('SAUCE_ENABLED') else randint(1024, 65535)
|
||||
LETTUCE_BROWSER = os.environ.get('LETTUCE_BROWSER', 'chrome')
|
||||
|
||||
# Where to run: local, saucelabs, or grid
|
||||
LETTUCE_SELENIUM_CLIENT = os.environ.get('LETTUCE_SELENIUM_CLIENT', 'local')
|
||||
|
||||
SELENIUM_GRID = {
|
||||
'URL': 'http://127.0.0.1:4444/wd/hub',
|
||||
'BROWSER': LETTUCE_BROWSER,
|
||||
}
|
||||
|
||||
#####################################################################
|
||||
# Lastly, see if the developer has any local overrides.
|
||||
try:
|
||||
from .private import * # pylint: disable=F0401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Because an override for where to run will affect which ports to use,
|
||||
# set this up after the local overrides.
|
||||
if LETTUCE_SELENIUM_CLIENT == 'saucelabs':
|
||||
LETTUCE_SERVER_PORT = choice(PORTS)
|
||||
else:
|
||||
LETTUCE_SERVER_PORT = randint(1024, 65535)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
This config file extends the test environment configuration
|
||||
so that we can run the lettuce acceptance tests.
|
||||
This is used in the django-admin call as acceptance.py
|
||||
contains random seeding, causing django-admin to create a random collection
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
from .test import *
|
||||
|
||||
# You need to start the server in debug mode,
|
||||
# otherwise the browser will not render the pages correctly
|
||||
DEBUG = True
|
||||
|
||||
# Disable warnings for acceptance tests, to make the logs readable
|
||||
import logging
|
||||
logging.disable(logging.ERROR)
|
||||
import os
|
||||
import random
|
||||
|
||||
MODULESTORE_OPTIONS = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'acceptance_xmodule',
|
||||
'collection': 'acceptance_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'direct': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'draft': {
|
||||
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
}
|
||||
}
|
||||
|
||||
CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'OPTIONS': {
|
||||
'host': 'localhost',
|
||||
'db': 'acceptance_xcontent',
|
||||
},
|
||||
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
|
||||
'ADDITIONAL_OPTIONS': {
|
||||
'trashcan': {
|
||||
'bucket': 'trash_fs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Set this up so that rake lms[acceptance] and running the
|
||||
# harvest command both use the same (test) database
|
||||
# which they can flush without messing up your dev db
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': TEST_ROOT / "db" / "test_mitx.db",
|
||||
'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db",
|
||||
}
|
||||
}
|
||||
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
INSTALLED_APPS += ('lettuce.django',)
|
||||
LETTUCE_APPS = ('contentstore',)
|
||||
LETTUCE_SERVER_PORT = random.randint(1024, 65535)
|
||||
LETTUCE_BROWSER = 'chrome'
|
||||
@@ -127,6 +127,10 @@ LOGGING = get_logger_config(LOG_DIR,
|
||||
#theming start:
|
||||
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX')
|
||||
|
||||
# Event Tracking
|
||||
if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS:
|
||||
TRACKING_IGNORE_URL_PATTERNS = ENV_TOKENS.get("TRACKING_IGNORE_URL_PATTERNS")
|
||||
|
||||
|
||||
################ SECURE AUTH ITEMS ###############################
|
||||
# Secret things: passwords, access keys, etc.
|
||||
@@ -147,7 +151,12 @@ MODULESTORE = AUTH_TOKENS['MODULESTORE']
|
||||
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
|
||||
|
||||
# Datadog for events!
|
||||
DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
|
||||
DATADOG = AUTH_TOKENS.get("DATADOG", {})
|
||||
DATADOG.update(ENV_TOKENS.get("DATADOG", {}))
|
||||
|
||||
# TODO: deprecated (compatibility with previous settings)
|
||||
if 'DATADOG_API' in AUTH_TOKENS:
|
||||
DATADOG['api_key'] = AUTH_TOKENS['DATADOG_API']
|
||||
|
||||
# Celery Broker
|
||||
CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "")
|
||||
@@ -161,3 +170,6 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
|
||||
CELERY_BROKER_PASSWORD,
|
||||
CELERY_BROKER_HOSTNAME,
|
||||
CELERY_BROKER_VHOST)
|
||||
|
||||
# Event tracking
|
||||
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
|
||||
|
||||
@@ -28,6 +28,12 @@ import lms.envs.common
|
||||
from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL
|
||||
from path import path
|
||||
|
||||
from lms.xblock.mixin import LmsBlockMixin
|
||||
from cms.xmodule_namespace import CmsBlockMixin
|
||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
from xmodule.x_module import XModuleMixin
|
||||
from dealer.git import git
|
||||
|
||||
############################ FEATURE CONFIGURATION #############################
|
||||
|
||||
MITX_FEATURES = {
|
||||
@@ -55,7 +61,7 @@ MITX_FEATURES = {
|
||||
|
||||
# If set to True, new Studio users won't be able to author courses unless
|
||||
# edX has explicitly added them to the course creator group.
|
||||
'ENABLE_CREATOR_GROUP': False
|
||||
'ENABLE_CREATOR_GROUP': False,
|
||||
}
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
@@ -64,6 +70,7 @@ ENABLE_JASMINE = False
|
||||
PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/cms
|
||||
REPO_ROOT = PROJECT_ROOT.dirname()
|
||||
COMMON_ROOT = REPO_ROOT / "common"
|
||||
LMS_ROOT = REPO_ROOT / "lms"
|
||||
ENV_ROOT = REPO_ROOT.dirname() # virtualenv dir /mitx is in
|
||||
|
||||
GITHUB_REPO_ROOT = ENV_ROOT / "data"
|
||||
@@ -83,7 +90,8 @@ MAKO_TEMPLATES = {}
|
||||
MAKO_TEMPLATES['main'] = [
|
||||
PROJECT_ROOT / 'templates',
|
||||
COMMON_ROOT / 'templates',
|
||||
COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates'
|
||||
COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates',
|
||||
COMMON_ROOT / 'djangoapps' / 'pipeline_js' / 'templates',
|
||||
]
|
||||
|
||||
for namespace, template_dirs in lms.envs.common.MAKO_TEMPLATES.iteritems():
|
||||
@@ -102,7 +110,8 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
'django.core.context_processors.static',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.contrib.auth.context_processors.auth', # this is required for admin
|
||||
'django.core.context_processors.csrf'
|
||||
'django.core.context_processors.csrf',
|
||||
'dealer.contrib.django.staff.context_processor', # access git revision
|
||||
)
|
||||
|
||||
# use the ratelimit backend to prevent brute force attacks
|
||||
@@ -136,7 +145,6 @@ TEMPLATE_LOADERS = (
|
||||
)
|
||||
|
||||
MIDDLEWARE_CLASSES = (
|
||||
'contentserver.middleware.StaticContentServer',
|
||||
'request_cache.middleware.RequestCache',
|
||||
'django.middleware.cache.UpdateCacheMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
@@ -146,6 +154,7 @@ MIDDLEWARE_CLASSES = (
|
||||
|
||||
# Instead of AuthenticationMiddleware, we use a cache-backed version
|
||||
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
|
||||
'contentserver.middleware.StaticContentServer',
|
||||
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'track.middleware.TrackMiddleware',
|
||||
@@ -160,6 +169,13 @@ MIDDLEWARE_CLASSES = (
|
||||
'ratelimitbackend.middleware.RateLimitMiddleware',
|
||||
)
|
||||
|
||||
############# XBlock Configuration ##########
|
||||
|
||||
# This should be moved into an XBlock Runtime/Application object
|
||||
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
|
||||
XBLOCK_MIXINS = (LmsBlockMixin, CmsBlockMixin, InheritanceMixin, XModuleMixin)
|
||||
|
||||
|
||||
############################ SIGNAL HANDLERS ################################
|
||||
# This is imported to register the exception signal handling that logs exceptions
|
||||
import monitoring.exceptions # noqa
|
||||
@@ -185,13 +201,14 @@ ADMINS = ()
|
||||
MANAGERS = ADMINS
|
||||
|
||||
# Static content
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_URL = '/static/' + git.revision + "/"
|
||||
ADMIN_MEDIA_PREFIX = '/static/admin/'
|
||||
STATIC_ROOT = ENV_ROOT / "staticfiles"
|
||||
STATIC_ROOT = ENV_ROOT / "staticfiles" / git.revision
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
COMMON_ROOT / "static",
|
||||
PROJECT_ROOT / "static",
|
||||
LMS_ROOT / "static",
|
||||
|
||||
# This is how you would use the textbook images locally
|
||||
# ("book", ENV_ROOT / "book_images")
|
||||
@@ -207,9 +224,6 @@ USE_L10N = True
|
||||
# Localization strings (e.g. django.po) are under this directory
|
||||
LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # mitx/conf/locale/
|
||||
|
||||
# Tracking
|
||||
TRACK_MAX_EVENT = 10000
|
||||
|
||||
# Messages
|
||||
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
|
||||
|
||||
@@ -246,29 +260,48 @@ PIPELINE_JS = {
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
|
||||
'js/models/uploads.js', 'js/views/uploads.js',
|
||||
'js/models/textbook.js', 'js/views/textbook.js',
|
||||
'js/views/assets.js', 'js/utility.js',
|
||||
'js/models/settings/course_grading_policy.js'],
|
||||
'js/src/utility.js',
|
||||
'js/models/settings/course_grading_policy.js',
|
||||
'js/models/asset.js', 'js/models/assets.js',
|
||||
'js/views/assets.js',
|
||||
'js/views/import.js',
|
||||
'js/views/assets_view.js', 'js/views/asset_view.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
},
|
||||
'module-js': {
|
||||
'source_filenames': (
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js') +
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/modules/js/*.js')
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/modules/js/*.js') +
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/discussion/*.js')
|
||||
),
|
||||
'output_filename': 'js/cms-modules.js',
|
||||
'test_order': 1
|
||||
},
|
||||
}
|
||||
|
||||
PIPELINE_COMPILERS = (
|
||||
'pipeline.compilers.coffee.CoffeeScriptCompiler',
|
||||
)
|
||||
|
||||
PIPELINE_CSS_COMPRESSOR = None
|
||||
PIPELINE_JS_COMPRESSOR = None
|
||||
|
||||
STATICFILES_IGNORE_PATTERNS = (
|
||||
"sass/*",
|
||||
"coffee/*",
|
||||
"*.py",
|
||||
"*.pyc"
|
||||
# it would be nice if we could do, for example, "**/*.scss",
|
||||
# but these strings get passed down to the `fnmatch` module,
|
||||
# which doesn't support that. :(
|
||||
# http://docs.python.org/2/library/fnmatch.html
|
||||
"sass/*.scss",
|
||||
"sass/*/*.scss",
|
||||
"sass/*/*/*.scss",
|
||||
"sass/*/*/*/*.scss",
|
||||
"coffee/*.coffee",
|
||||
"coffee/*/*.coffee",
|
||||
"coffee/*/*/*.coffee",
|
||||
"coffee/*/*/*/*.coffee",
|
||||
)
|
||||
|
||||
PIPELINE_YUI_BINARY = 'yui-compressor'
|
||||
@@ -347,6 +380,9 @@ INSTALLED_APPS = (
|
||||
# Tracking
|
||||
'track',
|
||||
|
||||
# Monitoring
|
||||
'datadog',
|
||||
|
||||
# For asset pipelining
|
||||
'mitxmako',
|
||||
'pipeline',
|
||||
@@ -363,6 +399,7 @@ INSTALLED_APPS = (
|
||||
'course_modes'
|
||||
)
|
||||
|
||||
|
||||
################# EDX MARKETING SITE ##################################
|
||||
|
||||
EDXMKTG_COOKIE_NAME = 'edxloggedin'
|
||||
@@ -379,3 +416,20 @@ MKTG_URL_LINK_MAP = {
|
||||
}
|
||||
|
||||
COURSES_WITH_UNSAFE_CODE = []
|
||||
|
||||
############################## EVENT TRACKING #################################
|
||||
|
||||
TRACK_MAX_EVENT = 10000
|
||||
|
||||
TRACKING_BACKENDS = {
|
||||
'logger': {
|
||||
'ENGINE': 'track.backends.logger.LoggerBackend',
|
||||
'OPTIONS': {
|
||||
'name': 'tracking'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# We're already logging events, and we don't want to capture user
|
||||
# names/passwords. Heartbeat events are likely not interesting.
|
||||
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
|
||||
|
||||
@@ -16,14 +16,17 @@ LOGGING = get_logger_config(ENV_ROOT / "log",
|
||||
dev_env=True,
|
||||
debug=True)
|
||||
|
||||
modulestore_options = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
DOC_STORE_CONFIG = {
|
||||
'host': 'localhost',
|
||||
'db': 'xmodule',
|
||||
'collection': 'modulestore',
|
||||
}
|
||||
|
||||
modulestore_options = dict({
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'fs_root': GITHUB_REPO_ROOT,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
}, **DOC_STORE_CONFIG)
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
@@ -185,6 +188,6 @@ if SEGMENT_IO_KEY:
|
||||
#####################################################################
|
||||
# Lastly, see if the developer has any local overrides.
|
||||
try:
|
||||
from .private import * # pylint: disable=F0401
|
||||
from .private import * # pylint: disable=F0401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -20,6 +20,15 @@ from warnings import filterwarnings
|
||||
# Nose Test Runner
|
||||
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
|
||||
|
||||
_system = 'cms'
|
||||
_report_dir = REPO_ROOT / 'reports' / _system
|
||||
_report_dir.makedirs_p()
|
||||
|
||||
NOSE_ARGS = [
|
||||
'--id-file', REPO_ROOT / '.testids' / _system / 'noseids',
|
||||
'--xunit-file', _report_dir / 'nosetests.xml',
|
||||
]
|
||||
|
||||
TEST_ROOT = path('test_root')
|
||||
|
||||
# Want static files in the same dir for running on jenkins.
|
||||
@@ -42,14 +51,17 @@ STATICFILES_DIRS += [
|
||||
if os.path.isdir(COMMON_TEST_DATA_ROOT / course_dir)
|
||||
]
|
||||
|
||||
MODULESTORE_OPTIONS = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
DOC_STORE_CONFIG = {
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'test_modulestore',
|
||||
}
|
||||
|
||||
MODULESTORE_OPTIONS = dict({
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
}, **DOC_STORE_CONFIG)
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Module with code executed during Studio startup
|
||||
"""
|
||||
import logging
|
||||
from django.conf import settings
|
||||
|
||||
# Force settings to run so that the python path is modified
|
||||
@@ -8,6 +9,8 @@ settings.INSTALLED_APPS # pylint: disable=W0104
|
||||
|
||||
from django_startup import autostartup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# TODO: Remove this code once Studio/CMS runs via wsgi in all environments
|
||||
INITIALIZED = False
|
||||
|
||||
@@ -22,4 +25,3 @@ def run():
|
||||
|
||||
INITIALIZED = True
|
||||
autostartup()
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<li class="field-group course-advanced-policy-list-item">
|
||||
<div class="field is-not-editable text key" id="<%= key %>">
|
||||
<label for="<%= keyUniqueId %>">Policy Key:</label>
|
||||
<input readonly title="This field is disabled: policy keys cannot be edited." type="text" class="short policy-key" id="<%= keyUniqueId %>" value="<%= key %>" />
|
||||
</div>
|
||||
|
||||
<div class="field text value">
|
||||
<label for="<%= valueUniqueId %>">Policy Value:</label>
|
||||
<textarea class="json text" id="<%= valueUniqueId %>"><%= value %></textarea>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1,14 +0,0 @@
|
||||
<!-- In order to enable better debugging of templates, put them in
|
||||
the script tag section.
|
||||
TODO add lazy load fn to load templates as needed (called
|
||||
from backbone view initialize to set this.template of the view)
|
||||
-->
|
||||
|
||||
<%block name="jsextra">
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
// How do I load an html file server side so I can
|
||||
// Precompiling your templates can be a big help when debugging errors you can't reproduce. This is because precompiled templates can provide line numbers and a stack trace, something that is not possible when compiling templates on the client. The source property is available on the compiled template function for easy precompilation.
|
||||
// <script>CMS.course_info_update = <%= _.template(jstText).source %>;</script>
|
||||
|
||||
</script>
|
||||
</%block>
|
||||
1
cms/static/coffee/fixtures
Symbolic link
1
cms/static/coffee/fixtures
Symbolic link
@@ -0,0 +1 @@
|
||||
../../templates/js/
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/edit-chapter.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/edit-textbook.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/metadata-editor.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/metadata-list-entry.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/metadata-number-entry.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/metadata-option-entry.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/metadata-string-entry.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/no-textbooks.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/section-name-edit.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/show-textbook.underscore
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/system-feedback.underscore
|
||||
@@ -1,33 +0,0 @@
|
||||
<div class="base_wrapper">
|
||||
<section class="editor-with-tabs">
|
||||
<div class="wrapper-comp-editor" id="editor-tab-id" data-html_id='test_id'>
|
||||
<div class="edit-header">
|
||||
<ul class="editor-tabs">
|
||||
<li class="inner_tab_wrap"><a href="#tab-0" class="tab">Tab 0 Editor</a></li>
|
||||
<li class="inner_tab_wrap"><a href="#tab-1" class="tab">Tab 1 Transcripts</a></li>
|
||||
<li class="inner_tab_wrap" id="settings"><a href="#tab-2" class="tab">Tab 2 Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tabs-wrapper">
|
||||
<div class="component-tab" id="tab-0">
|
||||
<textarea name="" class="edit-box">XML Editor Text</textarea>
|
||||
</div>
|
||||
<div class="component-tab" id="tab-1">
|
||||
Transcripts
|
||||
</div>
|
||||
<div class="component-tab" id="tab-2">
|
||||
Subtitles
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-comp-settings">
|
||||
<ul>
|
||||
<li id="editor-mode"><a>Editor</a></li>
|
||||
<li id="settings-mode"><a>Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="component-edit-header" style="display: block"/>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../../templates/js/upload-dialog.underscore
|
||||
@@ -1,20 +0,0 @@
|
||||
jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
|
||||
|
||||
# Stub jQuery.cookie
|
||||
@stubCookies =
|
||||
csrftoken: "stubCSRFToken"
|
||||
|
||||
jQuery.cookie = (key, value) =>
|
||||
if value?
|
||||
@stubCookies[key] = value
|
||||
else
|
||||
@stubCookies[key]
|
||||
|
||||
# Path Jasmine's `it` method to raise an error when the test is not defined.
|
||||
# This is helpful when writing the specs first before writing the test.
|
||||
@it = (desc, func) ->
|
||||
if func?
|
||||
jasmine.getEnv().it(desc, func)
|
||||
else
|
||||
jasmine.getEnv().it desc, ->
|
||||
throw "test is undefined"
|
||||
154
cms/static/coffee/spec/main.coffee
Normal file
154
cms/static/coffee/spec/main.coffee
Normal file
@@ -0,0 +1,154 @@
|
||||
requirejs.config({
|
||||
paths: {
|
||||
"gettext": "xmodule_js/common_static/js/test/i18n",
|
||||
"mustache": "xmodule_js/common_static/js/vendor/mustache",
|
||||
"codemirror": "xmodule_js/common_static/js/vendor/CodeMirror/codemirror",
|
||||
"jquery": "xmodule_js/common_static/js/vendor/jquery.min",
|
||||
"jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min",
|
||||
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
|
||||
"jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
|
||||
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
|
||||
"jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
|
||||
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
|
||||
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
|
||||
"jquery.cookie": "xmodule_js/common_static/js/vendor/jquery.cookie",
|
||||
"jquery.qtip": "xmodule_js/common_static/js/vendor/jquery.qtip.min",
|
||||
"jquery.fileupload": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload",
|
||||
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
|
||||
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
|
||||
"datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
|
||||
"date": "xmodule_js/common_static/js/vendor/date",
|
||||
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
|
||||
"underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
|
||||
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
|
||||
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
|
||||
"youtube": "xmodule_js/common_static/js/load_youtube",
|
||||
"tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
|
||||
"jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
|
||||
"mathjax": "https://edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full",
|
||||
"xmodule": "xmodule_js/src/xmodule",
|
||||
"utility": "xmodule_js/common_static/js/src/utility",
|
||||
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
|
||||
"squire": "xmodule_js/common_static/js/vendor/Squire",
|
||||
"jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth",
|
||||
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
|
||||
|
||||
"coffee/src/ajax_prefix": "xmodule_js/common_static/coffee/src/ajax_prefix"
|
||||
},
|
||||
shim: {
|
||||
"gettext": {
|
||||
exports: "gettext"
|
||||
},
|
||||
"date": {
|
||||
exports: "Date"
|
||||
},
|
||||
"jquery.ui": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.ui"
|
||||
},
|
||||
"jquery.form": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.ajaxForm"
|
||||
},
|
||||
"jquery.markitup": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.markitup"
|
||||
},
|
||||
"jquery.leanModal": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.leanModal"
|
||||
},
|
||||
"jquery.smoothScroll": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.smoothScroll"
|
||||
},
|
||||
"jquery.scrollTo": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.scrollTo"
|
||||
},
|
||||
"jquery.cookie": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.cookie"
|
||||
},
|
||||
"jquery.qtip": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.qtip"
|
||||
},
|
||||
"jquery.fileupload": {
|
||||
deps: ["jquery.iframe-transport"],
|
||||
exports: "jQuery.fn.fileupload"
|
||||
},
|
||||
"jquery.inputnumber": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.inputNumber"
|
||||
},
|
||||
"jquery.tinymce": {
|
||||
deps: ["jquery", "tinymce"],
|
||||
exports: "jQuery.fn.tinymce"
|
||||
},
|
||||
"datepair": {
|
||||
deps: ["jquery.ui", "jquery.timepicker"]
|
||||
},
|
||||
"underscore": {
|
||||
exports: "_"
|
||||
},
|
||||
"backbone": {
|
||||
deps: ["underscore", "jquery"],
|
||||
exports: "Backbone"
|
||||
},
|
||||
"backbone.associations": {
|
||||
deps: ["backbone"],
|
||||
exports: "Backbone.Associations"
|
||||
},
|
||||
"codemirror": {
|
||||
exports: "CodeMirror"
|
||||
},
|
||||
"tinymce": {
|
||||
exports: "tinymce"
|
||||
},
|
||||
"mathjax": {
|
||||
exports: "MathJax"
|
||||
},
|
||||
"xmodule": {
|
||||
exports: "XModule"
|
||||
},
|
||||
"sinon": {
|
||||
exports: "sinon"
|
||||
},
|
||||
"jasmine-stealth": {
|
||||
deps: ["jasmine"]
|
||||
},
|
||||
"jasmine.async": {
|
||||
deps: ["jasmine"],
|
||||
exports: "AsyncSpec"
|
||||
},
|
||||
|
||||
"coffee/src/main": {
|
||||
deps: ["coffee/src/ajax_prefix"]
|
||||
},
|
||||
"coffee/src/ajax_prefix": {
|
||||
deps: ["jquery"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
|
||||
|
||||
define([
|
||||
"coffee/spec/main_spec",
|
||||
|
||||
"coffee/spec/models/course_spec", "coffee/spec/models/metadata_spec",
|
||||
"coffee/spec/models/module_spec", "coffee/spec/models/section_spec",
|
||||
"coffee/spec/models/settings_grading_spec", "coffee/spec/models/textbook_spec",
|
||||
"coffee/spec/models/upload_spec",
|
||||
|
||||
"coffee/spec/views/section_spec",
|
||||
"coffee/spec/views/course_info_spec", "coffee/spec/views/feedback_spec",
|
||||
"coffee/spec/views/metadata_edit_spec", "coffee/spec/views/module_edit_spec",
|
||||
"coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec",
|
||||
|
||||
# these tests are run separate in the cms-squire suite, due to process
|
||||
# isolation issues with Squire.js
|
||||
# "coffee/spec/views/assets_spec"
|
||||
])
|
||||
|
||||
@@ -1,58 +1,55 @@
|
||||
describe "CMS", ->
|
||||
beforeEach ->
|
||||
CMS.unbind()
|
||||
require ["jquery", "backbone", "coffee/src/main", "sinon", "jasmine-stealth"],
|
||||
($, Backbone, main, sinon) ->
|
||||
describe "CMS", ->
|
||||
it "should initialize URL", ->
|
||||
expect(window.CMS.URL).toBeDefined()
|
||||
|
||||
it "should initialize Models", ->
|
||||
expect(CMS.Models).toBeDefined()
|
||||
describe "main helper", ->
|
||||
beforeEach ->
|
||||
@previousAjaxSettings = $.extend(true, {}, $.ajaxSettings)
|
||||
spyOn($, "cookie")
|
||||
$.cookie.when("csrftoken").thenReturn("stubCSRFToken")
|
||||
main()
|
||||
|
||||
it "should initialize Views", ->
|
||||
expect(CMS.Views).toBeDefined()
|
||||
afterEach ->
|
||||
$.ajaxSettings = @previousAjaxSettings
|
||||
|
||||
describe "main helper", ->
|
||||
beforeEach ->
|
||||
@previousAjaxSettings = $.extend(true, {}, $.ajaxSettings)
|
||||
window.stubCookies["csrftoken"] = "stubCSRFToken"
|
||||
$(document).ready()
|
||||
it "turn on Backbone emulateHTTP", ->
|
||||
expect(Backbone.emulateHTTP).toBeTruthy()
|
||||
|
||||
afterEach ->
|
||||
$.ajaxSettings = @previousAjaxSettings
|
||||
it "setup AJAX CSRF token", ->
|
||||
expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken")
|
||||
|
||||
it "turn on Backbone emulateHTTP", ->
|
||||
expect(Backbone.emulateHTTP).toBeTruthy()
|
||||
describe "AJAX Errors", ->
|
||||
tpl = readFixtures('system-feedback.underscore')
|
||||
|
||||
it "setup AJAX CSRF token", ->
|
||||
expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken")
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures(sandbox({id: "page-notification"}))
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
|
||||
describe "AJAX Errors", ->
|
||||
tpl = readFixtures('system-feedback.underscore')
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures(sandbox({id: "page-notification"}))
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
it "successful AJAX request does not pop an error notification", ->
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
$.ajax("/test")
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
@requests[0].respond(200)
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
it "AJAX request with error should pop an error notification", ->
|
||||
$.ajax("/test")
|
||||
@requests[0].respond(500)
|
||||
expect($("#page-notification")).not.toBeEmpty()
|
||||
expect($("#page-notification")).toContain('div.wrapper-notification-error')
|
||||
|
||||
it "successful AJAX request does not pop an error notification", ->
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
$.ajax("/test")
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
@requests[0].respond(200)
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
|
||||
it "AJAX request with error should pop an error notification", ->
|
||||
$.ajax("/test")
|
||||
@requests[0].respond(500)
|
||||
expect($("#page-notification")).not.toBeEmpty()
|
||||
expect($("#page-notification")).toContain('div.wrapper-notification-error')
|
||||
|
||||
it "can override AJAX request with error so it does not pop an error notification", ->
|
||||
$.ajax
|
||||
url: "/test"
|
||||
notifyOnError: false
|
||||
@requests[0].respond(500)
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
it "can override AJAX request with error so it does not pop an error notification", ->
|
||||
$.ajax
|
||||
url: "/test"
|
||||
notifyOnError: false
|
||||
@requests[0].respond(500)
|
||||
expect($("#page-notification")).toBeEmpty()
|
||||
|
||||
|
||||
140
cms/static/coffee/spec/main_squire.coffee
Normal file
140
cms/static/coffee/spec/main_squire.coffee
Normal file
@@ -0,0 +1,140 @@
|
||||
requirejs.config({
|
||||
paths: {
|
||||
"gettext": "xmodule_js/common_static/js/test/i18n",
|
||||
"mustache": "xmodule_js/common_static/js/vendor/mustache",
|
||||
"codemirror": "xmodule_js/common_static/js/vendor/CodeMirror/codemirror",
|
||||
"jquery": "xmodule_js/common_static/js/vendor/jquery.min",
|
||||
"jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min",
|
||||
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
|
||||
"jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
|
||||
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
|
||||
"jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
|
||||
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
|
||||
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
|
||||
"jquery.cookie": "xmodule_js/common_static/js/vendor/jquery.cookie",
|
||||
"jquery.qtip": "xmodule_js/common_static/js/vendor/jquery.qtip.min",
|
||||
"jquery.fileupload": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload",
|
||||
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
|
||||
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
|
||||
"datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
|
||||
"date": "xmodule_js/common_static/js/vendor/date",
|
||||
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
|
||||
"underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
|
||||
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
|
||||
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
|
||||
"youtube": "xmodule_js/common_static/js/load_youtube",
|
||||
"tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
|
||||
"jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
|
||||
"mathjax": "https://edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full",
|
||||
"xmodule": "xmodule_js/src/xmodule",
|
||||
"utility": "xmodule_js/common_static/js/src/utility",
|
||||
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
|
||||
"squire": "xmodule_js/common_static/js/vendor/Squire",
|
||||
"jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth",
|
||||
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
|
||||
|
||||
"coffee/src/ajax_prefix": "xmodule_js/common_static/coffee/src/ajax_prefix"
|
||||
},
|
||||
shim: {
|
||||
"gettext": {
|
||||
exports: "gettext"
|
||||
},
|
||||
"date": {
|
||||
exports: "Date"
|
||||
},
|
||||
"jquery.ui": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.ui"
|
||||
},
|
||||
"jquery.form": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.ajaxForm"
|
||||
},
|
||||
"jquery.markitup": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.markitup"
|
||||
},
|
||||
"jquery.leanModal": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.leanModal"
|
||||
},
|
||||
"jquery.smoothScroll": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.smoothScroll"
|
||||
},
|
||||
"jquery.scrollTo": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.scrollTo"
|
||||
},
|
||||
"jquery.cookie": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.cookie"
|
||||
},
|
||||
"jquery.qtip": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.qtip"
|
||||
},
|
||||
"jquery.fileupload": {
|
||||
deps: ["jquery.iframe-transport"],
|
||||
exports: "jQuery.fn.fileupload"
|
||||
},
|
||||
"jquery.inputnumber": {
|
||||
deps: ["jquery"],
|
||||
exports: "jQuery.fn.inputNumber"
|
||||
},
|
||||
"jquery.tinymce": {
|
||||
deps: ["jquery", "tinymce"],
|
||||
exports: "jQuery.fn.tinymce"
|
||||
},
|
||||
"datepair": {
|
||||
deps: ["jquery.ui", "jquery.timepicker"]
|
||||
},
|
||||
"underscore": {
|
||||
exports: "_"
|
||||
},
|
||||
"backbone": {
|
||||
deps: ["underscore", "jquery"],
|
||||
exports: "Backbone"
|
||||
},
|
||||
"backbone.associations": {
|
||||
deps: ["backbone"],
|
||||
exports: "Backbone.Associations"
|
||||
},
|
||||
"codemirror": {
|
||||
exports: "CodeMirror"
|
||||
},
|
||||
"tinymce": {
|
||||
exports: "tinymce"
|
||||
},
|
||||
"mathjax": {
|
||||
exports: "MathJax"
|
||||
},
|
||||
"xmodule": {
|
||||
exports: "XModule"
|
||||
},
|
||||
"sinon": {
|
||||
exports: "sinon"
|
||||
},
|
||||
"jasmine-stealth": {
|
||||
deps: ["jasmine"]
|
||||
},
|
||||
"jasmine.async": {
|
||||
deps: ["jasmine"],
|
||||
exports: "AsyncSpec"
|
||||
},
|
||||
|
||||
"coffee/src/main": {
|
||||
deps: ["coffee/src/ajax_prefix"]
|
||||
},
|
||||
"coffee/src/ajax_prefix": {
|
||||
deps: ["jquery"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
|
||||
|
||||
define([
|
||||
"coffee/spec/views/assets_spec"
|
||||
])
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
describe "CMS.Models.Course", ->
|
||||
describe "basic", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.Course({
|
||||
define ["js/models/course"], (Course) ->
|
||||
describe "Course", ->
|
||||
describe "basic", ->
|
||||
beforeEach ->
|
||||
@model = new Course({
|
||||
name: "Greek Hero"
|
||||
})
|
||||
})
|
||||
|
||||
it "should take a name argument", ->
|
||||
expect(@model.get("name")).toEqual("Greek Hero")
|
||||
it "should take a name argument", ->
|
||||
expect(@model.get("name")).toEqual("Greek Hero")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user