diff --git a/AUTHORS b/AUTHORS index 1a67c65d05..4b57d723d2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -53,7 +53,7 @@ Christina Roberts Robert Chirwa Ed Zarecor Deena Wang -Jean Manuel-Nater +Jean Manuel Náter Emily Zhang <1800.ehz.hang@gmail.com> Jennifer Akana Peter Baratta diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 15310ebb9e..897ea3ae3a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,14 @@ 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: Email will be sent to admin address when a user requests course creator +privileges for Studio (edge only). + +Studio: Studio course authors (both instructors and staff) will be auto-enrolled +for their courses so that "View Live" works. + +Common: Added ratelimiting to our authentication backend. + Common: Add additional logging to cover login attempts and logouts. Studio: Send e-mails to new Studio users (on edge only) when their course creator @@ -40,6 +48,9 @@ Common: Add a manage.py that knows about edx-platform specific settings and proj Common: Added *experimental* support for jsinput type. +Studio: Remove XML from HTML5 video component editor. All settings are +moved to be edited as metadata. + Common: Added setting to specify Celery Broker vhost Common: Utilize new XBlock bulk save API in LMS and CMS. diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 514eb8898e..a11a6cb869 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -40,6 +40,7 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is unchanged + # This feature will work in Firefox only when Firefox is the active window Scenario: Test automatic quoting of non-JSON values Given I am on the Advanced Course Settings page in Studio When I create a non-JSON value not in quotes diff --git a/cms/djangoapps/contentstore/features/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature index 10db23c4fa..f13ce53fc2 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -10,6 +10,7 @@ Feature: Course checklists Then I can check and uncheck tasks in a checklist And They are correctly selected after reloading the page + # CHROME ONLY, due to issues getting link to be active in firefox Scenario: A task can link to a location within Studio Given I have opened Checklists When I select a link to the course outline @@ -17,6 +18,7 @@ Feature: Course checklists And I press the browser back button Then I am brought back to the course outline in the correct state + # CHROME ONLY, due to issues getting link to be active in firefox Scenario: A task can link to a location outside Studio Given I have opened Checklists When I select a link to help page diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 8b23bc3635..8d13a39bb3 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -56,10 +56,8 @@ def i_have_opened_a_new_course(_step): @step('(I select|s?he selects) the new course') def select_new_course(_step, whom): - course_link_xpath = '//div[contains(@class, "courses")]//a[contains(@class, "class-link")]//span[contains(., "{name}")]/..'.format( - name="Robot Super Course") - element = world.browser.find_by_xpath(course_link_xpath) - element.click() + course_link_css = 'a.course-link' + world.css_click(course_link_css) @step(u'I press the "([^"]*)" notification button$') @@ -72,8 +70,12 @@ def press_the_notification_button(_step, name): 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 - - world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name + if world.is_firefox(): + # This is done to explicitly make the changes save on firefox. It will remove focus from the previously focused element + world.trigger_event(css, event='focus') + world.browser.execute_script("$('{}').click()".format(css)) + else: + world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name @step('I change the "(.*)" field to "(.*)"$') @@ -144,24 +146,13 @@ def fill_in_course_info( def log_into_studio( uname='robot', email='robot+studio@edx.org', - password='test'): + password='test', + name='Robot Studio'): - world.browser.cookies.delete() + world.log_in(username=uname, password=password, email=email, name=name) + # Navigate to the studio dashboard world.visit('/') - - signin_css = 'a.action-signin' - world.is_css_present(signin_css) - world.css_click(signin_css) - - def fill_login_form(): - login_form = world.browser.find_by_css('form#login_form') - login_form.find_by_name('email').fill(email) - login_form.find_by_name('password').fill(password) - login_form.find_by_name('submit').click() - world.retry_on_exception(fill_login_form) - assert_true(world.is_css_present('.new-course-button')) - world.scenario_dict['USER'] = get_user_by_email(email) - + world.wait_for(lambda _driver: uname in world.css_find('h2.title')[0].text) def create_a_course(): course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') @@ -178,9 +169,10 @@ def create_a_course(): group, __ = Group.objects.get_or_create(name=groupname) user.groups.add(group) user.save() - world.browser.reload() - course_link_css = 'span.class-name' + # Navigate to the studio dashboard + world.visit('/') + course_link_css = 'a.course-link' world.css_click(course_link_css) course_title_css = 'span.course-title' assert_true(world.is_css_present(course_title_css)) @@ -228,6 +220,26 @@ def i_created_a_video_component(step): ) +@step('I have created a Video Alpha component$') +def i_created_video_alpha(step): + step.given('I have enabled the videoalpha advanced module') + world.css_click('a.course-link') + step.given('I have added a new subsection') + step.given('I expand the first section') + world.css_click('a.new-unit-item') + world.css_click('.large-advanced-icon') + world.click_component_from_menu('videoalpha', None, '.xmodule_VideoAlphaModule') + + +@step('I have enabled the (.*) advanced module$') +def i_enabled_the_advanced_module(step, module): + step.given('I have opened a new course section in Studio') + world.css_click('.nav-course-settings') + world.css_click('.nav-course-settings-advanced a') + type_in_codemirror(0, '["%s"]' % module) + press_the_notification_button(step, 'Save') + + @step('I have clicked the new unit button') def open_new_unit(step): step.given('I have opened a new course section in Studio') @@ -236,14 +248,14 @@ def open_new_unit(step): world.css_click('a.new-unit-item') -@step('when I view the video it (.*) show the captions') -def shows_captions(step, show_captions): +@step('when I view the (video.*) it (.*) show the captions') +def shows_captions(_step, video_type, show_captions): # Prevent cookies from overriding course settings world.browser.cookies.delete('hide_captions') if show_captions == 'does not': - assert world.css_has_class('.video', 'closed') + assert world.css_has_class('.%s' % video_type, 'closed') else: - assert world.is_css_not_present('.video.closed') + assert world.is_css_not_present('.%s.closed' % video_type) @step('the save button is disabled$') @@ -265,7 +277,7 @@ def i_am_shown_a_notification(step, notification_type): def type_in_codemirror(index, text): - world.css_click(".CodeMirror", index=index) + world.css_click("div.CodeMirror-lines", index=index) world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')") g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") if world.is_mac(): @@ -274,3 +286,5 @@ def type_in_codemirror(index, text): g._element.send_keys(Keys.CONTROL + 'a') g._element.send_keys(Keys.DELETE) g._element.send_keys(text) + if world.is_firefox(): + world.trigger_event('div.CodeMirror', index=index, event='blur') diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 513eb699e9..606e3dcee8 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -56,13 +56,24 @@ def click_component_from_menu(category, boilerplate, expected_css): def edit_component_and_select_settings(): world.wait_for(lambda _driver: world.css_visible('a.edit-button')) world.css_click('a.edit-button') - world.css_click('#settings-mode') + world.css_click('#settings-mode a') + + +@world.absorb +def edit_component(): + world.wait_for(lambda _driver: world.css_visible('a.edit-button')) + world.css_click('a.edit-button') @world.absorb def verify_setting_entry(setting, display_name, value, explicitly_set): assert_equal(display_name, setting.find_by_css('.setting-label')[0].value) - assert_equal(value, setting.find_by_css('.setting-input')[0].value) + # Check specifically for the list type; it has a different structure + if setting.has_class('metadata-list-enum'): + list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item')) + assert_equal(value, list_value) + else: + assert_equal(value, setting.find_by_css('.setting-input')[0].value) settingClearButton = setting.find_by_css('.setting-clear')[0] assert_equal(explicitly_set, settingClearButton.has_class('active')) assert_equal(not explicitly_set, settingClearButton.has_class('inactive')) @@ -103,8 +114,20 @@ def revert_setting_entry(label): @world.absorb def get_setting_entry(label): - settings = world.browser.find_by_css('.wrapper-comp-setting') - for setting in settings: - if setting.find_by_css('.setting-label')[0].value == label: - return setting - return None + def get_setting(): + settings = world.css_find('.wrapper-comp-setting') + for setting in settings: + if setting.find_by_css('.setting-label')[0].value == label: + return setting + return None + return world.retry_on_exception(get_setting) + +@world.absorb +def get_setting_entry_index(label): + def get_index(): + settings = world.css_find('.wrapper-comp-setting') + for index, setting in enumerate(settings): + if setting.find_by_css('.setting-label')[0].value == label: + return index + return None + return world.retry_on_exception(get_index) diff --git a/cms/djangoapps/contentstore/features/course-overview.py b/cms/djangoapps/contentstore/features/course-overview.py index 8baad68fdf..3fcb134f5b 100644 --- a/cms/djangoapps/contentstore/features/course-overview.py +++ b/cms/djangoapps/contentstore/features/course-overview.py @@ -52,7 +52,7 @@ def have_a_course_with_two_sections(step): def navigate_to_the_course_overview_page(step): create_studio_user(is_staff=True) log_into_studio() - course_locator = '.class-name' + course_locator = 'a.course-link' world.css_click(course_locator) diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature index 20171eeae5..de5bb6556a 100644 --- a/cms/djangoapps/contentstore/features/course-team.feature +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -15,6 +15,8 @@ Feature: Course Team And I am viewing the course team settings When I add "bob" to the course team And "bob" logs in + And he selects the new course + And he views the course team settings Then he cannot delete users And he cannot add users @@ -69,7 +71,7 @@ Feature: Course Team And she selects the new course And she views the course team settings And she deletes me from the course team - And I log in + And I am logged into studio Then I do not see the course on my page Scenario: Admins should be able to remove their own admin rights diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py index 57545060c1..db7b4d81f9 100644 --- a/cms/djangoapps/contentstore/features/course-team.py +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -2,9 +2,10 @@ #pylint: disable=W0621 from lettuce import world, step -from common import create_studio_user, log_into_studio +from common import create_studio_user from django.contrib.auth.models import Group -from auth.authz import get_course_groupname_for_role +from auth.authz import get_course_groupname_for_role, get_user_by_email +from nose.tools import assert_true PASSWORD = 'test' EMAIL_EXTENSION = '@edx.org' @@ -42,9 +43,9 @@ def add_other_user(_step, name): world.wait(0.5) email_css = 'input#user-email-input' - f = world.css_find(email_css) - f._element.send_keys(name, EMAIL_EXTENSION) - + world.css_fill(email_css, name + EMAIL_EXTENSION) + if world.is_firefox(): + world.trigger_event(email_css) confirm_css = 'form.create-user button.action-primary' world.css_click(confirm_css) @@ -55,6 +56,8 @@ def delete_other_user(_step, name): email="{0}{1}".format(name, EMAIL_EXTENSION)) world.css_click(to_delete_css) # confirm prompt + # need to wait for the animation to be done, there isn't a good success condition that won't work both on latest chrome and jenkins + world.wait(.5) world.css_click(".wrapper-prompt-warning .action-primary") @@ -64,6 +67,7 @@ def other_delete_self(_step): email="robot+studio@edx.org") world.css_click(to_delete_css) # confirm prompt + world.wait(.5) world.css_click(".wrapper-prompt-warning .action-primary") @@ -87,13 +91,27 @@ def remove_course_team_admin(_step, outer_capture, name): @step(u'"([^"]*)" logs in$') def other_user_login(_step, name): - log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION) + world.browser.cookies.delete() + world.visit('/') + + signin_css = 'a.action-signin' + world.is_css_present(signin_css) + world.css_click(signin_css) + + def fill_login_form(): + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill(name + EMAIL_EXTENSION) + login_form.find_by_name('password').fill(PASSWORD) + login_form.find_by_name('submit').click() + world.retry_on_exception(fill_login_form) + assert_true(world.is_css_present('.new-course-button')) + world.scenario_dict['USER'] = get_user_by_email(name + EMAIL_EXTENSION) @step(u'I( do not)? see the course on my page') @step(u's?he does( not)? see the course on (his|her) page') def see_course(_step, inverted, gender='self'): - class_css = 'span.class-name' + class_css = 'h3.course-title' all_courses = world.css_find(class_css, wait_time=1) all_names = [item.html for item in all_courses] if inverted: diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index 9506191a76..f431af9cf5 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -9,7 +9,7 @@ from common import type_in_codemirror @step(u'I go to the course updates page') def go_to_updates(_step): menu_css = 'li.nav-course-courseware' - updates_css = 'li.nav-course-courseware-updates' + updates_css = 'li.nav-course-courseware-updates a' world.css_click(menu_css) world.css_click(updates_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 2feafce361..f18b06ec64 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -30,7 +30,7 @@ def i_create_a_course(step): @step('I click the course link in My Courses$') def i_click_the_course_link_in_my_courses(step): - course_css = 'span.class-name' + course_css = 'a.course-link' world.css_click(course_css) ############ ASSERTIONS ################### @@ -44,7 +44,7 @@ def courseware_page_has_loaded_in_studio(step): @step('I see the course listed in My Courses$') def i_see_the_course_in_my_courses(step): - course_css = 'span.class-name' + course_css = 'h3.class-title' assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name) diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature index cc1d766d2e..50c49a1896 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.feature +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -47,12 +47,12 @@ Feature: Problem Editor Scenario: User cannot type decimal values integer number field Given I have created a Blank Common Problem When I edit and select Settings - Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234" + Then if I set the max attempts to "2.34", it will persist as a valid integer Scenario: User cannot type out of range values in an integer number field Given I have created a Blank Common Problem When I edit and select Settings - Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0" + Then if I set the max attempts to "-3", it will persist as a valid integer Scenario: Settings changes are not saved on Cancel Given I have created a Blank Common Problem @@ -66,6 +66,7 @@ Feature: Problem Editor When I edit and select Settings Then Edit High Level Source is visible + # This feature will work in Firefox only when Firefox is the active window 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 diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index d7ccb557ba..d891789e4a 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -45,7 +45,10 @@ def i_see_five_settings_with_values(step): def i_can_modify_the_display_name(step): # Verifying that the display name can be a string containing a floating point value # (to confirm that we don't throw an error because it is of the wrong type). - world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4') + index = world.get_setting_entry_index(DISPLAY_NAME) + world.css_fill('.wrapper-comp-setting .setting-input', '3.4', index=index) + if world.is_firefox(): + world.trigger_event('.wrapper-comp-setting .setting-input', index=index) verify_modified_display_name() @@ -57,7 +60,10 @@ def my_display_name_change_is_persisted_on_save(step): @step('I can specify special characters in the display name') def i_can_modify_the_display_name_with_special_chars(step): - world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &") + index = world.get_setting_entry_index(DISPLAY_NAME) + world.css_fill('.wrapper-comp-setting .setting-input', "updated ' \" &", index=index) + if world.is_firefox(): + world.trigger_event('.wrapper-comp-setting .setting-input', index=index) verify_modified_display_name_with_special_chars() @@ -127,12 +133,16 @@ def set_the_weight_to_abc(step, bad_weight): world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) -@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"') -def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted): - world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set) - world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True) +@step('if I set the max attempts to "(.*)", it will persist as a valid integer$') +def set_the_max_attempts(step, max_attempts_set): + # on firefox with selenium, the behaviour is different. eg 2.34 displays as 2.34 and is persisted as 2 + index = world.get_setting_entry_index(MAXIMUM_ATTEMPTS) + world.css_fill('.wrapper-comp-setting .setting-input', max_attempts_set, index=index) + if world.is_firefox(): + world.trigger_event('.wrapper-comp-setting .setting-input', index=index) world.save_component_and_reopen(step) - world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True) + value = int(world.css_value('input.setting-input', index=index)) + assert value >= 0 @step('Edit High Level Source is not visible') @@ -213,7 +223,11 @@ def verify_unset_display_name(): def set_weight(weight): - world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight) + index = world.get_setting_entry_index(PROBLEM_WEIGHT) + world.css_fill('.wrapper-comp-setting .setting-input', weight, index=index) + if world.is_firefox(): + world.trigger_event('.wrapper-comp-setting .setting-input', index=index, event='blur') + world.trigger_event('a.save-button', event='focus') def open_high_level_source(): diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index a08b490c6d..6402db1bcb 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -3,7 +3,6 @@ Feature: Create Section As a course author I want to create and edit sections - @skip Scenario: Add a new section to a course Given I have opened a new course in Studio When I click the New Section link @@ -24,7 +23,7 @@ Feature: Create Section Given I have opened a new course in Studio And I have added a new section When I click the Edit link for the release date - And I save a new section release date + And I set the section release date to 12/25/2013 Then the section release date is updated And I see a "saving" notification diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 955c6a8f4e..3ca8e1676d 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -35,10 +35,15 @@ def i_click_the_edit_link_for_the_release_date(_step): world.css_click(button_css) -@step('I save a new section release date$') -def i_save_a_new_section_release_date(_step): - set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013', - 'input.start-time.time.ui-timepicker-input', '00:00') +@step('I set the section release date to ([0-9/-]+)( [0-9:]+)?') +def set_section_release_date(_step, datestring, timestring): + if hasattr(timestring, "strip"): + timestring = timestring.strip() + if not timestring: + timestring = "00:00" + set_date_and_time( + 'input.start-date.date.hasDatepicker', datestring, + 'input.start-time.time.ui-timepicker-input', timestring) world.browser.click_link_by_text('Save') diff --git a/cms/djangoapps/contentstore/features/static-pages.py b/cms/djangoapps/contentstore/features/static-pages.py index 3c9226f874..d3244955e1 100644 --- a/cms/djangoapps/contentstore/features/static-pages.py +++ b/cms/djangoapps/contentstore/features/static-pages.py @@ -8,7 +8,7 @@ from selenium.webdriver.common.keys import Keys @step(u'I go to the static pages page') def go_to_static(_step): menu_css = 'li.nav-course-courseware' - static_css = 'li.nav-course-courseware-pages' + static_css = 'li.nav-course-courseware-pages a' world.css_click(menu_css) world.css_click(static_css) @@ -38,14 +38,12 @@ def click_edit_delete(_step, edit_delete, page): @step(u'I change the name to "([^"]*)"$') def change_name(_step, new_name): - settings_css = '#settings-mode' + settings_css = '#settings-mode a' world.css_click(settings_css) input_css = 'input.setting-input' - name_input = world.css_find(input_css) - old_name = name_input.value - for count in range(len(old_name)): - name_input._element.send_keys(Keys.END, Keys.BACK_SPACE) - name_input._element.send_keys(new_name) + world.css_fill(input_css, new_name) + if world.is_firefox(): + world.trigger_event(input_css) save_button = 'a.save-button' world.css_click(save_button) diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 9f5793dbe7..84755b3644 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -14,7 +14,7 @@ Feature: Create Subsection When I click the New Subsection link And I enter a subsection name with a quote and click save Then I see my subsection name with a quote on the Courseware page - And I click to edit the subsection name + And I click on the subsection Then I see the complete subsection name with a quote in the editor Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258) @@ -27,10 +27,13 @@ Feature: Create Subsection Scenario: Set a due date in a different year (bug #256) Given I have opened a new subsection in Studio - And I have set a release date and due date in different years - Then I see the correct dates + And I set the subsection release date to 12/25/2011 03:00 + And I set the subsection due date to 01/02/2012 04:00 + Then I see the subsection release date is 12/25/2011 03:00 + And I see the subsection due date is 01/02/2012 04:00 And I reload the page - Then I see the correct dates + Then I see the subsection release date is 12/25/2011 03:00 + And I see the subsection due date is 01/02/2012 04:00 Scenario: Delete a subsection Given I have opened a new course section in Studio @@ -40,3 +43,16 @@ Feature: Create Subsection And I press the "subsection" delete icon And I confirm the prompt Then the subsection does not exist + + Scenario: Sync to Section + Given I have opened a new course section in Studio + And I click the Edit link for the release date + And I set the section release date to 01/02/2103 + And I have added a new subsection + And I click on the subsection + And I set the subsection release date to 01/20/2103 + And I reload the page + And I click the link to sync release date to section + And I wait for "1" second + And I reload the page + Then I see the subsection release date is 01/02/2103 diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index e280ec615d..60a325f550 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -41,8 +41,8 @@ def i_save_subsection_name_with_quote(step): save_subsection_name('Subsection With "Quote"') -@step('I click to edit the subsection name$') -def i_click_to_edit_subsection_name(step): +@step('I click on the subsection$') +def click_on_subsection(step): world.css_click('span.subsection-name-value') @@ -53,12 +53,28 @@ def i_see_complete_subsection_name_with_quote_in_editor(step): assert_equal(world.css_value(css), 'Subsection With "Quote"') -@step('I have set a release date and due date in different years$') -def test_have_set_dates_in_different_years(step): - set_date_and_time('input#start_date', '12/25/2011', 'input#start_time', '03:00') - world.css_click('.set-date') - # Use a year in the past so that current year will always be different. - set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00') +@step('I set the subsection release date to ([0-9/-]+)( [0-9:]+)?') +def set_subsection_release_date(_step, datestring, timestring): + if hasattr(timestring, "strip"): + timestring = timestring.strip() + if not timestring: + timestring = "00:00" + set_date_and_time( + 'input#start_date', datestring, + 'input#start_time', timestring) + + +@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?') +def set_subsection_due_date(_step, datestring, timestring): + if hasattr(timestring, "strip"): + timestring = timestring.strip() + if not timestring: + timestring = "00:00" + if not world.css_visible('input#due_date'): + world.css_click('.due-date-input .set-date') + set_date_and_time( + 'input#due_date', datestring, + 'input#due_time', timestring) @step('I mark it as Homework$') @@ -72,6 +88,11 @@ def i_see_it_marked__as_homework(step): assert_equal(world.css_value(".status-label"), 'Homework') +@step('I click the link to sync release date to section') +def click_sync_release_date(step): + world.css_click('.sync-date') + + ############ ASSERTIONS ################### @@ -91,16 +112,25 @@ def the_subsection_does_not_exist(step): assert world.browser.is_element_not_present_by_css(css) -@step('I see the correct dates$') -def i_see_the_correct_dates(step): - assert_equal('12/25/2011', get_date('input#start_date')) - assert_equal('03:00', get_date('input#start_time')) - assert_equal('01/02/2012', get_date('input#due_date')) - assert_equal('04:00', get_date('input#due_time')) +@step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?') +def i_see_subsection_release(_step, datestring, timestring): + if hasattr(timestring, "strip"): + timestring = timestring.strip() + assert_equal(datestring, get_date('input#start_date')) + if timestring: + assert_equal(timestring, get_date('input#start_time')) + + +@step('I see the subsection due date is ([0-9/-]+)( [0-9:]+)?') +def i_see_subsection_due(_step, datestring, timestring): + if hasattr(timestring, "strip"): + timestring = timestring.strip() + assert_equal(datestring, get_date('input#due_date')) + if timestring: + assert_equal(timestring, get_date('input#due_time')) ############ HELPER METHODS ################### - def get_date(css): return world.css_find(css).first.value.strip() diff --git a/cms/djangoapps/contentstore/features/textbooks.py b/cms/djangoapps/contentstore/features/textbooks.py index ca135d9725..d9c08ec6eb 100644 --- a/cms/djangoapps/contentstore/features/textbooks.py +++ b/cms/djangoapps/contentstore/features/textbooks.py @@ -11,8 +11,8 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT @step(u'I go to the textbooks page') def go_to_uploads(_step): world.click_course_content() - menu_css = 'li.nav-course-courseware-textbooks' - world.css_find(menu_css).click() + menu_css = 'li.nav-course-courseware-textbooks a' + world.css_click(menu_css) @step(u'I should see a message telling me to create a new textbook') @@ -45,6 +45,8 @@ def click_new_textbook(_step, on): def name_textbook(_step, name): input_css = ".textbook input[name=textbook-name]" world.css_fill(input_css, name) + if world.is_firefox(): + world.trigger_event(input_css) @step(u'I name the (first|second|third) chapter "([^"]*)"') @@ -52,6 +54,8 @@ def name_chapter(_step, ordinal, name): index = ["first", "second", "third"].index(ordinal) input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1) world.css_fill(input_css, name) + if world.is_firefox(): + world.trigger_event(input_css) @step(u'I type in "([^"]*)" for the (first|second|third) chapter asset') @@ -59,6 +63,8 @@ def asset_chapter(_step, name, ordinal): index = ["first", "second", "third"].index(ordinal) input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1) world.css_fill(input_css, name) + if world.is_firefox(): + world.trigger_event(input_css) @step(u'I click the Upload Asset link for the (first|second|third) chapter') diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index df63b26b3b..a989d6c07f 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -9,13 +9,11 @@ import random import os TEST_ROOT = settings.COMMON_TEST_DATA_ROOT -HTTP_PREFIX = "http://localhost:%s" % settings.LETTUCE_SERVER_PORT - @step(u'I go to the files and uploads page') def go_to_uploads(_step): menu_css = 'li.nav-course-courseware' - uploads_css = 'li.nav-course-courseware-uploads' + uploads_css = 'li.nav-course-courseware-uploads a' world.css_click(menu_css) world.css_click(uploads_css) @@ -24,13 +22,10 @@ def go_to_uploads(_step): def upload_file(_step, file_name): upload_css = 'a.upload-button' world.css_click(upload_css) - - file_css = 'input.file-input' - upload = world.css_find(file_css) #uploading the file itself path = os.path.join(TEST_ROOT, 'uploads/', file_name) - upload._element.send_keys(os.path.abspath(path)) - + world.browser.execute_script("$('input.file-input').css('display', 'block')") + world.browser.attach_file('file', os.path.abspath(path)) close_css = 'a.close-button' world.css_click(close_css) @@ -80,6 +75,9 @@ def check_download(_step, file_name): r = get_file(file_name) downloaded_text = r.text assert cur_text == downloaded_text + #resetting the file back to its original state + with open(os.path.abspath(path), 'w') as cur_file: + cur_file.write("This is an arbitrary file for testing uploads") @step(u'I modify "([^"]*)"$') @@ -109,6 +107,8 @@ def get_file(file_name): index = get_index(file_name) assert index != -1 - url_css = 'input.embeddable-xml-input' - url = world.css_find(url_css)[index].value - return requests.get(HTTP_PREFIX + url) + url_css = 'a.filename' + def get_url(): + return world.css_find(url_css)[index]._element.get_attribute('href') + url = world.retry_on_exception(get_url) + return requests.get(url) diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index 6113f42c91..ad3229ab53 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -22,3 +22,19 @@ def set_show_captions(step, setting): world.wait_for(lambda _driver: world.css_visible('a.save-button')) world.browser.select('Show Captions', setting) world.css_click('a.save-button') + + +@step('I see the correct videoalpha settings and default values$') +def correct_videoalpha_settings(_step): + world.verify_all_setting_entries([['Display Name', 'Video Alpha', False], + ['Download Track', '', False], + ['Download Video', '', False], + ['End Time', '0', False], + ['HTML5 Subtitles', '', False], + ['Show Captions', 'True', False], + ['Start Time', '0', False], + ['Video Sources', '', False], + ['Youtube ID', 'OEoXaMPEzfM', False], + ['Youtube ID for .75x speed', '', False], + ['Youtube ID for 1.25x speed', '', False], + ['Youtube ID for 1.5x speed', '', False]]) diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index e4caa70ef6..634bb8a17f 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -22,3 +22,33 @@ Feature: Video Component Given I have created a Video component And I have toggled captions Then when I view the video it does show the captions + + # Video Alpha Features will work in Firefox only when Firefox is the active window + Scenario: Autoplay is disabled in Studio for Video Alpha + Given I have created a Video Alpha component + Then when I view the videoalpha it does not have autoplay enabled + + Scenario: User can view Video Alpha metadata + Given I have created a Video Alpha component + And I edit the component + Then I see the correct videoalpha settings and default values + + Scenario: User can modify Video Alpha display name + Given I have created a Video Alpha component + And I edit the component + Then I can modify the display name + And my videoalpha display name change is persisted on save + + Scenario: Video Alpha captions are hidden when "show captions" is false + Given I have created a Video Alpha component + And I have set "show captions" to False + Then when I view the videoalpha it does not show the captions + + Scenario: Video Alpha captions are shown when "show captions" is true + Given I have created a Video Alpha component + And I have set "show captions" to True + Then when I view the videoalpha it does show the captions + + Scenario: Video data is shown correctly + Given I have created a video with only XML data + Then the correct Youtube video is shown diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index a6a362befc..e27ca28eb7 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -1,13 +1,17 @@ #pylint: disable=C0111 from lettuce import world, step +from terrain.steps import reload_the_page +from xmodule.modulestore import Location +from contentstore.utils import get_modulestore + ############### ACTIONS #################### -@step('when I view the video it does not have autoplay enabled') -def does_not_autoplay(_step): - assert world.css_find('.video')[0]['data-autoplay'] == 'False' +@step('when I view the (.*) it does not have autoplay enabled') +def does_not_autoplay(_step, video_type): + assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False' assert world.css_has_class('.video_control', 'play') @@ -29,5 +33,55 @@ def hide_or_show_captions(step, shown): # 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() + # mouse_out is not implemented on firefox with selenium + if not world.is_firefox: + button.mouse_out() world.css_click(button_css) + +@step('I edit the component') +def i_edit_the_component(_step): + world.edit_component() + + +@step('my videoalpha display name change is persisted on save') +def videoalpha_name_persisted(step): + world.css_click('a.save-button') + reload_the_page(step) + world.edit_component() + world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True) + + +@step('I have created a video with only XML data') +def xml_only_video(step): + # Create a new video *without* metadata. This requires a certain + # amount of rummaging to make sure all the correct data is present + step.given('I have clicked the new unit button') + + # Wait for the new unit to be created and to load the page + world.wait(1) + + location = world.scenario_dict['COURSE'].location + store = get_modulestore(location) + + parent_location = store.get_items(Location(category='vertical', revision='draft'))[0].location + + youtube_id = 'ABCDEFG' + world.scenario_dict['YOUTUBE_ID'] = youtube_id + + # Create a new Video component, but ensure that it doesn't have + # metadata. This allows us to test that we are correctly parsing + # out XML + video = world.ItemFactory.create( + parent_location=parent_location, + category='video', + data='' % youtube_id + ) + + # Refresh to see the new video + reload_the_page(step) + + +@step('The correct Youtube video is shown') +def the_youtube_video_is_shown(_step): + ele = world.css_find('.video').first + assert ele['data-youtube-id-1-0'] == world.scenario_dict['YOUTUBE_ID'] diff --git a/cms/djangoapps/contentstore/management/commands/clone.py b/cms/djangoapps/contentstore/management/commands/clone_course.py similarity index 54% rename from cms/djangoapps/contentstore/management/commands/clone.py rename to cms/djangoapps/contentstore/management/commands/clone_course.py index f20625d7f2..5fffe29543 100644 --- a/cms/djangoapps/contentstore/management/commands/clone.py +++ b/cms/djangoapps/contentstore/management/commands/clone_course.py @@ -12,7 +12,14 @@ from auth.authz import _copy_course_group # # To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3 # +from request_cache.middleware import RequestCache +from django.core.cache import get_cache +# +# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1 +# + +CACHE = get_cache('mongo_metadata_inheritance') class Command(BaseCommand): """Clone a MongoDB-backed course to another location""" @@ -21,19 +28,27 @@ class Command(BaseCommand): def handle(self, *args, **options): "Execute the command" if len(args) != 2: - raise CommandError("clone requires two arguments: ") + raise CommandError("clone requires two arguments: ") - source_location_str = args[0] - dest_location_str = args[1] + source_course_id = args[0] + dest_course_id = args[1] mstore = modulestore('direct') cstore = contentstore() - print("Cloning course {0} to {1}".format(source_location_str, dest_location_str)) + mstore.metadata_inheritance_cache_subsystem = CACHE + mstore.request_cache = RequestCache.get_request_cache() + org, course_num, run = dest_course_id.split("/") + mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num)) - source_location = CourseDescriptor.id_to_location(source_location_str) - dest_location = CourseDescriptor.id_to_location(dest_location_str) + print("Cloning course {0} to {1}".format(source_course_id, dest_course_id)) + + source_location = CourseDescriptor.id_to_location(source_course_id) + dest_location = CourseDescriptor.id_to_location(dest_course_id) if clone_course(mstore, cstore, source_location, dest_location): + # be sure to recompute metadata inheritance after all those updates + mstore.refresh_cached_metadata_inheritance_tree(dest_location) + print("copying User permissions...") _copy_course_group(source_location, dest_location) diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index 5aafe9f8a6..4d8c4eda55 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -9,12 +9,14 @@ from xmodule.course_module import CourseDescriptor from .prompt import query_yes_no from auth.authz import _delete_course_group +from request_cache.middleware import RequestCache +from django.core.cache import get_cache # # To run from command line: rake cms:delete_course LOC=MITx/111/Foo1 # - +CACHE = get_cache('mongo_metadata_inheritance') class Command(BaseCommand): help = '''Delete a MongoDB backed course''' @@ -22,7 +24,7 @@ class Command(BaseCommand): if len(args) != 1 and len(args) != 2: raise CommandError("delete_course requires one or more arguments: |commit|") - loc_str = args[0] + course_id = args[0] commit = False if len(args) == 2: @@ -34,9 +36,14 @@ class Command(BaseCommand): ms = modulestore('direct') cs = contentstore() - if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"): + ms.metadata_inheritance_cache_subsystem = CACHE + ms.request_cache = RequestCache.get_request_cache() + org, course_num, run = course_id.split("/") + ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num)) + + if query_yes_no("Deleting course {0}. Confirm?".format(course_id), default="no"): if query_yes_no("Are you sure. This action cannot be undone!", default="no"): - loc = CourseDescriptor.id_to_location(loc_str) + loc = CourseDescriptor.id_to_location(course_id) if delete_course(ms, cs, loc, commit): print 'removing User permissions from course....' # in the django layer, we need to remove all the user permissions groups associated with this course diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index cde40d502e..b627237729 100644 --- a/cms/djangoapps/contentstore/tests/test_assets.py +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -10,6 +10,8 @@ from unittest import TestCase, skip from .utils import CourseTestCase from django.core.urlresolvers import reverse from contentstore.views import assets +from xmodule.contentstore.content import StaticContent +from xmodule.modulestore import Location class AssetsTestCase(CourseTestCase): @@ -35,6 +37,11 @@ class AssetsTestCase(CourseTestCase): content = json.loads(resp.content) self.assertIsInstance(content, list) + def test_static_url_generation(self): + location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg']) + path = StaticContent.get_static_path_from_location(location) + self.assertEquals(path, '/static/my_file_name.jpg') + class UploadTestCase(CourseTestCase): """ diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 4c9fcf7f81..09413be7b7 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -49,7 +49,7 @@ import datetime from pytz import UTC from uuid import uuid4 from pymongo import MongoClient - +from student.views import is_enrolled_in_course TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex @@ -303,6 +303,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): num_drafts = self._get_draft_counts(course) self.assertEqual(num_drafts, 1) + def test_no_static_link_rewrites_on_import(self): + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['toy']) + + handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course_info', 'handouts', None])) + self.assertIn('/static/', handouts.data) + + handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None])) + self.assertIn('/static/', handouts.data) + def test_import_textbook_as_content_element(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['toy']) @@ -607,8 +617,26 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): } module_store = modulestore('direct') + draft_store = modulestore('draft') import_from_xml(module_store, 'common/test/data/', ['toy']) + source_course_id = 'edX/toy/2012_Fall' + dest_course_id = 'MITx/999/2013_Spring' + source_location = CourseDescriptor.id_to_location(source_course_id) + dest_location = CourseDescriptor.id_to_location(dest_course_id) + + # get a vertical (and components in it) to put into 'draft' + # this is to assert that draft content is also cloned over + vertical = module_store.get_instance(source_course_id, Location([ + source_location.tag, source_location.org, source_location.course, 'vertical', 'vertical_test', None]), depth=1) + + draft_store.convert_to_draft(vertical.location) + for child in vertical.get_children(): + draft_store.convert_to_draft(child.location) + + items = module_store.get_items(Location([source_location.tag, source_location.org, source_location.course, None, None, 'draft'])) + self.assertGreater(len(items), 0) + resp = self.client.post(reverse('create_new_course'), course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) @@ -616,22 +644,105 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): content_store = contentstore() - source_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') - dest_location = CourseDescriptor.id_to_location('MITx/999/2013_Spring') - + # now do the actual cloning clone_course(module_store, content_store, source_location, dest_location) + # first assert that all draft content got cloned as well + items = module_store.get_items(Location([source_location.tag, source_location.org, source_location.course, None, None, 'draft'])) + self.assertGreater(len(items), 0) + clone_items = module_store.get_items(Location([dest_location.tag, dest_location.org, dest_location.course, None, None, 'draft'])) + self.assertGreater(len(clone_items), 0) + self.assertEqual(len(items), len(clone_items)) + # now loop through all the units in the course and verify that the clone can render them, which # means the objects are at least present - items = module_store.get_items(Location(['i4x', 'edX', 'toy', 'poll_question', None])) + items = module_store.get_items(Location([source_location.tag, source_location.org, source_location.course, None, None])) self.assertGreater(len(items), 0) - clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'poll_question', None])) + clone_items = module_store.get_items(Location([dest_location.tag, dest_location.org, dest_location.course, None, None])) self.assertGreater(len(clone_items), 0) + for descriptor in items: - new_loc = descriptor.location.replace(org='MITx', course='999') + source_item = module_store.get_instance(source_course_id, descriptor.location) + if descriptor.location.category == 'course': + new_loc = descriptor.location.replace(org=dest_location.org, course=dest_location.course, name='2013_Spring') + else: + new_loc = descriptor.location.replace(org=dest_location.org, course=dest_location.course) print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) - resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) - self.assertEqual(resp.status_code, 200) + lookup_item = module_store.get_item(new_loc) + + # we want to assert equality between the objects, but we know the locations + # differ, so just make them equal for testing purposes + source_item.location = new_loc + if hasattr(source_item, 'data') and hasattr(lookup_item, 'data'): + self.assertEqual(source_item.data, lookup_item.data) + + # also make sure that metadata was cloned over and filtered with own_metadata, i.e. inherited + # values were not explicitly set + self.assertEqual(own_metadata(source_item), own_metadata(lookup_item)) + + # check that the children are as expected + self.assertEqual(source_item.has_children, lookup_item.has_children) + if source_item.has_children: + expected_children = [] + for child_loc_url in source_item.children: + child_loc = Location(child_loc_url) + child_loc = child_loc._replace( + tag=dest_location.tag, + org=dest_location.org, + course=dest_location.course + ) + expected_children.append(child_loc.url()) + self.assertEqual(expected_children, lookup_item.children) + + def test_portable_link_rewrites_during_clone_course(self): + course_data = { + 'org': 'MITx', + 'number': '999', + 'display_name': 'Robot Super Course', + 'run': '2013_Spring' + } + + module_store = modulestore('direct') + draft_store = modulestore('draft') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy']) + + source_course_id = 'edX/toy/2012_Fall' + dest_course_id = 'MITx/999/2013_Spring' + source_location = CourseDescriptor.id_to_location(source_course_id) + dest_location = CourseDescriptor.id_to_location(dest_course_id) + + # let's force a non-portable link in the clone source + # as a final check, make sure that any non-portable links are rewritten during cloning + html_module_location = Location([ + source_location.tag, source_location.org, source_location.course, 'html', 'nonportable']) + html_module = module_store.get_instance(source_location.course_id, html_module_location) + + self.assertTrue(isinstance(html_module.data, basestring)) + new_data = html_module.data.replace('/static/', '/c4x/{0}/{1}/asset/'.format( + source_location.org, source_location.course)) + module_store.update_item(html_module_location, new_data) + + html_module = module_store.get_instance(source_location.course_id, html_module_location) + self.assertEqual(new_data, html_module.data) + + # create the destination course + + resp = self.client.post(reverse('create_new_course'), course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/2013_Spring') + + # do the actual cloning + clone_course(module_store, content_store, source_location, dest_location) + + # make sure that any non-portable links are rewritten during cloning + html_module_location = Location([ + dest_location.tag, dest_location.org, dest_location.course, 'html', 'nonportable']) + html_module = module_store.get_instance(dest_location.course_id, html_module_location) + + self.assertIn('/static/foo.jpg', html_module.data) def test_illegal_draft_crud_ops(self): draft_store = modulestore('draft') @@ -660,6 +771,22 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) + def test_rewrite_nonportable_links_on_import(self): + module_store = modulestore('direct') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store) + + # first check a static asset link + html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable']) + html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location) + self.assertIn('/static/foo.jpg', html_module.data) + + # then check a intra courseware link + html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable_link']) + html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location) + self.assertIn('/jump_to_id/nonportable_link', html_module.data) + def test_delete_course(self): """ This test will import a course, make a draft item, and delete it. This will also assert that the @@ -1041,12 +1168,18 @@ class ContentStoreTest(ModuleStoreTestCase): data = parse_json(resp) self.assertNotIn('ErrMsg', data) self.assertEqual(data['id'], 'i4x://MITx/{0}/course/2013_Spring'.format(test_course_data['number'])) + # Verify that the creator is now registered in the course. + self.assertTrue(is_enrolled_in_course(self.user, self._get_course_id(test_course_data))) return test_course_data def test_create_course_check_forum_seeding(self): """Test new course creation and verify forum seeding """ test_course_data = self.assert_created_course(number_suffix=uuid4().hex) - self.assertTrue(are_permissions_roles_seeded('MITx/{0}/2013_Spring'.format(test_course_data['number']))) + self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data))) + + def _get_course_id(self, test_course_data): + """Returns the course ID (org/number/run).""" + return "{org}/{number}/{run}".format(**test_course_data) def test_create_course_duplicate_course(self): """Test new course creation - error path""" @@ -1057,10 +1190,15 @@ class ContentStoreTest(ModuleStoreTestCase): """ Checks that the course did not get created """ + course_id = self._get_course_id(self.course_data) + initially_enrolled = is_enrolled_in_course(self.user, course_id) resp = self.client.post(reverse('create_new_course'), self.course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertEqual(data['ErrMsg'], error_message) + # One test case involves trying to create the same course twice. Hence for that course, + # the user will be enrolled. In the other cases, initially_enrolled will be False. + self.assertEqual(initially_enrolled, is_enrolled_in_course(self.user, course_id)) def test_create_course_duplicate_number(self): """Test new course creation - error path""" @@ -1141,7 +1279,7 @@ class ContentStoreTest(ModuleStoreTestCase): resp = self.client.get(reverse('index')) self.assertContains( resp, - 'Robot Super Educational Course', + '

