diff --git a/AUTHORS b/AUTHORS index 7d6397629f..9bb4ede121 100644 --- a/AUTHORS +++ b/AUTHORS @@ -75,4 +75,6 @@ Frances Botsford Jonah Stanley Slater Victoroff Peter Fogg -Renzo Lucioni \ No newline at end of file +Bethany LaPenta +Renzo Lucioni +Felix Sun diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 03a3b8c809..0e161e4f72 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,16 +5,60 @@ 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 @@ -39,6 +83,9 @@ 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 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/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.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 049103db27..2360baea5a 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,12 +2,8 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_false, assert_equal, assert_regexp_matches - -""" -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' @@ -32,18 +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 :) - """ - world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click() - g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") - g._element.send_keys(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$') @@ -132,13 +130,5 @@ def change_display_name_value(step, new_value): def change_value(step, key, new_value): - index = get_index_of(key) - world.css_find(".CodeMirror")[index].click() - g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") - current_value = world.css_find(VALUE_CSS)[index].value - g._element.send_keys(Keys.CONTROL + Keys.END) - for count in range(len(current_value)): - g._element.send_keys(Keys.END, Keys.BACK_SPACE) - # Must delete "" before typing the JSON value - g._element.send_keys(Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, 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 494192ad06..e0f20d3d6e 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from nose.tools import assert_true @@ -16,7 +16,7 @@ logger = getLogger(__name__) ########### 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. @@ -26,17 +26,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': @@ -47,7 +47,7 @@ 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() @@ -73,7 +73,6 @@ def create_studio_user( registration.register(studio_user) registration.activate() - def fill_in_course_info( name='Robot Super Course', org='MITx', @@ -107,7 +106,7 @@ 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='MITx', course='999', display_name='Robot Super Course') # Add the user to the instructor group of the course # so they will have the permissions to see it in studio @@ -147,6 +146,7 @@ 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 @@ -169,3 +169,24 @@ def open_new_unit(step): 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/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/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature index bde350d8a3..cc1d766d2e 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.feature +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -3,65 +3,71 @@ Feature: Problem Editor Scenario: User can view metadata Given I have created a Blank Common Problem - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 - And I edit and select Settings + 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 index 7679128beb..8691a6772e 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -3,6 +3,7 @@ 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" @@ -135,12 +136,12 @@ def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_att @step('Edit High Level Source is not visible') def edit_high_level_source_not_visible(step): - verify_high_level_source(step, False) + verify_high_level_source_links(step, False) @step('Edit High Level Source is visible') -def edit_high_level_source_visible(step): - verify_high_level_source(step, True) +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') @@ -153,13 +154,33 @@ def cancel_does_not_save_changes(step): @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 (waiting for the tab to be visible) - world.css_find('#ui-id-2') + # 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') -def verify_high_level_source(step, visible): +@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')) @@ -187,3 +208,8 @@ def verify_unset_display_name(): 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.py b/cms/djangoapps/contentstore/features/section.py index 9d63fa73c8..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,7 +8,7 @@ from nose.tools import assert_equal ############### ACTIONS #################### -@step('I click the new section link$') +@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) diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 3a39f3cc15..468099f417 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 * diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index 4c2a460042..f28ee568dc 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -4,10 +4,20 @@ Feature: Video Component Editor Scenario: User can view metadata Given I have created a Video component And I edit and select Settings - Then I see only the Video display name setting + 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 index 27423575c3..987b4959b8 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -4,6 +4,20 @@ from lettuce import world, step -@step('I see only the video display name setting$') -def i_see_only_the_video_display_name(step): - world.verify_all_setting_entries([['Display Name', "default", True]]) +@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 index 0129732d30..e4caa70ef6 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -9,7 +9,16 @@ Feature: Video Component Given I have clicked the new unit button Then creating a video takes a single click - Scenario: Captions are shown correctly + 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 index fd8624999e..190f8e9f1e 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -6,23 +6,28 @@ from lettuce import world, step @step('when I view the video it does not have autoplay enabled') -def does_not_autoplay(step): +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): +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 captions') -def set_show_captions_false(step): - world.css_click('a.hide-subtitles') - - -@step('when I view the video it does not show the captions') -def does_not_show_captions(step): - assert world.css_find('.video')[0].has_class('closed') +@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 54bc726092..52e9ba14fe 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -19,7 +19,6 @@ 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 diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 03449fc22f..d24deacecf 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -28,6 +28,8 @@ 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 @@ -35,6 +37,7 @@ 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 @@ -129,7 +132,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # just pick one vertical descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] - location = descriptor.location._replace(name='.' + descriptor.location.name) + 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) @@ -221,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 @@ -366,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') @@ -382,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 = { @@ -461,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)) @@ -612,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) @@ -708,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) @@ -716,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) @@ -934,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 8c15b1ae95..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,6 +59,9 @@ 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") @@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase): 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())} + 'number': 1, + 'string': 'string', + 'datetime': datetime.datetime.now(UTC())} jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) @@ -118,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 @@ -181,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) @@ -256,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/tests.py b/cms/djangoapps/contentstore/tests/tests.py index f769652493..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): @@ -162,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 6f766ff7f5..0bfa70e4f5 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -224,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 @@ -244,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 229788f24d..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 @@ -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 @@ -145,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): diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 8762eb3a2a..dd7573bad5 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -12,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 @@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \ from django_comment_common.utils import seed_permissions_roles import datetime from django.utils.timezone import UTC - -# TODO: should explicitly enumerate exports with __all__ - __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', 'course_config_graders_page', @@ -230,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) }) 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 07eb4bc309..884a4e4fef 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -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) @@ -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(' - 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/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/src/main.coffee b/cms/static/coffee/src/main.coffee index efcd869113..8043b41638 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -18,11 +18,15 @@ $ -> $(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) -> if ajaxSettings.notifyOnError is false return - msg = new CMS.Models.ErrorMessage( + if jqXHR.responseText + message = _.str.truncate(jqXHR.responseText, 300) + else + message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") + msg = new CMS.Views.Notification.Error( "title": gettext("Studio's having trouble saving your work") - "message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") + "message": message ) - new CMS.Views.Notification({model: msg}) + msg.show() window.onTouchBasedDevice = -> navigator.userAgent.match /iPhone|iPod|iPad/i diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index d0a76a6c15..5154591d6f 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View [@metadataEditor.getDisplayName()]) @$el.find('.component-name').html(title) + customMetadata: -> + # Hack to support metadata fields that aren't part of the metadata editor (ie, LaTeX high level source). + # Walk through the set of elements which have the 'data-metadata_name' attribute and + # build up an object to pass back to the server on the subsequent POST. + # Note that these values will always be sent back on POST, even if they did not actually change. + _metadata = {} + _metadata[$(el).data("metadata-name")] = el.value for el in $('[data-metadata-name]', @$component_editor()) + return _metadata + changedMetadata: -> - return @metadataEditor.getModifiedMetadataValues() + return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata()) cloneTemplate: (parent, template) -> $.post("/clone_item", { diff --git a/cms/static/js/base.js b/cms/static/js/base.js index c626fa1b3f..92a16b8417 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -32,8 +32,6 @@ $(document).ready(function() { $modal.bind('click', hideModal); $modalCover.bind('click', hideModal); - $('.uploads .upload-button').bind('click', showUploadModal); - $('.upload-modal .close-button').bind('click', hideModal); $body.on('click', '.embeddable-xml-input', function() { $(this).select(); @@ -145,8 +143,6 @@ $(document).ready(function() { $('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate); $('.edit-section-start-save').bind('click', saveSetSectionScheduleDate); - $('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu); - $body.on('click', '.section-published-date .edit-button', editSectionPublishDate); $body.on('click', '.section-published-date .schedule-button', editSectionPublishDate); $body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate); @@ -398,73 +394,6 @@ function _deleteItem($el) { }); } -function showUploadModal(e) { - e.preventDefault(); - $modal = $('.upload-modal').show(); - $('.file-input').bind('change', startUpload); - $modalCover.show(); -} - -function showFileSelectionMenu(e) { - e.preventDefault(); - $('.file-input').click(); -} - -function startUpload(e) { - var files = $('.file-input').get(0).files; - if (files.length === 0) - return; - - $('.upload-modal h1').html(gettext('Uploading…')); - $('.upload-modal .file-name').html(files[0].name); - $('.upload-modal .file-chooser').ajaxSubmit({ - beforeSend: resetUploadBar, - uploadProgress: showUploadFeedback, - complete: displayFinishedUpload - }); - $('.upload-modal .choose-file-button').hide(); - $('.upload-modal .progress-bar').removeClass('loaded').show(); -} - -function resetUploadBar() { - var percentVal = '0%'; - $('.upload-modal .progress-fill').width(percentVal); - $('.upload-modal .progress-fill').html(percentVal); -} - -function showUploadFeedback(event, position, total, percentComplete) { - var percentVal = percentComplete + '%'; - $('.upload-modal .progress-fill').width(percentVal); - $('.upload-modal .progress-fill').html(percentVal); -} - -function displayFinishedUpload(xhr) { - if (xhr.status = 200) { - markAsLoaded(); - } - - var resp = JSON.parse(xhr.responseText); - $('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url')); - $('.upload-modal .embeddable').show(); - $('.upload-modal .file-name').hide(); - $('.upload-modal .progress-fill').html(resp.msg); - $('.upload-modal .choose-file-button').html(gettext('Load Another File')).show(); - $('.upload-modal .progress-fill').width('100%'); - - // see if this id already exists, if so, then user must have updated an existing piece of content - $("tr[data-id='" + resp.url + "']").remove(); - - var template = $('#new-asset-element').html(); - var html = Mustache.to_html(template, resp); - $('table > tbody').prepend(html); - - analytics.track('Uploaded a File', { - 'course': course_location_analytics, - 'asset_url': resp.url - }); - -} - function markAsLoaded() { $('.upload-modal .copy-button').css('display', 'inline-block'); $('.upload-modal .progress-bar').addClass('loaded'); diff --git a/cms/static/js/models/feedback.js b/cms/static/js/models/feedback.js deleted file mode 100644 index 1f1ee57000..0000000000 --- a/cms/static/js/models/feedback.js +++ /dev/null @@ -1,49 +0,0 @@ -CMS.Models.SystemFeedback = Backbone.Model.extend({ - defaults: { - "intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc - "title": "", - "message": "" - /* could also have an "actions" hash: here is an example demonstrating - the expected structure - "actions": { - "primary": { - "text": "Save", - "class": "action-save", - "click": function() { - // do something when Save is clicked - // `this` refers to the model - } - }, - "secondary": [ - { - "text": "Cancel", - "class": "action-cancel", - "click": function() {} - }, { - "text": "Discard Changes", - "class": "action-discard", - "click": function() {} - } - ] - } - */ - } -}); - -CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "warning" - }) -}); - -CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "error" - }) -}); - -CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "confirmation" - }) -}); diff --git a/cms/static/js/models/section.js b/cms/static/js/models/section.js index 467a2709a6..902585c58c 100644 --- a/cms/static/js/models/section.js +++ b/cms/static/js/models/section.js @@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({ }, showNotification: function() { if(!this.msg) { - this.msg = new CMS.Models.SystemFeedback({ - intent: "saving", - title: gettext("Saving…") - }); - } - if(!this.msgView) { - this.msgView = new CMS.Views.Notification({ - model: this.msg, + this.msg = new CMS.Views.Notification.Saving({ + title: gettext("Saving…"), closeIcon: false, minShown: 1250 }); } - this.msgView.show(); + this.msg.show(); }, hideNotification: function() { - if(!this.msgView) { return; } - this.msgView.hide(); + if(!this.msg) { return; } + this.msg.hide(); } }); diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js new file mode 100644 index 0000000000..18ef131f52 --- /dev/null +++ b/cms/static/js/views/assets.js @@ -0,0 +1,115 @@ +$(document).ready(function() { + $('.uploads .upload-button').bind('click', showUploadModal); + $('.upload-modal .close-button').bind('click', hideModal); + $('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu); + $('.remove-asset-button').bind('click', removeAsset); +}); + +function removeAsset(e){ + e.preventDefault(); + + var that = this; + var msg = new CMS.Views.Prompt.Confirmation({ + title: gettext("Delete File Confirmation"), + message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"), + actions: { + primary: { + text: gettext("OK"), + click: function(view) { + // call the back-end to actually remove the asset + var url = $('.asset-library').data('remove-asset-callback-url'); + var row = $(that).closest('tr'); + $.post(url, + { 'location': row.data('id') }, + function() { + // show the post-commit confirmation + $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); + row.remove(); + analytics.track('Deleted Asset', { + 'course': course_location_analytics, + 'id': row.data('id') + }); + } + ); + view.hide(); + } + }, + secondary: [{ + text: gettext("Cancel"), + click: function(view) { + view.hide(); + } + }] + } + }); + return msg.show(); +} + +function showUploadModal(e) { + e.preventDefault(); + $modal = $('.upload-modal').show(); + $('.file-input').bind('change', startUpload); + $modalCover.show(); +} + +function showFileSelectionMenu(e) { + e.preventDefault(); + $('.file-input').click(); +} + +function startUpload(e) { + var files = $('.file-input').get(0).files; + if (files.length === 0) + return; + + $('.upload-modal h1').html(gettext('Uploading…')); + $('.upload-modal .file-name').html(files[0].name); + $('.upload-modal .file-chooser').ajaxSubmit({ + beforeSend: resetUploadBar, + uploadProgress: showUploadFeedback, + complete: displayFinishedUpload + }); + $('.upload-modal .choose-file-button').hide(); + $('.upload-modal .progress-bar').removeClass('loaded').show(); +} + +function resetUploadBar() { + var percentVal = '0%'; + $('.upload-modal .progress-fill').width(percentVal); + $('.upload-modal .progress-fill').html(percentVal); +} + +function showUploadFeedback(event, position, total, percentComplete) { + var percentVal = percentComplete + '%'; + $('.upload-modal .progress-fill').width(percentVal); + $('.upload-modal .progress-fill').html(percentVal); +} + +function displayFinishedUpload(xhr) { + if (xhr.status == 200) { + markAsLoaded(); + } + + var resp = JSON.parse(xhr.responseText); + $('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url')); + $('.upload-modal .embeddable').show(); + $('.upload-modal .file-name').hide(); + $('.upload-modal .progress-fill').html(resp.msg); + $('.upload-modal .choose-file-button').html(gettext('Load Another File')).show(); + $('.upload-modal .progress-fill').width('100%'); + + // see if this id already exists, if so, then user must have updated an existing piece of content + $("tr[data-id='" + resp.url + "']").remove(); + + var template = $('#new-asset-element').html(); + var html = Mustache.to_html(template, resp); + $('table > tbody').prepend(html); + + // re-bind the listeners to delete it + $('.remove-asset-button').bind('click', removeAsset); + + analytics.track('Uploaded a File', { + 'course': course_location_analytics, + 'asset_url': resp.url + }); +} diff --git a/cms/static/js/views/feedback.js b/cms/static/js/views/feedback.js index 1a1a33ec1b..0cfd6fa4ef 100644 --- a/cms/static/js/views/feedback.js +++ b/cms/static/js/views/feedback.js @@ -1,39 +1,64 @@ -CMS.Views.Alert = Backbone.View.extend({ +CMS.Views.SystemFeedback = Backbone.View.extend({ options: { - type: "alert", + title: "", + message: "", + intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc + type: null, // "alert", "notification", or "prompt": set by subclass shown: true, // is this view currently being shown? icon: true, // should we render an icon related to the message intent? closeIcon: true, // should we render a close button in the top right corner? minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds) maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds) + + /* could also have an "actions" hash: here is an example demonstrating + the expected structure + actions: { + primary: { + "text": "Save", + "class": "action-save", + "click": function(view) { + // do something when Save is clicked + } + }, + secondary: [ + { + "text": "Cancel", + "class": "action-cancel", + "click": function(view) {} + }, { + "text": "Discard Changes", + "class": "action-discard", + "click": function(view) {} + } + ] + } + */ }, initialize: function() { + if(!this.options.type) { + throw "SystemFeedback: type required (given " + + JSON.stringify(this.options) + ")"; + } + if(!this.options.intent) { + throw "SystemFeedback: intent required (given " + + JSON.stringify(this.options) + ")"; + } var tpl = $("#system-feedback-tpl").text(); if(!tpl) { console.error("Couldn't load system-feedback template"); } this.template = _.template(tpl); this.setElement($("#page-"+this.options.type)); - this.listenTo(this.model, 'change', this.render); - return this.show(); - }, - render: function() { - var attrs = $.extend({}, this.options, this.model.attributes); - this.$el.html(this.template(attrs)); return this; }, - events: { - "click .action-close": "hide", - "click .action-primary": "primaryClick", - "click .action-secondary": "secondaryClick" - }, + // public API: show() and hide() show: function() { clearTimeout(this.hideTimeout); this.options.shown = true; this.shownAt = new Date(); this.render(); if($.isNumeric(this.options.maxShown)) { - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.maxShown); } return this; @@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({ this.options.minShown > new Date() - this.shownAt) { clearTimeout(this.hideTimeout); - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.minShown - (new Date() - this.shownAt)); } else { this.options.shown = false; @@ -52,40 +77,64 @@ CMS.Views.Alert = Backbone.View.extend({ } return this; }, - primaryClick: function() { - var actions = this.model.get("actions"); + // the rest of the API should be considered semi-private + events: { + "click .action-close": "hide", + "click .action-primary": "primaryClick", + "click .action-secondary": "secondaryClick" + }, + render: function() { + // there can be only one active view of a given type at a time: only + // one alert, only one notification, only one prompt. Therefore, we'll + // use a singleton approach. + var parent = CMS.Views[_.str.capitalize(this.options.type)]; + if(parent && parent.active && parent.active !== this) { + parent.active.stopListening(); + parent.active.undelegateEvents(); + } + this.$el.html(this.template(this.options)); + parent.active = this; + return this; + }, + primaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var primary = actions.primary; if(!primary) { return; } if(primary.click) { - primary.click.call(this.model, this); + primary.click.call(event.target, this, event); } }, - secondaryClick: function(e) { - var actions = this.model.get("actions"); + secondaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var secondaryList = actions.secondary; if(!secondaryList) { return; } // which secondary action was clicked? var i = 0; // default to the first secondary action (easier for testing) - if(e && e.target) { - i = _.indexOf(this.$(".action-secondary"), e.target); + if(event && event.target) { + i = _.indexOf(this.$(".action-secondary"), event.target); } - var secondary = this.model.get("actions").secondary[i]; + var secondary = secondaryList[i]; if(secondary.click) { - secondary.click.call(this.model, this); + secondary.click.call(event.target, this, event); } } }); -CMS.Views.Notification = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Alert = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { + type: "alert" + }) +}); +CMS.Views.Notification = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "notification", closeIcon: false }) }); -CMS.Views.Prompt = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "prompt", closeIcon: false, icon: false @@ -98,6 +147,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({ $body.removeClass('prompt-is-shown'); } // super() in Javascript has awkward syntax :( - return CMS.Views.Alert.prototype.render.apply(this, arguments); + return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments); } }); + +// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation, +// CMS.Views.Prompt.StepRequired, etc +var capitalCamel, types, intents; +capitalCamel = _.compose(_.str.capitalize, _.str.camelize); +types = ["alert", "notification", "prompt"]; +intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"]; +_.each(types, function(type) { + _.each(intents, function(intent) { + // "class" is a reserved word in Javascript, so use "klass" instead + var klass, subklass; + klass = CMS.Views[capitalCamel(type)]; + subklass = klass.extend({ + options: $.extend({}, klass.prototype.options, { + type: type, + intent: intent + }) + }); + klass[capitalCamel(intent)] = subklass; + }); +}); diff --git a/cms/static/js/views/section.js b/cms/static/js/views/section.js index 622249414d..eccc547a06 100644 --- a/cms/static/js/views/section.js +++ b/cms/static/js/views/section.js @@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({ showInvalidMessage: function(model, error, options) { model.set("name", model.previous("name")); var that = this; - var msg = new CMS.Models.ErrorMessage({ + var prompt = new CMS.Views.Prompt.Error({ title: gettext("Your change could not be saved"), message: error, actions: { @@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({ } } }); - new CMS.Views.Prompt({model: msg}); + prompt.show(); } }); diff --git a/cms/static/sass/elements/_system-help.scss b/cms/static/sass/elements/_system-help.scss index 7fcb218282..a9a3e16128 100644 --- a/cms/static/sass/elements/_system-help.scss +++ b/cms/static/sass/elements/_system-help.scss @@ -1,2 +1,40 @@ // studio - elements - system help // ==================== + +// notices - in-context: to be used as notices to users within the context of a form/action +.notice-incontext { + @extend .ui-well; + @include border-radius(($baseline/10)); + + .title { + @extend .t-title7; + margin-bottom: ($baseline/4); + font-weight: 600; + } + + .copy { + @extend .t-copy-sub1; + @include transition(opacity 0.25s ease-in-out 0); + opacity: 0.75; + } + + strong { + font-weight: 600; + } + + &:hover { + + .copy { + opacity: 1.0; + } + } +} + +// particular warnings around a workflow for something +.notice-workflow { + background: $yellow-l5; + + .copy { + color: $gray-d1; + } +} diff --git a/cms/static/sass/views/_assets.scss b/cms/static/sass/views/_assets.scss index d01dd988ef..d4cff42ee9 100644 --- a/cms/static/sass/views/_assets.scss +++ b/cms/static/sass/views/_assets.scss @@ -76,6 +76,10 @@ body.course.uploads { width: 250px; } + .delete-col { + width: 20px; + } + .embeddable-xml-input { @include box-shadow(none); width: 100%; diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 735774511f..cbb1034626 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -21,7 +21,7 @@ body.course.settings { font-size: 14px; } - .message-status { + .message-status { display: none; @include border-top-radius(2px); @include box-sizing(border-box); @@ -52,6 +52,12 @@ body.course.settings { } } + // notices - used currently for edx mktg + .notice-workflow { + margin-top: ($baseline); + } + + // in form - elements .group-settings { margin: 0 0 ($baseline*2) 0; diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index f03a9012f8..abbc5bb1b4 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -1,5 +1,6 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> +<%! from django.utils.translation import ugettext as _ %> <%block name="bodyclass">is-signedin course uploads <%block name="title">Files & Uploads @@ -30,6 +31,9 @@ + + + @@ -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/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/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 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 +
${hname | h}
${value | h}
+

