diff --git a/.gitignore b/.gitignore index 72de96e0c4..e92d49a0f2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AUTHORS b/AUTHORS index 4b57d723d2..94963e4630 100644 --- a/AUTHORS +++ b/AUTHORS @@ -84,3 +84,8 @@ Mukul Goyal Robert Marks Yarko Tymciurak Miles Steele +Kevin Luo +Akshay Jagadeesh +Nick Parlante +Marko Seric +Felipe Montoya diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab5e17357b..3a9eb76165 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,59 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Disable data download buttons on the instructor dashboard for large courses + +LMS: Refactor and clean student dashboard templates. + +LMS: Fix issue with CourseMode expiration dates + +CMS: Add text_customization Dict to advanced settings which can support +string customization at particular spots in the UI. At first just customizing +the Check/Final Check buttons with keys: custom_check and custom_final_check + +LMS: Add PaidCourseRegistration mode, where payment is required before course +registration. + +Studio: Switched to loading Javascript using require.js + +Studio: Better feedback during the course import process + +LMS: Add split testing functionality for internal use. + +CMS: Add edit_course_tabs management command, providing a primitive +editing capability for a course's list of tabs. + +Studio and LMS: add ability to lock assets (cannot be viewed unless registered +for class). + +LMS: First round of improvements to New (beta) Instructor Dash: +improvements, fixes, and internationalization to the Student Info section. + +LMS: Improved accessibility of parts of forum navigation sidebar. + +LMS: enhanced accessibility labeling and aria support for the discussion forum +new post dropdown as well as response and comment area labeling. + +LMS: enhanced shib support, including detection of linked shib account +at login page and support for the ?next= GET parameter. + +LMS: Experimental feature using the ICE change tracker JS pkg to allow peer +assessors to edit the original submitter's work. + +LMS: Fixed a bug that caused links from forum user profile pages to +threads to lead to 404s if the course id contained a '-' character. + +Studio/LMS: Added ability to set due date formatting through Studio's Advanced +Settings. The key is due_date_display_format, and the value should be a format +supported by Python's strftime function. + +Common: Added configurable backends for tracking events. Tracking events using +the python logging module is the default backend. Support for MongoDB and a +Django database is also available. + +Blades: Added Learning Tools Interoperability (LTI) blade. Now LTI components +can be included to courses. + LMS: Added alphabetical sorting of forum categories and subcategories. It is hidden behind a false defaulted course level flag. @@ -33,6 +86,9 @@ logic has been consolidated into the model -- you should use new class methods to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating CourseEnrollment objects or querying them directly. +LMS: Added bulk email for course feature, with option to optout of individual +course emails. + Studio: Email will be sent to admin address when a user requests course creator privileges for Studio (edge only). @@ -76,7 +132,8 @@ LMS: Added endpoints for AJAX requests to enable/disable notifications Studio: Allow instructors of a course to designate other staff as instructors; this allows instructors to hand off management of a course to someone else. -Common: Add a manage.py that knows about edx-platform specific settings and projects +Common: Add a manage.py that knows about edx-platform specific settings and +projects Common: Added *experimental* support for jsinput type. @@ -97,19 +154,23 @@ XModule: Added *experimental* crowdsource hinting module. Studio: Added support for uploading and managing PDF textbooks -Common: Student information is now passed to the tracking log via POST instead of GET. +Common: Student information is now passed to the tracking log via POST instead +of GET. -Blades: Added functionality and tests for new capa input type: choicetextresponse. +Blades: Added functionality and tests for new capa input type: +choicetextresponse. Common: Add tests for documentation generation to test suite -Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems +Blades: User answer now preserved (and changeable) after clicking "show answer" +in choice problems LMS: Removed press releases Common: Updated Sass and Bourbon libraries, added Neat library -LMS: Add a MixedModuleStore to aggregate the XMLModuleStore and MongoMonduleStore +LMS: Add a MixedModuleStore to aggregate the XMLModuleStore and +MongoMonduleStore LMS: Users are no longer auto-activated if they click "reset password" This is now done when they click on the link in the reset password @@ -127,10 +188,11 @@ as wide as the text to reduce accidental choice selections. Studio: - use xblock field defaults to initialize all new instances' fields and -only use templates as override samples. + only use templates as override samples. - create new instances via in memory create_xmodule and related methods rather -than cloning a db record. -- have an explicit method for making a draft copy as distinct from making a new module. + than cloning a db record. +- have an explicit method for making a draft copy as distinct from making a + new module. Studio: Remove XML from the video component editor. All settings are moved to be edited as metadata. @@ -169,8 +231,9 @@ value of lms.start in `lms/djangoapps/django_comment_client/utils.py` Studio, LMS: Make ModelTypes more strict about their expected content (for instance, Boolean, Integer, String), but also allow them to hold either the -typed value, or a String that can be converted to their typed value. For example, -an Integer can contain 3 or '3'. This changed an update to the xblock library. +typed value, or a String that can be converted to their typed value. For +example, an Integer can contain 3 or '3'. This changed an update to the xblock +library. LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django setting now run entirely outside the Python sandbox. @@ -181,21 +244,22 @@ Common: Have the capa module handle unicode better (especially errors) Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox. -Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide -captions. +Blades: Additional event tracking added to Video Alpha: fullscreen switch, +show/hide captions. CMS: Allow editors to delete uploaded files/assets XModules: `XModuleDescriptor.__init__` and `XModule.__init__` dropped the -`location` parameter (and added it as a field), and renamed `system` to `runtime`, -to accord more closely to `XBlock.__init__` +`location` parameter (and added it as a field), and renamed `system` to +`runtime`, to accord more closely to `XBlock.__init__` LMS: Some errors handling Non-ASCII data in XML courses have been fixed. LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and SEGMENT_IO_LMS feature flag is on) -Blades: Simplify calc.py (which is used for the Numerical/Formula responses); add trig/other functions. +Blades: Simplify calc.py (which is used for the Numerical/Formula responses); +add trig/other functions. LMS: Background colors on login, register, and courseware have been corrected back to white. @@ -214,8 +278,8 @@ Blades: Staff debug info is now accessible for Graphical Slider Tool problems. Blades: For Video Alpha the events ready, play, pause, seek, and speed change are logged on the server (in the logs). -Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive -datetimes. +Common: all dates and times are not time zone aware datetimes. No code should +create or use struct_times nor naive datetimes. Common: Developers can now have private Django settings files. @@ -281,3 +345,5 @@ Common: Allow setting of authentication session cookie name. LMS: Option to email students when enroll/un-enroll them. +Blades: Added WAI-ARIA markup to the video player controls. These are now fully +accessible by screen readers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..d2cfdfdf93 --- /dev/null +++ b/CONTRIBUTING.md @@ -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). diff --git a/Gemfile b/Gemfile index 8a6a2c8ccc..329f289d79 100644 --- a/Gemfile +++ b/Gemfile @@ -6,3 +6,8 @@ gem 'neat', '~> 1.3.0' gem 'colorize', '~> 0.5.8' gem 'launchy', '~> 2.1.2' gem 'sys-proctable', '~> 0.9.3' +# These gems aren't actually required; they are used by Linux and Mac to +# detect when files change. If these gems are not installed, the system +# will fall back to polling files. +gem 'rb-inotify', '~> 0.9' +gem 'rb-fsevent', '~> 0.9.3' diff --git a/README.md b/README.md index 0261f87b46..a595e306c2 100644 --- a/README.md +++ b/README.md @@ -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 ------------------------- diff --git a/Vagrantfile b/Vagrantfile index 0d409cc408..10218bb1c0 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -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 diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index b2941ac7a5..03e8e6ea23 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 201ac49e52..201d87f029 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -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() diff --git a/cms/djangoapps/contentstore/features/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature index 6289df9cfc..c2b411adf0 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index 1c41eed4d3..8a8aad0a12 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -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) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index a6f22db340..992de9301c 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -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') diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature index a30ce96ae6..3877cccc55 100644 --- a/cms/djangoapps/contentstore/features/component.feature +++ b/cms/djangoapps/contentstore/features/component.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index d0c1fd59e7..bec0c9431b 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -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': + '

Words of encouragement! This is a short note that most students will read.

', + 'E-text Written in LaTeX': + '

Example: E-text page

', + } + 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') - } -} diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 2971085081..9881290eba 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -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(): diff --git a/cms/djangoapps/contentstore/features/course-overview.feature b/cms/djangoapps/contentstore/features/course-overview.feature index 2cbb22ddd7..80b400a58e 100644 --- a/cms/djangoapps/contentstore/features/course-overview.feature +++ b/cms/djangoapps/contentstore/features/course-overview.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/course-overview.py b/cms/djangoapps/contentstore/features/course-overview.py index 289dbec308..57e9d13501 100644 --- a/cms/djangoapps/contentstore/features/course-overview.py +++ b/cms/djangoapps/contentstore/features/course-overview.py @@ -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) diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 9976179b68..0aeb95dbd9 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index 7004b9f99e..7ec6a1071a 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -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, "

Overview

") @@ -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`. """ diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature index de5bb6556a..05a59002f5 100644 --- a/cms/djangoapps/contentstore/features/course-team.feature +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py index 85044dbbad..deba2d820d 100644 --- a/cms/djangoapps/contentstore/features/course-team.py +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -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) diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature index 41ee785db5..15257bd911 100644 --- a/cms/djangoapps/contentstore/features/course-updates.feature +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -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 "
    Test
