diff --git a/.gitignore b/.gitignore index 05e76c4caa..69bc47afdd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ .AppleDouble database.sqlite requirements/private.txt +lms/envs/private.py +cms/envs/private.py courseware/static/js/mathjax/* flushdb.sh build @@ -27,6 +29,7 @@ conf/locale/en/LC_MESSAGES/*.po !messages.po 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 diff --git a/.ruby-gemset b/.ruby-gemset index 93a8706d3e..77266c35f0 100644 --- a/.ruby-gemset +++ b/.ruby-gemset @@ -1 +1 @@ -mitx +edx-platform diff --git a/AUTHORS b/AUTHORS index cdfdd4c2fe..9bb4ede121 100644 --- a/AUTHORS +++ b/AUTHORS @@ -72,3 +72,9 @@ Giulio Gratta David Baumgold Jason Bau Frances Botsford +Jonah Stanley +Slater Victoroff +Peter Fogg +Bethany LaPenta +Renzo Lucioni +Felix Sun diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000000..0e161e4f72 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,140 @@ +Change Log +---------- + +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. + +Studio: Remove XML from the video component editor. All settings are +moved to be edited as metadata. + +XModule: Only write out assets files if the contents have changed. + +XModule: Don't delete generated xmodule asset files when compiling (for +instance, when XModule provides a coffeescript file, don't delete +the associated javascript) + +Studio: For courses running on edx.org (marketing site), disable fields in +Course Settings that do not apply. + +Common: Make asset watchers run as singletons (so they won't start if the +watcher is already running in another shell). + +Common: Use coffee directly when watching for coffeescript file changes. + +Common: Make rake provide better error messages if packages are missing. + +Common: Repairs development documentation generation by sphinx. + +LMS: Problem rescoring. Added options on the Grades tab of the +Instructor Dashboard to allow all students' submissions for a +particular problem to be rescored. Also supports resetting all +students' number of attempts to zero. Provides a list of background +tasks that are currently running for the course, and an option to +see a history of background tasks for a given problem. + +LMS: Fixed the preferences scope for storing data in xmodules. + +LMS: Forums. Added handling for case where discussion module can get `None` as +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. + +LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django +setting now run entirely outside the Python sandbox. + +Blades: Added tests for Video Alpha player. + +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. + +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__` + +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. + +LMS: Background colors on login, register, and courseware have been corrected +back to white. + +LMS: Accessibility improvements have been made to several courseware and +navigation elements. + +LMS: Small design/presentation changes to login and register views. + +LMS: Functionality added to instructor enrollment tab in LMS such that invited +student can be auto-enrolled in course or when activating if not current +student. + +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: Developers can now have private Django settings files. + +Common: Safety code added to prevent anything above the vertical level in the +course tree from being marked as version='draft'. It will raise an exception if +the code tries to so mark a node. We need the backtraces to figure out where +this very infrequent intermittent marking was occurring. It was making courses +look different in Studio than in LMS. + +Deploy: MKTG_URLS is now read from env.json. + +Common: Theming makes it possible to change the look of the site, from +Stanford. + +Common: Accessibility UI fixes. + +Common: The "duplicate email" error message is more informative. + +Studio: Component metadata settings editor. + +Studio: Autoplay for Video Alpha is disabled (only in Studio). + +Studio: Single-click creation for video and discussion components. + +Studio: fixed a bad link in the activation page. + +LMS: Changed the help button text. + +LMS: Fixed failing numeric response (decimal but no trailing digits). + +LMS: XML Error module no longer shows students a stack trace. + +Blades: Videoalpha. + +XModules: Added partial credit for foldit module. + +XModules: Added "randomize" XModule to list of XModule types. + +XModules: Show errors with full descriptors. + +XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is +dropped suddenly. + +XQueue: Upload file submissions to a specially named bucket in S3. + +Common: Removed request debugger. + +Common: Updated Django to version 1.4.5. + +Common: Updated CodeJail. + +Common: Allow setting of authentication session cookie name. + diff --git a/Gemfile b/Gemfile index 7f7b146978..1ad685c34d 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,4 @@ gem 'sass', '3.1.15' gem 'bourbon', '~> 1.3.6' gem 'colorize', '~> 0.5.8' gem 'launchy', '~> 2.1.2' +gem 'sys-proctable', '~> 0.9.3' diff --git a/README.md b/README.md index ed52c21fb2..92a4116354 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ -This is edX, a platform for online course delivery. The project is primarily -written in [Python](http://python.org/), using the -[Django](https://www.djangoproject.com/) framework. We also use some -[Ruby](http://www.ruby-lang.org/) and some [NodeJS](http://nodejs.org/). +This is the main edX platform which consists of LMS and Studio. + +See [code.edx.org](http://code.edx.org/) for other parts of the edX code base. Installation ============ -The installation process is a bit messy at the moment. Here's a high-level -overview of what you should do to get started. -**TLDR:** There is a `scripts/create-dev-env.sh` script that will attempt to set all -of this up for you. If you're in a hurry, run that script. Otherwise, I suggest -that you understand what the script is doing, and why, by reading this document. +There is a `scripts/create-dev-env.sh` that will attempt to set up a development +environment. + +If you want to better understand what the script is doing, keep reading. Directory Hierarchy ------------------- + This code assumes that it is checked out in a directory that has three sibling directories: `data` (used for XML course data), `db` (used to hold a [sqlite](https://sqlite.org/) database), and `log` (used to hold logs). If you @@ -77,6 +76,7 @@ environment), and Node has a library installer called Once you've got your languages and virtual environments set up, install the libraries like so: + $ pip install -r requirements/edx/pre.txt $ pip install -r requirements/edx/base.txt $ pip install -r requirements/edx/post.txt $ bundle install @@ -111,11 +111,11 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run: $ rake django-admin[syncdb] $ rake django-admin[migrate] - $ rake django-admin[update_templates] + $ rake cms:update_templates If you are running these commands using the [`zsh`](http://www.zsh.org/) shell, zsh will assume that you are doing -[shell globbing](https://en.wikipedia.org/wiki/Glob_(programming)), search for +[shell globbing](https://en.wikipedia.org/wiki/Glob_%28programming%29), search for a file in your directory named `django-adminsyncdb` or `django-adminmigrate`, and fail. To fix this, just surround the argument with quotation marks, so that you're running `rake "django-admin[syncdb]"`. @@ -144,10 +144,28 @@ in the `data` directory, instead of in Mongo. To run this older version, run: $ rake lms -Further Documentation -===================== -Once you've got your project up and running, you can check out the `docs` -directory to see more documentation about how edX is structured. +License +------- +The code in this repository is licensed under version 3 of the AGPL unless +otherwise noted. +Please see ``LICENSE.txt`` for details. +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. + +Reporting Security Issues +------------------------- + +Please do not report security issues in public. Please email security@edx.org + +Mailing List and IRC Channel +---------------------------- + +You can discuss this code on the [edx-code Google Group](https://groups.google.com/forum/#!forum/edx-code) or in the +`edx-code` IRC channel on Freenode. diff --git a/cms/CHANGELOG.md b/cms/CHANGELOG.md deleted file mode 100644 index d21d08d23c..0000000000 --- a/cms/CHANGELOG.md +++ /dev/null @@ -1,21 +0,0 @@ -Instructions -============ -For each pull request, add one or more lines to the bottom of the change list. When -code is released to production, change the `Upcoming` entry to todays date, and add -a new block at the bottom of the file. - - Upcoming - -------- - -Change log entries should be targeted at end users. A good place to start is the -user story that instigated the pull request. - - -Changes -======= - -Upcoming --------- -* Fix: Deleting last component in a unit does not work -* Fix: Unit name is editable when a unit is public -* Fix: Visual feedback inconsistent when saving a unit name change diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 71b5e97bc2..58b63abd23 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -39,8 +39,6 @@ def get_users_in_course_group_by_role(location, role): ''' Create all permission groups for a new course and subscribe the caller into those roles ''' - - def create_all_course_groups(creator, location): create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME) @@ -57,13 +55,11 @@ def create_new_course_group(creator, location, role): return -''' -This is to be called only by either a command line code path or through a app which has already -asserted permissions -''' - - def _delete_course_group(location): + ''' + This is to be called only by either a command line code path or through a app which has already + asserted permissions + ''' # remove all memberships instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -75,13 +71,11 @@ def _delete_course_group(location): user.groups.remove(staff) user.save() -''' -This is to be called only by either a command line code path or through an app which has already -asserted permissions to do this action -''' - - def _copy_course_group(source, dest): + ''' + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + ''' instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 6f6cc50702..13600f2086 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -11,8 +11,6 @@ Feature: Advanced (manual) course policy Given I am on the Advanced Course Settings page in Studio Then the settings are alphabetized - # Skipped because Ubuntu ChromeDriver cannot click notification "Cancel" - @skip Scenario: Test cancel editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key @@ -21,8 +19,6 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is unchanged - # Skipped because Ubuntu ChromeDriver cannot click notification "Save" - @skip Scenario: Test editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key and save @@ -30,17 +26,20 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input - @skip Scenario: Test how multi-line input appears Given I am on the Advanced Course Settings page in Studio - When I create a JSON object as a value + When I create a JSON object as a value for "discussion_topics" Then it is displayed as formatted And I reload the page Then it is displayed as formatted - # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input - @skip + Scenario: Test error if value supplied is of the wrong type + Given I am on the Advanced Course Settings page in Studio + When I create a JSON object as a value for "display_name" + Then I get an error on save + And I reload the page + Then the policy key value is unchanged + Scenario: Test automatic quoting of non-JSON values Given I am on the Advanced Course Settings page in Studio When I create a non-JSON value not in quotes diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 3acebecac8..2360baea5a 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,13 +2,8 @@ #pylint: disable=W0621 from lettuce import world, step -from common import * -from nose.tools import assert_false, assert_equal - -""" -http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html -""" -from selenium.webdriver.common.keys import Keys +from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true +from common import type_in_codemirror KEY_CSS = '.key input.policy-key' VALUE_CSS = 'textarea.json' @@ -33,17 +28,20 @@ def i_am_on_advanced_course_settings(step): @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): css = 'a.%s-button' % name.lower() - world.css_click(css) + + # Save was clicked if either the save notification bar is gone, or we have a error notification + # overlaying it (expected in the case of typing Object into display_name). + def save_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 + + assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.') @step(u'I edit the value of a policy key$') def edit_the_value_of_a_policy_key(step): - """ - It is hard to figure out how to get into the CodeMirror - area, so cheat and do it from the policy key field :) - """ - e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] - e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X') + type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X') @step(u'I edit the value of a policy key and save$') @@ -51,9 +49,9 @@ def edit_the_value_of_a_policy_key_and_save(step): change_display_name_value(step, '"foo"') -@step('I create a JSON object as a value$') -def create_JSON_object(step): - change_display_name_value(step, '{"key": "value", "key_2": "value_2"}') +@step('I create a JSON object as a value for "(.*)"$') +def create_JSON_object(step, key): + change_value(step, key, '{"key": "value", "key_2": "value_2"}') @step('I create a non-JSON value not in quotes$') @@ -81,7 +79,12 @@ def they_are_alphabetized(step): @step('it is displayed as formatted$') def it_is_formatted(step): - assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}']) + assert_policy_entries(['discussion_topics'], ['{\n "key": "value",\n "key_2": "value_2"\n}']) + + +@step('I get an error on save$') +def error_on_save(step): + assert_regexp_matches(world.css_text('#notification-error-description'), 'Incorrect setting format') @step('it is displayed as a string') @@ -123,10 +126,9 @@ def get_display_name_value(): def change_display_name_value(step, new_value): - e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] - display_name = get_display_name_value() - for count in range(len(display_name)): - e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE) - # Must delete "" before typing the JSON value - e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value) + change_value(step, DISPLAY_NAME_KEY, new_value) + + +def change_value(step, key, new_value): + type_in_codemirror(get_index_of(key), new_value) press_the_notification_button(step, "Save") diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index afb38c3f9e..bdf07fc5ae 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,12 +1,9 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from nose.tools import assert_true -from nose.tools import assert_equal -from xmodule.modulestore.django import _MODULESTORES, modulestore -from xmodule.templates import update_templates from auth.authz import get_user_by_email from selenium.webdriver.common.keys import Keys @@ -15,10 +12,15 @@ import time from logging import getLogger logger = getLogger(__name__) +_COURSE_NAME = 'Robot Super Course' +_COURSE_NUM = '999' +_COURSE_ORG = 'MITx' + ########### STEP HELPERS ############## + @step('I (?:visit|access|open) the Studio homepage$') -def i_visit_the_studio_homepage(step): +def i_visit_the_studio_homepage(_step): # To make this go to port 8001, put # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. @@ -28,17 +30,17 @@ def i_visit_the_studio_homepage(step): @step('I am logged into Studio$') -def i_am_logged_into_studio(step): +def i_am_logged_into_studio(_step): log_into_studio() @step('I confirm the alert$') -def i_confirm_with_ok(step): +def i_confirm_with_ok(_step): world.browser.get_alert().accept() @step(u'I press the "([^"]*)" delete icon$') -def i_press_the_category_delete_icon(step, category): +def i_press_the_category_delete_icon(_step, category): if category == 'section': css = 'a.delete-button.delete-section-button span.delete-icon' elif category == 'subsection': @@ -49,37 +51,38 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') -def i_have_opened_a_new_course(step): +def i_have_opened_a_new_course(_step): + open_new_course() + + +####### HELPER FUNCTIONS ############## +def open_new_course(): world.clear_courses() + create_studio_user() log_into_studio() create_a_course() -####### HELPER FUNCTIONS ############## def create_studio_user( uname='robot', email='robot+studio@edx.org', password='test', is_staff=False): - studio_user = world.UserFactory.build( + studio_user = world.UserFactory( username=uname, email=email, password=password, is_staff=is_staff) - studio_user.set_password(password) - studio_user.save() registration = world.RegistrationFactory(user=studio_user) registration.register(studio_user) registration.activate() - user_profile = world.UserProfileFactory(user=studio_user) - def fill_in_course_info( - name='Robot Super Course', - org='MITx', - num='101'): + name=_COURSE_NAME, + org=_COURSE_ORG, + num=_COURSE_NUM): world.css_fill('.new-course-name', name) world.css_fill('.new-course-org', org) world.css_fill('.new-course-number', num) @@ -88,10 +91,7 @@ def fill_in_course_info( def log_into_studio( uname='robot', email='robot+studio@edx.org', - password='test', - is_staff=False): - - create_studio_user(uname=uname, email=email, is_staff=is_staff) + password='test'): world.browser.cookies.delete() world.visit('/') @@ -109,14 +109,14 @@ def log_into_studio( def create_a_course(): - c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + world.CourseFactory.create(org=_COURSE_ORG, course=_COURSE_NUM, display_name=_COURSE_NAME) # Add the user to the instructor group of the course # so they will have the permissions to see it in studio - g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') - u = get_user_by_email('robot+studio@edx.org') - u.groups.add(g) - u.save() + course = world.GroupFactory.create(name='instructor_MITx/{course_num}/{course_name}'.format(course_num=_COURSE_NUM, course_name=_COURSE_NAME.replace(" ", "_"))) + user = get_user_by_email('robot+studio@edx.org') + user.groups.add(course) + user.save() world.browser.reload() course_link_css = 'span.class-name' @@ -149,8 +149,47 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): world.css_fill(date_css, desired_date) # hit TAB to get to the time field e = world.css_find(date_css).first + # pylint: disable=W0212 e._element.send_keys(Keys.TAB) world.css_fill(time_css, desired_time) e = world.css_find(time_css).first e._element.send_keys(Keys.TAB) - time.sleep(float(1)) + time.sleep(float(1)) + + +@step('I have created a Video component$') +def i_created_a_video_component(step): + world.create_component_instance( + step, '.large-video-icon', + 'i4x://edx/templates/video/default', + '.xmodule_VideoModule' + ) + + +@step('I have clicked the new unit button') +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') + world.css_click('a.new-unit-item') + + +@step('when I view the video it (.*) show the captions') +def shows_captions(step, show_captions): + # Prevent cookies from overriding course settings + world.browser.cookies.delete('hide_captions') + if show_captions == 'does not': + assert world.css_find('.video')[0].has_class('closed') + else: + assert world.is_css_not_present('.video.closed') + + +def type_in_codemirror(index, text): + world.css_click(".CodeMirror", index=index) + g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") + if world.is_mac(): + g._element.send_keys(Keys.COMMAND + 'a') + else: + g._element.send_keys(Keys.CONTROL + 'a') + g._element.send_keys(Keys.DELETE) + g._element.send_keys(text) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py new file mode 100644 index 0000000000..43164f62be --- /dev/null +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -0,0 +1,92 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world +from nose.tools import assert_equal +from terrain.steps import reload_the_page + + +@world.absorb +def create_component_instance(step, component_button_css, instance_id, expected_css): + click_new_component_button(step, component_button_css) + click_component_from_menu(instance_id, expected_css) + + +@world.absorb +def click_new_component_button(step, component_button_css): + step.given('I have clicked the new unit button') + world.css_click(component_button_css) + + +@world.absorb +def click_component_from_menu(instance_id, expected_css): + """ + Creates a component from `instance_id`. For components with more + than one template, clicks on `elem_css` to create the new + component. Components with only one template are created as soon + as the user clicks the appropriate button, so we assert that the + expected component is present. + """ + elem_css = "a[data-location='%s']" % instance_id + elements = world.css_find(elem_css) + assert(len(elements) == 1) + if elements[0]['id'] == instance_id: # If this is a component with multiple templates + world.css_click(elem_css) + assert_equal(1, len(world.css_find(expected_css))) + + +@world.absorb +def edit_component_and_select_settings(): + world.css_click('a.edit-button') + world.css_click('#settings-mode') + + +@world.absorb +def verify_setting_entry(setting, display_name, value, explicitly_set): + assert_equal(display_name, setting.find_by_css('.setting-label')[0].value) + 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')) + + +@world.absorb +def verify_all_setting_entries(expected_entries): + settings = world.browser.find_by_css('.wrapper-comp-setting') + assert_equal(len(expected_entries), len(settings)) + for (counter, setting) in enumerate(settings): + world.verify_setting_entry( + setting, expected_entries[counter][0], + expected_entries[counter][1], expected_entries[counter][2] + ) + + +@world.absorb +def save_component_and_reopen(step): + world.css_click("a.save-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save. + reload_the_page(step) + edit_component_and_select_settings() + + +@world.absorb +def cancel_component(step): + world.css_click("a.cancel-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes were not persisted. + reload_the_page(step) + + +@world.absorb +def revert_setting_entry(label): + get_setting_entry(label).find_by_css('.setting-clear')[0].click() + + +@world.absorb +def get_setting_entry(label): + settings = world.browser.find_by_css('.wrapper-comp-setting') + for setting in settings: + if setting.find_by_css('.setting-label')[0].value == label: + return setting + return None diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature new file mode 100644 index 0000000000..fc1212f398 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -0,0 +1,34 @@ +Feature: Course Team + As a course author, I want to be able to add others to my team + + Scenario: Users can add other users + Given I have opened a new course in Studio + And the user "alice" exists + And I am viewing the course team settings + When I add "alice" to the course team + And "alice" logs in + Then she does see the course on her page + + Scenario: Added users cannot delete or add other users + Given I have opened a new course in Studio + And the user "bob" exists + And I am viewing the course team settings + When I add "bob" to the course team + And "bob" logs in + Then he cannot delete users + And he cannot add users + + Scenario: Users can delete other users + Given I have opened a new course in Studio + And the user "carol" exists + And I am viewing the course team settings + When I add "carol" to the course team + And I delete "carol" from the course team + And "carol" logs in + Then she does not see the course on her page + + Scenario: Users cannot add users that do not exist + Given I have opened a new course in Studio + And I am viewing the course team settings + When I add "dennis" to the course team + Then I should see "Could not find user by email address" somewhere on the page diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py new file mode 100644 index 0000000000..c126773db6 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -0,0 +1,67 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from common import create_studio_user, log_into_studio, _COURSE_NAME + +PASSWORD = 'test' +EMAIL_EXTENSION = '@edx.org' + + +@step(u'I am viewing the course team settings') +def view_grading_settings(_step): + world.click_course_settings() + link_css = 'li.nav-course-settings-team a' + world.css_click(link_css) + + +@step(u'the user "([^"]*)" exists$') +def create_other_user(_step, name): + create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION)) + + +@step(u'I add "([^"]*)" to the course team') +def add_other_user(_step, name): + new_user_css = 'a.new-user-button' + world.css_click(new_user_css) + + email_css = 'input.email-input' + f = world.css_find(email_css) + f._element.send_keys(name, EMAIL_EXTENSION) + + confirm_css = '#add_user' + world.css_click(confirm_css) + + +@step(u'I delete "([^"]*)" from the course team') +def delete_other_user(_step, name): + to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION) + world.css_click(to_delete_css) + + +@step(u'"([^"]*)" logs in$') +def other_user_login(_step, name): + log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION) + + +@step(u's?he does( not)? see the course on (his|her) page') +def see_course(_step, doesnt_see_course, gender): + class_css = 'span.class-name' + all_courses = world.css_find(class_css) + all_names = [item.html for item in all_courses] + if doesnt_see_course: + assert not _COURSE_NAME in all_names + else: + assert _COURSE_NAME in all_names + + +@step(u's?he cannot delete users') +def cannot_delete(_step): + to_delete_css = 'a.remove-user' + assert world.is_css_not_present(to_delete_css) + + +@step(u's?he cannot add users') +def cannot_add(_step): + add_css = 'a.new-user' + assert world.is_css_not_present(add_css) diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature new file mode 100644 index 0000000000..81714c43ae --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -0,0 +1,37 @@ +Feature: Course updates + As a course author, I want to be able to provide updates to my students + + Scenario: Users can add updates + Given I have opened a new course in Studio + And I go to the course updates page + When I add a new update with the text "Hello" + Then I should see the update "Hello" + + Scenario: Users can edit updates + Given I have opened a new course in Studio + And I go to the course updates page + When I add a new update with the text "Hello" + And I modify the text to "Goodbye" + Then I should see the update "Goodbye" + + Scenario: Users can delete updates + Given I have opened a new course in Studio + And I go to the course updates page + And I add a new update with the text "Hello" + When I will confirm all alerts + And I delete the update + Then I should not see the update "Hello" + + + Scenario: Users can edit update dates + Given I have opened a new course in Studio + And I go to the course updates page + And I add a new update with the text "Hello" + When I edit the date to "June 1, 2013" + Then I should see the date "June 1, 2013" + + Scenario: Users can change handouts + Given I have opened a new course in Studio + And I go to the course updates page + When I modify the handout to "
    Test
" + Then I see the handout "Test" diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py new file mode 100644 index 0000000000..d838061698 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -0,0 +1,84 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from selenium.webdriver.common.keys import Keys +from common import type_in_codemirror + + +@step(u'I go to the course updates page') +def go_to_updates(_step): + menu_css = 'li.nav-course-courseware' + updates_css = 'li.nav-course-courseware-updates' + world.css_click(menu_css) + world.css_click(updates_css) + + +@step(u'I add a new update with the text "([^"]*)"$') +def add_update(_step, text): + update_css = 'a.new-update-button' + world.css_click(update_css) + change_text(text) + + +@step(u'I should( not)? see the update "([^"]*)"$') +def check_update(_step, doesnt_see_update, text): + update_css = 'div.update-contents' + update = world.css_find(update_css) + if doesnt_see_update: + assert len(update) == 0 or not text in update.html + else: + assert text in update.html + + +@step(u'I modify the text to "([^"]*)"$') +def modify_update(_step, text): + button_css = 'div.post-preview a.edit-button' + world.css_click(button_css) + change_text(text) + + +@step(u'I delete the update$') +def click_button(_step): + button_css = 'div.post-preview a.delete-button' + world.css_click(button_css) + + +@step(u'I edit the date to "([^"]*)"$') +def change_date(_step, new_date): + button_css = 'div.post-preview a.edit-button' + world.css_click(button_css) + date_css = 'input.date' + date = world.css_find(date_css) + for i in range(len(date.value)): + date._element.send_keys(Keys.END, Keys.BACK_SPACE) + date._element.send_keys(new_date) + save_css = 'a.save-button' + world.css_click(save_css) + + +@step(u'I should see the date "([^"]*)"$') +def check_date(_step, date): + date_css = 'span.date-display' + date_html = world.css_find(date_css) + assert date == date_html.html + + +@step(u'I modify the handout to "([^"]*)"$') +def edit_handouts(_step, text): + edit_css = 'div.course-handouts > a.edit-button' + world.css_click(edit_css) + change_text(text) + + +@step(u'I see the handout "([^"]*)"$') +def check_handout(_step, handout): + handout_css = 'div.handouts-content' + handouts = world.css_find(handout_css) + assert handout in handouts.html + + +def change_text(text): + type_in_codemirror(0, text) + save_css = 'a.save-button' + world.css_click(save_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index aa2e9d68f8..5b279d402f 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -10,6 +10,7 @@ from common import * @step('There are no courses$') def no_courses(step): world.clear_courses() + create_studio_user() @step('I click the New Course button$') @@ -47,12 +48,6 @@ def i_see_the_course_in_my_courses(step): assert world.css_has_text(course_css, 'Robot Super Course') -@step('the course is loaded$') -def course_is_loaded(step): - class_css = 'a.class-name' - assert world.css_has_text(course_css, 'Robot Super Cousre') - - @step('I am on the "([^"]*)" tab$') def i_am_on_tab(step, tab_name): header_css = 'div.inner-wrapper h1' diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature new file mode 100644 index 0000000000..8fb14c3205 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.feature @@ -0,0 +1,17 @@ +Feature: Discussion Component Editor + As a course author, I want to be able to create discussion components. + + Scenario: User can view metadata + Given I have created a Discussion Tag + And I edit and select Settings + Then I see three alphabetized settings and their expected values + + Scenario: User can modify display name + Given I have created a Discussion Tag + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save + + Scenario: Creating a discussion takes a single click + Given I have clicked the new unit button + Then creating a discussion takes a single click diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py new file mode 100644 index 0000000000..ae3da3c458 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -0,0 +1,30 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I have created a Discussion Tag$') +def i_created_discussion_tag(step): + world.create_component_instance( + step, '.large-discussion-icon', + 'i4x://edx/templates/discussion/Discussion_Tag', + '.xmodule_DiscussionModule' + ) + + +@step('I see three alphabetized settings and their expected values$') +def i_see_only_the_settings_and_values(step): + world.verify_all_setting_entries( + [ + ['Category', "Week 1", True], + ['Display Name', "Discussion Tag", True], + ['Subcategory', "Topic-Level Student-Visible Label", True] + ]) + + +@step('creating a discussion takes a single click') +def discussion_takes_a_single_click(step): + assert(not world.is_css_present('.xmodule_DiscussionModule')) + world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']") + assert(world.is_css_present('.xmodule_DiscussionModule')) diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature new file mode 100644 index 0000000000..78634cb964 --- /dev/null +++ b/cms/djangoapps/contentstore/features/grading.feature @@ -0,0 +1,53 @@ +Feature: Course Grading + As a course author, I want to be able to configure how my course is graded + + Scenario: Users can add grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I add "1" new grade + Then I see I now have "3" grades + + Scenario: Users can only have up to 5 grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I add "6" new grades + Then I see I now have "5" grades + + #Cannot reliably make the delete button appear so using javascript instead + Scenario: Users can delete grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I add "1" new grade + And I delete a grade + Then I see I now have "2" grades + + Scenario: Users can move grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I move a grading section + Then I see that the grade range has changed + + Scenario: Users can modify Assignment types + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I change assignment type "Homework" to "New Type" + And I go back to the main course page + Then I do see the assignment name "New Type" + And I do not see the assignment name "Homework" + + Scenario: Users can delete Assignment types + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I delete the assignment type "Homework" + And I go back to the main course page + Then I do not see the assignment name "Homework" + + Scenario: Users can add Assignment types + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I add a new assignment type "New Type" + And I go back to the main course page + Then I do see the assignment name "New Type" diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py new file mode 100644 index 0000000000..4e59897c1c --- /dev/null +++ b/cms/djangoapps/contentstore/features/grading.py @@ -0,0 +1,108 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from common import * + + +@step(u'I am viewing the grading settings') +def view_grading_settings(step): + world.click_course_settings() + link_css = 'li.nav-course-settings-grading a' + world.css_click(link_css) + + +@step(u'I add "([^"]*)" new grade') +def add_grade(step, many): + grade_css = '.new-grade-button' + for i in range(int(many)): + world.css_click(grade_css) + + +@step(u'I delete a grade') +def delete_grade(step): + #grade_css = 'li.grade-specific-bar > a.remove-button' + #range_css = '.grade-specific-bar' + #world.css_find(range_css)[1].mouseover() + #world.css_click(grade_css) + world.browser.execute_script('document.getElementsByClassName("remove-button")[0].click()') + + +@step(u'I see I now have "([^"]*)" grades$') +def view_grade_slider(step, how_many): + grade_slider_css = '.grade-specific-bar' + all_grades = world.css_find(grade_slider_css) + assert len(all_grades) == int(how_many) + + +@step(u'I move a grading section') +def move_grade_slider(step): + moveable_css = '.ui-resizable-e' + f = world.css_find(moveable_css).first + f.action_chains.drag_and_drop_by_offset(f._element, 100, 0).perform() + + +@step(u'I see that the grade range has changed') +def confirm_change(step): + range_css = '.range' + all_ranges = world.css_find(range_css) + for i in range(len(all_ranges)): + assert all_ranges[i].html != '0-50' + + +@step(u'I change assignment type "([^"]*)" to "([^"]*)"$') +def change_assignment_name(step, old_name, new_name): + name_id = '#course-grading-assignment-name' + index = get_type_index(old_name) + f = world.css_find(name_id)[index] + assert index != -1 + for count in range(len(old_name)): + f._element.send_keys(Keys.END, Keys.BACK_SPACE) + f._element.send_keys(new_name) + + +@step(u'I go back to the main course page') +def main_course_page(step): + main_page_link_css = 'a[href="/MITx/999/course/Robot_Super_Course"]' + world.css_click(main_page_link_css) + + +@step(u'I do( not)? see the assignment name "([^"]*)"$') +def see_assignment_name(step, do_not, name): + assignment_menu_css = 'ul.menu > li > a' + assignment_menu = world.css_find(assignment_menu_css) + allnames = [item.html for item in assignment_menu] + if do_not: + assert not name in allnames + else: + assert name in allnames + + +@step(u'I delete the assignment type "([^"]*)"$') +def delete_assignment_type(step, to_delete): + delete_css = '.remove-grading-data' + world.css_click(delete_css, index=get_type_index(to_delete)) + + +@step(u'I add a new assignment type "([^"]*)"$') +def add_assignment_type(step, new_name): + add_button_css = '.add-grading-data' + world.css_click(add_button_css) + name_id = '#course-grading-assignment-name' + f = world.css_find(name_id)[4] + f._element.send_keys(new_name) + + +@step(u'I have populated the course') +def populate_course(step): + step.given('I have added a new section') + step.given('I have added a new subsection') + + +def get_type_index(name): + name_id = '#course-grading-assignment-name' + f = world.css_find(name_id) + for i in range(len(f)): + if f[i].value == name: + return i + return -1 diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature new file mode 100644 index 0000000000..6cd455d681 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.feature @@ -0,0 +1,13 @@ +Feature: HTML Editor + As a course author, I want to be able to create HTML blocks. + + Scenario: User can view metadata + Given I have created a Blank HTML Page + And I edit and select Settings + Then I see only the HTML display name setting + + Scenario: User can modify display name + Given I have created a Blank HTML Page + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py new file mode 100644 index 0000000000..054c0ea642 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -0,0 +1,17 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I have created a Blank HTML Page$') +def i_created_blank_html_page(step): + world.create_component_instance( + step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page', + '.xmodule_HtmlModule' + ) + + +@step('I see only the HTML display name setting$') +def i_see_only_the_html_display_name(step): + world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]]) diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature new file mode 100644 index 0000000000..cc1d766d2e --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -0,0 +1,73 @@ +Feature: 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 + And Edit High Level Source is not visible + + Scenario: User can modify String values + Given I have created a Blank Common Problem + When I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save + + Scenario: User can specify special characters in String values + Given I have created a Blank Common Problem + When I edit and select Settings + Then I can specify special characters in the display name + And my special characters and persisted on save + + Scenario: User can revert display name to unset + Given I have created a Blank Common Problem + When I edit and select Settings + Then I can revert the display name to unset + And my display name is unset on save + + Scenario: User can select values in a Select + Given I have created a Blank Common Problem + When I edit and select Settings + Then I can select Per Student for Randomization + And my change to randomization is persisted + And I can revert to the default value for randomization + + Scenario: User can modify float input values + Given I have created a Blank Common Problem + When I edit and select Settings + Then I can set the weight to "3.5" + And my change to weight is persisted + And I can revert to the default value of unset for weight + + Scenario: User cannot type letters in float number field + Given I have created a Blank Common Problem + When I edit and select Settings + Then if I set the weight to "abc", it remains unset + + Scenario: User cannot type decimal values integer number field + Given I have created a Blank Common Problem + When I edit and select Settings + Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234" + + Scenario: User cannot type out of range values in an integer number field + Given I have created a Blank Common Problem + When I edit and select Settings + Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0" + + Scenario: Settings changes are not saved on Cancel + Given I have created a Blank Common Problem + When I edit and select Settings + Then I can set the weight to "3.5" + And I can modify the display name + Then If I press Cancel my changes are not persisted + + Scenario: Edit High Level source is available for LaTeX problem + Given I have created a LaTeX Problem + When I edit and select Settings + Then Edit High Level Source is visible + + Scenario: High Level source is persisted for LaTeX problem (bug STUD-280) + Given I have created a LaTeX Problem + When I edit and compile the High Level Source + Then my change to the High Level Source is persisted + And when I view the High Level Source I see my changes diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py new file mode 100644 index 0000000000..8691a6772e --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -0,0 +1,215 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step +from nose.tools import assert_equal +from common import type_in_codemirror + +DISPLAY_NAME = "Display Name" +MAXIMUM_ATTEMPTS = "Maximum Attempts" +PROBLEM_WEIGHT = "Problem Weight" +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( + step, + '.large-problem-icon', + 'i4x://edx/templates/problem/Blank_Common_Problem', + '.xmodule_CapaModule' + ) + + +@step('I edit and select Settings$') +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): + world.verify_all_setting_entries( + [ + [DISPLAY_NAME, "Blank Common Problem", True], + [MAXIMUM_ATTEMPTS, "", False], + [PROBLEM_WEIGHT, "", False], + [RANDOMIZATION, "Never", True], + [SHOW_ANSWER, "Finished", True] + ]) + + +@step('I can modify the display name') +def i_can_modify_the_display_name(step): + # Verifying that the display name can be a string containing a floating point value + # (to confirm that we don't throw an error because it is of the wrong type). + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4') + verify_modified_display_name() + + +@step('my display name change is persisted on save') +def my_display_name_change_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name() + + +@step('I can specify special characters in the display name') +def i_can_modify_the_display_name_with_special_chars(step): + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &") + verify_modified_display_name_with_special_chars() + + +@step('my special characters and persisted on save') +def special_chars_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name_with_special_chars() + + +@step('I can revert the display name to unset') +def can_revert_display_name_to_unset(step): + world.revert_setting_entry(DISPLAY_NAME) + verify_unset_display_name() + + +@step('my display name is unset on save') +def my_display_name_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_unset_display_name() + + +@step('I can select Per Student for Randomization') +def i_can_select_per_student_for_randomization(step): + world.browser.select(RANDOMIZATION, "Per Student") + verify_modified_randomization() + + +@step('my change to randomization is persisted') +def my_change_to_randomization_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_randomization() + + +@step('I can revert to the default value for randomization') +def i_can_revert_to_default_for_randomization(step): + world.revert_setting_entry(RANDOMIZATION) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False) + + +@step('I can set the weight to "(.*)"?') +def i_can_set_weight(step, weight): + set_weight(weight) + verify_modified_weight() + + +@step('my change to weight is persisted') +def my_change_to_weight_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_weight() + + +@step('I can revert to the default value of unset for weight') +def i_can_revert_to_default_for_unset_weight(step): + world.revert_setting_entry(PROBLEM_WEIGHT) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the weight to "(.*)", it remains unset') +def set_the_weight_to_abc(step, bad_weight): + set_weight(bad_weight) + # We show the clear button immediately on type, hence the "True" here. + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", True) + world.save_component_and_reopen(step) + # But no change was actually ever sent to the model, so on reopen, explicitly_set is False + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"') +def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted): + world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True) + + +@step('Edit High Level Source is not visible') +def edit_high_level_source_not_visible(step): + verify_high_level_source_links(step, False) + + +@step('Edit High Level Source is visible') +def edit_high_level_source_links_visible(step): + verify_high_level_source_links(step, True) + + +@step('If I press Cancel my changes are not persisted') +def cancel_does_not_save_changes(step): + world.cancel_component(step) + step.given("I edit and select Settings") + step.given("I see five alphabetized settings and their expected values") + + +@step('I have created a LaTeX Problem') +def create_latex_problem(step): + world.click_new_component_button(step, '.large-problem-icon') + # Go to advanced tab. + world.css_click('#ui-id-2') + world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') + + +@step('I edit and compile the High Level Source') +def edit_latex_source(step): + open_high_level_source() + type_in_codemirror(1, "hi") + world.css_click('.hls-compile') + + +@step('my change to the High Level Source is persisted') +def high_level_source_persisted(step): + def verify_text(driver): + return world.css_find('.problem').text == 'hi' + + world.wait_for(verify_text) + + +@step('I view the High Level Source I see my changes') +def high_level_source_in_editor(step): + open_high_level_source() + assert_equal('hi', world.css_find('.source-edit-box').value) + + +def verify_high_level_source_links(step, visible): + assert_equal(visible, world.is_css_present('.launch-latex-compiler')) + world.cancel_component(step) + assert_equal(visible, world.is_css_present('.upload-button')) + + +def verify_modified_weight(): + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "3.5", True) + + +def verify_modified_randomization(): + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Per Student", True) + + +def verify_modified_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True) + + +def verify_modified_display_name_with_special_chars(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "updated ' \" &", True) + + +def verify_unset_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False) + + +def set_weight(weight): + world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight) + + +def open_high_level_source(): + world.css_click('a.edit-button') + world.css_click('.launch-latex-compiler > a') diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 236cf501fc..80ccb6cc7a 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -26,11 +26,9 @@ Feature: Create Section And I save a new section release date Then the section release date is updated - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip Scenario: Delete section Given I have opened a new course in Studio And I have added a new section - When I press the "section" delete icon - And I confirm the alert + When I will confirm all alerts + And I press the "section" delete icon Then the section does not exist diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 9a896d8ebe..989c73e010 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from common import * @@ -8,35 +8,35 @@ from nose.tools import assert_equal ############### ACTIONS #################### -@step('I click the new section link$') -def i_click_new_section_link(step): +@step('I click the New Section link$') +def i_click_new_section_link(_step): link_css = 'a.new-courseware-section-button' world.css_click(link_css) @step('I enter the section name and click save$') -def i_save_section_name(step): +def i_save_section_name(_step): save_section_name('My Section') @step('I enter a section name with a quote and click save$') -def i_save_section_name_with_quote(step): +def i_save_section_name_with_quote(_step): save_section_name('Section with "Quote"') @step('I have added a new section$') -def i_have_added_new_section(step): +def i_have_added_new_section(_step): add_section() @step('I click the Edit link for the release date$') -def i_click_the_edit_link_for_the_release_date(step): +def i_click_the_edit_link_for_the_release_date(_step): button_css = 'div.section-published-date a.edit-button' world.css_click(button_css) @step('I save a new section release date$') -def i_save_a_new_section_release_date(step): +def i_save_a_new_section_release_date(_step): set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013', 'input.start-time.time.ui-timepicker-input', '00:00') world.browser.click_link_by_text('Save') @@ -46,35 +46,35 @@ def i_save_a_new_section_release_date(step): @step('I see my section on the Courseware page$') -def i_see_my_section_on_the_courseware_page(step): +def i_see_my_section_on_the_courseware_page(_step): see_my_section_on_the_courseware_page('My Section') @step('I see my section name with a quote on the Courseware page$') -def i_see_my_section_name_with_quote_on_the_courseware_page(step): +def i_see_my_section_name_with_quote_on_the_courseware_page(_step): see_my_section_on_the_courseware_page('Section with "Quote"') @step('I click to edit the section name$') -def i_click_to_edit_section_name(step): +def i_click_to_edit_section_name(_step): world.css_click('span.section-name-span') @step('I see the complete section name with a quote in the editor$') -def i_see_complete_section_name_with_quote_in_editor(step): +def i_see_complete_section_name_with_quote_in_editor(_step): css = '.section-name-edit input[type=text]' assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') @step('the section does not exist$') -def section_does_not_exist(step): - css = 'span.section-name-span' - assert world.browser.is_element_not_present_by_css(css) +def section_does_not_exist(_step): + css = 'h3[data-name="My Section"]' + assert world.is_css_not_present(css) @step('I see a release date for my section$') -def i_see_a_release_date_for_my_section(step): +def i_see_a_release_date_for_my_section(_step): import re css = 'span.published-status' @@ -83,26 +83,32 @@ def i_see_a_release_date_for_my_section(step): # e.g. 11/06/2012 at 16:25 msg = 'Will Release:' - date_regex = '[01][0-9]\/[0-3][0-9]\/[12][0-9][0-9][0-9]' - time_regex = '[0-2][0-9]:[0-5][0-9]' - match_string = '%s %s at %s' % (msg, date_regex, time_regex) + date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}' + if not re.search(date_regex, status_text): + print status_text, date_regex + time_regex = r'[0-2]\d:[0-5]\d( \w{3})?' + if not re.search(time_regex, status_text): + print status_text, time_regex + match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex) + if not re.match(match_string, status_text): + print status_text, match_string assert re.match(match_string, status_text) @step('I see a link to create a new subsection$') -def i_see_a_link_to_create_a_new_subsection(step): +def i_see_a_link_to_create_a_new_subsection(_step): css = 'a.new-subsection-item' assert world.is_css_present(css) @step('the section release date picker is not visible$') -def the_section_release_date_picker_not_visible(step): +def the_section_release_date_picker_not_visible(_step): css = 'div.edit-subsection-publish-settings' assert not world.css_visible(css) @step('the section release date is updated$') -def the_section_release_date_is_updated(step): +def the_section_release_date_is_updated(_step): css = 'span.published-status' status_text = world.css_text(css) assert_equal(status_text, 'Will Release: 12/25/2013 at 00:00 UTC') diff --git a/cms/djangoapps/contentstore/features/static-pages.feature b/cms/djangoapps/contentstore/features/static-pages.feature new file mode 100644 index 0000000000..9997df69f0 --- /dev/null +++ b/cms/djangoapps/contentstore/features/static-pages.feature @@ -0,0 +1,24 @@ +Feature: 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 + + 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 + When I will confirm all alerts + And I "delete" the "Empty" page + Then I should not see a "Empty" static page + + Scenario: Users can edit 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 + And I change the name to "New" + Then I should see a "New" static page diff --git a/cms/djangoapps/contentstore/features/static-pages.py b/cms/djangoapps/contentstore/features/static-pages.py new file mode 100644 index 0000000000..a16a3246da --- /dev/null +++ b/cms/djangoapps/contentstore/features/static-pages.py @@ -0,0 +1,59 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from selenium.webdriver.common.keys import Keys + + +@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' + world.css_find(menu_css).click() + world.css_find(static_css).click() + + +@step(u'I add a new page') +def add_page(_step): + button_css = 'a.new-button' + world.css_find(button_css).click() + + +@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 "([^"]*)" 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_find(button_css)[index].click() + + +@step(u'I change the name to "([^"]*)"$') +def change_name(_step, new_name): + settings_css = '#settings-mode' + world.css_find(settings_css).click() + input_css = 'input.setting-input' + name_input = world.css_find(input_css) + old_name = name_input.value + for count in range(len(old_name)): + name_input._element.send_keys(Keys.END, Keys.BACK_SPACE) + name_input._element.send_keys(new_name) + save_button = 'a.save-button' + world.css_find(save_button).click() + + +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 all_pages[i].html == '\n {name}\n'.format(name=name): + return i + return -1 diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index c9f5b43dfb..e746f3629a 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -1,61 +1,59 @@ Feature: Overview Toggle Section - In order to quickly view the details of a course's section or to scan the inventory of sections + In order to quickly view the details of a course's section or to scan the inventory of sections As a course author I want to toggle the visibility of each section's subsection details in the overview listing - Scenario: The default layout for the overview page is to show sections in expanded view - Given I have a course with multiple sections - When I navigate to the course overview page - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: The default layout for the overview page is to show sections in expanded view + Given I have a course with multiple sections + When I navigate to the course overview page + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expand /collapse for a course with no sections - Given I have a course with no sections - When I navigate to the course overview page - Then I do not see the "Collapse All Sections" link + Scenario: Expand /collapse for a course with no sections + Given I have a course with no sections + When I navigate to the course overview page + Then I do not see the "Collapse All Sections" link - Scenario: Collapse link appears after creating first section of a course - Given I have a course with no sections - When I navigate to the course overview page - And I add a section - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Collapse link appears after creating first section of a course + Given I have a course with no sections + When I navigate to the course overview page + And I add a section + Then I see the "Collapse All Sections" link + And all sections are expanded - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip - Scenario: Collapse link is not removed after last section of a course is deleted - Given I have a course with 1 section - And I navigate to the course overview page - When I press the "section" delete icon - And I confirm the alert - Then I see the "Collapse All Sections" link + Scenario: Collapse link is not removed after last section of a course is deleted + Given I have a course with 1 section + And I navigate to the course overview page + When I will confirm all alerts + And I press the "section" delete icon + Then I see the "Collapse All Sections" link - Scenario: Collapsing all sections when all sections are expanded - Given I navigate to the courseware page of a course with multiple sections - And all sections are expanded - When I click the "Collapse All Sections" link - Then I see the "Expand All Sections" link - And all sections are collapsed + Scenario: Collapsing all sections when all sections are expanded + Given I navigate to the courseware page of a course with multiple sections + And all sections are expanded + When I click the "Collapse All Sections" link + Then I see the "Expand All Sections" link + And all sections are collapsed - Scenario: Collapsing all sections when 1 or more sections are already collapsed - Given I navigate to the courseware page of a course with multiple sections - And all sections are expanded - When I collapse the first section - And I click the "Collapse All Sections" link - Then I see the "Expand All Sections" link - And all sections are collapsed + Scenario: Collapsing all sections when 1 or more sections are already collapsed + Given I navigate to the courseware page of a course with multiple sections + And all sections are expanded + When I collapse the first section + And I click the "Collapse All Sections" link + Then I see the "Expand All Sections" link + And all sections are collapsed - Scenario: Expanding all sections when all sections are collapsed - Given I navigate to the courseware page of a course with multiple sections - And I click the "Collapse All Sections" link - When I click the "Expand All Sections" link - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Expanding all sections when all sections are collapsed + Given I navigate to the courseware page of a course with multiple sections + And I click the "Collapse All Sections" link + When I click the "Expand All Sections" link + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expanding all sections when 1 or more sections are already expanded - Given I navigate to the courseware page of a course with multiple sections - And I click the "Collapse All Sections" link - When I expand the first section - And I click the "Expand All Sections" link - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Expanding all sections when 1 or more sections are already expanded + Given I navigate to the courseware page of a course with multiple sections + And I click the "Collapse All Sections" link + When I expand the first section + And I click the "Expand All Sections" link + Then I see the "Collapse All Sections" link + And all sections are expanded diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 7f717b731c..1fbd965871 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from common import * @@ -50,7 +50,8 @@ def have_a_course_with_two_sections(step): @step(u'I navigate to the course overview page$') def navigate_to_the_course_overview_page(step): - log_into_studio(is_staff=True) + create_studio_user(is_staff=True) + log_into_studio() course_locator = '.class-name' world.css_click(course_locator) @@ -112,7 +113,7 @@ def all_sections_are_expanded(step): @step(u'all sections are collapsed$') -def all_sections_are_expanded(step): +def all_sections_are_collapsed(step): subsection_locator = 'div.subsection-list' subsections = world.css_find(subsection_locator) for s in subsections: diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 8bb12467ff..a11467e3f9 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -32,12 +32,10 @@ Feature: Create Subsection And I reload the page Then I see the correct dates - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip Scenario: Delete a subsection Given I have opened a new course section in Studio And I have added a new subsection And I see my subsection on the Courseware page - When I press the "subsection" delete icon - And I confirm the alert + When I will confirm all alerts + And I press the "subsection" delete icon Then the subsection does not exist diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index edc8b17168..1134e53280 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -10,9 +10,7 @@ from nose.tools import assert_equal @step('I have opened a new course section in Studio$') def i_have_opened_a_new_course_section(step): - world.clear_courses() - log_into_studio() - create_a_course() + open_new_course() add_section() diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature new file mode 100644 index 0000000000..b3c1fc2ce3 --- /dev/null +++ b/cms/djangoapps/contentstore/features/upload.feature @@ -0,0 +1,38 @@ +Feature: Upload Files + As a course author, I want to be able to upload files for my students + + Scenario: Users can upload files + Given I have opened a new course in Studio + And I go to the files and uploads page + When I upload the file "test" + Then I should see the file "test" was uploaded + And The url for the file "test" is valid + + Scenario: Users can update files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I upload the file "test" + Then I should see only one "test" + + Scenario: Users can delete uploaded files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I delete the file "test" + Then I should not see the file "test" was uploaded + + Scenario: Users can download files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + Then I can download the correct "test" file + + Scenario: Users can download updated files + Given I have opened a new course in studio + And I go to the files and uploads page + 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 diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py new file mode 100644 index 0000000000..258fc5ebcf --- /dev/null +++ b/cms/djangoapps/contentstore/features/upload.py @@ -0,0 +1,108 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from django.conf import settings +import requests +import string +import random +import os + +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT +HTTP_PREFIX = "http://localhost:8001" + + +@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' + world.css_find(menu_css).click() + world.css_find(uploads_css).click() + + +@step(u'I upload the file "([^"]*)"$') +def upload_file(_step, file_name): + upload_css = 'a.upload-button' + world.css_find(upload_css).click() + + file_css = 'input.file-input' + upload = world.css_find(file_css) + #uploading the file itself + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + upload._element.send_keys(os.path.abspath(path)) + + close_css = 'a.close-button' + world.css_find(close_css).click() + + +@step(u'I should( not)? see the file "([^"]*)" was uploaded$') +def check_upload(_step, do_not_see_file, file_name): + index = get_index(file_name) + if do_not_see_file: + assert index == -1 + else: + assert 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 + + +@step(u'I delete the file "([^"]*)"$') +def delete_file(_step, file_name): + index = get_index(file_name) + assert index != -1 + delete_css = "a.remove-asset-button" + world.css_click(delete_css, index=index) + + prompt_confirm_css = 'li.nav-item > a.action-primary' + 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) + only_one = False + for i in range(len(all_names)): + if file_name == all_names[i].html: + only_one = not only_one + assert only_one + + +@step(u'I can download the correct "([^"]*)" file$') +def check_download(_step, file_name): + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + with open(os.path.abspath(path), 'r') as cur_file: + cur_text = cur_file.read() + r = get_file(file_name) + downloaded_text = r.text + assert cur_text == downloaded_text + + +@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) + + +def get_index(file_name): + names_css = 'td.name-col > a.filename' + all_names = world.css_find(names_css) + for i in range(len(all_names)): + if file_name == all_names[i].html: + return i + return -1 + + +def get_file(file_name): + index = get_index(file_name) + assert index != -1 + + url_css = 'input.embeddable-xml-input' + url = world.css_find(url_css)[index].value + return requests.get(HTTP_PREFIX + url) diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature new file mode 100644 index 0000000000..f28ee568dc --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -0,0 +1,23 @@ +Feature: Video Component Editor + As a course author, I want to be able to create video components. + + Scenario: User can view metadata + Given I have created a Video component + And I edit and select Settings + Then I see the correct settings and default values + + Scenario: User can modify display name + Given I have created a Video component + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save + + Scenario: Captions are hidden when "show captions" is false + Given I have created a Video component + And I have set "show captions" to False + Then when I view the video it does not show the captions + + Scenario: Captions are shown when "show captions" is true + Given I have created a Video component + 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 new file mode 100644 index 0000000000..987b4959b8 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -0,0 +1,23 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I see the correct settings and default values$') +def i_see_the_correct_settings_and_values(step): + world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False], + ['Display Name', 'default', True], + ['Download Track', '', False], + ['Download Video', '', False], + ['Show Captions', 'True', False], + ['Speed: .75x', '', False], + ['Speed: 1.25x', '', False], + ['Speed: 1.5x', '', False]]) + + +@step('I have set "show captions" to (.*)') +def set_show_captions(step, setting): + world.css_click('a.edit-button') + world.browser.select('Show Captions', setting) + world.css_click('a.save-button') diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature new file mode 100644 index 0000000000..e4caa70ef6 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video.feature @@ -0,0 +1,24 @@ +Feature: Video Component + As a course author, I want to be able to view my created videos in Studio. + + Scenario: Autoplay is disabled in Studio + Given I have created a Video component + Then when I view the video it does not have autoplay enabled + + Scenario: Creating a video takes a single click + Given I have clicked the new unit button + Then creating a video takes a single click + + Scenario: Captions are hidden correctly + Given I have created a Video component + And I have hidden captions + Then when I view the video it does not show the captions + + Scenario: Captions are shown correctly + Given I have created a Video component + Then when I view the video it does show the captions + + Scenario: Captions are toggled correctly + Given I have created a Video component + And I have toggled captions + Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py new file mode 100644 index 0000000000..190f8e9f1e --- /dev/null +++ b/cms/djangoapps/contentstore/features/video.py @@ -0,0 +1,33 @@ +#pylint: disable=C0111 + +from lettuce import world, step + +############### ACTIONS #################### + + +@step('when I view the video it does not have autoplay enabled') +def does_not_autoplay(_step): + assert world.css_find('.video')[0]['data-autoplay'] == 'False' + assert world.css_find('.video_control')[0].has_class('play') + + +@step('creating a video takes a single click') +def video_takes_a_single_click(_step): + assert(not world.is_css_present('.xmodule_VideoModule')) + world.css_click("a[data-location='i4x://edx/templates/video/default']") + assert(world.is_css_present('.xmodule_VideoModule')) + + +@step('I have (hidden|toggled) captions') +def hide_or_show_captions(step, shown): + button_css = 'a.hide-subtitles' + if shown == 'hidden': + world.css_click(button_css) + if shown == 'toggled': + world.css_click(button_css) + # When we click the first time, a tooltip shows up. We want to + # click the button rather than the tooltip, so move the mouse + # away to make it disappear. + button = world.css_find(button_css) + button.mouse_out() + world.css_click(button_css) diff --git a/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py new file mode 100644 index 0000000000..9af3277a2b --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand, CommandError +from xmodule.course_module import CourseDescriptor +from xmodule.contentstore.utils import empty_asset_trashcan +from xmodule.modulestore.django import modulestore +from .prompt import query_yes_no + + +class Command(BaseCommand): + help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.''' + + def handle(self, *args, **options): + if len(args) != 1 and len(args) != 0: + raise CommandError("empty_asset_trashcan requires one or no arguments: ||") + + locs = [] + + if len(args) == 1: + locs.append(CourseDescriptor.id_to_location(args[0])) + else: + courses = modulestore('direct').get_courses() + for course in courses: + locs.append(course.location) + + if query_yes_no("Emptying trashcan. Confirm?", default="no"): + empty_asset_trashcan(locs) diff --git a/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py new file mode 100644 index 0000000000..6770bfaf44 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand, CommandError +from xmodule.contentstore.utils import restore_asset_from_trashcan + + +class Command(BaseCommand): + help = '''Restore a deleted asset from the trashcan back to it's original course''' + + def handle(self, *args, **options): + if len(args) != 1 and len(args) != 0: + raise CommandError("restore_asset_from_trashcan requires one argument: ") + + restore_asset_from_trashcan(args[0]) + diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index f7d1bbd8fe..e361c97875 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -39,10 +39,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= def set_module_info(store, location, post_data): module = None try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) + module = store.get_item(location) except: pass diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index f0889b0861..52e9ba14fe 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -19,6 +19,23 @@ class ChecklistTestCase(CourseTestCase): modulestore = get_modulestore(self.course.location) return modulestore.get_item(self.course.location).checklists + 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')) + for pers, req in zip(persisted['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['action_url'], req['action_url']) + 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. """ checklists_url = get_url_reverse('Checklists', self.course) @@ -31,9 +48,9 @@ class ChecklistTestCase(CourseTestCase): self.course.checklists = None modulestore = get_modulestore(self.course.location) modulestore.update_metadata(self.course.location, own_metadata(self.course)) - self.assertEquals(self.get_persisted_checklists(), None) + self.assertEqual(self.get_persisted_checklists(), None) response = self.client.get(checklists_url) - self.assertEquals(payload, response.content) + self.assertEqual(payload, response.content) def test_update_checklists_no_index(self): """ No checklist index, should return all of them. """ @@ -43,7 +60,8 @@ class ChecklistTestCase(CourseTestCase): 'name': self.course.location.name}) returned_checklists = json.loads(self.client.get(update_url).content) - self.assertListEqual(self.get_persisted_checklists(), returned_checklists) + for pay, resp in zip(self.get_persisted_checklists(), returned_checklists): + self.compare_checklists(pay, resp) def test_update_checklists_index_ignored_on_get(self): """ Checklist index ignored on get. """ @@ -53,7 +71,8 @@ class ChecklistTestCase(CourseTestCase): 'checklist_index': 1}) returned_checklists = json.loads(self.client.get(update_url).content) - self.assertListEqual(self.get_persisted_checklists(), returned_checklists) + for pay, resp in zip(self.get_persisted_checklists(), returned_checklists): + self.compare_checklists(pay, resp) def test_update_checklists_post_no_index(self): """ No checklist index, will error on post. """ @@ -78,13 +97,18 @@ class ChecklistTestCase(CourseTestCase): 'course': self.course.location.course, 'name': self.course.location.name, 'checklist_index': 2}) + + def get_first_item(checklist): + return checklist['items'][0] + payload = self.course.checklists[2] - self.assertFalse(payload.get('is_checked')) - payload['is_checked'] = True + self.assertFalse(get_first_item(payload).get('is_checked')) + get_first_item(payload)['is_checked'] = True returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content) - self.assertTrue(returned_checklist.get('is_checked')) - self.assertEqual(self.get_persisted_checklists()[2], returned_checklist) + self.assertTrue(get_first_item(returned_checklist).get('is_checked')) + pers = self.get_persisted_checklists() + self.compare_checklists(pers[2], returned_checklist) def test_update_checklists_delete_unsupported(self): """ Delete operation is not supported. """ @@ -93,4 +117,4 @@ class ChecklistTestCase(CourseTestCase): 'name': self.course.location.name, 'checklist_index': 100}) response = self.client.delete(update_url) - self.assertContains(response, 'Unsupported request', status_code=400) \ No newline at end of file + self.assertContains(response, 'Unsupported request', status_code=400) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index a36ed76d11..d24deacecf 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -28,13 +28,21 @@ from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.inheritance import own_metadata +from xmodule.contentstore.content import StaticContent +from xmodule.contentstore.utils import restore_asset_from_trashcan, empty_asset_trashcan from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor from xmodule.seq_module import SequenceDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError +from contentstore.views.component import ADVANCED_COMPONENT_TYPES +from xmodule.exceptions import NotFoundError + from django_comment_common.utils import are_permissions_roles_seeded +from xmodule.exceptions import InvalidVersionError +import datetime +from pytz import UTC TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') @@ -75,6 +83,60 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) + def check_components_on_page(self, component_types, expected_types): + """ + Ensure that the right types end up on the page. + + component_types is the list of advanced components. + + expected_types is the list of elements that should appear on the page. + + expected_types and component_types should be similar, but not + exactly the same -- for example, 'videoalpha' in + component_types should cause 'Video Alpha' to be present. + """ + store = modulestore('direct') + import_from_xml(store, 'common/test/data/', ['simple']) + + course = store.get_item(Location(['i4x', 'edX', 'simple', + 'course', '2012_Fall', None]), depth=None) + + course.advanced_modules = component_types + + store.update_metadata(course.location, own_metadata(course)) + + # just pick one vertical + descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] + + resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) + self.assertEqual(resp.status_code, 200) + + for expected in expected_types: + self.assertIn(expected, resp.content) + + def test_advanced_components_in_edit_unit(self): + # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page + # response HTML + self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha', + 'Word cloud', + 'Annotation', + 'Open Ended Response', + 'Peer Grading Interface']) + + def test_advanced_components_require_two_clicks(self): + self.check_components_on_page(['videoalpha'], ['Video Alpha']) + + def test_malformed_edit_unit_request(self): + store = modulestore('direct') + import_from_xml(store, 'common/test/data/', ['simple']) + + # just pick one vertical + descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] + location = descriptor.location.replace(name='.' + descriptor.location.name) + + resp = self.client.get(reverse('edit_unit', kwargs={'location': location.url()})) + self.assertEqual(resp.status_code, 400) + def check_edit_unit(self, test_course_name): import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) @@ -162,7 +224,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): draft_store.clone_item(html_module.location, html_module.location) html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) - new_graceperiod = timedelta(**{'hours': 1}) + new_graceperiod = timedelta(hours=1) self.assertNotIn('graceperiod', own_metadata(html_module)) html_module.lms.graceperiod = new_graceperiod @@ -212,7 +274,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ) self.assertTrue(getattr(draft_problem, 'is_draft', False)) - #now requery with depth + # now requery with depth course = modulestore('draft').get_item( Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), depth=None @@ -307,7 +369,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ''' module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['full']) - effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) self.assertEqual(effort.data, '6 hours') @@ -323,6 +384,159 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course = module_store.get_item(source_location) self.assertFalse(course.hide_progress_tab) + def test_asset_import(self): + ''' + This test validates that an image asset is imported and a thumbnail was generated for a .gif + ''' + content_store = contentstore() + + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + + course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + course = module_store.get_item(course_location) + + self.assertIsNotNone(course) + + # make sure we have some assets in our contentstore + all_assets = content_store.get_all_content_for_course(course_location) + self.assertGreater(len(all_assets), 0) + + # make sure we have some thumbnails in our contentstore + all_thumbnails = content_store.get_all_content_thumbnails_for_course(course_location) + + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # + # self.assertGreater(len(all_thumbnails), 0) + + content = None + try: + location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + content = content_store.find(location) + except NotFoundError: + pass + + self.assertIsNotNone(content) + + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # self.assertIsNotNone(content.thumbnail_location) + # + # thumbnail = None + # try: + # thumbnail = content_store.find(content.thumbnail_location) + # except: + # pass + # + # self.assertIsNotNone(thumbnail) + + def test_asset_delete_and_restore(self): + ''' + This test will exercise the soft delete/restore functionality of the assets + ''' + content_store = contentstore() + trash_store = contentstore('trashcan') + module_store = modulestore('direct') + + import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + + # look up original (and thumbnail) in content store, should be there after import + location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + content = content_store.find(location, throw_on_not_found=False) + thumbnail_location = content.thumbnail_location + self.assertIsNotNone(content) + + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # self.assertIsNotNone(thumbnail_location) + + # 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': 'full', 'name': '6.002_Spring_2012'}) + resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'}) + self.assertEqual(resp.status_code, 200) + + asset_location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + + # now try to find it in store, but they should not be there any longer + content = content_store.find(asset_location, throw_on_not_found=False) + self.assertIsNone(content) + + if thumbnail_location: + thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False) + self.assertIsNone(thumbnail) + + # now try to find it and the thumbnail in trashcan - should be in there + content = trash_store.find(asset_location, throw_on_not_found=False) + self.assertIsNotNone(content) + + if thumbnail_location: + thumbnail = trash_store.find(thumbnail_location, throw_on_not_found=False) + self.assertIsNotNone(thumbnail) + + # let's restore the asset + restore_asset_from_trashcan('/c4x/edX/full/asset/circuits_duality.gif') + + # now try to find it in courseware store, and they should be back after restore + content = content_store.find(asset_location, throw_on_not_found=False) + self.assertIsNotNone(content) + + if thumbnail_location: + thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False) + self.assertIsNotNone(thumbnail) + + def test_empty_trashcan(self): + ''' + This test will exercise the empting of the asset trashcan + ''' + content_store = contentstore() + trash_store = contentstore('trashcan') + module_store = modulestore('direct') + + import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + + course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + content = content_store.find(location, throw_on_not_found=False) + self.assertIsNotNone(content) + + # 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': 'full', 'name': '6.002_Spring_2012'}) + resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'}) + self.assertEqual(resp.status_code, 200) + + # make sure there's something in the trashcan + all_assets = trash_store.get_all_content_for_course(course_location) + self.assertGreater(len(all_assets), 0) + + # make sure we have some thumbnails in our trashcan + all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # self.assertGreater(len(all_thumbnails), 0) + + # empty the trashcan + empty_asset_trashcan([course_location]) + + # make sure trashcan is empty + all_assets = trash_store.get_all_content_for_course(course_location) + self.assertEqual(len(all_assets), 0) + + + all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) + self.assertEqual(len(all_thumbnails), 0) + def test_clone_course(self): course_data = { @@ -359,6 +573,32 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) + def test_illegal_draft_crud_ops(self): + draft_store = modulestore('draft') + direct_store = modulestore('direct') + + CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + location = Location('i4x://MITx/999/chapter/neuvo') + self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty', + location) + direct_store.clone_item('i4x://edx/templates/chapter/Empty', location) + self.assertRaises(InvalidVersionError, draft_store.clone_item, location, + location) + + self.assertRaises(InvalidVersionError, draft_store.update_item, location, + 'chapter data') + + # taking advantage of update_children and other functions never checking that the ids are valid + self.assertRaises(InvalidVersionError, draft_store.update_children, location, + ['i4x://MITx/999/problem/doesntexist']) + + self.assertRaises(InvalidVersionError, draft_store.update_metadata, location, + {'due': datetime.datetime.now(UTC)}) + + self.assertRaises(InvalidVersionError, draft_store.unpublish, location) + + def test_bad_contentstore_request(self): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) @@ -376,12 +616,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) self.assertEqual(len(items), 0) - def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): + def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''): filesystem = OSFS(root_dir / 'test_export') self.assertTrue(filesystem.exists(dirname)) query_loc = Location('i4x', location.org, location.course, category_name, None) - items = modulestore.get_items(query_loc) + items = store.get_items(query_loc) for item in items: filesystem = OSFS(root_dir / ('test_export/' + dirname)) @@ -441,6 +681,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # check for custom_tags self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template') + # check for about content + self.verify_content_existence(module_store, root_dir, location, 'about', 'about', '.html') + # check for graiding_policy.json filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') self.assertTrue(filesystem.exists('grading_policy.json')) @@ -451,7 +694,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): on_disk = loads(grading_policy.read()) self.assertEqual(on_disk, course.grading_policy) - #check for policy.json + # check for policy.json self.assertTrue(filesystem.exists('policy.json')) # compare what's on disk to what we have in the course module @@ -524,7 +767,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_prefetch_children(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['full']) - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') wrapper = MongoCollectionFindWrapper(module_store.collection.find) @@ -620,7 +862,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_duplicate_course(self): """Test new course creation - error path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) + self.client.post(reverse('create_new_course'), self.course_data) resp = self.client.post(reverse('create_new_course'), self.course_data) data = parse_json(resp) self.assertEqual(resp.status_code, 200) @@ -628,7 +870,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_duplicate_number(self): """Test new course creation - error path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) + self.client.post(reverse('create_new_course'), self.course_data) self.course_data['display_name'] = 'Robot Super Course Two' resp = self.client.post(reverse('create_new_course'), self.course_data) @@ -846,11 +1088,9 @@ class ContentStoreTest(ModuleStoreTestCase): json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) - def test_import_metadata_with_attempts_empty_string(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['simple']) - did_load_item = False try: module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2a4ff46038..5c2a15ac87 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,11 +1,16 @@ +""" +Tests for Studio Course Settings. +""" import datetime import json import copy +import mock from django.contrib.auth.models import User from django.test.client import Client from django.core.urlresolvers import reverse from django.utils.timezone import UTC +from django.test.utils import override_settings from xmodule.modulestore import Location from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) @@ -21,6 +26,9 @@ from xmodule.fields import Date class CourseTestCase(ModuleStoreTestCase): + """ + Base class for test classes below. + """ def setUp(self): """ These tests need a user in the DB so that the django Test Client @@ -51,9 +59,13 @@ class CourseTestCase(ModuleStoreTestCase): class CourseDetailsTestCase(CourseTestCase): + """ + Tests the first course settings page (course dates, overview, etc.). + """ def test_virgin_fetch(self): details = CourseDetails.fetch(self.course_location) self.assertEqual(details.course_location, self.course_location, "Location not copied into") + self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end)) @@ -67,7 +79,6 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=") - # Note, start_date is being initialized someplace. I'm not sure why b/c the default will make no sense. self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") @@ -76,6 +87,23 @@ class CourseDetailsTestCase(CourseTestCase): self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized") self.assertIsNone(jsondetails['effort'], "effort somehow initialized") + def test_ooc_encoder(self): + """ + Test the encoder out of its original constrained purpose to see if it functions for general use + """ + details = {'location': Location(['tag', 'org', 'course', 'category', 'name']), + 'number': 1, + 'string': 'string', + 'datetime': datetime.datetime.now(UTC())} + jsondetails = json.dumps(details, cls=CourseSettingsEncoder) + jsondetails = json.loads(jsondetails) + + self.assertIn('location', jsondetails) + self.assertIn('org', jsondetails['location']) + self.assertEquals('org', jsondetails['location'][1]) + self.assertEquals(1, jsondetails['number']) + self.assertEqual(jsondetails['string'], 'string') + def test_update_and_fetch(self): # # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions jsondetails = CourseDetails.fetch(self.course_location) @@ -101,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails.effort, "After set effort" ) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) + def test_marketing_site_fetch(self): + settings_details_url = reverse( + 'settings_details', + kwargs={ + 'org': self.course_location.org, + 'name': self.course_location.name, + 'course': self.course_location.course + } + ) + + 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.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, "not the dates shown on your course summary page") + + self.assertNotContains(response, "Introducing Your Course") + self.assertNotContains(response, "Requirements") + + def test_regular_site_fetch(self): + settings_details_url = reverse( + 'settings_details', + kwargs={ + 'org': self.course_location.org, + 'name': self.course_location.name, + 'course': self.course_location.course + } + ) + + 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.assertNotContains(response, "course summary page will not be viewable") + + self.assertContains(response, "Course Start Date") + self.assertContains(response, "Course End Date") + self.assertContains(response, "Enrollment Start Date") + self.assertContains(response, "Enrollment End Date") + self.assertNotContains(response, "not the dates shown on your course summary page") + + self.assertContains(response, "Introducing Your Course") + self.assertContains(response, "Requirements") + class CourseDetailsViewTest(CourseTestCase): + """ + Tests for modifying content on the first course settings page (course dates, overview, etc.). + """ def alter_field(self, url, details, field, val): setattr(details, field, val) # Need to partially serialize payload b/c the mock doesn't handle it correctly @@ -116,11 +196,8 @@ class CourseDetailsViewTest(CourseTestCase): self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val)) @staticmethod - def convert_datetime_to_iso(datetime): - if datetime is not None: - return datetime.isoformat("T") - else: - return None + def convert_datetime_to_iso(dt): + return Date().to_json(dt) def test_update_and_fetch(self): details = CourseDetails.fetch(self.course_location) @@ -151,22 +228,12 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") - @staticmethod - def struct_to_datetime(struct_time): - return datetime.datetime(*struct_time[:6], tzinfo=UTC()) - def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: date = Date() if field in encoded and encoded[field] is not None: - encoded_encoded = date.from_json(encoded[field]) - dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded) - - if isinstance(details[field], datetime.datetime): - dt2 = details[field] - else: - details_encoded = date.from_json(details[field]) - dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded) + dt1 = date.from_json(encoded[field]) + dt2 = details[field] expected_delta = datetime.timedelta(0) self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context) @@ -177,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase): class CourseGradingTest(CourseTestCase): + """ + Tests for the course settings grading page. + """ def test_initial_grader(self): descriptor = get_modulestore(self.course_location).get_item(self.course_location) test_grader = CourseGradingModel(descriptor) @@ -252,6 +322,9 @@ class CourseGradingTest(CourseTestCase): class CourseMetadataEditingTest(CourseTestCase): + """ + Tests for CourseMetadata. + """ def setUp(self): CourseTestCase.setUp(self) # add in the full class too diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 07264cdc30..1831a5769a 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -1,4 +1,3 @@ -from contentstore.utils import get_modulestore, get_url_reverse from contentstore.tests.test_course_settings import CourseTestCase from xmodule.modulestore.tests.factories import CourseFactory from django.core.urlresolvers import reverse diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 0757992f2f..fec82db1bb 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -4,6 +4,7 @@ import mock import collections import copy from django.test import TestCase +from django.test.utils import override_settings from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -11,11 +12,52 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase class LMSLinksTestCase(TestCase): """ Tests for LMS links. """ def about_page_test(self): - """ Get URL for about page. """ + """ Get URL for about page, no marketing site """ + # default for ENABLE_MKTG_SITE is False. + self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) + def about_page_marketing_site_test(self): + """ Get URL for about page, marketing root present. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//dummy-root/courses/mitX/101/test/about") + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): + self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'http://www.dummy'}) + def about_page_marketing_site_remove_http_test(self): + """ Get URL for about page, marketing root present, remove http://. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'https://www.dummy'}) + def about_page_marketing_site_remove_https_test(self): + """ Get URL for about page, marketing root present, remove https://. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'www.dummyhttps://x'}) + def about_page_marketing_site_https__edge_test(self): + """ Get URL for about page, only remove https:// at the beginning of the string. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummyhttps://x/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={}) + def about_page_marketing_urls_not_set_test(self): + """ Error case. ENABLE_MKTG_SITE is True, but there is either no MKTG_URLS, or no MKTG_URLS Root property. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), None) + + @override_settings(LMS_BASE=None) + def about_page_no_lms_base_test(self): + """ No LMS_BASE, nor is ENABLE_MKTG_SITE True """ + self.assertEquals(self.get_about_page_link(), None) + + def get_about_page_link(self): + """ create mock course and return the about page link """ location = 'i4x', 'mitX', '101', 'course', 'test' utils.get_course_id = mock.Mock(return_value="mitX/101/test") - link = utils.get_lms_link_for_about_page(location) - self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about") + return utils.get_lms_link_for_about_page(location) def lms_link_test(self): """ Tests get_lms_link_for_item. """ diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 34e5da4b4d..f7f330f91e 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse from .utils import parse_json, user, registration from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from contentstore.tests.test_course_settings import CourseTestCase +from xmodule.modulestore.tests.factories import CourseFactory +import datetime +from pytz import UTC class ContentStoreTestCase(ModuleStoreTestCase): @@ -111,6 +115,18 @@ class AuthTestCase(ContentStoreTestCase): # Now login should work self.login(self.email, self.pw) + def test_login_link_on_activation_age(self): + self.create_account(self.username, self.email, self.pw) + # we want to test the rendering of the activation page when the user isn't logged in + self.client.logout() + resp = self._activate_user(self.email) + self.assertEqual(resp.status_code, 200) + + # check the the HTML has links to the right login page. Note that this is merely a content + # check and thus could be fragile should the wording change on this page + expected = 'You can now login.' + self.assertIn(expected, resp.content) + def test_private_pages_auth(self): """Make sure pages that do require login work.""" auth_pages = ( @@ -150,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase): self.assertEqual(resp.status_code, 302) # Logged in should work. + + +class ForumTestCase(CourseTestCase): + def setUp(self): + """ Creates the test course. """ + super(ForumTestCase, self).setUp() + self.course = CourseFactory.create(org='testX', number='727', display_name='Forum Course') + + def test_blackouts(self): + now = datetime.datetime.now(UTC) + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in + [(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + self.assertTrue(self.course.forum_posts_allowed) + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in + [(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + self.assertFalse(self.course.forum_posts_allowed) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 35451cf7cc..0bfa70e4f5 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,8 +4,11 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from django.core.urlresolvers import reverse import copy +import logging +import re +from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES -DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] +log = logging.getLogger(__name__) #In order to instantiate an open ended tab automatically, need to have this data OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"} @@ -107,9 +110,29 @@ def get_lms_link_for_about_page(location): """ Returns the url to the course about page from the location tuple. """ - if settings.LMS_BASE is not None: - lms_link = "//{lms_base}/courses/{course_id}/about".format( - lms_base=settings.LMS_BASE, + if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False): + if not hasattr(settings, 'MKTG_URLS'): + log.exception("ENABLE_MKTG_SITE is True, but MKTG_URLS is not defined.") + about_base = None + else: + marketing_urls = settings.MKTG_URLS + if marketing_urls.get('ROOT', None) is None: + log.exception('There is no ROOT defined in MKTG_URLS') + about_base = None + else: + # Root will be "https://www.edx.org". The complete URL will still not be exactly correct, + # but redirects exist from www.edx.org to get to the Drupal course about page URL. + about_base = marketing_urls.get('ROOT') + # Strip off https:// (or http://) to be consistent with the formatting of LMS_BASE. + about_base = re.sub(r"^https?://", "", about_base) + elif settings.LMS_BASE is not None: + about_base = settings.LMS_BASE + else: + about_base = None + + if about_base is not None: + lms_link = "//{about_base_url}/courses/{course_id}/about".format( + about_base_url=about_base, course_id=get_course_id(location) ) else: @@ -201,14 +224,14 @@ def add_extra_panel_tab(tab_type, course): @param course: A course object from the modulestore. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course. """ - #Copy course tabs + # Copy course tabs course_tabs = copy.copy(course.tabs) changed = False - #Check to see if open ended panel is defined in the course - + # Check to see if open ended panel is defined in the course + tab_panel = EXTRA_TAB_PANELS.get(tab_type) if tab_panel not in course_tabs: - #Add panel to the tabs if it is not defined + # Add panel to the tabs if it is not defined course_tabs.append(tab_panel) changed = True return changed, course_tabs @@ -221,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course): @param course: A course object from the modulestore. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course. """ - #Copy course tabs + # Copy course tabs course_tabs = copy.copy(course.tabs) changed = False - #Check to see if open ended panel is defined in the course + # Check to see if open ended panel is defined in the course tab_panel = EXTRA_TAB_PANELS.get(tab_type) if tab_panel in course_tabs: - #Add panel to the tabs if it is not defined + # Add panel to the tabs if it is not defined course_tabs = [ct for ct in course_tabs if ct != tab_panel] changed = True return changed, course_tabs diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index b5041d3e9f..400013b59b 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -25,6 +25,8 @@ 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 from ..utils import get_url_reverse from .access import get_location_and_verify_access @@ -62,7 +64,7 @@ def asset_index(request, org, course, name): asset_id = asset['_id'] display_info = {} display_info['displayname'] = asset['displayname'] - display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple()) + 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) @@ -78,10 +80,17 @@ def asset_index(request, org, course, name): 'active_tab': 'assets', 'context_course': course_module, 'assets': asset_display, - 'upload_asset_callback_url': upload_asset_callback_url + 'upload_asset_callback_url': upload_asset_callback_url, + 'remove_asset_callback_url': reverse('remove_asset', kwargs={ + 'org': org, + 'course': course, + 'name': name + }) }) +@login_required +@ensure_csrf_cookie def upload_asset(request, org, course, coursename): ''' cdodge: this method allows for POST uploading of files into the course asset library, which will @@ -103,6 +112,9 @@ def upload_asset(request, org, course, coursename): logging.error('Could not find course' + location) return HttpResponseBadRequest() + if 'file' not in request.FILES: + return HttpResponseBadRequest() + # compute a 'filename' which is similar to the location formatting, we're using the 'filename' # nomenclature since we're using a FileSystem paradigm here. We're just imposing # the Location string formatting expectations to keep things a bit more consistent @@ -131,7 +143,7 @@ def upload_asset(request, org, course, coursename): readback = contentstore().find(content.location) response_payload = {'displayname': content.name, - 'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()), + 'uploadDate': get_default_time_display(readback.last_modified_at), 'url': StaticContent.get_url_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' @@ -142,6 +154,57 @@ def upload_asset(request, org, course, coursename): return response +@ensure_csrf_cookie +@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 + ''' + 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: + 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 + + # delete the original + contentstore().delete(content.get_id()) + # remove from cache + del_cached_content(content.location) + + return HttpResponse() + + @ensure_csrf_cookie @login_required def import_course(request, org, course, name): @@ -227,11 +290,9 @@ def generate_export_course(request, org, course, name): root_dir = path(mkdtemp()) # export out to a tempdir - logging.debug('root = {0}'.format(root_dir)) export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore()) - #filename = root_dir / name + '.tar.gz' logging.debug('tar file being generated at {0}'.format(export_file.name)) tar_file = tarfile.open(name=export_file.name, mode='w:gz') diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 30005d4524..039deb2740 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -7,7 +7,7 @@ from django.contrib.auth.decorators import login_required 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 mitxmako.shortcuts import render_to_response from xmodule.modulestore import Location @@ -42,7 +42,7 @@ COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] NOTE_COMPONENT_TYPES = ['notes'] -ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES +ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud', 'videoalpha'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @@ -50,11 +50,18 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @login_required def edit_subsection(request, location): # check that we have permissions to edit this item - course = get_course_for_item(location) + try: + course = get_course_for_item(location) + except InvalidLocationError: + return HttpResponseBadRequest() + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location, depth=1) + try: + item = modulestore().get_item(location, depth=1) + except ItemNotFoundError: + return HttpResponseBadRequest() lms_link = get_lms_link_for_item(location, course_id=course.location.course_id) preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True) @@ -113,11 +120,18 @@ def edit_unit(request, location): id: A Location URL """ - course = get_course_for_item(location) + try: + course = get_course_for_item(location) + except InvalidLocationError: + return HttpResponseBadRequest() + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location, depth=1) + try: + item = modulestore().get_item(location, depth=1) + except ItemNotFoundError: + return HttpResponseBadRequest() lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id) @@ -149,8 +163,7 @@ def edit_unit(request, location): component_templates[category].append(( template.display_name_with_default, template.location.url(), - hasattr(template, 'markdown') and template.markdown is not None, - template.cms.empty, + hasattr(template, 'markdown') and template.markdown is not None )) components = [ diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 07f6b9669c..dd7573bad5 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -2,7 +2,6 @@ Views related to operations on course objects """ import json -import time from django.contrib.auth.decorators import login_required from django_future.csrf import ensure_csrf_cookie @@ -13,8 +12,8 @@ from django.core.urlresolvers import reverse from mitxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore - -from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError +from xmodule.modulestore.exceptions import ItemNotFoundError, \ + InvalidLocationError from xmodule.modulestore import Location from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update @@ -32,9 +31,8 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \ NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY from django_comment_common.utils import seed_permissions_roles - -# TODO: should explicitly enumerate exports with __all__ - +import datetime +from django.utils.timezone import UTC __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', 'course_config_graders_page', @@ -130,7 +128,7 @@ def create_new_course(request): new_course.display_name = display_name # set a default start date to now - new_course.start = time.gmtime() + new_course.start = datetime.datetime.now(UTC()) initialize_course_tabs(new_course) @@ -229,7 +227,8 @@ def get_course_settings(request, org, course, name): kwargs={"org": org, "course": course, "name": name, - "section": "details"}) + "section": "details"}), + 'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) }) @@ -357,52 +356,55 @@ def course_advanced_updates(request, org, course, name): # Whether or not to filter the tabs key out of the settings metadata filter_tabs = True - #Check to see if the user instantiated any advanced components. This is a hack - #that does the following : - # 1) adds/removes the open ended panel tab to a course automatically if the user + # Check to see if the user instantiated any advanced components. This is a hack + # that does the following : + # 1) adds/removes the open ended panel tab to a course automatically if the user # has indicated that they want to edit the combinedopendended or peergrading module # 2) adds/removes the notes panel tab to a course automatically if the user has # indicated that they want the notes module enabled in their course # TODO refactor the above into distinct advanced policy settings if ADVANCED_COMPONENT_POLICY_KEY in request_body: - #Get the course so that we can scrape current tabs + # Get the course so that we can scrape current tabs course_module = modulestore().get_item(location) - #Maps tab types to components + # Maps tab types to components tab_component_map = { - 'open_ended': OPEN_ENDED_COMPONENT_TYPES, + 'open_ended': OPEN_ENDED_COMPONENT_TYPES, 'notes': NOTE_COMPONENT_TYPES, } - #Check to see if the user instantiated any notes or open ended components + # Check to see if the user instantiated any notes or open ended components for tab_type in tab_component_map.keys(): component_types = tab_component_map.get(tab_type) found_ac_type = False for ac_type in component_types: if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: - #Add tab to the course if needed + # Add tab to the course if needed changed, new_tabs = add_extra_panel_tab(tab_type, course_module) - #If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json + # If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json if changed: course_module.tabs = new_tabs request_body.update({'tabs': new_tabs}) - #Indicate that tabs should not be filtered out of the metadata + # Indicate that tabs should not be filtered out of the metadata filter_tabs = False - #Set this flag to avoid the tab removal code below. + # Set this flag to avoid the tab removal code below. found_ac_type = True break - #If we did not find a module type in the advanced settings, + # If we did not find a module type in the advanced settings, # we may need to remove the tab from the course. if not found_ac_type: - #Remove tab from the course if needed + # Remove tab from the course if needed changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) if changed: course_module.tabs = new_tabs request_body.update({'tabs': new_tabs}) - #Indicate that tabs should *not* be filtered out of the metadata + # Indicate that tabs should *not* be filtered out of the metadata filter_tabs = False - - response_json = json.dumps(CourseMetadata.update_from_json(location, + try: + response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) + except (TypeError, ValueError), e: + return HttpResponseBadRequest("Incorrect setting format. " + str(e), content_type="text/plain") + return HttpResponse(response_json, mimetype="application/json") diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 25094ddcfe..abc5f48564 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -103,7 +103,7 @@ def clone_item(request): @expect_json def delete_item(request): item_location = request.POST['id'] - item_loc = Location(item_location) + item_location = Location(item_location) # check permissions for this user within this course if not has_access(request.user, item_location): @@ -124,11 +124,11 @@ def delete_item(request): # cdodge: we need to remove our parent's pointer to us so that it is no longer dangling if delete_all_versions: - parent_locs = modulestore('direct').get_parent_locations(item_loc, None) + parent_locs = modulestore('direct').get_parent_locations(item_location, None) for parent_loc in parent_locs: parent = modulestore('direct').get_item(parent_loc) - item_url = item_loc.url() + item_url = item_location.url() if item_url in parent.children: children = parent.children children.remove(item_url) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 0dbb47b31b..884a4e4fef 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -3,26 +3,26 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder -import time from contentstore.utils import get_modulestore from models.settings import course_grading from contentstore.utils import update_item from xmodule.fields import Date import re import logging +import datetime class CourseDetails(object): def __init__(self, location): - self.course_location = location # a Location obj + self.course_location = location # a Location obj self.start_date = None # 'start' - self.end_date = None # 'end' + self.end_date = None # 'end' self.enrollment_start = None self.enrollment_end = None - self.syllabus = None # a pdf file asset - self.overview = "" # html to render as the overview - self.intro_video = None # a video pointer - self.effort = None # int hours/week + self.syllabus = None # a pdf file asset + self.overview = "" # html to render as the overview + self.intro_video = None # a video pointer + self.effort = None # int hours/week @classmethod def fetch(cls, course_location): @@ -41,25 +41,25 @@ class CourseDetails(object): course.enrollment_start = descriptor.enrollment_start course.enrollment_end = descriptor.enrollment_end - temploc = course_location._replace(category='about', name='syllabus') + temploc = course_location.replace(category='about', name='syllabus') try: course.syllabus = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='overview') + temploc = temploc.replace(name='overview') try: course.overview = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='effort') + temploc = temploc.replace(name='effort') try: course.effort = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='video') + temploc = temploc.replace(name='video') try: raw_video = get_modulestore(temploc).get_item(temploc).data course.intro_video = CourseDetails.parse_video_tag(raw_video) @@ -73,9 +73,9 @@ class CourseDetails(object): """ Decode the json into CourseDetails and save any changed attrs to the db """ - ## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore + # TODO make it an error for this to be undefined & for it to not be retrievable from modulestore course_location = jsondict['course_location'] - ## Will probably want to cache the inflight courses because every blur generates an update + # Will probably want to cache the inflight courses because every blur generates an update descriptor = get_modulestore(course_location).get_item(course_location) dirty = False @@ -126,16 +126,16 @@ class CourseDetails(object): # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # to make faster, could compare against db or could have client send over a list of which fields changed. - temploc = Location(course_location)._replace(category='about', name='syllabus') + temploc = Location(course_location).replace(category='about', name='syllabus') update_item(temploc, jsondict['syllabus']) - temploc = temploc._replace(name='overview') + temploc = temploc.replace(name='overview') update_item(temploc, jsondict['overview']) - temploc = temploc._replace(name='effort') + temploc = temploc.replace(name='effort') update_item(temploc, jsondict['effort']) - temploc = temploc._replace(name='video') + temploc = temploc.replace(name='video') recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) update_item(temploc, recomposed_video_tag) @@ -153,9 +153,9 @@ class CourseDetails(object): if not raw_video: return None - keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video) + keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video) if keystring_matcher is None: - keystring_matcher = re.search('/mitx_all/python/bin/python +from django.core import management + +if __name__ == '__main__': + management.execute_from_command_line() diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index 73dfc565a2..5b4d829b3a 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -7,6 +7,7 @@ "js/vendor/jquery.cookie.js", "js/vendor/json2.js", "js/vendor/underscore-min.js", + "js/vendor/underscore.string.min.js", "js/vendor/backbone-min.js", "js/vendor/jquery.leanModal.min.js", "js/vendor/sinon-1.7.1.js", diff --git a/cms/static/coffee/fixtures/metadata-editor.underscore b/cms/static/coffee/fixtures/metadata-editor.underscore new file mode 120000 index 0000000000..9696774d0a --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-editor.underscore @@ -0,0 +1 @@ +../../../templates/js/metadata-editor.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 new file mode 120000 index 0000000000..99138aa9c1 --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-number-entry.underscore @@ -0,0 +1 @@ +../../../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 new file mode 120000 index 0000000000..c6cd499801 --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-option-entry.underscore @@ -0,0 +1 @@ +../../../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 new file mode 120000 index 0000000000..f713ab5387 --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-string-entry.underscore @@ -0,0 +1 @@ +../../../templates/js/metadata-string-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/spec/models/feedback_spec.coffee b/cms/static/coffee/spec/models/feedback_spec.coffee deleted file mode 100644 index 6ddac41ebf..0000000000 --- a/cms/static/coffee/spec/models/feedback_spec.coffee +++ /dev/null @@ -1,34 +0,0 @@ -describe "CMS.Models.SystemFeedback", -> - beforeEach -> - @model = new CMS.Models.SystemFeedback() - - it "should have an empty message by default", -> - expect(@model.get("message")).toEqual("") - - it "should have an empty title by default", -> - expect(@model.get("title")).toEqual("") - - it "should not have an intent set by default", -> - expect(@model.get("intent")).toBeNull() - - -describe "CMS.Models.WarningMessage", -> - beforeEach -> - @model = new CMS.Models.WarningMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("warning") - -describe "CMS.Models.ErrorMessage", -> - beforeEach -> - @model = new CMS.Models.ErrorMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("error") - -describe "CMS.Models.ConfirmationMessage", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("confirmation") diff --git a/cms/static/coffee/spec/models/metadata_spec.coffee b/cms/static/coffee/spec/models/metadata_spec.coffee new file mode 100644 index 0000000000..5ff65e1bfa --- /dev/null +++ b/cms/static/coffee/spec/models/metadata_spec.coffee @@ -0,0 +1,58 @@ +describe "CMS.Models.Metadata", -> + it "knows when the value has not been modified", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': false}) + expect(model.isModified()).toBeFalsy() + + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': true}) + model.setValue('original') + expect(model.isModified()).toBeFalsy() + + it "knows when the value has been modified", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': false}) + model.setValue('original') + expect(model.isModified()).toBeTruthy() + + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': true}) + model.setValue('modified') + expect(model.isModified()).toBeTruthy() + + it "tracks when values have been explicitly set", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': false}) + expect(model.isExplicitlySet()).toBeFalsy() + model.setValue('original') + expect(model.isExplicitlySet()).toBeTruthy() + + it "has both 'display value' and a 'value' methods", -> + model = new CMS.Models.Metadata( + {'value': 'default', 'explicitly_set': false}) + expect(model.getValue()).toBeNull + expect(model.getDisplayValue()).toBe('default') + model.setValue('modified') + expect(model.getValue()).toBe('modified') + expect(model.getDisplayValue()).toBe('modified') + + it "has a clear method for reverting to the default", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'default_value' : 'default', 'explicitly_set': true}) + model.clear() + expect(model.getValue()).toBeNull + expect(model.getDisplayValue()).toBe('default') + expect(model.isExplicitlySet()).toBeFalsy() + + it "has a getter for field name", -> + model = new CMS.Models.Metadata({'field_name': 'foo'}) + expect(model.getFieldName()).toBe('foo') + + it "has a getter for options", -> + model = new CMS.Models.Metadata({'options': ['foo', 'bar']}) + expect(model.getOptions()).toEqual(['foo', 'bar']) + + it "has a getter for type", -> + model = new CMS.Models.Metadata({'type': 'Integer'}) + expect(model.getType()).toBe(CMS.Models.Metadata.INTEGER_TYPE) + diff --git a/cms/static/coffee/spec/views/feedback_spec.coffee b/cms/static/coffee/spec/views/feedback_spec.coffee index 3e7d080a7c..a3950c0b3c 100644 --- a/cms/static/coffee/spec/views/feedback_spec.coffee +++ b/cms/static/coffee/spec/views/feedback_spec.coffee @@ -18,79 +18,105 @@ beforeEach -> else return trimmedText.indexOf(text) != -1; -describe "CMS.Views.Alert as base class", -> +describe "CMS.Views.SystemFeedback", -> beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ + @options = title: "Portal" message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) # it will be interesting to see when this.render is called, so lets spy on it - spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough() + @renderSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'render').andCallThrough() + @showSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'show').andCallThrough() + @hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough() - it "renders on initalize", -> - view = new CMS.Views.Alert({model: @model}) - expect(view.render).toHaveBeenCalled() + it "requires a type and an intent", -> + neither = => + new CMS.Views.SystemFeedback(@options) + noType = => + options = $.extend({}, @options) + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + noIntent = => + options = $.extend({}, @options) + options.type = "alert" + new CMS.Views.SystemFeedback(options) + both = => + options = $.extend({}, @options) + options.type = "alert" + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + + expect(neither).toThrow() + expect(noType).toThrow() + expect(noIntent).toThrow() + expect(both).not.toThrow() + + # for simplicity, we'll use CMS.Views.Alert.Confirmation from here on, + # which extends and proxies to CMS.Views.SystemFeedback + + it "does not show on initalize", -> + view = new CMS.Views.Alert.Confirmation(@options) + expect(@renderSpy).not.toHaveBeenCalled() + expect(@showSpy).not.toHaveBeenCalled() it "renders the template", -> - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options) + view.show() + expect(view.$(".action-close")).toBeDefined() expect(view.$('.wrapper')).toBeShown() - expect(view.$el).toContainText(@model.get("title")) - expect(view.$el).toContainText(@model.get("message")) + expect(view.$el).toContainText(@options.title) + expect(view.$el).toContainText(@options.message) it "close button sends a .hide() message", -> - spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough() - - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options).show() view.$(".action-close").click() - expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled() + expect(@hideSpy).toHaveBeenCalled() expect(view.$('.wrapper')).toBeHiding() describe "CMS.Views.Prompt", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ - title: "Portal" - message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) - # for some reason, expect($("body")) blows up the test runner, so this test # just exercises the Prompt rather than asserting on anything. Best I can # do for now. :( it "changes class on body", -> # expect($("body")).not.toHaveClass("prompt-is-shown") - view = new CMS.Views.Prompt({model: @model}) + view = new CMS.Views.Prompt.Confirmation({ + title: "Portal" + message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" + }) # expect($("body")).toHaveClass("prompt-is-shown") view.hide() # expect($("body")).not.toHaveClass("prompt-is-shown") -describe "CMS.Views.Alert click events", -> +describe "CMS.Views.SystemFeedback click events", -> beforeEach -> - @model = new CMS.Models.WarningMessage( + @primaryClickSpy = jasmine.createSpy('primaryClick') + @secondaryClickSpy = jasmine.createSpy('secondaryClick') + @view = new CMS.Views.Notification.Warning( title: "Unsaved", message: "Your content is currently Unsaved.", actions: primary: text: "Save", class: "save-button", - click: jasmine.createSpy('primaryClick') + click: @primaryClickSpy secondary: [{ text: "Revert", class: "cancel-button", - click: jasmine.createSpy('secondaryClick') + click: @secondaryClickSpy }] - ) - - @view = new CMS.Views.Alert({model: @model}) + @view.show() it "should trigger the primary event on a primary click", -> - @view.primaryClick() - expect(@model.get('actions').primary.click).toHaveBeenCalled() + @view.$(".action-primary").click() + expect(@primaryClickSpy).toHaveBeenCalled() + expect(@secondaryClickSpy).not.toHaveBeenCalled() it "should trigger the secondary event on a secondary click", -> - @view.secondaryClick() - expect(@model.get('actions').secondary[0].click).toHaveBeenCalled() + @view.$(".action-secondary").click() + expect(@secondaryClickSpy).toHaveBeenCalled() + expect(@primaryClickSpy).not.toHaveBeenCalled() it "should apply class to primary action", -> expect(@view.$(".action-primary")).toHaveClass("save-button") @@ -100,20 +126,18 @@ describe "CMS.Views.Alert click events", -> describe "CMS.Views.Notification minShown and maxShown", -> beforeEach -> - @model = new CMS.Models.SystemFeedback( - intent: "saving" - title: "Saving" - ) - spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough() - spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough() + @showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show') + @showSpy.andCallThrough() + @hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide') + @hideSpy.andCallThrough() @clock = sinon.useFakeTimers() afterEach -> @clock.restore() it "a minShown view should not hide too quickly", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # call hide() on it, but the minShown should prevent it from hiding right away @@ -125,8 +149,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view should hide by itself", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the maxShown timeout to expire, and check again @@ -134,13 +158,13 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a minShown view can stay visible longer", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the minShown timeout to expire, and check again @clock.tick(1001) - expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled() + expect(@hideSpy).not.toHaveBeenCalled() expect(view.$('.wrapper')).toBeShown() # can now hide immediately @@ -148,8 +172,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view can hide early", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait 50 milliseconds, and hide it early @@ -162,7 +186,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a view can have both maxShown and minShown", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000}) + view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000}) + view.show() # can't hide early @clock.tick(50) diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee new file mode 100644 index 0000000000..0c2069cf00 --- /dev/null +++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee @@ -0,0 +1,300 @@ +describe "Test Metadata Editor", -> + editorTemplate = readFixtures('metadata-editor.underscore') + numberEntryTemplate = readFixtures('metadata-number-entry.underscore') + stringEntryTemplate = readFixtures('metadata-string-entry.underscore') + optionEntryTemplate = readFixtures('metadata-option-entry.underscore') + + beforeEach -> + setFixtures($(" @@ -56,7 +60,7 @@
-
+
@@ -64,6 +68,7 @@ + @@ -86,6 +91,9 @@ + % endfor @@ -129,3 +137,21 @@ + +<%block name="view_alerts"> + +
+
+ + +
+

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

+
+ + + + ${_('close alert')} + +
+
+ diff --git a/cms/templates/base.html b/cms/templates/base.html index 07587860e5..11e8d41496 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -38,6 +38,7 @@ + @@ -54,7 +55,6 @@ - diff --git a/cms/templates/component.html b/cms/templates/component.html index dad407ff7b..512847aa3d 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -1,19 +1,41 @@ +<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='static_content.html'/> + + + + + +
-
- ${editor} -
-
- Save - Cancel -
-
+
+ + +
+ +
+
+ ${editor} +
+
+ +
- + ${preview} diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index 9bb9b3a506..cbce91ab44 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! import logging - from xmodule.util.date_utils import get_time_struct_display + from xmodule.util.date_utils import get_default_time_display %> <%! from django.core.urlresolvers import reverse %> @@ -36,11 +36,15 @@
- +
- +
% if subsection.lms.start != parent_item.lms.start and subsection.lms.start: @@ -48,7 +52,7 @@

The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset. % else:

The date above differs from the release date of ${parent_item.display_name_with_default} – - ${get_time_struct_display(parent_item.lms.start, '%m/%d/%Y at %H:%M UTC')}. + ${get_default_time_display(parent_item.lms.start)}. % endif Sync to ${parent_item.display_name_with_default}.

% endif @@ -65,11 +69,15 @@
- +
- +
Remove due date
diff --git a/cms/templates/emails/activation_email.txt b/cms/templates/emails/activation_email.txt index 5a1d63b670..4badb4ca88 100644 --- a/cms/templates/emails/activation_email.txt +++ b/cms/templates/emails/activation_email.txt @@ -1,4 +1,4 @@ -Thank you for signing up for edX edge! To activate your account, +Thank you for signing up for edX Studio! To activate your account, please copy and paste this address into your web browser's address bar: diff --git a/cms/templates/js/metadata-editor.underscore b/cms/templates/js/metadata-editor.underscore new file mode 100644 index 0000000000..03fdd28996 --- /dev/null +++ b/cms/templates/js/metadata-editor.underscore @@ -0,0 +1,6 @@ +
    + <% _.each(_.range(numEntries), function() { %> + + <% }) %> +
diff --git a/cms/templates/js/metadata-number-entry.underscore b/cms/templates/js/metadata-number-entry.underscore new file mode 100644 index 0000000000..333233ef4e --- /dev/null +++ b/cms/templates/js/metadata-number-entry.underscore @@ -0,0 +1,8 @@ +
+ + + +
+<%= model.get('help') %> diff --git a/cms/templates/js/metadata-option-entry.underscore b/cms/templates/js/metadata-option-entry.underscore new file mode 100644 index 0000000000..4cb107e882 --- /dev/null +++ b/cms/templates/js/metadata-option-entry.underscore @@ -0,0 +1,16 @@ +
+ + + +
+<%= model.get('help') %> diff --git a/cms/templates/js/metadata-string-entry.underscore b/cms/templates/js/metadata-string-entry.underscore new file mode 100644 index 0000000000..759e3ad826 --- /dev/null +++ b/cms/templates/js/metadata-string-entry.underscore @@ -0,0 +1,8 @@ +
+ + + +
+<%= model.get('help') %> diff --git a/cms/templates/overview.html b/cms/templates/overview.html index d327c8b324..43d0afc263 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! import logging - from xmodule.util.date_utils import get_time_struct_display + from xmodule.util import date_utils %> <%! from django.core.urlresolvers import reverse %> <%block name="title">Course Outline @@ -154,14 +154,19 @@

diff --git a/cms/templates/registration/activation_complete.html b/cms/templates/registration/activation_complete.html index 8cc3dc8c56..a4d028ef5b 100644 --- a/cms/templates/registration/activation_complete.html +++ b/cms/templates/registration/activation_complete.html @@ -3,6 +3,12 @@ <%namespace name='static' file='../static_content.html'/> +%if not user_logged_in: +<%block name="bodyclass"> + not-signedin + +%endif + <%block name="content">
@@ -18,7 +24,7 @@ %if user_logged_in: Visit your dashboard to see your courses. %else: - You can now login. + You can now login. %endif

diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 2adc0cd980..14c79e586a 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -1,3 +1,5 @@ +<%! 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 @@ -50,8 +52,8 @@ from contentstore import utils

- Settings - > Schedule & Details + ${_("Settings")} + > ${_("Schedule & Details")}

@@ -62,59 +64,68 @@ from contentstore import utils
-

Basic Information

- The nuts and bolts of your course +

${_("Basic Information")}

+ ${_("The nuts and bolts of your course")}
  1. - - + +
  2. - - + +
  3. - - + +
-

Course Summary Page (for student enrollment and access)

+

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

+ + % if not about_page_editable: +
+

${_("Promoting Your Course with edX")}

+
+

${_('Your course summary page will not be viewable until your course has been announced. To provide content for the page and preview it, follow the instructions provided by your PM or Conrad Warre (conrad@edx.org).')}

+
+
+ % endif

-

Course Schedule

- Important steps and segments of your course +

${_('Course Schedule')}

+ ${_('Dates that control when your course can be viewed.')}
  1. - + - First day the course begins + ${_("First day the course begins")}
    - +
    @@ -122,29 +133,30 @@ from contentstore import utils
  2. - + - Last day your course is active + ${_("Last day your course is active")}
    - +
+ % if about_page_editable:
  1. - + - First day students can enroll + ${_("First day students can enroll")}
    - +
    @@ -152,91 +164,106 @@ from contentstore import utils
  2. - + - Last day students can enroll + ${_("Last day students can enroll")}
    - +
-
+ % endif + % if not about_page_editable: +
+

${_("These Dates Are Not Used When Promoting Your Course")}

+
+

${_('These dates impact when your courseware can be viewed, but they are not the dates shown on your course summary page. To provide the course start and registration dates as shown on your course summary page, follow the instructions provided by your PM or Conrad Warre (conrad@edx.org).')}

+
+
+ % endif +
+ % if about_page_editable: +
+
+

${_("Introducing Your Course")}

+ ${_("Information for prospective students")} +
-
-
-

Introducing Your Course

- Information for prospective students -
+
    +
  1. + + + <%def name='overview_text()'><% + a_link_start = '' + _("your course summary page") + '' + a_link = a_link_start + utils.get_lms_link_for_about_page(course_location) + a_link_end + text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link + %>${text} + ${overview_text()} +
  2. -
      -
    1. - - - Introductions, prerequisites, FAQs that are used on your course summary page (formatted in HTML) -
    2. +
    3. + + -
    4. - -
      -
      - -
      - -
      +
      + + ${_("Enter your YouTube video's ID (along with any restriction parameters)")} +
      +
    5. +
    +
-
- - Enter your YouTube video's ID (along with any restriction parameters) -
- - -
+
-
+
+
+

${_("Requirements")}

+ ${_("Expectations of the students taking this course")} +
-
-
-

Requirements

- Expectations of the students taking this course -
- -
    -
  1. - - - Time spent on all course work -
  2. -
-
+
    +
  1. + + + ${_("Time spent on all course work")} +
  2. +
+
+ % endif -
Name Date Added URL
+ +
%for hname in datatable['header']: - + %endfor %for row in datatable['data']: %for value in row: - + + %endfor + + %endfor +
${hname}${hname | h}
${value}${value | h}
+

+%endif + +## Output tasks in progress + +%if instructor_tasks is not None and len(instructor_tasks) > 0: +
+

Pending Instructor Tasks

+
+ + + + + + + + + + + + %for tasknum, instructor_task in enumerate(instructor_tasks): + + + + + + + + + + + %endfor +
Task TypeTask inputsTask IdRequesterSubmittedTask StateDuration (sec)Task Progress
${instructor_task.task_type}${instructor_task.task_input}${instructor_task.task_id}${instructor_task.requester}${instructor_task.created}${instructor_task.task_state}unknownunknown
+
+
+ +%endif + +##----------------------------------------------------------------------------- + +%if course_stats and modeflag.get('Psychometrics') is None: + +
+
+

+


+

${course_stats['title'] | h}

+ + + %for hname in course_stats['header']: + + %endfor + + %for row in course_stats['data']: + + %for value in row: + %endfor %endfor diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index fcd4348f96..bf1414cc07 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -35,7 +35,7 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", %if not course.disable_progress_graph: -
+ %endif
    @@ -54,7 +54,12 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", %>

    - ${ section['display_name'] } + ${ section['display_name'] } + %if total > 0 or earned > 0: + + ${"{0:.3n} of {1:.3n} possible points".format( float(earned), float(total) )} + + %endif %if total > 0 or earned > 0: ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )} %endif diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 75c0cafabd..c41d753444 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -138,8 +138,14 @@
    Full Name (edit)
    ${ user.profile.name | h }
  1. - Email (edit) ${ user.email | h } + Email + % if external_auth_map is None or 'shib' not in external_auth_map.external_domain: + (edit) + % endif + ${ user.email | h }
  2. + + % if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
  3. Reset Password
    @@ -147,9 +153,13 @@
  4. + % endif + + ## `news` should be `None` whenever a non-edX theme is enabled: + ## see common/djangoapps/student/views.py#_get_news %if news:

diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html index deebd391d2..deb2db3610 100644 --- a/lms/templates/help_modal.html +++ b/lms/templates/help_modal.html @@ -20,17 +20,18 @@ <% discussion_link = get_discussion_link(course) if course else None %> -% if discussion_link: -

- Have a course-specific question? - - Post it on the course forums. - -

-
-% endif -

Have a general question about edX? Check the FAQ.

+% if discussion_link: +

For questions on course lectures, homework, tools, or materials for this course, post in the + course discussion forum. +

+% endif + +

Have general questions about edX? You can find lots of helpful information in the edX + FAQ.

+ +

Have a question about something specific? You can contact the edX general + support team directly:


@@ -58,11 +59,15 @@ discussion_link = get_discussion_link(course) if course else None % endif - + - + - + +% if course: + +% endif
@@ -112,21 +117,41 @@ discussion_link = get_discussion_link(course) if course else None $("#feedback_success_wrapper").css("display", "none"); $("#help_wrapper").css("display", "block"); }); - showFeedback = function(e, tag, title) { + showFeedback = function(event, issue_type, title, subject_label, details_label) { $("#help_wrapper").css("display", "none"); - $("#feedback_form input[name='tag']").val(tag); + $("#feedback_form input[name='issue_type']").val(issue_type); $("#feedback_form_wrapper").css("display", "block"); $("#feedback_form_wrapper header").html("

" + title + "


"); - e.preventDefault(); + $("#feedback_form_wrapper label[data-field='subject']").html(subject_label); + $("#feedback_form_wrapper label[data-field='details']").html(details_label); + event.preventDefault(); }; - $("#feedback_link_problem").click(function(e) { - showFeedback(e, "problem", "Report a Problem"); + $("#feedback_link_problem").click(function(event) { + showFeedback( + event, + "problem", + "Report a Problem", + "Brief description of the problem*", + "Details of the problem you are encountering* Include error messages, steps which lead to the issue, etc." + ); }); - $("#feedback_link_suggestion").click(function(e) { - showFeedback(e, "suggestion", "Make a Suggestion"); + $("#feedback_link_suggestion").click(function(event) { + showFeedback( + event, + "suggestion", + "Make a Suggestion", + "Brief description of your suggestion*", + "Details*" + ); }); - $("#feedback_link_question").click(function(e) { - showFeedback(e, "question", "Ask a Question"); + $("#feedback_link_question").click(function(event) { + showFeedback( + event, + "question", + "Ask a Question", + "Brief summary of your question*", + "Details*" + ); }); $("#feedback_form").submit(function() { $("input[type='submit']", this).attr("disabled", "disabled"); diff --git a/lms/templates/index.html b/lms/templates/index.html index 109ab6fb2f..e6b9c86ba4 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -8,30 +8,37 @@
-

The Future of Online Education

+ % if self.stanford_theme_enabled(): +

Free courses from Stanford

+ % else: +

The Future of Online Education

+ % endif

For anyone, anywhere, anytime

-
-
- Sign Up -
- -
- -
+ +
+ +
+ + % endif
@@ -45,113 +52,116 @@
-

Explore free courses from edX universities

+ ## Disable university partner logos and sites for non-edX sites + % if not self.theme_enabled(): +

Explore free courses from edX universities

-
-
    -
  1. - - -
    - MITx -
    -
    -
  2. -
  3. - - -
    - HarvardX -
    -
    -
  4. -
  5. - - -
    - BerkeleyX -
    -
    -
  6. -
  7. - - -
    - UTx -
    -
    -
  8. -
  9. - - -
    - McGillX -
    -
    -
  10. -
  11. - - -
    - ANUx -
    -
    -
  12. -
+
+
    +
  1. + + +
    + MITx +
    +
    +
  2. +
  3. + + +
    + HarvardX +
    +
    +
  4. +
  5. + + +
    + BerkeleyX +
    +
    +
  6. +
  7. + + +
    + UTx +
    +
    +
  8. +
  9. + + +
    + McGillX +
    +
    +
  10. +
  11. + + +
    + ANUx +
    +
    +
  12. +
-
+
-
    -
  1. - - -
    - WellesleyX -
    -
    -
  2. -
  3. - - -
    - GeorgetownX -
    -
    -
  4. -
  5. - - -
    - University of TorontoX -
    -
    -
  6. -
  7. - - -
    - EPFLx -
    -
    -
  8. -
  9. - - -
    - DelftX -
    -
    -
  10. -
  11. - - -
    - RiceX -
    -
    -
  12. -
-
+
    +
  1. + + +
    + WellesleyX +
    +
    +
  2. +
  3. + + +
    + GeorgetownX +
    +
    +
  4. +
  5. + + +
    + University of TorontoX +
    +
    +
  6. +
  7. + + +
    + EPFLx +
    +
    +
  8. +
  9. + + +
    + DelftX +
    +
    +
  10. +
  11. + + +
    + RiceX +
    +
    +
  12. +
+
+ % endif
    @@ -165,47 +175,56 @@
-
-
-
-

edX News & Announcements

- edX MEDIA KIT -
-
-
- %for entry in news: -
- %if entry.image: - - %endif -
- ${entry.title} - %if entry.summary: -

${entry.summary}

- %endif - -
-
- %endfor -
- + % endif
diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index 1c5f7364ad..0a28a2b026 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -29,7 +29,7 @@

Instructions

-

This is the list of problems that current need to be graded in order to train the machine learning models. Each problem needs to be trained separately, and we have indicated the number of student submissions that need to be graded in order for a model to be generated. You can grade more than the minimum required number of submissions--this will improve the accuracy of machine learning, though with diminishing returns. You can see the current accuracy of machine learning while grading.

+

This is the list of problems that currently need to be graded in order to train the machine learning models. Each problem needs to be trained separately, and we have indicated the number of student submissions that need to be graded in order for a model to be generated. You can grade more than the minimum required number of submissions--this will improve the accuracy of machine learning, though with diminishing returns. You can see the current accuracy of machine learning while grading.

Problem List

diff --git a/lms/templates/invalid_email_key.html b/lms/templates/invalid_email_key.html index 437dfa151d..212f91fa9d 100644 --- a/lms/templates/invalid_email_key.html +++ b/lms/templates/invalid_email_key.html @@ -1,8 +1,16 @@ -

Invalid key

+<%inherit file="main.html" /> -

This e-mail key is not valid. Please check: -

    -
  • Was this key already used? Check whether the e-mail change has already happened. -
  • Did your e-mail client break the URL into two lines? -
  • The keys are valid for a limited amount of time. Has the key expired? -
+
+ +
+

Invalid email change key

+
+

This e-mail key is not valid. Please check:

+
    +
  • Was this key already used? Check whether the e-mail change has already happened. +
  • Did your e-mail client break the URL into two lines? +
  • The keys are valid for a limited amount of time. Has the key expired? +
+

Go back to the home page.

+
+
diff --git a/lms/templates/login.html b/lms/templates/login.html index 3e33c84b7a..fbd2f6d07c 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -1,8 +1,11 @@ <%inherit file="main.html" /> <%namespace name='static' file='static_content.html'/> + <%! from django.core.urlresolvers import reverse %> -<%block name="title">Log into your edX Account +<%! from django.utils.translation import ugettext as _ %> + +<%block name="title">Log into your ${settings.PLATFORM_NAME} Account <%block name="js_extra"> - + <%static:css group='application'/> <%static:js group='main_vendor'/> <%block name="headextra"/> + % if theme_enabled(): + <%include file="theme-head-extra.html" /> + % endif @@ -105,7 +103,7 @@

- Please complete the following fields to register for an edX account.
+ Please complete the following fields to register for an account.
Required fields are noted by bold text and an asterisk (*).

@@ -117,20 +115,20 @@
  1. - +
  2. - +
  3. - + Will be shown in any discussions or forums you participate in
  4. - + Needed for any certificates you may earn (cannot be changed later)
@@ -138,16 +136,37 @@ % else:
-

Welcome ${extauth_email}

+

Welcome ${extauth_id}

Enter a public username:

    + + % if ask_for_email: + +
  1. + + +
  2. + + % endif +
  3. - + Will be shown in any discussions or forums you participate in
  4. + + % if ask_for_fullname: + +
  5. + + + Needed for any certificates you may earn (cannot be changed later) +
  6. + + % endif +
% endif @@ -201,7 +220,7 @@
  • - +
  • @@ -212,14 +231,26 @@
    1. + + % if has_extauth_info is UNDEFINED or ask_for_tos : +
      - +
      + % endif +
      - + <% + ## TODO: provide a better way to override these links + if self.stanford_theme_enabled(): + honor_code_path = marketing_link('TOS') + "#honor" + else: + honor_code_path = marketing_link('HONOR') + %> +
    @@ -241,6 +272,8 @@

    Registration Help

    + % if has_extauth_info is UNDEFINED: +

    Already registered?

    @@ -249,24 +282,36 @@

    + + % endif -
    -

    Welcome to edX

    -

    Registering with edX gives you access to all of our current and future free courses. Not ready to take a course just yet? Registering puts you on our mailing list – we will update you as courses are added.

    -
    + ## TODO: Use a %block tag or something to allow themes to + ## override in a more generalizable fashion. + % if not self.stanford_theme_enabled(): +
    +

    Welcome to ${settings.PLATFORM_NAME}

    +

    Registering with ${settings.PLATFORM_NAME} gives you access to all of our current and future free courses. Not ready to take a course just yet? Registering puts you on our mailing list – we will update you as courses are added.

    +
    + % endif

    Next Steps

    -

    As part of joining edX, you will receive an activation email. You must click on the activation link to complete the process. Don’t see the email? Check your spam folder and mark edX emails as ‘not spam’. At edX, we communicate mostly through email.

    + % if self.stanford_theme_enabled(): +

    You will receive an activation email. You must click on the activation link to complete the process. Don’t see the email? Check your spam folder and mark emails from class.stanford.edu as ‘not spam’, since you'll want to be able to receive email from your courses.

    + % else: +

    As part of joining ${settings.PLATFORM_NAME}, you will receive an activation email. You must click on the activation link to complete the process. Don’t see the email? Check your spam folder and mark ${settings.PLATFORM_NAME} emails as ‘not spam’. At ${settings.PLATFORM_NAME}, we communicate mostly through email.

    + % endif
    -
    -

    Need Help?

    -

    Need help in registering with edX? - - View our FAQs for answers to commonly asked questions. - - Once registered, most questions can be answered in the course specific discussion forums or through the FAQs.

    -
    + % if settings.MKTG_URL_LINK_MAP.get('FAQ'): +
    +

    Need Help?

    +

    Need help in registering with ${settings.PLATFORM_NAME}? + + View our FAQs for answers to commonly asked questions. + + Once registered, most questions can be answered in the course specific discussion forums or through the FAQs.

    +
    + % endif diff --git a/lms/templates/registration/activation_invalid.html b/lms/templates/registration/activation_invalid.html index 09d373a39d..0a6d6d30c9 100644 --- a/lms/templates/registration/activation_invalid.html +++ b/lms/templates/registration/activation_invalid.html @@ -12,7 +12,7 @@

    Something went wrong. Check to make sure the URL you went to was correct -- e-mail programs will sometimes split it into two lines. If you still have issues, e-mail us to let us know what happened - at bugs@edx.org.

    + at ${settings.BUGS_EMAIL}.

    Or you can go back to the home page.

    diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index 304e7834f1..8b94ff5658 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -1,5 +1,9 @@
    diff --git a/lms/templates/signup_modal.html b/lms/templates/signup_modal.html index a68e36e902..9c1a868e2d 100644 --- a/lms/templates/signup_modal.html +++ b/lms/templates/signup_modal.html @@ -32,11 +32,23 @@ % else: -

    Welcome ${extauth_email}


    +

    Welcome ${extauth_id}


    Enter a public username:

    - + - + + + % if ask_for_email: + + + % endif + + + % if ask_for_fullname: + + + % endif + % endif
    diff --git a/lms/templates/simplewiki/simplewiki_base.html b/lms/templates/simplewiki/simplewiki_base.html deleted file mode 100644 index e19d8d61ca..0000000000 --- a/lms/templates/simplewiki/simplewiki_base.html +++ /dev/null @@ -1,164 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="../main.html"/> -<%namespace name='static' file='../static_content.html'/> - -<%block name="headextra"> - <%static:css group='course'/> - - -<%! - from django.core.urlresolvers import reverse - from simplewiki.views import wiki_reverse -%> - -<%block name="js_extra"> - - -## TODO (cpennington): Remove this when we have a good way for modules to specify js to load on the page -## and in the wiki - - - - - - - <%block name="wiki_head"/> - - - -<%block name="bodyextra"> - -%if course: -<%include file="/courseware/course_navigation.html" args="active_page='wiki'" /> -%endif - -
    -
    - <%block name="wiki_panel"> -
    -

    Course Wiki

    -
      -
    • -

      - All Articles -

      -
    • - -
    • -

      - Create Article -

      - -
      - <% - baseURL = wiki_reverse("wiki_create", course=course, kwargs={"article_path" : namespace + "/" }) - %> - -
      - - -
      -
        -
      • - -
      • -
      - -
      -
    • - - -
    - -
    - - -
    - %if wiki_article is not UNDEFINED: -
    - %if wiki_article.locked: -

    This article has been locked

    - %endif -

    Last modified: ${wiki_article.modified_on.strftime("%b %d, %Y, %I:%M %p")}

    - %endif - - %if wiki_article is not UNDEFINED: - -
    - %endif - - <%block name="wiki_page_title"/> - <%block name="wiki_body"/> -
    -
    -
    - diff --git a/lms/templates/simplewiki/simplewiki_edit.html b/lms/templates/simplewiki/simplewiki_edit.html deleted file mode 100644 index 0381a21857..0000000000 --- a/lms/templates/simplewiki/simplewiki_edit.html +++ /dev/null @@ -1,76 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title"> - -%if create_article: -Wiki – Create Article – MITx 6.002x -%else: -${"Edit " + wiki_title + " - " if wiki_title is not UNDEFINED else ""}MITx 6.002x Wiki -%endif - - -<%block name="wiki_page_title"> -%if create_article: -

    Create article

    -%else: -

    ${ wiki_article.title }

    -%endif - - -<%block name="wiki_head"> - - - - - - - - - - -<%block name="wiki_body"> -
    -
    - -
    - ${wiki_form} - %if create_article: - - %else: - - - %endif - -<%include file="simplewiki_instructions.html"/> - - diff --git a/lms/templates/simplewiki/simplewiki_error.html b/lms/templates/simplewiki/simplewiki_error.html deleted file mode 100644 index 0ce0763def..0000000000 --- a/lms/templates/simplewiki/simplewiki_error.html +++ /dev/null @@ -1,79 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%! - from simplewiki.views import wiki_reverse -%> - -<%block name="title">Wiki Error – MITx 6.002x - - -<%block name="wiki_page_title"> -

    Oops...

    - - - -<%block name="wiki_body"> -
    -%if wiki_error is not UNDEFINED: -${wiki_error} -%endif - -%if wiki_err_notfound is not UNDEFINED: -

    - The page you requested could not be found. - Click here to create it. -

    -%elif wiki_err_no_namespace is not UNDEFINED and wiki_err_no_namespace: -

    - You must specify a namespace to create an article in. -

    -%elif wiki_err_bad_namespace is not UNDEFINED and wiki_err_bad_namespace: -

    - The namespace for this article does not exist. This article cannot be created. -

    -%elif wiki_err_locked is not UNDEFINED and wiki_err_locked: -

    - The article you are trying to modify is locked. -

    -%elif wiki_err_noread is not UNDEFINED and wiki_err_noread: -

    - You do not have access to read this article. -

    -%elif wiki_err_nowrite is not UNDEFINED and wiki_err_nowrite: -

    - You do not have access to edit this article. -

    -%elif wiki_err_noanon is not UNDEFINED and wiki_err_noanon: -

    - Anonymous attachments are not allowed. Try logging in. -

    -%elif wiki_err_create is not UNDEFINED and wiki_err_create: -

    - You do not have access to create this article. -

    -%elif wiki_err_encode is not UNDEFINED and wiki_err_encode: -

    - The url you requested could not be handled by the wiki. - Probably you used a bad character in the URL. - Only use digits, English letters, underscore and dash. For instance - /wiki/An_Article-1 -

    -%elif wiki_err_deleted is not UNDEFINED and wiki_err_deleted: -

    - The article you tried to access has been deleted. You may be able to restore it to an earlier version in its history, or create a new version. -

    -%elif wiki_err_norevision is not UNDEFINED: -

    - This article does not contain revision ${wiki_err_norevision | h}. -

    -%else: -

    - An error has occured. -

    -%endif - -
    - - diff --git a/lms/templates/simplewiki/simplewiki_history.html b/lms/templates/simplewiki/simplewiki_history.html deleted file mode 100644 index 0fc77eeb0c..0000000000 --- a/lms/templates/simplewiki/simplewiki_history.html +++ /dev/null @@ -1,92 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">${"Revision history of " + wiki_title + " - " if wiki_title is not UNDEFINED else ""}Wiki – MITx 6.002x - -<%! - from django.core.urlresolvers import reverse - from simplewiki.views import wiki_reverse -%> - -<%block name="wiki_page_title"> -

    -${ wiki_article.title } -

    - - -<%block name="wiki_body"> - -
    - -
    -
    ${hname | h}
    ${value | h}
    - - - - - - - - - - <% loopCount = 0 %> - %for revision in wiki_history: - %if revision.deleted < 2 or show_delete_revision: - <% loopCount += 1 %> - - - - - - - %endif - %endfor - - %if wiki_prev_page or wiki_next_page: - - - - - - %endif -
    RevisionCommentDiffModified
    - - - - ${ revision.revision_text if revision.revision_text else "None" } - %for x in revision.get_diff(): - ${x|h}
    - %endfor
    ${revision.get_user()} -
    - ${revision.revision_date.strftime("%b %d, %Y, %I:%M %p")} -
    - %if wiki_prev_page: - Previous page - %endif - %if wiki_next_page: - Next page - %endif -
    -
    - - %if show_delete_revision: - - - - - %endif -
    - - diff --git a/lms/templates/simplewiki/simplewiki_instructions.html b/lms/templates/simplewiki/simplewiki_instructions.html deleted file mode 100644 index 449b92b004..0000000000 --- a/lms/templates/simplewiki/simplewiki_instructions.html +++ /dev/null @@ -1,24 +0,0 @@ -
    - This wiki uses Markdown for styling. -

    MITx Additions:

    -

    circuit-schematic:

    -

    $LaTeX Math Expression$

    - To create a new wiki article, create a link to it. Clicking the link gives you the creation page. -

    [Article Name](wiki:ArticleName)

    - -

    Useful examples:

    -

    [Link](http://google.com)

    -

    Huge Header -
    ====

    -

    Smaller Header -
    -------

    -

    *emphasis* or _emphasis_

    -

    **strong** or __strong__

    -

    - Unordered List -
      - Sub Item 1 -
      - Sub Item 2

    -

    1. Ordered -
    2. List

    - -

    Need more help? There are several useful guides online.

    -
    diff --git a/lms/templates/simplewiki/simplewiki_revision_feed.html b/lms/templates/simplewiki/simplewiki_revision_feed.html deleted file mode 100644 index 69b69afdff..0000000000 --- a/lms/templates/simplewiki/simplewiki_revision_feed.html +++ /dev/null @@ -1,63 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">Wiki - Revision feed - MITx 6.002x - -<%! - from simplewiki.views import wiki_reverse -%> - -<%block name="wiki_page_title"> -

    Revision Feed - Page ${wiki_page}

    - - -<%block name="wiki_body"> - - - - - - - - - - - <% loopCount = 0 %> - %for revision in wiki_history: - %if revision.deleted < 2 or show_delete_revision: - <% loopCount += 1 %> - - - - - - - %endif - %endfor - - %if wiki_prev_page or wiki_next_page: - - - - - - %endif -
    RevisionCommentDiffModified
    - ${revision.article.title} - ${revision} - - ${ revision.revision_text if revision.revision_text else "None" } - %for x in revision.get_diff(): - ${x|h}
    - %endfor
    ${revision.get_user()} -
    - ${revision.revision_date.strftime("%b %d, %Y, %I:%M %p")} -
    - %if wiki_prev_page: - Previous page - %endif - %if wiki_next_page: - Next page - %endif -
    - diff --git a/lms/templates/simplewiki/simplewiki_searchresults.html b/lms/templates/simplewiki/simplewiki_searchresults.html deleted file mode 100644 index e64a01ae62..0000000000 --- a/lms/templates/simplewiki/simplewiki_searchresults.html +++ /dev/null @@ -1,34 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">Wiki - Search Results - MITx 6.002x - -<%! - from simplewiki.views import wiki_reverse -%> - -<%block name="wiki_page_title"> -

    -%if wiki_search_query: -Search results for ${wiki_search_query | h} -%else: -Displaying all articles -%endif -

    - - -<%block name="wiki_body"> -
    -
      -%for article in wiki_search_results: -<% article_deleted = not article.current_revision.deleted == 0 %> -
    • ${article.title} ${'(Deleted)' if article_deleted else ''}

    • -%endfor - -%if not wiki_search_results: -No articles matching ${wiki_search_query if wiki_search_query is not UNDEFINED else ""} ! -%endif -
    -
    - diff --git a/lms/templates/simplewiki/simplewiki_updateprogressbar.html b/lms/templates/simplewiki/simplewiki_updateprogressbar.html deleted file mode 100644 index a7739d6bf1..0000000000 --- a/lms/templates/simplewiki/simplewiki_updateprogressbar.html +++ /dev/null @@ -1,37 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license -##This file has been converted to Mako, but not tested. It is because uploads are disabled for the wiki. If they are reenabled, this may contain bugs. -<%! - from django.template.defaultfilters import filesizeformat -%> - - -%if started: - -%else: -%if finished: - -%else: -%if overwrite_warning: - -%else: -%if too_big: - -%else: - -%endif -%endif -%endif -%endif diff --git a/lms/templates/simplewiki/simplewiki_view.html b/lms/templates/simplewiki/simplewiki_view.html deleted file mode 100644 index 53f0030eaf..0000000000 --- a/lms/templates/simplewiki/simplewiki_view.html +++ /dev/null @@ -1,15 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">${wiki_title + " - " if wiki_title is not UNDEFINED else ""}Wiki – MITx 6.002x - -<%block name="wiki_page_title"> -

    ${ wiki_article.title } ${'- Deleted Revision!' if wiki_current_revision_deleted else ''}

    - - -<%block name="wiki_body"> -
    - ${ wiki_article_revision.contents_parsed| n} -
    - diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 7b4abf13fd..d24d6528ac 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -1,6 +1,6 @@ ## The JS for this is defined in xqa_interface.html ${module_content} -%if location.category in ['problem','video','html','combinedopenended']: +%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool']: % if edit_link:
    Edit diff --git a/lms/templates/static_templates/404.html b/lms/templates/static_templates/404.html index f29968e2f5..c297cec881 100644 --- a/lms/templates/static_templates/404.html +++ b/lms/templates/static_templates/404.html @@ -4,5 +4,5 @@

    Page not found

    -

    The page that you were looking for was not found. Go back to the homepage or let us know about any pages that may have been moved at technical@edx.org.

    +

    The page that you were looking for was not found. Go back to the homepage or let us know about any pages that may have been moved at ${settings.TECH_SUPPORT_EMAIL}.

    diff --git a/lms/templates/static_templates/server-down.html b/lms/templates/static_templates/server-down.html index 7fada34a53..ac847db9ee 100644 --- a/lms/templates/static_templates/server-down.html +++ b/lms/templates/static_templates/server-down.html @@ -1,6 +1,6 @@ <%inherit file="../main.html" />
    -

    Currently the edX servers are down

    -

    Our staff is currently working to get the site back up as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

    +

    Currently the ${settings.PLATFORM_NAME} servers are down

    +

    Our staff is currently working to get the site back up as soon as possible. Please email us at ${settings.TECH_SUPPORT_EMAIL} to report any problems or downtime.

    diff --git a/lms/templates/static_templates/server-error.html b/lms/templates/static_templates/server-error.html index 5564ea082e..04fc11d11a 100644 --- a/lms/templates/static_templates/server-error.html +++ b/lms/templates/static_templates/server-error.html @@ -1,6 +1,6 @@ <%inherit file="../main.html" />
    -

    There has been a 500 error on the edX servers

    -

    Please wait a few seconds and then reload the page. If the problem persists, please email us at technical@edx.org.

    +

    There has been a 500 error on the ${settings.PLATFORM_NAME} servers

    +

    Please wait a few seconds and then reload the page. If the problem persists, please email us at ${settings.TECH_SUPPORT_EMAIL}.

    diff --git a/lms/templates/static_templates/server-overloaded.html b/lms/templates/static_templates/server-overloaded.html index bbf4550ff4..2432f2b481 100644 --- a/lms/templates/static_templates/server-overloaded.html +++ b/lms/templates/static_templates/server-overloaded.html @@ -1,6 +1,6 @@ <%inherit file="../main.html" />
    -

    Currently the edX servers are overloaded

    -

    Our staff is currently working to get the site back up as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

    +

    Currently the ${settings.PLATFORM_NAME} servers are overloaded

    +

    Our staff is currently working to get the site back up as soon as possible. Please email us at ${settings.TECH_SUPPORT_EMAIL} to report any problems or downtime.

    diff --git a/lms/templates/video.html b/lms/templates/video.html index 24785abf72..77c8a5ee16 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -2,21 +2,34 @@

    ${display_name}

    % endif -%if settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']: -
    - -%elif settings.MITX_FEATURES.get('USE_YOUTUBE_OBJECT_API') and normal_speed_video_id: +%if settings.MITX_FEATURES.get('USE_YOUTUBE_OBJECT_API') and normal_speed_video_id: + % if not settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']: + value="https://www.youtube.com/v/${normal_speed_video_id}?version=3&autoplay=1&rel=0"> + % endif + - + %else: -
    +
    diff --git a/lms/templates/videoalpha.html b/lms/templates/videoalpha.html index 2028d3c320..2bb5d817a8 100644 --- a/lms/templates/videoalpha.html +++ b/lms/templates/videoalpha.html @@ -2,33 +2,38 @@

    ${display_name}

    % endif -%if settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']: -
    -%else: -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -%endif +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    % if sources.get('main'):
    diff --git a/lms/templates/widgets/segment-io.html b/lms/templates/widgets/segment-io.html new file mode 100644 index 0000000000..dd9787a77c --- /dev/null +++ b/lms/templates/widgets/segment-io.html @@ -0,0 +1,24 @@ +% if settings.MITX_FEATURES.get('SEGMENT_IO_LMS'): + + + +% else: + + + +% endif \ No newline at end of file diff --git a/lms/urls.py b/lms/urls.py index 851731e6ec..52ce539f73 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -3,7 +3,8 @@ from django.conf.urls import patterns, include, url from django.contrib import admin from django.conf.urls.static import static -from . import one_time_startup +# Not used, the work is done in the imported module. +from . import one_time_startup # pylint: disable=W0611 import django.contrib.auth.views @@ -58,66 +59,92 @@ urlpatterns = ('', # nopep8 name='auth_password_reset_done'), url(r'^heartbeat$', include('heartbeat.urls')), +) - ## - ## Only universities without courses should be included here. If - ## courses exist, the dynamic profile rule below should win. - ## - url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'WellesleyX'}), - url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'McGillX'}), - url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'TorontoX'}), - url(r'^(?i)university_profile/RiceX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'RiceX'}), - url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'ANUx'}), - url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'EPFLx'}), +# University profiles only make sense in the default edX context +if not settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + urlpatterns += ( + ## + ## Only universities without courses should be included here. If + ## courses exist, the dynamic profile rule below should win. + ## + url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'WellesleyX'}), + url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'McGillX'}), + url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'TorontoX'}), + url(r'^(?i)university_profile/RiceX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'RiceX'}), + url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'ANUx'}), + url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'EPFLx'}), - url(r'^university_profile/(?P[^/]+)$', 'courseware.views.university_profile', - name="university_profile"), + url(r'^university_profile/(?P[^/]+)$', 'courseware.views.university_profile', + name="university_profile"), + ) - #Semi-static views (these need to be rendered and have the login bar, but don't change) +#Semi-static views (these need to be rendered and have the login bar, but don't change) +urlpatterns += ( url(r'^404$', 'static_template_view.views.render', {'template': '404.html'}, name="404"), - url(r'^about$', 'static_template_view.views.render', - {'template': 'about.html'}, name="about_edx"), - url(r'^jobs$', 'static_template_view.views.render', - {'template': 'jobs.html'}, name="jobs"), - url(r'^contact$', 'static_template_view.views.render', - {'template': 'contact.html'}, name="contact"), - url(r'^press$', 'student.views.press', name="press"), - url(r'^media-kit$', 'static_template_view.views.render', - {'template': 'media-kit.html'}, name="media-kit"), - url(r'^faq$', 'static_template_view.views.render', - {'template': 'faq.html'}, name="faq_edx"), - url(r'^help$', 'static_template_view.views.render', - {'template': 'help.html'}, name="help_edx"), - - url(r'^tos$', 'static_template_view.views.render', - {'template': 'tos.html'}, name="tos"), - url(r'^privacy$', 'static_template_view.views.render', - {'template': 'privacy.html'}, name="privacy_edx"), - # TODO: (bridger) The copyright has been removed until it is updated for edX - # url(r'^copyright$', 'static_template_view.views.render', - # {'template': 'copyright.html'}, name="copyright"), - url(r'^honor$', 'static_template_view.views.render', - {'template': 'honor.html'}, name="honor"), - - #Press releases - url(r'^press/([_a-zA-Z0-9-]+)$', 'static_template_view.views.render_press_release', name='press_release'), - - # Favicon - (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), - - url(r'^submit_feedback$', 'util.views.submit_feedback_via_zendesk'), - - # TODO: These urls no longer work. They need to be updated before they are re-enabled - # url(r'^reactivate/(?P[^/]*)$', 'student.views.reactivation_email'), ) +# Semi-static views only used by edX, not by themes +if not settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + urlpatterns += ( + url(r'^jobs$', 'static_template_view.views.render', + {'template': 'jobs.html'}, name="jobs"), + url(r'^press$', 'student.views.press', name="press"), + url(r'^media-kit$', 'static_template_view.views.render', + {'template': 'media-kit.html'}, name="media-kit"), + url(r'^faq$', 'static_template_view.views.render', + {'template': 'faq.html'}, name="faq_edx"), + url(r'^help$', 'static_template_view.views.render', + {'template': 'help.html'}, name="help_edx"), + + # TODO: (bridger) The copyright has been removed until it is updated for edX + # url(r'^copyright$', 'static_template_view.views.render', + # {'template': 'copyright.html'}, name="copyright"), + + #Press releases + url(r'^press/([_a-zA-Z0-9-]+)$', 'static_template_view.views.render_press_release', name='press_release'), + + # Favicon + (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), + + url(r'^submit_feedback$', 'util.views.submit_feedback'), + + ) + +# Only enable URLs for those marketing links actually enabled in the +# settings. Disable URLs by marking them as None. +for key, value in settings.MKTG_URL_LINK_MAP.items(): + # Skip disabled URLs + if value is None: + continue + + # These urls are enabled separately + if key == "ROOT" or key == "COURSES" or key == "FAQ": + continue + + # Make the assumptions that the templates are all in the same dir + # and that they all match the name of the key (plus extension) + template = "%s.html" % key.lower() + + # To allow theme templates to inherit from default templates, + # prepend a standard prefix + if settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + template = "theme-" + template + + # Make the assumption that the URL we want is the lowercased + # version of the map key + urlpatterns += (url(r'^%s' % key.lower(), + 'static_template_view.views.render', + {'template': template}, name=value),) + + if settings.PERFSTATS: urlpatterns += (url(r'^reprofile$', 'perfstats.views.end_profile'),) @@ -241,8 +268,6 @@ if settings.COURSEWARE_ENABLED: 'instructor.views.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', 'instructor.views.grade_summary', name='grade_summary'), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/enroll_students$', - 'instructor.views.enroll_students', name='enroll_students'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/staff_grading$', 'open_ended_grading.views.staff_grading', name='staff_grading'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/staff_grading/get_next$', @@ -337,6 +362,21 @@ if settings.MITX_FEATURES.get('AUTH_USE_OPENID'): url(r'^openid/logo.gif$', 'django_openid_auth.views.logo', name='openid-logo'), ) +if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): + urlpatterns += ( + url(r'^shib-login/$', 'external_auth.views.shib_login', name='shib-login'), + ) + +if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): + urlpatterns += ( + url(r'^course_specific_login/(?P[^/]+/[^/]+/[^/]+)/$', + 'external_auth.views.course_specific_login', name='course-specific-login'), + url(r'^course_specific_register/(?P[^/]+/[^/]+/[^/]+)/$', + 'external_auth.views.course_specific_register', name='course-specific-register'), + + ) + + if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): urlpatterns += ( url(r'^openid/provider/login/$', 'external_auth.views.provider_login', name='openid-provider-login'), @@ -368,6 +408,17 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): url(r'^status/', include('service_status.urls')), ) +if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): + urlpatterns += ( + url(r'^instructor_task_status/$', 'instructor_task.views.instructor_task_status', name='instructor_task_status'), + ) + +if settings.MITX_FEATURES.get('RUN_AS_ANALYTICS_SERVER_ENABLED'): + urlpatterns += ( + url(r'^edinsights_service/', include('edinsights.core.urls')), + ) + import edinsights.core.registry + # FoldIt views urlpatterns += ( # The path is hardcoded into their app... @@ -387,3 +438,5 @@ if settings.DEBUG: #Custom error pages handler404 = 'static_template_view.views.render_404' handler500 = 'static_template_view.views.render_500' + + diff --git a/lms/wsgi_apache_lms.py b/lms/wsgi_apache_lms.py new file mode 100644 index 0000000000..0f9950ca41 --- /dev/null +++ b/lms/wsgi_apache_lms.py @@ -0,0 +1,15 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") +os.environ.setdefault("SERVICE_VARIANT", "lms") + +# 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/xmodule_namespace.py b/lms/xmodule_namespace.py index 6b78d18db0..aaef0b76db 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -1,15 +1,15 @@ """ Namespace that defines fields common to all blocks used in the LMS """ -from xblock.core import Namespace, Boolean, Scope, String -from xmodule.fields import Date, Timedelta, StringyFloat, StringyBoolean +from xblock.core import Namespace, Boolean, Scope, String, Float +from xmodule.fields import Date, Timedelta class LmsNamespace(Namespace): """ Namespace that defines fields common to all blocks used in the LMS """ - hide_from_toc = StringyBoolean( + hide_from_toc = Boolean( help="Whether to display this module in the table of contents", default=False, scope=Scope.settings @@ -37,7 +37,7 @@ class LmsNamespace(Namespace): ) showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") rerandomize = String(help="When to rerandomize the problem", default="always", scope=Scope.settings) - days_early_for_beta = StringyFloat( + days_early_for_beta = Float( help="Number of days early to show content to beta users", default=None, scope=Scope.settings diff --git a/pylintrc b/pylintrc index d4085379b4..af958e4af4 100644 --- a/pylintrc +++ b/pylintrc @@ -35,9 +35,11 @@ load-plugins= # it should appear only once). disable= # Never going to use these +# I0011: Locally disabling W0232 # C0301: Line too long -# W0142: Used * or ** magic # W0141: Used builtin function 'map' +# W0142: Used * or ** magic + I0011,C0301,W0141,W0142, # Might use these when the code is in better shape # C0302: Too many lines in module @@ -50,7 +52,7 @@ disable= # R0912: Too many branches # R0913: Too many arguments # R0914: Too many local variables - C0301,C0302,W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914 + C0302,R0201,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914 [REPORTS] diff --git a/rakefile b/rakefile index 20101a14db..2cf442bca9 100644 --- a/rakefile +++ b/rakefile @@ -1,9 +1,11 @@ -require 'json' -require 'rake/clean' -require './rakefiles/helpers.rb' - -Dir['rakefiles/*.rake'].each do |rakefile| - import rakefile +begin + require 'json' + require 'rake/clean' + require './rakelib/helpers.rb' +rescue LoadError => error + puts "Import failed (#{error})" + puts "Please run `bundle install` to bootstrap ruby dependencies" + exit 1 end # Build Constants diff --git a/rakefiles/assets.rake b/rakefiles/assets.rake deleted file mode 100644 index 68127a317f..0000000000 --- a/rakefiles/assets.rake +++ /dev/null @@ -1,168 +0,0 @@ -# Theming constants -THEME_NAME = ENV_TOKENS['THEME_NAME'] -USE_CUSTOM_THEME = !(THEME_NAME.nil? || THEME_NAME.empty?) -if USE_CUSTOM_THEME - THEME_ROOT = File.join(ENV_ROOT, "themes", THEME_NAME) - THEME_SASS = File.join(THEME_ROOT, "static", "sass") -end - -# Run the specified file through the Mako templating engine, providing -# the ENV_TOKENS to the templating context. -def preprocess_with_mako(filename) - # simple command-line invocation of Mako engine - # cdodge: the .gsub() are used to translate true->True and false->False to make the generated - # python actually valid python. This is just a short term hack to unblock the release train - # until a real fix can be made by people who know this better - mako = "from mako.template import Template;" + - "print Template(filename=\"#{filename}\")" + - # Total hack. It works because a Python dict literal has - # the same format as a JSON object. - ".render(env=#{ENV_TOKENS.to_json.gsub("true","True").gsub("false","False")});" - - # strip off the .mako extension - output_filename = filename.chomp(File.extname(filename)) - - # just pipe from stdout into the new file, exiting on failure - File.open(output_filename, 'w') do |file| - file.write(`python -c '#{mako}'`) - exit_code = $?.to_i - abort "#{mako} failed with #{exit_code}" if exit_code.to_i != 0 - end -end - -def xmodule_cmd(watch=false, debug=false) - xmodule_cmd = 'xmodule_assets common/static/xmodule' - if watch - "watchmedo shell-command " + - "--patterns='*.js;*.coffee;*.sass;*.scss;*.css' " + - "--recursive " + - "--command='#{xmodule_cmd}' " + - "common/lib/xmodule" - else - xmodule_cmd - end -end - -def coffee_cmd(watch=false, debug=false) - if watch - # On OSx, coffee fails with EMFILE when - # trying to watch all of our coffee files at the same - # time. - # - # Ref: https://github.com/joyent/node/issues/2479 - # - # Instead, watch 50 files per process in parallel - cmds = [] - Dir['*/static/**/*.coffee'].each_slice(50) do |coffee_files| - cmds << "node_modules/.bin/coffee --watch --compile #{coffee_files.join(' ')}" - end - cmds - else - 'node_modules/.bin/coffee --compile */static' - end -end - -def sass_cmd(watch=false, debug=false) - sass_load_paths = ["./common/static/sass"] - sass_watch_paths = ["*/static"] - if USE_CUSTOM_THEME - sass_load_paths << THEME_SASS - sass_watch_paths << THEME_SASS - end - - "sass #{debug ? '--debug-info' : '--style compressed'} " + - "--load-path #{sass_load_paths.join(' ')} " + - "--require ./common/static/sass/bourbon/lib/bourbon.rb " + - "#{watch ? '--watch' : '--update'} #{sass_watch_paths.join(' ')}" -end - -desc "Compile all assets" -multitask :assets => 'assets:all' - -namespace :assets do - - desc "Compile all assets in debug mode" - multitask :debug - - desc "Preprocess all static assets that have the .mako extension" - task :preprocess do - # Run assets through the Mako templating engine. Right now we - # just hardcode the asset filenames. - preprocess_with_mako("lms/static/sass/application.scss.mako") - end - - desc "Watch all assets for changes and automatically recompile" - task :watch => 'assets:_watch' do - puts "Press ENTER to terminate".red - $stdin.gets - end - - {:xmodule => :install_python_prereqs, - :coffee => :install_node_prereqs, - :sass => [:install_ruby_prereqs, :preprocess]}.each_pair do |asset_type, prereq_tasks| - desc "Compile all #{asset_type} assets" - task asset_type => prereq_tasks do - cmd = send(asset_type.to_s + "_cmd", watch=false, debug=false) - if cmd.kind_of?(Array) - cmd.each {|c| sh(c)} - else - sh(cmd) - end - end - - multitask :all => asset_type - multitask :debug => "assets:#{asset_type}:debug" - multitask :_watch => "assets:#{asset_type}:_watch" - - namespace asset_type do - desc "Compile all #{asset_type} assets in debug mode" - task :debug => prereq_tasks do - cmd = send(asset_type.to_s + "_cmd", watch=false, debug=true) - sh(cmd) - end - - desc "Watch all #{asset_type} assets and compile on change" - task :watch => "assets:#{asset_type}:_watch" do - puts "Press ENTER to terminate".red - $stdin.gets - end - - task :_watch => prereq_tasks do - cmd = send(asset_type.to_s + "_cmd", watch=true, debug=true) - if cmd.kind_of?(Array) - cmd.each {|c| background_process(c)} - else - background_process(cmd) - end - end - end - end - - - multitask :sass => 'assets:xmodule' - namespace :sass do - # In watch mode, sass doesn't immediately compile out of date files, - # so force a recompile first - task :_watch => 'assets:sass:debug' - multitask :debug => 'assets:xmodule:debug' - end - - multitask :coffee => 'assets:xmodule' - namespace :coffee do - multitask :debug => 'assets:xmodule:debug' - end -end - -[:lms, :cms].each do |system| - # Per environment tasks - environments(system).each do |env| - desc "Compile coffeescript and sass, and then run collectstatic in the specified environment" - task "#{system}:gather_assets:#{env}" => :assets do - sh("#{django_admin(system, env, 'collectstatic', '--noinput')} > /dev/null") do |ok, status| - if !ok - abort "collectstatic failed!" - end - end - end - end -end diff --git a/rakefiles/jasmine.rake b/rakefiles/jasmine.rake deleted file mode 100644 index 4182bef9e2..0000000000 --- a/rakefiles/jasmine.rake +++ /dev/null @@ -1,119 +0,0 @@ -require 'colorize' -require 'erb' -require 'launchy' -require 'net/http' - - -def django_for_jasmine(system, django_reload) - if !django_reload - reload_arg = '--noreload' - end - - port = 10000 + rand(40000) - jasmine_url = "http://localhost:#{port}/_jasmine/" - - background_process(*django_admin(system, 'jasmine', 'runserver', '-v', '0', port.to_s, reload_arg).split(' ')) - - up = false - start_time = Time.now - until up do - if Time.now - start_time > 30 - abort "Timed out waiting for server to start to run jasmine tests" - end - begin - response = Net::HTTP.get_response(URI(jasmine_url)) - puts response.code - up = response.code == '200' - rescue => e - puts e.message - ensure - puts('Waiting server to start') - sleep(0.5) - end - end - yield jasmine_url -end - -def template_jasmine_runner(lib) - case lib - when /common\/lib\/.+/ - coffee_files = Dir["#{lib}/**/js/**/*.coffee", "common/static/coffee/src/**/*.coffee"] - when /common\/static\/coffee/ - coffee_files = Dir["#{lib}/**/*.coffee"] - else - puts('I do not know how to run jasmine tests for #{lib}') - exit - end - if !coffee_files.empty? - sh("node_modules/.bin/coffee -c #{coffee_files.join(' ')}") - end - phantom_jasmine_path = File.expand_path("node_modules/phantom-jasmine") - jasmine_reporters_path = File.expand_path("node_modules/jasmine-reporters") - common_js_root = File.expand_path("common/static/js") - common_coffee_root = File.expand_path("common/static/coffee/src") - - # Get arrays of spec and source files, ordered by how deep they are nested below the library - # (and then alphabetically) and expanded from a relative to an absolute path - spec_glob = File.join("#{lib}", "**", "spec", "**", "*.js") - src_glob = File.join("#{lib}", "**", "src", "**", "*.js") - js_specs = Dir[spec_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} - js_source = Dir[src_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} - - report_dir = report_dir_path("#{lib}/jasmine") - template = ERB.new(File.read("common/templates/jasmine/jasmine_test_runner.html.erb")) - template_output = "#{lib}/jasmine_test_runner.html" - File.open(template_output, 'w') do |f| - f.write(template.result(binding)) - end - yield File.expand_path(template_output) -end - -def run_phantom_js(url) - phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' - sh("#{phantomjs} node_modules/jasmine-reporters/test/phantomjs-testrunner.js #{url}") -end - -[:lms, :cms].each do |system| - desc "Open jasmine tests for #{system} in your default browser" - task "browse_jasmine_#{system}" => :assets do - django_for_jasmine(system, true) do |jasmine_url| - Launchy.open(jasmine_url) - puts "Press ENTER to terminate".red - $stdin.gets - end - end - - desc "Use phantomjs to run jasmine tests for #{system} from the console" - task "phantomjs_jasmine_#{system}" => :assets do - django_for_jasmine(system, false) do |jasmine_url| - run_phantom_js(jasmine_url) - end - end -end - -STATIC_JASMINE_TESTS = Dir["common/lib/*"].select{|lib| File.directory?(lib)} -STATIC_JASMINE_TESTS << 'common/static/coffee' - -STATIC_JASMINE_TESTS.each do |lib| - desc "Open jasmine tests for #{lib} in your default browser" - task "browse_jasmine_#{lib}" do - template_jasmine_runner(lib) do |f| - sh("python -m webbrowser -t 'file://#{f}'") - puts "Press ENTER to terminate".red - $stdin.gets - end - end - - desc "Use phantomjs to run jasmine tests for #{lib} from the console" - task "phantomjs_jasmine_#{lib}" do - template_jasmine_runner(lib) do |f| - run_phantom_js(f) - end - end -end - -desc "Open jasmine tests for discussion in your default browser" -task "browse_jasmine_discussion" => "browse_jasmine_common/static/coffee" - -desc "Use phantomjs to run jasmine tests for discussion from the console" -task "phantomjs_jasmine_discussion" => "phantomjs_jasmine_common/static/coffee" diff --git a/rakefiles/tests.rake b/rakefiles/tests.rake deleted file mode 100644 index 448a482f04..0000000000 --- a/rakefiles/tests.rake +++ /dev/null @@ -1,148 +0,0 @@ - -# Set up the clean and clobber tasks -CLOBBER.include(REPORT_DIR, 'test_root/*_repo', 'test_root/staticfiles') - -$failed_tests = 0 - -def run_under_coverage(cmd, root) - cmd0, cmd_rest = cmd.split(" ", 2) - # We use "python -m coverage" so that the proper python will run the importable coverage - # rather than the coverage that OS path finds. - cmd = "python -m coverage run --rcfile=#{root}/.coveragerc `which #{cmd0}` #{cmd_rest}" - return cmd -end - -def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) - ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") - 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', test_id) - sh(run_under_coverage(cmd, system)) do |ok, res| - if !ok and stop_on_failure - abort "Test failed!" - end - $failed_tests += 1 unless ok - end -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')) - end - sh(django_admin(system, 'acceptance', 'syncdb', '--noinput')) - sh(django_admin(system, 'acceptance', 'migrate', '--noinput')) - sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--tag -skip', harvest_args)) -end - - -directory REPORT_DIR - -task :clean_test_files do - sh("git clean -fqdx test_root") -end - -TEST_TASK_DIRS = [] - -[:lms, :cms].each do |system| - report_dir = report_dir_path(system) - - # Per System tasks - desc "Run all django tests on our djangoapps for the #{system}" - task "test_#{system}", [:test_id, :stop_on_failure] => ["clean_test_files", :predjango, "#{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, :stop_on_failure] => [report_dir, :install_prereqs, :predjango] do |t, args| - args.with_defaults(:stop_on_failure => 'true', :test_id => nil) - run_tests(system, report_dir, args.test_id, args.stop_on_failure) - end - - # Run acceptance tests - desc "Run acceptance tests" - task "test_acceptance_#{system}", [:harvest_args] => ["#{system}:gather_assets:acceptance", "fasttest_acceptance_#{system}"] - - desc "Run acceptance tests without collectstatic" - task "fasttest_acceptance_#{system}", [:harvest_args] => ["clean_test_files", :predjango, report_dir] 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 -end - -Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| - task_name = "test_#{lib}" - - report_dir = report_dir_path(lib) - - desc "Run tests for common lib #{lib}" - task task_name => report_dir do - ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") - cmd = "nosetests #{lib}" - sh(run_under_coverage(cmd, lib)) do |ok, res| - $failed_tests += 1 unless ok - end - end - TEST_TASK_DIRS << lib - - desc "Run tests for common lib #{lib} (without coverage)" - task "fasttest_#{lib}" do - sh("nosetests #{lib}") - end -end - -task :report_dirs - -TEST_TASK_DIRS.each do |dir| - report_dir = report_dir_path(dir) - directory report_dir - task :report_dirs => [REPORT_DIR, report_dir] -end - -task :test do - TEST_TASK_DIRS.each do |dir| - Rake::Task["test_#{dir}"].invoke(nil, false) - end - - if $failed_tests > 0 - abort "Tests failed!" - end -end - -namespace :coverage do - desc "Build the html coverage reports" - task :html => :report_dirs do - TEST_TASK_DIRS.each do |dir| - report_dir = report_dir_path(dir) - - if !File.file?("#{report_dir}/.coverage") - next - end - - sh("coverage html --rcfile=#{dir}/.coveragerc") - end - end - - desc "Build the xml coverage reports" - task :xml => :report_dirs do - TEST_TASK_DIRS.each do |dir| - report_dir = report_dir_path(dir) - - if !File.file?("#{report_dir}/.coverage") - next - end - # Why doesn't the rcfile control the xml output file properly?? - sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc") - end - end -end diff --git a/rakelib/assets.rake b/rakelib/assets.rake new file mode 100644 index 0000000000..5c8abc1fb0 --- /dev/null +++ b/rakelib/assets.rake @@ -0,0 +1,159 @@ +# Theming constants +THEME_NAME = ENV_TOKENS['THEME_NAME'] +USE_CUSTOM_THEME = !(THEME_NAME.nil? || THEME_NAME.empty?) +if USE_CUSTOM_THEME + THEME_ROOT = File.join(ENV_ROOT, "themes", THEME_NAME) + THEME_SASS = File.join(THEME_ROOT, "static", "sass") +end + +MINIMAL_DARWIN_NOFILE_LIMIT = 8000 + +def xmodule_cmd(watch=false, debug=false) + xmodule_cmd = 'xmodule_assets common/static/xmodule' + if watch + "watchmedo shell-command " + + "--patterns='*.js;*.coffee;*.sass;*.scss;*.css' " + + "--recursive " + + "--command='#{xmodule_cmd}' " + + "--wait " + + "common/lib/xmodule" + else + xmodule_cmd + end +end + +def coffee_cmd(watch=false, debug=false) + if watch && Launchy::Application.new.host_os_family.darwin? + available_files = Process::getrlimit(:NOFILE)[0] + if available_files < MINIMAL_DARWIN_NOFILE_LIMIT + Process.setrlimit(:NOFILE, MINIMAL_DARWIN_NOFILE_LIMIT) + + end + end + "node_modules/.bin/coffee --compile #{watch ? '--watch' : ''} ." +end + +def sass_cmd(watch=false, debug=false) + sass_load_paths = ["./common/static/sass"] + sass_watch_paths = ["*/static"] + if USE_CUSTOM_THEME + sass_load_paths << THEME_SASS + sass_watch_paths << THEME_SASS + end + + "sass #{debug ? '--debug-info' : '--style compressed'} " + + "--load-path #{sass_load_paths.join(' ')} " + + "--require ./common/static/sass/bourbon/lib/bourbon.rb " + + "#{watch ? '--watch' : '--update'} -E utf-8 #{sass_watch_paths.join(' ')}" +end + +# This task takes arguments purely to pass them via dependencies to the preprocess task +desc "Compile all assets" +task :assets, [:system, :env] => 'assets:all' + +namespace :assets do + + desc "Compile all assets in debug mode" + multitask :debug + + desc "Preprocess all templatized static asset files" + task :preprocess, [:system, :env] do |t, args| + args.with_defaults(:system => "lms", :env => "dev") + sh(django_admin(args.system, args.env, "preprocess_assets")) do |ok, status| + abort "asset preprocessing failed!" if !ok + end + end + + desc "Watch all assets for changes and automatically recompile" + task :watch => 'assets:_watch' do + puts "Press ENTER to terminate".red + $stdin.gets + end + + {:xmodule => [:install_python_prereqs], + :coffee => [:install_node_prereqs, :'assets:coffee:clobber'], + :sass => [:install_ruby_prereqs, :preprocess]}.each_pair do |asset_type, prereq_tasks| + # This task takes arguments purely to pass them via dependencies to the preprocess task + desc "Compile all #{asset_type} assets" + task asset_type, [:system, :env] => prereq_tasks do |t, args| + cmd = send(asset_type.to_s + "_cmd", watch=false, debug=false) + if cmd.kind_of?(Array) + cmd.each {|c| sh(c)} + else + sh(cmd) + end + end + + # This task takes arguments purely to pass them via dependencies to the preprocess task + multitask :all, [:system, :env] => asset_type + multitask :debug => "assets:#{asset_type}:debug" + multitask :_watch => "assets:#{asset_type}:_watch" + + namespace asset_type do + desc "Compile all #{asset_type} assets in debug mode" + task :debug => prereq_tasks do + cmd = send(asset_type.to_s + "_cmd", watch=false, debug=true) + sh(cmd) + end + + desc "Watch all #{asset_type} assets and compile on change" + task :watch => "assets:#{asset_type}:_watch" do + puts "Press ENTER to terminate".red + $stdin.gets + end + + # Fully compile before watching for changes + task :_watch => (prereq_tasks + ["assets:#{asset_type}:debug"]) do + cmd = send(asset_type.to_s + "_cmd", watch=true, debug=true) + if cmd.kind_of?(Array) + cmd.each {|c| singleton_process(c)} + else + singleton_process(cmd) + end + end + end + end + + multitask :sass => 'assets:xmodule' + namespace :sass do + multitask :debug => 'assets:xmodule:debug' + end + + multitask :coffee => 'assets:xmodule' + namespace :coffee do + multitask :debug => 'assets:xmodule:debug' + + desc "Remove compiled coffeescript files" + task :clobber do + FileList['*/static/coffee/**/*.js'].each {|f| File.delete(f)} + end + end + + namespace :xmodule do + # Only start the xmodule watcher after the coffee and sass watchers have already started + task :_watch => ['assets:coffee:_watch', 'assets:sass:_watch'] + end +end + +# This task does the real heavy lifting to gather all of the static +# assets. We want people to call it via the wrapper below, so we +# don't provide a description so that it won't show up in rake -T. +task :gather_assets, [:system, :env] => :assets do |t, args| + sh("#{django_admin(args.system, args.env, 'collectstatic', '--noinput')} > /dev/null") do |ok, status| + if !ok + abort "collectstatic failed!" + end + end +end + +[:lms, :cms].each do |system| + # Per environment tasks + environments(system).each do |env| + # This task wraps the one above, since we need the system and + # env arguments to be passed to all dependent tasks. + desc "Compile coffeescript and sass, and then run collectstatic in the specified environment" + task "#{system}:gather_assets:#{env}" do + Rake::Task[:gather_assets].invoke(system, env) + end + end +end diff --git a/rakefiles/deploy.rake b/rakelib/deploy.rake similarity index 100% rename from rakefiles/deploy.rake rename to rakelib/deploy.rake diff --git a/rakelib/deprecated.rake b/rakelib/deprecated.rake new file mode 100644 index 0000000000..00c1987bd5 --- /dev/null +++ b/rakelib/deprecated.rake @@ -0,0 +1,23 @@ + +require 'colorize' + +def deprecated(deprecated, deprecated_by) + task deprecated do + puts("Task #{deprecated} has been deprecated. Use #{deprecated_by} instead. Waiting 5 seconds...".red) + sleep(5) + Rake::Task[deprecated_by].invoke + end +end + +[:lms, :cms].each do |system| + deprecated("browse_jasmine_#{system}", "jasmine:#{system}:browser") + deprecated("phantomjs_jasmine_#{system}", "jasmine:#{system}:phantomjs") +end + +Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| + deprecated("browse_jasmine_#{lib}", "jasmine:#{lib}:browser") + deprecated("phantomjs_jasmine_#{lib}", "jasmine:#{lib}:phantomjs") +end + +deprecated("browse_jasmine_discussion", "jasmine:common/static/coffee:browser") +deprecated("phantomjs_jasmine_discussion", "jasmine:common/static/coffee:phantomjs") \ No newline at end of file diff --git a/rakefiles/django.rake b/rakelib/django.rake similarity index 90% rename from rakefiles/django.rake rename to rakelib/django.rake index 8b42192130..b1adf24050 100644 --- a/rakefiles/django.rake +++ b/rakelib/django.rake @@ -15,14 +15,22 @@ task :fastlms do sh("#{django_admin} runserver --traceback --settings=lms.envs.dev --pythonpath=.") end +# Start :system locally with the specified :env and :options. +# +# This task should be invoked via the wrapper below, so we don't +# include a description to keep it from showing up in rake -T. +task :runserver, [:system, :env, :options] => [:install_prereqs, 'assets:_watch', :predjango] do |t, args| + sh(django_admin(args.system, args.env, 'runserver', args.options)) +end + [:lms, :cms].each do |system| desc <<-desc Start the #{system} locally with the specified environment (defaults to dev). Other useful environments are devplus (for dev testing with a real local database) desc - task system, [:env, :options] => [:install_prereqs, 'assets:_watch', :predjango] do |t, args| + task system, [:env, :options] do |t, args| args.with_defaults(:env => 'dev', :options => default_options[system]) - sh(django_admin(system, args.env, 'runserver', args.options)) + Rake::Task[:runserver].invoke(system, args.env, args.options) end desc "Start #{system} Celery worker" diff --git a/rakefiles/docs.rake b/rakelib/docs.rake similarity index 89% rename from rakefiles/docs.rake rename to rakelib/docs.rake index f10fc80d59..2247b686fa 100644 --- a/rakefiles/docs.rake +++ b/rakelib/docs.rake @@ -22,9 +22,7 @@ task :showdocs, [:options] do |t, args| path = "docs" end - Dir.chdir("#{path}/build/html") do - Launchy.open('index.html') - end + Launchy.open("#{path}/build/html/index.html") end desc "Build docs and show them in browser" diff --git a/rakefiles/helpers.rb b/rakelib/helpers.rb similarity index 69% rename from rakefiles/helpers.rb rename to rakelib/helpers.rb index f344aa2042..3373214a19 100644 --- a/rakefiles/helpers.rb +++ b/rakelib/helpers.rb @@ -1,8 +1,14 @@ require 'digest/md5' +require 'sys/proctable' +require 'colorize' +def find_executable(exec) + path = %x(which #{exec}).strip + $?.exitstatus == 0 ? path : nil +end def select_executable(*cmds) - cmds.find_all{ |cmd| system("which #{cmd} > /dev/null 2>&1") }[0] || fail("No executables found from #{cmds.join(', ')}") + cmds.find_all{ |cmd| !find_executable(cmd).nil? }[0] || fail("No executables found from #{cmds.join(', ')}") end def django_admin(system, env, command, *args) @@ -80,8 +86,46 @@ def background_process(*command) end end +# Runs a command as a background process, as long as no other processes +# tagged with the same tag are running +def singleton_process(*command) + if Sys::ProcTable.ps.select {|proc| proc.cmdline.include?(command.join(' '))}.empty? + background_process(*command) + else + puts "Process '#{command.join(' ')} already running, skipping".blue + end +end + def environments(system) Dir["#{system}/envs/**/*.py"].select{|file| ! (/__init__.py$/ =~ file)}.map do |env_file| env_file.gsub("#{system}/envs/", '').gsub(/\.py/, '').gsub('/', '.') end end + +$failed_tests = 0 + +# Run sh on args. If TESTS_FAIL_FAST is set, then stop on the first shell failure. +# Otherwise, a final task will be added that will fail if any tests have failed +def test_sh(*args) + sh(*args) do |ok, res| + if ok + return + end + + if ENV['TESTS_FAIL_FAST'] + fail("Test failed!") + else + $failed_tests += 1 + end + end +end + +# Add a task after all other tasks that fails if any tests have failed +if !ENV['TESTS_FAIL_FAST'] + task :fail_tests do + fail("#{$failed_tests} tests failed!") if $failed_tests > 0 + end + + Rake.application.top_level_tasks << :fail_tests +end + diff --git a/rakefiles/i18n.rake b/rakelib/i18n.rake similarity index 100% rename from rakefiles/i18n.rake rename to rakelib/i18n.rake diff --git a/rakelib/jasmine.rake b/rakelib/jasmine.rake new file mode 100644 index 0000000000..0f532fdf6f --- /dev/null +++ b/rakelib/jasmine.rake @@ -0,0 +1,159 @@ +require 'colorize' +require 'erb' +require 'launchy' +require 'net/http' + +PHANTOMJS_PATH = find_executable(ENV['PHANTOMJS_PATH'] || 'phantomjs') +PREFERRED_METHOD = PHANTOMJS_PATH.nil? ? 'browser' : 'phantomjs' +if PHANTOMJS_PATH.nil? + puts("phantomjs not found on path. Set $PHANTOMJS_PATH. Using browser for jasmine tests".blue) +end + +def django_for_jasmine(system, django_reload) + if !django_reload + reload_arg = '--noreload' + end + + port = 10000 + rand(40000) + jasmine_url = "http://localhost:#{port}/_jasmine/" + + background_process(*django_admin(system, 'jasmine', 'runserver', '-v', '0', port.to_s, reload_arg).split(' ')) + + up = false + start_time = Time.now + until up do + if Time.now - start_time > 30 + abort "Timed out waiting for server to start to run jasmine tests" + end + begin + response = Net::HTTP.get_response(URI(jasmine_url)) + puts response.code + up = response.code == '200' + rescue => e + puts e.message + ensure + puts('Waiting server to start') + sleep(0.5) + end + end + yield jasmine_url +end + +def template_jasmine_runner(lib) + phantom_jasmine_path = File.expand_path("node_modules/phantom-jasmine") + jasmine_reporters_path = File.expand_path("node_modules/jasmine-reporters") + common_js_root = File.expand_path("common/static/js") + common_coffee_root = File.expand_path("common/static/coffee/src") + + # Get arrays of spec and source files, ordered by how deep they are nested below the library + # (and then alphabetically) and expanded from a relative to an absolute path + spec_glob = File.join(lib, "**", "spec", "**", "*.js") + src_glob = File.join(lib, "**", "src", "**", "*.js") + js_specs = Dir[spec_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} + js_source = Dir[src_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} + + report_dir = report_dir_path("#{lib}/jasmine") + template = ERB.new(File.read("common/templates/jasmine/jasmine_test_runner.html.erb")) + template_output = "#{lib}/jasmine_test_runner.html" + File.open(template_output, 'w') do |f| + f.write(template.result(binding)) + end + yield File.expand_path(template_output) +end + +def jasmine_browser(url, jitter=3, wait=10) + # Jitter starting the browser so that the tests don't all try and + # start the browser simultaneously + sleep(rand(jitter)) + sh("python -m webbrowser -t '#{url}'") + sleep(wait) +end + +def jasmine_phantomjs(url) + fail("phantomjs not found. Add it to your path, or set $PHANTOMJS_PATH") if PHANTOMJS_PATH.nil? + test_sh("#{PHANTOMJS_PATH} node_modules/jasmine-reporters/test/phantomjs-testrunner.js #{url}") +end + +# Wrapper tasks for the real browse_jasmine and phantomjs_jasmine +# tasks above. These have a nicer UI since there's no arg passing. +[:lms, :cms].each do |system| + namespace :jasmine do + namespace system do + desc "Open jasmine tests for #{system} in your default browser" + task :browser do + Rake::Task[:assets].invoke(system, 'jasmine') + django_for_jasmine(system, true) do |jasmine_url| + jasmine_browser(jasmine_url) + end + end + + desc "Open jasmine tests for #{system} in your default browser, and dynamically recompile coffeescript" + task :'browser:watch' => :'assets:coffee:_watch' do + django_for_jasmine(system, true) do |jasmine_url| + jasmine_browser(jasmine_url, jitter=0, wait=0) + end + puts "Press ENTER to terminate".red + $stdin.gets + end + + desc "Use phantomjs to run jasmine tests for #{system} from the console" + task :phantomjs do + Rake::Task[:assets].invoke(system, 'jasmine') + phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' + django_for_jasmine(system, false) do |jasmine_url| + jasmine_phantomjs(jasmine_url) + end + end + end + + desc "Run jasmine tests for #{system} using #{PREFERRED_METHOD}" + task system => "jasmine:#{system}:#{PREFERRED_METHOD}" + + task :phantomjs => "jasmine:#{system}:phantomjs" + multitask :browser => "jasmine:#{system}:browser" + end +end + +static_js_dirs = Dir["common/lib/*"].select{|lib| File.directory?(lib)} +static_js_dirs << 'common/static/coffee' +static_js_dirs.select!{|lib| !Dir["#{lib}/**/spec"].empty?} + +static_js_dirs.each do |dir| + namespace :jasmine do + namespace dir do + desc "Open jasmine tests for #{dir} in your default browser" + task :browser do + # We need to use either CMS or LMS to preprocess files. Use LMS by default + Rake::Task['assets:coffee'].invoke('lms', 'jasmine') + template_jasmine_runner(dir) do |f| + jasmine_browser("file://#{f}") + end + end + + desc "Use phantomjs to run jasmine tests for #{dir} from the console" + task :phantomjs do + # We need to use either CMS or LMS to preprocess files. Use LMS by default + Rake::Task[:assets].invoke('lms', 'jasmine') + template_jasmine_runner(dir) do |f| + jasmine_phantomjs(f) + end + end + end + + desc "Run jasmine tests for #{dir} using #{PREFERRED_METHOD}" + task dir => "jasmine:#{dir}:#{PREFERRED_METHOD}" + + task :phantomjs => "jasmine:#{dir}:phantomjs" + multitask :browser => "jasmine:#{dir}:browser" + end +end + +desc "Run all jasmine tests using #{PREFERRED_METHOD}" +task :jasmine => "jasmine:#{PREFERRED_METHOD}" + +['phantomjs', 'browser'].each do |method| + desc "Run all jasmine tests using #{method}" + task "jasmine:#{method}" +end + +task :test => :jasmine diff --git a/rakefiles/prereqs.rake b/rakelib/prereqs.rake similarity index 98% rename from rakefiles/prereqs.rake rename to rakelib/prereqs.rake index ff8b4b8784..e06d411435 100644 --- a/rakefiles/prereqs.rake +++ b/rakelib/prereqs.rake @@ -1,5 +1,3 @@ -require './rakefiles/helpers.rb' - PREREQS_MD5_DIR = ENV["PREREQ_CACHE_DIR"] || File.join(REPO_ROOT, '.prereqs_cache') CLOBBER.include(PREREQS_MD5_DIR) diff --git a/rakefiles/quality.rake b/rakelib/quality.rake similarity index 100% rename from rakefiles/quality.rake rename to rakelib/quality.rake diff --git a/rakelib/tests.rake b/rakelib/tests.rake new file mode 100644 index 0000000000..3cb5e8f4e5 --- /dev/null +++ b/rakelib/tests.rake @@ -0,0 +1,161 @@ +# Set up the clean and clobber tasks +CLOBBER.include(REPORT_DIR, 'test_root/*_repo', 'test_root/staticfiles') + +# Create the directory to hold coverage reports, if it doesn't already exist. +directory REPORT_DIR + +def run_under_coverage(cmd, root) + cmd0, cmd_rest = cmd.split(" ", 2) + # We use "python -m coverage" so that the proper python will run the importable coverage + # rather than the coverage that OS path finds. + cmd = "python -m coverage run --rcfile=#{root}/.coveragerc `which #{cmd0}` #{cmd_rest}" + return cmd +end + +def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) + ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") + 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', 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')) + 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)) +end + +# Run documentation tests +desc "Run documentation tests" +task :test_docs do + # Be sure that sphinx can build docs w/o exceptions. + test_message = "If test fails, you shoud run %s and look at whole output and fix exceptions. +(You shouldn't fix rst warnings and errors for this to pass, just get rid of exceptions.)" + puts (test_message % ["rake doc"]).colorize( :light_green ) + test_sh('rake builddocs') + puts (test_message % ["rake doc[pub]"]).colorize( :light_green ) + test_sh('rake builddocs[pub]') +end + +task :clean_test_files do + desc "Clean fixture files used by tests" + sh("git clean -fqdx test_root") +end + +task :clean_reports_dir do + desc "Clean coverage files, to ensure that we don't use stale data to generate reports." + + # We delete the files but preserve the directory structure + # so that coverage.py has a place to put the reports. + sh("find #{REPORT_DIR} -type f -delete") +end + + +TEST_TASK_DIRS = [] + +[:lms, :cms].each do |system| + report_dir = report_dir_path(system) + + # 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}"] + + # Have a way to run the tests without running collectstatic -- useful when debugging without + # messing with static files. + task "fasttest_#{system}", [:test_id] => [report_dir, :clean_reports_dir, :install_prereqs, :predjango] 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" + task "test_acceptance_#{system}", [:harvest_args] => [:clean_test_files, "#{system}:gather_assets:acceptance", "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 +end + +Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| + + report_dir = report_dir_path(lib) + + desc "Run tests for common lib #{lib}" + task "test_#{lib}" => [report_dir, :clean_reports_dir] do + ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") + cmd = "nosetests #{lib}" + test_sh(run_under_coverage(cmd, lib)) + end + TEST_TASK_DIRS << lib + + # There used to be a fasttest_#{lib} command that ran without coverage. + # However, this is an inconsistent usage of "fast": + # When running tests for lms and cms, "fast" means skipping + # staticfiles collection, but still running under coverage. + # We keep the fasttest_#{lib} command for backwards compatibility, + # but make it an alias to the normal test command. + task "fasttest_#{lib}" => "test_#{lib}" +end + +task :report_dirs + +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}" +end + +desc "Run all tests" +task :test => :test_docs + +desc "Build the html, xml, and diff coverage reports" +task :coverage => :report_dirs do + + found_coverage_info = false + + TEST_TASK_DIRS.each do |dir| + report_dir = report_dir_path(dir) + + if !File.file?("#{report_dir}/.coverage") + next + else + found_coverage_info = true + end + + # Generate the coverage.py HTML report + sh("coverage html --rcfile=#{dir}/.coveragerc") + + # Generate the coverage.py XML report + sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc") + + # Generate the diff coverage HTML report, based on the XML report + sh("diff-cover #{report_dir}/coverage.xml --html-report #{report_dir}/diff_cover.html") + + # Print the diff coverage report to the console + sh("diff-cover #{report_dir}/coverage.xml") + puts "\n" + end + + if not found_coverage_info + puts "No coverage info found. Run `rake test` before running `rake coverage`." + end +end diff --git a/rakefiles/workspace.rake b/rakelib/workspace.rake similarity index 100% rename from rakefiles/workspace.rake rename to rakelib/workspace.rake diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index d801f46c8e..d5f05083c8 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -1 +1,3 @@ numpy==1.6.2 +networkx==1.7 +sympy==0.7.1 \ No newline at end of file diff --git a/requirements/edx-sandbox/local.txt b/requirements/edx-sandbox/local.txt index ba24805057..c21a50338a 100644 --- a/requirements/edx-sandbox/local.txt +++ b/requirements/edx-sandbox/local.txt @@ -4,3 +4,4 @@ common/lib/calc common/lib/chem common/lib/sandbox-packages +common/lib/symmath diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 61e510e1a8..0db55bacb2 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -4,7 +4,7 @@ beautifulsoup4==4.1.3 beautifulsoup==3.2.1 boto==2.6.0 celery==3.0.19 -distribute==0.6.28 +distribute>=0.6.28 django-celery==3.0.17 django-countries==1.5 django-followit==0.0.3 @@ -32,7 +32,7 @@ nltk==2.0.4 paramiko==1.9.0 path.py==3.0.1 Pillow==1.7.8 -pip +pip>=1.3 polib==1.0.3 pygments==1.5 pygraphviz==1.1 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index b1aef0a108..5ce748e7b5 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -3,10 +3,11 @@ # Third-party: -e git://github.com/edx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles -e git://github.com/edx/django-pipeline.git#egg=django-pipeline --e git://github.com/edx/django-wiki.git@e2e84558#egg=django-wiki +-e git://github.com/edx/django-wiki.git@ac906abe#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@2144a25d#egg=XBlock --e git+https://github.com/edx/codejail.git@5fb5fa0#egg=codejail +-e git+https://github.com/edx/XBlock.git@4d8735e883#egg=XBlock +-e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail +-e git+https://github.com/edx/diff-cover.git@v0.1.2#egg=diff_cover diff --git a/requirements/edx/local.txt b/requirements/edx/local.txt index a72f1f6dea..f5ba60e21b 100644 --- a/requirements/edx/local.txt +++ b/requirements/edx/local.txt @@ -2,5 +2,6 @@ -e common/lib/calc -e common/lib/capa -e common/lib/chem +-e common/lib/symmath -e common/lib/xmodule -e . diff --git a/requirements/system/ubuntu/apt-packages.txt b/requirements/system/ubuntu/apt-packages.txt index 2635388757..5dc47157f6 100644 --- a/requirements/system/ubuntu/apt-packages.txt +++ b/requirements/system/ubuntu/apt-packages.txt @@ -1,12 +1,18 @@ python-software-properties pkg-config +gfortran +libatlas-dev +libblas-dev +liblapack-dev +liblapack3gf curl git python-virtualenv +python-scipy +python-numpy build-essential python-dev gfortran -liblapack-dev libfreetype6-dev libpng12-dev libjpeg-dev @@ -14,6 +20,7 @@ libxml2-dev libxslt-dev yui-compressor graphviz +libgraphviz-dev graphviz-dev mysql-server libmysqlclient-dev @@ -23,3 +30,6 @@ libreadline6-dev mongodb nodejs coffeescript +mysql-client +virtualenvwrapper +libgeos-ruby1.8 diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh index d387465c49..edb0bcdcae 100755 --- a/scripts/create-dev-env.sh +++ b/scripts/create-dev-env.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash + +#Exit if any commands return a non-zero status set -e # posix compliant sanity check @@ -27,10 +29,17 @@ EOL } +#Setting error color to red before reset error() { printf '\E[31m'; echo "$@"; printf '\E[0m' } +#Setting warning color to magenta before reset +warning() { + printf '\E[35m'; echo "$@"; printf '\E[0m' +} + +#Setting output color to cyan before reset output() { printf '\E[36m'; echo "$@"; printf '\E[0m' } @@ -51,7 +60,7 @@ EO info() { cat<1.7 is # --no-site-packages - mkvirtualenv -a "$BASE/mitx" mitx || { + mkvirtualenv -a "$HOME/.virtualenvs" edx-platform || { error "mkvirtualenv exited with a non-zero error" return 1 } @@ -380,10 +458,30 @@ if [[ -n $compile ]]; then rm -rf numpy-${NUMPY_VER} scipy-${SCIPY_VER} fi +# building correct version of distribute from source +DISTRIBUTE_VER="0.6.28" +output "Building Distribute" +SITE_PACKAGES="$HOME/.virtualenvs/edx-platform/lib/python2.7/site-packages" +cd "$SITE_PACKAGES" +curl -O http://pypi.python.org/packages/source/d/distribute/distribute-${DISTRIBUTE_VER}.tar.gz +tar -xzvf distribute-${DISTRIBUTE_VER}.tar.gz +cd distribute-${DISTRIBUTE_VER} +python setup.py install +cd .. +rm distribute-${DISTRIBUTE_VER}.tar.gz + +DISTRIBUTE_VERSION=`pip freeze | grep distribute` + +if [[ "$DISTRIBUTE_VERSION" == "distribute==0.6.28" ]]; then + output "Distribute successfully installed" +else + error "Distribute failed to build correctly. This script requires a working version of Distribute 0.6.28 in your virtualenv's python installation" + exit 1 +fi + case `uname -s` in Darwin) # on mac os x get the latest distribute and pip - curl http://python-distribute.org/distribute_setup.py | python pip install -U pip # need latest pytz before compiling numpy and scipy pip install -U pytz @@ -395,18 +493,30 @@ case `uname -s` in ;; esac -output "Installing MITx pre-requirements" -pip install -r $BASE/mitx/pre-requirements.txt +output "Installing edX pre-requirements" +pip install -r $BASE/edx-platform/requirements/edx/pre.txt -output "Installing MITx requirements" -# Need to be in the mitx dir to get the paths to local modules right -cd $BASE/mitx -pip install -r requirements.txt +output "Installing edX requirements" +# Install prereqs +cd $BASE/edx-platform +rvm use "$RUBY_VER@edx-platform" +rake install_prereqs -mkdir "$BASE/log" || true -mkdir "$BASE/db" || true +# Final dependecy +output "Finishing Touches" +cd $BASE +pip install argcomplete +cd $BASE/edx-platform +bundle install +rake install_prereqs +mkdir -p "$BASE/log" +mkdir -p "$BASE/db" +mkdir -p "$BASE/data" +rake django-admin[syncdb] +rake django-admin[migrate] +rake cms:update_templates # Configure Git output "Fixing your git default settings" diff --git a/scripts/install-system-req.sh b/scripts/install-system-req.sh index 37bc6d1716..43e405524d 100755 --- a/scripts/install-system-req.sh +++ b/scripts/install-system-req.sh @@ -16,10 +16,11 @@ output() { ### START -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -BREW_FILE=$DIR/"brew-formulas.txt" -APT_REPOS_FILE=$DIR/"apt-repos.txt" -APT_PKGS_FILE=$DIR/"apt-packages.txt" +SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REQUIREMENTS_DIR="$SELF_DIR/../requirements/system" +BREW_FILE=$REQUIREMENTS_DIR/"mac_os_x/brew-formulas.txt" +APT_REPOS_FILE=$REQUIREMENTS_DIR/"ubuntu/apt-repos.txt" +APT_PKGS_FILE=$REQUIREMENTS_DIR/"ubuntu/apt-packages.txt" case `uname -s` in [Ll]inux) @@ -30,8 +31,9 @@ case `uname -s` in distro=`lsb_release -cs` case $distro in - maya|lisa|natty|oneiric|precise|quantal) - output "Installing Ubuntu requirements" + #Tries to install the same + squeeze|wheezy|jessie|maya|lisa|olivia|nadia|natty|oneiric|precise|quantal|raring) + output "Installing Debian family requirements" # DEBIAN_FRONTEND=noninteractive is required for silent mysql-server installation export DEBIAN_FRONTEND=noninteractive @@ -39,7 +41,10 @@ case `uname -s` in # add repositories cat $APT_REPOS_FILE | xargs -n 1 sudo add-apt-repository -y sudo apt-get -y update - + sudo apt-get -y install gfortran + sudo apt-get -y install graphviz libgraphviz-dev graphviz-dev + sudo apt-get -y install libatlas-dev libblas-dev + sudo apt-get -y install ruby-rvm # install packages listed in APT_PKGS_FILE cat $APT_PKGS_FILE | xargs sudo apt-get -y install ;; @@ -70,10 +75,13 @@ EO output "Installing OSX requirements" if [[ ! -r $BREW_FILE ]]; then - error "$BREW_FILE does not exist, needed to install brew" + error "$BREW_FILE does not exist, please include the brew formulas file in the requirements/system/mac_os_x directory" exit 1 fi + # for some reason openssl likes to be installed by itself first + brew install openssl + # brew errors if the package is already installed for pkg in $(cat $BREW_FILE); do grep $pkg <(brew list) &>/dev/null || { diff --git a/scripts/release-email-list.sh b/scripts/release-email-list.sh new file mode 100755 index 0000000000..92f7a9aef4 --- /dev/null +++ b/scripts/release-email-list.sh @@ -0,0 +1,31 @@ +#! /bin/bash + +LOG_SPEC="$1..$2" +LOG_CMD="git --no-pager log $LOG_SPEC" + +RESPONSIBLE=$(sort -u <($LOG_CMD --format='tformat:%ae' && $LOG_CMD --format='tformat:%ce')) + +echo -n 'To: ' +echo ${RESPONSIBLE} | sed "s/ /, /g" +echo + +echo "You've made changes that are about to be released. All of the commits +that you either authored or committed are listed below. Please verify them on +\$ENVIRONMENT" +echo + +for EMAIL in $RESPONSIBLE; do + AUTHORED_BY="$LOG_CMD --author=<${EMAIL}>" + COMMITTED_BY="$LOG_CMD --committer=<${EMAIL}>" + COMMITTED_NOT_AUTHORED="$COMMITTED_BY $($AUTHORED_BY --format='tformat:^%h')" + + echo $EMAIL "authored the following commits:" + $AUTHORED_BY --format='tformat: %s - https://github.com/edx/edx-platform/commit/%h' + echo + + if [[ $($COMMITTED_NOT_AUTHORED) != "" ]]; then + echo $EMAIL "committed but didn't author the following commits:" + $COMMITTED_NOT_AUTHORED --format='tformat: %s - https://github.com/edx/edx-platform/commit/%h' + echo + fi +done \ No newline at end of file diff --git a/test_root/data/videoalpha/gizmo.mp4 b/test_root/data/videoalpha/gizmo.mp4 new file mode 100644 index 0000000000..1fc478842f Binary files /dev/null and b/test_root/data/videoalpha/gizmo.mp4 differ diff --git a/test_root/data/videoalpha/gizmo.ogv b/test_root/data/videoalpha/gizmo.ogv new file mode 100644 index 0000000000..2c4a447f1f Binary files /dev/null and b/test_root/data/videoalpha/gizmo.ogv differ diff --git a/test_root/data/videoalpha/gizmo.webm b/test_root/data/videoalpha/gizmo.webm new file mode 100644 index 0000000000..95d5031a86 Binary files /dev/null and b/test_root/data/videoalpha/gizmo.webm differ