+%endif + ##----------------------------------------------------------------------------- %if modeflag.get('Psychometrics'): diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index e2fbaed9cf..c41d753444 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -138,8 +138,14 @@
Full Name (edit)
${ user.profile.name | h }
  • - 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 }
  • + + % if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
  • Reset Password
    @@ -147,6 +153,8 @@
  • + % endif + diff --git a/lms/templates/extauth_failure.html b/lms/templates/extauth_failure.html index fa53ab1084..330c63e604 100644 --- a/lms/templates/extauth_failure.html +++ b/lms/templates/extauth_failure.html @@ -2,10 +2,10 @@ "http://www.w3.org/TR/html4/strict.dtd"> - OpenID failed + External Authentication failed -

    OpenID failed

    +

    External Authentication failed

    ${message}

    diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 190a58f691..a26e1ca367 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -95,16 +95,26 @@ site_status_msg = get_site_status_msg(course_id) % endif % if not settings.MITX_FEATURES['DISABLE_LOGIN_BUTTON']: - + % if course and settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: + + % else: + + % endif % endif diff --git a/lms/templates/register.html b/lms/templates/register.html index 73a6df9319..1a42d402e5 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -136,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 @@ -210,11 +231,16 @@
    1. + + % if has_extauth_info is UNDEFINED or ask_for_tos : +
      + % endif +
      <% @@ -246,6 +272,8 @@

      Registration Help

      + % if has_extauth_info is UNDEFINED: +

      Already registered?

      @@ -254,6 +282,8 @@

      + + % endif ## TODO: Use a %block tag or something to allow themes to ## override in a more generalizable fashion. 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"> - -
      - -
      - - - - - - - - - - - <% 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/video.html b/lms/templates/video.html index 267372176a..77c8a5ee16 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -2,37 +2,34 @@

      ${display_name}

      % endif -%if settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']: -
      -
      -
      -
      -
      -
        -
      • -
      • -
        0:00 / 0:00
        -
      • -
      - -
      -
      -
      -
      -%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 07c7dbee27..2bb5d817a8 100644 --- a/lms/templates/videoalpha.html +++ b/lms/templates/videoalpha.html @@ -2,34 +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 index dea222653e..dd9787a77c 100644 --- a/lms/templates/widgets/segment-io.html +++ b/lms/templates/widgets/segment-io.html @@ -1,9 +1,7 @@ +% 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 2d85fe1e66..922031fe93 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 @@ -363,6 +364,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'), @@ -394,6 +410,11 @@ 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'), + ) + # FoldIt views urlpatterns += ( # The path is hardcoded into their app... 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/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..96bd4c2e96 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 faild (#{error})" + puts "Please run `bundle install` to bootstrap ruby dependencies" + exit 1 end # Build Constants diff --git a/rakefiles/assets.rake b/rakelib/assets.rake similarity index 83% rename from rakefiles/assets.rake rename to rakelib/assets.rake index 764d049a68..5c8abc1fb0 100644 --- a/rakefiles/assets.rake +++ b/rakelib/assets.rake @@ -6,6 +6,8 @@ if USE_CUSTOM_THEME 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 @@ -21,24 +23,14 @@ def xmodule_cmd(watch=false, debug=false) 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 - # - # So, instead, we use watchmedo, which works around the problem - "watchmedo shell-command " + - "--command 'node_modules/.bin/coffee -c ${watch_src_path}' " + - "--recursive " + - "--patterns '*.coffee' " + - "--ignore-directories " + - "--wait " + - "." - else - 'node_modules/.bin/coffee --compile .' + 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) @@ -55,8 +47,9 @@ def sass_cmd(watch=false, debug=false) "#{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" -multitask :assets => 'assets:all' +task :assets, [:system, :env] => 'assets:all' namespace :assets do @@ -80,8 +73,9 @@ namespace :assets do {: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 => prereq_tasks do + 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)} @@ -90,7 +84,8 @@ namespace :assets do end end - multitask :all => asset_type + # 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" @@ -111,9 +106,9 @@ namespace :assets do 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| background_process(c)} + cmd.each {|c| singleton_process(c)} else - background_process(cmd) + singleton_process(cmd) 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/rakefiles/deprecated.rake b/rakelib/deprecated.rake similarity index 100% rename from rakefiles/deprecated.rake rename to rakelib/deprecated.rake diff --git a/rakefiles/django.rake b/rakelib/django.rake similarity index 100% rename from rakefiles/django.rake rename to rakelib/django.rake 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 90% rename from rakefiles/helpers.rb rename to rakelib/helpers.rb index 4b10bef709..3373214a19 100644 --- a/rakefiles/helpers.rb +++ b/rakelib/helpers.rb @@ -1,4 +1,6 @@ require 'digest/md5' +require 'sys/proctable' +require 'colorize' def find_executable(exec) path = %x(which #{exec}).strip @@ -84,6 +86,16 @@ 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('/', '.') 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/rakefiles/jasmine.rake b/rakelib/jasmine.rake similarity index 100% rename from rakefiles/jasmine.rake rename to rakelib/jasmine.rake 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/rakefiles/tests.rake b/rakelib/tests.rake similarity index 74% rename from rakefiles/tests.rake rename to rakelib/tests.rake index b4754c2c3c..3cb5e8f4e5 100644 --- a/rakefiles/tests.rake +++ b/rakelib/tests.rake @@ -1,6 +1,9 @@ # 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 @@ -33,13 +36,32 @@ def run_acceptance_tests(system, report_dir, harvest_args) test_sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--tag -skip', harvest_args)) end - -directory REPORT_DIR +# 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| @@ -47,21 +69,21 @@ TEST_TASK_DIRS = [] # Per System tasks desc "Run all django tests on our djangoapps for the #{system}" - task "test_#{system}", [:test_id] => ["clean_test_files", :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] + task "test_#{system}", [:test_id] => [:clean_test_files, :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, :install_prereqs, :predjango] do |t, args| + 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] => ["#{system}:gather_assets:acceptance", "fasttest_acceptance_#{system}"] + 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] => ["clean_test_files", :predjango, report_dir] do |t, args| + 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 @@ -77,7 +99,7 @@ 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}" => ["clean_test_files", report_dir] do + 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)) @@ -103,7 +125,7 @@ TEST_TASK_DIRS.each do |dir| end desc "Run all tests" -task :test +task :test => :test_docs desc "Build the html, xml, and diff coverage reports" task :coverage => :report_dirs do 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/github.txt b/requirements/edx/github.txt index dc39bd5fa4..5ce748e7b5 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -3,11 +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@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.1#egg=diff_cover +-e git+https://github.com/edx/diff-cover.git@v0.1.2#egg=diff_cover diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh index ede86b123a..d3b7715904 100755 --- a/scripts/create-dev-env.sh +++ b/scripts/create-dev-env.sh @@ -73,7 +73,7 @@ change_git_push_defaults() { #Set git push defaults to upstream rather than master output "Changing git defaults" git config --global push.default upstream - + } clone_repos() { @@ -206,10 +206,10 @@ case `uname -s` in distro=`lsb_release -cs` case $distro in - wheezy|jessie|maya|olivia|nadia|precise|quantal) + wheezy|jessie|maya|olivia|nadia|precise|quantal) warning " Debian support is not fully debugged. Assuming you have standard - development packages already working like scipy rvm, the + development packages already working like scipy rvm, the installation should go fine, but this is still a work in progress. Please report issues you have and let us know if you are able to figure @@ -218,7 +218,7 @@ case `uname -s` in Press return to continue or control-C to abort" read dummy - sudo apt-get install git ;; + sudo apt-get install git ;; squeeze|lisa|katya|oneiric|natty|raring) warning " It seems like you're using $distro which has been deprecated. @@ -231,7 +231,7 @@ case `uname -s` in Press return to continue or control-C to abort" read dummy sudo apt-get install git - ;; + ;; *) error "Unsupported distribution - $distro" @@ -283,7 +283,7 @@ clone_repos if [[ -d $BASE/edx-platform/scripts ]]; then output "Installing system-level dependencies" bash $BASE/edx-platform/scripts/install-system-req.sh -else +else error "It appears that our directory structure has changed and somebody failed to update this script. raise an issue on Github and someone should fix it." exit 1 @@ -314,14 +314,14 @@ case `uname -s` in [Ll]inux) warning "Setting up rvm on linux. This is a known pain point. If the script fails here - refer to the following stack overflow question: + refer to the following stack overflow question: http://stackoverflow.com/questions/9056008/installed-ruby-1-9-3-with-rvm-but-command-line-doesnt-show-ruby-v/9056395#9056395" sudo apt-get --purge remove ruby-rvm sudo rm -rf /usr/share/ruby-rvm /etc/rvmrc /etc/profile.d/rvm.sh curl -sL https://get.rvm.io | bash -s stable --ruby --autolibs=enable --auto-dotfiles ;; esac - + # Ensure we have RVM available as a shell function so that it can mess # with the environment and set everything up properly. The RVM install @@ -494,10 +494,11 @@ cd $BASE pip install argcomplete cd $BASE/edx-platform bundle install +rake install_prereqs -mkdir "$BASE/log" || true -mkdir "$BASE/db" || true -mkdir "$BASE/data" || true +mkdir -p "$BASE/log" +mkdir -p "$BASE/db" +mkdir -p "$BASE/data" rake django-admin[syncdb] rake django-admin[migrate] 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