" 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 "" + # 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 "" + 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 "
" + # 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 "" + 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" diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index f431af9cf5..da74f5aa4b 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -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) diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature index a0ba8099ac..c5316e0d6c 100644 --- a/cms/djangoapps/contentstore/features/courses.feature +++ b/cms/djangoapps/contentstore/features/courses.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index f18b06ec64..25fd0190b4 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -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 ################### diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature index e4b1f5450b..7278accf0b 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.feature +++ b/cms/djangoapps/contentstore/features/discussion-editor.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index 15a7c4b9ab..d68860ff49 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -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) diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature index 0b34feb7aa..f3ce1823e6 100644 --- a/cms/djangoapps/contentstore/features/grading.feature +++ b/cms/djangoapps/contentstore/features/grading.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py index 93e44b3893..24beefcd6a 100644 --- a/cms/djangoapps/contentstore/features/grading.py +++ b/cms/djangoapps/contentstore/features/grading.py @@ -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): diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature index 4419d6018b..29dcbbbfc5 100644 --- a/cms/djangoapps/contentstore/features/html-editor.feature +++ b/cms/djangoapps/contentstore/features/html-editor.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature index 1296acec1c..e3f659a929 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.feature +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index 5e4fe6364d..fca2249066 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -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(): diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 6402db1bcb..16d833aed8 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index c249ad61e8..f1f2c13583 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 94c6e6f18e..ee8950bac2 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -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) diff --git a/cms/djangoapps/contentstore/features/static-pages.feature b/cms/djangoapps/contentstore/features/static-pages.feature index 67652ea8f1..54d23d985d 100644 --- a/cms/djangoapps/contentstore/features/static-pages.feature +++ b/cms/djangoapps/contentstore/features/static-pages.feature @@ -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" diff --git a/cms/djangoapps/contentstore/features/static-pages.py b/cms/djangoapps/contentstore/features/static-pages.py index d3244955e1..58932ad8e2 100644 --- a/cms/djangoapps/contentstore/features/static-pages.py +++ b/cms/djangoapps/contentstore/features/static-pages.py @@ -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 diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 6703c60c3b..2cb708ad3c 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 6d9612d9bd..68e65ee7ac 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -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:]+)?') diff --git a/cms/djangoapps/contentstore/features/textbooks.feature b/cms/djangoapps/contentstore/features/textbooks.feature index 36de10daa1..010e490256 100644 --- a/cms/djangoapps/contentstore/features/textbooks.feature +++ b/cms/djangoapps/contentstore/features/textbooks.feature @@ -1,4 +1,5 @@ -Feature: Textbooks +@shard_3 +Feature: CMS.Textbooks Scenario: No textbooks Given I have opened a new course in Studio diff --git a/cms/djangoapps/contentstore/features/textbooks.py b/cms/djangoapps/contentstore/features/textbooks.py index b432b84d4f..2cf6683d6d 100644 --- a/cms/djangoapps/contentstore/features/textbooks.py +++ b/cms/djangoapps/contentstore/features/textbooks.py @@ -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') diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature index 441de597ea..2e73c11c0c 100644 --- a/cms/djangoapps/contentstore/features/upload.feature +++ b/cms/djangoapps/contentstore/features/upload.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index 882b36e6b2..cf0d92fc94 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -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 diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index d238a7e523..d5b4a2a03b 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index 93d3be2ac0..56b1610ce6 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -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 + ) diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index d2f9915f55..105a26c868 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index afa9953c90..20db375184 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -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='' % 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'] - diff --git a/cms/djangoapps/contentstore/management/commands/check_course.py b/cms/djangoapps/contentstore/management/commands/check_course.py index 13ac6af50c..541f5dee75 100644 --- a/cms/djangoapps/contentstore/management/commands/check_course.py +++ b/cms/djangoapps/contentstore/management/commands/check_course.py @@ -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()) - diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index 50f9b82e80..085fce5fe5 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -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) diff --git a/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py new file mode 100644 index 0000000000..d9c73e42fa --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py @@ -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 required, e.g. Stanford/CS99/2013_spring') + delete_option = make_option('--delete', + action='store_true', + dest='delete', + default=False, + help='--delete ') + insert_option = make_option('--insert', + action='store_true', + dest='insert', + default=False, + help='--insert , 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) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index c0e1ff7207..b43a32f635 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -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 diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index 2f158cfda6..2e90955220 100644 --- a/cms/djangoapps/contentstore/tests/test_assets.py +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -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) diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 5a99c37fbb..3b26f6d7d9 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -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'] diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 696b60fbe5..288e6443f7 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -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( diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 524dde07e5..f7ffbad334 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -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): diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py index e543b7b517..b95d58d913 100644 --- a/cms/djangoapps/contentstore/tests/test_crud.py +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -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 diff --git a/cms/djangoapps/contentstore/tests/test_import_export.py b/cms/djangoapps/contentstore/tests/test_import_export.py index 05f2b0b7b9..005cfbc1e0 100644 --- a/cms/djangoapps/contentstore/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/tests/test_import_export.py @@ -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 will put its contents + directly in (rather than ). + """ + 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 will also put its contents + directly in (rather than ). + """ + 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)) diff --git a/cms/djangoapps/contentstore/tests/test_import_nostatic.py b/cms/djangoapps/contentstore/tests/test_import_nostatic.py index f0f65c9b07..275f9b6b1f 100644 --- a/cms/djangoapps/contentstore/tests/test_import_nostatic.py +++ b/cms/djangoapps/contentstore/tests/test_import_nostatic.py @@ -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): ''' diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index e5ff992cb8..dcd94838c8 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -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)) diff --git a/cms/djangoapps/contentstore/tests/test_tabs.py b/cms/djangoapps/contentstore/tests/test_tabs.py new file mode 100644 index 0000000000..f1cf8ddfa5 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_tabs.py @@ -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'}) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index e5ae6bb66b..09d04e4929 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 4743622fa8..4f9db0bf4d 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -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 + } diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index 030aa70693..773ec087eb 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index a5fec7c033..deef87a403 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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 + ), }) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index aad56e4a2e..772dfd2778 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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: diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 5830e07a52..67e64d67d0 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -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): diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index bbd95dba84..c9edcd60a0 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -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): diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 7a3a224d86..b5cfc74a57 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/session_kv_store.py b/cms/djangoapps/contentstore/views/session_kv_store.py index 87a92a9e2e..ddee2c1dbc 100644 --- a/cms/djangoapps/contentstore/views/session_kv_store.py +++ b/cms/djangoapps/contentstore/views/session_kv_store.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index f38685edfc..f897fa1378 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -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)) diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index 0746fc7a90..578961fad6 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -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 diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 8d9a292867..603865b884 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -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. diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 3b89e2e988..e5ed2261b5 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -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) diff --git a/cms/envs/acceptance_static.py b/cms/envs/acceptance_static.py deleted file mode 100644 index f7d69794fb..0000000000 --- a/cms/envs/acceptance_static.py +++ /dev/null @@ -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' diff --git a/cms/envs/aws.py b/cms/envs/aws.py index c2ba51a5f8..1ad5374744 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -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", {})) diff --git a/cms/envs/common.py b/cms/envs/common.py index 29e99b2551..dbf3647839 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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'] diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 42a6f706b6..6e4ce460c3 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -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 diff --git a/cms/envs/test.py b/cms/envs/test.py index ffbf9f5376..77c05d9fa4 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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': { diff --git a/cms/startup.py b/cms/startup.py index eb1098a707..111cb9f96a 100644 --- a/cms/startup.py +++ b/cms/startup.py @@ -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() - diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html deleted file mode 100644 index 6be22e2116..0000000000 --- a/cms/static/client_templates/advanced_entry.html +++ /dev/null @@ -1,11 +0,0 @@ -
  • -
    - - -
    - -
    - - -
    -
  • \ No newline at end of file diff --git a/cms/static/client_templates/load_templates.html b/cms/static/client_templates/load_templates.html deleted file mode 100644 index 3ff88d6fe5..0000000000 --- a/cms/static/client_templates/load_templates.html +++ /dev/null @@ -1,14 +0,0 @@ - - -<%block name="jsextra"> - - - - \ No newline at end of file diff --git a/cms/static/coffee/fixtures b/cms/static/coffee/fixtures new file mode 120000 index 0000000000..800ce1d60d --- /dev/null +++ b/cms/static/coffee/fixtures @@ -0,0 +1 @@ +../../templates/js/ \ No newline at end of file diff --git a/cms/static/coffee/fixtures/edit-chapter.underscore b/cms/static/coffee/fixtures/edit-chapter.underscore deleted file mode 120000 index 9e057ab233..0000000000 --- a/cms/static/coffee/fixtures/edit-chapter.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/edit-chapter.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/edit-textbook.underscore b/cms/static/coffee/fixtures/edit-textbook.underscore deleted file mode 120000 index 5bb17a2d43..0000000000 --- a/cms/static/coffee/fixtures/edit-textbook.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/edit-textbook.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-editor.underscore b/cms/static/coffee/fixtures/metadata-editor.underscore deleted file mode 120000 index 9696774d0a..0000000000 --- a/cms/static/coffee/fixtures/metadata-editor.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-editor.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-list-entry.underscore b/cms/static/coffee/fixtures/metadata-list-entry.underscore deleted file mode 120000 index 78fa4e2000..0000000000 --- a/cms/static/coffee/fixtures/metadata-list-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-list-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-number-entry.underscore b/cms/static/coffee/fixtures/metadata-number-entry.underscore deleted file mode 120000 index 99138aa9c1..0000000000 --- a/cms/static/coffee/fixtures/metadata-number-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-number-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-option-entry.underscore b/cms/static/coffee/fixtures/metadata-option-entry.underscore deleted file mode 120000 index c6cd499801..0000000000 --- a/cms/static/coffee/fixtures/metadata-option-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-option-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-string-entry.underscore b/cms/static/coffee/fixtures/metadata-string-entry.underscore deleted file mode 120000 index f713ab5387..0000000000 --- a/cms/static/coffee/fixtures/metadata-string-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-string-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/no-textbooks.underscore b/cms/static/coffee/fixtures/no-textbooks.underscore deleted file mode 120000 index d2e1c9a71a..0000000000 --- a/cms/static/coffee/fixtures/no-textbooks.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/no-textbooks.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/section-name-edit.underscore b/cms/static/coffee/fixtures/section-name-edit.underscore deleted file mode 120000 index 89284ccf90..0000000000 --- a/cms/static/coffee/fixtures/section-name-edit.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/section-name-edit.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/show-textbook.underscore b/cms/static/coffee/fixtures/show-textbook.underscore deleted file mode 120000 index d2ba37d689..0000000000 --- a/cms/static/coffee/fixtures/show-textbook.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/show-textbook.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/system-feedback.underscore b/cms/static/coffee/fixtures/system-feedback.underscore deleted file mode 120000 index 10893f87c4..0000000000 --- a/cms/static/coffee/fixtures/system-feedback.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/system-feedback.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/tabs-edit.html b/cms/static/coffee/fixtures/tabs-edit.html deleted file mode 100644 index c83a145622..0000000000 --- a/cms/static/coffee/fixtures/tabs-edit.html +++ /dev/null @@ -1,33 +0,0 @@ -
    -
    -
    - -
    -
    - -
    -
    - Transcripts -
    -
    - Subtitles -
    -
    -
    - -
    -
    -
    - -
    -
    - diff --git a/cms/static/coffee/fixtures/upload-dialog.underscore b/cms/static/coffee/fixtures/upload-dialog.underscore deleted file mode 120000 index e5637e9a53..0000000000 --- a/cms/static/coffee/fixtures/upload-dialog.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/upload-dialog.underscore \ No newline at end of file diff --git a/cms/static/coffee/spec/helpers.coffee b/cms/static/coffee/spec/helpers.coffee deleted file mode 100644 index a03e2a0e56..0000000000 --- a/cms/static/coffee/spec/helpers.coffee +++ /dev/null @@ -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" diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee new file mode 100644 index 0000000000..39214fd6a7 --- /dev/null +++ b/cms/static/coffee/spec/main.coffee @@ -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" + ]) + diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee index 9383f2547e..6811b3d96a 100644 --- a/cms/static/coffee/spec/main_spec.coffee +++ b/cms/static/coffee/spec/main_spec.coffee @@ -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($(" - """ - - appendSetFixtures """ - - """ - - appendSetFixtures """ -
    -
    -

    Section Release Date

    -
    -
    - - -
    -
    - - -
    -
    -

    On the date set above, this section – – will be released to students. Any units marked private will only be visible to admins.

    -
    -
    - SaveCancel -
    -
    - """ - - appendSetFixtures """ -
    - -
    - """ - - spyOn(window, 'saveSetSectionScheduleDate').andCallThrough() - # Have to do this here, as it normally gets bound in document.ready() - $('a.save-button').click(saveSetSectionScheduleDate) - $('a.delete-section-button').click(deleteSection) - $(".edit-subsection-publish-settings .start-date").datepicker() - - @notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough() - window.analytics = jasmine.createSpyObj('analytics', ['track']) - window.course_location_analytics = jasmine.createSpy() - @xhr = sinon.useFakeXMLHttpRequest() - requests = @requests = [] - @xhr.onCreate = (req) -> requests.push(req) - - afterEach -> - delete window.analytics - delete window.course_location_analytics - @notificationSpy.reset() - - it "should save model when save is clicked", -> - $('a.edit-button').click() - $('a.save-button').click() - expect(saveSetSectionScheduleDate).toHaveBeenCalled() - - it "should show a confirmation on save", -> - $('a.edit-button').click() - $('a.save-button').click() - expect(@notificationSpy).toHaveBeenCalled() - - it "should delete model when delete is clicked", -> - deleteSpy = spyOn(window, '_deleteItem').andCallThrough() - $('a.delete-section-button').click() - $('a.action-primary').click() - expect(deleteSpy).toHaveBeenCalled() - expect(@requests[0].url).toEqual('/delete_item') - - it "should not delete model when cancel is clicked", -> - deleteSpy = spyOn(window, '_deleteItem').andCallThrough() - $('a.delete-section-button').click() - $('a.action-secondary').click() - expect(@requests.length).toEqual(0) - - it "should show a confirmation on delete", -> - $('a.delete-section-button').click() - $('a.action-primary').click() - expect(@notificationSpy).toHaveBeenCalled() diff --git a/cms/static/coffee/spec/views/section_spec.coffee b/cms/static/coffee/spec/views/section_spec.coffee index e64294f249..d83707c8f0 100644 --- a/cms/static/coffee/spec/views/section_spec.coffee +++ b/cms/static/coffee/spec/views/section_spec.coffee @@ -1,85 +1,87 @@ -describe "CMS.Views.SectionShow", -> - describe "Basic", -> - beforeEach -> - spyOn(CMS.Views.SectionShow.prototype, "switchToEditView") - .andCallThrough() - @model = new CMS.Models.Section({ - id: 42 - name: "Life, the Universe, and Everything" - }) - @view = new CMS.Views.SectionShow({model: @model}) - @view.render() +define ["js/models/section", "js/views/section_show", "js/views/section_edit", "sinon"], (Section, SectionShow, SectionEdit, sinon) -> - it "should contain the model name", -> - expect(@view.$el).toHaveText(@model.get('name')) + describe "SectionShow", -> + describe "Basic", -> + beforeEach -> + spyOn(SectionShow.prototype, "switchToEditView") + .andCallThrough() + @model = new Section({ + id: 42 + name: "Life, the Universe, and Everything" + }) + @view = new SectionShow({model: @model}) + @view.render() - it "should call switchToEditView when clicked", -> - @view.$el.click() - expect(@view.switchToEditView).toHaveBeenCalled() + it "should contain the model name", -> + expect(@view.$el).toHaveText(@model.get('name')) - it "should pass the same element to SectionEdit when switching views", -> - spyOn(CMS.Views.SectionEdit.prototype, 'initialize').andCallThrough() - @view.switchToEditView() - expect(CMS.Views.SectionEdit.prototype.initialize).toHaveBeenCalled() - expect(CMS.Views.SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el) + it "should call switchToEditView when clicked", -> + @view.$el.click() + expect(@view.switchToEditView).toHaveBeenCalled() -describe "CMS.Views.SectionEdit", -> - describe "Basic", -> - tpl = readFixtures('section-name-edit.underscore') - feedback_tpl = readFixtures('system-feedback.underscore') + it "should pass the same element to SectionEdit when switching views", -> + spyOn(SectionEdit.prototype, 'initialize').andCallThrough() + @view.switchToEditView() + expect(SectionEdit.prototype.initialize).toHaveBeenCalled() + expect(SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el) - beforeEach -> - setFixtures($(" + + <%block name="jsextra"> - + <%block name="content"> - - -
    +
    -

    - Content - > Files & Uploads -

    +

    + ${_("Content")} + > ${_("Files & Uploads")} +

    - -
    -
    - -
    -
    -
    - -
    -
    - - - - - - - - - - - - % for asset in assets: - - - - - - - - % endfor - -
    NameDate AddedURL
    -
    - % if asset['thumb_url'] is not None: - - % endif -
    -
    - ${asset['displayname']} -
    -
    - ${asset['uploadDate']} - - - - -
    -
    + +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    ${_("List of uploaded files and assets in this course")}
    ${_("Preview")}${_("Name")}${_("Date Added")}${_("URL")}${_("Actions")}
    +
    + + +
    +
    + + - - - - +
    @@ -141,17 +212,17 @@ <%block name="view_alerts">
    -
    - +
    + -
    -

    ${_('Your file has been deleted.')}

    +
    +

    ${_('Your file has been deleted.')}

    +
    + + + + ${_('close alert')} +
    - - - - ${_('close alert')} - -
    diff --git a/cms/templates/base.html b/cms/templates/base.html index b53dc2657d..a27cac7760 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -18,10 +18,10 @@ - <%static:css group='base-style'/> + <%include file="widgets/segment-io.html" /> @@ -29,44 +29,151 @@ - <%include file="courseware_vendor_js.html"/> + + + + - ## js templates - +## js templates + - ## javascript - - - - - - - - - <%static:js group='main'/> - <%static:js group='module-js'/> - - - - - - - - - - - % if context_course: % endif @@ -91,6 +198,7 @@ window.course = new CMS.Models.Course({
    <%block name="jsextra"> + <%include file="widgets/qualaroo.html" /> diff --git a/cms/templates/checklists.html b/cms/templates/checklists.html index ad4f29aeb6..8a9d59512b 100644 --- a/cms/templates/checklists.html +++ b/cms/templates/checklists.html @@ -1,28 +1,34 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> -<%! from django.core.urlresolvers import reverse %> +<%! + from django.core.urlresolvers import reverse + from django.utils.translation import ugettext as _ +%> <%block name="title">Course Checklists -<%block name="bodyclass">is-signedin course uxdesign checklists +<%block name="bodyclass">is-signedin course view-checklists <%namespace name='static' file='static_content.html'/> + +<%block name="header_extras"> +% for template_name in ["checklist"]: + +% endfor + + <%block name="jsextra"> - - - - - diff --git a/cms/templates/component.html b/cms/templates/component.html index 2412cd74d4..7496e85b18 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -1,11 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> <%namespace name='static' file='static_content.html'/> - - - - -
    diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html index 03f4c35d14..b2abd43e9b 100644 --- a/cms/templates/course_info.html +++ b/cms/templates/course_info.html @@ -4,9 +4,10 @@ <%block name="title">${_("Course Updates")} -<%block name="bodyclass">is-signedin course course-info updates +<%block name="bodyclass">is-signedin course course-info updates view-updates <%block name="header_extras"> + % for template_name in ["course_info_update", "course_info_handouts"]: - - - - - - - diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index 08c71259c4..ae597f0377 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -1,18 +1,23 @@ <%inherit file="base.html" /> -<%! from django.utils.translation import ugettext as _ %> -<%! from django.core.urlresolvers import reverse %> +<%namespace name='static' file='static_content.html'/> +<%! + from django.utils.translation import ugettext as _ + from django.core.urlresolvers import reverse +%> <%block name="title">Static Pages -<%block name="bodyclass">is-signedin course pages static-pages +<%block name="bodyclass">is-signedin course view-static-pages <%block name="jsextra"> @@ -72,7 +77,7 @@

    ${_("How Static Pages are Used in Your Course")}

    - ${_('Preview of how Static Pages are used in your course')} + ${_('Preview of how Static Pages are used in your course')}
    ${_("These pages will be presented in your course's main navigation alongside Courseware, Course Info, Discussion, etc.")}
    diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index 5b03643f3b..364411b01c 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -6,7 +6,7 @@ from django.core.urlresolvers import reverse %> <%block name="title">${_("CMS Subsection")} -<%block name="bodyclass">is-signedin course subsection +<%block name="bodyclass">is-signedin course view-subsection <%namespace name="units" file="widgets/units.html" /> @@ -37,21 +37,21 @@
    - % if subsection.lms.start and not almost_same_datetime(subsection.lms.start, parent_item.lms.start): - % if parent_item.lms.start is None: + % if subsection.start and not almost_same_datetime(subsection.start, parent_item.start): + % if parent_item.start is None:

    ${_("The date above differs from the release date of {name}, which is unset.").format(name=parent_item.display_name_with_default)} % else: -

    ${_("The date above differs from the release date of {name} - {start_time}").format(name=parent_item.display_name_with_default, start_time=get_default_time_display(parent_item.lms.start))}. +

    ${_("The date above differs from the release date of {name} - {start_time}").format(name=parent_item.display_name_with_default, start_time=get_default_time_display(parent_item.start))}. % endif ${_("Sync to {name}.").format(name=parent_item.display_name_with_default)}

    % endif @@ -60,7 +60,7 @@
    -
    +
    @@ -69,13 +69,13 @@
    ${_("Remove due date")} @@ -97,40 +97,33 @@ <%block name="jsextra"> - - - - - - - - diff --git a/cms/templates/export.html b/cms/templates/export.html index 3356bea42b..04b4572528 100644 --- a/cms/templates/export.html +++ b/cms/templates/export.html @@ -1,63 +1,70 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%namespace name='static' file='static_content.html'/> -<%! from django.core.urlresolvers import reverse %> +<%! + from django.core.urlresolvers import reverse + from django.utils.translation import ugettext as _ + import json +%> <%block name="title">${_("Course Export")} -<%block name="bodyclass">is-signedin course tools export +<%block name="bodyclass">is-signedin course tools view-export <%block name="jsextra"> % if in_err: %endif diff --git a/cms/templates/howitworks.html b/cms/templates/howitworks.html index e3a92aa345..f405976040 100644 --- a/cms/templates/howitworks.html +++ b/cms/templates/howitworks.html @@ -1,9 +1,12 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> -<%! from django.core.urlresolvers import reverse %> +<%namespace name='static' file='static_content.html'/> +<%! + from django.core.urlresolvers import reverse + from django.utils.translation import ugettext as _ +%> <%block name="title">${_("Welcome")} -<%block name="bodyclass">not-signedin index howitworks +<%block name="bodyclass">not-signedin index view-howitworks <%block name="content"> @@ -27,7 +30,7 @@
  • - ${_('Studio Helps You Keep Your Courses Organized')} + ${_('Studio Helps You Keep Your Courses Organized')}
    ${_("Studio Helps You Keep Your Courses Organized")}
    @@ -61,7 +64,7 @@
  • - ${_('Learning is More than Just Lectures')} + ${_('Learning is More than Just Lectures')}
    ${_("Learning is More than Just Lectures")}
    @@ -95,7 +98,7 @@
  • - ${_('Studio Gives You Simple, Fast, and Incremental Publishing. With Friends.')} + ${_('Studio Gives You Simple, Fast, and Incremental Publishing. With Friends.')}
    ${_("Studio Gives You Simple, Fast, and Incremental Publishing. With Friends.")}
    @@ -149,7 +152,7 @@

    ${_("Outlining Your Course")}

    - +
    ${_("Simple two-level outline to organize your couse. Drag and drop, and see your course at a glance.")}
    @@ -162,7 +165,7 @@

    ${_("More than Just Lectures")}

    - +
    ${_("Quickly create videos, text snippets, inline discussions, and a variety of problem types.")}
    @@ -175,7 +178,7 @@

    ${_("Publishing on Date")}

    - +
    ${_("Simply set the date of a section or subsection, and Studio will publish it to your students for you.")}
    diff --git a/cms/templates/import.html b/cms/templates/import.html index a5c6b9f412..7daf4f78d4 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -1,10 +1,11 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%namespace name='static' file='static_content.html'/> - -<%! from django.core.urlresolvers import reverse %> +<%! + from django.core.urlresolvers import reverse + from django.utils.translation import ugettext as _ +%> <%block name="title">${_("Course Import")} -<%block name="bodyclass">is-signedin course tools import +<%block name="bodyclass">is-signedin course tools view-import <%block name="content">
    @@ -16,49 +17,155 @@
    -
    -
    -
    -
    -

    ${_("Importing a new course will delete all content currently associated with your course and replace it with the contents of the uploaded file.")}

    - ## Translators: ".tar.gz" is a file extension, and files with that extension are called "gzipped tar files": these terms should not be translated -

    ${_("File uploads must be gzipped tar files (.tar.gz) containing, at a minimum, a {filename} file.").format(filename='course.xml')}

    -

    ${_("Please note that if your course has any problems with auto-generated {nodename} nodes, re-importing your course could cause the loss of student data associated with those problems.").format(nodename='url_name')}

    +
    +
    +
    + +
    +

    ${_("You may import existing course structure and content into Studio.")}

    + +

    ${_("Importing is not something to take lightly as the course content you successfully upload will be integrated into your course content and cannot be reversed.")}

    +

    ${_("During the initial stages of the import process, please do not navigate away from this page.")}

    +
    -

    ${_("Course to import:")}

    + + + + ## Translators: ".tar.gz" is a file extension, and files with that extension are called "gzipped tar files": these terms should not be translated +

    ${_("Select a File (.tar.gz format) to Replace Your Course Content")}

    +

    - ${_("Choose File")} -

    ${_("change")}

    - - - -

    Unpacking...

    -
    -
    -
    0%
    + + ${_("Choose File")} + +
    +

    + ${_("File Chosen:")} + +

    + + + + +
    + +
    -
    + + +
    <%block name="jsextra"> - - - diff --git a/cms/templates/index.html b/cms/templates/index.html index e5cf2fa54a..df8459e448 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -3,13 +3,11 @@ <%inherit file="base.html" /> <%block name="title">${_("My Courses")} -<%block name="bodyclass">is-signedin index dashboard - - - +<%block name="bodyclass">is-signedin index view-dashboard <%block name="jsextra"> diff --git a/cms/templates/js/asset.underscore b/cms/templates/js/asset.underscore new file mode 100644 index 0000000000..73f815f9e1 --- /dev/null +++ b/cms/templates/js/asset.underscore @@ -0,0 +1,30 @@ + +
    + <% if (thumbnail !== '') { %> + + <% } %> +
    + + + <%= display_name %> + +
    + + + <%= date_added %> + + + + + + + diff --git a/cms/static/client_templates/checklist.html b/cms/templates/js/checklist.underscore similarity index 100% rename from cms/static/client_templates/checklist.html rename to cms/templates/js/checklist.underscore diff --git a/cms/static/client_templates/course_grade_policy.html b/cms/templates/js/course_grade_policy.underscore similarity index 100% rename from cms/static/client_templates/course_grade_policy.html rename to cms/templates/js/course_grade_policy.underscore diff --git a/cms/templates/login.html b/cms/templates/login.html index 3b41be1a7d..ac183927c0 100644 --- a/cms/templates/login.html +++ b/cms/templates/login.html @@ -1,8 +1,10 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> -<%! from django.core.urlresolvers import reverse %> +<%! +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +%> <%block name="title">${_("Sign In")} -<%block name="bodyclass">not-signedin signin +<%block name="bodyclass">not-signedin view-signin <%block name="content"> @@ -56,17 +58,14 @@ <%block name="jsextra"> diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index e8026677e8..da4d255ecd 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -4,7 +4,7 @@ <%! import json %> <%inherit file="base.html" /> <%block name="title">${_("Course Team Settings")} -<%block name="bodyclass">is-signedin course users team +<%block name="bodyclass">is-signedin course users view-team <%block name="content"> @@ -162,6 +162,9 @@ <%block name="jsextra"> diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 2c42df187a..b3888d8706 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -6,54 +6,45 @@ from django.core.urlresolvers import reverse %> <%block name="title">${_("Course Outline")} -<%block name="bodyclass">is-signedin course outline +<%block name="bodyclass">is-signedin course view-outline <%namespace name='static' file='static_content.html'/> <%namespace name="units" file="widgets/units.html" /> <%block name="jsextra"> - - - - - - - - + - - @@ -157,19 +148,19 @@

    -
    +
    diff --git a/cms/templates/registration/activation_complete.html b/cms/templates/registration/activation_complete.html index 32b95cd050..92a0e180f7 100644 --- a/cms/templates/registration/activation_complete.html +++ b/cms/templates/registration/activation_complete.html @@ -5,9 +5,7 @@ <%namespace name='static' file='../static_content.html'/> %if not user_logged_in: -<%block name="bodyclass"> - not-signedin - +<%block name="bodyclass">not-signedin view-activation %endif <%block name="content"> diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 4b7cdd0a56..961b7d3d92 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -1,56 +1,45 @@ -<%! from django.utils.translation import ugettext as _ %> - <%inherit file="base.html" /> <%block name="title">${_("Schedule & Details Settings")} -<%block name="bodyclass">is-signedin course schedule settings feature-upload +<%block name="bodyclass">is-signedin course schedule view-settings feature-upload <%namespace name='static' file='static_content.html'/> <%! -from contentstore import utils + from contentstore import utils + from django.utils.translation import ugettext as _ %> <%block name="jsextra"> - - - - - - - - - @@ -91,6 +80,7 @@ from contentstore import utils
  • + % if about_page_editable:

    ${_("Course Summary Page")} ${_("(for student enrollment and access)")}

    @@ -103,6 +93,7 @@ from contentstore import utils
  • + % endif % if not about_page_editable:
    @@ -152,7 +143,6 @@ from contentstore import utils - % if about_page_editable:
    1. @@ -182,7 +172,6 @@ from contentstore import utils
    - % endif % if not about_page_editable:
    @@ -194,14 +183,13 @@ from contentstore import utils % endif
    - % if about_page_editable:

    ${_("Introducing Your Course")}

    ${_("Information for prospective students")}
    -
      + % if about_page_editable:
    1. @@ -213,6 +201,7 @@ from contentstore import utils %>${text} ${overview_text()}
    2. + % endif
    3. @@ -242,6 +231,7 @@ from contentstore import utils
    + % if about_page_editable:
  • @@ -258,9 +248,11 @@ from contentstore import utils ${_("Enter your YouTube video's ID (along with any restriction parameters)")}
  • + % endif + % if about_page_editable:
    @@ -277,7 +269,7 @@ from contentstore import utils
    - % endif + % endif
    + +
    diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_all.html b/common/lib/xmodule/xmodule/js/fixtures/video_all.html index 23c248a21a..57052bf65d 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_all.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_all.html @@ -13,7 +13,11 @@ data-webm-source="xmodule/include/fixtures/test.webm" data-ogg-source="xmodule/include/fixtures/test.ogv" data-autoplay="False" + data-yt-test-timeout="1500" + data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" > +
    +
    + +
    diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html index 6720007310..32789b6ba9 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html @@ -13,7 +13,11 @@ data-webm-source="xmodule/include/fixtures/test.webm" data-ogg-source="xmodule/include/fixtures/test.ogv" data-autoplay="False" + data-yt-test-timeout="1500" + data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" > +
    +
    @@ -24,6 +28,8 @@
    + +
    diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html index c611acfffd..61975784c1 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html @@ -10,7 +10,11 @@ data-end="" data-caption-asset-path="/static/subs/" data-autoplay="False" + data-yt-test-timeout="1500" + data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" > +
    +
    @@ -19,7 +23,9 @@
    + +
    - \ No newline at end of file + diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html new file mode 100644 index 0000000000..c6b40cdf16 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html @@ -0,0 +1,175 @@ +
    +
    +
    +
    +
    + +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    diff --git a/common/lib/xmodule/xmodule/js/js_test.yml b/common/lib/xmodule/xmodule/js/js_test.yml index b8c41ed2da..7fb5699ecf 100644 --- a/common/lib/xmodule/xmodule/js/js_test.yml +++ b/common/lib/xmodule/xmodule/js/js_test.yml @@ -36,7 +36,8 @@ lib_paths: - common_static/coffee/src/ajax_prefix.js - common_static/coffee/src/logger.js - common_static/js/vendor/jasmine-jquery.js - - common_static/js/vendor/RequireJS.js + - common_static/js/vendor/require.js + - RequireJS-namespace-undefine.js - common_static/js/vendor/jquery.min.js - common_static/js/vendor/jquery-ui.min.js - common_static/js/vendor/jquery.ui.draggable.js @@ -53,6 +54,7 @@ lib_paths: - common_static/js/vendor/sinon-1.7.1.js - common_static/js/vendor/analytics.js - common_static/js/test/add_ajax_prefix.js + - common_static/js/src/utility.js # Paths to spec (test) JavaScript files spec_paths: diff --git a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee index 293d6405ad..ef2c3cf0f9 100644 --- a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee @@ -48,17 +48,32 @@ describe 'CombinedOpenEnded', -> expect(@combined.task_count).toEqual 2 expect(@combined.task_number).toEqual 1 - it 'subelements are made collapsible', -> + it 'subelements are made collapsible', -> expect(Collapsible.setCollapsibles).toHaveBeenCalled() describe 'poll', -> + # We will store default window.setTimeout() function here. + oldSetTimeout = null + beforeEach => # setup the spies @combined = new CombinedOpenEnded @element spyOn(@combined, 'reload').andCallFake -> return 0 + + # Store original window.setTimeout() function. If we do not do this, then + # all other tests that rely on code which uses window.setTimeout() + # function might (and probably will) fail. + oldSetTimeout = window.setTimeout + # Redefine window.setTimeout() function as a spy. window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5 + afterEach => + # Reset the default window.setTimeout() function. If we do not do this, + # then all other tests that rely on code which uses window.setTimeout() + # function might (and probably will) fail. + window.setTimeout = oldSetTimeout + it 'polls at the correct intervals', => fakeResponseContinue = state: 'not done' spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseContinue) @@ -67,19 +82,34 @@ describe 'CombinedOpenEnded', -> expect(window.queuePollerID).toBe(5) it 'polling stops properly', => - fakeResponseDone = state: "done" + fakeResponseDone = state: "done" spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseDone) @combined.poll() expect(window.queuePollerID).toBeUndefined() expect(window.setTimeout).not.toHaveBeenCalled() describe 'rebind', -> + # We will store default window.setTimeout() function here. + oldSetTimeout = null + beforeEach -> @combined = new CombinedOpenEnded @element spyOn(@combined, 'queueing').andCallFake -> return 0 spyOn(@combined, 'skip_post_assessment').andCallFake -> return 0 + + # Store original window.setTimeout() function. If we do not do this, then + # all other tests that rely on code which uses window.setTimeout() + # function might (and probably will) fail. + oldSetTimeout = window.setTimeout + # Redefine window.setTimeout() function as a spy. window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5 + afterEach => + # Reset the default window.setTimeout() function. If we do not do this, + # then all other tests that rely on code which uses window.setTimeout() + # function might (and probably will) fail. + window.setTimeout = oldSetTimeout + it 'when our child is in an assessing state', -> @combined.child_state = 'assessing' @combined.rebind() @@ -87,19 +117,19 @@ describe 'CombinedOpenEnded', -> expect(@combined.submit_button.val()).toBe("Submit assessment") expect(@combined.queueing).toHaveBeenCalled() - it 'when our child state is initial', -> + it 'when our child state is initial', -> @combined.child_state = 'initial' @combined.rebind() expect(@combined.answer_area.attr("disabled")).toBeUndefined() expect(@combined.submit_button.val()).toBe("Submit") - it 'when our child state is post_assessment', -> + it 'when our child state is post_assessment', -> @combined.child_state = 'post_assessment' @combined.rebind() expect(@combined.answer_area.attr("disabled")).toBe("disabled") expect(@combined.submit_button.val()).toBe("Submit post-assessment") - it 'when our child state is done', -> + it 'when our child state is done', -> spyOn(@combined, 'next_problem').andCallFake -> @combined.child_state = 'done' @combined.rebind() @@ -112,7 +142,7 @@ describe 'CombinedOpenEnded', -> @combined.child_state = 'done' it 'handling a successful call', -> - fakeResponse = + fakeResponse = success: true html: "dummy html" allow_reset: false diff --git a/common/lib/xmodule/xmodule/js/spec/helper.coffee b/common/lib/xmodule/xmodule/js/spec/helper.coffee index 423177a4b4..bed85cee4d 100644 --- a/common/lib/xmodule/xmodule/js/spec/helper.coffee +++ b/common/lib/xmodule/xmodule/js/spec/helper.coffee @@ -90,12 +90,24 @@ jasmine.stubbedHtml5Speeds = ['0.75', '1.0', '1.25', '1.50'] jasmine.stubRequests = -> spyOn($, 'ajax').andCallFake (settings) -> if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/ - if settings.success + status = match[1].split('_') + if status and status[0] is 'status' + { + always: (callback) -> + callback.call(window, {}, status[1]) + error: (callback) -> + callback.call(window, {}, status[1]) + done: (callback) -> + callback.call(window, {}, status[1]) + } + else if settings.success # match[1] - it's video ID settings.success data: jasmine.stubbedMetadata[match[1]] else { always: (callback) -> - callback.call(window, {}, 'success'); + callback.call(window, {}, 'success') + done: (callback) -> + callback.call(window, {}, 'success') } else if match = settings.url.match /static(\/.*)?\/subs\/(.+)\.srt\.sjson/ settings.success jasmine.stubbedCaption diff --git a/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee index 3b89d3500d..2b53ee2327 100644 --- a/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee @@ -1,4 +1,8 @@ describe 'HTMLEditingDescriptor', -> + beforeEach -> + window.baseUrl = "/static/deadbeef" + afterEach -> + delete window.baseUrl describe 'Read data from server, create Editor, and get data back out', -> it 'Does not munge <', -> # This is a test for Lighthouse #22, diff --git a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js new file mode 100644 index 0000000000..f016d42d9d --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js @@ -0,0 +1,84 @@ +/** + * File: constructor.js + * + * Purpose: Jasmine tests for LTI module (front-end part). + * + * + * The front-end part of the LTI module is really simple. If an action + * is set for the hidden LTI form, then it is submited, and the results are + * redirected to an iframe. + * + * We will test that the form is only submited when the action is set (i.e. + * not empty). + * + * Other aspects of LTI module will be covered by Python unit tests and + * acceptance tests. + * + */ + +/* + * "Hence that general is skilful in attack whose opponent does not know what + * to defend; and he is skilful in defense whose opponent does not know what + * to attack." + * + * ~ Sun Tzu + */ + +(function () { + describe('LTI', function () { + describe('constructor', function () { + describe('before settings were filled in', function () { + var element, errorMessage, frame; + + // This function will be executed before each of the it() specs + // in this suite. + beforeEach(function () { + loadFixtures('lti.html'); + + element = $('#lti_id'); + errorMessage = element.find('.error_message'); + form = element.find('.ltiLaunchForm'); + frame = element.find('.ltiLaunchFrame'); + + spyOnEvent(form, 'submit'); + + LTI(element); + }); + + it( + 'when URL setting is not filled form is not submited', + function () { + + expect('submit').not.toHaveBeenTriggeredOn(form); + }); + }); + + describe('After the settings were filled in', function () { + var element, errorMessage, frame; + + // This function will be executed before each of the it() specs + // in this suite. + beforeEach(function () { + loadFixtures('lti.html'); + + element = $('#lti_id'); + errorMessage = element.find('.error_message'); + form = element.find('.ltiLaunchForm'); + frame = element.find('.ltiLaunchFrame'); + + spyOnEvent(form, 'submit'); + + // The user "fills in" the necessary settings, and the + // form will get an action URL. + form.attr('action', 'http://www.example.com/test_submit'); + + LTI(element); + }); + + it('when URL setting is filled form is submited', function () { + expect('submit').toHaveBeenTriggeredOn(form); + }); + }); + }); + }); +}()); diff --git a/common/lib/xmodule/xmodule/js/spec/sequence/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/sequence/display_spec.coffee index 1944f7dc74..7d7b51c416 100644 --- a/common/lib/xmodule/xmodule/js/spec/sequence/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/sequence/display_spec.coffee @@ -134,6 +134,7 @@ xdescribe 'Sequence', -> beforeEach -> jasmine.stubRequests() @sequence = new Sequence '1', 'sequence_1', @items, 'sequence', 2 + $.scrollTo 150 $('.sequence-nav-buttons .next a').click() it 'log the next sequence event', -> @@ -142,10 +143,14 @@ xdescribe 'Sequence', -> it 'call render on the next sequence', -> expect($('#seq_content').html()).toEqual 'Sample Problem' + it 'scrolls to the top of the page', -> + expect($('body').scrollTop()).toBe 0 + describe 'previous', -> beforeEach -> jasmine.stubRequests() @sequence = new Sequence '1', 'sequence_1', @items, 'sequence', 2 + $.scrollTo 150 $('.sequence-nav-buttons .prev a').click() it 'log the previous sequence event', -> @@ -154,6 +159,9 @@ xdescribe 'Sequence', -> it 'call render on the previous sequence', -> expect($('#seq_content').html()).toEqual 'Video 1' + it 'scrolls to the top of the page', -> + expect($('body').scrollTop()).toBe 0 + describe 'link_for', -> it 'return a link for specific position', -> sequence = new Sequence '1', 'sequence_1', @items, 2 diff --git a/cms/static/coffee/spec/tabs/edit.coffee b/common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee similarity index 97% rename from cms/static/coffee/spec/tabs/edit.coffee rename to common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee index 734e398c74..d5502b7a29 100644 --- a/cms/static/coffee/spec/tabs/edit.coffee +++ b/common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee @@ -65,7 +65,7 @@ describe "TabsEditingDescriptor", -> describe "editor/settings header", -> it "is hidden", -> - expect(@descriptor.element.find(".component-edit-header").css('display')).toEqual('none') + expect(@descriptor.element.closest(".component-editor").find(".component-edit-header")).toBeHidden() describe "TabsEditingDescriptor special save cases", -> beforeEach -> diff --git a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js index 41cf607473..3444f5389f 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js @@ -55,46 +55,6 @@ expect(this.state.speed).toEqual('0.75'); }); }); - - describe('Check Youtube link existence', function () { - var statusList = { - error: 'html5', - timeout: 'html5', - abort: 'html5', - parsererror: 'html5', - success: 'youtube', - notmodified: 'youtube' - }; - - function stubDeffered(data, status) { - return { - always: function(callback) { - callback.call(window, data, status); - } - } - } - - function checkPlayer(videoType, data, status) { - this.state = new window.Video('#example'); - spyOn(this.state , 'getVideoMetadata') - .andReturn(stubDeffered(data, status)); - this.state.initialize('#example'); - - expect(this.state.videoType).toEqual(videoType); - } - - it('if video id is incorrect', function () { - checkPlayer('html5', { error: {} }, 'success'); - }); - - $.each(statusList, function(status, mode){ - it('Status:' + status + ', mode:' + mode, function () { - checkPlayer(mode, {}, status); - }); - }); - - }); - }); describe('HTML5', function () { @@ -154,10 +114,22 @@ it('parse Html5 sources', function () { var html5Sources = { - mp4: 'xmodule/include/fixtures/test.mp4', - webm: 'xmodule/include/fixtures/test.webm', - ogg: 'xmodule/include/fixtures/test.ogv' - }; + mp4: null, + webm: null, + ogg: null + }, v = document.createElement('video'); + + if (!!(v.canPlayType && v.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, ''))) { + html5Sources['webm'] = 'xmodule/include/fixtures/test.webm'; + } + + if (!!(v.canPlayType && v.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, ''))) { + html5Sources['mp4'] = 'xmodule/include/fixtures/test.mp4'; + } + + if (!!(v.canPlayType && v.canPlayType('video/ogg; codecs="theora"').replace(/no/, ''))) { + html5Sources['ogg'] = 'xmodule/include/fixtures/test.ogv'; + } expect(state.html5Sources).toEqual(html5Sources); }); @@ -214,6 +186,46 @@ }); }); + describe('multiple YT on page', function () { + var state1, state2, state3; + + beforeEach(function () { + loadFixtures('video_yt_multiple.html'); + + spyOn($, 'ajaxWithPrefix'); + + $.ajax.calls.length = 0; + $.ajaxWithPrefix.calls.length = 0; + + // Because several other tests have run, the variable + // that stores the value of the first ajax request must be + // cleared so that we test a pristine state of the video + // module. + Video.clearYoutubeXhr(); + + state1 = new Video('#example1'); + state2 = new Video('#example2'); + state3 = new Video('#example3'); + }); + + it('check for YT availability is performed only once', function () { + var numAjaxCalls = 0; + + // Total ajax calls made. + numAjaxCalls = $.ajax.calls.length; + + // Subtract ajax calls to get captions. + numAjaxCalls -= $.ajaxWithPrefix.calls.length; + + // Subtract ajax calls to get metadata for each video. + numAjaxCalls -= 3; + + // This should leave just one call. It was made to check + // for YT availability. + expect(numAjaxCalls).toBe(1); + }); + }); + describe('setSpeed', function () { describe('YT', function () { beforeEach(function () { diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 9458b483da..0f729da62d 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -53,7 +53,8 @@ expect($.ajaxWithPrefix).toHaveBeenCalledWith({ url: videoCaption.captionURL(), notifyOnError: false, - success: jasmine.any(Function) + success: jasmine.any(Function), + error: jasmine.any(Function), }); }); }); @@ -92,6 +93,7 @@ $('.subtitles li[data-index]').each(function(index, link) { expect($(link)).toHaveData('index', index); expect($(link)).toHaveData('start', captionsData.start[index]); + expect($(link)).toHaveAttr('tabindex', 0); expect($(link)).toHaveText(captionsData.text[index]); }); }); @@ -103,7 +105,13 @@ it('bind all the caption link', function() { $('.subtitles li[data-index]').each(function(index, link) { - expect($(link)).toHandleWith('click', videoCaption.seekPlayer); + expect($(link)).toHandleWith('mouseover', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mouseout', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mousedown', videoCaption.captionMouseDown); + expect($(link)).toHandleWith('click', videoCaption.captionClick); + expect($(link)).toHandleWith('focus', videoCaption.captionFocus); + expect($(link)).toHandleWith('blur', videoCaption.captionBlur); + expect($(link)).toHandleWith('keydown', videoCaption.captionKeyDown); }); }); @@ -126,15 +134,46 @@ expect(videoCaption.rendered).toBeFalsy(); }); }); + + describe('when no captions file was specified', function () { + beforeEach(function () { + loadFixtures('video_all.html'); + + // Unspecify the captions file. + $('#example').find('#video_id').data('sub', ''); + + state = new Video('#example'); + videoCaption = state.videoCaption; + }); + + it('captions panel is not shown', function () { + expect(videoCaption.hideSubtitlesEl).toBeHidden(); + }); + }); }); describe('mouse movement', function() { + // We will store default window.setTimeout() function here. + var oldSetTimeout = null; + beforeEach(function() { + // Store original window.setTimeout() function. If we do not do this, then + // all other tests that rely on code which uses window.setTimeout() + // function might (and probably will) fail. + oldSetTimeout = window.setTimeout; + // Redefine window.setTimeout() function as a spy. window.setTimeout = jasmine.createSpy().andCallFake(function(callback, timeout) { return 5; }) window.setTimeout.andReturn(100); spyOn(window, 'clearTimeout'); }); + afterEach(function () { + // Reset the default window.setTimeout() function. If we do not do this, + // then all other tests that rely on code which uses window.setTimeout() + // function might (and probably will) fail. + window.setTimeout = oldSetTimeout; + }); + describe('when cursor is outside of the caption box', function() { beforeEach(function() { $(window).trigger(jQuery.Event('mousemove')); @@ -222,7 +261,7 @@ describe('search', function() { it('return a correct caption index', function() { - expect(videoCaption.search(0)).toEqual(0); + expect(videoCaption.search(0)).toEqual(-1); expect(videoCaption.search(3120)).toEqual(1); expect(videoCaption.search(6270)).toEqual(2); expect(videoCaption.search(8490)).toEqual(2); @@ -246,6 +285,7 @@ $('.subtitles li[data-index]').each(function(index, link) { expect($(link)).toHaveData('index', index); expect($(link)).toHaveData('start', captionsData.start[index]); + expect($(link)).toHaveAttr('tabindex', 0); expect($(link)).toHaveText(captionsData.text[index]); }); }); @@ -257,7 +297,13 @@ it('bind all the caption link', function() { $('.subtitles li[data-index]').each(function(index, link) { - expect($(link)).toHandleWith('click', videoCaption.seekPlayer); + expect($(link)).toHandleWith('mouseover', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mouseout', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mousedown', videoCaption.captionMouseDown); + expect($(link)).toHandleWith('click', videoCaption.captionClick); + expect($(link)).toHandleWith('focus', videoCaption.captionFocus); + expect($(link)).toHandleWith('blur', videoCaption.captionBlur); + expect($(link)).toHandleWith('keydown', videoCaption.captionKeyDown); }); }); @@ -446,7 +492,7 @@ }); // Temporarily disabled due to intermittent failures - // Fails with error: "InvalidStateError: An attempt was made to + // Fails with error: "InvalidStateError: An attempt was made to // use an object that is not, or is no longer, usable // Expected 0 to equal 14.91." // on Firefox @@ -526,6 +572,102 @@ }); }); }); + + describe('caption accessibility', function() { + beforeEach(function() { + initialize(); + }); + + describe('when getting focus through TAB key', function() { + beforeEach(function() { + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + }); + + it('shows an outline around the caption', function() { + expect($('.subtitles li[data-index=0]')).toHaveClass('focused'); + }); + + it('has automatic scrolling disabled', function() { + expect(videoCaption.autoScrolling).toBe(false); + }); + }); + + describe('when loosing focus through TAB key', function() { + beforeEach(function() { + $('.subtitles li[data-index=0]').trigger(jQuery.Event('blur')); + }); + + it('does not show an outline around the caption', function() { + expect($('.subtitles li[data-index=0]')).not.toHaveClass('focused'); + }); + + it('has automatic scrolling enabled', function() { + expect(videoCaption.autoScrolling).toBe(true); + }); + }); + + describe('when same caption gets the focus through mouse after having focus through TAB key', function() { + beforeEach(function() { + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + $('.subtitles li[data-index=0]').trigger(jQuery.Event('mousedown')); + }); + + it('does not show an outline around it', function() { + expect($('.subtitles li[data-index=0]')).not.toHaveClass('focused'); + }); + + it('has automatic scrolling enabled', function() { + expect(videoCaption.autoScrolling).toBe(true); + }); + }); + + describe('when a second caption gets focus through mouse after first had focus through TAB key', function() { + beforeEach(function() { + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + $('.subtitles li[data-index=0]').trigger(jQuery.Event('blur')); + videoCaption.isMouseFocus = true; + $('.subtitles li[data-index=1]').trigger(jQuery.Event('mousedown')); + }); + + it('does not show an outline around the first', function() { + expect($('.subtitles li[data-index=0]')).not.toHaveClass('focused'); + }); + + it('does not show an outline around the second', function() { + expect($('.subtitles li[data-index=1]')).not.toHaveClass('focused'); + }); + + it('has automatic scrolling enabled', function() { + expect(videoCaption.autoScrolling).toBe(true); + }); + }); + + xdescribe('when enter key is pressed on a caption', function() { + beforeEach(function() { + var e; + spyOn(videoCaption, 'seekPlayer').andCallThrough(); + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + e = jQuery.Event('keydown'); + e.which = 13; // ENTER key + $('.subtitles li[data-index=0]').trigger(e); + }); + + // Temporarily disabled due to intermittent failures + // Fails with error: "InvalidStateError: InvalidStateError: An attempt + // was made to use an object that is not, or is no longer, usable" + xit('shows an outline around it', function() { + expect($('.subtitles li[data-index=0]')).toHaveClass('focused'); + }); + + xit('calls seekPlayer', function() { + expect(videoCaption.seekPlayer).toHaveBeenCalled(); + }); + }); + }); }); }).call(this); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_focus_grabber_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_focus_grabber_spec.js new file mode 100644 index 0000000000..6f33d978fb --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/video/video_focus_grabber_spec.js @@ -0,0 +1,97 @@ +(function () { + describe('Video FocusGrabber', function () { + var state; + + beforeEach(function () { + // https://github.com/pivotal/jasmine/issues/184 + // + // This is a known issue. jQuery animations depend on setTimeout + // and the jasmine mock clock stubs that function. You need to turn + // off jQuery animations ($.fx.off()) in a global beforeEach. + // + // I think this is a good pattern - you don't want animations + // messing with your tests. If you need to test with animations on + // I suggest you add incremental browser-based testing to your + // stack. + jQuery.fx.off = true; + + loadFixtures('video_html5.html'); + state = new Video('#example'); + + spyOnEvent(state.el, 'mousemove'); + spyOn(state.focusGrabber, 'disableFocusGrabber').andCallThrough(); + spyOn(state.focusGrabber, 'enableFocusGrabber').andCallThrough(); + }); + + afterEach(function () { + // Turn jQuery animations back on. + jQuery.fx.off = true; + }); + + it( + 'check existence of focus grabber elements and their position', + function () { + + var firstFGEl = state.el.find('.focus_grabber.first'), + lastFGEl = state.el.find('.focus_grabber.last'), + tcWrapperEl = state.el.find('.tc-wrapper'); + + // Existence check. + expect(firstFGEl.length).toBe(1); + expect(lastFGEl.length).toBe(1); + + // Position check. + expect(firstFGEl.index() + 1).toBe(tcWrapperEl.index()); + expect(lastFGEl.index() - 1).toBe(tcWrapperEl.index()); + }); + + it('from the start, focus grabbers are disabled', function () { + expect(state.focusGrabber.elFirst.attr('tabindex')).toBe(-1); + expect(state.focusGrabber.elLast.attr('tabindex')).toBe(-1); + }); + + it( + 'when first focus grabber is focused "mousemove" event is ' + + 'triggered, grabbers are disabled', + function () { + + state.focusGrabber.elFirst.triggerHandler('focus'); + + expect('mousemove').toHaveBeenTriggeredOn(state.el); + expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled(); + }); + + it( + 'when last focus grabber is focused "mousemove" event is ' + + 'triggered, grabbers are disabled', + function () { + + state.focusGrabber.elLast.triggerHandler('focus'); + + expect('mousemove').toHaveBeenTriggeredOn(state.el); + expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled(); + }); + + it('after controls hide focus grabbers are enabled', function () { + runs(function () { + // Captions should not be "sticky" for the autohide mechanism + // to work. + state.videoCaption.hideCaptions(true); + + // Make sure that the controls are visible. After this event + // is triggered a count down is started to autohide captions. + state.el.triggerHandler('mousemove'); + }); + + // Wait for the autohide to happen. We make it +100ms to make sure + // that there is clearly no race conditions for our expect below. + waits(state.videoControl.fadeOutTimeout + 100); + + runs(function () { + expect( + state.focusGrabber.enableFocusGrabber + ).toHaveBeenCalled(); + }); + }); + }); +}).call(this); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 7beb2957da..a7df088d67 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -547,7 +547,7 @@ }); it('replace the full screen button tooltip', function() { - expect($('.add-fullscreen')).toHaveAttr('title', 'Exit fullscreen'); + expect($('.add-fullscreen')).toHaveAttr('title', 'Exit full browser'); }); it('add the video-fullscreen class', function() { @@ -573,7 +573,7 @@ }); it('replace the full screen button tooltip', function() { - expect($('.add-fullscreen')).toHaveAttr('title', 'Fullscreen'); + expect($('.add-fullscreen')).toHaveAttr('title', 'Fill browser'); }); it('remove the video-fullscreen class', function() { diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js index 09a74e8e25..30a873f340 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js @@ -145,7 +145,18 @@ }); describe('onStop', function() { + // We will store default window.setTimeout() function here. + var oldSetTimeout = null; + beforeEach(function() { + // Store original window.setTimeout() function. If we do not do this, then + // all other tests that rely on code which uses window.setTimeout() + // function might (and probably will) fail. + oldSetTimeout = window.setTimeout; + // Redefine window.setTimeout() function as a spy. + window.setTimeout = jasmine.createSpy().andCallFake(function(callback, timeout) { return 5; }) + window.setTimeout.andReturn(100); + initialize(); spyOn(videoPlayer, 'onSlideSeek').andCallThrough(); videoProgressSlider.onStop({}, { @@ -153,6 +164,13 @@ }); }); + afterEach(function () { + // Reset the default window.setTimeout() function. If we do not do this, + // then all other tests that rely on code which uses window.setTimeout() + // function might (and probably will) fail. + window.setTimeout = oldSetTimeout; + }); + it('freeze the slider', function() { expect(videoProgressSlider.frozen).toBeTruthy(); }); @@ -162,7 +180,9 @@ expect(videoPlayer.currentTime).toEqual(20); }); - it('set timeout to unfreeze the slider', function() { + // Temporarily disabled due to intermittent failures + // Fails with error: " Expected true to be falsy." + xit('set timeout to unfreeze the slider', function() { expect(window.setTimeout).toHaveBeenCalledWith(jasmine.any(Function), 200); window.setTimeout.mostRecentCall.args[0](); expect(videoProgressSlider.frozen).toBeFalsy(); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js index 627438c736..eb2f19aa60 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js @@ -24,7 +24,8 @@ initialize(); }); - it('render the quality control', function() { + // Disabled when ARIA markup was added to the anchor + xit('render the quality control', function() { expect(videoControl.secondaryControlsEl.html()).toContain(""); }); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js index f8f2b63123..2684fb738e 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js @@ -82,6 +82,38 @@ $('.speeds').mouseenter().click(); expect($('.speeds')).not.toHaveClass('open'); }); + // Tabbing depends on the following order: + // 1. Play anchor + // 2. Speed anchor + // 3. A number of speed entry anchors + // 4. Volume anchor + // If an other focusable element is inserted or if the order is changed, things will + // malfunction as a flag, state.previousFocus, is set in the 1,3,4 elements and is + // used to determine the behavior of foucus() and blur() for the speed anchor. + it('checks for a certain order in focusable elements in video controls', function() { + var playIndex, speedIndex, firstSpeedEntry, lastSpeedEntry, volumeIndex, foundFirst = false; + $('.video-controls').find('a, :focusable').each(function(index) { + if ($(this).hasClass('video_control')) { + playIndex = index; + } + else if ($(this).parent().hasClass('speeds')) { + speedIndex = index; + } + else if ($(this).hasClass('speed_link')) { + if (!foundFirst) { + firstSpeedEntry = index; + foundFirst = true; + } + lastSpeedEntry = index; + } + else if ($(this).parent().hasClass('volume')) { + volumeIndex = index; + } + }); + expect(playIndex+1).toEqual(speedIndex); + expect(speedIndex+1).toEqual(firstSpeedEntry); + expect(lastSpeedEntry+1).toEqual(volumeIndex); + }); }); }); @@ -106,6 +138,26 @@ expect(videoSpeedControl.currentSpeed).toEqual(0.75); }); }); + + describe('make sure the speed control gets the focus afterwards', function() { + var anchor; + beforeEach(function() { + initialize(); + anchor= $('.speeds > a').first(); + videoSpeedControl.setSpeed(1.0); + spyOnEvent(anchor, 'focus'); + }); + + it('when the speed is the same', function() { + $('li[data-speed="1.0"] a').click(); + expect('focus').toHaveBeenTriggeredOn(anchor); + }); + + it('when the speed is not the same', function() { + $('li[data-speed="0.75"] a').click(); + expect('focus').toHaveBeenTriggeredOn(anchor); + }); + }); }); describe('onSpeedChange', function() { diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 61df101d08..7786daecf7 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -25,6 +25,8 @@ class @Problem @$('section.action button.show').click @show @$('section.action input.save').click @save + @bindResetCorrectness() + # Collapsibles Collapsible.setCollapsibles(@el) @@ -370,6 +372,56 @@ class @Problem element.CodeMirror.save() if element.CodeMirror.save @answers = @inputs.serialize() + bindResetCorrectness: -> + # Loop through all input types + # Bind the reset functions at that scope. + $inputtypes = @el.find(".capa_inputtype").add(@el.find(".inputtype")) + $inputtypes.each (index, inputtype) => + classes = $(inputtype).attr('class').split(' ') + for cls in classes + bindMethod = @bindResetCorrectnessByInputtype[cls] + if bindMethod? + bindMethod(inputtype) + + # Find all places where each input type displays its correct-ness + # Replace them with their original state--'unanswered'. + bindResetCorrectnessByInputtype: + # These are run at the scope of the capa inputtype + # They should set handlers on each to reset the whole. + formulaequationinput: (element) -> + $(element).find('input').on 'input', -> + $p = $(element).find('p.status') + $p.text gettext("unanswered") + $p.parent().removeClass().addClass "unanswered" + + choicegroup: (element) -> + $element = $(element) + id = ($element.attr('id').match /^inputtype_(.*)$/)[1] + $element.find('input').on 'change', -> + $status = $("#status_#{id}") + if $status[0] # We found a status icon. + $status.removeClass().addClass "unanswered" + $status.empty().css 'display', 'inline-block' + else + # Recreate the unanswered dot on left. + $("", {"class": "unanswered", "style": "display: inline-block;", "id": "status_#{id}"}) + + $element.find("label").removeClass() + + 'option-input': (element) -> + $select = $(element).find('select') + id = ($select.attr('id').match /^input_(.*)$/)[1] + $select.on 'change', -> + $status = $("#status_#{id}") + .removeClass().addClass("unanswered") + .find('span').text(gettext('Status: unsubmitted')) + + textline: (element) -> + $(element).find('input').on 'input', -> + $p = $(element).find('p.status') + $p.text "unanswered" + $p.parent().removeClass().addClass "unanswered" + inputtypeSetupMethods: 'text-input-dynamath': (element) => diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index bd399e8c88..030d93e9b5 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -119,6 +119,7 @@ class @CombinedOpenEnded next_rubric_sel: '.rubric-next-button' previous_rubric_sel: '.rubric-previous-button' oe_alert_sel: '.open-ended-alert' + save_button_sel: '.save-button' constructor: (el) -> @el=el @@ -153,9 +154,11 @@ class @CombinedOpenEnded @rub = new Rubric(@coe) @rub.initialize(@location) @is_ctrl = false + #Setup reset @reset_button = @$(@reset_button_sel) - @reset_button.click @reset + @reset_button.click @confirm_reset + #Setup next problem @next_problem_button = @$(@next_step_sel) @next_problem_button.click @next_problem @@ -181,6 +184,7 @@ class @CombinedOpenEnded @hint_wrapper = @$(@oe).find(@hint_wrapper_sel) @message_wrapper = @$(@oe).find(@message_wrapper_sel) @submit_button = @$(@oe).find(@submit_button_sel) + @save_button = @$(@oe).find(@save_button_sel) @child_state = @oe.data('state') @child_type = @oe.data('child-type') if @child_type=="openended" @@ -268,6 +272,8 @@ class @CombinedOpenEnded # rebind to the appropriate function for the current state @submit_button.unbind('click') @submit_button.show() + @save_button.unbind('click') + @save_button.hide() @reset_button.hide() @hide_file_upload() @next_problem_button.hide() @@ -291,8 +297,10 @@ class @CombinedOpenEnded else if @child_state == 'initial' @answer_area.attr("disabled", false) @submit_button.prop('value', 'Submit') - @submit_button.click @save_answer + @submit_button.click @confirm_save_answer @setup_file_upload() + @save_button.click @store_answer + @save_button.show() else if @child_state == 'assessing' @answer_area.attr("disabled", true) @replace_text_inputs() @@ -304,7 +312,7 @@ class @CombinedOpenEnded @submit_button.hide() @queueing() @grader_status = @$(@grader_status_sel) - @grader_status.html("Your response has been submitted. Please check back later for your grade. ") + @grader_status.html("Your response has been submitted. Please check back later for your grade.") else if @child_type == "selfassessment" @setup_score_selection() else if @child_state == 'post_assessment' @@ -332,13 +340,26 @@ class @CombinedOpenEnded else @reset_button.show() - find_assessment_elements: -> @assessment = @$('input[name="grade-selection"]') find_hint_elements: -> @hint_area = @$('textarea.post_assessment') + store_answer: (event) => + event.preventDefault() + if @child_state == 'initial' + data = {'student_answer' : @answer_area.val()} + @save_button.attr("disabled",true) + $.postWithPrefix "#{@ajax_url}/store_answer", data, (response) => + if response.success + @gentle_alert("Answer saved, but not yet submitted.") + else + @errors_area.html(response.error) + @save_button.attr("disabled",false) + else + @errors_area.html(@out_of_sync_message) + replace_answer: (response) => if response.success @rubric_wrapper.html(response.rubric_html) @@ -351,32 +372,41 @@ class @CombinedOpenEnded answer_area_div = @$(@answer_area_div_sel) answer_area_div.html(response.student_response) else - @can_upload_files = pre_can_upload_files + @submit_button.show() + @submit_button.attr('disabled', false) @gentle_alert response.error + confirm_save_answer: (event) => + @save_answer(event) if confirm('Please confirm that you wish to submit your work. You will not be able to make any changes after submitting.') + save_answer: (event) => + @$el.find(@oe_alert_sel).remove() @submit_button.attr("disabled",true) @submit_button.hide() event.preventDefault() @answer_area.attr("disabled", true) max_filesize = 2*1000*1000 #2MB - pre_can_upload_files = @can_upload_files if @child_state == 'initial' files = "" + valid_files_attached = false if @can_upload_files == true files = @$(@file_upload_box_sel)[0].files[0] if files != undefined + valid_files_attached = true if files.size > max_filesize - @can_upload_files = false files = "" - else - @can_upload_files = false + # Don't submit the file in the case of it being too large, deal with the error locally. + @submit_button.show() + @submit_button.attr('disabled', false) + @gentle_alert "You are trying to upload a file that is too large for our system. Please choose a file under 2MB or paste a link to it into the answer box." + return fd = new FormData() fd.append('student_answer', @answer_area.val()) fd.append('student_file', files) - fd.append('can_upload_files', @can_upload_files) + fd.append('valid_files_attached', valid_files_attached) + that=this settings = type: "POST" data: fd @@ -453,6 +483,9 @@ class @CombinedOpenEnded else @errors_area.html(@out_of_sync_message) + confirm_reset: (event) => + @reset(event) if confirm('Are you sure you want to remove your previous response to this question?') + reset: (event) => event.preventDefault() if @child_state == 'done' or @allow_reset=="True" @@ -548,12 +581,12 @@ class @CombinedOpenEnded collapse_question: (event) => @prompt_container.slideToggle() @prompt_container.toggleClass('open') - if @question_header.text() == "Hide Prompt" - new_text = "Show Prompt" + if @question_header.text() == "Hide Question" + new_text = "Show Question" Logger.log 'oe_hide_question', {location: @location} else Logger.log 'oe_show_question', {location: @location} - new_text = "Hide Prompt" + new_text = "Hide Question" @question_header.text(new_text) return false @@ -593,13 +626,13 @@ class @CombinedOpenEnded if @prompt_container.is(":hidden")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("Hide Prompt") + @question_header.text("Hide Question") prompt_hide: () => if @prompt_container.is(":visible")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("Show Prompt") + @question_header.text("Show Question") log_feedback_click: (event) -> link_text = @$(event.target).html() diff --git a/common/lib/xmodule/xmodule/js/src/html/edit.coffee b/common/lib/xmodule/xmodule/js/src/html/edit.coffee index 4f29ffa0f2..5ba7de874a 100644 --- a/common/lib/xmodule/xmodule/js/src/html/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/html/edit.coffee @@ -18,19 +18,19 @@ class @HTMLEditingDescriptor # This is a workaround for the fact that tinyMCE's baseURL property is not getting correctly set on AWS # instances (like sandbox). It is not necessary to explicitly set baseURL when running locally. - tinyMCE.baseURL = '/static/js/vendor/tiny_mce' + tinyMCE.baseURL = "#{baseUrl}/js/vendor/tiny_mce" @tiny_mce_textarea = $(".tiny-mce", @element).tinymce({ - script_url : '/static/js/vendor/tiny_mce/tiny_mce.js', + script_url : "#{baseUrl}/js/vendor/tiny_mce/tiny_mce.js", theme : "advanced", skin: 'studio', schema: "html5", # Necessary to preserve relative URLs to our images. convert_urls : false, # TODO: we should share this CSS with studio (and LMS) - content_css : "/static/css/tiny-mce.css", + content_css : "#{baseUrl}/css/tiny-mce.css", # The default popup_css path uses an absolute path referencing page in which tinyMCE is being hosted. # Supply the correct relative path instead. - popup_css: '/static/js/vendor/tiny_mce/themes/advanced/skins/default/dialog.css', + popup_css: "#{baseUrl}/js/vendor/tiny_mce/themes/advanced/skins/default/dialog.css", formats : { # Disable h4, h5, and h6 styles as we don't have CSS for them. h4: {}, @@ -67,7 +67,7 @@ class @HTMLEditingDescriptor setupTinyMCE: (ed) => ed.addButton('wrapAsCode', { title : 'Code', - image : '/static/images/ico-tinymce-code.png', + image : "#{baseUrl}/images/ico-tinymce-code.png", onclick : () -> ed.formatter.toggle('code') # Without this, the dirty flag does not get set unless the user also types in text. @@ -101,32 +101,25 @@ class @HTMLEditingDescriptor # Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing. showAdvancedEditor: (visualEditor) -> if visualEditor.isDirty() - content = @rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/') + content = rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/') @advanced_editor.setValue(content) @advanced_editor.setCursor(0) @advanced_editor.refresh() @advanced_editor.focus() @showingVisualEditor = false - rewriteStaticLinks: (content, from, to) -> - if from == null || to == null - return content - - regex = new RegExp(from, 'g') - return content.replace(regex, to) - # Show the Visual (tinyMCE) Editor. Pulled out as a helper method for unit testing. showVisualEditor: (visualEditor) -> # In order for isDirty() to return true ONLY if edits have been made after setting the text, # both the startContent must be sync'ed up and the dirty flag set to false. - content = @rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url) + content = rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url) visualEditor.setContent(content) visualEditor.startContent = content @focusVisualEditor(visualEditor) @showingVisualEditor = true initInstanceCallback: (visualEditor) => - visualEditor.setContent(@rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url)) + visualEditor.setContent(rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url)) @focusVisualEditor(visualEditor) focusVisualEditor: (visualEditor) => @@ -150,5 +143,5 @@ class @HTMLEditingDescriptor text = @advanced_editor.getValue() visualEditor = @getVisualEditor() if @showingVisualEditor and visualEditor.isDirty() - text = @rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/') + text = rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/') data: text diff --git a/common/lib/xmodule/xmodule/js/src/lti/lti.js b/common/lib/xmodule/xmodule/js/src/lti/lti.js new file mode 100644 index 0000000000..7d5b183f21 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/lti/lti.js @@ -0,0 +1,26 @@ +window.LTI = (function () { + // Function initialize(element) + // + // Initialize the LTI iframe. + function initialize(element) { + var form; + + // In cms (Studio) the element is already a jQuery object. In lms it is + // a DOM object. + // + // To make sure that there is no error, we pass it through the $() + // function. This will make it a jQuery object if it isn't already so. + element = $(element); + + form = element.find('.ltiLaunchForm'); + + // If the Form's action attribute is set (i.e. we can perform a normal + // submit), then we submit the form and make the frame shown. + if (form.attr('action') && form.attr('action') !== 'http://www.example.com') { + form.submit(); + element.find('.lti').addClass('rendered'); + } + } + + return initialize; +}()); diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/ice.min.js b/common/lib/xmodule/xmodule/js/src/peergrading/ice.min.js new file mode 100644 index 0000000000..16a08c2c6f --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/peergrading/ice.min.js @@ -0,0 +1,9 @@ +// +// ice - Master +// The MIT License +// Copyright (c) 2012 The New York Times, CMS Group, Matthew DeLambo +// +window.rangy=function(){function e(e,t){var n=typeof e[t];return n==u||!(n!=d||!e[t])||"unknown"==n}function t(e,t){return!(typeof e[t]!=d||!e[t])}function n(e,t){return typeof e[t]!=h}function i(e){return function(t,n){for(var i=n.length;i--;)if(!e(t,n[i]))return!1;return!0}}function r(e){return e&&C(e,p)&&N(e,m)}function o(e){window.alert("Rangy not supported in your browser. Reason: "+e),E.initialized=!0,E.supported=!1}function s(e){var t="Rangy warning: "+e;E.config.alertOnWarn?window.alert(t):typeof window.console!=h&&typeof window.console.log!=h&&window.console.log(t)}function a(){if(!E.initialized){var n,i=!1,s=!1;e(document,"createRange")&&(n=document.createRange(),C(n,g)&&N(n,f)&&(i=!0),n.detach());var a=t(document,"body")?document.body:document.getElementsByTagName("body")[0];a&&e(a,"createTextRange")&&(n=a.createTextRange(),r(n)&&(s=!0)),i||s||o("Neither Range nor TextRange are implemented"),E.initialized=!0,E.features={implementsDomRange:i,implementsTextRange:s};for(var c=_.concat(y),l=0,d=c.length;d>l;++l)try{c[l](E)}catch(u){t(window,"console")&&e(window.console,"log")&&window.console.log("Init listener threw an exception. Continuing.",u)}}}function c(e){e=e||window,a();for(var t=0,n=T.length;n>t;++t)T[t](e)}function l(e){this.name=e,this.initialized=!1,this.supported=!1}var d="object",u="function",h="undefined",f=["startContainer","startOffset","endContainer","endOffset","collapsed","commonAncestorContainer","START_TO_START","START_TO_END","END_TO_START","END_TO_END"],g=["setStart","setStartBefore","setStartAfter","setEnd","setEndBefore","setEndAfter","collapse","selectNode","selectNodeContents","compareBoundaryPoints","deleteContents","extractContents","cloneContents","insertNode","surroundContents","cloneRange","toString","detach"],m=["boundingHeight","boundingLeft","boundingTop","boundingWidth","htmlText","text"],p=["collapse","compareEndPoints","duplicate","getBookmark","moveToBookmark","moveToElementText","parentElement","pasteHTML","select","setEndPoint","getBoundingClientRect"],C=i(e),v=i(t),N=i(n),E={version:"1.2.3",initialized:!1,supported:!0,util:{isHostMethod:e,isHostObject:t,isHostProperty:n,areHostMethods:C,areHostObjects:v,areHostProperties:N,isTextRange:r},features:{},modules:{},config:{alertOnWarn:!1,preferTextRange:!1}};E.fail=o,E.warn=s,{}.hasOwnProperty?E.util.extend=function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])}:o("hasOwnProperty not supported");var y=[],_=[];E.init=a,E.addInitListener=function(e){E.initialized?e(E):y.push(e)};var T=[];E.addCreateMissingNativeApiListener=function(e){T.push(e)},E.createMissingNativeApi=c,l.prototype.fail=function(e){throw this.initialized=!0,this.supported=!1,Error("Module '"+this.name+"' failed to load: "+e)},l.prototype.warn=function(e){E.warn("Module "+this.name+": "+e)},l.prototype.createError=function(e){return Error("Error in Rangy "+this.name+" module: "+e)},E.createModule=function(e,t){var n=new l(e);E.modules[e]=n,_.push(function(e){t(e,n),n.initialized=!0,n.supported=!0})},E.requireModules=function(e){for(var t,n,i=0,r=e.length;r>i;++i){if(n=e[i],t=E.modules[n],!(t&&t instanceof l))throw Error("Module '"+n+"' not found");if(!t.supported)throw Error("Module '"+n+"' not supported")}};var S=!1,R=function(){S||(S=!0,E.initialized||a())};return typeof window==h?(o("No window found"),void 0):typeof document==h?(o("No document found"),void 0):(e(document,"addEventListener")&&document.addEventListener("DOMContentLoaded",R,!1),e(window,"addEventListener")?window.addEventListener("load",R,!1):e(window,"attachEvent")?window.attachEvent("onload",R):o("Window does not have required addEventListener or attachEvent method"),E)}(),rangy.createModule("DomUtil",function(e,t){function n(e){var t;return typeof e.namespaceURI==R||null===(t=e.namespaceURI)||"http://www.w3.org/1999/xhtml"==t}function i(e){var t=e.parentNode;return 1==t.nodeType?t:null}function r(e){for(var t=0;e=e.previousSibling;)t++;return t}function o(e){var t;return l(e)?e.length:(t=e.childNodes)?t.length:0}function s(e,t){var n,i=[];for(n=e;n;n=n.parentNode)i.push(n);for(n=t;n;n=n.parentNode)if(D(i,n))return n;return null}function a(e,t,n){for(var i=n?t:t.parentNode;i;){if(i===e)return!0;i=i.parentNode}return!1}function c(e,t,n){for(var i,r=n?e:e.parentNode;r;){if(i=r.parentNode,i===t)return r;r=i}return null}function l(e){var t=e.nodeType;return 3==t||4==t||8==t}function d(e,t){var n=t.nextSibling,i=t.parentNode;return n?i.insertBefore(e,n):i.appendChild(e),e}function u(e,t){var n=e.cloneNode(!1);return n.deleteData(0,t),e.deleteData(t,e.length-t),d(n,e),n}function h(e){if(9==e.nodeType)return e;if(typeof e.ownerDocument!=R)return e.ownerDocument;if(typeof e.document!=R)return e.document;if(e.parentNode)return h(e.parentNode);throw Error("getDocument: no document found for node")}function f(e){var t=h(e);if(typeof t.defaultView!=R)return t.defaultView;if(typeof t.parentWindow!=R)return t.parentWindow;throw Error("Cannot get a window object for node")}function g(e){if(typeof e.contentDocument!=R)return e.contentDocument;if(typeof e.contentWindow!=R)return e.contentWindow.document;throw Error("getIframeWindow: No Document object found for iframe element")}function m(e){if(typeof e.contentWindow!=R)return e.contentWindow;if(typeof e.contentDocument!=R)return e.contentDocument.defaultView;throw Error("getIframeWindow: No Window object found for iframe element")}function p(e){return b.isHostObject(e,"body")?e.body:e.getElementsByTagName("body")[0]}function C(e){for(var t;t=e.parentNode;)e=t;return e}function v(e,t,n,i){var o,a,l,d,u;if(e==n)return t===i?0:i>t?-1:1;if(o=c(n,e,!0))return r(o)>=t?-1:1;if(o=c(e,n,!0))return i>r(o)?-1:1;if(a=s(e,n),l=e===a?a:c(e,a,!0),d=n===a?a:c(n,a,!0),l===d)throw Error("comparePoints got to case 4 and childA and childB are the same!");for(u=a.firstChild;u;){if(u===l)return-1;if(u===d)return 1;u=u.nextSibling}throw Error("Should not be here!")}function N(e){for(var t,n=h(e).createDocumentFragment();t=e.firstChild;)n.appendChild(t);return n}function E(e){if(!e)return"[No node]";if(l(e))return'"'+e.data+'"';if(1==e.nodeType){var t=e.id?' id="'+e.id+'"':"";return"<"+e.nodeName+t+">["+e.childNodes.length+"]"}return e.nodeName}function y(e){this.root=e,this._next=e}function _(e){return new y(e)}function T(e,t){this.node=e,this.offset=t}function S(e){this.code=this[e],this.codeName=e,this.message="DOMException: "+this.codeName}var R="undefined",b=e.util;b.areHostMethods(document,["createDocumentFragment","createElement","createTextNode"])||t.fail("document missing a Node creation method"),b.isHostMethod(document,"getElementsByTagName")||t.fail("document missing getElementsByTagName method");var O=document.createElement("div");b.areHostMethods(O,["insertBefore","appendChild","cloneNode"]||!b.areHostObjects(O,["previousSibling","nextSibling","childNodes","parentNode"]))||t.fail("Incomplete Element implementation"),b.isHostProperty(O,"innerHTML")||t.fail("Element is missing innerHTML property");var w=document.createTextNode("test");b.areHostMethods(w,["splitText","deleteData","insertData","appendData","cloneNode"]||!b.areHostObjects(O,["previousSibling","nextSibling","childNodes","parentNode"])||!b.areHostProperties(w,["data"]))||t.fail("Incomplete Text Node implementation");var D=function(e,t){for(var n=e.length;n--;)if(e[n]===t)return!0;return!1};y.prototype={_current:null,hasNext:function(){return!!this._next},next:function(){var e,t,n=this._current=this._next;if(this._current)if(e=n.firstChild)this._next=e;else{for(t=null;n!==this.root&&!(t=n.nextSibling);)n=n.parentNode;this._next=t}return this._current},detach:function(){this._current=this._next=this.root=null}},T.prototype={equals:function(e){return this.node===e.node&this.offset==e.offset},inspect:function(){return"[DomPosition("+E(this.node)+":"+this.offset+")]"}},S.prototype={INDEX_SIZE_ERR:1,HIERARCHY_REQUEST_ERR:3,WRONG_DOCUMENT_ERR:4,NO_MODIFICATION_ALLOWED_ERR:7,NOT_FOUND_ERR:8,NOT_SUPPORTED_ERR:9,INVALID_STATE_ERR:11},S.prototype.toString=function(){return this.message},e.dom={arrayContains:D,isHtmlNamespace:n,parentElement:i,getNodeIndex:r,getNodeLength:o,getCommonAncestor:s,isAncestorOf:a,getClosestAncestorIn:c,isCharacterDataNode:l,insertAfter:d,splitDataNode:u,getDocument:h,getWindow:f,getIframeWindow:m,getIframeDocument:g,getBody:p,getRootContainer:C,comparePoints:v,inspectNode:E,fragmentFromNodeChildren:N,createIterator:_,DomPosition:T},e.DOMException=S}),rangy.createModule("DomRange",function(e){function t(e,t){return 3!=e.nodeType&&(L.isAncestorOf(e,t.startContainer,!0)||L.isAncestorOf(e,t.endContainer,!0))}function n(e){return L.getDocument(e.startContainer)}function i(e,t,n){var i=e._listeners[t];if(i)for(var r=0,o=i.length;o>r;++r)i[r].call(e,{target:e,args:n})}function r(e){return new H(e.parentNode,L.getNodeIndex(e))}function o(e){return new H(e.parentNode,L.getNodeIndex(e)+1)}function s(e,t,n){var i=11==e.nodeType?e.firstChild:e;return L.isCharacterDataNode(t)?n==t.length?L.insertAfter(e,t):t.parentNode.insertBefore(e,0==n?t:L.splitDataNode(t,n)):n>=t.childNodes.length?t.appendChild(e):t.insertBefore(e,t.childNodes[n]),i}function a(e){for(var t,i,r,o=n(e.range).createDocumentFragment();i=e.next();){if(t=e.isPartiallySelectedSubtree(),i=i.cloneNode(!t),t&&(r=e.getSubtreeIterator(),i.appendChild(a(r)),r.detach(!0)),10==i.nodeType)throw new j("HIERARCHY_REQUEST_ERR");o.appendChild(i)}return o}function c(e,t,n){var i,r;n=n||{stop:!1};for(var o,s;o=e.next();)if(e.isPartiallySelectedSubtree()){if(t(o)===!1)return n.stop=!0,void 0;if(s=e.getSubtreeIterator(),c(s,t,n),s.detach(!0),n.stop)return}else for(i=L.createIterator(o);r=i.next();)if(t(r)===!1)return n.stop=!0,void 0}function l(e){for(var t;e.next();)e.isPartiallySelectedSubtree()?(t=e.getSubtreeIterator(),l(t),t.detach(!0)):e.remove()}function d(e){for(var t,i,r=n(e.range).createDocumentFragment();t=e.next();){if(e.isPartiallySelectedSubtree()?(t=t.cloneNode(!1),i=e.getSubtreeIterator(),t.appendChild(d(i)),i.detach(!0)):e.remove(),10==t.nodeType)throw new j("HIERARCHY_REQUEST_ERR");r.appendChild(t)}return r}function u(e,t,n){var i,r=!(!t||!t.length),o=!!n;r&&(i=RegExp("^("+t.join("|")+")$"));var s=[];return c(new f(e,!1),function(e){r&&!i.test(e.nodeType)||o&&!n(e)||s.push(e)}),s}function h(e){var t=e.getName===void 0?"Range":e.getName();return"["+t+"("+L.inspectNode(e.startContainer)+":"+e.startOffset+", "+L.inspectNode(e.endContainer)+":"+e.endOffset+")]"}function f(e,t){if(this.range=e,this.clonePartiallySelectedTextNodes=t,!e.collapsed){this.sc=e.startContainer,this.so=e.startOffset,this.ec=e.endContainer,this.eo=e.endOffset;var n=e.commonAncestorContainer;this.sc===this.ec&&L.isCharacterDataNode(this.sc)?(this.isSingleCharacterDataNode=!0,this._first=this._last=this._next=this.sc):(this._first=this._next=this.sc!==n||L.isCharacterDataNode(this.sc)?L.getClosestAncestorIn(this.sc,n,!0):this.sc.childNodes[this.so],this._last=this.ec!==n||L.isCharacterDataNode(this.ec)?L.getClosestAncestorIn(this.ec,n,!0):this.ec.childNodes[this.eo-1])}}function g(e){this.code=this[e],this.codeName=e,this.message="RangeException: "+this.codeName}function m(e,t,n){this.nodes=u(e,t,n),this._next=this.nodes[0],this._position=0}function p(e){return function(t,n){for(var i,r=n?t:t.parentNode;r;){if(i=r.nodeType,L.arrayContains(e,i))return r;r=r.parentNode}return null}}function C(e,t){if(q(e,t))throw new g("INVALID_NODE_TYPE_ERR")}function v(e){if(!e.startContainer)throw new j("INVALID_STATE_ERR")}function N(e,t){if(!L.arrayContains(t,e.nodeType))throw new g("INVALID_NODE_TYPE_ERR")}function E(e,t){if(0>t||t>(L.isCharacterDataNode(e)?e.length:e.childNodes.length))throw new j("INDEX_SIZE_ERR")}function y(e,t){if(X(e,!0)!==X(t,!0))throw new j("WRONG_DOCUMENT_ERR")}function _(e){if(z(e,!0))throw new j("NO_MODIFICATION_ALLOWED_ERR")}function T(e,t){if(!e)throw new j(t)}function S(e){return!L.arrayContains(Q,e.nodeType)&&!X(e,!0)}function R(e,t){return(L.isCharacterDataNode(e)?e.length:e.childNodes.length)>=t}function b(e){return!!e.startContainer&&!!e.endContainer&&!S(e.startContainer)&&!S(e.endContainer)&&R(e.startContainer,e.startOffset)&&R(e.endContainer,e.endOffset)}function O(e){if(v(e),!b(e))throw Error("Range error: Range is no longer valid after DOM mutation ("+e.inspect()+")")}function w(){}function D(e){e.START_TO_START=et,e.START_TO_END=tt,e.END_TO_END=nt,e.END_TO_START=it,e.NODE_BEFORE=rt,e.NODE_AFTER=ot,e.NODE_BEFORE_AND_AFTER=st,e.NODE_INSIDE=at}function x(e){D(e),D(e.prototype)}function k(e,t){return function(){O(this);var n,i,r=this.startContainer,s=this.startOffset,a=this.commonAncestorContainer,l=new f(this,!0);r!==a&&(n=L.getClosestAncestorIn(r,a,!0),i=o(n),r=i.node,s=i.offset),c(l,_),l.reset();var d=e(l);return l.detach(),t(this,r,s,r,s),d}}function A(n,i,s){function a(e,t){return function(n){v(this),N(n,U),N(V(n),Q);var i=(e?r:o)(n);(t?c:u)(this,i.node,i.offset)}}function c(e,t,n){var r=e.endContainer,o=e.endOffset;(t!==e.startContainer||n!==e.startOffset)&&((V(t)!=V(r)||1==L.comparePoints(t,n,r,o))&&(r=t,o=n),i(e,t,n,r,o))}function u(e,t,n){var r=e.startContainer,o=e.startOffset;(t!==e.endContainer||n!==e.endOffset)&&((V(t)!=V(r)||-1==L.comparePoints(t,n,r,o))&&(r=t,o=n),i(e,r,o,t,n))}function h(e,t,n){(t!==e.startContainer||n!==e.startOffset||t!==e.endContainer||n!==e.endOffset)&&i(e,t,n,t,n)}n.prototype=new w,e.util.extend(n.prototype,{setStart:function(e,t){v(this),C(e,!0),E(e,t),c(this,e,t)},setEnd:function(e,t){v(this),C(e,!0),E(e,t),u(this,e,t)},setStartBefore:a(!0,!0),setStartAfter:a(!1,!0),setEndBefore:a(!0,!1),setEndAfter:a(!1,!1),collapse:function(e){O(this),e?i(this,this.startContainer,this.startOffset,this.startContainer,this.startOffset):i(this,this.endContainer,this.endOffset,this.endContainer,this.endOffset)},selectNodeContents:function(e){v(this),C(e,!0),i(this,e,0,e,L.getNodeLength(e))},selectNode:function(e){v(this),C(e,!1),N(e,U);var t=r(e),n=o(e);i(this,t.node,t.offset,n.node,n.offset)},extractContents:k(d,i),deleteContents:k(l,i),canSurroundContents:function(){O(this),_(this.startContainer),_(this.endContainer);var e=new f(this,!0),n=e._first&&t(e._first,this)||e._last&&t(e._last,this);return e.detach(),!n},detach:function(){s(this)},splitBoundaries:function(){O(this);var e=this.startContainer,t=this.startOffset,n=this.endContainer,r=this.endOffset,o=e===n;L.isCharacterDataNode(n)&&r>0&&n.length>r&&L.splitDataNode(n,r),L.isCharacterDataNode(e)&&t>0&&e.length>t&&(e=L.splitDataNode(e,t),o?(r-=t,n=e):n==e.parentNode&&r>=L.getNodeIndex(e)&&r++,t=0),i(this,e,t,n,r)},normalizeBoundaries:function(){O(this);var e=this.startContainer,t=this.startOffset,n=this.endContainer,r=this.endOffset,o=function(e){var t=e.nextSibling;t&&t.nodeType==e.nodeType&&(n=e,r=e.length,e.appendData(t.data),t.parentNode.removeChild(t))},s=function(i){var o=i.previousSibling;if(o&&o.nodeType==i.nodeType){e=i;var s=i.length;if(t=o.length,i.insertData(0,o.data),o.parentNode.removeChild(o),e==n)r+=t,n=e;else if(n==i.parentNode){var a=L.getNodeIndex(i);r==a?(n=i,r=s):r>a&&r--}}},a=!0;if(L.isCharacterDataNode(n))n.length==r&&o(n);else{if(r>0){var c=n.childNodes[r-1];c&&L.isCharacterDataNode(c)&&o(c)}a=!this.collapsed}if(a){if(L.isCharacterDataNode(e))0==t&&s(e);else if(e.childNodes.length>t){var l=e.childNodes[t];l&&L.isCharacterDataNode(l)&&s(l)}}else e=n,t=r;i(this,e,t,n,r)},collapseToPoint:function(e,t){v(this),C(e,!0),E(e,t),h(this,e,t)}}),x(n)}function P(e){e.collapsed=e.startContainer===e.endContainer&&e.startOffset===e.endOffset,e.commonAncestorContainer=e.collapsed?e.startContainer:L.getCommonAncestor(e.startContainer,e.endContainer)}function I(e,t,n,r,o){var s=e.startContainer!==t||e.startOffset!==n,a=e.endContainer!==r||e.endOffset!==o;e.startContainer=t,e.startOffset=n,e.endContainer=r,e.endOffset=o,P(e),i(e,"boundarychange",{startMoved:s,endMoved:a})}function B(e){v(e),e.startContainer=e.startOffset=e.endContainer=e.endOffset=null,e.collapsed=e.commonAncestorContainer=null,i(e,"detach",null),e._listeners=null}function M(e){this.startContainer=e,this.startOffset=0,this.endContainer=e,this.endOffset=0,this._listeners={boundarychange:[],detach:[]},P(this)}e.requireModules(["DomUtil"]);var L=e.dom,H=L.DomPosition,j=e.DOMException;f.prototype={_current:null,_next:null,_first:null,_last:null,isSingleCharacterDataNode:!1,reset:function(){this._current=null,this._next=this._first},hasNext:function(){return!!this._next},next:function(){var e=this._current=this._next;return e&&(this._next=e!==this._last?e.nextSibling:null,L.isCharacterDataNode(e)&&this.clonePartiallySelectedTextNodes&&(e===this.ec&&(e=e.cloneNode(!0)).deleteData(this.eo,e.length-this.eo),this._current===this.sc&&(e=e.cloneNode(!0)).deleteData(0,this.so))),e},remove:function(){var e,t,n=this._current;!L.isCharacterDataNode(n)||n!==this.sc&&n!==this.ec?n.parentNode&&n.parentNode.removeChild(n):(e=n===this.sc?this.so:0,t=n===this.ec?this.eo:n.length,e!=t&&n.deleteData(e,t-e))},isPartiallySelectedSubtree:function(){var e=this._current;return t(e,this.range)},getSubtreeIterator:function(){var e;if(this.isSingleCharacterDataNode)e=this.range.cloneRange(),e.collapse();else{e=new M(n(this.range));var t=this._current,i=t,r=0,o=t,s=L.getNodeLength(t);L.isAncestorOf(t,this.sc,!0)&&(i=this.sc,r=this.so),L.isAncestorOf(t,this.ec,!0)&&(o=this.ec,s=this.eo),I(e,i,r,o,s)}return new f(e,this.clonePartiallySelectedTextNodes)},detach:function(e){e&&this.range.detach(),this.range=this._current=this._next=this._first=this._last=this.sc=this.so=this.ec=this.eo=null}},g.prototype={BAD_BOUNDARYPOINTS_ERR:1,INVALID_NODE_TYPE_ERR:2},g.prototype.toString=function(){return this.message},m.prototype={_current:null,hasNext:function(){return!!this._next},next:function(){return this._current=this._next,this._next=this.nodes[++this._position],this._current},detach:function(){this._current=this._next=this.nodes=null}};var U=[1,3,4,5,7,8,10],Q=[2,9,11],W=[5,6,10,12],F=[1,3,4,5,7,8,10,11],K=[1,3,4,5,7,8],V=L.getRootContainer,X=p([9,11]),z=p(W),q=p([6,10,12]),Y=document.createElement("style"),$=!1;try{Y.innerHTML="x",$=3==Y.firstChild.nodeType}catch(G){}e.features.htmlParsingConforms=$;var Z=$?function(e){var t=this.startContainer,n=L.getDocument(t);if(!t)throw new j("INVALID_STATE_ERR");var i=null;return 1==t.nodeType?i=t:L.isCharacterDataNode(t)&&(i=L.parentElement(t)),i=null===i||"HTML"==i.nodeName&&L.isHtmlNamespace(L.getDocument(i).documentElement)&&L.isHtmlNamespace(i)?n.createElement("body"):i.cloneNode(!1),i.innerHTML=e,L.fragmentFromNodeChildren(i)}:function(e){v(this);var t=n(this),i=t.createElement("body");return i.innerHTML=e,L.fragmentFromNodeChildren(i)},J=["startContainer","startOffset","endContainer","endOffset","collapsed","commonAncestorContainer"],et=0,tt=1,nt=2,it=3,rt=0,ot=1,st=2,at=3;w.prototype={attachListener:function(e,t){this._listeners[e].push(t)},compareBoundaryPoints:function(e,t){O(this),y(this.startContainer,t.startContainer);var n,i,r,o,s=e==it||e==et?"start":"end",a=e==tt||e==et?"start":"end";return n=this[s+"Container"],i=this[s+"Offset"],r=t[a+"Container"],o=t[a+"Offset"],L.comparePoints(n,i,r,o)},insertNode:function(e){if(O(this),N(e,F),_(this.startContainer),L.isAncestorOf(e,this.startContainer,!0))throw new j("HIERARCHY_REQUEST_ERR");var t=s(e,this.startContainer,this.startOffset);this.setStartBefore(t)},cloneContents:function(){O(this);var e,t;if(this.collapsed)return n(this).createDocumentFragment();if(this.startContainer===this.endContainer&&L.isCharacterDataNode(this.startContainer))return e=this.startContainer.cloneNode(!0),e.data=e.data.slice(this.startOffset,this.endOffset),t=n(this).createDocumentFragment(),t.appendChild(e),t;var i=new f(this,!0);return e=a(i),i.detach(),e},canSurroundContents:function(){O(this),_(this.startContainer),_(this.endContainer);var e=new f(this,!0),n=e._first&&t(e._first,this)||e._last&&t(e._last,this);return e.detach(),!n},surroundContents:function(e){if(N(e,K),!this.canSurroundContents())throw new g("BAD_BOUNDARYPOINTS_ERR");var t=this.extractContents();if(e.hasChildNodes())for(;e.lastChild;)e.removeChild(e.lastChild);s(e,this.startContainer,this.startOffset),e.appendChild(t),this.selectNode(e)},cloneRange:function(){O(this);for(var e,t=new M(n(this)),i=J.length;i--;)e=J[i],t[e]=this[e];return t},toString:function(){O(this);var e=this.startContainer;if(e===this.endContainer&&L.isCharacterDataNode(e))return 3==e.nodeType||4==e.nodeType?e.data.slice(this.startOffset,this.endOffset):"";var t=[],n=new f(this,!0);return c(n,function(e){(3==e.nodeType||4==e.nodeType)&&t.push(e.data)}),n.detach(),t.join("")},compareNode:function(e){O(this);var t=e.parentNode,n=L.getNodeIndex(e);if(!t)throw new j("NOT_FOUND_ERR");var i=this.comparePoint(t,n),r=this.comparePoint(t,n+1);return 0>i?r>0?st:rt:r>0?ot:at},comparePoint:function(e,t){return O(this),T(e,"HIERARCHY_REQUEST_ERR"),y(e,this.startContainer),0>L.comparePoints(e,t,this.startContainer,this.startOffset)?-1:L.comparePoints(e,t,this.endContainer,this.endOffset)>0?1:0},createContextualFragment:Z,toHtml:function(){O(this);var e=n(this).createElement("div");return e.appendChild(this.cloneContents()),e.innerHTML},intersectsNode:function(e,t){if(O(this),T(e,"NOT_FOUND_ERR"),L.getDocument(e)!==n(this))return!1;var i=e.parentNode,r=L.getNodeIndex(e);T(i,"NOT_FOUND_ERR");var o=L.comparePoints(i,r,this.endContainer,this.endOffset),s=L.comparePoints(i,r+1,this.startContainer,this.startOffset);return t?0>=o&&s>=0:0>o&&s>0},isPointInRange:function(e,t){return O(this),T(e,"HIERARCHY_REQUEST_ERR"),y(e,this.startContainer),L.comparePoints(e,t,this.startContainer,this.startOffset)>=0&&0>=L.comparePoints(e,t,this.endContainer,this.endOffset)},intersectsRange:function(e,t){if(O(this),n(e)!=n(this))throw new j("WRONG_DOCUMENT_ERR");var i=L.comparePoints(this.startContainer,this.startOffset,e.endContainer,e.endOffset),r=L.comparePoints(this.endContainer,this.endOffset,e.startContainer,e.startOffset);return t?0>=i&&r>=0:0>i&&r>0},intersection:function(e){if(this.intersectsRange(e)){var t=L.comparePoints(this.startContainer,this.startOffset,e.startContainer,e.startOffset),n=L.comparePoints(this.endContainer,this.endOffset,e.endContainer,e.endOffset),i=this.cloneRange();return-1==t&&i.setStart(e.startContainer,e.startOffset),1==n&&i.setEnd(e.endContainer,e.endOffset),i}return null},union:function(e){if(this.intersectsRange(e,!0)){var t=this.cloneRange();return-1==L.comparePoints(e.startContainer,e.startOffset,this.startContainer,this.startOffset)&&t.setStart(e.startContainer,e.startOffset),1==L.comparePoints(e.endContainer,e.endOffset,this.endContainer,this.endOffset)&&t.setEnd(e.endContainer,e.endOffset),t}throw new g("Ranges do not intersect")},containsNode:function(e,t){return t?this.intersectsNode(e,!1):this.compareNode(e)==at},containsNodeContents:function(e){return this.comparePoint(e,0)>=0&&0>=this.comparePoint(e,L.getNodeLength(e))},containsRange:function(e){return this.intersection(e).equals(e)},containsNodeText:function(e){var t=this.cloneRange();t.selectNode(e);var n=t.getNodes([3]);if(n.length>0){t.setStart(n[0],0);var i=n.pop();t.setEnd(i,i.length);var r=this.containsRange(t);return t.detach(),r}return this.containsNodeContents(e)},createNodeIterator:function(e,t){return O(this),new m(this,e,t)},getNodes:function(e,t){return O(this),u(this,e,t)},getDocument:function(){return n(this)},collapseBefore:function(e){v(this),this.setEndBefore(e),this.collapse(!1)},collapseAfter:function(e){v(this),this.setStartAfter(e),this.collapse(!0)},getName:function(){return"DomRange"},equals:function(e){return M.rangesEqual(this,e)},isValid:function(){return b(this)},inspect:function(){return h(this)}},A(M,I,B),e.rangePrototype=w.prototype,M.rangeProperties=J,M.RangeIterator=f,M.copyComparisonConstants=x,M.createPrototypeRange=A,M.inspect=h,M.getRangeDocument=n,M.rangesEqual=function(e,t){return e.startContainer===t.startContainer&&e.startOffset===t.startOffset&&e.endContainer===t.endContainer&&e.endOffset===t.endOffset},e.DomRange=M,e.RangeException=g}),rangy.createModule("WrappedRange",function(e){function t(e){var t=e.parentElement(),n=e.duplicate();n.collapse(!0);var i=n.parentElement();n=e.duplicate(),n.collapse(!1);var r=n.parentElement(),o=i==r?i:s.getCommonAncestor(i,r);return o==t?o:s.getCommonAncestor(t,o)}function n(e){return 0==e.compareEndPoints("StartToEnd",e)}function i(e,t,n,i){var r=e.duplicate();r.collapse(n);var o=r.parentElement();if(s.isAncestorOf(t,o,!0)||(o=t),!o.canHaveHTML)return new a(o.parentNode,s.getNodeIndex(o));var c,l,d,u,h,f=s.getDocument(o).createElement("span"),g=n?"StartToStart":"StartToEnd";do o.insertBefore(f,f.previousSibling),r.moveToElementText(f);while((c=r.compareEndPoints(g,e))>0&&f.previousSibling);if(h=f.nextSibling,-1==c&&h&&s.isCharacterDataNode(h)){r.setEndPoint(n?"EndToStart":"EndToEnd",e);var m;if(/[\r\n]/.test(h.data)){var p=r.duplicate(),C=p.text.replace(/\r\n/g,"\r").length;for(m=p.moveStart("character",C);-1==(c=p.compareEndPoints("StartToEnd",p));)m++,p.moveStart("character",1)}else m=r.text.length;u=new a(h,m)}else l=(i||!n)&&f.previousSibling,d=(i||n)&&f.nextSibling,u=d&&s.isCharacterDataNode(d)?new a(d,0):l&&s.isCharacterDataNode(l)?new a(l,l.length):new a(o,s.getNodeIndex(f));return f.parentNode.removeChild(f),u}function r(e,t){var n,i,r,o,a=e.offset,c=s.getDocument(e.node),l=c.body.createTextRange(),d=s.isCharacterDataNode(e.node);return d?(n=e.node,i=n.parentNode):(o=e.node.childNodes,n=o.length>a?o[a]:null,i=e.node),r=c.createElement("span"),r.innerHTML="&#feff;",n?i.insertBefore(r,n):i.appendChild(r),l.moveToElementText(r),l.collapse(!t),i.removeChild(r),d&&l[t?"moveStart":"moveEnd"]("character",a),l}e.requireModules(["DomUtil","DomRange"]);var o,s=e.dom,a=s.DomPosition,c=e.DomRange;if(!e.features.implementsDomRange||e.features.implementsTextRange&&e.config.preferTextRange){if(e.features.implementsTextRange){o=function(e){this.textRange=e,this.refresh()},o.prototype=new c(document),o.prototype.refresh=function(){var e,r,o=t(this.textRange);n(this.textRange)?r=e=i(this.textRange,o,!0,!0):(e=i(this.textRange,o,!0,!1),r=i(this.textRange,o,!1,!1)),this.setStart(e.node,e.offset),this.setEnd(r.node,r.offset)},c.copyComparisonConstants(o);var l=function(){return this}();l.Range===void 0&&(l.Range=o),e.createNativeRange=function(e){return e=e||document,e.body.createTextRange()}}}else(function(){function t(e){for(var t,n=d.length;n--;)t=d[n],e[t]=e.nativeRange[t]}function n(e,t,n,i,r){var o=e.startContainer!==t||e.startOffset!=n,s=e.endContainer!==i||e.endOffset!=r;(o||s)&&(e.setEnd(i,r),e.setStart(t,n))}function i(e){e.nativeRange.detach(),e.detached=!0;for(var t,n=d.length;n--;)t=d[n],e[t]=null}var r,a,l,d=c.rangeProperties;o=function(e){if(!e)throw Error("Range must be specified");this.nativeRange=e,t(this)},c.createPrototypeRange(o,n,i),r=o.prototype,r.selectNode=function(e){this.nativeRange.selectNode(e),t(this)},r.deleteContents=function(){this.nativeRange.deleteContents(),t(this)},r.extractContents=function(){var e=this.nativeRange.extractContents();return t(this),e},r.cloneContents=function(){return this.nativeRange.cloneContents()},r.surroundContents=function(e){this.nativeRange.surroundContents(e),t(this)},r.collapse=function(e){this.nativeRange.collapse(e),t(this)},r.cloneRange=function(){return new o(this.nativeRange.cloneRange())},r.refresh=function(){t(this)},r.toString=function(){return""+this.nativeRange};var u=document.createTextNode("test");s.getBody(document).appendChild(u);var h=document.createRange();h.setStart(u,0),h.setEnd(u,0);try{h.setStart(u,1),a=!0,r.setStart=function(e,n){this.nativeRange.setStart(e,n),t(this)},r.setEnd=function(e,n){this.nativeRange.setEnd(e,n),t(this)},l=function(e){return function(n){this.nativeRange[e](n),t(this)}}}catch(f){a=!1,r.setStart=function(e,n){try{this.nativeRange.setStart(e,n)}catch(i){this.nativeRange.setEnd(e,n),this.nativeRange.setStart(e,n)}t(this)},r.setEnd=function(e,n){try{this.nativeRange.setEnd(e,n)}catch(i){this.nativeRange.setStart(e,n),this.nativeRange.setEnd(e,n)}t(this)},l=function(e,n){return function(i){try{this.nativeRange[e](i)}catch(r){this.nativeRange[n](i),this.nativeRange[e](i)}t(this)}}}r.setStartBefore=l("setStartBefore","setEndBefore"),r.setStartAfter=l("setStartAfter","setEndAfter"),r.setEndBefore=l("setEndBefore","setStartBefore"),r.setEndAfter=l("setEndAfter","setStartAfter"),h.selectNodeContents(u),r.selectNodeContents=h.startContainer==u&&h.endContainer==u&&0==h.startOffset&&h.endOffset==u.length?function(e){this.nativeRange.selectNodeContents(e),t(this)}:function(e){this.setStart(e,0),this.setEnd(e,c.getEndOffset(e))},h.selectNodeContents(u),h.setEnd(u,3);var g=document.createRange();g.selectNodeContents(u),g.setEnd(u,4),g.setStart(u,2),r.compareBoundaryPoints=-1==h.compareBoundaryPoints(h.START_TO_END,g)&1==h.compareBoundaryPoints(h.END_TO_START,g)?function(e,t){return t=t.nativeRange||t,e==t.START_TO_END?e=t.END_TO_START:e==t.END_TO_START&&(e=t.START_TO_END),this.nativeRange.compareBoundaryPoints(e,t)}:function(e,t){return this.nativeRange.compareBoundaryPoints(e,t.nativeRange||t)},e.util.isHostMethod(h,"createContextualFragment")&&(r.createContextualFragment=function(e){return this.nativeRange.createContextualFragment(e)}),s.getBody(document).removeChild(u),h.detach(),g.detach()})(),e.createNativeRange=function(e){return e=e||document,e.createRange()};e.features.implementsTextRange&&(o.rangeToTextRange=function(e){if(e.collapsed){var t=r(new a(e.startContainer,e.startOffset),!0);return t}var n=r(new a(e.startContainer,e.startOffset),!0),i=r(new a(e.endContainer,e.endOffset),!1),o=s.getDocument(e.startContainer).body.createTextRange();return o.setEndPoint("StartToStart",n),o.setEndPoint("EndToEnd",i),o}),o.prototype.getName=function(){return"WrappedRange"},e.WrappedRange=o,e.createRange=function(t){return t=t||document,new o(e.createNativeRange(t))},e.createRangyRange=function(e){return e=e||document,new c(e)},e.createIframeRange=function(t){return e.createRange(s.getIframeDocument(t))},e.createIframeRangyRange=function(t){return e.createRangyRange(s.getIframeDocument(t))},e.addCreateMissingNativeApiListener(function(t){var n=t.document;n.createRange===void 0&&(n.createRange=function(){return e.createRange(this)}),n=t=null})}),rangy.createModule("WrappedSelection",function(e,t){function n(e){return(e||window).getSelection()}function i(e){return(e||window).document.selection}function r(e,t,n){var i=n?"end":"start",r=n?"start":"end";e.anchorNode=t[i+"Container"],e.anchorOffset=t[i+"Offset"],e.focusNode=t[r+"Container"],e.focusOffset=t[r+"Offset"]}function o(e){var t=e.nativeSelection;e.anchorNode=t.anchorNode,e.anchorOffset=t.anchorOffset,e.focusNode=t.focusNode,e.focusOffset=t.focusOffset}function s(e){e.anchorNode=e.focusNode=null,e.anchorOffset=e.focusOffset=0,e.rangeCount=0,e.isCollapsed=!0,e._ranges.length=0}function a(t){var n;return t instanceof S?(n=t._selectionNativeRange,n||(n=e.createNativeRange(_.getDocument(t.startContainer)),n.setEnd(t.endContainer,t.endOffset),n.setStart(t.startContainer,t.startOffset),t._selectionNativeRange=n,t.attachListener("detach",function(){this._selectionNativeRange=null}))):t instanceof R?n=t.nativeRange:e.features.implementsDomRange&&t instanceof _.getWindow(t.startContainer).Range&&(n=t),n}function c(e){if(!e.length||1!=e[0].nodeType)return!1;for(var t=1,n=e.length;n>t;++t)if(!_.isAncestorOf(e[0],e[t]))return!1;return!0}function l(e){var t=e.getNodes();if(!c(t))throw Error("getSingleElementFromRange: range "+e.inspect()+" did not consist of a single element");return t[0]}function d(e){return!!e&&e.text!==void 0}function u(e,t){var n=new R(t);e._ranges=[n],r(e,n,!1),e.rangeCount=1,e.isCollapsed=n.collapsed}function h(t){if(t._ranges.length=0,"None"==t.docSelection.type)s(t);else{var n=t.docSelection.createRange();if(d(n))u(t,n);else{t.rangeCount=n.length;for(var i,o=_.getDocument(n.item(0)),a=0;t.rangeCount>a;++a)i=e.createRange(o),i.selectNode(n.item(a)),t._ranges.push(i);t.isCollapsed=1==t.rangeCount&&t._ranges[0].collapsed,r(t,t._ranges[t.rangeCount-1],!1) +}}}function f(e,t){for(var n=e.docSelection.createRange(),i=l(t),r=_.getDocument(n.item(0)),o=_.getBody(r).createControlRange(),s=0,a=n.length;a>s;++s)o.add(n.item(s));try{o.add(i)}catch(c){throw Error("addRange(): Element within the specified Range could not be added to control selection (does it have layout?)")}o.select(),h(e)}function g(e,t,n){this.nativeSelection=e,this.docSelection=t,this._ranges=[],this.win=n,this.refresh()}function m(e,t){for(var n,i=_.getDocument(t[0].startContainer),r=_.getBody(i).createControlRange(),o=0;rangeCount>o;++o){n=l(t[o]);try{r.add(n)}catch(s){throw Error("setRanges(): Element within the one of the specified Ranges could not be added to control selection (does it have layout?)")}}r.select(),h(e)}function p(e,t){if(e.anchorNode&&_.getDocument(e.anchorNode)!==_.getDocument(t))throw new b("WRONG_DOCUMENT_ERR")}function C(e){var t=[],n=new O(e.anchorNode,e.anchorOffset),i=new O(e.focusNode,e.focusOffset),r="function"==typeof e.getName?e.getName():"Selection";if(e.rangeCount!==void 0)for(var o=0,s=e.rangeCount;s>o;++o)t[o]=S.inspect(e.getRangeAt(o));return"["+r+"(Ranges: "+t.join(", ")+")(anchor: "+n.inspect()+", focus: "+i.inspect()+"]"}e.requireModules(["DomUtil","DomRange","WrappedRange"]),e.config.checkSelectionRanges=!0;var v,N,E="boolean",y="_rangySelection",_=e.dom,T=e.util,S=e.DomRange,R=e.WrappedRange,b=e.DOMException,O=_.DomPosition,w="Control",D=e.util.isHostMethod(window,"getSelection"),x=e.util.isHostObject(document,"selection"),k=x&&(!D||e.config.preferTextRange);k?(v=i,e.isSelectionValid=function(e){var t=(e||window).document,n=t.selection;return"None"!=n.type||_.getDocument(n.createRange().parentElement())==t}):D?(v=n,e.isSelectionValid=function(){return!0}):t.fail("Neither document.selection or window.getSelection() detected."),e.getNativeSelection=v;var A=v(),P=e.createNativeRange(document),I=_.getBody(document),B=T.areHostObjects(A,["anchorNode","focusNode"]&&T.areHostProperties(A,["anchorOffset","focusOffset"]));e.features.selectionHasAnchorAndFocus=B;var M=T.isHostMethod(A,"extend");e.features.selectionHasExtend=M;var L="number"==typeof A.rangeCount;e.features.selectionHasRangeCount=L;var H=!1,j=!0;T.areHostMethods(A,["addRange","getRangeAt","removeAllRanges"])&&"number"==typeof A.rangeCount&&e.features.implementsDomRange&&function(){var e=document.createElement("iframe");e.frameBorder=0,e.style.position="absolute",e.style.left="-10000px",I.appendChild(e);var t=_.getIframeDocument(e);t.open(),t.write("12"),t.close();var n=_.getIframeWindow(e).getSelection(),i=t.documentElement,r=i.lastChild,o=r.firstChild,s=t.createRange();s.setStart(o,1),s.collapse(!0),n.addRange(s),j=1==n.rangeCount,n.removeAllRanges();var a=s.cloneRange();s.setStart(o,0),a.setEnd(o,2),n.addRange(s),n.addRange(a),H=2==n.rangeCount,s.detach(),a.detach(),I.removeChild(e)}(),e.features.selectionSupportsMultipleRanges=H,e.features.collapsedNonEditableSelectionsSupported=j;var U,Q=!1;I&&T.isHostMethod(I,"createControlRange")&&(U=I.createControlRange(),T.areHostProperties(U,["item","add"])&&(Q=!0)),e.features.implementsControlRange=Q,N=B?function(e){return e.anchorNode===e.focusNode&&e.anchorOffset===e.focusOffset}:function(e){return e.rangeCount?e.getRangeAt(e.rangeCount-1).collapsed:!1};var W;T.isHostMethod(A,"getRangeAt")?W=function(e,t){try{return e.getRangeAt(t)}catch(n){return null}}:B&&(W=function(t){var n=_.getDocument(t.anchorNode),i=e.createRange(n);return i.setStart(t.anchorNode,t.anchorOffset),i.setEnd(t.focusNode,t.focusOffset),i.collapsed!==this.isCollapsed&&(i.setStart(t.focusNode,t.focusOffset),i.setEnd(t.anchorNode,t.anchorOffset)),i}),e.getSelection=function(e){e=e||window;var t=e[y],n=v(e),r=x?i(e):null;return t?(t.nativeSelection=n,t.docSelection=r,t.refresh(e)):(t=new g(n,r,e),e[y]=t),t},e.getIframeSelection=function(t){return e.getSelection(_.getIframeWindow(t))};var F=g.prototype;if(!k&&B&&T.areHostMethods(A,["removeAllRanges","addRange"])){F.removeAllRanges=function(){this.nativeSelection.removeAllRanges(),s(this)};var K=function(t,n){var i=S.getRangeDocument(n),r=e.createRange(i);r.collapseToPoint(n.endContainer,n.endOffset),t.nativeSelection.addRange(a(r)),t.nativeSelection.extend(n.startContainer,n.startOffset),t.refresh()};F.addRange=L?function(t,n){if(Q&&x&&this.docSelection.type==w)f(this,t);else if(n&&M)K(this,t);else{var i;if(H?i=this.rangeCount:(this.removeAllRanges(),i=0),this.nativeSelection.addRange(a(t)),this.rangeCount=this.nativeSelection.rangeCount,this.rangeCount==i+1){if(e.config.checkSelectionRanges){var o=W(this.nativeSelection,this.rangeCount-1);o&&!S.rangesEqual(o,t)&&(t=new R(o))}this._ranges[this.rangeCount-1]=t,r(this,t,z(this.nativeSelection)),this.isCollapsed=N(this)}else this.refresh()}}:function(e,t){t&&M?K(this,e):(this.nativeSelection.addRange(a(e)),this.refresh())},F.setRanges=function(e){if(Q&&e.length>1)m(this,e);else{this.removeAllRanges();for(var t=0,n=e.length;n>t;++t)this.addRange(e[t])}}}else{if(!(T.isHostMethod(A,"empty")&&T.isHostMethod(P,"select")&&Q&&k))return t.fail("No means of selecting a Range or TextRange was found"),!1;F.removeAllRanges=function(){try{if(this.docSelection.empty(),"None"!=this.docSelection.type){var e;if(this.anchorNode)e=_.getDocument(this.anchorNode);else if(this.docSelection.type==w){var t=this.docSelection.createRange();t.length&&(e=_.getDocument(t.item(0)).body.createTextRange())}if(e){var n=e.body.createTextRange();n.select(),this.docSelection.empty()}}}catch(i){}s(this)},F.addRange=function(e){this.docSelection.type==w?f(this,e):(R.rangeToTextRange(e).select(),this._ranges[0]=e,this.rangeCount=1,this.isCollapsed=this._ranges[0].collapsed,r(this,e,!1))},F.setRanges=function(e){this.removeAllRanges();var t=e.length;t>1?m(this,e):t&&this.addRange(e[0])}}F.getRangeAt=function(e){if(0>e||e>=this.rangeCount)throw new b("INDEX_SIZE_ERR");return this._ranges[e]};var V;if(k)V=function(t){var n;e.isSelectionValid(t.win)?n=t.docSelection.createRange():(n=_.getBody(t.win.document).createTextRange(),n.collapse(!0)),t.docSelection.type==w?h(t):d(n)?u(t,n):s(t)};else if(T.isHostMethod(A,"getRangeAt")&&"number"==typeof A.rangeCount)V=function(t){if(Q&&x&&t.docSelection.type==w)h(t);else if(t._ranges.length=t.rangeCount=t.nativeSelection.rangeCount,t.rangeCount){for(var n=0,i=t.rangeCount;i>n;++n)t._ranges[n]=new e.WrappedRange(t.nativeSelection.getRangeAt(n));r(t,t._ranges[t.rangeCount-1],z(t.nativeSelection)),t.isCollapsed=N(t)}else s(t)};else{if(!B||typeof A.isCollapsed!=E||typeof P.collapsed!=E||!e.features.implementsDomRange)return t.fail("No means of obtaining a Range or TextRange from the user's selection was found"),!1;V=function(e){var t,n=e.nativeSelection;n.anchorNode?(t=W(n,0),e._ranges=[t],e.rangeCount=1,o(e),e.isCollapsed=N(e)):s(e)}}F.refresh=function(e){var t=e?this._ranges.slice(0):null;if(V(this),e){var n=t.length;if(n!=this._ranges.length)return!1;for(;n--;)if(!S.rangesEqual(t[n],this._ranges[n]))return!1;return!0}};var X=function(e,t){var n=e.getAllRanges(),i=!1;e.removeAllRanges();for(var r=0,o=n.length;o>r;++r)i||t!==n[r]?e.addRange(n[r]):i=!0;e.rangeCount||s(e)};F.removeRange=Q?function(e){if(this.docSelection.type==w){for(var t,n=this.docSelection.createRange(),i=l(e),r=_.getDocument(n.item(0)),o=_.getBody(r).createControlRange(),s=!1,a=0,c=n.length;c>a;++a)t=n.item(a),t!==i||s?o.add(n.item(a)):s=!0;o.select(),h(this)}else X(this,e)}:function(e){X(this,e)};var z;!k&&B&&e.features.implementsDomRange?(z=function(e){var t=!1;return e.anchorNode&&(t=1==_.comparePoints(e.anchorNode,e.anchorOffset,e.focusNode,e.focusOffset)),t},F.isBackwards=function(){return z(this)}):z=F.isBackwards=function(){return!1},F.toString=function(){for(var e=[],t=0,n=this.rangeCount;n>t;++t)e[t]=""+this._ranges[t];return e.join("")},F.collapse=function(t,n){p(this,t);var i=e.createRange(_.getDocument(t));i.collapseToPoint(t,n),this.removeAllRanges(),this.addRange(i),this.isCollapsed=!0},F.collapseToStart=function(){if(!this.rangeCount)throw new b("INVALID_STATE_ERR");var e=this._ranges[0];this.collapse(e.startContainer,e.startOffset)},F.collapseToEnd=function(){if(!this.rangeCount)throw new b("INVALID_STATE_ERR");var e=this._ranges[this.rangeCount-1];this.collapse(e.endContainer,e.endOffset)},F.selectAllChildren=function(t){p(this,t);var n=e.createRange(_.getDocument(t));n.selectNodeContents(t),this.removeAllRanges(),this.addRange(n)},F.deleteFromDocument=function(){if(Q&&x&&this.docSelection.type==w){for(var e,t=this.docSelection.createRange();t.length;)e=t.item(0),t.remove(e),e.parentNode.removeChild(e);this.refresh()}else if(this.rangeCount){var n=this.getAllRanges();this.removeAllRanges();for(var i=0,r=n.length;r>i;++i)n[i].deleteContents();this.addRange(n[r-1])}},F.getAllRanges=function(){return this._ranges.slice(0)},F.setSingleRange=function(e){this.setRanges([e])},F.containsNode=function(e,t){for(var n=0,i=this._ranges.length;i>n;++n)if(this._ranges[n].containsNode(e,t))return!0;return!1},F.toHtml=function(){var e="";if(this.rangeCount){for(var t=S.getRangeDocument(this._ranges[0]).createElement("div"),n=0,i=this._ranges.length;i>n;++n)t.appendChild(this._ranges[n].cloneContents());e=t.innerHTML}return e},F.getName=function(){return"WrappedSelection"},F.inspect=function(){return C(this)},F.detach=function(){this.win[y]=null,this.win=this.anchorNode=this.focusNode=null},g.inspect=C,e.Selection=g,e.selectionPrototype=F,e.addCreateMissingNativeApiListener(function(t){t.getSelection===void 0&&(t.getSelection=function(){return e.getSelection(this)}),t=null})}),"function"!=typeof String.prototype.trim&&(String.prototype.trim=function(){return this.replace(/^\s+|\s+$/g,"")}),"indexOf"in Array.prototype||(Array.prototype.indexOf=function(e,t){void 0===t&&(t=0),0>t&&(t+=this.length),0>t&&(t=0);for(var n=this.length;n>t;t++)if(t in this&&this[t]===e)return t;return-1}),"lastIndexOf"in Array.prototype||(Array.prototype.lastIndexOf=function(e,t){for(void 0===t&&(t=this.length-1),0>t&&(t+=this.length),t>this.length-1&&(t=this.length-1),t++;t-->0;)if(t in this&&this[t]===e)return t;return-1}),"map"in Array.prototype||(Array.prototype.map=function(e,t){for(var n=Array(this.length),i=0,r=this.length;r>i;i++)i in this&&(n[i]=e.call(t,this[i],i,this));return n}),"filter"in Array.prototype||(Array.prototype.filter=function(e,t){for(var n,i=[],r=0,o=this.length;o>r;r++)r in this&&e.call(t,n=this[r],r,this)&&i.push(n);return i}),function(){var e,t,n=this;e={changeIdAttribute:"data-cid",userIdAttribute:"data-userid",userNameAttribute:"data-username",timeAttribute:"data-time",attrValuePrefix:"",blockEl:"p",blockEls:["p","ol","ul","li","h1","h2","h3","h4","h5","h6","blockquote"],stylePrefix:"cts",currentUser:{id:null,name:null},changeTypes:{insertType:{tag:"span",alias:"ins",action:"Inserted"},deleteType:{tag:"span",alias:"del",action:"Deleted"}},handleEvents:!1,contentEditable:!0,isTracking:!0,noTrack:".ice-no-track",avoid:".ice-avoid",mergeBlocks:!0},t=function(t){if(this._changes={},t||(t={}),!t.element)throw Error("`options.element` must be defined for ice construction.");ice.dom.extend(!0,this,e,t),this.pluginsManager=new ice.IcePluginManager(this),t.plugins&&this.pluginsManager.usePlugins("ice-init",t.plugins)},t.prototype={_userStyles:{},_styles:{},_uniqueStyleIndex:0,_browserType:null,_batchChangeid:null,_uniqueIDIndex:1,_delBookmark:"tempdel",isPlaceHoldingDeletes:!1,startTracking:function(){if(this.element.setAttribute("contentEditable",this.contentEditable),this.handleEvents){var e=this;ice.dom.bind(e.element,"keyup.ice keydown.ice keypress.ice mousedown.ice mouseup.ice",function(t){return e.handleEvent(t)})}return this.initializeEnvironment(),this.initializeEditor(),this.initializeRange(),this.pluginsManager.fireEnabled(this.element),this},stopTracking:function(){if(this.element.setAttribute("contentEditable",!this.contentEditable),this.handleEvents){var e=this;ice.dom.unbind(e.element,"keyup.ice keydown.ice keypress.ice mousedown.ice mouseup.ice")}return this.pluginsManager.fireDisabled(this.element),this},initializeEnvironment:function(){this.env||(this.env={}),this.env.element=this.element,this.env.document=this.element.ownerDocument,this.env.window=this.env.document.defaultView||this.env.document.parentWindow||window,this.env.frame=this.env.window.frameElement,this.env.selection=this.selection=new ice.Selection(this.env),this.env.document.createElement(this.changeTypes.insertType.tag),this.env.document.createElement(this.changeTypes.deleteType.tag)},initializeRange:function(){var e=this.selection.createRange();e.setStart(ice.dom.find(this.element,this.blockEls.join(", "))[0],0),e.collapse(!0),this.selection.addRange(e),this.env.frame?this.env.frame.contentWindow.focus():this.element.focus()},initializeEditor:function(){var e=this,t=this.env.document.createElement("div");this.element.childNodes.length?(t.innerHTML=this.element.innerHTML,ice.dom.removeWhitespace(t),""===t.innerHTML&&t.appendChild(ice.dom.create("<"+this.blockEl+" >
    "))):t.appendChild(ice.dom.create("<"+this.blockEl+" >
    ")),this.element.innerHTML=t.innerHTML;var n=[];for(var i in this.changeTypes)n.push(this._getIceNodeClass(i));ice.dom.each(ice.dom.find(this.element,"."+n.join(", .")),function(t,i){for(var r=0,o="",s=i.className.split(" "),t=0;s.length>t;t++){var a=RegExp(e.stylePrefix+"-(\\d+)").exec(s[t]);a&&(r=a[1]);var c=RegExp("("+n.join("|")+")").exec(s[t]);c&&(o=e._getChangeTypeFromAlias(c[1]))}var l=ice.dom.attr(i,e.userIdAttribute);e.setUserStyle(l,Number(r));var d=ice.dom.attr(i,e.changeIdAttribute);e._changes[d]={type:o,userid:l,username:ice.dom.attr(i,e.userNameAttribute),time:ice.dom.attr(i,e.timeAttribute)}})},enableChangeTracking:function(){this.isTracking=!0,this.pluginsManager.fireEnabled(this.element)},disableChangeTracking:function(){this.isTracking=!1,this.pluginsManager.fireDisabled(this.element)},setCurrentUser:function(e){this.currentUser=e},handleEvent:function(e){if(this.isTracking)if("mouseup"==e.type){var t=this;setTimeout(function(){t.mouseUp(e)},200)}else{if("mousedown"==e.type)return this.mouseDown(e);if("keypress"==e.type){var n=this.keyPress(e);return n||e.preventDefault(),n}if("keydown"==e.type){var n=this.keyDown(e);return n||e.preventDefault(),n}"keyup"==e.type&&this.pluginsManager.fireCaretUpdated()}},createIceNode:function(e,t){var n=this.env.document.createElement(this.changeTypes[e].tag);return ice.dom.addClass(n,this._getIceNodeClass(e)),n.appendChild(t?t:this.env.document.createTextNode("")),this.addChange(this.changeTypes[e].alias,[n]),this.pluginsManager.fireNodeCreated(n,{action:this.changeTypes[e].action}),n},insert:function(e,t){var n=!e;if(e||(e=""),t?this.selection.addRange(t):t=this.getCurrentRange(),"string"==typeof e&&(e=document.createTextNode(e)),!t.collapsed&&(this.deleteContents(),t=this.getCurrentRange(),t.startContainer===t.endContainer&&this.element===t.startContainer)){ice.dom.empty(this.element);var i=t.getLastSelectableChild(this.element);t.setStartAfter(i),t.collapse(!0)}this._moveRangeToValidTrackingPos(t);var r=this.startBatchChange();return this._insertNode(e,t,n),this.pluginsManager.fireNodeInserted(e,t),this.endBatchChange(r),n},placeholdDeletes:function(){var e=this;this.isPlaceholdingDeletes&&this.revertDeletePlaceholders(),this.isPlaceholdingDeletes=!0,this._deletes=[];var t="."+this._getIceNodeClass("deleteType");return ice.dom.each(ice.dom.find(this.element,t),function(t,n){e._deletes.push(ice.dom.cloneNode(n)),ice.dom.replaceWith(n,"<"+e._delBookmark+' data-allocation="'+(e._deletes.length-1)+'"/>')}),!0},revertDeletePlaceholders:function(){var e=this;return this.isPlaceholdingDeletes?(ice.dom.each(this._deletes,function(t,n){ice.dom.find(e.element,e._delBookmark+"[data-allocation="+t+"]").replaceWith(n)}),this.isPlaceholdingDeletes=!1,!0):!1},deleteContents:function(e,t){var n=!0;t?this.selection.addRange(t):t=this.getCurrentRange();var i=this.startBatchChange(this.changeTypes.deleteType.alias);return t.collapsed===!1?this._deleteSelection(t):n=e?this._deleteRight(t):this._deleteLeft(t),this.selection.addRange(t),this.endBatchChange(i),n},getChanges:function(){return this._changes},getChangeUserids:function(){var e=[],t=Object.keys(this._changes);for(var n in t)e.push(this._changes[t[n]].userid);return e.sort().filter(function(e,t,n){return t==n.indexOf(e)?1:0})},getElementContent:function(){return this.element.innerHTML},getCleanContent:function(e,t,n){var i="",r=this;ice.dom.each(this.changeTypes,function(e,t){"deleteType"!=e&&(t>0&&(i+=","),i+="."+r._getIceNodeClass(e))}),e=e?"string"==typeof e?ice.dom.create("
    "+e+"
    "):ice.dom.cloneNode(e,!1)[0]:ice.dom.cloneNode(this.element,!1)[0],e=n?n.call(this,e):e;var o=ice.dom.find(e,i);ice.dom.each(o,function(){ice.dom.replaceWith(this,ice.dom.contents(this))});var s=ice.dom.find(e,"."+this._getIceNodeClass("deleteType"));return ice.dom.remove(s),e=t?t.call(this,e):e,e.innerHTML},acceptAll:function(){this.element.innerHTML=this.getCleanContent()},rejectAll:function(){var e="."+this._getIceNodeClass("insertType"),t="."+this._getIceNodeClass("deleteType");ice.dom.remove(ice.dom.find(this.element,e)),ice.dom.each(ice.dom.find(this.element,t),function(e,t){ice.dom.replaceWith(t,ice.dom.contents(t))})},acceptChange:function(e){this.acceptRejectChange(e,!0)},rejectChange:function(e){this.acceptRejectChange(e,!1)},acceptRejectChange:function(e,t){var n,i,r,o,s,a,c,l=ice.dom;if(!e){var d=this.getCurrentRange();if(!d.collapsed)return;e=d.startContainer}n=o="."+this._getIceNodeClass("deleteType"),i=s="."+this._getIceNodeClass("insertType"),r=n+","+i,a=l.getNode(e,r),c=l.find(this.element,"["+this.changeIdAttribute+"="+l.attr(a,this.changeIdAttribute)+"]"),t||(o=i,s=n),ice.dom.is(a,s)?l.each(c,function(e,t){l.replaceWith(t,ice.dom.contents(t))}):l.is(a,o)&&l.remove(c)},isInsideChange:function(e){var t="."+this._getIceNodeClass("insertType")+", ."+this._getIceNodeClass("deleteType");if(!e){if(range=this.getCurrentRange(),!range.collapsed)return!1;e=range.startContainer}return!!ice.dom.getNode(e,t)},addChangeType:function(e,t,n,i){var r={tag:t,alias:n};i&&(r.action=i),this.changeTypes[e]=r},getIceNode:function(e,t){var n="."+this._getIceNodeClass(t);return ice.dom.getNode(e,n)},_moveRangeToValidTrackingPos:function(e){for(var t=!1,n=this._getVoidElement(e.endContainer);n;){try{e.moveEnd(ice.dom.CHARACTER_UNIT,1),e.moveEnd(ice.dom.CHARACTER_UNIT,-1)}catch(i){t=!0}if(t||ice.dom.onBlockBoundary(e.endContainer,e.startContainer,this.blockEls)){e.setStartAfter(n),e.collapse(!0);break}n=this._getVoidElement(e.endContainer),n?(e.setEnd(e.endContainer,0),e.moveEnd(ice.dom.CHARACTER_UNIT,ice.dom.getNodeCharacterLength(e.endContainer)),e.collapse()):(e.setStart(e.endContainer,0),e.collapse(!0))}},_getNoTrackElement:function(e){var t=this._getNoTrackSelector(),n=ice.dom.is(e,t)?e:ice.dom.parents(e,t)[0]||null;return n},_getNoTrackSelector:function(){return this.noTrack},_getVoidElement:function(e){var t=this._getVoidElSelector();return ice.dom.is(e,t)?e:ice.dom.parents(e,t)[0]||null},_getVoidElSelector:function(){return"."+this._getIceNodeClass("deleteType")+","+this.avoid},_currentUserIceNode:function(e){return ice.dom.attr(e,this.userIdAttribute)==this.currentUser.id},_getChangeTypeFromAlias:function(e){var t,n=null;for(t in this.changeTypes)this.changeTypes.hasOwnProperty(t)&&this.changeTypes[t].alias==e&&(n=t);return n},_getIceNodeClass:function(e){return this.attrValuePrefix+this.changeTypes[e].alias},getUserStyle:function(e){var t=null;return t=this._userStyles[e]?this._userStyles[e]:this.setUserStyle(e,this.getNewStyleId())},setUserStyle:function(e,t){var n=this.stylePrefix+"-"+t;return this._styles[t]||(this._styles[t]=!0),this._userStyles[e]=n},getNewStyleId:function(){var e=++this._uniqueStyleIndex;return this._styles[e]?this.getNewStyleId():(this._styles[e]=!0,e)},addChange:function(e,t){var n=this._batchChangeid||this.getNewChangeId();this._changes[n]||(this._changes[n]={type:this._getChangeTypeFromAlias(e),time:(new Date).getTime(),userid:this.currentUser.id,username:this.currentUser.name});var i=this;return ice.dom.foreach(t,function(e){i.addNodeToChange(n,t[e])}),n},addNodeToChange:function(e,t){null!==this._batchChangeid&&(e=this._batchChangeid);var n=this.getChange(e);t.getAttribute(this.changeIdAttribute)||t.setAttribute(this.changeIdAttribute,e),t.getAttribute(this.userIdAttribute)||t.setAttribute(this.userIdAttribute,n.userid),t.getAttribute(this.userNameAttribute)||t.setAttribute(this.userNameAttribute,n.username),t.getAttribute(this.timeAttribute)||t.setAttribute(this.timeAttribute,n.time),ice.dom.hasClass(t,this._getIceNodeClass(n.type))||ice.dom.addClass(t,this._getIceNodeClass(n.type));var i=this.getUserStyle(n.userid);ice.dom.hasClass(t,i)||ice.dom.addClass(t,i)},getChange:function(e){var t=null;return this._changes[e]&&(t=this._changes[e]),t},getNewChangeId:function(){var e=++this._uniqueIDIndex;return this._changes[e]&&(e=this.getNewChangeId()),e},startBatchChange:function(){return this._batchChangeid=this.getNewChangeId(),this._batchChangeid},endBatchChange:function(e){e===this._batchChangeid&&(this._batchChangeid=null)},getCurrentRange:function(){return this.selection.getRangeAt(0)},_insertNode:function(e,t,n){ice.dom.isBlockElement(t.startContainer)||ice.dom.canContainTextElement(ice.dom.getBlockParent(t.startContainer,this.element))||!t.startContainer.previousSibling||t.setStart(t.startContainer.previousSibling,0),t.startContainer;var i=ice.dom.isBlockElement(t.startContainer)&&t.startContainer||ice.dom.getBlockParent(t.startContainer,this.element)||null;if(i===this.element){var r=document.createElement(this.blockEl);return i.appendChild(r),t.setStart(r,0),t.collapse(),this._insertNode(e,t,n)}ice.dom.hasNoTextOrStubContent(i)&&(ice.dom.empty(i),ice.dom.append(i,"
    "),t.setStart(i,0));var o=this.getIceNode(t.startContainer,"insertType"),s=this._currentUserIceNode(o);n&&s||(s||(e=this.createIceNode("insertType",e)),t.insertNode(e),t.setEnd(e,1),n?t.setStart(e,0):t.collapse(),this.selection.addRange(t))},_handleVoidEl:function(e,t){var n=this._getVoidElement(e);return n&&!this.getIceNode(n,"deleteType")?(t.collapse(!0),!0):!1},_deleteSelection:function(e){for(var t=new ice.Bookmark(this.env,e),n=ice.dom.getElementsBetween(t.start,t.end),i=ice.dom.parents(e.startContainer,this.blockEls.join(", "))[0],r=ice.dom.parents(e.endContainer,this.blockEls.join(", "))[0],o=[],s=0;n.length>s;s++){var a=n[s];if(!ice.dom.isBlockElement(a)||(o.push(a),ice.dom.canContainTextElement(a))){if((a.nodeType!==ice.dom.TEXT_NODE||0!==ice.dom.getNodeTextContent(a).length)&&!this._getVoidElement(a)){if(a.nodeType!==ice.dom.TEXT_NODE){if(ice.dom.BREAK_ELEMENT==ice.dom.getTagName(a))continue;if(ice.dom.isStubElement(a)){this._addNodeTracking(a,!1,!0);continue}for(ice.dom.hasNoTextOrStubContent(a)&&ice.dom.remove(a),j=0;a.childNodes.length>j;j++){var c=a.childNodes[j];n.push(c)}continue}var l=ice.dom.getBlockParent(a);this._addNodeTracking(a,!1,!0,!0),ice.dom.hasNoTextOrStubContent(l)&&ice.dom.remove(l)}}else for(var d=0;a.childNodes.length>d;d++)n.push(a.childNodes[d])}if(this.mergeBlocks&&i!==r){for(;o.length;)ice.dom.mergeContainers(o.shift(),i);ice.dom.removeBRFromChild(r),ice.dom.removeBRFromChild(i),ice.dom.mergeContainers(r,i)}t.selectBookmark(),e.collapse(!1)},_deleteRight:function(e){var t,n,i=ice.dom.isBlockElement(e.startContainer)&&e.startContainer||ice.dom.getBlockParent(e.startContainer,this.element)||null,r=i?ice.dom.hasNoTextOrStubContent(i):!1,o=i&&ice.dom.getNextContentNode(i,this.element),s=o?ice.dom.hasNoTextOrStubContent(o):!1,a=e.endContainer,c=e.endOffset,l=e.commonAncestorContainer;if(r)return!1;if(l.nodeType!==ice.dom.TEXT_NODE){if(0===c&&ice.dom.isBlockElement(l)&&!ice.dom.canContainTextElement(l)){var d=l.firstElementChild;if(d)return e.setStart(d,0),e.collapse(),this._deleteRight(e)}if(l.childNodes.length>c){var u=document.createTextNode(" ");return l.insertBefore(u,l.childNodes[c]),e.setStart(u,1),e.collapse(!0),n=this._deleteRight(e),ice.dom.remove(u),n}return t=ice.dom.getNextContentNode(l,this.element),e.setEnd(t,0),e.collapse(),this._deleteRight(e)}if(e.moveEnd(ice.dom.CHARACTER_UNIT,1),e.moveEnd(ice.dom.CHARACTER_UNIT,-1),c===a.data.length&&!ice.dom.hasNoTextOrStubContent(a)){if(t=ice.dom.getNextNode(a,this.element),!t)return e.selectNodeContents(a),e.collapse(),!1;if(ice.dom.BREAK_ELEMENT==ice.dom.getTagName(t)&&(t=ice.dom.getNextNode(t,this.element)),t.nodeType===ice.dom.TEXT_NODE&&(t=t.parentNode),!t.isContentEditable){n=this._addNodeTracking(t,!1,!1);var h=document.createTextNode("");return t.parentNode.insertBefore(h,t.nextSibling),e.selectNode(h),e.collapse(!0),n}if(this._handleVoidEl(t,e))return!0;if(ice.dom.isChildOf(t,i)&&ice.dom.isStubElement(t))return this._addNodeTracking(t,e,!1)}if(this._handleVoidEl(t,e))return!0;if(this._getNoTrackElement(e.endContainer.parentElement))return e.deleteContents(),!1;if(ice.dom.isOnBlockBoundary(e.startContainer,e.endContainer,this.element)){if(this.mergeBlocks&&ice.dom.is(ice.dom.getBlockParent(t,this.element),this.blockEl)){o!==ice.dom.getBlockParent(e.endContainer,this.element)&&e.setEnd(o,0);for(var f=ice.dom.getElementsBetween(e.startContainer,e.endContainer),g=0;f.length>g;g++)ice.dom.remove(f[g]);var m=e.startContainer,p=e.endContainer;return ice.dom.remove(ice.dom.find(m,"br")),ice.dom.remove(ice.dom.find(p,"br")),ice.dom.mergeBlockWithSibling(e,ice.dom.getBlockParent(e.endContainer,this.element)||i)}return s?(ice.dom.remove(o),e.collapse(!0),!0):(e.setStart(o,0),e.collapse(!0),!0)}var C=e.endContainer,v=C.splitText(e.endOffset);return v.splitText(1),this._addNodeTracking(v,e,!1)},_deleteLeft:function(e){var t,n,i=ice.dom.isBlockElement(e.startContainer)&&e.startContainer||ice.dom.getBlockParent(e.startContainer,this.element)||null,r=i?ice.dom.hasNoTextOrStubContent(i):!1,o=i&&ice.dom.getPrevContentNode(i,this.element),s=o?ice.dom.hasNoTextOrStubContent(o):!1,a=e.startContainer,c=e.startOffset,l=e.commonAncestorContainer;if(r)return!1;if(0===c||l.nodeType!==ice.dom.TEXT_NODE){if(ice.dom.isBlockElement(l)&&!ice.dom.canContainTextElement(l))if(0===c){var d=l.firstElementChild;if(d)return e.setStart(d,0),e.collapse(),this._deleteLeft(e)}else{var u=l.lastElementChild;if(u&&(t=e.getLastSelectableChild(u)))return e.setStart(t,t.data.length),e.collapse(),this._deleteLeft(e)}if(n=0===c?ice.dom.getPrevContentNode(a,this.element):l.childNodes[c-1],!n)return!1;if(ice.dom.is(n,"."+this._getIceNodeClass("insertType")+", ."+this._getIceNodeClass("deleteType"))&&n.childNodes.length>0&&n.lastChild&&(n=n.lastChild),n.nodeType===ice.dom.TEXT_NODE&&(n=n.parentNode),!n.isContentEditable){var h=this._addNodeTracking(n,!1,!0),f=document.createTextNode("");return n.parentNode.insertBefore(f,n),e.selectNode(f),e.collapse(!0),h}if(this._handleVoidEl(n,e))return!0;if(ice.dom.isStubElement(n)&&ice.dom.isChildOf(n,i)||!n.isContentEditable)return this._addNodeTracking(n,e,!0);if(ice.dom.isStubElement(n))return ice.dom.remove(n),e.collapse(!0),!1;if(n!==i&&!ice.dom.isChildOf(n,i)){if(ice.dom.canContainTextElement(n)||(n=n.lastElementChild),n.lastChild&&n.lastChild.nodeType!==ice.dom.TEXT_NODE&&ice.dom.isStubElement(n.lastChild)&&"BR"!==n.lastChild.tagName)return e.setStartAfter(n.lastChild),e.collapse(!0),!0;if(t=e.getLastSelectableChild(n),t&&!ice.dom.isOnBlockBoundary(e.startContainer,t,this.element))return e.selectNodeContents(t),e.collapse(),!0}}if(1===c&&!ice.dom.isBlockElement(l)&&e.startContainer.childNodes.length>1&&e.startContainer.childNodes[0].nodeType===ice.dom.TEXT_NODE&&0===e.startContainer.childNodes[0].data.length)return e.setStart(e.startContainer,0),this._deleteLeft(e);if(e.moveStart(ice.dom.CHARACTER_UNIT,-1),e.moveStart(ice.dom.CHARACTER_UNIT,1),this._getNoTrackElement(e.startContainer.parentElement))return e.deleteContents(),!1;if(ice.dom.isOnBlockBoundary(e.startContainer,e.endContainer,this.element)){if(s)return ice.dom.remove(o),e.collapse(),!0;if(this.mergeBlocks&&ice.dom.is(ice.dom.getBlockParent(n,this.element),this.blockEl)){o!==ice.dom.getBlockParent(e.startContainer,this.element)&&e.setStart(o,o.childNodes.length);for(var g=ice.dom.getElementsBetween(e.startContainer,e.endContainer),m=0;g.length>m;m++)ice.dom.remove(g[m]);var p=e.startContainer,C=e.endContainer;return ice.dom.remove(ice.dom.find(p,"br")),ice.dom.remove(ice.dom.find(C,"br")),ice.dom.mergeBlockWithSibling(e,ice.dom.getBlockParent(e.endContainer,this.element)||i)}return o&&o.lastChild&&ice.dom.isStubElement(o.lastChild)?(e.setStartAfter(o.lastChild),e.collapse(!0),!0):(t=e.getLastSelectableChild(o),t?(e.setStart(t,t.data.length),e.collapse(!0)):o&&(e.setStart(o,o.childNodes.length),e.collapse(!0)),!0)}var v=e.startContainer,N=v.splitText(e.startOffset-1);return N.splitText(1),this._addNodeTracking(N,e,!0)},_addNodeTracking:function(e,t,n){var i=this.getIceNode(e,"insertType");if(i&&this._currentUserIceNode(i)){t&&n&&t.selectNode(e),e.parentNode.removeChild(e);var r=ice.dom.cloneNode(i);if(ice.dom.remove(ice.dom.find(r,".iceBookmark")),null!==i&&ice.dom.hasNoTextOrStubContent(r[0])){var o=this.env.document.createTextNode("");ice.dom.insertBefore(i,o),t&&(t.setStart(o,0),t.collapse(!0)),ice.dom.replaceWith(i,ice.dom.contents(i))}return!0}if(t&&this.getIceNode(e,"deleteType")){e.normalize();var s=!1;if(n){for(var a=ice.dom.getPrevContentNode(e,this.element);!s;)d=this.getIceNode(a,"deleteType"),d?a=ice.dom.getPrevContentNode(a,this.element):s=!0;if(a){var c=t.getLastSelectableChild(a);c&&(a=c),t.setStart(a,ice.dom.getNodeCharacterLength(a)),t.collapse(!0)}return!0}for(var l=ice.dom.getNextContentNode(e,this.element);!s;)d=this.getIceNode(l,"deleteType"),d?l=ice.dom.getNextContentNode(l,this.element):s=!0;return l&&(t.selectNodeContents(l),t.collapse(!0)),!0}e.previousSibling&&e.previousSibling.nodeType===ice.dom.TEXT_NODE&&0===e.previousSibling.length&&e.parentNode.removeChild(e.previousSibling),e.nextSibling&&e.nextSibling.nodeType===ice.dom.TEXT_NODE&&0===e.nextSibling.length&&e.parentNode.removeChild(e.nextSibling);var d,u=this.getIceNode(e.previousSibling,"deleteType"),h=this.getIceNode(e.nextSibling,"deleteType");if(u&&this._currentUserIceNode(u)){if(d=u,d.appendChild(e),h&&this._currentUserIceNode(h)){var f=ice.dom.extractContent(h);ice.dom.append(d,f),h.parentNode.removeChild(h)}}else h&&this._currentUserIceNode(h)?(d=h,d.insertBefore(e,d.firstChild)):(d=this.createIceNode("deleteType"),e.parentNode.insertBefore(d,e),d.appendChild(e));return t&&(ice.dom.isStubElement(e)?t.selectNode(e):t.selectNodeContents(e),n?t.collapse(!0):t.collapse(),e.normalize()),!0},_handleAncillaryKey:function(e){var t=e.keyCode,n=!0;switch(e.shiftKey,t){case ice.dom.DOM_VK_DELETE:n=this.deleteContents(),this.pluginsManager.fireKeyPressed(e);break;case 46:n=this.deleteContents(!0),this.pluginsManager.fireKeyPressed(e);break;case ice.dom.DOM_VK_DOWN:case ice.dom.DOM_VK_UP:case ice.dom.DOM_VK_LEFT:case ice.dom.DOM_VK_RIGHT:this.pluginsManager.fireCaretPositioned(),n=!1;break;default:n=!1}return n===!0?(ice.dom.preventDefault(e),!1):!0},keyDown:function(e){if(!this.pluginsManager.fireKeyDown(e))return ice.dom.preventDefault(e),!1;var t=!1;if(this._handleSpecialKey(e)===!1)return ice.dom.isBrowser("msie")!==!0&&(this._preventKeyPress=!0),!1;if(!(e.ctrlKey!==!0&&e.metaKey!==!0||ice.dom.isBrowser("msie")!==!0&&ice.dom.isBrowser("chrome")!==!0||this.pluginsManager.fireKeyPressed(e)))return!1;switch(e.keyCode){case 27:break;default:/Firefox/.test(navigator.userAgent)!==!0&&(t=!this._handleAncillaryKey(e))}return t?(ice.dom.preventDefault(e),!1):!0},keyPress:function(e){if(this._preventKeyPress===!0)return this._preventKeyPress=!1,void 0;var t=null;if(null==e.which?t=String.fromCharCode(e.keyCode):e.which>0&&(t=String.fromCharCode(e.which)),!this.pluginsManager.fireKeyPress(e))return!1;if(e.ctrlKey||e.metaKey)return!0;var n=this.getCurrentRange(),i=ice.dom.parents(n.startContainer,"br")[0]||null;if(i&&(n.moveToNextEl(i),i.parentNode.removeChild(i)),null!==t&&e.ctrlKey!==!0&&e.metaKey!==!0)switch(e.keyCode){case ice.dom.DOM_VK_DELETE:return this._handleAncillaryKey(e); +case ice.dom.DOM_VK_ENTER:return this._handleEnter();default:return this._moveRangeToValidTrackingPos(n,n.startContainer),this.insert()}return this._handleAncillaryKey(e)},_handleEnter:function(){var e=this.getCurrentRange();return e.collapsed||this.deleteContents(),!0},_handleSpecialKey:function(e){var t=e.which;null===t&&(t=e.keyCode);var n=!1;switch(t){case 65:if(e.ctrlKey===!0||e.metaKey===!0){n=!0;var i=this.getCurrentRange();if(ice.dom.isBrowser("msie")===!0){var r=this.env.document.createTextNode(""),o=this.env.document.createTextNode("");this.element.firstChild?ice.dom.insertBefore(this.element.firstChild,r):this.element.appendChild(r),this.element.appendChild(o),i.setStart(r,0),i.setEnd(o,0)}else{i.setStart(i.getFirstSelectableChild(this.element),0);var s=i.getLastSelectableChild(this.element);i.setEnd(s,s.length)}this.selection.addRange(i)}break;default:}return n===!0?(ice.dom.preventDefault(e),!1):!0},mouseUp:function(e){return this.pluginsManager.fireClicked(e)?(this.pluginsManager.fireSelectionChanged(this.getCurrentRange()),void 0):!1},mouseDown:function(e){return this.pluginsManager.fireMouseDown(e)?(this.pluginsManager.fireCaretUpdated(),void 0):!1}},n.ice=this.ice||{},n.ice.InlineChangeEditor=t}.call(this),function(){var e=this,t={};t.DOM_VK_DELETE=8,t.DOM_VK_LEFT=37,t.DOM_VK_UP=38,t.DOM_VK_RIGHT=39,t.DOM_VK_DOWN=40,t.DOM_VK_ENTER=13,t.ELEMENT_NODE=1,t.ATTRIBUTE_NODE=2,t.TEXT_NODE=3,t.CDATA_SECTION_NODE=4,t.ENTITY_REFERENCE_NODE=5,t.ENTITY_NODE=6,t.PROCESSING_INSTRUCTION_NODE=7,t.COMMENT_NODE=8,t.DOCUMENT_NODE=9,t.DOCUMENT_TYPE_NODE=10,t.DOCUMENT_FRAGMENT_NODE=11,t.NOTATION_NODE=12,t.CHARACTER_UNIT="character",t.WORD_UNIT="word",t.BREAK_ELEMENT="br",t.CONTENT_STUB_ELEMENTS=["img","hr","iframe","param","link","meta","input","frame","col","base","area"],t.BLOCK_ELEMENTS=["p","div","pre","ul","ol","li","table","tbody","td","th","fieldset","form","blockquote","dl","dt","dd","dir","center","address","h1","h2","h3","h4","h5","h6"],t.TEXT_CONTAINER_ELEMENTS=["p","div","pre","li","td","th","blockquote","dt","dd","center","address","h1","h2","h3","h4","h5","h6"],t.STUB_ELEMENTS=t.CONTENT_STUB_ELEMENTS.slice(),t.STUB_ELEMENTS.push(t.BREAK_ELEMENT),t.getKeyChar=function(e){return String.fromCharCode(e.which)},t.getClass=function(e,t,n){return t||(t=document.body),e="."+e.split(" ").join("."),n&&(e=n+e),jQuery.makeArray(jQuery(t).find(e))},t.getId=function(e,t){return t||(t=document),element=t.getElementById(e)},t.getTag=function(e,t){return t||(t=document),jQuery.makeArray(jQuery(t).find(e))},t.getElementWidth=function(e){return e.offsetWidth},t.getElementHeight=function(e){return e.offsetHeight},t.getElementDimensions=function(e){var n={width:t.getElementWidth(e),height:t.getElementHeight(e)};return n},t.trim=function(e){return jQuery.trim(e)},t.empty=function(e){return e?jQuery(e).empty():void 0},t.remove=function(e){return e?jQuery(e).remove():void 0},t.prepend=function(e,t){jQuery(e).prepend(t)},t.append=function(e,t){jQuery(e).append(t)},t.insertBefore=function(e,t){jQuery(e).before(t)},t.insertAfter=function(e,t){jQuery(e).after(t)},t.getHtml=function(e){return jQuery(e).html()},t.setHtml=function(e,t){e&&jQuery(e).html(t)},t.removeWhitespace=function(e){jQuery(e).contents().filter(function(){return this.nodeType!=ice.dom.TEXT_NODE&&"UL"==this.nodeName||"OL"==this.nodeName?(t.removeWhitespace(this),!1):this.nodeType!=ice.dom.TEXT_NODE?!1:!/\S/.test(this.nodeValue)}).remove()},t.contents=function(e){return jQuery.makeArray(jQuery(e).contents())},t.extractContent=function(e){for(var t,n=document.createDocumentFragment();t=e.firstChild;)n.appendChild(t);return n},t.getNode=function(e,n){return t.is(e,n)?e:t.parents(e,n)[0]||null},t.getParents=function(e,t,n){for(var i=jQuery(e).parents(t),r=i.length,o=[],s=0;r>s&&i[s]!==n;s++)o.push(i[s]);return o},t.hasBlockChildren=function(e){for(var n=e.childNodes.length,i=0;n>i;i++)if(e.childNodes[i].nodeType===t.ELEMENT_NODE&&t.isBlockElement(e.childNodes[i])===!0)return!0;return!1},t.removeTag=function(e,t){return jQuery(e).find(t).replaceWith(function(){return jQuery(this).contents()}),e},t.stripEnclosingTags=function(e,t){var n=jQuery(e);return n.find("*").not(t).replaceWith(function(){var e,t=jQuery();try{e=jQuery(this),t=e.contents()}catch(n){}return 0===t.length&&e.remove(),t}),n[0]},t.getSiblings=function(e,t,n,i){if(n===!0)return"prev"===t?jQuery(e).prevAll():jQuery(e).nextAll();var r=[];if("prev"===t)for(;e.previousSibling&&(e=e.previousSibling,e!==i);)r.push(e);else for(;e.nextSibling&&(e=e.nextSibling,e!==i);)r.push(e);return r},t.getNodeTextContent=function(e){return jQuery(e).text()},t.getNodeStubContent=function(e){return jQuery(e).find(t.CONTENT_STUB_ELEMENTS.join(", "))},t.hasNoTextOrStubContent=function(e){return t.getNodeTextContent(e).length>0?!1:jQuery(e).find(t.CONTENT_STUB_ELEMENTS.join(", ")).length>0?!1:!0},t.getNodeCharacterLength=function(e){return t.getNodeTextContent(e).length+jQuery(e).find(t.STUB_ELEMENTS.join(", ")).length},t.setNodeTextContent=function(e,t){return jQuery(e).text(t)},t.getTagName=function(e){return e.tagName&&e.tagName.toLowerCase()||null},t.getIframeDocument=function(e){var t=null;return e.contentDocument?t=e.contentDocument:e.contentWindow?t=e.contentWindow.document:e.document&&(t=e.document),t},t.isBlockElement=function(e){return-1!=t.BLOCK_ELEMENTS.lastIndexOf(e.nodeName.toLowerCase())},t.isStubElement=function(e){return-1!=t.STUB_ELEMENTS.lastIndexOf(e.nodeName.toLowerCase())},t.removeBRFromChild=function(e){if(e&&e.hasChildNodes())for(var t=0;e.childNodes.length>t;t++){var n=e.childNodes[t];n&&ice.dom.BREAK_ELEMENT==ice.dom.getTagName(n)&&n.parentNode.removeChild(n)}},t.isChildOf=function(e,t){try{for(;e&&e.parentNode;){if(e.parentNode===t)return!0;e=e.parentNode}}catch(n){}return!1},t.isChildOfTagName=function(e,t){try{for(;e&&e.parentNode;){if(e.parentNode&&e.parentNode.tagName&&e.parentNode.tagName.toLowerCase()===t)return e.parentNode;e=e.parentNode}}catch(n){}return!1},t.isChildOfTagNames=function(e,t){try{for(;e&&e.parentNode;){if(e.parentNode&&e.parentNode.tagName){tagName=e.parentNode.tagName.toLowerCase();for(var n=0;t.length>n;n++)if(tagName===t[n])return e.parentNode}e=e.parentNode}}catch(i){}return null},t.isChildOfClassName=function(e,t){try{for(;e&&e.parentNode;){if(jQuery(e.parentNode).hasClass(t))return e.parentNode;e=e.parentNode}}catch(n){}return null},t.cloneNode=function(e,t){return void 0===t&&(t=!0),jQuery(e).clone(t)},t.bind=function(e,t,n){return jQuery(e).bind(t,n)},t.unbind=function(e,t,n){return jQuery(e).unbind(t,n)},t.attr=function(e,t,n){return n?jQuery(e).attr(t,n):jQuery(e).attr(t)},t.replaceWith=function(e,t){return jQuery(e).replaceWith(t)},t.removeAttr=function(e,t){jQuery(e).removeAttr(t)},t.getElementsBetween=function(e,n){var i=[];if(e===n)return i;if(t.isChildOf(n,e)===!0){for(var r=e.childNodes.length,o=0;r>o&&e.childNodes[o]!==n;o++){if(t.isChildOf(n,e.childNodes[o])===!0)return t.arrayMerge(i,t.getElementsBetween(e.childNodes[o],n));i.push(e.childNodes[o])}return i}for(var s=e.nextSibling;s;){if(t.isChildOf(n,s)===!0)return i=t.arrayMerge(i,t.getElementsBetween(s,n));if(s===n)return i;i.push(s),s=s.nextSibling}for(var a=t.getParents(e),c=t.getParents(n),l=t.arrayDiff(a,c,!0),d=l.length,u=0;d-1>u;u++)i=t.arrayMerge(i,t.getSiblings(l[u],"next"));var h=l[l.length-1];return i=t.arrayMerge(i,t.getElementsBetween(h,n))},t.getCommonAncestor=function(e,n){for(var i=e;i;){if(t.isChildOf(n,i)===!0)return i;i=i.parentNode}return null},t.getNextNode=function(e,n){if(e)for(;e.parentNode;){if(e===n)return null;if(e.nextSibling){if(e.nextSibling.nodeType===t.TEXT_NODE&&0===e.nextSibling.length){e=e.nextSibling;continue}return t.getFirstChild(e.nextSibling)}e=e.parentNode}return null},t.getNextContentNode=function(e,n){if(e)for(;e.parentNode;){if(e===n)return null;if(e.nextSibling&&t.canContainTextElement(t.getBlockParent(e))){if(e.nextSibling.nodeType===t.TEXT_NODE&&0===e.nextSibling.length){e=e.nextSibling;continue}return e.nextSibling}if(e.nextElementSibling)return e.nextElementSibling;e=e.parentNode}return null},t.getPrevNode=function(e,n){if(e)for(;e.parentNode;){if(e===n)return null;if(e.previousSibling){if(e.previousSibling.nodeType===t.TEXT_NODE&&0===e.previousSibling.length){e=e.previousSibling;continue}return t.getLastChild(e.previousSibling)}e=e.parentNode}return null},t.getPrevContentNode=function(e,n){if(e)for(;e.parentNode;){if(e===n)return null;if(e.previousSibling&&t.canContainTextElement(t.getBlockParent(e))){if(e.previousSibling.nodeType===t.TEXT_NODE&&0===e.previousSibling.length){e=e.previousSibling;continue}return e.previousSibling}if(e.previousElementSibling)return e.previousElementSibling;e=e.parentNode}return null},t.canContainTextElement=function(e){return e&&e.nodeName?-1!=t.TEXT_CONTAINER_ELEMENTS.lastIndexOf(e.nodeName.toLowerCase()):!1},t.getFirstChild=function(e){return e.firstChild?e.firstChild.nodeType===t.ELEMENT_NODE?t.getFirstChild(e.firstChild):e.firstChild:e},t.getLastChild=function(e){return e.lastChild?e.lastChild.nodeType===t.ELEMENT_NODE?t.getLastChild(e.lastChild):e.lastChild:e},t.removeEmptyNodes=function(e,n){for(var i=jQuery(e).find(":empty"),r=i.length;r>0;)r--,t.isStubElement(i[r])===!1&&(n&&n.call(this,i[r])===!1||t.remove(i[r]))},t.create=function(e){return jQuery(e)[0]},t.find=function(e,t){return jQuery(e).find(t)},t.children=function(e,t){return jQuery(e).children(t)},t.parent=function(e,t){return jQuery(e).parent(t)[0]},t.parents=function(e,t){return jQuery(e).parents(t)},t.is=function(e,t){return jQuery(e).is(t)},t.extend=function(){return jQuery.extend.apply(this,arguments)},t.walk=function(e,n,i){if(e){i||(i=0);var r=n.call(this,e,i);r!==!1&&(e.childNodes&&e.childNodes.length>0?t.walk(e.firstChild,n,i+1):e.nextSibling?t.walk(e.nextSibling,n,i):e.parentNode&&e.parentNode.nextSibling&&t.walk(e.parentNode.nextSibling,n,i-1))}},t.revWalk=function(e,n){if(e){var i=n.call(this,e);i!==!1&&(e.childNodes&&e.childNodes.length>0?t.walk(e.lastChild,n):e.previousSibling?t.walk(e.previousSibling,n):e.parentNode&&e.parentNode.previousSibling&&t.walk(e.parentNode.previousSibling,n))}},t.setStyle=function(e,t,n){e&&jQuery(e).css(t,n)},t.getStyle=function(e,t){return jQuery(e).css(t)},t.hasClass=function(e,t){return jQuery(e).hasClass(t)},t.addClass=function(e,t){jQuery(e).addClass(t)},t.removeClass=function(e,t){jQuery(e).removeClass(t)},t.preventDefault=function(e){e.preventDefault(),t.stopPropagation(e)},t.stopPropagation=function(e){e.stopPropagation()},t.noInclusionInherits=function(e,n){(n instanceof String||"string"==typeof n)&&(n=window[n]),(e instanceof String||"string"==typeof e)&&(e=window[e]);var i=function(){};if(t.isset(n)===!0)for(value in n.prototype)e.prototype[value]?i.prototype[value]=n.prototype[value]:e.prototype[value]=n.prototype[value];e.prototype&&(i.prototype.constructor=n,e.prototype["super"]=new i)},t.each=function(e,t){jQuery.each(e,function(e,n){t.call(this,e,n)})},t.foreach=function(e,t){if(e instanceof Array||e instanceof NodeList||e.length!==void 0&&e.item!==void 0)for(var n=e.length,i=0;n>i;i++){var r=t.call(this,i,e[i]);if(r===!1)break}else for(var o in e)if(e.hasOwnProperty(o)===!0){var r=t.call(this,o);if(r===!1)break}},t.isBlank=function(e){return!e||/^\s*$/.test(e)?!0:!1},t.isFn=function(e){return"function"==typeof e?!0:!1},t.isObj=function(e){return null!==e&&"object"==typeof e?!0:!1},t.isset=function(e){return e!==void 0&&null!==e?!0:!1},t.isArray=function(e){return jQuery.isArray(e)},t.isNumeric=function(e){var t=e.match(/^\d+$/);return null!==t?!0:!1},t.getUniqueId=function(){var e=(new Date).getTime(),t=Math.ceil(1e6*Math.random()),n=e+""+t;return n.substr(5,18).replace(/,/,"")},t.inArray=function(e,t){for(var n=t.length,i=0;n>i;i++)if(e===t[i])return!0;return!1},t.arrayDiff=function(e,n,i){for(var r=e.length,o=[],s=0;r>s;s++)t.inArray(e[s],n)===!1&&o.push(e[s]);if(i!==!0){r=n.length;for(var s=0;r>s;s++)t.inArray(n[s],e)===!1&&o.push(n[s])}return o},t.arrayMerge=function(e,t){for(var n=t.length,i=0;n>i;i++)e.push(t[i]);return e},t.stripTags=function(e,n){if("string"==typeof n){var i=jQuery("
    "+e+"
    ");return i.find("*").not(n).remove(),i.html()}for(var r,o=RegExp(/<\/?(\w+)((\s+\w+(\s*=\s*(?:".*?"|'.*?'|[^'">\s]+))?)+\s*|\s*)\/?>/gim),s=e;null!=(r=o.exec(e));)(t.isset(n)===!1||t.inArray(r[1],n)!==!0)&&(s=s.replace(r[0],""));return s},t.browser=function(){var e={};return e.version=jQuery.browser.version,jQuery.browser.mozilla===!0?e.type="mozilla":jQuery.browser.msie===!0?e.type="msie":jQuery.browser.opera===!0?e.type="opera":jQuery.browser.webkit===!0&&(e.type="webkit"),e},t.getBrowserType=function(){if(null===this._browserType){for(var e=["msie","firefox","chrome","safari"],t=e.length,n=0;t>n;n++){var i=RegExp(e[n],"i");if(i.test(navigator.userAgent)===!0)return this._browserType=e[n],this._browserType}this._browserType="other"}return this._browserType},t.getWebkitType=function(){if("webkit"!==t.browser().type)return console.log("Not a webkit!"),!1;var e=Object.prototype.toString.call(window.HTMLElement).indexOf("Constructor")>0;return e?"safari":"chrome"},t.isBrowser=function(e){return t.browser().type===e},t.getBlockParent=function(e,n){if(t.isBlockElement(e)===!0)return e;if(e)for(;e.parentNode;){if(e=e.parentNode,e===n)return null;if(t.isBlockElement(e)===!0)return e}return null},t.findNodeParent=function(e,n,i){if(e)for(;e.parentNode;){if(e===i)return null;if(t.is(e,n)===!0)return e;e=e.parentNode}return null},t.onBlockBoundary=function(e,n,i){if(!e||!n)return!1;var r=t.isChildOfTagNames(e,i)||t.is(e,i.join(", "))&&e||null,o=t.isChildOfTagNames(n,i)||t.is(n,i.join(", "))&&n||null;return r!==o},t.isOnBlockBoundary=function(e,n,i){if(!e||!n)return!1;var r=t.getBlockParent(e,i)||t.isBlockElement(e,i)&&e||null,o=t.getBlockParent(n,i)||t.isBlockElement(n,i)&&n||null;return r!==o},t.mergeContainers=function(e,n){if(!e||!n)return!1;if(e.nodeType===t.TEXT_NODE||t.isStubElement(e))n.appendChild(e);else if(e.nodeType===t.ELEMENT_NODE){for(;e.firstChild;)n.appendChild(e.firstChild);t.remove(e)}return!0},t.mergeBlockWithSibling=function(e,n,i){var r=i?jQuery(n).next().get(0):jQuery(n).prev().get(0);return i?t.mergeContainers(r,n):t.mergeContainers(n,r),e.collapse(!0),!0},t.date=function(e,n,i){if(null!==n||!i||(n=t.tsIso8601ToTimestamp(i))){for(var r=new Date(n),o=e.split(""),s=o.length,a="",c=0;s>c;c++){var l="",d=o[c];switch(d){case"D":case"l":var u=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];l=u[r.getDay()],"D"===d&&(l=l.substring(0,3));break;case"F":case"m":l=r.getMonth()+1,10>l&&(l="0"+l);break;case"M":months=["January","February","March","April","May","June","July","August","September","October","November","December"],l=months[r.getMonth()],"M"===d&&(l=l.substring(0,3));break;case"d":l=r.getDate();break;case"S":l=t.getOrdinalSuffix(r.getDate());break;case"Y":l=r.getFullYear();break;case"y":l=r.getFullYear(),l=(""+l).substring(2);break;case"H":l=r.getHours();break;case"h":l=r.getHours(),0===l?l=12:l>12&&(l-=12);break;case"i":l=t.addNumberPadding(r.getMinutes());break;case"a":l="am",r.getHours()>=12&&(l="pm");break;default:l=d}a+=l}return a}},t.getOrdinalSuffix=function(e){var t="",n=e%100;if(n>=4&&20>=n)t="th";else switch(e%10){case 1:t="st";break;case 2:t="nd";break;case 3:t="rd";break;default:t="th"}return t},t.addNumberPadding=function(e){return 10>e&&(e="0"+e),e},t.tsIso8601ToTimestamp=function(e){var t=/(\d\d\d\d)(?:-?(\d\d)(?:-?(\d\d)(?:[T ](\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(?:Z|(?:([-+])(\d\d)(?::?(\d\d))?)?)?)?)?)?/,n=e.match(RegExp(t));if(n){var i=new Date;i.setDate(n[3]),i.setFullYear(n[1]),i.setMonth(n[2]-1),i.setHours(n[4]),i.setMinutes(n[5]),i.setSeconds(n[6]);var r=60*n[9];"+"===n[8]&&(r*=-1),r-=i.getTimezoneOffset();var o=i.getTime()+1e3*60*r;return o}return null},e.dom=t}.call(this.ice),function(){var e,t=this;e=function(e,t,n){this.env=e,this.element=e.element,this.selection=this.env.selection,n||this.removeBookmarks(this.element);var i=t||this.selection.getRangeAt(0);t=i.cloneRange();var r=t.startContainer;t.endContainer;var o=t.startOffset;t.endOffset;var s;t.collapse(!1);var a=this.env.document.createElement("span");a.style.display="none",ice.dom.setHtml(a," "),ice.dom.addClass(a,"iceBookmark iceBookmark_end"),a.setAttribute("iceBookmark","end"),t.insertNode(a),ice.dom.isChildOf(a,this.element)||this.element.appendChild(a),t.setStart(r,o),t.collapse(!0);var c=this.env.document.createElement("span");c.style.display="none",ice.dom.addClass(c,"iceBookmark iceBookmark_start"),ice.dom.setHtml(c," "),c.setAttribute("iceBookmark","start");try{t.insertNode(c),c.previousSibling===a&&(s=c,c=a,a=s)}catch(l){ice.dom.insertBefore(a,c)}ice.dom.isChildOf(c,this.element)===!1&&(this.element.firstChild?ice.dom.insertBefore(this.element.firstChild,c):this.element.appendChild(c)),a.previousSibling||(s=this.env.document.createTextNode(""),ice.dom.insertBefore(a,s)),c.nextSibling||(s=this.env.document.createTextNode(""),ice.dom.insertAfter(c,s)),i.setStart(c.nextSibling,0),i.setEnd(a.previousSibling,a.previousSibling.length||0),this.start=c,this.end=a},e.prototype={selectBookmark:function(){var e=this.selection.getRangeAt(0),t=null,n=null,i=0,r=null;if(this.start.nextSibling===this.end||0===ice.dom.getElementsBetween(this.start,this.end).length)this.end.nextSibling?t=ice.dom.getFirstChild(this.end.nextSibling):this.start.previousSibling?(t=ice.dom.getFirstChild(this.start.previousSibling),t.nodeType===ice.dom.TEXT_NODE&&(i=t.length)):(this.end.parentNode.appendChild(this.env.document.createTextNode("")),t=ice.dom.getFirstChild(this.end.nextSibling));else{if(this.start.nextSibling)t=ice.dom.getFirstChild(this.start.nextSibling);else{if(!this.start.previousSibling){var o=this.env.document.createTextNode("");ice.dom.insertBefore(this.start,o)}t=ice.dom.getLastChild(this.start.previousSibling),i=t.length}this.end.previousSibling?n=ice.dom.getLastChild(this.end.previousSibling):(n=ice.dom.getFirstChild(this.end.nextSibling||this.end),r=0)}ice.dom.remove([this.start,this.end]),null===n?(e.setEnd(t,i),e.collapse(!1)):(e.setStart(t,i),null===r&&(r=n.length||0),e.setEnd(n,r));try{this.selection.addRange(e)}catch(s){}},getBookmark:function(e,t){var n=ice.dom.getClass("iceBookmark_"+t,e)[0];return n},removeBookmarks:function(e){ice.dom.remove(ice.dom.getClass("iceBookmark",e,"span"))}},t.Bookmark=e}.call(this.ice),function(){var e,t=this;e=function(e){this._selection=null,this.env=e,this._initializeRangeLibrary(),this._getSelection()},e.prototype={_getSelection:function(){return this._selection?this._selection.refresh():this._selection=this.env.frame?rangy.getIframeSelection(this.env.frame):rangy.getSelection(),this._selection},createRange:function(){return rangy.createRange(this.env.document)},getRangeAt:function(e){this._selection.refresh();try{return this._selection.getRangeAt(e)}catch(t){return this._selection=null,this._getSelection().getRangeAt(0)}},addRange:function(e){this._selection||(this._selection=this._getSelection()),this._selection.setSingleRange(e),this._selection.ranges=[e]},_initializeRangeLibrary:function(){var e=this;rangy.init(),rangy.config.checkSelectionRanges=!1;var t=function(e,t,n,i){if(0===n)throw Error("InvalidArgumentException: units cannot be 0");switch(t){case ice.dom.CHARACTER_UNIT:n>0?e.moveCharRight(i,n):e.moveCharLeft(i,-1*n);break;case ice.dom.WORD_UNIT:default:}};rangy.rangePrototype.moveStart=function(e,n){t(this,e,n,!0)},rangy.rangePrototype.moveEnd=function(e,n){t(this,e,n,!1)},rangy.rangePrototype.setRange=function(e,t,n){e?this.setStart(t,n):this.setEnd(t,n)},rangy.rangePrototype.moveCharLeft=function(e,t){var n,i;if(e?(n=this.startContainer,i=this.startOffset):(n=this.endContainer,i=this.endOffset),n.nodeType===ice.dom.ELEMENT_NODE)if(n.hasChildNodes()){for(n=n.childNodes[i],n=this.getPreviousTextNode(n);n&&n.nodeType==ice.dom.TEXT_NODE&&""===n.nodeValue;)n=this.getPreviousTextNode(n);i=n.data.length-t}else i=-1*t;else i-=t;if(0>i)for(;0>i;){var r=[];if(n=this.getPreviousTextNode(n,r),!n)return;n.nodeType!==ice.dom.ELEMENT_NODE&&(i+=n.data.length)}this.setRange(e,n,i)},rangy.rangePrototype.moveCharRight=function(e,t){var n,i;e?(n=this.startContainer,i=this.startOffset):(n=this.endContainer,i=this.endOffset),n.nodeType===ice.dom.ELEMENT_NODE?(n=n.childNodes[i],n.nodeType!==ice.dom.TEXT_NODE&&(n=this.getNextTextNode(n)),i=t):i+=t;var r=i-n.data.length;if(r>0){for(var o=[];r>0;)if(n=this.getNextContainer(n,o),n.nodeType!==ice.dom.ELEMENT_NODE){if(n.data.length>=r)break;n.data.length>0&&(r-=n.data.length)}i=r}this.setRange(e,n,i)},rangy.rangePrototype.getNextContainer=function(e,t){if(!e)return null;for(;e.nextSibling;)if(e=e.nextSibling,e.nodeType!==ice.dom.TEXT_NODE){var n=this.getFirstSelectableChild(e);if(null!==n)return n}else if(this.isSelectable(e)===!0)return e;for(;e&&!e.nextSibling;)e=e.parentNode;if(!e)return null;if(e=e.nextSibling,this.isSelectable(e)===!0)return e;t&&ice.dom.isBlockElement(e)===!0&&t.push(e);var i=this.getFirstSelectableChild(e);return null!==i?i:this.getNextContainer(e,t)},rangy.rangePrototype.getPreviousContainer=function(e,t){if(!e)return null;for(;e.previousSibling;)if(e=e.previousSibling,e.nodeType!==ice.dom.TEXT_NODE){if(ice.dom.isStubElement(e)===!0)return e;var n=this.getLastSelectableChild(e);if(null!==n)return n}else if(this.isSelectable(e)===!0)return e;for(;e&&!e.previousSibling;)e=e.parentNode;if(!e)return null;if(e=e.previousSibling,this.isSelectable(e)===!0)return e;t&&ice.dom.isBlockElement(e)===!0&&t.push(e);var i=this.getLastSelectableChild(e);return null!==i?i:this.getPreviousContainer(e,t)},rangy.rangePrototype.getNextTextNode=function(e){return e.nodeType===ice.dom.ELEMENT_NODE&&0!==e.childNodes.length?this.getFirstSelectableChild(e):(e=this.getNextContainer(e),e.nodeType===ice.dom.TEXT_NODE?e:this.getNextTextNode(e))},rangy.rangePrototype.getPreviousTextNode=function(e,t){return e=this.getPreviousContainer(e,t),e.nodeType===ice.dom.TEXT_NODE?e:this.getPreviousTextNode(e,t)},rangy.rangePrototype.getFirstSelectableChild=function(e){if(e){if(e.nodeType===ice.dom.TEXT_NODE)return e;for(var t=e.firstChild;t;){if(this.isSelectable(t)===!0)return t;if(t.firstChild){var n=this.getFirstSelectableChild(t);if(null!==n)return n;t=t.nextSibling}else t=t.nextSibling}}return null},rangy.rangePrototype.getLastSelectableChild=function(e){if(e){if(e.nodeType===ice.dom.TEXT_NODE)return e;for(var t=e.lastChild;t;){if(this.isSelectable(t)===!0)return t;if(t.lastChild){var n=this.getLastSelectableChild(t);if(null!==n)return n;t=t.previousSibling}else t=t.previousSibling}}return null},rangy.rangePrototype.isSelectable=function(e){return e&&e.nodeType===ice.dom.TEXT_NODE&&0!==e.data.length?!0:!1},rangy.rangePrototype.getHTMLContents=function(t){t||(t=this.cloneContents());var n=e.env.document.createElement("div");return n.appendChild(t.cloneNode(!0)),n.innerHTML},rangy.rangePrototype.getHTMLContentsObj=function(){return this.cloneContents()}}},t.Selection=e}.call(this.ice),function(){var e=this,t=function(e){this._ice=e};t.prototype={start:function(){},clicked:function(){return!0},mouseDown:function(){return!0},keyDown:function(){return!0},keyPress:function(){return!0},selectionChanged:function(){},setEnabled:function(){},setDisabled:function(){},caretUpdated:function(){},nodeInserted:function(){},nodeCreated:function(){},caretPositioned:function(){},remove:function(){this._ice.removeKeyPressListener(this)},setSettings:function(){}},e.IcePlugin=t}.call(this.ice),function(){var e=this,t=function(e){this.plugins={},this.pluginConstructors={},this.keyPressListeners={},this.activePlugin=null,this.pluginSets={},this.activePluginSet=null,this._ice=e};t.prototype={getPluginNames:function(){var e=[];for(var t in this.plugins)e.push(t);return e},addPluginObject:function(e,t){this.plugins[e]=t},addPlugin:function(e,t){if("function"!=typeof t)throw Error("IcePluginException: plugin must be a constructor function");ice.dom.isset(this.pluginConstructors[e])===!1&&(this.pluginConstructors[e]=t)},loadPlugins:function(e,t){if(0===e.length)t.call(this);else{var n=e.shift();if("object"==typeof n&&(n=n.name),ice.dom.isset(ice._plugin[n])!==!0)throw Error("plugin was not included in the page: "+n);this.addPlugin(n,ice._plugin[n]),this.loadPlugins(e,t)}},_enableSet:function(e){this.activePluginSet=e;for(var t=this.pluginSets[e].length,n=0;t>n;n++){var i=this.pluginSets[e][n],r="";r="object"==typeof i?i.name:i;var o=this.pluginConstructors[r];if(o){var s=new o(this._ice);this.plugins[r]=s,ice.dom.isset(i.settings)===!0&&s.setSettings(i.settings),s.start()}}},setActivePlugin:function(e){this.activePlugin=e},getActivePlugin:function(){return this.activePlugin},_getPluginName:function(e){var t=""+e,n="function ".length,i=t.substr(n,t.indexOf("(")-n);return i},removePlugin:function(e){this.plugins[e]&&this.plugins[e].remove()},getPlugin:function(e){return this.plugins[e]},usePlugins:function(e,t,n){var i=this;this.pluginSets[e]=ice.dom.isset(t)===!0?t:[];var r=this.pluginSets[e].concat([]);this.loadPlugins(r,function(){i._enableSet(e),n&&n.call(this)})},disablePlugin:function(e){this.plugins[e].disable()},isPluginElement:function(e){for(var t in this.plugins)if(this.plugins[t].isPluginElement&&this.plugins[t].isPluginElement(e)===!0)return!0;return!1},fireKeyPressed:function(e){if(this._fireKeyPressFns(e,"all_keys")===!1)return!1;var t=[];switch((e.ctrlKey===!0||e.metaKey===!0)&&t.push("ctrl"),e.shiftKey===!0&&t.push("shift"),e.altKey===!0&&t.push("alt"),e.keyCode){case 13:t.push("enter");break;case ice.dom.DOM_VK_LEFT:t.push("left");break;case ice.dom.DOM_VK_RIGHT:t.push("right");break;case ice.dom.DOM_VK_UP:t.push("up");break;case ice.dom.DOM_VK_DOWN:t.push("down");break;case 9:t.push("tab");break;case ice.dom.DOM_VK_DELETE:t.push("delete");break;default:var n;e.keyCode?n=e.keyCode:e.which&&(n=e.which),n&&t.push(String.fromCharCode(n).toLowerCase())}var i=t.sort().join("+");return this._fireKeyPressFns(e,i)},_fireKeyPressFns:function(e,t){if(this.keyPressListeners[t])for(var n=this.keyPressListeners[t].length,i=0;n>i;i++){var r=this.keyPressListeners[t][i],o=r.fn,s=r.plugin,a=r.data;if(o)if(ice.dom.isFn(o)===!0){if(o.call(s,e,a)===!0)return ice.dom.preventDefault(e),!1}else if(s[o]&&s[o].call(s,e,a)===!0)return ice.dom.preventDefault(e),!1}return!0},fireSelectionChanged:function(e){for(var t in this.plugins)this.plugins[t].selectionChanged(e)},fireNodeInserted:function(e,t){for(var n in this.plugins)if(this.plugins[n].nodeInserted(e,t)===!1)return!1},fireNodeCreated:function(e,t){for(var n in this.plugins)if(this.plugins[n].nodeCreated(e,t)===!1)return!1},fireCaretPositioned:function(){for(var e in this.plugins)this.plugins[e].caretPositioned()},fireClicked:function(e){var t=!0;for(var n in this.plugins)this.plugins[n].clicked(e)===!1&&(t=!1);return t},fireMouseDown:function(e){var t=!0;for(var n in this.plugins)this.plugins[n].mouseDown(e)===!1&&(t=!1);return t},fireKeyDown:function(e){var t=!0;for(var n in this.plugins)this.plugins[n].keyDown(e)===!1&&(t=!1);return t},fireKeyPress:function(e){var t=!0;for(var n in this.plugins)this.plugins[n].keyPress(e)===!1&&(t=!1);return t},fireEnabled:function(e){for(var t in this.plugins)this.plugins[t].setEnabled(e)},fireDisabled:function(e){for(var t in this.plugins)this.plugins[t].setDisabled(e)},fireCaretUpdated:function(){for(var e in this.plugins)this.plugins[e].caretUpdated&&this.plugins[e].caretUpdated()}},e._plugin={},e.IcePluginManager=t}.call(this.ice),function(){var e,t=this;e=function(e){this._ice=e},e.prototype={nodeCreated:function(e,t){e.setAttribute("title",(t.action||"Modified")+" by "+e.getAttribute(this._ice.userNameAttribute)+" - "+ice.dom.date("m/d/Y h:ia",parseInt(e.getAttribute(this._ice.timeAttribute))))}},ice.dom.noInclusionInherits(e,ice.IcePlugin),t._plugin.IceAddTitlePlugin=e}.call(this.ice),function(){var e,t=this;e=function(e){this._ice=e,this._tmpNode=null,this._tmpNodeTagName="icepaste",this._pasteId="icepastediv";var t=this;this.pasteType="formattedClean",this.preserve="p",this.beforePasteClean=function(e){return e},this.afterPasteClean=function(e){return e},e.element.oncopy=function(){return t.handleCopy.apply(t)}},e.prototype={setSettings:function(e){e=e||{},ice.dom.extend(this,e),this.preserve+=","+this._tmpNodeTagName,this.setupPreserved()},keyDown:function(e){return e.metaKey===!0||e.ctrlKey===!0?(86==e.keyCode?this.handlePaste():88==e.keyCode&&this.handleCut(),!0):void 0},keyPress:function(e){var t=null;null==e.which?t=String.fromCharCode(e.keyCode):e.which>0&&(t=String.fromCharCode(e.which));var n=this;return this.cutElement&&"x"===t?ice.dom.isBrowser("webkit")&&n.cutElement.focus():"v"===t&&ice.dom.isBrowser("webkit")&&this._ice.env.document.getElementById(n._pasteId).focus(),!0},handleCopy:function(){},handlePaste:function(){var e=this._ice.getCurrentRange();if(e.collapsed||(this._ice.isTracking?(this._ice.deleteContents(),e=e.cloneRange()):(e.deleteContents(),e.collapse(!0))),this._ice.isTracking&&this._ice._moveRangeToValidTrackingPos(e),e.startContainer==this._ice.element){var t=ice.dom.find(this._ice.element,this._ice.blockEl)[0];t||(t=ice.dom.create("<"+this._ice.blockEl+" >
    "),this._ice.element.appendChild(t)),e.setStart(t,0),e.collapse(!0),this._ice.env.selection.addRange(e)}switch(this._tmpNode=this._ice.env.document.createElement(this._tmpNodeTagName),e.insertNode(this._tmpNode),this.pasteType){case"formatted":this.setupPaste();break;case"formattedClean":this.setupPaste(!0)}return!0},setupPaste:function(e){var t=this.createDiv(this._pasteId),n=this,i=this._ice.getCurrentRange();return i.selectNodeContents(t),this._ice.selection.addRange(i),t.onpaste=function(){setTimeout(function(){n.handlePasteValue(e)},0)},t.focus(),!0},handlePasteValue:function(e){var t=this._ice.env.document,n=t.getElementById(this._pasteId),i=ice.dom.getHtml(n),r=ice.dom.children("
    "+i+"
    ",this._ice.blockEl);1===r.length&&ice.dom.getNodeTextContent("
    "+i+"
    ")===ice.dom.getNodeTextContent(r)&&(i=ice.dom.getHtml(i)),i=this.beforePasteClean.call(this,i),e&&(i=this._ice.getCleanContent(i),i=this.stripPaste(i)),i=this.afterPasteClean.call(this,i),i=ice.dom.trim(i);var o=this._ice.getCurrentRange();o.setStartAfter(this._tmpNode),o.collapse(!0);var s=null,a=null,c=null,l=o.createContextualFragment(i),d=this._ice.startBatchChange();if(ice.dom.hasBlockChildren(l)){var u=ice.dom.isChildOfTagName(this._tmpNode,this._ice.blockEl);o.setEndAfter(u.lastChild),this._ice.selection.addRange(o);var h=o.extractContents(),f=t.createElement(this._ice.blockEl);f.appendChild(h),ice.dom.insertAfter(u,f),o.setStart(f,0),o.collapse(!0),this._ice.selection.addRange(o);for(var g=o.startContainer;l.firstChild;)if(3!==l.firstChild.nodeType||jQuery.trim(l.firstChild.nodeValue))if(ice.dom.isBlockElement(l.firstChild)){if(""!==l.firstChild.textContent){s=null;var m=null;this._ice.isTracking?(m=this._ice.createIceNode("insertType"),this._ice.addChange("insertType",[m]),c=t.createElement(l.firstChild.tagName),m.innerHTML=l.firstChild.innerHTML,c.appendChild(m)):(m=c=t.createElement(l.firstChild.tagName),c.innerHTML=l.firstChild.innerHTML),a=m,ice.dom.insertBefore(g,c)}l.removeChild(l.firstChild)}else s||(c=t.createElement(this._ice.blockEl),ice.dom.insertBefore(g,c),this._ice.isTracking?(s=this._ice.createIceNode("insertType"),this._ice.addChange("insertType",[s]),c.appendChild(s)):s=c),a=s,s.appendChild(l.removeChild(l.firstChild));else l.removeChild(l.firstChild);f.textContent||f.parentNode.removeChild(f)}else if(this._ice.isTracking)c=this._ice.createIceNode("insertType",l),this._ice.addChange("insertType",[c]),o.insertNode(c),a=c;else for(var p;p=l.firstChild;)o.insertNode(p),o.setStartAfter(p),o.collapse(!0),a=p;this._ice.endBatchChange(d),n.parentNode.removeChild(n),this._cleanup(a)},createDiv:function(e){var t=this._ice.env.document,n=t.getElementById(e);n&&ice.dom.remove(n);var i=t.createElement("div");return i.id=e,i.setAttribute("contentEditable",!0),ice.dom.setStyle(i,"width","1px"),ice.dom.setStyle(i,"height","1px"),ice.dom.setStyle(i,"overflow","hidden"),ice.dom.setStyle(i,"position","fixed"),ice.dom.setStyle(i,"top","10px"),ice.dom.setStyle(i,"left","10px"),i.appendChild(t.createElement("br")),t.body.appendChild(i),i +},handleCut:function(){var e=this,t=this._ice.getCurrentRange();if(!t.collapsed){this.cutElement=this.createDiv("icecut"),this.cutElement.innerHTML=t.getHTMLContents().replace(/ /g,"> "),this._ice.isTracking?this._ice.deleteContents():t.deleteContents();var n=this._ice.env.document.createRange();n.setStart(this.cutElement.firstChild,0),n.setEndAfter(this.cutElement.lastChild),setTimeout(function(){e.cutElement.focus(),setTimeout(function(){ice.dom.remove(e.cutElement),t.setStart(t.startContainer,t.startOffset),t.collapse(!1),e._ice.env.selection.addRange(t)},100)},0),e._ice.env.selection.addRange(n)}},stripPaste:function(e){return e=this._cleanWordPaste(e),e=this.cleanPreserved(e)},setupPreserved:function(){var e=this;this._tags="",this._attributesMap=[],ice.dom.each(this.preserve.split(","),function(t,n){n.match(/(\w+)(\[(.+)\])?/);var i=RegExp.$1,r=RegExp.$3;e._tags&&(e._tags+=","),e._tags+=i.toLowerCase(),e._attributesMap[i]=r.split("|")})},cleanPreserved:function(e){var t=this,n=this._ice.env.document.createElement("div");return n.innerHTML=e,n=ice.dom.stripEnclosingTags(n,this._tags),ice.dom.each(ice.dom.find(n,this._tags),function(e,n){if(ice.dom.hasClass(n,"skip-clean"))return!0;var i=n.tagName.toLowerCase(),r=t._attributesMap[i];if(r[0]&&"*"===r[0])return!0;if(n.hasAttributes())for(var o=n.attributes,e=o.length-1;e>=0;e--)ice.dom.inArray(o[e].name,r)||n.removeAttribute(o[e].name)}),n.innerHTML},_cleanWordPaste:function(e){return e=e.replace(/<(meta|link)[^>]+>/g,""),e=e.replace(//g,""),e=e.replace(/ diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index ee9b400251..e2e1d9d664 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -9,6 +9,8 @@ from django.utils.translation import ugettext as _ import branding # app that handles site status messages from status.status import get_site_status_msg +# shopping cart +import shoppingcart %> ## Provide a hook for themes to inject branding on top. @@ -44,7 +46,7 @@ site_status_msg = get_site_status_msg(course_id)

    <%block name="navigation_logo"> - ${_('{settings.PLATFORM_NAME} home')} + ${settings.PLATFORM_NAME} ${_('Home')}

    @@ -57,9 +59,11 @@ site_status_msg = get_site_status_msg(course_id)
      @@ -79,7 +83,17 @@ site_status_msg = get_site_status_msg(course_id)
    - + % if settings.MITX_FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION') and \ + settings.MITX_FEATURES.get('ENABLE_SHOPPING_CART') and \ + shoppingcart.models.Order.user_cart_has_items(user): +
      +
    1. + + Shopping Cart + +
    2. +
    + % endif % else: @@ -155,16 +162,16 @@
  • - - ${_('Will be shown in any discussions or forums you participate in')} + + ${_('Will be shown in any discussions or forums you participate in')} (${_('cannot be changed later')})
  • % if ask_for_fullname:
  • - - ${_("Needed for any certificates you may earn (cannot be changed later)")} + + ${_("Needed for any certificates you may earn")}
  • % endif @@ -172,10 +179,10 @@ % endif - + -
    - ${_("Optional Personal Information")} +
    +

    ${_("Optional Personal Information")}

    1. @@ -210,10 +217,10 @@
    -
    + -
    - ${_("Optional Personal Information")} +
    +

    ${_("Optional Personal Information")}

    1. @@ -226,10 +233,10 @@
    -
    + -
    - ${_("Account Acknowledgements")} +
    +

    ${_("Account Acknowledgements")}

    1. @@ -260,7 +267,7 @@
    -
    + % if course_id and enrollment_action: diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index bb04d1b31c..0e7fae62b3 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -18,7 +18,7 @@ data-id="${item['id']}" data-element="${idx+1}" href="javascript:void(0);"> -

    ${item['title']}, ${item['type']}

    +

    ${item['title']}, ${item['type']}

    % endfor diff --git a/lms/templates/shoppingcart/error.html b/lms/templates/shoppingcart/error.html index da88dc1a78..662bb948e9 100644 --- a/lms/templates/shoppingcart/error.html +++ b/lms/templates/shoppingcart/error.html @@ -9,6 +9,4 @@

    ${_("There was an error processing your order!")}

    ${error_html} - -

    ${_("Return to cart to retry payment")}

    diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index cf452baab0..820e05dc47 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -7,47 +7,62 @@ <%block name="title">${_("Your Shopping Cart")}
    -

    ${_("Your selected items:")}

    - % if shoppingcart_items: - - - ${_("")} - - - % for item in shoppingcart_items: - - - - - % endfor - - - - -
    QuantityDescriptionUnit PricePriceCurrency
    ${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}[x]
    ${_("Total Amount")}
    ${"{0:0.2f}".format(amount)}
    - - ${form_html} - % else: -

    ${_("You have selected no items for purchase.")}

    - % endif +

    ${_("Your selected items:")}

    + % if shoppingcart_items: + + + + + + + + + + + + % for item in shoppingcart_items: + + + + + + + + + % endfor + + + + + + + + + +
    ${_("Quantity")}${_("Description")}${_("Unit Price")}${_("Price")}${_("Currency")}
    ${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}[x]
    ${_("Total Amount")}
    ${"{0:0.2f}".format(amount)}
    + + ${form_html} + % else: +

    ${_("You have selected no items for purchase.")}

    + % endif
    diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html index 0386b6b353..20c8a4272c 100644 --- a/lms/templates/shoppingcart/receipt.html +++ b/lms/templates/shoppingcart/receipt.html @@ -3,58 +3,89 @@ <%! from django.conf import settings %> <%inherit file="../main.html" /> +<%block name="bodyclass">purchase-receipt -<%block name="title">${_("Receipt for Order")} ${order.id} +<%block name="title">${_("Register for [Course Name] | Receipt (Order")} ${order.id}) -% if notification is not UNDEFINED: -
    - ${notification} -
    -% endif +<%block name="content"> -
    -

    ${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

    -

    ${_("Order #")}${order.id}

    -

    ${_("Date:")} ${order.purchase_time.date().isoformat()}

    -

    ${_("Items ordered:")}

    +
    +
    +

    Thank you for your Purchase!

    +

    Please print this receipt page for your records. You should also have received a receipt in your email.

    + % for inst in instructions: +

    ${inst}

    + % endfor +
    - - - ${_("")} - - - % for item in order_items: - - % if item.status == "purchased": - +
    +
    +
    +

    ${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

    +
    + +
    QtyDescriptionUnit PricePriceCurrency
    ${item.qty}${item.line_desc}
    + + + + + + + + + + + + + + + + + % for item in order_items: + + % if item.status == "purchased": + + - % elif item.status == "refunded": - + % elif item.status == "refunded": + + - % endif - % endfor - - - + % endif + % endfor + + + + + + + + + + +

    ${_("Order #")}${order.id}

    ${_("Date:")} ${order.purchase_time.date().isoformat()}

    ${_("Items ordered:")}

    ${_("Qty")}${_("Description")}${_("Unit Price")}${_("Price")}${_("Currency")}
    ${item.qty}${item.line_desc} ${"{0:0.2f}".format(item.unit_cost)} ${"{0:0.2f}".format(item.line_cost)} ${item.currency.upper()}
    ${item.qty}${item.line_desc}${item.qty}${item.line_desc} ${"{0:0.2f}".format(item.unit_cost)} ${"{0:0.2f}".format(item.line_cost)} ${item.currency.upper()}
    ${_("Total Amount")}
    ${"{0:0.2f}".format(order.total_cost)}
    ${_("Total Amount")}
    ${"{0:0.2f}".format(order.total_cost)}
    - % if any_refunds: -

    - ${_("Note: items with strikethough like ")}this${_(" have been refunded.")} -

    - % endif -

    ${_("Billed To:")}

    -

    - ${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}
    - ${order.bill_to_first} ${order.bill_to_last}
    - ${order.bill_to_street1}
    - ${order.bill_to_street2}
    - ${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}
    - ${order.bill_to_country.upper()}
    -

    + % if any_refunds: +

    + ${_("Note: items with strikethough like ")}this${_(" have been refunded.")} +

    + % endif -
    +

    ${_("Billed To:")}

    +

    + ${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}
    + ${order.bill_to_first} ${order.bill_to_last}
    + ${order.bill_to_street1}
    + ${order.bill_to_street2}
    + ${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}
    + ${order.bill_to_country.upper()}
    +

    + + + + diff --git a/lms/templates/shoppingcart/test/fake_payment_error.html b/lms/templates/shoppingcart/test/fake_payment_error.html new file mode 100644 index 0000000000..fcfe21ed15 --- /dev/null +++ b/lms/templates/shoppingcart/test/fake_payment_error.html @@ -0,0 +1,9 @@ + + + Payment Error + + +

    An error occurred while you submitted your order. + If you are trying to make a purchase, please contact the merchant.

    + + diff --git a/lms/templates/shoppingcart/test/fake_payment_page.html b/lms/templates/shoppingcart/test/fake_payment_page.html new file mode 100644 index 0000000000..ba488bbdb4 --- /dev/null +++ b/lms/templates/shoppingcart/test/fake_payment_page.html @@ -0,0 +1,12 @@ + +Payment Form + +

    Payment page

    +
    + % for name, value in post_params.items(): + + % endfor + +
    + + diff --git a/lms/templates/shoppingcart/verified_cert_receipt.html b/lms/templates/shoppingcart/verified_cert_receipt.html new file mode 100644 index 0000000000..3d999570fc --- /dev/null +++ b/lms/templates/shoppingcart/verified_cert_receipt.html @@ -0,0 +1,266 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%! from student.views import course_from_id %> + +<%inherit file="../main.html" /> +<%block name="bodyclass">register verification-process step-confirmation + +<%block name="title">${_("Receipt (Order")} ${order.id}) + +<%block name="content"> +% if notification is not UNDEFINED: +
    + ${notification} +
    +% endif + +
    +
    + + + +
    +
    +

    ${_("Your Progress")}

    + +
      +
    1. + 0 + ${_("Current Step: ")}${_("Intro")} +
    2. + +
    3. + 1 + ${_("Take Photo")} +
    4. + +
    5. + 2 + ${_("Take ID Photo")} +
    6. + +
    7. + 3 + ${_("Review")} +
    8. + +
    9. + 4 + ${_("Make Payment")} +
    10. + +
    11. + + + + ${_("Current Step: ")}${_("Confirmation")} +
    12. +
    + + + + +
    +
    + +
    +
    +

    ${_("Congratulations! You are now verified on ")} ${_(settings.PLATFORM_NAME)}.

    + +
    +

    ${_("You are now registered as a verified student! Your registration details are below.")}

    +
    + +
      +
    • +

      ${_("You are registered for:")}

      + +
      + + + + + + + + + + + + % for item in order_items: + + + + + + % endfor + + + + + + +
      ${_("A list of courses you have just registered for as a verified student")}
      ${_("Course")}${_("Status")}${_("Options")}
      ${item.line_desc} + ${_("Starts: {start_date}").format(start_date=course_start_date_text)} + + %if course_has_started: + ${_("Go to Course")} + %else: + %endif +
      + ${_("Go to your Dashboard")} +
      +
      +
    • + +
    • +

      ${_("Verified Status")}

      + +
      +

      ${_("We have received your identification details to verify your identity. If there is a problem with any of the items, we will contact you to resubmit. You can now register for any of the verified certificate courses this semester without having to re-verify.")}

      + +

      ${_("The professor will ask you to periodically submit a new photo to verify your work during the course (usually at exam times).")}

      +
      +
    • + +
    • +

      ${_("Payment Details")}

      + +
      +

      ${_("Please print this page for your records; it serves as your receipt. You will also receive an email with the same information.")}

      +
      + +
      + + + + + + + + + + + + % for item in order_items: + + % if item.status == "purchased": + + + + + + % elif item.status == "refunded": + + + + + % endif + + % endfor + + + + + + + + +
      ${_("Order No.")}${_("Description")}${_("Date")}${_("Description")}
      ${order.id}${item.line_desc}${order.purchase_time.date().isoformat()}${"{0:0.2f}".format(item.line_cost)} (${item.currency.upper()})${order.id}${item.line_desc}${order.purchase_time.date().isoformat()}${"{0:0.2f}".format(item.line_cost)} (${item.currency.upper()})
      ${_("Total")} + ${"{0:0.2f}".format(order.total_cost)} + (${item.currency.upper()}) +
      + + % if any_refunds: +
      +

      Please Note:

      +
      +

      ${_("Note: items with strikethough like ")}${_("this")}${_(" have been refunded.")}

      +
      +
      + % endif +
      + +
      +

      ${_("Billed To")}: + ${order.bill_to_first} ${order.bill_to_last} (${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode} ${order.bill_to_country.upper()}) +

      +
      +
    • + + <%doc> +
    • +

      ${_("Billing Information")}

      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      ${_("Billed To")}${_("Billing Address")}${_("Payment Method Type")}${_("Payment Method Details")}
      + ${order.bill_to_first} ${order.bill_to_last} + + ${order.bill_to_street1} + ${order.bill_to_street2} + + ${order.bill_to_street2}, + ${order.bill_to_state} + ${order.bill_to_postalcode} + + ${order.bill_to_country.upper()} + + ${order.bill_to_cardtype} + + ${order.bill_to_ccnum} +
      ${_("Total")}${"{0:0.2f}".format(order.total_cost)} (${item.currency.upper()})
      +
      +
    • + +
    +
    +
    + +
    +
    + diff --git a/lms/templates/signup_modal.html b/lms/templates/signup_modal.html index a9d709ba60..0416eea82b 100644 --- a/lms/templates/signup_modal.html +++ b/lms/templates/signup_modal.html @@ -131,7 +131,7 @@ - +

    diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 6d1517c447..7f87674047 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -1,7 +1,7 @@ <%! from django.utils.translation import ugettext as _ %> ## The JS for this is defined in xqa_interface.html -${module_content} +${block_content} %if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool']: % if edit_link:
    @@ -59,12 +59,6 @@ location = ${location | h} ${name}
    ${field | h}
    %endfor - - - %for name, field in lms_fields: - - %endfor -
    ${_('{platform_name} Fields').format(platform_name=settings.PLATFORM_NAME)}
    ${name}
    ${field | h}
    %for name, field in xml_attributes.items(): diff --git a/lms/templates/static_pdfbook.html b/lms/templates/static_pdfbook.html index 4e9f604a0c..d1ec14c1a6 100644 --- a/lms/templates/static_pdfbook.html +++ b/lms/templates/static_pdfbook.html @@ -13,6 +13,7 @@ <%static:js group='courseware'/> + diff --git a/lms/templates/verify_student/_modal_editname.html b/lms/templates/verify_student/_modal_editname.html new file mode 100644 index 0000000000..1e5efadc44 --- /dev/null +++ b/lms/templates/verify_student/_modal_editname.html @@ -0,0 +1,34 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/lms/templates/verify_student/_verification_header.html b/lms/templates/verify_student/_verification_header.html new file mode 100644 index 0000000000..4870a59c49 --- /dev/null +++ b/lms/templates/verify_student/_verification_header.html @@ -0,0 +1,21 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/lms/templates/verify_student/_verification_support.html b/lms/templates/verify_student/_verification_support.html new file mode 100644 index 0000000000..0503c3bf7d --- /dev/null +++ b/lms/templates/verify_student/_verification_support.html @@ -0,0 +1,28 @@ +<%! from django.utils.translation import ugettext as _ %> + +
    + +
    diff --git a/lms/templates/verify_student/face_upload.html b/lms/templates/verify_student/face_upload.html new file mode 100644 index 0000000000..6bfc18fd72 --- /dev/null +++ b/lms/templates/verify_student/face_upload.html @@ -0,0 +1,325 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="bodyclass">register verification photos + +<%block name="js_extra"> + + + + + +<%block name="content"> +
    + + + +
    +

    Your Progress

    +
      +
    1. Current: Step 1 Take Your Photo
    2. +
    3. Step 2 ID Photo
    4. +
    5. Step 3 Review
    6. +
    7. Step 4 Payment
    8. +
    9. Finished Confirmation
    10. +
    +
    + + + + + + + +
    +

    More questions? Check out our FAQs.

    +

    Change your mind? You can always Audit the course for free without verifying.

    +
    + + +
    + + + + + + + diff --git a/lms/templates/verify_student/final_verification.html b/lms/templates/verify_student/final_verification.html new file mode 100644 index 0000000000..c9abf876ae --- /dev/null +++ b/lms/templates/verify_student/final_verification.html @@ -0,0 +1,10 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="content"> + +Final Verification! + + + diff --git a/lms/templates/verify_student/photo_id_upload.html b/lms/templates/verify_student/photo_id_upload.html new file mode 100644 index 0000000000..1c8ec47dd7 --- /dev/null +++ b/lms/templates/verify_student/photo_id_upload.html @@ -0,0 +1,145 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="bodyclass">register verification select-track + +<%block name="js_extra"> + + + +<%block name="content"> +
    +
    + + + +

    Select your track:

    + +
    +
    +
    +

    Audit This Course

    +

    Sign up to audit this course for free and track your own progress.

    +
    + +
    +

    + Select Audit +

    +
    +
    + +

    or

    + +
    +
    +

    Certificate of Achievement

    + +

    Sign up as a verified student and work toward a Certificate of Achievement.

    + +
    +
    +
    + Select your contribution for this course: +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
    + +

    + Why do I have to pay? What if I don't meet all the requirements? +

    + +
    +
    +
    Why do I have to pay?
    +
    Your payment helps cover the costs of verification. As a non-profit, edX keeps these costs as low as possible, Your payment will also help edX with our mission to provide quality education to anyone.
    +
    What if I can't afford it?
    +
    If you cannot afford the minimum payment, you can always work towards a free Honor Code Certificate of Achievement for this course. + + +
    + +
    I'd like to pay more than the minimum. Is my contribution tax deductible?
    +
    Please check with your tax advisor to determine whether your contribution is tax deductible.
    + +
    What if I don't meet all of the requirements for financial assistance but I still want to work toward a certificate?
    +
    If you don't have a webcam, credit or debit card or acceptable ID, you can opt to simply audit this course, or select to work towards a free Honor Code Certificate of Achievement for this course by checking the box below. Then click the Select Certificate button to complete registration. We won't ask you to verify your identity. +

    +
    +
    +
    + + + + +
    + +

    + What is an ID Verified Certificate? +

    + +

    + Select Certificate +

    +
    + +
    +

    + To register for a Verified Certificate of Achievement option, you will need a webcam, a credit or debit card, and an ID. View requirements +

    +
    + +
    + + +

    Have questions? Check out our FAQs.

    +

    Not the course you wanted? Return to our course listings.

    + + +
    +
    + + diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html new file mode 100644 index 0000000000..537c26ff84 --- /dev/null +++ b/lms/templates/verify_student/photo_verification.html @@ -0,0 +1,406 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process step-photos +<%block name="title">${_("Register for {} | Verification").format(course_name)} + +<%block name="js_extra"> + + + + + +<%block name="content"> + + + + + + + +
    +
    + + <%include file="_verification_header.html" args="course_name=course_name" /> + +
    +
    +

    ${_("Your Progress")}

    + + +
      +
    1. + 0 + ${_("Intro")} +
    2. + +
    3. + 1 + ${_("Current Step: ")}${_("Take Photo")} +
    4. + +
    5. + 2 + ${_("Take ID Photo")} +
    6. + +
    7. + 3 + ${_("Review")} +
    8. + +
    9. + 4 + ${_("Make Payment")} +
    10. + +
    11. + + + + ${_("Confirmation")} +
    12. +
    + + + + +
    +
    + +
    +
    + + +
    +
    + + <%include file="_verification_support.html" /> +
    +
    + +<%include file="_modal_editname.html" /> + diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html new file mode 100644 index 0000000000..432a13fc62 --- /dev/null +++ b/lms/templates/verify_student/show_requirements.html @@ -0,0 +1,166 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%block name="bodyclass">register verification-process step-requirements +<%block name="title">${_("Register for {}").format(course_name)} + +<%block name="content"> +%if is_not_active: +
    +
    + +
    +

    ${_("You need to activate your edX account before proceeding")}

    +
    +

    ${_("Please check your email for further instructions on activating your new account.")}

    +
    +
    +
    +
    +%endif + +
    +
    + + <%include file="_verification_header.html" args="course_name=course_name"/> + +
    +
    +

    ${_("Your Progress")}

    + +
      +
    1. + 0 + ${_("Current Step: ")}${_("Intro")} +
    2. + +
    3. + 1 + ${_("Take Photo")} +
    4. + +
    5. + 2 + ${_("Take ID Photo")} +
    6. + +
    7. + 3 + ${_("Review")} +
    8. + +
    9. + 4 + ${_("Make Payment")} +
    10. + +
    11. + + + + ${_("Confirmation")} +
    12. +
    + + + + +
    +
    + + +
    +
    +

    ${_("What You Will Need to Register")}

    + +
    +

    ${_("There are three things you will need to register as an ID verified student:")}

    +
    + + + + +
    +
    + + <%include file="_verification_support.html" /> +
    +
    + diff --git a/lms/templates/verify_student/verified.html b/lms/templates/verify_student/verified.html new file mode 100644 index 0000000000..41b4b312b9 --- /dev/null +++ b/lms/templates/verify_student/verified.html @@ -0,0 +1,106 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-verified +<%block name="title">${_("Register for {} | Verification").format(course_name)} + +<%block name="js_extra"> + + + +<%block name="content"> +
    +
    + + <%include file="_verification_header.html" /> + +
    +
    +

    ${_("Your Progress")}

    + +
      +
    1. + 1 + ${_("ID Verification")} +
    2. +
    3. + 2 + ${_("Current Step: ")}${_("Review")} +
    4. + +
    5. + 3 + ${_("Make Payment")} +
    6. + +
    7. + + + + ${_("Confirmation")} +
    8. +
    + + + + +
    +
    + +
    +
    +

    ${_("You've Been Verified Previously")}

    + +
    +

    ${_("We've already verified your identity (through the photos of you and your ID you provided earlier). You can proceed to make your secure payment and complete registration.")}

    +
    + + +
    +
    + + <%include file="_verification_support.html" /> +
    +
    + diff --git a/lms/templates/video.html b/lms/templates/video.html index 43f36915a0..caf0aaa06f 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -23,43 +23,50 @@ data-end="${end}" data-caption-asset-path="${caption_asset_path}" data-autoplay="${autoplay}" + data-yt-test-timeout="${yt_test_timeout}" + data-yt-test-url="${yt_test_url}" + + tabindex="-1" > +
    +
    + +
    % if sources.get('main'): diff --git a/lms/templates/widgets/html-edit.html b/lms/templates/widgets/html-edit.html new file mode 100644 index 0000000000..0cb0ca4f0a --- /dev/null +++ b/lms/templates/widgets/html-edit.html @@ -0,0 +1,13 @@ +<%! from django.utils.translation import ugettext as _ %> + +
    + + +
    + + +
    +
    diff --git a/lms/templates/widgets/segment-io.html b/lms/templates/widgets/segment-io.html index b34ee7a352..87dc0eebe0 100644 --- a/lms/templates/widgets/segment-io.html +++ b/lms/templates/widgets/segment-io.html @@ -1,14 +1,21 @@ % if settings.MITX_FEATURES.get('SEGMENT_IO_LMS'): +<%! from django.core.urlresolvers import reverse %> +<%! import waffle %> + +<% active_flags = " + ".join(waffle.get_flags(request)) %> + diff --git a/lms/urls.py b/lms/urls.py index 7998acc2d0..e624ac9f34 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -58,8 +58,17 @@ urlpatterns = ('', # nopep8 url(r'^heartbeat$', include('heartbeat.urls')), url(r'^user_api/', include('user_api.urls')), + + url(r'^', include('waffle.urls')), ) +# if settings.MITX_FEATURES.get("MULTIPLE_ENROLLMENT_ROLES"): +urlpatterns += ( + url(r'^verify_student/', include('verify_student.urls')), + url(r'^course_modes/', include('course_modes.urls')), +) + + js_info_dict = { 'domain': 'djangojs', 'packages': ('lms',), @@ -190,6 +199,7 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/?$', 'branding.views.courses', name="courses"), url(r'^change_enrollment$', 'student.views.change_enrollment', name="change_enrollment"), + url(r'^change_email_settings$', 'student.views.change_email_settings', name="change_email_settings"), #About the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/about$', @@ -337,6 +347,7 @@ if settings.COURSEWARE_ENABLED: name='submission_history'), ) + if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): urlpatterns += ( url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor_dashboard$', @@ -362,6 +373,12 @@ if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): url(r'^shib-login/$', 'external_auth.views.shib_login', name='shib-login'), ) +if settings.MITX_FEATURES.get('AUTH_USE_CAS'): + urlpatterns += ( + url(r'^cas-auth/login/$', 'external_auth.views.cas_login', name="cas-login"), + url(r'^cas-auth/logout/$', 'django_cas.views.logout', {'next_page': '/'}, name="cas-logout"), + ) + if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): urlpatterns += ( url(r'^course_specific_login/(?P[^/]+/[^/]+/[^/]+)/$', diff --git a/lms/wsgi_apache_lms.py b/lms/wsgi_apache_lms.py index 0f9950ca41..41c3317e14 100644 --- a/lms/wsgi_apache_lms.py +++ b/lms/wsgi_apache_lms.py @@ -3,13 +3,10 @@ import os os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") os.environ.setdefault("SERVICE_VARIANT", "lms") +import lms.startup as startup +startup.run() + # This application object is used by the development server # as well as any WSGI server configured to use this file. from django.core.wsgi import get_wsgi_application application = get_wsgi_application() - -from django.conf import settings -from xmodule.modulestore.django import modulestore - -for store_name in settings.MODULESTORE: - modulestore(store_name) diff --git a/lms/xblock/__init__.py b/lms/xblock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/xblock/field_data.py b/lms/xblock/field_data.py new file mode 100644 index 0000000000..6073a86863 --- /dev/null +++ b/lms/xblock/field_data.py @@ -0,0 +1,26 @@ +""" +:class:`~xblock.field_data.FieldData` subclasses used by the LMS +""" + +from xblock.field_data import ReadOnlyFieldData, SplitFieldData +from xblock.fields import Scope + + +def lms_field_data(authored_data, student_data): + """ + Returns a new :class:`~xblock.field_data.FieldData` that + reads all UserScope.ONE and UserScope.ALL fields from `student_data` + and all UserScope.NONE fields from `authored_data`. It also prevents + writing to `authored_data`. + """ + authored_data = ReadOnlyFieldData(authored_data) + return SplitFieldData({ + Scope.content: authored_data, + Scope.settings: authored_data, + Scope.parent: authored_data, + Scope.children: authored_data, + Scope.user_state_summary: student_data, + Scope.user_state: student_data, + Scope.user_info: student_data, + Scope.preferences: student_data, + }) diff --git a/lms/xblock/mixin.py b/lms/xblock/mixin.py new file mode 100644 index 0000000000..edf84fbe6b --- /dev/null +++ b/lms/xblock/mixin.py @@ -0,0 +1,22 @@ +""" +Namespace that defines fields common to all blocks used in the LMS +""" +from xblock.fields import Boolean, Scope, String, XBlockMixin + + +class LmsBlockMixin(XBlockMixin): + """ + Mixin that defines fields common to all blocks used in the LMS + """ + hide_from_toc = Boolean( + help="Whether to display this module in the table of contents", + default=False, + scope=Scope.settings + ) + format = String( + help="What format this module is in (used for deciding which " + "grader to apply, and what to show in the TOC)", + scope=Scope.settings, + ) + source_file = String(help="source file name (eg for latex)", scope=Scope.settings) + ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py deleted file mode 100644 index ad3f634977..0000000000 --- a/lms/xmodule_namespace.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Namespace that defines fields common to all blocks used in the LMS -""" -from xblock.core import Namespace, Boolean, Scope, String, Float -from xmodule.fields import Date, Timedelta -from datetime import datetime -from pytz import UTC - - -class LmsNamespace(Namespace): - """ - Namespace that defines fields common to all blocks used in the LMS - """ - hide_from_toc = Boolean( - help="Whether to display this module in the table of contents", - default=False, - scope=Scope.settings - ) - graded = Boolean( - help="Whether this module contributes to the final course grade", - default=False, - scope=Scope.settings - ) - format = String( - help="What format this module is in (used for deciding which " - "grader to apply, and what to show in the TOC)", - scope=Scope.settings, - ) - - start = Date( - help="Start time when this module is visible", - default=datetime.fromtimestamp(0, UTC), - scope=Scope.settings - ) - due = Date(help="Date that this problem is due by", scope=Scope.settings) - source_file = String(help="source file name (eg for latex)", scope=Scope.settings) - giturl = String(help="url root for course data git repository", scope=Scope.settings) - xqa_key = String(help="DO NOT USE", scope=Scope.settings) - ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) - graceperiod = Timedelta( - help="Amount of time after the due date that submissions will be accepted", - scope=Scope.settings - ) - showanswer = String( - help="When to show the problem answer to the student", - scope=Scope.settings, - default="finished" - ) - rerandomize = String( - help="When to rerandomize the problem", - default="never", - scope=Scope.settings - ) - days_early_for_beta = Float( - help="Number of days early to show content to beta users", - default=None, - scope=Scope.settings - ) - static_asset_path = String(help="Path to use for static assets - overrides Studio c4x://", scope=Scope.settings, default='') diff --git a/pylintrc b/pylintrc index 9525f04362..a3c84c1555 100644 --- a/pylintrc +++ b/pylintrc @@ -89,6 +89,9 @@ evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / stateme # evaluation report (RP0004). comment=no +# Display symbolic names of messages in reports +symbols=yes + [TYPECHECK] @@ -120,7 +123,10 @@ generated-members= content, status_code, # For factory_boy factories - create + create, + build, +# For xblocks + fields, [BASIC] diff --git a/rakelib/acceptance_test.rake b/rakelib/acceptance_test.rake new file mode 100644 index 0000000000..f6fdfd9ad6 --- /dev/null +++ b/rakelib/acceptance_test.rake @@ -0,0 +1,71 @@ +ACCEPTANCE_DB = 'test_root/db/test_edx.db' +ACCEPTANCE_REPORT_DIR = report_dir_path('acceptance') +directory ACCEPTANCE_REPORT_DIR + +def run_acceptance_tests(system, harvest_args) + # Create the acceptance report directory + # because if it doesn't exist then lettuce will give an IOError. + report_dir = report_dir_path('acceptance') + + report_file = File.join(ACCEPTANCE_REPORT_DIR, "#{system}.xml") + report_args = "--with-xunit --xunit-file #{report_file}" + test_sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--verbosity 2', report_args, harvest_args)) +end + +task :setup_acceptance_db do + # HACK: Since the CMS depends on the existence of some database tables + # that are now in common but used to be in LMS (Role/Permissions for Forums) + # we need to create/migrate the database tables defined in the LMS. + # We might be able to address this by moving out the migrations from + # lms/django_comment_client, but then we'd have to repair all the existing + # migrations from the upgrade tables in the DB. + # But for now for either system (lms or cms), use the lms + # definitions to sync and migrate. + if File.exists?(ACCEPTANCE_DB) + File.delete(ACCEPTANCE_DB) + end + + sh(django_admin('lms', 'acceptance', 'syncdb', '--noinput')) + sh(django_admin('lms', 'acceptance', 'migrate', '--noinput')) +end + +task :prep_for_acceptance_tests => [ + :clean_reports_dir, :clean_test_files, ACCEPTANCE_REPORT_DIR, + :install_prereqs, :setup_acceptance_db +] + +namespace :test do + namespace :acceptance do + task :all, [:harvest_args] => [ + :prep_for_acceptance_tests, + "^^lms:gather_assets:acceptance", + "^^cms:gather_assets:acceptance" + ] do |t, args| + run_acceptance_tests('lms', args.harvest_args) + run_acceptance_tests('cms', args.harvest_args) + end + + ['lms', 'cms'].each do |system| + desc "Run the acceptance tests for the #{system}" + task system, [:harvest_args] => [ + :prep_for_acceptance_tests, + "^^#{system}:gather_assets:acceptance" + ] do |t, args| + args.with_defaults(:harvest_args => '') + run_acceptance_tests(system, args.harvest_args) + end + + desc "Run acceptance tests for the #{system} without collectstatic or db migrations" + task "#{system}:fast", [:harvest_args] => [ + :clean_reports_dir, ACCEPTANCE_REPORT_DIR, + ] do |t, args| + args.with_defaults(:harvest_args => '') + run_acceptance_tests(system, args.harvest_args) + end + end + end + desc "Run the lettuce acceptance tests for lms and cms" + task :acceptance, [:harvest_args] do |t, args| + Rake::Task["test:acceptance:all"].invoke(args.harvest_args) + end +end diff --git a/rakelib/assets.rake b/rakelib/assets.rake index 6b2ce4bef5..ca24f61be7 100644 --- a/rakelib/assets.rake +++ b/rakelib/assets.rake @@ -45,7 +45,7 @@ def sass_cmd(watch=false, debug=false) sass_watch_paths << THEME_SASS end - "sass #{debug ? '--debug-info' : '--style compressed'} " + + "sass #{debug ? '' : '--style compressed'} " + "--load-path #{sass_load_paths.join(' ')} " + "#{watch ? '--watch' : '--update'} -E utf-8 #{sass_watch_paths.join(' ')}" end diff --git a/rakelib/deprecated.rake b/rakelib/deprecated.rake index 55c033226c..fc7d67e43c 100644 --- a/rakelib/deprecated.rake +++ b/rakelib/deprecated.rake @@ -24,6 +24,8 @@ end deprecated("jasmine:#{system}:phantomjs", "test:js:run", system) deprecated("#{system}:check_settings:jasmine", "") deprecated("#{system}:gather_assets:jasmine", "") + deprecated("test_acceptance_#{system}", "test:acceptance:#{system}") + deprecated("fasttest_acceptance_#{system}", "test:acceptance:#{system}:fast") end Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| @@ -49,3 +51,4 @@ deprecated("jasmine:common/static/coffee:phantomjs", "test:js:run", "common") deprecated("jasmine", "test:js") deprecated("jasmine:phantomjs", "test:js:run") deprecated("jasmine:browser", "test:js:dev") +deprecated("test_acceptance", "test:acceptance") diff --git a/rakelib/helpers.rb b/rakelib/helpers.rb index 6826570a18..18473739b5 100644 --- a/rakelib/helpers.rb +++ b/rakelib/helpers.rb @@ -1,6 +1,7 @@ require 'digest/md5' require 'sys/proctable' require 'colorize' +require 'timeout' def find_executable(exec) path = %x(which #{exec}).strip diff --git a/rakelib/js_test.rake b/rakelib/js_test.rake index 68b00e398f..d1cce49350 100644 --- a/rakelib/js_test.rake +++ b/rakelib/js_test.rake @@ -1,6 +1,7 @@ JS_TEST_SUITES = { 'lms' => 'lms/static/js_test.yml', 'cms' => 'cms/static/js_test.yml', + # 'cms-squire' => 'cms/static/js_test_squire.yml', 'xmodule' => 'common/lib/xmodule/xmodule/js/js_test.yml', 'common' => 'common/static/js_test.yml', } diff --git a/rakelib/prereqs.rake b/rakelib/prereqs.rake index e06d411435..a40e0ac529 100644 --- a/rakelib/prereqs.rake +++ b/rakelib/prereqs.rake @@ -29,9 +29,9 @@ task :install_python_prereqs => "ws:migrate" do unchanged = 'Python requirements unchanged, nothing to install' when_changed(unchanged, ['requirements/**/*'], [site_packages_dir]) do ENV['PIP_DOWNLOAD_CACHE'] ||= '.pip_download_cache' - sh('pip install --exists-action w -r requirements/edx/pre.txt') - sh('pip install --exists-action w -r requirements/edx/base.txt') - sh('pip install --exists-action w -r requirements/edx/post.txt') + sh('pip install -q --exists-action w -r requirements/edx/pre.txt') + sh('pip install -q --exists-action w -r requirements/edx/base.txt') + sh('pip install -q --exists-action w -r requirements/edx/post.txt') # requirements/private.txt is used to install our libs as # working dirs, or for personal-use tools. if File.file?("requirements/private.txt") diff --git a/rakelib/quality.rake b/rakelib/quality.rake index a3314919bf..dd34b0ccf4 100644 --- a/rakelib/quality.rake +++ b/rakelib/quality.rake @@ -48,13 +48,20 @@ dquality_dir = File.join(REPORT_DIR, "diff_quality") directory dquality_dir desc "Build the html diff quality reports, and print the reports to the console." -task :quality => dquality_dir do +task :quality => [dquality_dir, :install_python_prereqs] do + # Generage diff-quality html report for pep8, and print to console - sh("diff-quality --violations=pep8 --html-report #{dquality_dir}/diff_quality_pep8.html") - sh("diff-quality --violations=pep8") + # If pep8 reports exist, use those + # Otherwise, `diff-quality` will call pep8 itself + pep8_reports = FileList[File.join(REPORT_DIR, '**/pep8.report')].join(' ') + sh("diff-quality --violations=pep8 --html-report #{dquality_dir}/diff_quality_pep8.html #{pep8_reports}") + sh("diff-quality --violations=pep8 #{pep8_reports}") # Generage diff-quality html report for pylint, and print to console + # If pylint reports exist, use those + # Otherwise, `diff-quality` will call pylint itself + pylint_reports = FileList[File.join(REPORT_DIR, '**/pylint.report')].join(' ') pythonpath_prefix = "PYTHONPATH=$PYTHONPATH:lms:lms/djangoapps:lms/lib:cms:cms/djangoapps:cms/lib:common:common/djangoapps:common/lib" - sh("#{pythonpath_prefix} diff-quality --violations=pylint --html-report #{dquality_dir}/diff_quality_pylint.html") - sh("#{pythonpath_prefix} diff-quality --violations=pylint") -end \ No newline at end of file + sh("#{pythonpath_prefix} diff-quality --violations=pylint --html-report #{dquality_dir}/diff_quality_pylint.html #{pylint_reports}") + sh("#{pythonpath_prefix} diff-quality --violations=pylint #{pylint_reports}") +end diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 1c976b1c05..130ff3d9fd 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -17,33 +17,21 @@ def run_under_coverage(cmd, root) end def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) - ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") - test_id_file = File.join(test_id_dir(system), "noseids") - dirs = Dir["common/djangoapps/*"] + Dir["#{system}/djangoapps/*"] - test_id = dirs.join(' ') if test_id.nil? or test_id == '' - cmd = django_admin( - system, :test, 'test', - '--logging-clear-handlers', - '--liveserver=localhost:8000-9000', - "--id-file=#{test_id_file}", - test_id) - test_sh(run_under_coverage(cmd, system)) -end -def run_acceptance_tests(system, report_dir, harvest_args) - # HACK: Since now the CMS depends on the existence of some database tables - # that used to be in LMS (Role/Permissions for Forums) we need to make - # sure the acceptance tests create/migrate the database tables - # that are represented in the LMS. We might be able to address this by moving - # out the migrations from lms/django_comment_client, but then we'd have to - # repair all the existing migrations from the upgrade tables in the DB. - if system == :cms - sh(django_admin('lms', 'acceptance', 'syncdb', '--noinput')) - sh(django_admin('lms', 'acceptance', 'migrate', '--noinput')) + # If no test id is provided, we need to limit the test runner + # to the Djangoapps we want to test. Otherwise, it will + # run tests on all installed packages. + if test_id.nil? + test_id = "#{system}/djangoapps common/djangoapps" + + # Handle "--failed" as a special case: we want to re-run only + # the tests that failed within our Django apps + elsif test_id == '--failed' + test_id = "#{system}/djangoapps common/djangoapps --failed" end - sh(django_admin(system, 'acceptance', 'syncdb', '--noinput')) - sh(django_admin(system, 'acceptance', 'migrate', '--noinput')) - test_sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--tag -skip', harvest_args)) + + cmd = django_admin(system, :test, 'test', test_id) + test_sh(run_under_coverage(cmd, system)) end # Run documentation tests @@ -57,8 +45,9 @@ task :test_docs do end task :clean_test_files do - desc "Clean fixture files used by tests" - sh("git clean -fqdx test_root") + desc "Clean fixture files used by tests and .pyc files" + sh("git clean -fqdx test_root/logs test_root/data test_root/staticfiles test_root/uploads") + sh("find . -type f -name *.pyc -delete") end task :clean_reports_dir => REPORT_DIR do @@ -69,7 +58,6 @@ task :clean_reports_dir => REPORT_DIR do sh("find #{REPORT_DIR} -type f -delete") end - TEST_TASK_DIRS = [] [:lms, :cms].each do |system| @@ -80,28 +68,18 @@ TEST_TASK_DIRS = [] # Per System tasks/ desc "Run all django tests on our djangoapps for the #{system}" - task "test_#{system}", [:test_id] => [:clean_test_files, :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] + task "test_#{system}", [:test_id] => [ + :clean_test_files, :install_prereqs, + "#{system}:gather_assets:test", "fasttest_#{system}" + ] # Have a way to run the tests without running collectstatic -- useful when debugging without # messing with static files. - task "fasttest_#{system}", [:test_id] => [test_id_dir, report_dir, :clean_reports_dir, :install_prereqs, :predjango] do |t, args| + task "fasttest_#{system}", [:test_id] => [test_id_dir, report_dir, :clean_reports_dir] do |t, args| args.with_defaults(:test_id => nil) run_tests(system, report_dir, args.test_id) end - # Run acceptance tests - desc "Run acceptance tests" - #gather_assets uses its own env because acceptance contains seeds to make the information unique - #acceptance_static is acceptance without the random seeding - task "test_acceptance_#{system}", [:harvest_args] => [:clean_test_files, "#{system}:gather_assets:acceptance_static", "fasttest_acceptance_#{system}"] - - desc "Run acceptance tests without collectstatic" - task "fasttest_acceptance_#{system}", [:harvest_args] => [report_dir, :clean_reports_dir, :predjango] do |t, args| - args.with_defaults(:harvest_args => '') - run_acceptance_tests(system, report_dir, args.harvest_args) - end - - task :fasttest => "fasttest_#{system}" TEST_TASK_DIRS << system @@ -116,7 +94,10 @@ Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| directory test_id_dir desc "Run tests for common lib #{lib}" - task "test_#{lib}", [:test_id] => [test_id_dir, report_dir, :clean_reports_dir] do |t, args| + task "test_#{lib}", [:test_id] => [ + test_id_dir, report_dir, :clean_test_files, + :clean_reports_dir, :install_prereqs + ] do |t, args| args.with_defaults(:test_id => lib) ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") cmd = "nosetests --id-file=#{test_ids} #{args.test_id}" @@ -139,11 +120,16 @@ TEST_TASK_DIRS.each do |dir| report_dir = report_dir_path(dir) directory report_dir task :report_dirs => [REPORT_DIR, report_dir] - task :test => "test_#{dir}" + task 'test:python' => "test_#{dir}" +end + +namespace :test do + desc "Run all python tests" + task :python, [:test_id] end desc "Run all tests" -task :test, [:test_id] => :test_docs +task :test, [:test_id] => [:test_docs, 'test:python'] desc "Build the html, xml, and diff coverage reports" task :coverage => :report_dirs do @@ -162,7 +148,7 @@ task :coverage => :report_dirs do end end - + # Find all coverage XML files (both Python and JavaScript) xml_reports = FileList[File.join(REPORT_DIR, '**/coverage.xml')] diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index d5f05083c8..6612d7ab2b 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -1,3 +1,11 @@ +# DON'T JUST ADD NEW DEPENDENCIES!!! +# +# If you open a pull request that adds a new dependency, you should notify: +# * @jtauber - to check licensing +# * One of @e0d, @jarv, or @feanil - to check system requirements + numpy==1.6.2 networkx==1.7 -sympy==0.7.1 \ No newline at end of file +sympy==0.7.1 +pyparsing==1.5.6 +nltk==2.0.4 diff --git a/requirements/edx-sandbox/local.txt b/requirements/edx-sandbox/local.txt index c21a50338a..8e1e5cb7f2 100644 --- a/requirements/edx-sandbox/local.txt +++ b/requirements/edx-sandbox/local.txt @@ -1,3 +1,9 @@ +# DON'T JUST ADD NEW DEPENDENCIES!!! +# +# If you open a pull request that adds a new dependency, you should notify: +# * @jtauber - to check licensing +# * One of @e0d, @jarv, or @feanil - to check system requirements + # Install these packages from the edx-platform working tree # NOTE: if you change code in these packages, you MUST change the version # number in its setup.py or the code WILL NOT be installed during deploy. diff --git a/requirements/edx-sandbox/post.txt b/requirements/edx-sandbox/post.txt index 218fdf307e..fbaf761da2 100644 --- a/requirements/edx-sandbox/post.txt +++ b/requirements/edx-sandbox/post.txt @@ -1,3 +1,9 @@ +# DON'T JUST ADD NEW DEPENDENCIES!!! +# +# If you open a pull request that adds a new dependency, you should notify: +# * @jtauber - to check licensing +# * One of @e0d, @jarv, or @feanil - to check system requirements + # Packages to install in the Python sandbox for secured execution. scipy==0.11.0 lxml==3.0.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a0c07a9305..8da2504f2e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -1,9 +1,16 @@ +# DON'T JUST ADD NEW DEPENDENCIES!!! +# +# If you open a pull request that adds a new dependency, you should notify: +# * @jtauber - to check licensing +# * One of @e0d, @jarv, or @feanil - to check system requirements + -r repo.txt beautifulsoup4==4.1.3 beautifulsoup==3.2.1 boto==2.6.0 celery==3.0.19 +dealer==0.2.3 distribute>=0.6.28, <0.7 django-celery==3.0.17 django-countries==1.5 @@ -12,6 +19,7 @@ django-followit==0.0.3 django-keyedcache==1.4-6 django-kombu==0.9.4 django-mako==0.1.5pre +django-model-utils==1.4.0 django-masquerade==0.1.6 django-mptt==0.5.5 django-openid-auth==0.4 @@ -22,38 +30,41 @@ django-storages==1.1.5 django-threaded-multihost==1.4-1 django-method-override==0.1.0 djangorestframework==2.3.5 -django==1.4.5 +django==1.4.8 feedparser==5.1.3 fs==0.4.0 GitPython==0.3.2.RC1 glob2==0.3 +lazy==1.1 lxml==3.0.1 mako==0.7.3 Markdown==2.2.1 networkx==1.7 nltk==2.0.4 +oauthlib==0.5.1 paramiko==1.9.0 path.py==3.0.1 Pillow==1.7.8 pip>=1.4 polib==1.0.3 pycrypto>=2.6 -pygments==1.5 +pygments==1.6 pygraphviz==1.1 pymongo==2.4.1 +pyparsing==1.5.6 python-memcached==1.48 python-openid==2.2.5 pytz==2012h PyYAML==3.10 -requests==0.14.2 +requests==1.2.3 scipy==0.11.0 Shapely==1.2.16 +singledispatch==3.4.0.2 sorl-thumbnail==11.12 South==0.7.6 sympy==0.7.1 xmltodict==0.4.1 django-ratelimit-backend==0.6 -django-model-utils==1.4.0 # Used for debugging ipython==0.13.1 @@ -63,8 +74,7 @@ watchdog==0.6.0 # Metrics gathering and monitoring dogapi==1.2.1 -dogstatsd-python==0.2.1 -newrelic==1.8.0.13 +newrelic==1.13.1.31 # Used for documentation gathering sphinx==1.1.3 @@ -76,7 +86,6 @@ transifex-client==0.9.1 # Used for testing coverage==3.6 factory_boy==2.0.2 -lettuce==0.2.16 mock==1.0.1 nosexcover==1.0.7 pep8==1.4.5 @@ -91,3 +100,6 @@ nose-ignore-docstring nose-exclude git+https://github.com/mfogel/django-settings-context-processor.git + +# django-cas version 2.0.3 with patch to be compatible with django 1.4 +git+https://github.com/mitocw/django-cas.git diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 3023fa0607..31ee9b3fd5 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -1,14 +1,22 @@ +# DON'T JUST ADD NEW DEPENDENCIES!!! +# +# If you open a pull request that adds a new dependency, you should notify: +# * @jtauber - to check licensing +# * One of @e0d, @jarv, or @feanil - to check system requirements + # Python libraries to install directly from github # Third-party: -e git+https://github.com/edx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles -e git+https://github.com/edx/django-pipeline.git#egg=django-pipeline -e git+https://github.com/edx/django-wiki.git@41815e2ef1b0323f92900f8e60711b0f0c37766b#egg=django-wiki +-e git+https://github.com/edx/lettuce.git@503fe2d2599290c45b021d6c424ab5ea899e42be#egg=lettuce -e git+https://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@446668fddc75b78512eef4e9425cbc9a3327606f#egg=XBlock +-e git+https://github.com/edx/XBlock.git@8a66ca3#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail --e git+https://github.com/edx/diff-cover.git@v0.2.1#egg=diff_cover --e git+https://github.com/edx/js-test-tool.git@v0.0.7#egg=js_test_tool +-e git+https://github.com/edx/diff-cover.git@v0.2.5#egg=diff_cover +-e git+https://github.com/edx/js-test-tool.git@v0.1.1#egg=js_test_tool +-e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle diff --git a/requirements/edx/local.txt b/requirements/edx/local.txt index 04a1f7f2c6..7941f9fa5e 100644 --- a/requirements/edx/local.txt +++ b/requirements/edx/local.txt @@ -5,4 +5,3 @@ -e common/lib/sandbox-packages -e common/lib/symmath -e common/lib/xmodule --e . diff --git a/requirements/edx/post.txt b/requirements/edx/post.txt index b637b65db0..402d80e621 100644 --- a/requirements/edx/post.txt +++ b/requirements/edx/post.txt @@ -1,2 +1,8 @@ +# DON'T JUST ADD NEW DEPENDENCIES!!! +# +# If you open a pull request that adds a new dependency, you should notify: +# * @jtauber - to check licensing +# * One of @e0d, @jarv, or @feanil - to check system requirements + # This must be installed after distribute has been updated. MySQL-python==1.2.4 diff --git a/requirements/edx/pre.txt b/requirements/edx/pre.txt index a8dff9bf9a..dd00da4e88 100644 --- a/requirements/edx/pre.txt +++ b/requirements/edx/pre.txt @@ -1,3 +1,9 @@ +# DON'T JUST ADD NEW DEPENDENCIES!!! +# +# If you open a pull request that adds a new dependency, you should notify: +# * @jtauber - to check licensing +# * One of @e0d, @jarv, or @feanil - to check system requirements + # Numpy and scipy can't be installed in the same pip run. # Install numpy before other things to help resolve the problem. numpy==1.6.2 diff --git a/requirements/system/mac_os_x/brew-formulas.txt b/requirements/system/mac_os_x/brew-formulas.txt index 061297edc5..22558042c4 100644 --- a/requirements/system/mac_os_x/brew-formulas.txt +++ b/requirements/system/mac_os_x/brew-formulas.txt @@ -10,3 +10,6 @@ graphviz mysql geos mongodb +lynx +libjpeg +libtiff diff --git a/requirements/system/ubuntu/apt-packages.txt b/requirements/system/ubuntu/apt-packages.txt index 5dc47157f6..6f130107da 100644 --- a/requirements/system/ubuntu/apt-packages.txt +++ b/requirements/system/ubuntu/apt-packages.txt @@ -16,6 +16,8 @@ gfortran libfreetype6-dev libpng12-dev libjpeg-dev +libtiff4-dev +zlib1g-dev libxml2-dev libxslt-dev yui-compressor @@ -33,3 +35,4 @@ coffeescript mysql-client virtualenvwrapper libgeos-ruby1.8 +lynx-cur diff --git a/scripts/install-acceptance-req.sh b/scripts/install-acceptance-req.sh new file mode 100755 index 0000000000..012e983f30 --- /dev/null +++ b/scripts/install-acceptance-req.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +# Install all the requirements for running the +# acceptance test suite and the JavaScript +# unit test suite. +# +# Requires 32-bit Ubuntu + +# Exit if any commands return a non-zero status +set -e + +sudo apt-get update + +sudo apt-get install unzip + +# Install xvfb +if [ -z `command -v xvfb` ]; then + echo "Installing Xvfb..." + sudo apt-get install -y xvfb + + # Install the xvfb upstart script + sudo cat > /etc/init/xvfb.conf <> .bashrc < /dev/null || sudo restart xvfb 2> /dev/null || echo "Cannot start xvfb" + +# Move to a temp directory so we can download things +cd /var/tmp + +# Install Chrome +echo "Downloading Google Chrome..." +if [ -z `command -v google-chrome` ]; then + wget --quiet https://dl.google.com/linux/direct/google-chrome-stable_current_i386.deb + sudo dpkg -i google-chrome*.deb 2> /dev/null || true + sudo apt-get -f -y install +else + echo "Already installed; skipping." +fi + +# Install ChromeDriver +echo "Installing ChromeDriver..." +if [ -z `command -v chromedriver` ]; then + wget --quiet https://chromedriver.googlecode.com/files/chromedriver_linux32_2.3.zip + unzip chromedriver_linux32_2.3.zip + sudo mv chromedriver /usr/local/bin/chromedriver + sudo chmod go+rx /usr/local/bin/chromedriver +else + echo "Already installed; skipping." +fi + +# Install Firefox +echo "Installing Firefox..." +sudo apt-get -y install firefox + +# Install dbus (required for FF) +sudo apt-get -y install dbus-x11 + +# Install PhantomJS +echo "Installing PhantomJS..." +if [ -z `command -v phantomjs` ]; then + wget --quiet "https://phantomjs.googlecode.com/files/phantomjs-1.9.1-linux-i686.tar.bz2" + tar -xjf phantomjs-1.9.1-linux-i686.tar.bz2 + sudo mv phantomjs-1.9.1-linux-i686/bin/phantomjs /usr/local/bin/phantomjs +else + echo "Already installed; skipping." +fi + +exit 0 diff --git a/scripts/runone.py b/scripts/runone.py index 8baf6790b8..19b5f7195b 100755 --- a/scripts/runone.py +++ b/scripts/runone.py @@ -53,7 +53,8 @@ def main(argv): # Run as a django test suite from django.core import management - django_args = ["./manage.py", system, "--settings", "test", "test"] + os.environ['DJANGO_SETTINGS_MODULE'] = system + '.envs.test' + django_args = ["./manage.py", "test"] if args.nocapture: django_args.append("-s") if args.pdb: diff --git a/scripts/vagrant-provisioning.sh b/scripts/vagrant-provisioning.sh index 0243cd36ae..d7c098a5ce 100755 --- a/scripts/vagrant-provisioning.sh +++ b/scripts/vagrant-provisioning.sh @@ -106,6 +106,9 @@ on_create() # Permissions chown vagrant.vagrant ~vagrant/.bash_profile + # Install completed entirely & successfully - set flag to skip in future runs + touch /opt/edx/.install_succeeded + cat << EOF ============================================================================== Success - Created your development environment! @@ -114,9 +117,8 @@ Success - Created your development environment! EOF } # End on_create() ######################################################## -## only initialize / setup the development environment once: -# we create node_modules, so that's a good test: -[[ -d /opt/edx/node_modules ]] || on_create +## only initialize / setup the development environment once: +[[ -f /opt/edx/.install_succeeded ]] || on_create # grab what the Vagrantfile spec'd our IP to be: # expecting: diff --git a/setup.cfg b/setup.cfg index da9525a300..abc86c43fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,5 +7,6 @@ with-id=1 exclude-dir=lms/envs cms/envs -# Uncomment the following line to open pdb when a test fails +# Uncomment the following lines to open pdb when a test fails +#nocapture=1 #pdb=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 48572de6de..0000000000 --- a/setup.py +++ /dev/null @@ -1,19 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="edX Apps", - version="0.1", - install_requires=['distribute'], - requires=[ - 'xmodule', - ], - py_modules=['lms.xmodule_namespace', 'cms.xmodule_namespace'], - # See http://guide.python-distribute.org/creation.html#entry-points - # for a description of entry_points - entry_points={ - 'xblock.namespace': [ - 'lms = lms.xmodule_namespace:LmsNamespace', - 'cms = cms.xmodule_namespace:CmsNamespace', - ], - } -) \ No newline at end of file
    ${_('XML attributes')}