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:
ichuang
2013-10-07 21:23:05 -04:00
870 changed files with 37614 additions and 13739 deletions

88
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
Feature: Textbooks
@shard_3
Feature: CMS.Textbooks
Scenario: No textbooks
Given I have opened a new course in Studio

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", {}))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1 @@
../../templates/js/

View File

@@ -1 +0,0 @@
../../../templates/js/edit-chapter.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/edit-textbook.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-editor.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-list-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-number-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-option-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/metadata-string-entry.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/no-textbooks.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/section-name-edit.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/show-textbook.underscore

View File

@@ -1 +0,0 @@
../../../templates/js/system-feedback.underscore

View File

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

View File

@@ -1 +0,0 @@
../../../templates/js/upload-dialog.underscore

View File

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

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

View File

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

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

View File

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