Robot Super Educational Course

', status_code=200, html=True ) @@ -1313,6 +1451,31 @@ class ContentStoreTest(ModuleStoreTestCase): json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) + def test_import_into_new_course_id(self): + module_store = modulestore('direct') + target_location = Location(['i4x', 'MITx', '999', 'course', '2013_Spring']) + + course_data = { + 'org': target_location.org, + 'number': target_location.course, + 'display_name': 'Robot Super Course', + 'run': target_location.name + } + + resp = self.client.post(reverse('create_new_course'), course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], target_location.url()) + + import_from_xml(module_store, 'common/test/data/', ['simple'], target_location_namespace=target_location) + + modules = module_store.get_items(Location([ + target_location.tag, target_location.org, target_location.course, None, None, None])) + + # we should have a number of modules in there + # we can't specify an exact number since it'll always be changing + self.assertGreater(len(modules), 10) + def test_import_metadata_with_attempts_empty_string(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['simple']) diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index 4b9dcf487f..a9216da612 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -1,8 +1,12 @@ +""" +Tests for contentstore/views/user.py. +""" import json from .utils import CourseTestCase from django.contrib.auth.models import User, Group from django.core.urlresolvers import reverse from auth.authz import get_course_groupname_for_role +from student.views import is_enrolled_in_course class UsersTestCase(CourseTestCase): @@ -90,6 +94,7 @@ class UsersTestCase(CourseTestCase): # no content: should not be in any roles self.assertNotIn(self.staff_groupname, groups) self.assertNotIn(self.inst_groupname, groups) + self.assert_not_enrolled() def test_detail_post_staff(self): resp = self.client.post( @@ -104,6 +109,7 @@ class UsersTestCase(CourseTestCase): groups = [g.name for g in ext_user.groups.all()] self.assertIn(self.staff_groupname, groups) self.assertNotIn(self.inst_groupname, groups) + self.assert_enrolled() def test_detail_post_staff_other_inst(self): inst_group, _ = Group.objects.get_or_create(name=self.inst_groupname) @@ -122,6 +128,7 @@ class UsersTestCase(CourseTestCase): groups = [g.name for g in ext_user.groups.all()] self.assertIn(self.staff_groupname, groups) self.assertNotIn(self.inst_groupname, groups) + self.assert_enrolled() # check that other user is unchanged user = User.objects.get(email=self.user.email) groups = [g.name for g in user.groups.all()] @@ -141,6 +148,7 @@ class UsersTestCase(CourseTestCase): groups = [g.name for g in ext_user.groups.all()] self.assertNotIn(self.staff_groupname, groups) self.assertIn(self.inst_groupname, groups) + self.assert_enrolled() def test_detail_post_missing_role(self): resp = self.client.post( @@ -152,6 +160,7 @@ class UsersTestCase(CourseTestCase): self.assert4XX(resp.status_code) result = json.loads(resp.content) self.assertIn("error", result) + self.assert_not_enrolled() def test_detail_post_bad_json(self): resp = self.client.post( @@ -163,6 +172,7 @@ class UsersTestCase(CourseTestCase): self.assert4XX(resp.status_code) result = json.loads(resp.content) self.assertIn("error", result) + self.assert_not_enrolled() def test_detail_post_no_json(self): resp = self.client.post( @@ -176,6 +186,7 @@ class UsersTestCase(CourseTestCase): groups = [g.name for g in ext_user.groups.all()] self.assertIn(self.staff_groupname, groups) self.assertNotIn(self.inst_groupname, groups) + self.assert_enrolled() def test_detail_delete_staff(self): group, _ = Group.objects.get_or_create(name=self.staff_groupname) @@ -317,3 +328,57 @@ class UsersTestCase(CourseTestCase): ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] self.assertIn(self.staff_groupname, groups) + + def test_user_not_initially_enrolled(self): + # Verify that ext_user is not enrolled in the new course before being added as a staff member. + self.assert_not_enrolled() + + def test_remove_staff_does_not_unenroll(self): + # Add user with staff permissions. + self.client.post( + self.detail_url, + data=json.dumps({"role": "staff"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert_enrolled() + # Remove user from staff on course. Will not un-enroll them from the course. + resp = self.client.delete( + self.detail_url, + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + self.assert_enrolled() + + def test_staff_to_instructor_still_enrolled(self): + # Add user with staff permission. + self.client.post( + self.detail_url, + data=json.dumps({"role": "staff"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert_enrolled() + # Now add with instructor permission. Verify still enrolled. + resp = self.client.post( + self.detail_url, + data=json.dumps({"role": "instructor"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + self.assert_enrolled() + + def assert_not_enrolled(self): + """ Asserts that self.ext_user is not enrolled in self.course. """ + self.assertFalse( + is_enrolled_in_course(self.ext_user, self.course.location.course_id), + 'Did not expect ext_user to be enrolled in course' + ) + + def assert_enrolled(self): + """ Asserts that self.ext_user is enrolled in self.course. """ + self.assertTrue( + is_enrolled_in_course(self.ext_user, self.course.location.course_id), + 'User ext_user should have been enrolled in the course' + ) diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 1f2a4185a3..0cbc82cbf1 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -1,4 +1,5 @@ from django.test.client import Client +from django.core.cache import cache from django.core.urlresolvers import reverse from .utils import parse_json, user, registration @@ -79,6 +80,8 @@ class AuthTestCase(ContentStoreTestCase): self.pw = 'xyz' self.username = 'testuser' self.client = Client() + # clear the cache so ratelimiting won't affect these tests + cache.clear() def check_page_get(self, url, expected): resp = self.client.get(url) @@ -119,6 +122,18 @@ class AuthTestCase(ContentStoreTestCase): # Now login should work self.login(self.email, self.pw) + def test_login_ratelimited(self): + # try logging in 30 times, the default limit in the number of failed + # login attempts in one 5 minute period before the rate gets limited + for i in xrange(30): + resp = self._login(self.email, 'wrong_password{0}'.format(i)) + self.assertEqual(resp.status_code, 200) + resp = self._login(self.email, 'wrong_password') + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertFalse(data['success']) + self.assertIn('Too many failed login attempts.', data['value']) + def test_login_link_on_activation_age(self): self.create_account(self.username, self.email, self.pw) # we want to test the rendering of the activation page when the user isn't logged in diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index e4201cddd7..94bfa55b58 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -3,6 +3,7 @@ import json import os import tarfile import shutil +import cgi from tempfile import mkdtemp from path import path @@ -27,7 +28,7 @@ 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 xmodule.exceptions import NotFoundError, SerializationError from .access import get_location_and_verify_access from util.json_request import JsonResponse @@ -105,6 +106,7 @@ def asset_index(request, org, course, name): asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name']) display_info['url'] = StaticContent.get_url_path_from_location(asset_location) + display_info['portable_url'] = StaticContent.get_static_path_from_location(asset_location) # note, due to the schema change we may not have a 'thumbnail_location' in the result set _thumbnail_location = asset.get('thumbnail_location', None) @@ -187,12 +189,12 @@ def upload_asset(request, org, course, coursename): response_payload = {'displayname': content.name, 'uploadDate': get_default_time_display(readback.last_modified_at), 'url': StaticContent.get_url_path_from_location(content.location), + 'portable_url': StaticContent.get_static_path_from_location(content.location), 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None, 'msg': 'Upload completed' } response = JsonResponse(response_payload) - response['asset_url'] = StaticContent.get_url_path_from_location(content.location) return response @@ -313,6 +315,8 @@ def import_course(request, org, course, name): create_all_course_groups(request.user, course_items[0].location) + logging.debug('created all course groups at {0}'.format(course_items[0].location)) + return HttpResponse(json.dumps({'Status': 'OK'})) else: course_module = modulestore().get_item(location) @@ -335,16 +339,59 @@ def generate_export_course(request, org, course, name): the course """ location = get_location_and_verify_access(request, org, course, name) - + course_module = modulestore().get_instance(location.course_id, location) loc = Location(location) export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") root_dir = path(mkdtemp()) - # export out to a tempdir - logging.debug('root = {0}'.format(root_dir)) + try: + export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore()) + except SerializationError, e: + unit = None + failed_item = None + parent = None + try: + failed_item = modulestore().get_instance(course_module.location.course_id, e.location) + parent_locs = modulestore().get_parent_locations(failed_item.location, course_module.location.course_id) - export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore()) + if len(parent_locs) > 0: + parent = modulestore().get_item(parent_locs[0]) + if parent.location.category == 'vertical': + unit = parent + except: + # if we have a nested exception, then we'll show the more generic error message + pass + + return render_to_response('export.html', { + 'context_course': course_module, + 'successful_import_redirect_url': '', + 'in_err': True, + 'raw_err_msg': str(e), + 'failed_module': failed_item, + 'unit': unit, + 'edit_unit_url': reverse('edit_unit', kwargs={ + 'location': parent.location + }) if parent else '', + 'course_home_url': reverse('course_index', kwargs={ + 'org': org, + 'course': course, + 'name': name + }) + }) + except Exception, e: + return render_to_response('export.html', { + 'context_course': course_module, + 'successful_import_redirect_url': '', + 'in_err': True, + 'unit': None, + 'raw_err_msg': str(e), + 'course_home_url': reverse('course_index', kwargs={ + 'org': org, + 'course': course, + 'name': name + }) + }) logging.debug('tar file being generated at {0}'.format(export_file.name)) tar_file = tarfile.open(name=export_file.name, mode='w:gz') diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index e68210dea4..8ac1d223cb 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -44,6 +44,8 @@ from .component import ( from django_comment_common.utils import seed_permissions_roles +from student.views import enroll_in_course + from xmodule.html_module import AboutDescriptor __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', @@ -162,6 +164,9 @@ def create_new_course(request): # seed the forums seed_permissions_roles(new_course.location.course_id) + # auto-enroll the course creator in the course so that "View Live" will work. + enroll_in_course(request.user, new_course.location.course_id) + return JsonResponse({'id': new_course.location.url()}) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index e1c75bad0f..8b92107e88 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -13,6 +13,7 @@ from django.core.context_processors import csrf from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location +from xmodule.error_module import ErrorDescriptor from contentstore.utils import get_lms_link_for_item from util.json_request import JsonResponse from auth.authz import ( @@ -23,6 +24,8 @@ from course_creators.views import ( from .access import has_access +from student.views import enroll_in_course + @login_required @ensure_csrf_cookie @@ -54,10 +57,13 @@ def index(request): course.location, course_id=course.location.course_id, ), + course.display_org_with_default, + course.display_number_with_default, + course.location.name ) return render_to_response('index.html', { - 'courses': [format_course_for_view(c) for c in courses], + 'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)], 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), @@ -179,7 +185,7 @@ def course_team_user(request, org, course, name, email): return JsonResponse() # all other operations require the requesting user to specify a role - if request.META.get("CONTENT_TYPE", "") == "application/json" and request.body: + if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body: try: payload = json.loads(request.body) except: @@ -201,6 +207,8 @@ def course_team_user(request, org, course, name, email): return JsonResponse(msg, 400) user.groups.add(groups["instructor"]) user.save() + # auto-enroll the course creator in the course so that "View Live" will work. + enroll_in_course(user, location.course_id) elif role == "staff": # if we're trying to downgrade a user from "instructor" to "staff", # make sure we have at least one other instructor in the course team. @@ -214,6 +222,9 @@ def course_team_user(request, org, course, name, email): user.groups.remove(groups["instructor"]) user.groups.add(groups["staff"]) user.save() + # auto-enroll the course creator in the course so that "View Live" will work. + enroll_in_course(user, location.course_id) + return JsonResponse() diff --git a/cms/djangoapps/course_creators/admin.py b/cms/djangoapps/course_creators/admin.py index 65473d8bde..df2baa1aa2 100644 --- a/cms/djangoapps/course_creators/admin.py +++ b/cms/djangoapps/course_creators/admin.py @@ -2,13 +2,15 @@ django admin page for the course creators table """ -from course_creators.models import CourseCreator, update_creator_state +from course_creators.models import CourseCreator, update_creator_state, send_user_notification, send_admin_notification from course_creators.views import update_course_creator_group -from django.contrib import admin +from ratelimitbackend import admin from django.conf import settings from django.dispatch import receiver from mitxmako.shortcuts import render_to_string +from django.core.mail import send_mail +from smtplib import SMTPException import logging @@ -28,12 +30,12 @@ class CourseCreatorAdmin(admin.ModelAdmin): """ # Fields to display on the overview page. - list_display = ['user', get_email, 'state', 'state_changed', 'note'] - readonly_fields = ['user', 'state_changed'] + list_display = ['username', get_email, 'state', 'state_changed', 'note'] + readonly_fields = ['username', 'state_changed'] # Controls the order on the edit form (without this, read-only fields appear at the end). fieldsets = ( (None, { - 'fields': ['user', 'state', 'state_changed', 'note'] + 'fields': ['username', 'state', 'state_changed', 'note'] }), ) # Fields that filtering support @@ -43,6 +45,16 @@ class CourseCreatorAdmin(admin.ModelAdmin): # Turn off the action bar (we have no bulk actions) actions = None + def username(self, inst): + """ + Returns the username for a given user. + + Implemented to make sorting by username instead of by user object. + """ + return inst.user.username + + username.admin_order_field = 'user__username' + def has_add_permission(self, request): return False @@ -70,7 +82,16 @@ def update_creator_group_callback(sender, **kwargs): updated_state = kwargs['state'] update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED) - studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL','') + +@receiver(send_user_notification, sender=CourseCreator) +def send_user_notification_callback(sender, **kwargs): + """ + Callback for notifying user about course creator status change. + """ + user = kwargs['user'] + updated_state = kwargs['state'] + + studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '') context = {'studio_request_email': studio_request_email} subject = render_to_string('emails/course_creator_subject.txt', context) @@ -88,3 +109,29 @@ def update_creator_group_callback(sender, **kwargs): user.email_user(subject, message, studio_request_email) except: log.warning("Unable to send course creator status e-mail to %s", user.email) + + +@receiver(send_admin_notification, sender=CourseCreator) +def send_admin_notification_callback(sender, **kwargs): + """ + Callback for notifying admin of a user in the 'pending' state. + """ + user = kwargs['user'] + + studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '') + context = {'user_name': user.username, 'user_email': user.email} + + subject = render_to_string('emails/course_creator_admin_subject.txt', context) + subject = ''.join(subject.splitlines()) + message = render_to_string('emails/course_creator_admin_user_pending.txt', context) + + try: + send_mail( + subject, + message, + studio_request_email, + [studio_request_email], + fail_silently=False + ) + except SMTPException: + log.warning("Failure sending 'pending state' e-mail for %s to %s", user.email, studio_request_email) diff --git a/cms/djangoapps/course_creators/models.py b/cms/djangoapps/course_creators/models.py index ba434c9140..5138916c88 100644 --- a/cms/djangoapps/course_creators/models.py +++ b/cms/djangoapps/course_creators/models.py @@ -10,7 +10,13 @@ from django.utils import timezone from django.utils.translation import ugettext as _ # A signal that will be sent when users should be added or removed from the creator group -update_creator_state = Signal(providing_args=["caller", "user", "add"]) +update_creator_state = Signal(providing_args=["caller", "user", "state"]) + +# A signal that will be sent when admin should be notified of a pending user request +send_admin_notification = Signal(providing_args=["user"]) + +# A signal that will be sent when user should be notified of change in course creator privileges +send_user_notification = Signal(providing_args=["user", "state"]) class CourseCreator(models.Model): @@ -60,9 +66,10 @@ def post_save_callback(sender, **kwargs): # We only wish to modify the state_changed time if the state has been modified. We don't wish to # modify it for changes to the notes field. if instance.state != instance.orig_state: + granted_state_change = instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED # If either old or new state is 'granted', we must manipulate the course creator # group maintained by authz. That requires staff permissions (stored admin). - if instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED: + if granted_state_change: assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group' update_creator_state.send( sender=sender, @@ -71,6 +78,22 @@ def post_save_callback(sender, **kwargs): state=instance.state ) + # If user has been denied access, granted access, or previously granted access has been + # revoked, send a notification message to the user. + if instance.state == CourseCreator.DENIED or granted_state_change: + send_user_notification.send( + sender=sender, + user=instance.user, + state=instance.state + ) + + # If the user has gone into the 'pending' state, send a notification to interested admin. + if instance.state == CourseCreator.PENDING: + send_admin_notification.send( + sender=sender, + user=instance.user + ) + instance.state_changed = timezone.now() instance.orig_state = instance.state instance.save() diff --git a/cms/djangoapps/course_creators/tests/test_admin.py b/cms/djangoapps/course_creators/tests/test_admin.py index 91a28d77ae..aa293e008e 100644 --- a/cms/djangoapps/course_creators/tests/test_admin.py +++ b/cms/djangoapps/course_creators/tests/test_admin.py @@ -11,6 +11,7 @@ import mock from course_creators.admin import CourseCreatorAdmin from course_creators.models import CourseCreator from auth.authz import is_user_in_creator_group +from django.core import mail def mock_render_to_string(template_name, context): @@ -37,21 +38,25 @@ class CourseCreatorAdminTest(TestCase): self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite()) + self.studio_request_email = 'mark@marky.mark' + self.enable_creator_group_patch = { + "ENABLE_CREATOR_GROUP": True, + "STUDIO_REQUEST_EMAIL": self.studio_request_email + } + @mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True)) @mock.patch('django.contrib.auth.models.User.email_user') def test_change_status(self, email_user): """ Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent. """ - STUDIO_REQUEST_EMAIL = 'mark@marky.mark' - - def change_state(state, is_creator): - """ Helper method for changing state """ - self.table_entry.state = state - self.creator_admin.save_model(self.request, self.table_entry, None, True) + + def change_state_and_verify_email(state, is_creator): + """ Changes user state, verifies creator status, and verifies e-mail is sent based on transition """ + self._change_state(state) self.assertEqual(is_creator, is_user_in_creator_group(self.user)) - - context = {'studio_request_email': STUDIO_REQUEST_EMAIL} + + context = {'studio_request_email': self.studio_request_email} if state == CourseCreator.GRANTED: template = 'emails/course_creator_granted.txt' elif state == CourseCreator.DENIED: @@ -61,30 +66,76 @@ class CourseCreatorAdminTest(TestCase): email_user.assert_called_with( mock_render_to_string('emails/course_creator_subject.txt', context), mock_render_to_string(template, context), - STUDIO_REQUEST_EMAIL + self.studio_request_email ) - with mock.patch.dict( - 'django.conf.settings.MITX_FEATURES', - { - "ENABLE_CREATOR_GROUP": True, - "STUDIO_REQUEST_EMAIL": STUDIO_REQUEST_EMAIL - }): + with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch): # User is initially unrequested. self.assertFalse(is_user_in_creator_group(self.user)) - change_state(CourseCreator.GRANTED, True) + change_state_and_verify_email(CourseCreator.GRANTED, True) - change_state(CourseCreator.DENIED, False) + change_state_and_verify_email(CourseCreator.DENIED, False) - change_state(CourseCreator.GRANTED, True) + change_state_and_verify_email(CourseCreator.GRANTED, True) - change_state(CourseCreator.PENDING, False) + change_state_and_verify_email(CourseCreator.PENDING, False) - change_state(CourseCreator.GRANTED, True) + change_state_and_verify_email(CourseCreator.GRANTED, True) - change_state(CourseCreator.UNREQUESTED, False) + change_state_and_verify_email(CourseCreator.UNREQUESTED, False) + + change_state_and_verify_email(CourseCreator.DENIED, False) + + @mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True)) + def test_mail_admin_on_pending(self): + """ + Tests that the admin account is notified when a user is in the 'pending' state. + """ + + def check_admin_message_state(state, expect_sent_to_admin, expect_sent_to_user): + """ Changes user state and verifies e-mail sent to admin address only when pending. """ + mail.outbox = [] + self._change_state(state) + + # If a message is sent to the user about course creator status change, it will be the first + # message sent. Admin message will follow. + base_num_emails = 1 if expect_sent_to_user else 0 + if expect_sent_to_admin: + context = {'user_name': "test_user", 'user_email': 'test_user+courses@edx.org'} + self.assertEquals(base_num_emails + 1, len(mail.outbox), 'Expected admin message to be sent') + sent_mail = mail.outbox[base_num_emails] + self.assertEquals( + mock_render_to_string('emails/course_creator_admin_subject.txt', context), + sent_mail.subject + ) + self.assertEquals( + mock_render_to_string('emails/course_creator_admin_user_pending.txt', context), + sent_mail.body + ) + self.assertEquals(self.studio_request_email, sent_mail.from_email) + self.assertEqual([self.studio_request_email], sent_mail.to) + else: + self.assertEquals(base_num_emails, len(mail.outbox)) + + with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch): + # E-mail message should be sent to admin only when new state is PENDING, regardless of what + # previous state was (unless previous state was already PENDING). + # E-mail message sent to user only on transition into and out of GRANTED state. + check_admin_message_state(CourseCreator.UNREQUESTED, expect_sent_to_admin=False, expect_sent_to_user=False) + check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=True, expect_sent_to_user=False) + check_admin_message_state(CourseCreator.GRANTED, expect_sent_to_admin=False, expect_sent_to_user=True) + check_admin_message_state(CourseCreator.DENIED, expect_sent_to_admin=False, expect_sent_to_user=True) + check_admin_message_state(CourseCreator.GRANTED, expect_sent_to_admin=False, expect_sent_to_user=True) + check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=True, expect_sent_to_user=True) + check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=False, expect_sent_to_user=False) + check_admin_message_state(CourseCreator.DENIED, expect_sent_to_admin=False, expect_sent_to_user=True) + + def _change_state(self, state): + """ Helper method for changing state """ + self.table_entry.state = state + self.creator_admin.save_model(self.request, self.table_entry, None, True) def test_add_permission(self): """ @@ -106,3 +157,18 @@ class CourseCreatorAdminTest(TestCase): self.request.user = self.user self.assertFalse(self.creator_admin.has_change_permission(self.request)) + + def test_rate_limit_login(self): + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}): + post_params = {'username': self.user.username, 'password': 'wrong_password'} + # try logging in 30 times, the default limit in the number of failed + # login attempts in one 5 minute period before the rate gets limited + for _ in xrange(30): + response = self.client.post('/admin/', post_params) + self.assertEquals(response.status_code, 200) + + response = self.client.post('/admin/', post_params) + # Since we are using the default rate limit behavior, we are + # expecting this to return a 403 error to indicate that there have + # been too many attempts + self.assertEquals(response.status_code, 403) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 7c3b883283..78c5dcff33 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -173,7 +173,7 @@ class CourseDetails(object): # the right thing result = None if video_key: - result = '' return result diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index ecd22ed769..a1f5edb153 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -72,6 +72,9 @@ DATABASES = { } } +# Use the auto_auth workflow for creating users and logging them in +MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True + # Include the lettuce app for acceptance testing, including the 'harvest' django-admin command INSTALLED_APPS += ('lettuce.django',) LETTUCE_APPS = ('contentstore',) diff --git a/cms/envs/common.py b/cms/envs/common.py index 155c4d46d8..40084c20ae 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -108,6 +108,11 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.core.context_processors.csrf' ) +# use the ratelimit backend to prevent brute force attacks +AUTHENTICATION_BACKENDS = ( + 'ratelimitbackend.backends.RateLimitModelBackend', +) + LMS_BASE = None #################### CAPA External Code Evaluation ############################# @@ -152,7 +157,10 @@ MIDDLEWARE_CLASSES = ( # Detects user-requested locale from 'accept-language' header in http request 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.transaction.TransactionMiddleware' + 'django.middleware.transaction.TransactionMiddleware', + + # catches any uncaught RateLimitExceptions and returns a 403 instead of a 500 + 'ratelimitbackend.middleware.RateLimitMiddleware', ) ############################ SIGNAL HANDLERS ################################ @@ -188,8 +196,8 @@ STATICFILES_DIRS = [ COMMON_ROOT / "static", PROJECT_ROOT / "static", -# This is how you would use the textbook images locally -# ("book", ENV_ROOT / "book_images") + # This is how you would use the textbook images locally + # ("book", ENV_ROOT / "book_images") ] # Locale/Internationalization diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 0b0a62f05d..42a6f706b6 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -150,14 +150,13 @@ DEBUG_TOOLBAR_PANELS = ( 'debug_toolbar.panels.sql.SQLDebugPanel', 'debug_toolbar.panels.signals.SignalDebugPanel', 'debug_toolbar.panels.logger.LoggingPanel', - 'debug_toolbar_mongo.panel.MongoDebugPanel', # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and # Django=1.3.1/1.4 where requests to views get duplicated (your method gets # hit twice). So you can uncomment when you need to diagnose performance # problems, but you shouldn't leave it on. # 'debug_toolbar.panels.profiling.ProfilingDebugPanel', - ) +) DEBUG_TOOLBAR_CONFIG = { 'INTERCEPT_REDIRECTS': False @@ -165,7 +164,7 @@ DEBUG_TOOLBAR_CONFIG = { # To see stacktraces for MongoDB queries, set this to True. # Stacktraces slow down page loads drastically (for pages with lots of queries). -DEBUG_TOOLBAR_MONGO_STACKTRACES = True +DEBUG_TOOLBAR_MONGO_STACKTRACES = False # disable NPS survey in dev mode MITX_FEATURES['STUDIO_NPS_SURVEY'] = False diff --git a/cms/envs/dev_dbperf.py b/cms/envs/dev_dbperf.py new file mode 100644 index 0000000000..2ea131b69e --- /dev/null +++ b/cms/envs/dev_dbperf.py @@ -0,0 +1,31 @@ +""" +This configuration is to turn on the Django Toolbar stats for DB access stats, for performance analysis +""" + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + +from .dev import * + +DEBUG_TOOLBAR_PANELS = ( + 'debug_toolbar.panels.version.VersionDebugPanel', + 'debug_toolbar.panels.timer.TimerDebugPanel', + 'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel', + 'debug_toolbar.panels.headers.HeaderDebugPanel', + 'debug_toolbar.panels.request_vars.RequestVarsDebugPanel', + 'debug_toolbar.panels.sql.SQLDebugPanel', + 'debug_toolbar.panels.signals.SignalDebugPanel', + 'debug_toolbar.panels.logger.LoggingPanel', + 'debug_toolbar_mongo.panel.MongoDebugPanel' + + # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and + # Django=1.3.1/1.4 where requests to views get duplicated (your method gets + # hit twice). So you can uncomment when you need to diagnose performance + # problems, but you shouldn't leave it on. + # 'debug_toolbar.panels.profiling.ProfilingDebugPanel', +) + +# To see stacktraces for MongoDB queries, set this to True. +# Stacktraces slow down page loads drastically (for pages with lots of queries). +DEBUG_TOOLBAR_MONGO_STACKTRACES = True diff --git a/cms/envs/test.py b/cms/envs/test.py index efc7c5a7ef..4f3b0caee0 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -15,6 +15,7 @@ sessions. Assumes structure: from .common import * import os from path import path +from warnings import filterwarnings # Nose Test Runner INSTALLED_APPS += ('django_nose',) @@ -124,6 +125,9 @@ CACHES = { } } +# hide ratelimit warnings while running tests +filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit') + ################################# CELERY ###################################### CELERY_ALWAYS_EAGER = True diff --git a/cms/static/coffee/fixtures/metadata-list-entry.underscore b/cms/static/coffee/fixtures/metadata-list-entry.underscore new file mode 120000 index 0000000000..78fa4e2000 --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-list-entry.underscore @@ -0,0 +1 @@ +../../../templates/js/metadata-list-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/tabs-edit.html b/cms/static/coffee/fixtures/tabs-edit.html new file mode 100644 index 0000000000..c83a145622 --- /dev/null +++ b/cms/static/coffee/fixtures/tabs-edit.html @@ -0,0 +1,33 @@ +
+
+
+ +
+
+ +
+
+ Transcripts +
+
+ Subtitles +
+
+
+ +
+
+
+ +
+
+ diff --git a/cms/static/coffee/spec/tabs/edit.coffee b/cms/static/coffee/spec/tabs/edit.coffee new file mode 100644 index 0000000000..734e398c74 --- /dev/null +++ b/cms/static/coffee/spec/tabs/edit.coffee @@ -0,0 +1,95 @@ +describe "TabsEditingDescriptor", -> + beforeEach -> + @isInactiveClass = "is-inactive" + @isCurrent = "current" + loadFixtures 'tabs-edit.html' + @descriptor = new TabsEditingDescriptor($('.base_wrapper')) + @html_id = 'test_id' + @tab_0_switch = jasmine.createSpy('tab_0_switch'); + @tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate'); + @tab_1_switch = jasmine.createSpy('tab_1_switch'); + @tab_1_modelUpdate = jasmine.createSpy('tab_1_modelUpdate'); + TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate) + TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 0 Editor', @tab_0_switch) + TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 1 Transcripts', @tab_1_modelUpdate) + TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 1 Transcripts', @tab_1_switch) + + spyOn($.fn, 'hide').andCallThrough() + spyOn($.fn, 'show').andCallThrough() + spyOn(TabsEditingDescriptor.Model, 'initialize') + spyOn(TabsEditingDescriptor.Model, 'updateValue') + + afterEach -> + TabsEditingDescriptor.Model.modules= {} + + describe "constructor", -> + it "first tab should be visible", -> + expect(@descriptor.$tabs.first()).toHaveClass(@isCurrent) + expect(@descriptor.$content.first()).not.toHaveClass(@isInactiveClass) + + describe "onSwitchEditor", -> + it "switching tabs changes styles", -> + @descriptor.$tabs.eq(1).trigger("click") + expect(@descriptor.$tabs.eq(0)).not.toHaveClass(@isCurrent) + expect(@descriptor.$content.eq(0)).toHaveClass(@isInactiveClass) + expect(@descriptor.$tabs.eq(1)).toHaveClass(@isCurrent) + expect(@descriptor.$content.eq(1)).not.toHaveClass(@isInactiveClass) + expect(@tab_1_switch).toHaveBeenCalled() + + it "if click on current tab, nothing should happen", -> + spyOn($.fn, 'trigger').andCallThrough() + currentTab = @descriptor.$tabs.filter('.' + @isCurrent) + @descriptor.$tabs.eq(0).trigger("click") + expect(@descriptor.$tabs.filter('.' + @isCurrent)).toEqual(currentTab) + expect($.fn.trigger.calls.length).toEqual(1) + + it "onSwitch function call", -> + @descriptor.$tabs.eq(1).trigger("click") + expect(TabsEditingDescriptor.Model.updateValue).toHaveBeenCalled() + expect(@tab_1_switch).toHaveBeenCalled() + + describe "save", -> + it "function for current tab should be called", -> + @descriptor.$tabs.eq(1).trigger("click") + data = @descriptor.save().data + expect(@tab_1_modelUpdate).toHaveBeenCalled() + + it "detach click event", -> + spyOn($.fn, "off") + @descriptor.save() + expect($.fn.off).toHaveBeenCalledWith( + 'click', + '.editor-tabs .tab', + @descriptor.onSwitchEditor + ) + + describe "editor/settings header", -> + it "is hidden", -> + expect(@descriptor.element.find(".component-edit-header").css('display')).toEqual('none') + +describe "TabsEditingDescriptor special save cases", -> + beforeEach -> + @isInactiveClass = "is-inactive" + @isCurrent = "current" + loadFixtures 'tabs-edit.html' + @descriptor = new window.TabsEditingDescriptor($('.base_wrapper')) + @html_id = 'test_id' + + describe "save", -> + it "case: no init", -> + data = @descriptor.save().data + expect(data).toEqual(null) + + it "case: no function in model update", -> + TabsEditingDescriptor.Model.initialize(@html_id) + data = @descriptor.save().data + expect(data).toEqual(null) + + it "case: no function in model update, but value presented", -> + @tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate').andReturn(1) + TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate) + @descriptor.$tabs.eq(1).trigger("click") + expect(@tab_0_modelUpdate).toHaveBeenCalled() + data = @descriptor.save().data + expect(data).toEqual(1) + diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee index 0c2069cf00..926e5be315 100644 --- a/cms/static/coffee/spec/views/metadata_edit_spec.coffee +++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee @@ -3,12 +3,14 @@ describe "Test Metadata Editor", -> numberEntryTemplate = readFixtures('metadata-number-entry.underscore') stringEntryTemplate = readFixtures('metadata-string-entry.underscore') optionEntryTemplate = readFixtures('metadata-option-entry.underscore') + listEntryTemplate = readFixtures('metadata-list-entry.underscore') beforeEach -> setFixtures($(" - """ - - appendSetFixtures """ - - """ + _.each ["/static/js/vendor/date.js", "/static/js/vendor/timepicker/jquery.timepicker.js", "/jsi18n/"], (path) -> + appendSetFixtures """ + + """ appendSetFixtures """ - """#" + """ appendSetFixtures """
@@ -38,7 +35,7 @@ describe "Course Overview", -> SaveCancel
- """#" + """ appendSetFixtures """
@@ -46,12 +43,13 @@ describe "Course Overview", ->
- """#" + """ spyOn(window, 'saveSetSectionScheduleDate').andCallThrough() # Have to do this here, as it normally gets bound in document.ready() $('a.save-button').click(saveSetSectionScheduleDate) $('a.delete-section-button').click(deleteSection) + $(".edit-subsection-publish-settings .start-date").datepicker() @notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough() window.analytics = jasmine.createSpyObj('analytics', ['track']) diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index fd679c289b..0154b4f51a 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -120,6 +120,7 @@ class CMS.Views.UnitEdit extends Backbone.View @model.save() deleteComponent: (event) => + event.preventDefault() msg = new CMS.Views.Prompt.Warning( title: gettext('Delete this component?'), message: gettext('Deleting this component is permanent and cannot be undone.'), diff --git a/cms/static/js/base.js b/cms/static/js/base.js index de0fd955dc..80b24776da 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -253,21 +253,20 @@ function syncReleaseDate(e) { $("#start_time").val(""); } -function getEdxTimeFromDateTimeVals(date_val, time_val) { - if (date_val != '') { - if (time_val == '') time_val = '00:00'; - - return new Date(date_val + " " + time_val + "Z"); +function getDatetime(datepickerInput, timepickerInput) { + // given a pair of inputs (datepicker and timepicker), return a JS Date + // object that corresponds to the datetime that they represent. Assume + // UTC timezone, NOT the timezone of the user's browser. + var date = $(datepickerInput).datepicker("getDate"); + var time = $(timepickerInput).timepicker("getTime"); + if(date && time) { + return new Date(Date.UTC( + date.getFullYear(), date.getMonth(), date.getDate(), + time.getHours(), time.getMinutes() + )); + } else { + return null; } - - else return null; -} - -function getEdxTimeFromDateTimeInputs(date_id, time_id) { - var input_date = $('#' + date_id).val(); - var input_time = $('#' + time_id).val(); - - return getEdxTimeFromDateTimeVals(input_date, input_time); } function autosaveInput(e) { @@ -307,9 +306,17 @@ function saveSubsection() { metadata[$(el).data("metadata-name")] = el.value; } - // Piece back together the date/time UI elements into one date/time string - metadata['start'] = getEdxTimeFromDateTimeInputs('start_date', 'start_time'); - metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time'); + // get datetimes for start and due, stick into metadata + _(["start", "due"]).each(function(name) { + + var datetime = getDatetime( + document.getElementById(name+"_date"), + document.getElementById(name+"_time") + ); + // if datetime is null, we want to set that in metadata anyway; + // its an indication to the server to clear the datetime in the DB + metadata[name] = datetime; + }); $.ajax({ url: "/save_item", @@ -772,21 +779,21 @@ function cancelSetSectionScheduleDate(e) { function saveSetSectionScheduleDate(e) { e.preventDefault(); - var input_date = $('.edit-subsection-publish-settings .start-date').val(); - var input_time = $('.edit-subsection-publish-settings .start-time').val(); - - var start = getEdxTimeFromDateTimeVals(input_date, input_time); + var datetime = getDatetime( + $('.edit-subsection-publish-settings .start-date'), + $('.edit-subsection-publish-settings .start-time') + ); var id = $modal.attr('data-id'); analytics.track('Edited Section Release Date', { 'course': course_location_analytics, 'id': id, - 'start': start + 'start': datetime }); var saving = new CMS.Views.Notification.Mini({ - title: gettext("Saving") + "…", + title: gettext("Saving") + "…" }); saving.show(); // call into server to commit the new order @@ -798,20 +805,29 @@ function saveSetSectionScheduleDate(e) { data: JSON.stringify({ 'id': id, 'metadata': { - 'start': start + 'start': datetime } }) }).success(function() { + var pad2 = function(number) { + // pad a number to two places: useful for formatting months, days, hours, etc + // when displaying a date/time + return (number < 10 ? '0' : '') + number; + }; + var $thisSection = $('.courseware-section[data-id="' + id + '"]'); var html = _.template( '' + '' + gettext("Will Release:") + ' ' + - gettext("<%= date %> at <%= time %> UTC") + + gettext("{month}/{day}/{year} at {hour}:{minute} UTC") + '' + - '' + + '' + gettext("Edit") + '', - {date: input_date, time: input_time, id: id}); + {year: datetime.getUTCFullYear(), month: pad2(datetime.getUTCMonth() + 1), day: pad2(datetime.getUTCDate()), + hour: pad2(datetime.getUTCHours()), minute: pad2(datetime.getUTCMinutes()), + id: id}, + {interpolate: /\{(.+?)\}/g}); $thisSection.find('.section-published-date').html(html); hideModal(); saving.hide(); diff --git a/cms/static/js/models/metadata_model.js b/cms/static/js/models/metadata_model.js index 2aff78b295..c1c69939cc 100644 --- a/cms/static/js/models/metadata_model.js +++ b/cms/static/js/models/metadata_model.js @@ -111,3 +111,4 @@ CMS.Models.Metadata.SELECT_TYPE = "Select"; CMS.Models.Metadata.INTEGER_TYPE = "Integer"; CMS.Models.Metadata.FLOAT_TYPE = "Float"; CMS.Models.Metadata.GENERIC_TYPE = "Generic"; +CMS.Models.Metadata.LIST_TYPE = "List"; diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index d7e11d5689..4d048bab81 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -75,7 +75,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ return this.videosourceSample(); }, videosourceSample : function() { - if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video'); + if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video'); else return ""; } }); diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js index 282aeab69c..4b0b97180a 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -96,7 +96,7 @@ function displayFinishedUpload(xhr) { } var resp = JSON.parse(xhr.responseText); - $('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url')); + $('.upload-modal .embeddable-xml-input').val(resp.portable_url); $('.upload-modal .embeddable').show(); $('.upload-modal .file-name').hide(); $('.upload-modal .progress-fill').html(resp.msg); diff --git a/cms/static/js/views/metadata_editor_view.js b/cms/static/js/views/metadata_editor_view.js index 564e77cb8a..f8c093e62a 100644 --- a/cms/static/js/views/metadata_editor_view.js +++ b/cms/static/js/views/metadata_editor_view.js @@ -27,6 +27,9 @@ CMS.Views.Metadata.Editor = Backbone.View.extend({ model.getType() === CMS.Models.Metadata.FLOAT_TYPE) { new CMS.Views.Metadata.Number(data); } + else if(model.getType() === CMS.Models.Metadata.LIST_TYPE) { + new CMS.Views.Metadata.List(data); + } else { // Everything else is treated as GENERIC_TYPE, which uses String editor. new CMS.Views.Metadata.String(data); @@ -310,3 +313,59 @@ CMS.Views.Metadata.Option = CMS.Views.Metadata.AbstractEditor.extend({ }).prop('selected', true); } }); + +CMS.Views.Metadata.List = CMS.Views.Metadata.AbstractEditor.extend({ + + events : { + "click .setting-clear" : "clear", + "keypress .setting-input" : "showClearButton", + "change input" : "updateModel", + "input input" : "enableAdd", + "click .create-setting" : "addEntry", + "click .remove-setting" : "removeEntry" + }, + + templateName: "metadata-list-entry", + + getValueFromEditor: function () { + return _.map( + this.$el.find('li input'), + function (ele) { return ele.value.trim(); } + ).filter(_.identity); + }, + + setValueInEditor: function (value) { + var list = this.$el.find('ol'); + list.empty(); + _.each(value, function(ele, index) { + var template = _.template( + '
  • ' + + '' + + 'Remove' + + '
  • ' + ); + list.append($(template({'ele': ele, 'index': index}))); + }); + }, + + addEntry: function(event) { + event.preventDefault(); + // We don't call updateModel here since it's bound to the + // change event + var list = this.model.get('value') || []; + this.setValueInEditor(list.concat([''])) + this.$el.find('.create-setting').addClass('is-disabled'); + }, + + removeEntry: function(event) { + event.preventDefault(); + var entry = $(event.currentTarget).siblings().val(); + this.setValueInEditor(_.without(this.model.get('value'), entry)); + this.updateModel(); + this.$el.find('.create-setting').removeClass('is-disabled'); + }, + + enableAdd: function() { + this.$el.find('.create-setting').removeClass('is-disabled'); + } +}); diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index e41a6971a6..4ce0d3688e 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -148,7 +148,7 @@ function generateCheckHoverState(selectorsToOpen, selectorsToShove) { } }); - } + }; } function removeHesitate(event, ui) { diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index 739d091b8e..a9e2f45533 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -65,6 +65,7 @@ nav { pointer-events: none; width: ($baseline*8); overflow: hidden; + height: 0; // dropped down state @@ -72,6 +73,7 @@ nav { opacity: 1.0; pointer-events: auto; overflow: visible; + height: auto; } } diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index ab3ad6f810..817fc726c0 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -291,7 +291,7 @@ body.dashboard { // ==================== - // course listings + // ELEM: course listings .courses { margin: $baseline 0; } @@ -304,61 +304,110 @@ body.dashboard { box-shadow: 0 1px 2px $shadow-l1; .course-item { + @include box-sizing(border-box); + width: flex-grid(9, 9); position: relative; border-bottom: 1px solid $gray-l1; + padding: $baseline; - &:last-child { - border-bottom: none; - } + // STATE: hover/focus + &:hover { + background: $paleYellow; - .class-link { - z-index: 100; - display: block; - padding: 20px 25px; - line-height: 1.3; + .course-actions .view-live-button { + opacity: 1.0; + pointer-events: auto; + } - &:hover { - background: $paleYellow; + .course-title { + color: $orange-d1; + } - + .view-live-button { - opacity: 1.0; - pointer-events: auto; - } - } - } - } + .course-metadata { + opacity: 1.0; + } + } - .class-name { - display: block; - font-size: 19px; - font-weight: 300; - } + .course-link, .course-actions { + @include box-sizing(border-box); + display: inline-block; + vertical-align: middle; + } - .detail { - font-size: 14px; - font-weight: 400; - margin-right: 20px; - color: #3c3c3c; - } + // encompassing course link + .course-link { + @extend .ui-depth2; + width: flex-grid(7, 9); + margin-right: flex-gutter(); + } - // view live button - .view-live-button { - z-index: 10000; - position: absolute; - top: ($baseline*0.75); - right: $baseline; - padding: ($baseline/4) ($baseline/2); - opacity: 0.0; - pointer-events: none; + // course title + .course-title { + @extend .t-title4; + margin: 0 ($baseline*2) ($baseline/4) 0; + font-weight: 300; + } - &:hover { - opacity: 1.0; - pointer-events: auto; - } + // course metadata + .course-metadata { + @extend .t-copy-sub1; + @include transition(opacity $tmg-f1 ease-in-out 0); + color: $gray; + opacity: 0.75; + + .metadata-item { + display: inline-block; + + &:after { + content: "/"; + margin-left: ($baseline/10); + margin-right: ($baseline/10); + color: $gray-l4; + } + + &:last-child { + + &:after { + content: ""; + margin-left: 0; + margin-right: 0; + } + } + + .label { + @extend .cont-text-sr; + } + } + } + + .course-actions { + @extend .ui-depth3; + position: static; + width: flex-grid(2, 9); + text-align: right; + + // view live button + .view-live-button { + @extend .ui-depth3; + @include transition(opacity $tmg-f2 ease-in-out 0); + @include box-sizing(border-box); + padding: ($baseline/2); + opacity: 0.0; + pointer-events: none; + + &:hover { + opacity: 1.0; + pointer-events: auto; + } + } + + &:last-child { + border-bottom: none; + } + } } } - // ELEM: new user form .wrapper-create-course { diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index 8058673b2b..b83d22414b 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -148,6 +148,14 @@ body.course.textbooks { padding: ($baseline*0.75) $baseline; background: $gray-l6; + .action { + margin-right: ($baseline/4); + + &:last-child { + margin-right: 0; + } + } + // add a chapter is below with chapters styling .action-primary { diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss index 06685ad96b..6e893fece3 100644 --- a/cms/static/sass/views/_unit.scss +++ b/cms/static/sass/views/_unit.scss @@ -449,12 +449,39 @@ body.course.unit { // Module Actions, also used for Static Pages .module-actions { - box-shadow: inset 0 1px 1px $shadow; - padding: 0 0 $baseline $baseline; - background-color: $gray-l6; + box-shadow: inset 0 1px 2px $shadow; + border-top: 1px solid $gray-l1; + padding: ($baseline*0.75) $baseline; + background: $gray-l6; - .save-button { - margin: ($baseline/2) 8px 0 0; + .action { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + + &:last-child { + margin-right: 0; + } + } + + .action-primary { + @include blue-button; + @extend .t-action2; + @include transition(all .15s); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + text-transform: uppercase; + } + + .action-secondary { + @include grey-button; + @extend .t-action2; + @include transition(all .15s); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + text-transform: uppercase; } } } @@ -599,26 +626,27 @@ body.course.unit { } } - .wrapper-comp-setting{ + .wrapper-comp-setting { display: inline-block; min-width: 300px; - width: 45%; + width: 55%; top: 0; vertical-align: top; margin-bottom:5px; position: relative; } - label.setting-label { + .setting-label { @extend .t-copy-sub1; @include transition(color $tmg-f2 ease-in-out 0s); - font-weight: 400; vertical-align: middle; display: inline-block; position: relative; left: 0; + width: 33%; min-width: 100px; - width: 35%; + margin-right: ($baseline/2); + font-weight: 600; &.is-focused { color: $blue; @@ -708,14 +736,98 @@ body.course.unit { } } - .tip.setting-help { + .setting-help { @include font-size(12); display: inline-block; font-color: $gray-l6; - min-width: 260px; - width: 50%; + min-width: ($baseline*10); + width: 35%; vertical-align: top; } + + + + // TYPE: enumerated lists of metadata sets + .metadata-list-enum { + + * { + @include box-sizing(border-box); + } + + // label + .setting-label { + vertical-align: top; + margin-top: ($baseline/2); + } + + // inputs and labels + .wrapper-list-settings { + @include size(45%,100%); + display: inline-block; + min-width: ($baseline*5); + + // enumerated fields + .list-settings { + margin: 0; + + .list-settings-item { + margin-bottom: ($baseline/2); + } + + // inputs + .input { + width: 80%; + margin-right: ($baseline/2); + vertical-align: middle; + } + } + } + + // actions + .create-action, .remove-action, .setting-clear { + + } + + .setting-clear { + vertical-align: top; + margin-top: ($baseline/4); + } + + .create-setting { + @extend .ui-btn-flat-outline; + @extend .t-action3; + display: block; + width: 100%; + padding: ($baseline/2); + font-weight: 600; + + *[class^="icon-"] { + margin-right: ($baseline/4); + } + + // STATE: disabled + &.is-disabled { + + } + } + + .remove-setting { + @include transition(color 0.25s ease-in-out); + @include font-size(20); + display: inline-block; + background: transparent; + color: $blue-l3; + + &:hover { + color: $blue; + } + + // STATE: disabled + &.is-disabled { + + } + } + } } } } diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 6c92994a6f..c681cf5058 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -29,7 +29,7 @@ {{uploadDate}} - + @@ -89,7 +89,7 @@ ${asset['uploadDate']} - + diff --git a/cms/templates/component.html b/cms/templates/component.html index 512847aa3d..2412cd74d4 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -26,8 +26,8 @@ diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index bdcaf18015..5b03643f3b 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -1,11 +1,10 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%! - import logging - from xmodule.util.date_utils import get_default_time_display, almost_same_datetime + import logging + from xmodule.util.date_utils import get_default_time_display, almost_same_datetime + from django.utils.translation import ugettext as _ + from django.core.urlresolvers import reverse %> - -<%! from django.core.urlresolvers import reverse %> <%block name="title">${_("CMS Subsection")} <%block name="bodyclass">is-signedin course subsection diff --git a/cms/templates/emails/course_creator_admin_subject.txt b/cms/templates/emails/course_creator_admin_subject.txt new file mode 100644 index 0000000000..13c7c3094d --- /dev/null +++ b/cms/templates/emails/course_creator_admin_subject.txt @@ -0,0 +1,2 @@ +<%! from django.utils.translation import ugettext as _ %> +${_("{email} has requested Studio course creator privileges on edge".format(email=user_email))} \ No newline at end of file diff --git a/cms/templates/emails/course_creator_admin_user_pending.txt b/cms/templates/emails/course_creator_admin_user_pending.txt new file mode 100644 index 0000000000..37464d3e9c --- /dev/null +++ b/cms/templates/emails/course_creator_admin_user_pending.txt @@ -0,0 +1,9 @@ +<%! from django.utils.translation import ugettext as _ %> +${_("User '{user}' with e-mail {email} has requested Studio course creator privileges on edge.".format(user=user_name, email=user_email))} +${_("To grant or deny this request, use the course creator admin table.")} + +% if is_secure: + https://${ site }/admin/course_creators/coursecreator/ +% else: + http://${ site }/admin/course_creators/coursecreator/ +% endif diff --git a/cms/templates/export.html b/cms/templates/export.html index 593cf3dd6e..3356bea42b 100644 --- a/cms/templates/export.html +++ b/cms/templates/export.html @@ -6,6 +6,62 @@ <%block name="title">${_("Course Export")} <%block name="bodyclass">is-signedin course tools export +<%block name="jsextra"> + % if in_err: + + %endif + + <%block name="content">
    @@ -18,6 +74,7 @@
    +

    ${_("About Exporting Courses")}

    diff --git a/cms/templates/index.html b/cms/templates/index.html index 9c845ccb5a..9702eedada 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -133,12 +133,30 @@ %if len(courses) > 0:
    diff --git a/cms/templates/js/metadata-editor.underscore b/cms/templates/js/metadata-editor.underscore index 03fdd28996..67898a77b6 100644 --- a/cms/templates/js/metadata-editor.underscore +++ b/cms/templates/js/metadata-editor.underscore @@ -1,6 +1,6 @@
      <% _.each(_.range(numEntries), function() { %> - <% }) %>
    diff --git a/cms/templates/js/metadata-list-entry.underscore b/cms/templates/js/metadata-list-entry.underscore new file mode 100644 index 0000000000..8b3d99c507 --- /dev/null +++ b/cms/templates/js/metadata-list-entry.underscore @@ -0,0 +1,17 @@ + +<%= model.get('help') %> diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 3795e9d09b..2c42df187a 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -1,10 +1,10 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%! - import logging - from xmodule.util import date_utils + import logging + from xmodule.util import date_utils + from django.utils.translation import ugettext as _ + from django.core.urlresolvers import reverse %> -<%! from django.core.urlresolvers import reverse %> <%block name="title">${_("Course Outline")} <%block name="bodyclass">is-signedin course outline diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html index 3ee1bc5b74..28349b5436 100644 --- a/cms/templates/textbooks.html +++ b/cms/templates/textbooks.html @@ -76,7 +76,7 @@ $(function() {

    ${_("What if my book isn't divided into chapters?")}

    -

    ${_("If you haven't broken your textbook into chapters, you can upload the entire text as Chapter 1.")}

    +

    ${_("If you haven't broken your text into chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.")}

    diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 6178058689..a0d4d0fd41 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -13,7 +13,7 @@

    ${_("Current Course:")} - ${ctx_loc.org}${ctx_loc.course} + ${context_course.display_org_with_default | h}${context_course.display_number_with_default | h} ${context_course.display_name_with_default}

    diff --git a/cms/templates/widgets/html-edit.html b/cms/templates/widgets/html-edit.html index 879ae43e07..34866321c4 100644 --- a/cms/templates/widgets/html-edit.html +++ b/cms/templates/widgets/html-edit.html @@ -1,6 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> -
    +
    • ${_("Visual")}
    • diff --git a/cms/templates/widgets/metadata-edit.html b/cms/templates/widgets/metadata-edit.html index db50a1d877..70a2df3400 100644 --- a/cms/templates/widgets/metadata-edit.html +++ b/cms/templates/widgets/metadata-edit.html @@ -25,6 +25,10 @@ <%static:include path="js/metadata-option-entry.underscore" /> + + <% showHighLevelSource='source_code' in editable_metadata_fields and editable_metadata_fields['source_code']['explicitly_set'] %> <% metadata_field_copy = copy.copy(editable_metadata_fields) %> ## Delete 'source_code' field (if it exists) so metadata editor view does not attempt to render it. @@ -40,4 +44,4 @@ <%include file="source-edit.html" /> % endif -
      -
    1. ${_("Course Number")}

      ${course.number}
    2. +
    3. ${_("Course Number")}

      ${course.display_number_with_default | h}
    4. ${_("Classes Start")}

      ${course.start_date_text}
    5. ## We plan to ditch end_date (which is not stored in course metadata), diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 8d033434f0..2a52b50b09 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -2,7 +2,7 @@ <%inherit file="/main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="bodyclass">courseware ${course.css_class} -<%block name="title">${_("{course_number} Courseware").format(course_number=course.number)} +<%block name="title">${_("{course_number} Courseware").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='course'/> diff --git a/lms/templates/courseware/info.html b/lms/templates/courseware/info.html index 4bb961428e..a1aad363a9 100644 --- a/lms/templates/courseware/info.html +++ b/lms/templates/courseware/info.html @@ -7,8 +7,7 @@ <%static:css group='course'/> -<%block name="title">${_("{course.number} Course Info").format(course=course)} - +<%block name="title">${_("{course.display_number_with_default} Course Info").format(course=course) | h} <%include file="/courseware/course_navigation.html" args="active_page='info'" /> <%! from courseware.courses import get_course_info_section diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index bdc8b9d059..5b254fc86e 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -201,7 +201,7 @@ function goto( mode)

      ${_("Course-specific grade adjustment")}

      - {_("Specify a particular problem in the course here by its url:")} + ${_("Specify a particular problem in the course here by its url:")}

      @@ -385,7 +385,7 @@ function goto( mode) ${_("Notify students by email")}

      ${_("Auto-enroll students when they activate")} - +

      @@ -639,7 +639,7 @@ function goto( mode) %if instructor_tasks is not None and len(instructor_tasks) > 0:


      -

      $_("Pending Instructor Tasks")}

      +

      ${_("Pending Instructor Tasks")}

      diff --git a/lms/templates/courseware/mktg_course_about.html b/lms/templates/courseware/mktg_course_about.html index a2c1bd7d0a..be623b5baa 100644 --- a/lms/templates/courseware/mktg_course_about.html +++ b/lms/templates/courseware/mktg_course_about.html @@ -8,7 +8,7 @@ <%inherit file="../mktg_iframe.html" /> -<%block name="title">${_("About {course_number}").format(course_number=course.number)} +<%block name="title">${_("About {course_number}").format(course_number=course.display_number_with_default) | h} <%block name="bodyclass">view-partial-mktgregister @@ -52,7 +52,7 @@
      ${_("You Are Registered")}
      %endif %elif allow_registration: - ${_("Register for")} ${course.number} + ${_("Register for")} ${course.display_number_with_default | h} %else:
      ${_("Registration Is Closed")}
      %endif diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 51feed23ab..324eadf560 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -8,7 +8,7 @@ <%namespace name="progress_graph" file="/courseware/progress_graph.js"/> -<%block name="title">${_("{course_number} Progress").format(course_number=course.number)} +<%block name="title">${_("{course_number} Progress").format(course_number=course.display_number_with_default) | h} <%! from django.core.urlresolvers import reverse diff --git a/lms/templates/courseware/static_tab.html b/lms/templates/courseware/static_tab.html index 5b6e1480dd..f03dcacb80 100644 --- a/lms/templates/courseware/static_tab.html +++ b/lms/templates/courseware/static_tab.html @@ -6,7 +6,7 @@ <%static:css group='course'/> -<%block name="title">${course.number} ${tab['name']} +<%block name="title">${course.display_number_with_default | h} ${tab['name']} <%include file="/courseware/course_navigation.html" args="active_page='static_tab_{0}'.format(tab['url_slug'])" /> diff --git a/lms/templates/courseware/syllabus.html b/lms/templates/courseware/syllabus.html index f9b5455273..e2f3c53739 100644 --- a/lms/templates/courseware/syllabus.html +++ b/lms/templates/courseware/syllabus.html @@ -6,7 +6,7 @@ <%static:css group='course'/> -<%block name="title">${_("{course.number} Course Info").format(course=course)} +<%block name="title">${_("{course.display_number_with_default} Course Info").format(course=course) | h} <%include file="/courseware/course_navigation.html" args="active_page='syllabus'" /> <%! diff --git a/lms/templates/courseware/welcome-back.html b/lms/templates/courseware/welcome-back.html index a622fe3c57..b9f4681403 100644 --- a/lms/templates/courseware/welcome-back.html +++ b/lms/templates/courseware/welcome-back.html @@ -2,7 +2,7 @@

      ${chapter_module.display_name_with_default}

      ${_("You were most recently in {section_link}. If you\'re done with that, choose another section on the left.").format( - section_link='{section_name}'.format( + section_link=u'{section_name}'.format( url=prev_section_url, section_name=prev_section.display_name_with_default, ) diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 6356b64209..1dfa66461c 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -140,7 +140,7 @@ % if course.id in show_courseware_links_for: - ${_('{course_number} {course_name} Cover Image').format(course_number='${course.number}', course_name='${course.display_name_with_default}')} + ${_('{course_number} {course_name} Cover Image').format(course_number='${course.number}', course_name='${course.display_name_with_default}') |h} % else:

      @@ -162,9 +162,9 @@

      ${get_course_about_section(course, 'university')}

      % if course.id in show_courseware_links_for: - ${course.number} ${course.display_name_with_default} + ${course.display_number_with_default | h} ${course.display_name_with_default} % else: - ${course.number} ${course.display_name_with_default} + ${course.display_number_with_default | h} ${course.display_name_with_default} % endif

      @@ -202,7 +202,7 @@ link_start=''.format(url=testcenter_register_target), link_end='')} ${_("Otherwise {link_start}contact edX at {email}{link_end} for further help.").format( - link_start=''.format(email="exam-help@edx.org", about=get_course_about_section(course, 'university'), number=course.number), + link_start=''.format(email="exam-help@edx.org", about=get_course_about_section(course, 'university'), number=course.display_number_with_default), link_end='', email="exam-help@edx.org", )} diff --git a/lms/templates/discussion/index.html b/lms/templates/discussion/index.html index 3b6937b7d5..de53433790 100644 --- a/lms/templates/discussion/index.html +++ b/lms/templates/discussion/index.html @@ -6,7 +6,7 @@ <%inherit file="../main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">discussion -<%block name="title">${_("Discussion - {course_number}").format(course_number=course.number) | h} +<%block name="title">${_("Discussion - {course_number}").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='course'/> diff --git a/lms/templates/discussion/single_thread.html b/lms/templates/discussion/single_thread.html index 6aae08a7a7..5bd6c6ca03 100644 --- a/lms/templates/discussion/single_thread.html +++ b/lms/templates/discussion/single_thread.html @@ -7,7 +7,7 @@ <%inherit file="../main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">discussion -<%block name="title">${_("Discussion - {course_number}").format(course_number=course.number) | h} +<%block name="title">${_("Discussion - {course_number}").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='course'/> diff --git a/lms/templates/discussion/user_profile.html b/lms/templates/discussion/user_profile.html index 1c1901c10b..dbfa79d4e7 100644 --- a/lms/templates/discussion/user_profile.html +++ b/lms/templates/discussion/user_profile.html @@ -4,7 +4,7 @@ <%inherit file="../main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">discussion -<%block name="title">${_("Discussion - {course_number}").format(course_number=course.number) | h} +<%block name="title">${_("Discussion - {course_number}").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='course'/> diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html index 6692d02d09..41a751aae4 100644 --- a/lms/templates/help_modal.html +++ b/lms/templates/help_modal.html @@ -31,7 +31,7 @@ discussion_link = get_discussion_link(course) if course else None

      % endif -

      ${_('Have general questions about {platform_name}? You can find lots of helpful information in the {platform_name} {link_start}FAQ{link_end}.').format(link_start='', link_end='').format(platform_name=settings.PLATFORM_NAME)}

      +

      ${_('Have general questions about {platform_name}? You can find lots of helpful information in the {platform_name} {link_start}FAQ{link_end}.').format(link_start='', link_end='', platform_name=settings.PLATFORM_NAME)}

      ${_('Have a question about something specific? You can contact the {platform_name} general support team directly:').format(platform_name=settings.PLATFORM_NAME)}


      diff --git a/lms/templates/index.html b/lms/templates/index.html index e7c0d638c7..0fecd24e84 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -186,7 +186,7 @@ else: youtube_video_id = "XNaiOGxWeto" %> - +
      diff --git a/lms/templates/instructor/instructor_dashboard_2/analytics.html b/lms/templates/instructor/instructor_dashboard_2/analytics.html index ebb8a8cb3c..8469c1db93 100644 --- a/lms/templates/instructor/instructor_dashboard_2/analytics.html +++ b/lms/templates/instructor/instructor_dashboard_2/analytics.html @@ -1,12 +1,54 @@ <%page args="section_data"/> -

      Distributions

      - -
      -
      -
      -
      -
      -
      + + + + +
      + +
      + +
      + +
      diff --git a/lms/templates/instructor/instructor_dashboard_2/student_admin.html b/lms/templates/instructor/instructor_dashboard_2/student_admin.html index a24288f4de..bf99fcea57 100644 --- a/lms/templates/instructor/instructor_dashboard_2/student_admin.html +++ b/lms/templates/instructor/instructor_dashboard_2/student_admin.html @@ -1,6 +1,6 @@ <%page args="section_data"/> -
      +

      Student-specific grade adjustment

      @@ -47,12 +47,11 @@
      %endif +
      %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']: -
      - -
      +

      Course-specific grade adjustment

      @@ -81,9 +80,9 @@

      -
      -
      +
      +

      Pending Instructor Tasks

      diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index fb6766fe15..40e80de11e 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -7,7 +7,7 @@ <%static:css group='course'/> -<%block name="title">${_("{course_number} Staff Grading").format(course_number=course.number)} +<%block name="title">${_("{course_number} Staff Grading").format(course_number=course.display_number_with_default) | h} <%include file="/courseware/course_navigation.html" args="active_page='staff_grading'" /> diff --git a/lms/templates/module-error.html b/lms/templates/module-error.html index b0641ea5c4..f43db37dfc 100644 --- a/lms/templates/module-error.html +++ b/lms/templates/module-error.html @@ -1,7 +1,7 @@ <%! from django.utils.translation import ugettext as _ %>
      -

      ${_("There has been an error on the {platform_name} servers")}

      +

      ${_("There has been an error on the {platform_name} servers").format(platform_name=settings.PLATFORM_NAME)}

      ${_("We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at {tech_support_email} to report any problems or downtime.").format(platform_name=settings.PLATFORM_NAME, tech_support_email=settings.TECH_SUPPORT_EMAIL)}

      % if staff_access: diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 589d12666d..ee9b400251 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -50,7 +50,7 @@ site_status_msg = get_site_status_msg(course_id) % if course: -

      ${course.org}: ${course.number} ${course.display_name_with_default}

      +

      ${course.display_org_with_default | h}: ${course.display_number_with_default | h} ${course.display_name_with_default}

      % endif % if user.is_authenticated(): diff --git a/lms/templates/open_ended_problems/combined_notifications.html b/lms/templates/open_ended_problems/combined_notifications.html index 86eb4083dd..ce22a5c580 100644 --- a/lms/templates/open_ended_problems/combined_notifications.html +++ b/lms/templates/open_ended_problems/combined_notifications.html @@ -7,7 +7,7 @@ <%static:css group='course'/> -<%block name="title">${_("{course_number} Combined Notifications").format(course_number=course.number)} +<%block name="title">${_("{course_number} Combined Notifications").format(course_number=course.display_number_with_default) | h} <%include file="/courseware/course_navigation.html" args="active_page='open_ended'" /> diff --git a/lms/templates/open_ended_problems/open_ended_flagged_problems.html b/lms/templates/open_ended_problems/open_ended_flagged_problems.html index ab60e54300..8e746f585f 100644 --- a/lms/templates/open_ended_problems/open_ended_flagged_problems.html +++ b/lms/templates/open_ended_problems/open_ended_flagged_problems.html @@ -7,7 +7,7 @@ <%static:css group='course'/> -<%block name="title">${_("{course_number} Flagged Open Ended Problems").format(course_number=course.number)} +<%block name="title">${_("{course_number} Flagged Open Ended Problems").format(course_number=course.display_number_with_default) | h} <%include file="/courseware/course_navigation.html" args="active_page='open_ended_flagged_problems'" /> diff --git a/lms/templates/open_ended_problems/open_ended_problems.html b/lms/templates/open_ended_problems/open_ended_problems.html index 56b269d8b7..68882984f5 100644 --- a/lms/templates/open_ended_problems/open_ended_problems.html +++ b/lms/templates/open_ended_problems/open_ended_problems.html @@ -7,7 +7,7 @@ <%static:css group='course'/> -<%block name="title">${_("{course_number} Open Ended Problems").format(course_number=course.number)} +<%block name="title">${_("{course_number} Open Ended Problems").format(course_number=course.display_number_with_default) | h} <%include file="/courseware/course_navigation.html" args="active_page='open_ended_problems'" /> diff --git a/lms/templates/static_htmlbook.html b/lms/templates/static_htmlbook.html index 67274e30c9..60421502e6 100644 --- a/lms/templates/static_htmlbook.html +++ b/lms/templates/static_htmlbook.html @@ -2,7 +2,7 @@ <%inherit file="main.html" /> <%namespace name='static' file='static_content.html'/> -<%block name="title">${_('{course_number} Textbook').format(course_number=course.number)} +<%block name="title">${_('{course_number} Textbook').format(course_number=course.display_number_with_default) | h} <%block name="headextra"> diff --git a/lms/templates/static_pdfbook.html b/lms/templates/static_pdfbook.html index dfa7a47157..4e9f604a0c 100644 --- a/lms/templates/static_pdfbook.html +++ b/lms/templates/static_pdfbook.html @@ -5,7 +5,7 @@ <%block name="title"> -${_('{course_number} Textbook').format(course_number=course.number)} +${_('{course_number} Textbook').format(course_number=course.display_number_with_default) | h} <%block name="headextra"> diff --git a/lms/templates/static_templates/about.html b/lms/templates/static_templates/about.html index fbaaf4e816..fa6188746a 100644 --- a/lms/templates/static_templates/about.html +++ b/lms/templates/static_templates/about.html @@ -52,7 +52,7 @@

      ${_("{massachusetts_institute_of_technology}").format(massachusetts_institute_of_technology="Massachusetts Institute of Technology")}

      ${_("The {massachusetts_institute_of_technology} — a coeducational, privately endowed research university founded in 1861 — is dedicated to advancing knowledge and educating students in science, technology, and other areas of scholarship that will best serve the nation and the world in the 21st century. The Institute has close to 1,000 faculty and 10,000 undergraduate and graduate students. It is organized into five Schools: Architecture and Urban Planning; Engineering; Humanities, Arts, and Social Sciences; {Sloan} School of Management; and Science.").format(massachusetts_institute_of_technology="Massachusetts Institute of Technology", Sloan="Sloan")}

      -

      ${_("{MIT}'s commitment to innovation has led to a host of scientific breakthroughs and technological advances. Achievements of the Institute's faculty and graduates have included the first chemical synthesis of penicillin and vitamin A, the development of inertial guidance systems, modern technologies for artificial limbs, and the magnetic core memory that made possible the development of digital computers. 78 alumni, faculty, researchers and staff have won Nobel Prizes.")}

      +

      ${_("{MIT}'s commitment to innovation has led to a host of scientific breakthroughs and technological advances. Achievements of the Institute's faculty and graduates have included the first chemical synthesis of penicillin and vitamin A, the development of inertial guidance systems, modern technologies for artificial limbs, and the magnetic core memory that made possible the development of digital computers. 78 alumni, faculty, researchers and staff have won Nobel Prizes.").format(MIT="MIT")}

      ${_("Current areas of research and education include neuroscience and the study of the brain and mind, bioengineering, cancer, energy, the environment and sustainable development, information sciences and technology, new media, financial technology, and entrepreneurship.")}

      diff --git a/lms/templates/static_templates/faq.html b/lms/templates/static_templates/faq.html index ea3e0e4725..9b90fd5c6c 100644 --- a/lms/templates/static_templates/faq.html +++ b/lms/templates/static_templates/faq.html @@ -21,7 +21,7 @@

      ${_("Organization")}

      ${_("What is {edX}?").format(edX="edX")}

      -

      ${_('{edX} is a not-for-profit enterprise of its founding partners, the {MIT_long} ({MIT}) and {harvard_u} that offers online learning to on-campus students and to millions of people around the world. To do so, {edX} is building an open-source online learning platform and hosts an online web portal at www.edx.org for online education.').format(edX="edX", MIT_long="Massachusetts Institute of Technology", MIT="MIT", harvard_u="Harvard University")}

      +

      ${_('{EdX} is a not-for-profit enterprise of its founding partners, the {MIT_long} ({MIT}) and {harvard_u} that offers online learning to on-campus students and to millions of people around the world. To do so, {edX} is building an open-source online learning platform and hosts an online web portal at www.edx.org for online education.').format(EdX="EdX", edX="edX", MIT_long="Massachusetts Institute of Technology", MIT="MIT", harvard_u="Harvard University")}

      ${_("{EdX} currently offers {HarvardX}, {MITx} and {BerkeleyX} classes online for free. Beginning in fall 2013, {edX} will offer {WellesleyX} , {GeorgetownX} and the {UTexas} classes online for free. The {UT} System includes nine universities and six health institutions. In 2014, {edX} will further expand its consortium, including several international schools, when it begins offering courses from {EPFL}, {McGill}, {Toronto}, {ANU}, {Delft}, and {Rice}. The {edX} institutions aim to extend their collective reach to build a global community of online students. Along with offering online courses, the three universities undertake research on how students learn and how technology can transform learning both on-campus and online throughout the world.").format( EdX="EdX", edX="edX", @@ -32,7 +32,7 @@ GeorgetownX="GeorgetownX", UTexas="University of Texas System", UT="UT", - EPFL="École Polytechnique Fédérale de Lausanne", + EPFL=u"École Polytechnique Fédérale de Lausanne", McGill="McGill University", Toronto="University of Toronto", ANU="Australian National University", @@ -44,12 +44,12 @@

      ${_("Will {edX} be adding additional X Universities?").format(edX="edX")}

      -

      ${_("More than 200 institutions from around the world have expressed interest in collaborating with {edX} since {Harvard} and {MIT} announced its creation in May. {EdX} is focused above all on quality and developing the best not-for-profit model for online education. In addition to providing online courses on the {edX} platform, the {x_consortium} will be a forum in which members can share experiences around online learning. {Harvard}, {MIT}, {Berkeley}, the {UTexas} and the other {consortium} members will work collaboratively to establish the {\"X_University\" Consortium}, whose membership will expand to include additional \"{X_Universities}.\" As noted above, {edX}'s newest {consortium} members include {Wellesley}, {Georgetown}, {EPFL}, {McGill}, {Toronto}, {ANU}, {Delft}, and {Rice}. Each member of the {consortium} will offer courses on the {edX} platform as an \"{X_University}\". The gathering of many universities' educational content together on one site will enable learners worldwide to access the offered course content of any participating university from a single website, and to use a set of online educational tools shared by all participating universities.").format( +

      ${_("More than 200 institutions from around the world have expressed interest in collaborating with {edX} since {Harvard} and {MIT} announced its creation in May. {EdX} is focused above all on quality and developing the best not-for-profit model for online education. In addition to providing online courses on the {edX} platform, the {x_consortium} will be a forum in which members can share experiences around online learning. {Harvard}, {MIT}, {Berkeley}, the {UTexas} and the other {consortium} members will work collaboratively to establish the {x_consortium}, whose membership will expand to include additional \"{X_Universities}.\" As noted above, {edX}'s newest {consortium} members include {Wellesley}, {Georgetown}, {EPFL}, {McGill}, {Toronto}, {ANU}, {Delft}, and {Rice}. Each member of the {consortium} will offer courses on the {edX} platform as an \"{X_University}\". The gathering of many universities' educational content together on one site will enable learners worldwide to access the offered course content of any participating university from a single website, and to use a set of online educational tools shared by all participating universities.").format( EdX="EdX", edX="edX", Harvard="Harvard", MIT="MIT", - x_consortium="\"X_University\" Consortium", + x_consortium="\"X University\" Consortium", consortium="consortium", X_Universities="X Universities", X_University="X University", @@ -57,14 +57,14 @@ Wellesley="Wellesley", Georgetown="Georgetown", UTexas="University of Texas System", - EPFL="École Polytechnique Fédérale de Lausanne", + EPFL=u"École Polytechnique Fédérale de Lausanne", McGill="McGill University", Toronto="University of Toronto", ANU="Australian National University", Delft="Delft University of Technology", Rice="Rice University", )}

      -

      ${_("{edX} will actively explore the addition of other institutions from around the world to the {edX} platform, and looks forward to adding more \"{X Universities}\".").format(edX="edX", X_Universities="X Universities")} +

      ${_("{EdX} will actively explore the addition of other institutions from around the world to the {edX} platform, and looks forward to adding more \"{X_Universities}\".").format(EdX="EdX", edX="edX", X_Universities="X Universities")}

      @@ -79,7 +79,7 @@

      ${_("Will certificates be awarded?")}

      ${_("Yes. Online learners who demonstrate mastery of subjects can earn a certificate " "of mastery. Certificates will be issued at the discretion of {edX} and the underlying " - "{X_University} that offered the course under the name of the underlying \"{X_University}\" from where the course originated, i.e. {HarvardX}, {MITx} or {BerkeleyX}. " + "\"{X_University}\" that offered the course under the name of the underlying \"{X_University}\" from where the course originated, i.e. {HarvardX}, {MITx} or {BerkeleyX}. " "For the courses in Fall 2012, those certificates will be free. There is a plan to " "charge a modest fee for certificates in the future. Note: At this time, {edX} is " "holding certificates for learners connected with Cuba, Iran, Syria and Sudan " @@ -102,7 +102,7 @@

      ${_("{EdX} institutions have assembled faculty members who will collect and analyze data to assess results and the impact {edX} is having on learning.").format(EdX="EdX", edX="edX")}

      -

      ${_("How may I apply to study with {edX}?")}

      +

      ${_("How may I apply to study with {edX}?").format(edX="edX")}

      ${_('Simply complete the online {link_start}signup form{link_end}. Enrolling will create your unique student record in the {edX} database, allow you to register for classes, and to receive a certificate on successful completion.').format(link_start='', link_end='', edX="edX")}

      diff --git a/lms/templates/static_templates/help.html b/lms/templates/static_templates/help.html index b2743f892f..f4f65be053 100644 --- a/lms/templates/static_templates/help.html +++ b/lms/templates/static_templates/help.html @@ -139,7 +139,7 @@

      ${_("How can I talk to professors, fellows and teaching assistants?")}

      -

      ${_("The Discussion Forums are the best place to reach out to the {edX} teaching team for your class, and you don\'t have to wait in line or rearrange your schedule to fit your professor'\s - just post your questions. The response isn\'t always immediate, but it\'s usually pretty darned quick.").format(edX="edX")}

      +

      ${_("The Discussion Forums are the best place to reach out to the {edX} teaching team for your class, and you don\'t have to wait in line or rearrange your schedule to fit your professor\'s - just post your questions. The response isn\'t always immediate, but it\'s usually pretty darned quick.").format(edX="edX")}

      @@ -189,7 +189,7 @@

      ${_("How are {edX} certificates delivered?").format(edX="edX")}

      -

      ${_("{EdX} certificates are delivered online through edx.org. So be sure to check your email in the weeks following the final grading - you will be able to download and print your certificate.").format(edX="edX")}

      +

      ${_("{EdX} certificates are delivered online through edx.org. So be sure to check your email in the weeks following the final grading - you will be able to download and print your certificate.").format(EdX="EdX")}

      @@ -319,7 +319,7 @@ ${_("The Classes")} ${_("Certificates and Credits")} ${_("{edX} & Open source").format(edX="edX")} - $${_("Other Help Questions - Account Questions")} + ${_("Other Help Questions - Account Questions")} diff --git a/lms/templates/static_templates/press.html b/lms/templates/static_templates/press.html index 8abead7175..58acbca332 100644 --- a/lms/templates/static_templates/press.html +++ b/lms/templates/static_templates/press.html @@ -18,7 +18,9 @@ % for article in articles:
      - ${"" % static.url('images/press/' + article.image)} + + +
      diff --git a/lms/templates/staticbook.html b/lms/templates/staticbook.html index 980448b482..09741e0a9e 100644 --- a/lms/templates/staticbook.html +++ b/lms/templates/staticbook.html @@ -2,7 +2,7 @@ <%inherit file="main.html" /> <%namespace name='static' file='static_content.html'/> -<%block name="title">${_("{course_number} Textbook").format(course_number=course.number)} +<%block name="title">${_("{course_number} Textbook").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='course'/> diff --git a/lms/templates/test_center_register.html b/lms/templates/test_center_register.html index df6845e577..c35acf9914 100644 --- a/lms/templates/test_center_register.html +++ b/lms/templates/test_center_register.html @@ -95,7 +95,7 @@
      -

      ${get_course_about_section(course, 'university')} ${course.number} ${course.display_name_with_default}

      +

      ${get_course_about_section(course, 'university')} ${course.display_number_with_default | h} ${course.display_name_with_default | h}

      % if registration:

      ${_('Your Pearson VUE Proctored Exam Registration')}

      @@ -442,7 +442,7 @@ % endif
      -

      ${_("About {university} {course_number}").format(university=get_course_about_section(course, 'university'), course_number=course.number)}

      +

      ${_("About {university} {course_number}").format(university=get_course_about_section(course, 'university'), course_number=course.course.display_number_with_default) | h}

      % if course.has_ended(): ${_('Course Completed:')} ${course.end_date_text} diff --git a/lms/templates/university_profile/edge.html b/lms/templates/university_profile/edge.html new file mode 100644 index 0000000000..166a95a106 --- /dev/null +++ b/lms/templates/university_profile/edge.html @@ -0,0 +1,66 @@ +<%! from django.utils.translation import ugettext as _ %> +<%inherit file="../stripped-main.html" /> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">${_("edX edge")} +<%block name="bodyclass">no-header edge-landing + +<%block name="content"> +

      +
      ${_("edX edge")}
      +
      + + +
      +
      + + + +<%block name="js_extra"> + + + +<%include file="../signup_modal.html" /> +<%include file="../forgot_password_modal.html" /> \ No newline at end of file diff --git a/lms/templates/videoalpha.html b/lms/templates/videoalpha.html index 416620a4b5..d0eb7290a7 100644 --- a/lms/templates/videoalpha.html +++ b/lms/templates/videoalpha.html @@ -1,86 +1,86 @@ <%! from django.utils.translation import ugettext as _ %> % if display_name is not UNDEFINED and display_name is not None: -

      ${display_name}

      +

      ${display_name}

      % endif
      -
      - +
      +
      - % if show_captions == 'true': -
      - % endif - -
      +
      +
      % if sources.get('main'): -
      -

      ${_('Download video here.') % sources.get('main')}

      -
      +
      +

      ${(_('Download video') + ' ' + _('here') + '.') % sources.get('main')}

      +
      % endif % if track: -
      -

      ${_('Download subtitles here.') % track}

      -
      +
      +

      ${(_('Download subtitles') + ' ' + _('here') + '.') % track}

      +
      % endif diff --git a/lms/templates/word_cloud.html b/lms/templates/word_cloud.html index 5ec83afcc7..dc66b04a9b 100644 --- a/lms/templates/word_cloud.html +++ b/lms/templates/word_cloud.html @@ -1,3 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> +
      =1.3 +pip>=1.4 polib==1.0.3 pycrypto>=2.6 pygments==1.5 @@ -52,6 +52,7 @@ sorl-thumbnail==11.12 South==0.7.6 sympy==0.7.1 xmltodict==0.4.1 +django-ratelimit-backend==0.6 # Used for debugging ipython==0.13.1 @@ -68,8 +69,8 @@ newrelic==1.8.0.13 sphinx==1.1.3 # Used for Internationalization and localization -Babel==0.9.6 -transifex-client==0.8 +Babel==1.3 +transifex-client==0.9.1 # Used for testing coverage==3.6 @@ -80,7 +81,7 @@ nosexcover==1.0.7 pep8==1.4.5 pylint==0.28 rednose==0.3 -selenium==2.33.0 +selenium==2.34.0 splinter==0.5.4 django_nose==1.1 django-jasmine==0.3.2 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 0d77dac179..a2b4dde59c 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -8,6 +8,6 @@ -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@b697bebd45deebd0f868613fab6722a0460ca0c1#egg=XBlock +-e git+https://github.com/edx/XBlock.git@446668fddc75b78512eef4e9425cbc9a3327606f#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail --e git+https://github.com/edx/diff-cover.git@v0.2.0#egg=diff_cover +-e git+https://github.com/edx/diff-cover.git@v0.2.1#egg=diff_cover diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh index 25e1b7520b..73ae93ab2d 100755 --- a/scripts/create-dev-env.sh +++ b/scripts/create-dev-env.sh @@ -387,12 +387,12 @@ export WORKON_HOME=$PYTHON_DIR if [[ `type -t mkvirtualenv` != "function" ]]; then case `uname -s` in Darwin) - source `which virtualenvwrapper.sh` + VEWRAPPER=`which virtualenvwrapper.sh` ;; [Ll]inux) if [[ -f "/etc/bash_completion.d/virtualenvwrapper" ]]; then - source /etc/bash_completion.d/virtualenvwrapper + VEWRAPPER=/etc/bash_completion.d/virtualenvwrapper else error "Could not find virtualenvwrapper" exit 1 @@ -401,6 +401,7 @@ if [[ `type -t mkvirtualenv` != "function" ]]; then esac fi +source $VEWRAPPER # Create edX virtualenv and link it to repo # virtualenvwrapper automatically sources the activation script if [[ $systempkgs ]]; then @@ -514,11 +515,11 @@ if [[ ! $quiet ]]; then environment. Ensure the following lines are added to your login script, and source your login script if needed: - source `which virtualenvwrapper.sh` + source $VEWRAPPER Then, every time you're ready to work on the project, just run - $ workon mitx + $ workon edx-platform To start the Django on port 8000