diff --git a/AUTHORS b/AUTHORS index c700eab277..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 @@ -83,4 +83,4 @@ Ian Hoover Mukul Goyal Robert Marks Yarko Tymciurak - +Miles Steele diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 68308980ad..897ea3ae3a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,33 @@ 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 +status has changed. This will not be in use until the course creator table +is enabled. + +Studio: Added improvements to Course Creation: richer error messaging, tip +text, and fourth field for course run. + +Blades: New features for VideoAlpha player: +1.) Controls are auto hidden after a delay of mouse inactivity - the full video +becomes visible. +2.) When captions (CC) button is pressed, captions stick (not auto hidden after +a delay of mouse inactivity). The video player size does not change - the video +is down-sized and placed in the middle of the black area. +3.) All source code of Video Alpha 2 is written in JavaScript. It is not a basic +conversion from CoffeeScript. The structure of the player has been changed. +4.) A lot of additional unit tests. + LMS: Added user preferences (arbitrary user/key/value tuples, for which which user/key is unique) and a REST API for reading users and preferences. Access to the REST API is restricted by use of the @@ -14,10 +41,16 @@ the setting is not present, the API is disabled). LMS: Added endpoints for AJAX requests to enable/disable notifications (which are not yet implemented) and a one-click unsubscribe page. +Studio: Allow instructors of a course to designate other staff as instructors; +this allows instructors to hand off management of a course to someone else. + Common: Add a manage.py that knows about edx-platform specific settings and projects Common: 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/README.md b/README.md index 4ab07b3550..0261f87b46 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ installation process. Providers. You should use VirtualBox >= 4.2.12. (Windows: later/earlier VirtualBox versions than 4.2.12 have been reported to not work well with Vagrant. If this is still a problem, you can - install 4.2.12 from https://www.virtualbox.org/wiki/Download_Old_Builds_4_2). + install 4.2.12 from http://download.virtualbox.org/virtualbox/4.2.12/). 4. Install Vagrant: http://www.vagrantup.com/ (Vagrant 1.2.2 or later) 5. Open a terminal -6. Download the project: `git clone git://github.com/edx/edx-platform.git` +6. Download the project: `git clone https://github.com/edx/edx-platform.git` 7. Enter the project directory: `cd edx-platform/` 8. (Windows only) Run the commands to [deal with line endings and symlinks under Windows](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#dealing-with-line-endings-and-symlinks-under-windows) diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 0f2e60dd6e..4923851445 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -178,10 +178,12 @@ def _remove_user_from_group(user, group_name): user.save() -def is_user_in_course_group_role(user, location, role): +def is_user_in_course_group_role(user, location, role, check_staff=True): if user.is_active and user.is_authenticated: # all "is_staff" flagged accounts belong to all groups - return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 + if check_staff and user.is_staff: + return True + return user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 return False diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 7e1e6470ff..7ea09333ed 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -1,7 +1,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from lxml import html +from lxml import html, etree import re from django.http import HttpResponseBadRequest import logging @@ -74,34 +74,44 @@ def update_course_updates(location, update, passed_id=None): escaped = django.utils.html.escape(course_updates.data) course_html_parsed = html.fromstring("
  1. " + escaped + "
") + # if there's no ol, create it + if course_html_parsed.tag != 'ol': + # surround whatever's there w/ an ol + if course_html_parsed.tag != 'li': + # but first wrap in an li + li = etree.Element('li') + li.append(course_html_parsed) + course_html_parsed = li + ol = etree.Element('ol') + ol.append(course_html_parsed) + course_html_parsed = ol + # No try/catch b/c failure generates an error back to client new_html_parsed = html.fromstring('
  • ' + update['date'] + '

    ' + update['content'] + '
  • ') - # Confirm that root is
      , iterate over
    1. , pull out

      subs and then rest of val - if course_html_parsed.tag == 'ol': - # ??? Should this use the id in the json or in the url or does it matter? - if passed_id is not None: - idx = get_idx(passed_id) - # idx is count from end of list - course_html_parsed[-idx] = new_html_parsed - else: - course_html_parsed.insert(0, new_html_parsed) + # ??? Should this use the id in the json or in the url or does it matter? + if passed_id is not None: + idx = get_idx(passed_id) + # idx is count from end of list + course_html_parsed[-idx] = new_html_parsed + else: + course_html_parsed.insert(0, new_html_parsed) - idx = len(course_html_parsed) - passed_id = course_updates.location.url() + "/" + str(idx) + idx = len(course_html_parsed) + passed_id = course_updates.location.url() + "/" + str(idx) - # update db record - course_updates.data = html.tostring(course_html_parsed) - modulestore('direct').update_item(location, course_updates.data) + # update db record + course_updates.data = html.tostring(course_html_parsed) + modulestore('direct').update_item(location, course_updates.data) - if (len(new_html_parsed) == 1): - content = new_html_parsed[0].tail - else: - content = "\n".join([html.tostring(ele) for ele in new_html_parsed[1:]]) + if (len(new_html_parsed) == 1): + content = new_html_parsed[0].tail + else: + content = "\n".join([html.tostring(ele) for ele in new_html_parsed[1:]]) - return {"id": passed_id, - "date": update['date'], - "content": content} + return {"id": passed_id, + "date": update['date'], + "content": content} def delete_course_update(location, update, passed_id): 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/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index cdba6d9cb2..18e179abdb 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,7 +2,7 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true +from nose.tools import assert_false, assert_equal, assert_regexp_matches from common import type_in_codemirror, press_the_notification_button KEY_CSS = '.key input.policy-key' @@ -90,18 +90,18 @@ def the_policy_key_value_is_changed(step): ############# HELPERS ############### def assert_policy_entries(expected_keys, expected_values): - for counter in range(len(expected_keys)): - index = get_index_of(expected_keys[counter]) - assert_false(index == -1, "Could not find key: " + expected_keys[counter]) - assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect") + for key, value in zip(expected_keys, expected_values): + index = get_index_of(key) + assert_false(index == -1, "Could not find key: {key}".format(key=key)) + assert_equal(value, world.css_find(VALUE_CSS)[index].value, "value is incorrect") def get_index_of(expected_key): - for counter in range(len(world.css_find(KEY_CSS))): - # Sometimes get stale reference if I hold on to the array of elements - key = world.css_value(KEY_CSS, index=counter) + for i, element in enumerate(world.css_find(KEY_CSS)): + # Sometimes get stale reference if I hold on to the array of elements + key = world.css_value(KEY_CSS, index=i) if key == expected_key: - return counter + return i return -1 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 d357c8ae96..69d2213eb4 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -4,10 +4,11 @@ from lettuce import world, step from nose.tools import assert_true -from auth.authz import get_user_by_email +from auth.authz import get_user_by_email, get_course_groupname_for_role from selenium.webdriver.common.keys import Keys import time +from django.contrib.auth.models import Group from logging import getLogger logger = getLogger(__name__) @@ -53,6 +54,12 @@ def i_have_opened_a_new_course(_step): open_new_course() +@step('(I select|s?he selects) the new course') +def select_new_course(_step, whom): + course_link_css = 'a.course-link' + world.css_click(course_link_css) + + @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(_step, name): css = 'a.action-%s' % name.lower() @@ -63,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 "(.*)"$') @@ -118,14 +129,18 @@ def create_studio_user( registration.register(studio_user) registration.activate() + return studio_user + def fill_in_course_info( name='Robot Super Course', org='MITx', - num='999'): + num='101', + run='2013_Spring'): world.css_fill('.new-course-name', name) world.css_fill('.new-course-org', org) world.css_fill('.new-course-number', num) + world.css_fill('.new-course-run', run) def log_into_studio( @@ -133,40 +148,30 @@ def log_into_studio( email='robot+studio@edx.org', password='test'): - world.browser.cookies.delete() + world.log_in(username=uname, password=password, email=email, name='Robot Studio') + # 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) - def create_a_course(): - world.scenario_dict['COURSE'] = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + world.scenario_dict['COURSE'] = course + + user = world.scenario_dict.get("USER") + if not user: + user = get_user_by_email('robot+studio@edx.org') # Add the user to the instructor group of the course # so they will have the permissions to see it in studio - - course = world.GroupFactory.create(name='instructor_MITx/{}/{}'.format(world.scenario_dict['COURSE'].number, - world.scenario_dict['COURSE'].display_name.replace(" ", "_"))) - if world.scenario_dict.get('USER') is None: - user = world.scenario_dict['USER'] - else: - user = get_user_by_email('robot+studio@edx.org') - user.groups.add(course) + for role in ("staff", "instructor"): + groupname = get_course_groupname_for_role(course.location, role) + group, __ = Group.objects.get_or_create(name=groupname) + user.groups.add(group) user.save() - 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)) @@ -214,6 +219,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') @@ -222,14 +247,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$') @@ -242,7 +267,7 @@ def save_button_disabled(step): @step('I confirm the prompt') def confirm_the_prompt(step): prompt_css = 'a.button.action-primary' - world.css_click(prompt_css) + world.css_click(prompt_css, success_condition=lambda: not world.css_visible(prompt_css)) @step(u'I am shown a (.*)$') @@ -251,7 +276,8 @@ 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(): g._element.send_keys(Keys.COMMAND + 'a') @@ -259,3 +285,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 2b206e4466..606e3dcee8 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -12,11 +12,20 @@ def create_component_instance(step, component_button_css, category, has_multiple_templates=True): click_new_component_button(step, component_button_css) + if category in ('problem', 'html'): + def animation_done(_driver): + return world.browser.evaluate_script("$('div.new-component').css('display')") == 'none' + world.wait_for(animation_done) if has_multiple_templates: click_component_from_menu(category, boilerplate, expected_css) - assert_equal(1, len(world.css_find(expected_css))) + assert_equal( + 1, + len(world.css_find(expected_css)), + "Component instance with css {css} was not created successfully".format(css=expected_css)) + + @world.absorb def click_new_component_button(step, component_button_css): @@ -39,19 +48,32 @@ def click_component_from_menu(category, boilerplate, expected_css): elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category) elements = world.css_find(elem_css) assert_equal(len(elements), 1) - world.css_click(elem_css) + world.wait_for(lambda _driver: world.css_visible(elem_css)) + world.css_click(elem_css, success_condition=lambda: 1 == len(world.css_find(expected_css))) @world.absorb 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 a') + + +@world.absorb +def edit_component(): + world.wait_for(lambda _driver: world.css_visible('a.edit-button')) world.css_click('a.edit-button') - world.css_click('#settings-mode') @world.absorb def verify_setting_entry(setting, display_name, value, explicitly_set): assert_equal(display_name, setting.find_by_css('.setting-label')[0].value) - assert_equal(value, setting.find_by_css('.setting-input')[0].value) + # 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')) @@ -92,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.feature b/cms/djangoapps/contentstore/features/course-overview.feature index b3041b9b18..a9aed5d982 100644 --- a/cms/djangoapps/contentstore/features/course-overview.feature +++ b/cms/djangoapps/contentstore/features/course-overview.feature @@ -63,3 +63,10 @@ Feature: Course Overview When I navigate to the course overview page And I change an assignment's grading status Then I am shown a notification + + Scenario: Notification is shown on subsection reorder + Given I have opened a new course section in Studio + And I have added a new subsection + And I have added a new subsection + When I reorder subsections + Then I am shown a notification diff --git a/cms/djangoapps/contentstore/features/course-overview.py b/cms/djangoapps/contentstore/features/course-overview.py index 10fa6453b2..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) @@ -124,3 +124,14 @@ def all_sections_are_collapsed(step): def change_grading_status(step): world.css_find('a.menu-toggle').click() world.css_find('.menu li').first.click() + + +@step(u'I reorder subsections') +def reorder_subsections(_step): + draggable_css = 'a.drag-handle' + ele = world.css_find(draggable_css).first + ele.action_chains.drag_and_drop_by_offset( + ele._element, + 30, + 0 + ).perform() diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature index fc1212f398..95843fc423 100644 --- a/cms/djangoapps/contentstore/features/course-team.feature +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -1,7 +1,7 @@ Feature: Course Team As a course author, I want to be able to add others to my team - Scenario: Users can add other users + Scenario: Admins can add other users Given I have opened a new course in Studio And the user "alice" exists And I am viewing the course team settings @@ -9,16 +9,18 @@ Feature: Course Team And "alice" logs in Then she does see the course on her page - Scenario: Added users cannot delete or add other users + Scenario: Added admins cannot delete or add other users Given I have opened a new course in Studio And the user "bob" exists And I am viewing the course team settings When I add "bob" to the course team And "bob" logs in + And he selects the new course + And he views the course team settings Then he cannot delete users And he cannot add users - Scenario: Users can delete other users + Scenario: Admins can delete other users Given I have opened a new course in Studio And the user "carol" exists And I am viewing the course team settings @@ -27,8 +29,60 @@ Feature: Course Team And "carol" logs in Then she does not see the course on her page - Scenario: Users cannot add users that do not exist + Scenario: Admins cannot add users that do not exist Given I have opened a new course in Studio And I am viewing the course team settings When I add "dennis" to the course team Then I should see "Could not find user by email address" somewhere on the page + + Scenario: Admins should be able to make other people into admins + Given I have opened a new course in Studio + And the user "emily" exists + And I am viewing the course team settings + And I add "emily" to the course team + When I make "emily" a course team admin + And "emily" logs in + And she selects the new course + And she views the course team settings + Then "emily" should be marked as an admin + And she can add users + And she can delete users + + Scenario: Admins should be able to remove other admins + Given I have opened a new course in Studio + And the user "frank" exists as a course admin + And I am viewing the course team settings + When I remove admin rights from "frank" + And "frank" logs in + And he selects the new course + And he views the course team settings + Then "frank" should not be marked as an admin + And he cannot add users + And he cannot delete users + + Scenario: Admins should be able to give course ownership to someone else + Given I have opened a new course in Studio + And the user "gina" exists + And I am viewing the course team settings + When I add "gina" to the course team + And I make "gina" a course team admin + And I remove admin rights from myself + And "gina" logs in + 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 + Then I do not see the course on my page + + Scenario: Admins should be able to remove their own admin rights + Given I have opened a new course in Studio + And the user "harry" exists as a course admin + And I am viewing the course team settings + Then I should be marked as an admin + And I can add users + And I can delete users + When I remove admin rights from myself + Then I should not be marked as an admin + And I cannot add users + And I cannot delete users + And I cannot make myself a course team admin diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py index ad5d31977c..07c30e1187 100644 --- a/cms/djangoapps/contentstore/features/course-team.py +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -3,40 +3,88 @@ from lettuce import world, step from common import create_studio_user, log_into_studio +from django.contrib.auth.models import Group +from auth.authz import get_course_groupname_for_role PASSWORD = 'test' EMAIL_EXTENSION = '@edx.org' -@step(u'I am viewing the course team settings') -def view_grading_settings(_step): +@step(u'(I am viewing|s?he views) the course team settings') +def view_grading_settings(_step, whom): world.click_course_settings() link_css = 'li.nav-course-settings-team a' world.css_click(link_css) -@step(u'the user "([^"]*)" exists$') -def create_other_user(_step, name): - create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION)) +@step(u'the user "([^"]*)" exists( as a course (admin|staff member))?$') +def create_other_user(_step, name, has_extra_perms, role_name): + email = name + EMAIL_EXTENSION + user = create_studio_user(uname=name, password=PASSWORD, email=email) + if has_extra_perms: + location = world.scenario_dict["COURSE"].location + if role_name == "admin": + # admins get staff privileges, as well + roles = ("staff", "instructor") + else: + roles = ("staff",) + for role in roles: + groupname = get_course_groupname_for_role(location, role) + group, __ = Group.objects.get_or_create(name=groupname) + user.groups.add(group) + user.save() @step(u'I add "([^"]*)" to the course team') def add_other_user(_step, name): - new_user_css = 'a.new-user-button' + new_user_css = 'a.create-user-button' world.css_click(new_user_css) + world.wait(0.5) - email_css = 'input.email-input' - f = world.css_find(email_css) - f._element.send_keys(name, EMAIL_EXTENSION) - - confirm_css = '#add_user' + email_css = 'input#user-email-input' + 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) @step(u'I delete "([^"]*)" from the course team') def delete_other_user(_step, name): - to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION) + to_delete_css = '.user-item .item-actions a.remove-user[data-id="{email}"]'.format( + 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") + + +@step(u's?he deletes me from the course team') +def other_delete_self(_step): + to_delete_css = '.user-item .item-actions a.remove-user[data-id="{email}"]'.format( + email="robot+studio@edx.org") + world.css_click(to_delete_css) + # confirm prompt + world.css_click(".wrapper-prompt-warning .action-primary") + + +@step(u'I make "([^"]*)" a course team admin') +def make_course_team_admin(_step, name): + admin_btn_css = '.user-item[data-email="{email}"] .user-actions .add-admin-role'.format( + email=name+EMAIL_EXTENSION) + world.css_click(admin_btn_css) + + +@step(u'I remove admin rights from ("([^"]*)"|myself)') +def remove_course_team_admin(_step, outer_capture, name): + if outer_capture == "myself": + email = world.scenario_dict["USER"].email + else: + email = name + EMAIL_EXTENSION + admin_btn_css = '.user-item[data-email="{email}"] .user-actions .remove-admin-role'.format( + email=email) + world.css_click(admin_btn_css) @step(u'"([^"]*)" logs in$') @@ -44,24 +92,62 @@ def other_user_login(_step, name): log_into_studio(uname=name, password=PASSWORD, 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, doesnt_see_course, gender): - class_css = 'span.class-name' +def see_course(_step, inverted, gender='self'): + class_css = 'h3.course-title' all_courses = world.css_find(class_css, wait_time=1) all_names = [item.html for item in all_courses] - if doesnt_see_course: + if inverted: assert not world.scenario_dict['COURSE'].display_name in all_names else: assert world.scenario_dict['COURSE'].display_name in all_names -@step(u's?he cannot delete users') -def cannot_delete(_step): +@step(u'"([^"]*)" should( not)? be marked as an admin') +def marked_as_admin(_step, name, inverted): + flag_css = '.user-item[data-email="{email}"] .flag-role.flag-role-admin'.format( + email=name+EMAIL_EXTENSION) + if inverted: + assert world.is_css_not_present(flag_css) + else: + assert world.is_css_present(flag_css) + + +@step(u'I should( not)? be marked as an admin') +def self_marked_as_admin(_step, inverted): + return marked_as_admin(_step, "robot+studio", inverted) + + +@step(u'I can(not)? delete users') +@step(u's?he can(not)? delete users') +def can_delete_users(_step, inverted): to_delete_css = 'a.remove-user' - assert world.is_css_not_present(to_delete_css) + if inverted: + assert world.is_css_not_present(to_delete_css) + else: + assert world.is_css_present(to_delete_css) -@step(u's?he cannot add users') -def cannot_add(_step): - add_css = 'a.new-user' - assert world.is_css_not_present(add_css) +@step(u'I can(not)? add users') +@step(u's?he can(not)? add users') +def can_add_users(_step, inverted): + add_css = 'a.create-user-button' + if inverted: + assert world.is_css_not_present(add_css) + else: + assert world.is_css_present(add_css) + + +@step(u'I can(not)? make ("([^"]*)"|myself) a course team admin') +@step(u's?he can(not)? make ("([^"]*)"|me) a course team admin') +def can_make_course_admin(_step, inverted, outer_capture, name): + if outer_capture == "myself": + email = world.scenario_dict["USER"].email + else: + email = name + EMAIL_EXTENSION + add_button_css = '.user-item[data-email="{email}"] .add-admin-role'.format(email=email) + if inverted: + assert world.is_css_not_present(add_button_css) + else: + assert world.is_css_present(add_button_css) diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature index 81714c43ae..fb18e51f2d 100644 --- a/cms/djangoapps/contentstore/features/course-updates.feature +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -6,6 +6,7 @@ Feature: Course updates And I go to the course updates page When I add a new update with the text "Hello" Then I should see the update "Hello" + And I see a "saving" notification Scenario: Users can edit updates Given I have opened a new course in Studio @@ -13,15 +14,16 @@ Feature: Course updates When I add a new update with the text "Hello" And I modify the text to "Goodbye" Then I should see the update "Goodbye" + And I see a "saving" notification Scenario: Users can delete updates Given I have opened a new course in Studio And I go to the course updates page And I add a new update with the text "Hello" - When I will confirm all alerts And I delete the update + And I confirm the prompt Then I should not see the update "Hello" - + And I see a "deleting" notification Scenario: Users can edit update dates Given I have opened a new course in Studio @@ -29,9 +31,11 @@ Feature: Course updates And I add a new update with the text "Hello" When I edit the date to "June 1, 2013" Then I should see the date "June 1, 2013" + And I see a "saving" notification Scenario: Users can change handouts Given I have opened a new course in Studio And I go to the course updates page When I modify the handout to "
        Test
      " Then I see the handout "Test" + And I see a "saving" notification 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/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index 13927a7d89..15a7c4b9ab 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -19,7 +19,7 @@ def i_see_only_the_settings_and_values(step): world.verify_all_setting_entries( [ ['Category', "Week 1", False], - ['Display Name', "Discussion Tag", False], + ['Display Name', "Discussion", False], ['Subcategory', "Topic-Level Student-Visible Label", False] ]) diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature index 6cd455d681..4cd5e1c1b9 100644 --- a/cms/djangoapps/contentstore/features/html-editor.feature +++ b/cms/djangoapps/contentstore/features/html-editor.feature @@ -11,3 +11,8 @@ Feature: HTML Editor And I edit and select Settings Then I can modify the display name And my display name change is persisted on save + + Scenario: Edit High Level source is available for LaTeX html + Given I have created an E-text Written in LaTeX + When I edit and select Settings + Then Edit High Level Source is visible diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py index c3e0afa480..d89f052dcc 100644 --- a/cms/djangoapps/contentstore/features/html-editor.py +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -15,3 +15,14 @@ def i_created_blank_html_page(step): @step('I see only the HTML display name setting$') def i_see_only_the_html_display_name(step): world.verify_all_setting_entries([['Display Name', "Text", False]]) + + +@step('I have created an E-text Written in LaTeX$') +def i_created_blank_html_page(step): + world.create_component_instance( + step, + '.large-html-icon', + 'html', + '.xmodule_HtmlModule', + 'latex_html.yaml' + ) 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 565a35f802..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') @@ -155,6 +165,10 @@ def cancel_does_not_save_changes(step): @step('I have created a LaTeX Problem') def create_latex_problem(step): world.click_new_component_button(step, '.large-problem-icon') + + def animation_done(_driver): + return world.browser.evaluate_script("$('div.new-component').css('display')") == 'none' + world.wait_for(animation_done) # Go to advanced tab. world.css_click('#ui-id-2') world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule') @@ -209,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 4b69b9b37e..3ca8e1676d 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -35,15 +35,20 @@ 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') -@step('I see a "saving" notification') -def i_see_a_saving_notification(step): +@step('I see a "(saving|deleting)" notification') +def i_see_a_mini_notification(_step, _type): saving_css = '.wrapper-notification-mini' assert world.is_css_present(saving_css) diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index 03a1c9524a..c249ad61e8 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -8,5 +8,21 @@ Feature: Sign in When I click the link with the text "Sign Up" And I fill in the registration form And I press the Create My Account button on the registration form - Then I should see be on the studio home page - And I should see the message "please click on the activation link in your email." + Then I should see an email verification prompt + + Scenario: Login with a valid redirect + Given I have opened a new course in Studio + And I am not logged in + And I visit the url "/MITx/999/course/Robot_Super_Course" + And I should see that the path is "/signin?next=/MITx/999/course/Robot_Super_Course" + When I fill in and submit the signin form + And I wait for "2" seconds + Then I should see that the path is "/MITx/999/course/Robot_Super_Course" + + Scenario: Login with an invalid redirect + Given I have opened a new course in Studio + And I am not logged in + And I visit the url "/signin?next=http://www.google.com/" + When I fill in and submit the signin form + And I wait for "2" seconds + Then I should see that the path is "/" diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 49a305f70b..94c6e6f18e 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -2,7 +2,6 @@ #pylint: disable=W0621 from lettuce import world, step -from common import * @step('I fill in the registration form$') @@ -23,11 +22,17 @@ def i_press_the_button_on_the_registration_form(step): world.css_click(submit_css) -@step('I should see be on the studio home page$') -def i_should_see_be_on_the_studio_home_page(step): - assert world.browser.find_by_css('div.inner-wrapper') +@step('I should see an email verification prompt') +def i_should_see_an_email_verification_prompt(step): + world.css_has_text('h1.page-header', u'My Courses') + world.css_has_text('div.msg h3.title', u'We need to verify your email address') -@step(u'I should see the message "([^"]*)"$') -def i_should_see_the_message(step, msg): - assert world.browser.is_text_present(msg, 5) +@step(u'I fill in and submit the signin form$') +def i_fill_in_the_signin_form(step): + def fill_login_form(): + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill('robot+studio@edx.org') + login_form.find_by_name('password').fill('test') + login_form.find_by_name('submit').click() + world.retry_on_exception(fill_login_form) 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 0c700956e3..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) @@ -58,7 +53,7 @@ def delete_file(_step, file_name): world.css_click(delete_css, index=index) prompt_confirm_css = 'li.nav-item > a.action-primary' - world.css_click(prompt_confirm_css) + world.css_click(prompt_confirm_css, success_condition=lambda: not world.css_visible(prompt_confirm_css)) @step(u'I should see only one "([^"]*)"$') @@ -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 93d638e621..ad3229ab53 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -19,5 +19,22 @@ def i_see_the_correct_settings_and_values(step): @step('I have set "show captions" to (.*)') def set_show_captions(step, setting): world.css_click('a.edit-button') + world.wait_for(lambda _driver: world.css_visible('a.save-button')) world.browser.select('Show Captions', setting) world.css_click('a.save-button') + + +@step('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.py deleted file mode 100644 index 0ca50acb50..0000000000 --- a/cms/djangoapps/contentstore/management/commands/clone.py +++ /dev/null @@ -1,37 +0,0 @@ -### -### Script for cloning a course -### -from django.core.management.base import BaseCommand, CommandError -from xmodule.modulestore.store_utilities import clone_course -from xmodule.modulestore.django import modulestore -from xmodule.contentstore.django import contentstore -from xmodule.course_module import CourseDescriptor - -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 -# - - -class Command(BaseCommand): - help = 'Clone a MongoDB backed course to another location' - - def handle(self, *args, **options): - if len(args) != 2: - raise CommandError("clone requires two arguments: ") - - source_location_str = args[0] - dest_location_str = args[1] - - ms = modulestore('direct') - cs = contentstore() - - print "Cloning course {0} to {1}".format(source_location_str, dest_location_str) - - source_location = CourseDescriptor.id_to_location(source_location_str) - dest_location = CourseDescriptor.id_to_location(dest_location_str) - - if clone_course(ms, cs, source_location, dest_location): - print "copying User permissions..." - _copy_course_group(source_location, dest_location) diff --git a/cms/djangoapps/contentstore/management/commands/clone_course.py b/cms/djangoapps/contentstore/management/commands/clone_course.py new file mode 100644 index 0000000000..5fffe29543 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/clone_course.py @@ -0,0 +1,54 @@ +""" +Script for cloning a course +""" +from django.core.management.base import BaseCommand, CommandError +from xmodule.modulestore.store_utilities import clone_course +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.django import contentstore +from xmodule.course_module import CourseDescriptor + +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""" + help = 'Clone a MongoDB backed course to another location' + + def handle(self, *args, **options): + "Execute the command" + if len(args) != 2: + raise CommandError("clone requires two arguments: ") + + source_course_id = args[0] + dest_course_id = args[1] + + mstore = modulestore('direct') + cstore = contentstore() + + 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)) + + 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/management/commands/dump_course_structure.py b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py index d9b7c55cbd..139c603172 100644 --- a/cms/djangoapps/contentstore/management/commands/dump_course_structure.py +++ b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py @@ -1,3 +1,6 @@ +""" +Script for dumping course dumping the course structure +""" from django.core.management.base import BaseCommand, CommandError from xmodule.course_module import CourseDescriptor from xmodule.modulestore.django import modulestore @@ -9,10 +12,14 @@ filter_list = ['xml_attributes', 'checklists'] class Command(BaseCommand): + """ + The Django command for dumping course structure + """ help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized in a JSON format. This can be used for analytics.''' def handle(self, *args, **options): + "Execute the command" if len(args) < 2 or len(args) > 3: raise CommandError("dump_course_structure requires two or more arguments: ||") @@ -32,7 +39,7 @@ class Command(BaseCommand): try: course = store.get_item(loc, depth=4) except: - print 'Could not find course at {0}'.format(course_id) + print('Could not find course at {0}'.format(course_id)) return info = {} diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index 90db8750d9..efeb5dc339 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -1,6 +1,6 @@ -### -### Script for exporting courseware from Mongo to a tar.gz file -### +""" +Script for exporting courseware from Mongo to a tar.gz file +""" import os from django.core.management.base import BaseCommand, CommandError @@ -10,20 +10,21 @@ from xmodule.contentstore.django import contentstore from xmodule.course_module import CourseDescriptor -unnamed_modules = 0 - - class Command(BaseCommand): + """ + Export the specified data directory into the default ModuleStore + """ help = 'Export the specified data directory into the default ModuleStore' def handle(self, *args, **options): + "Execute the command" if len(args) != 2: raise CommandError("export requires two arguments: ") course_id = args[0] output_path = args[1] - print "Exporting course id = {0} to {1}".format(course_id, output_path) + print("Exporting course id = {0} to {1}".format(course_id, output_path)) location = CourseDescriptor.id_to_location(course_id) diff --git a/cms/djangoapps/contentstore/management/commands/export_all_courses.py b/cms/djangoapps/contentstore/management/commands/export_all_courses.py index 69cfb298fb..2118551138 100644 --- a/cms/djangoapps/contentstore/management/commands/export_all_courses.py +++ b/cms/djangoapps/contentstore/management/commands/export_all_courses.py @@ -1,8 +1,6 @@ -### -### Script for exporting all courseware from Mongo to a directory -### -import os - +""" +Script for exporting all courseware from Mongo to a directory +""" from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.django import modulestore @@ -10,13 +8,12 @@ from xmodule.contentstore.django import contentstore from xmodule.course_module import CourseDescriptor -unnamed_modules = 0 - - class Command(BaseCommand): + """Export all courses from mongo to the specified data directory""" help = 'Export all courses from mongo to the specified data directory' def handle(self, *args, **options): + "Execute the command" if len(args) != 1: raise CommandError("export requires one argument: ") @@ -27,14 +24,14 @@ class Command(BaseCommand): root_dir = output_path courses = ms.get_courses() - print "%d courses to export:" % len(courses) + print("%d courses to export:" % len(courses)) cids = [x.id for x in courses] - print cids + print(cids) for course_id in cids: - print "-"*77 - print "Exporting course id = {0} to {1}".format(course_id, output_path) + print("-"*77) + print("Exporting course id = {0} to {1}".format(course_id, output_path)) if 1: try: @@ -42,6 +39,6 @@ class Command(BaseCommand): course_dir = course_id.replace('/', '...') export_to_xml(ms, cs, location, root_dir, course_dir, modulestore()) except Exception as err: - print "="*30 + "> Oops, failed to export %s" % course_id - print "Error:" - print err + print("="*30 + "> Oops, failed to export %s" % course_id) + print("Error:") + print(err) diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 9b919daad0..46f439b055 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -1,6 +1,6 @@ -### -### Script for importing courseware from XML format -### +""" +Script for importing courseware from XML format +""" from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_importer import import_from_xml @@ -8,13 +8,14 @@ from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore -unnamed_modules = 0 - - class Command(BaseCommand): + """ + Import the specified data directory into the default ModuleStore + """ help = 'Import the specified data directory into the default ModuleStore' def handle(self, *args, **options): + "Execute the command" if len(args) == 0: raise CommandError("import requires at least one argument: [...]") @@ -23,8 +24,8 @@ class Command(BaseCommand): course_dirs = args[1:] else: course_dirs = None - print "Importing. Data_dir={data}, course_dirs={courses}".format( + print("Importing. Data_dir={data}, course_dirs={courses}".format( data=data_dir, - courses=course_dirs) + courses=course_dirs)) import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False, static_content_store=contentstore(), verbose=True) diff --git a/cms/djangoapps/contentstore/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py index 90d8b3c668..37d647fd1a 100644 --- a/cms/djangoapps/contentstore/management/commands/populate_creators.py +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -39,7 +39,7 @@ class Command(BaseCommand): # added with status granted above, and add_user_with_status_unrequested # will not try to add them again if they already exist in the course creator database. for user in get_users_with_staff_role(): - add_user_with_status_unrequested(admin, user) + add_user_with_status_unrequested(user) # There could be users who are not in either staff or instructor (they've # never actually done anything in Studio). I plan to add those as unrequested diff --git a/cms/djangoapps/contentstore/management/commands/xlint.py b/cms/djangoapps/contentstore/management/commands/xlint.py index 21c8e7d1f8..835b8b84df 100644 --- a/cms/djangoapps/contentstore/management/commands/xlint.py +++ b/cms/djangoapps/contentstore/management/commands/xlint.py @@ -1,18 +1,17 @@ +""" +Verify the structure of courseware as to it's suitability for import +To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)] +""" from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_importer import perform_xlint -unnamed_modules = 0 - - class Command(BaseCommand): - help = \ - ''' - Verify the structure of courseware as to it's suitability for import - To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)] - ''' + """Verify the structure of courseware as to it's suitability for import""" + help = "Verify the structure of courseware as to it's suitability for import" def handle(self, *args, **options): + "Execute the command" if len(args) == 0: raise CommandError("import requires at least one argument: [...]") @@ -21,7 +20,7 @@ class Command(BaseCommand): course_dirs = args[1:] else: course_dirs = None - print "Importing. Data_dir={data}, course_dirs={courses}".format( + print("Importing. Data_dir={data}, course_dirs={courses}".format( data=data_dir, - courses=course_dirs) + courses=course_dirs)) perform_xlint(data_dir, course_dirs, load_error_modules=False) diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index 58aee3c77d..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): """ @@ -50,9 +57,9 @@ class UploadTestCase(CourseTestCase): @skip("CorruptGridFile error on continuous integration server") def test_happy_path(self): - file = BytesIO("sample content") - file.name = "sample.txt" - resp = self.client.post(self.url, {"name": "my-name", "file": file}) + f = BytesIO("sample content") + f.name = "sample.txt" + resp = self.client.post(self.url, {"name": "my-name", "file": f}) self.assert2XX(resp.status_code) def test_no_file(self): diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 02999f6567..5a99c37fbb 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -1,5 +1,5 @@ """ Unit tests for checklist methods in views.py. """ -from contentstore.utils import get_modulestore, get_url_reverse +from contentstore.utils import get_modulestore from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.tests.factories import CourseFactory from django.core.urlresolvers import reverse @@ -27,6 +27,7 @@ class ChecklistTestCase(CourseTestCase): """ self.assertEqual(persisted['short_description'], request['short_description']) compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded')) + pers, req = None, None for pers, req in zip(persisted['items'], request['items']): self.assertEqual(pers['short_description'], req['short_description']) self.assertEqual(pers['long_description'], req['long_description']) @@ -38,7 +39,11 @@ class ChecklistTestCase(CourseTestCase): def test_get_checklists(self): """ Tests the get checklists method. """ - checklists_url = get_url_reverse('Checklists', self.course) + checklists_url = reverse("checklists", kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + }) response = self.client.get(checklists_url) self.assertContains(response, "Getting Started With Studio") payload = response.content diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index a51110163d..838af2cafa 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 @@ -95,8 +95,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) def tearDown(self): - mongo = MongoClient() - mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) _CONTENTSTORE.clear() def check_components_on_page(self, component_types, expected_types): @@ -304,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']) @@ -604,6 +613,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', + 'run': '2013_Spring' } module_store = modulestore('direct') @@ -612,12 +622,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 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/Robot_Super_Course') + self.assertEqual(data['id'], 'i4x://MITx/999/course/2013_Spring') content_store = contentstore() source_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') - dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course') + dest_location = CourseDescriptor.id_to_location('MITx/999/2013_Spring') clone_course(module_store, content_store, source_location, dest_location) @@ -855,6 +865,68 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): shutil.rmtree(root_dir) + def test_export_course_with_metadata_only_word_cloud(self): + """ + Similar to `test_export_course_with_metadata_only_video`. + """ + module_store = modulestore('direct') + draft_store = modulestore('draft') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['word_cloud']) + location = CourseDescriptor.id_to_location('HarvardX/ER22x/2013_Spring') + + verticals = module_store.get_items(['i4x', 'HarvardX', 'ER22x', 'vertical', None, None]) + + self.assertGreater(len(verticals), 0) + + parent = verticals[0] + + ItemFactory.create(parent_location=parent.location, category="word_cloud", display_name="untitled") + + root_dir = path(mkdtemp_clean()) + + print 'Exporting to tempdir = {0}'.format(root_dir) + + # export out to a tempdir + export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store) + + shutil.rmtree(root_dir) + + def test_empty_data_roundtrip(self): + """ + Test that an empty `data` field is preserved through + export/import. + """ + module_store = modulestore('direct') + draft_store = modulestore('draft') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy']) + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') + + verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None]) + + self.assertGreater(len(verticals), 0) + + parent = verticals[0] + + # Create a module, and ensure that its `data` field is empty + word_cloud = ItemFactory.create(parent_location=parent.location, category="word_cloud", display_name="untitled") + del word_cloud.data + self.assertEquals(word_cloud.data, '') + + # Export the course + root_dir = path(mkdtemp_clean()) + export_to_xml(module_store, content_store, location, root_dir, 'test_roundtrip', draft_modulestore=draft_store) + + # Reimport and get the video back + import_from_xml(module_store, root_dir) + imported_word_cloud = module_store.get_item(Location(['i4x', 'edX', 'toy', 'word_cloud', 'untitled', None])) + + # It should now contain empty data + self.assertEquals(imported_word_cloud.data, '') + def test_course_handouts_rewrites(self): module_store = modulestore('direct') @@ -954,6 +1026,7 @@ class ContentStoreTest(ModuleStoreTestCase): 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', + 'run': '2013_Spring' } def tearDown(self): @@ -965,40 +1038,58 @@ class ContentStoreTest(ModuleStoreTestCase): """Test new course creation - happy path""" self.assert_created_course() - def assert_created_course(self): + def assert_created_course(self, number_suffix=None): """ Checks that the course was created properly. """ - resp = self.client.post(reverse('create_new_course'), self.course_data) + test_course_data = {} + test_course_data.update(self.course_data) + if number_suffix: + test_course_data['number'] = '{0}_{1}'.format(test_course_data['number'], number_suffix) + resp = self.client.post(reverse('create_new_course'), test_course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) - self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + 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 """ - self.assert_created_course() - self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course')) + test_course_data = self.assert_created_course(number_suffix=uuid4().hex) + self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data))) + + def _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""" self.client.post(reverse('create_new_course'), self.course_data) - self.assert_course_creation_failed('There is already a course defined with this name.') + self.assert_course_creation_failed('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.') def assert_course_creation_failed(self, error_message): """ 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""" self.client.post(reverse('create_new_course'), self.course_data) self.course_data['display_name'] = 'Robot Super Course Two' + self.course_data['run'] = '2013_Summer' - self.assert_course_creation_failed('There is already a course defined with the same organization and course number.') + self.assert_course_creation_failed('There is already a course defined with the same organization and course number. Please change at least one field to be unique.') def test_create_course_with_bad_organization(self): """Test new course creation - error path for bad organization name""" @@ -1071,7 +1162,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 ) @@ -1167,7 +1258,9 @@ class ContentStoreTest(ModuleStoreTestCase): # manage users resp = self.client.get(reverse('manage_users', - kwargs={'location': loc.url()})) + kwargs={'org': loc.org, + 'course': loc.course, + 'name': loc.name})) self.assertEqual(200, resp.status_code) # course info @@ -1359,3 +1452,63 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(course.textbooks, fetched_course.textbooks) # is this test too strict? i.e., it requires the dicts to be == self.assertEqual(course.checklists, fetched_course.checklists) + + +class MetadataSaveTestCase(ModuleStoreTestCase): + """ + Test that metadata is correctly decached. + """ + + def setUp(self): + sample_xml = ''' + + ''' + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) + + model_data = {'data': sample_xml} + self.descriptor = ItemFactory.create(parent_location=course_location, category='video', data=model_data) + + def test_metadata_persistence(self): + """ + Test that descriptors which set metadata fields in their + constructor are correctly persisted. + """ + # We should start with a source field, from the XML's tag + self.assertIn('source', own_metadata(self.descriptor)) + attrs_to_strip = { + 'show_captions', + 'youtube_id_1_0', + 'youtube_id_0_75', + 'youtube_id_1_25', + 'youtube_id_1_5', + 'start_time', + 'end_time', + 'source', + 'track' + } + # We strip out all metadata fields to reproduce a bug where + # constructors which set their fields (e.g. Video) didn't have + # those changes persisted. So in the end we have the XML data + # in `descriptor.data`, but not in the individual fields + fields = self.descriptor.fields + for field in fields: + if field.name in attrs_to_strip: + field.delete_from(self.descriptor) + + # Assert that we correctly stripped the field + self.assertNotIn('source', own_metadata(self.descriptor)) + get_modulestore(self.descriptor.location).update_metadata( + self.descriptor.location, + own_metadata(self.descriptor) + ) + module = get_modulestore(self.descriptor.location).get_item(self.descriptor.location) + # Assert that get_item correctly sets the metadata + self.assertIn('source', own_metadata(module)) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 0862eb462d..2007ba2f69 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -18,8 +18,6 @@ from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata -from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.django import modulestore from xmodule.fields import Date from .utils import CourseTestCase @@ -167,8 +165,8 @@ class CourseDetailsViewTest(CourseTestCase): self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val)) @staticmethod - def convert_datetime_to_iso(dt): - return Date().to_json(dt) + def convert_datetime_to_iso(datetime_obj): + return Date().to_json(datetime_obj) def test_update_and_fetch(self): loc = self.course.location diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index 30114496c8..c121b1bc09 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -2,6 +2,7 @@ from contentstore.tests.test_course_settings import CourseTestCase from django.core.urlresolvers import reverse import json +from xmodule.modulestore.django import modulestore class CourseUpdateTest(CourseTestCase): @@ -145,3 +146,36 @@ class CourseUpdateTest(CourseTestCase): resp = self.client.delete(url) payload = json.loads(resp.content) self.assertTrue(len(payload) == before_delete - 1) + + def test_no_ol_course_update(self): + '''Test trying to add to a saved course_update which is not an ol.''' + # get the updates and set to something wrong + location = self.course.location.replace(category='course_info', name='updates') + modulestore('direct').create_and_save_xmodule(location) + course_updates = modulestore('direct').get_item(location) + course_updates.data = 'bad news' + modulestore('direct').update_item(location, course_updates.data) + + init_content = '' + payload = {'content': content, + 'date': 'January 8, 2013'} + url = reverse('course_info_json', + kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'provided_id': ''}) + + resp = self.client.post(url, json.dumps(payload), "application/json") + + payload = json.loads(resp.content) + + self.assertHTMLEqual(payload['content'], content) + + # now confirm that the bad news and the iframe make up 2 updates + url = reverse('course_info_json', + kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'provided_id': ''}) + resp = self.client.get(url) + payload = json.loads(resp.content) + self.assertTrue(len(payload) == 2) diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py index 84643f7787..e12711a6ff 100644 --- a/cms/djangoapps/contentstore/tests/test_crud.py +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -127,7 +127,7 @@ class TemplateTests(unittest.TestCase): persistent_factories.ItemFactory.create(display_name='chapter 1', parent_location=test_course.location) - id_locator = CourseLocator(course_id=test_course.location.course_id, revision='draft') + id_locator = CourseLocator(course_id=test_course.location.course_id, branch='draft') guid_locator = CourseLocator(version_guid=test_course.location.version_guid) # verify it can be retireved by id self.assertIsInstance(modulestore('split').get_course(id_locator), CourseDescriptor) diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index 88df19ec2d..e6baf57213 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -85,9 +85,11 @@ class InternationalizationTest(ModuleStoreTestCase): HTTP_ACCEPT_LANGUAGE='fr' ) - TEST_STRING = u'

      ' \ - + u'My \xc7\xf6\xfcrs\xe9s L#' \ - + u'

      ' + TEST_STRING = ( + u'

      ' + u'My \xc7\xf6\xfcrs\xe9s L#' + u'

      ' + ) self.assertContains(resp, TEST_STRING, diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 578b82b3cf..827dd1b054 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -14,19 +14,26 @@ class DeleteItem(CourseTestCase): super(DeleteItem, self).setUp() self.course = CourseFactory.create(org='mitX', number='333', display_name='Dummy Course') - def testDeleteStaticPage(self): + def test_delete_static_page(self): # Add static tab data = json.dumps({ 'parent_location': 'i4x://mitX/333/course/Dummy_Course', 'category': 'static_tab' }) - resp = self.client.post(reverse('create_item'), data, - content_type="application/json") + resp = self.client.post( + reverse('create_item'), + data, + content_type="application/json" + ) self.assertEqual(resp.status_code, 200) # Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore). - resp = self.client.post(reverse('delete_item'), resp.content, "application/json") + resp = self.client.post( + reverse('delete_item'), + resp.content, + "application/json" + ) self.assertEqual(resp.status_code, 200) @@ -122,6 +129,7 @@ class TestCreateItem(CourseTestCase): ) self.assertEqual(resp.status_code, 200) + class TestEditItem(CourseTestCase): """ Test contentstore.views.item.save_item @@ -151,10 +159,10 @@ class TestEditItem(CourseTestCase): chap_location = self.response_id(resp) resp = self.client.post( reverse('create_item'), - json.dumps( - {'parent_location': chap_location, - 'category': 'sequential' - }), + json.dumps({ + 'parent_location': chap_location, + 'category': 'sequential', + }), content_type="application/json" ) self.seq_location = self.response_id(resp) @@ -162,9 +170,10 @@ class TestEditItem(CourseTestCase): template_id = 'multiplechoice.yaml' resp = self.client.post( reverse('create_item'), - json.dumps({'parent_location': self.seq_location, - 'category': 'problem', - 'boilerplate': template_id + json.dumps({ + 'parent_location': self.seq_location, + 'category': 'problem', + 'boilerplate': template_id, }), content_type="application/json" ) @@ -195,7 +204,6 @@ class TestEditItem(CourseTestCase): problem = modulestore('draft').get_item(self.problems[0]) self.assertEqual(problem.rerandomize, 'never') - def test_null_field(self): """ Sending null in for a field 'deletes' it @@ -240,4 +248,3 @@ class TestEditItem(CourseTestCase): sequential = modulestore().get_item(self.seq_location) self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) - diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index f945ef50fc..a9216da612 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -1,15 +1,384 @@ +""" +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): def setUp(self): super(UsersTestCase, self).setUp() - self.url = reverse("add_user", kwargs={"location": ""}) + self.ext_user = User.objects.create_user( + "joe", "joe@comedycentral.com", "haha") + self.ext_user.is_active = True + self.ext_user.is_staff = False + self.ext_user.save() + self.inactive_user = User.objects.create_user( + "carl", "carl@comedycentral.com", "haha") + self.inactive_user.is_active = False + self.inactive_user.is_staff = False + self.inactive_user.save() - def test_empty(self): - resp = self.client.post(self.url) + self.index_url = reverse("manage_users", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + }) + self.detail_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.ext_user.email, + }) + self.inactive_detail_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.inactive_user.email, + }) + self.invalid_detail_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": "nonexistent@user.com", + }) + self.staff_groupname = get_course_groupname_for_role(self.course.location, "staff") + self.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor") + + def test_index(self): + resp = self.client.get(self.index_url) + # ext_user is not currently a member of the course team, and so should + # not show up on the page. + self.assertNotContains(resp, self.ext_user.email) + + def test_index_member(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.get(self.index_url) + self.assertContains(resp, self.ext_user.email) + + def test_detail(self): + resp = self.client.get(self.detail_url) + self.assertEqual(resp.status_code, 200) + result = json.loads(resp.content) + self.assertEqual(result["role"], None) + self.assertTrue(result["active"]) + + def test_detail_inactive(self): + resp = self.client.get(self.inactive_detail_url) + self.assert2XX(resp.status_code) + result = json.loads(resp.content) + self.assertFalse(result["active"]) + + def test_detail_invalid(self): + resp = self.client.get(self.invalid_detail_url) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_detail_post(self): + resp = self.client.post( + self.detail_url, + data={"role": None}, + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + # 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( + self.detail_url, + data=json.dumps({"role": "staff"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + 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) + 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) + self.user.groups.add(inst_group) + self.user.save() + + resp = self.client.post( + self.detail_url, + data=json.dumps({"role": "staff"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + 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) + 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()] + self.assertNotIn(self.staff_groupname, groups) + self.assertIn(self.inst_groupname, groups) + + def test_detail_post_instructor(self): + 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) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + 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( + self.detail_url, + data=json.dumps({"toys": "fun"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + 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( + self.detail_url, + data="{foo}", + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + 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( + self.detail_url, + data={"role": "staff"}, + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + 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) + 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) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.delete( + self.detail_url, + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertNotIn(self.staff_groupname, groups) + + def test_detail_delete_instructor(self): + group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.user.groups.add(group) + self.ext_user.groups.add(group) + self.user.save() + self.ext_user.save() + + resp = self.client.delete( + self.detail_url, + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertNotIn(self.inst_groupname, groups) + + def test_delete_last_instructor(self): + group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.delete( + self.detail_url, + HTTP_ACCEPT="application/json", + ) self.assertEqual(resp.status_code, 400) - content = json.loads(resp.content) - self.assertEqual(content["Status"], "Failed") + result = json.loads(resp.content) + self.assertIn("error", result) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.inst_groupname, groups) + + def test_post_last_instructor(self): + group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.post( + self.detail_url, + data={"role": "staff"}, + HTTP_ACCEPT="application/json", + ) + self.assertEqual(resp.status_code, 400) + result = json.loads(resp.content) + self.assertIn("error", result) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.inst_groupname, groups) + + def test_permission_denied_self(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + + self_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.user.email, + }) + + resp = self.client.post( + self_url, + data={"role": "instructor"}, + HTTP_ACCEPT="application/json", + ) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_permission_denied_other(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + + resp = self.client.post( + self.detail_url, + data={"role": "instructor"}, + HTTP_ACCEPT="application/json", + ) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_staff_can_delete_self(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + + self_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.user.email, + }) + + resp = self.client.delete(self_url) + self.assert2XX(resp.status_code) + # reload user from DB + user = User.objects.get(email=self.user.email) + groups = [g.name for g in user.groups.all()] + self.assertNotIn(self.staff_groupname, groups) + + def test_staff_cannot_delete_other(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.delete(self.detail_url) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + # reload user from DB + 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/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index fec82db1bb..26c49843b5 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -72,50 +72,6 @@ class LMSLinksTestCase(TestCase): ) -class UrlReverseTestCase(ModuleStoreTestCase): - """ Tests for get_url_reverse """ - def test_course_page_names(self): - """ Test the defined course pages. """ - course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course') - - self.assertEquals( - '/manage_users/i4x://mitX/666/course/URL_Reverse_Course', - utils.get_url_reverse('ManageUsers', course) - ) - - self.assertEquals( - '/mitX/666/settings-details/URL_Reverse_Course', - utils.get_url_reverse('SettingsDetails', course) - ) - - self.assertEquals( - '/mitX/666/settings-grading/URL_Reverse_Course', - utils.get_url_reverse('SettingsGrading', course) - ) - - self.assertEquals( - '/mitX/666/course/URL_Reverse_Course', - utils.get_url_reverse('CourseOutline', course) - ) - - self.assertEquals( - '/mitX/666/checklists/URL_Reverse_Course', - utils.get_url_reverse('Checklists', course) - ) - - def test_unknown_passes_through(self): - """ Test that unknown values pass through. """ - course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course') - self.assertEquals( - 'foobar', - utils.get_url_reverse('foobar', course) - ) - self.assertEquals( - 'https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', - utils.get_url_reverse('https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', course) - ) - - class ExtraPanelTabTestCase(TestCase): """ Tests adding and removing extra course tabs. """ diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index d55a7eff55..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 @@ -15,14 +16,16 @@ class ContentStoreTestCase(ModuleStoreTestCase): Login. View should always return 200. The success/fail is in the returned json """ - resp = self.client.post(reverse('login_post'), - {'email': email, 'password': password}) + resp = self.client.post( + reverse('login_post'), + {'email': email, 'password': password} + ) self.assertEqual(resp.status_code, 200) return resp - def login(self, email, pw): + def login(self, email, password): """Login, check that it worked.""" - resp = self._login(email, pw) + resp = self._login(email, password) data = parse_json(resp) self.assertTrue(data['success']) return resp @@ -77,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) @@ -117,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 @@ -178,11 +195,15 @@ class ForumTestCase(CourseTestCase): def test_blackouts(self): now = datetime.datetime.now(UTC) - self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in - [(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)), - (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + times1 = [ + (now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30)) + ] + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in times1] self.assertTrue(self.course.forum_posts_allowed) - self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in - [(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), - (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + times2 = [ + (now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30)) + ] + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in times2] self.assertFalse(self.course.forum_posts_allowed) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 4973bddaca..a2e927ef46 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -188,38 +188,6 @@ def update_item(location, value): get_modulestore(location).update_item(location, value) -def get_url_reverse(course_page_name, course_module): - """ - Returns the course URL link to the specified location. This value is suitable to use as an href link. - - course_page_name should correspond to an attribute in CoursePageNames (for example, 'ManageUsers' - or 'SettingsDetails'), or else it will simply be returned. This method passes back unknown values of - course_page_names so that it can also be used for absolute (known) URLs. - - course_module is used to obtain the location, org, course, and name properties for a course, if - course_page_name corresponds to an attribute in CoursePageNames. - """ - url_name = getattr(CoursePageNames, course_page_name, None) - ctx_loc = course_module.location - - if CoursePageNames.ManageUsers == url_name: - return reverse(url_name, kwargs={"location": ctx_loc}) - elif url_name in [CoursePageNames.SettingsDetails, CoursePageNames.SettingsGrading, - CoursePageNames.CourseOutline, CoursePageNames.Checklists]: - return reverse(url_name, kwargs={'org': ctx_loc.org, 'course': ctx_loc.course, 'name': ctx_loc.name}) - else: - return course_page_name - - -class CoursePageNames: - """ Constants for pages that are recognized by get_url_reverse method. """ - ManageUsers = "manage_users" - SettingsDetails = "settings_details" - SettingsGrading = "settings_grading" - CourseOutline = "course_index" - Checklists = "checklists" - - def add_extra_panel_tab(tab_type, course): """ Used to add the panel tab to a course if it does not exist. diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index f3e98ec216..197c54ff36 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -15,3 +15,7 @@ from .public import * from .user import * from .tabs import * from .requests import * +try: + from .dev import * +except ImportError: + pass diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 0bb9551ac9..1c22114d76 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -29,7 +29,6 @@ from xmodule.util.date_utils import get_default_time_display from xmodule.modulestore import InvalidLocationError from xmodule.exceptions import NotFoundError -from ..utils import get_url_reverse from .access import get_location_and_verify_access from util.json_request import JsonResponse @@ -106,6 +105,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) @@ -188,12 +188,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 @@ -284,7 +284,7 @@ def import_course(request, org, course, name): tar_file.extractall(course_dir + '/') # find the 'course.xml' file - + dirpath = None for dirpath, _dirnames, filenames in os.walk(course_dir): for filename in filenames: if filename == 'course.xml': @@ -320,7 +320,11 @@ def import_course(request, org, course, name): return render_to_response('import.html', { 'context_course': course_module, - 'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module) + 'successful_import_redirect_url': reverse('course_index', kwargs={ + 'org': location.org, + 'course': location.course, + 'name': location.name, + }) }) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index bcf4a1a5b9..74f0a33769 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -4,12 +4,13 @@ from util.json_request import JsonResponse from django.http import HttpResponseBadRequest from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods +from django.core.urlresolvers import reverse from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response from xmodule.modulestore.inheritance import own_metadata -from ..utils import get_modulestore, get_url_reverse +from ..utils import get_modulestore from .access import get_location_and_verify_access from xmodule.course_module import CourseDescriptor @@ -96,10 +97,25 @@ def expand_checklist_action_urls(course_module): """ checklists = course_module.checklists modified = False + urlconf_map = { + "ManageUsers": "manage_users", + "SettingsDetails": "settings_details", + "SettingsGrading": "settings_grading", + "CourseOutline": "course_index", + "Checklists": "checklists", + } for checklist in checklists: if not checklist.get('action_urls_expanded', False): for item in checklist.get('items'): - item['action_url'] = get_url_reverse(item.get('action_url'), course_module) + action_url = item.get('action_url') + if action_url not in urlconf_map: + continue + urlconf_name = urlconf_map[action_url] + item['action_url'] = reverse(urlconf_name, kwargs={ + 'org': course_module.location.org, + 'course': course_module.location.course, + 'name': course_module.location.name, + }) checklist['action_urls_expanded'] = True modified = True diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 1be6ac2822..7cb503db1e 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -46,13 +46,19 @@ COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video'] OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] NOTE_COMPONENT_TYPES = ['notes'] -ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud', 'videoalpha'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES +ADVANCED_COMPONENT_TYPES = [ + 'annotatable', + 'word_cloud', + 'videoalpha', + 'graphical_slider_tool' +] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @login_required def edit_subsection(request, location): + "Edit the subsection of a course" # check that we have permissions to edit this item try: course = get_course_for_item(location) @@ -264,6 +270,7 @@ def assignment_type_update(request, org, course, category, name): @login_required @expect_json def create_draft(request): + "Create a draft" location = request.POST['id'] # check permissions for this user within this course @@ -280,6 +287,7 @@ def create_draft(request): @login_required @expect_json def publish_draft(request): + "Publish a draft" location = request.POST['id'] # check permissions for this user within this course @@ -295,6 +303,7 @@ def publish_draft(request): @login_required @expect_json def unpublish_unit(request): + "Unpublish a unit" location = request.POST['id'] # check permissions for this user within this course @@ -312,6 +321,7 @@ def unpublish_unit(request): @login_required @ensure_csrf_cookie def module_info(request, module_location): + "Get or set information for a module in the modulestore" location = Location(module_location) # check that logged in user has permissions to this item diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 02eb4c65b8..8ac1d223cb 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -3,6 +3,7 @@ Views related to operations on course objects """ import json import random +from django.utils.translation import ugettext as _ import string # pylint: disable=W0402 from django.contrib.auth.decorators import login_required @@ -43,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', @@ -101,12 +104,13 @@ def create_new_course(request): org = request.POST.get('org') number = request.POST.get('number') display_name = request.POST.get('display_name') + run = request.POST.get('run') try: - dest_location = Location('i4x', org, number, 'course', Location.clean(display_name)) + dest_location = Location('i4x', org, number, 'course', run) except InvalidLocationError as error: return JsonResponse({ - "ErrMsg": "Unable to create course '{name}'.\n\n{err}".format( + "ErrMsg": _("Unable to create course '{name}'.\n\n{err}").format( name=display_name, err=error.message)}) # see if the course already exists @@ -116,12 +120,24 @@ def create_new_course(request): except ItemNotFoundError: pass if existing_course is not None: - return JsonResponse({'ErrMsg': 'There is already a course defined with this name.'}) + return JsonResponse( + { + 'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.'), + 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'), + 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'), + } + ) course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None] courses = modulestore().get_items(course_search_location) if len(courses) > 0: - return JsonResponse({'ErrMsg': 'There is already a course defined with the same organization and course number.'}) + return JsonResponse( + { + 'ErrMsg': _('There is already a course defined with the same organization and course number. Please change at least one field to be unique.'), + 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'), + 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'), + } + ) # instantiate the CourseDescriptor and then persist it # note: no system to pass @@ -148,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/dev.py b/cms/djangoapps/contentstore/views/dev.py new file mode 100644 index 0000000000..0fcc355c11 --- /dev/null +++ b/cms/djangoapps/contentstore/views/dev.py @@ -0,0 +1,12 @@ +""" +Views that are only activated when the project is running in development mode. +These views will NOT be shown on production: trying to access them will result +in a 404 error. +""" +# pylint: disable=W0613 +from mitxmako.shortcuts import render_to_response + + +def dev_mode(request): + "Sample static view" + return render_to_response("dev/dev_mode.html") diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 35af3e9ac3..f2a07abe32 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -68,6 +68,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None): @login_required def preview_component(request, location): + "Return the HTML preview of a component" # TODO (vshnayder): change name from id to location in coffee+html as well. if not has_access(request.user, location): return HttpResponseForbidden() @@ -91,6 +92,7 @@ def preview_module_system(request, preview_id, descriptor): """ def preview_model_data(descriptor): + "Helper method to create a DbModel from a descriptor" return DbModel( SessionKeyValueStore(request, descriptor._model_data), descriptor.module_class, @@ -105,7 +107,7 @@ def preview_module_system(request, preview_id, descriptor): # TODO (cpennington): Do we want to track how instructors are using the preview problems? track_function=lambda event_type, event: None, filestore=descriptor.system.resources_fs, - get_module=partial(get_preview_module, request, preview_id), + get_module=partial(load_preview_module, request, preview_id), render_template=render_from_lms, debug=True, replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location), @@ -115,28 +117,13 @@ def preview_module_system(request, preview_id, descriptor): ) -def get_preview_module(request, preview_id, descriptor): - """ - Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily - from the set of preview data for the descriptor specified by Location - - request: The active django request - preview_id (str): An identifier specifying which preview this module is used for - location: A Location - """ - - return load_preview_module(request, preview_id, descriptor) - - def load_preview_module(request, preview_id, descriptor): """ - Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state + Return a preview XModule instantiated from the supplied descriptor. request: The active django request preview_id (str): An identifier specifying which preview this module is used for descriptor: An XModuleDescriptor - instance_state: An instance state string - shared_state: A shared state string """ system = preview_module_system(request, preview_id, descriptor) try: diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 0ee228b996..2f74df1d8c 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -1,3 +1,6 @@ +""" +Public views +""" from django_future.csrf import ensure_csrf_cookie from django.core.context_processors import csrf from django.shortcuts import redirect @@ -10,10 +13,6 @@ from .user import index __all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks'] -""" -Public views -""" - @ensure_csrf_cookie def signup(request): @@ -45,6 +44,7 @@ def login_page(request): def howitworks(request): + "Proxy view" if request.user.is_authenticated(): return index(request) else: diff --git a/cms/djangoapps/contentstore/views/requests.py b/cms/djangoapps/contentstore/views/requests.py index 8a05bf1258..abbf84755e 100644 --- a/cms/djangoapps/contentstore/views/requests.py +++ b/cms/djangoapps/contentstore/views/requests.py @@ -1,4 +1,5 @@ from django.http import HttpResponse +from django.shortcuts import redirect from mitxmako.shortcuts import render_to_string, render_to_response __all__ = ['edge', 'event', 'landing'] @@ -11,7 +12,7 @@ def landing(request, org, course, coursename): # points to the temporary edge page def edge(request): - return render_to_response('university_profiles/edge.html', {}) + return redirect('/') def event(request): diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index d55932e33d..f38685edfc 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -1,3 +1,6 @@ +""" +Views related to course tabs +""" from access import has_access from util.json_request import expect_json @@ -39,6 +42,7 @@ def initialize_course_tabs(course): @login_required @expect_json def reorder_static_tabs(request): + "Order the static tabs in the requested order" tabs = request.POST['tabs'] course = get_course_for_item(tabs[0]) @@ -86,6 +90,7 @@ def reorder_static_tabs(request): @login_required @ensure_csrf_cookie def edit_tabs(request, org, course, coursename): + "Edit tabs" location = ['i4x', org, course, 'course', coursename] store = get_modulestore(location) course_item = store.get_item(location) @@ -122,6 +127,7 @@ def edit_tabs(request, org, course, coursename): @login_required @ensure_csrf_cookie def static_pages(request, org, course, coursename): + "Static pages view" location = get_location_and_verify_access(request, org, course, coursename) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 020e4b5cb9..a5c495597c 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -1,20 +1,30 @@ +import json from django.conf import settings from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse +from django.contrib.auth.models import User, Group from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_http_methods from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response +from django.core.context_processors import csrf -from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from contentstore.utils import get_url_reverse, get_lms_link_for_item -from util.json_request import expect_json, JsonResponse -from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role -from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group +from xmodule.modulestore import Location +from contentstore.utils import get_lms_link_for_item +from util.json_request import JsonResponse +from auth.authz import ( + STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role) +from course_creators.views import ( + get_course_creator_status, add_user_with_status_unrequested, + user_requested_access) from .access import has_access +from student.views import enroll_in_course + @login_required @ensure_csrf_cookie @@ -34,113 +44,209 @@ def index(request): and course.location.name != '') courses = filter(course_filter, courses) + def format_course_for_view(course): + return ( + course.display_name, + reverse("course_index", kwargs={ + 'org': course.location.org, + 'course': course.location.course, + 'name': course.location.name, + }), + get_lms_link_for_item( + 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': [(course.display_name, - get_url_reverse('CourseOutline', course), - get_lms_link_for_item(course.location, course_id=course.location.course_id)) - for course in courses], + 'courses': [format_course_for_view(c) for c in courses], 'user': request.user, - 'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff + 'request_course_creator_url': reverse('request_course_creator'), + 'course_creator_status': _get_course_creator_status(request.user), + 'csrf': csrf(request)['csrf_token'] }) +@require_POST +@login_required +def request_course_creator(request): + """ + User has requested course creation access. + """ + user_requested_access(request.user) + return JsonResponse({"Status": "OK"}) + + @login_required @ensure_csrf_cookie -def manage_users(request, location): +def manage_users(request, org, course, name): ''' This view will return all CMS users who are editors for the specified course ''' + location = Location('i4x', org, course, 'course', name) # check that logged in user has permissions to this item if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): raise PermissionDenied() course_module = modulestore().get_item(location) + staff_groupname = get_course_groupname_for_role(location, "staff") + staff_group, __ = Group.objects.get_or_create(name=staff_groupname) + inst_groupname = get_course_groupname_for_role(location, "instructor") + inst_group, __ = Group.objects.get_or_create(name=inst_groupname) + return render_to_response('manage_users.html', { 'context_course': course_module, - 'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME), - 'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'), - 'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'), + 'staff': staff_group.user_set.all(), + 'instructors': inst_group.user_set.all(), 'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), - 'request_user_id': request.user.id }) -@expect_json @login_required @ensure_csrf_cookie -def add_user(request, location): - ''' - This POST-back view will add a user - specified by email - to the list of editors for - the specified course - ''' - email = request.POST.get("email") - - if not email: +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +def course_team_user(request, org, course, name, email): + location = Location('i4x', org, course, 'course', name) + # check that logged in user has permissions to this item + if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): + # instructors have full permissions + pass + elif has_access(request.user, location, role=STAFF_ROLE_NAME) and email == request.user.email: + # staff can only affect themselves + pass + else: msg = { - 'Status': 'Failed', - 'ErrMsg': _('Please specify an email address.'), + "error": _("Insufficient permissions") } return JsonResponse(msg, 400) - # remove leading/trailing whitespace if necessary - email = email.strip() - - # check that logged in user has admin permissions to this course - if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): - raise PermissionDenied() - - user = get_user_by_email(email) - - # user doesn't exist?!? Return error. - if user is None: + try: + user = User.objects.get(email=email) + except: msg = { - 'Status': 'Failed', - 'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email), + "error": _("Could not find user by email address '{email}'.").format(email=email), } return JsonResponse(msg, 404) - # user exists, but hasn't activated account?!? + # role hierarchy: "instructor" has more permissions than "staff" (in a course) + roles = ["instructor", "staff"] + + if request.method == "GET": + # just return info about the user + msg = { + "email": user.email, + "active": user.is_active, + "role": None, + } + # what's the highest role that this user has? + groupnames = set(g.name for g in user.groups.all()) + for role in roles: + role_groupname = get_course_groupname_for_role(location, role) + if role_groupname in groupnames: + msg["role"] = role + break + return JsonResponse(msg) + + # can't modify an inactive user if not user.is_active: msg = { - 'Status': 'Failed', - 'ErrMsg': _('User {email} has registered but has not yet activated his/her account.').format(email=email), + "error": _('User {email} has registered but has not yet activated his/her account.').format(email=email), } return JsonResponse(msg, 400) - # ok, we're cool to add to the course group - add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME) + # make sure that the role groups exist + groups = {} + for role in roles: + groupname = get_course_groupname_for_role(location, role) + group, __ = Group.objects.get_or_create(name=groupname) + groups[role] = group - return JsonResponse({"Status": "OK"}) + if request.method == "DELETE": + # remove all roles in this course from this user: but fail if the user + # is the last instructor in the course team + instructors = set(groups["instructor"].user_set.all()) + staff = set(groups["staff"].user_set.all()) + if user in instructors and len(instructors) == 1: + msg = { + "error": _("You may not remove the last instructor from a course") + } + return JsonResponse(msg, 400) + + if user in instructors: + user.groups.remove(groups["instructor"]) + if user in staff: + user.groups.remove(groups["staff"]) + user.save() + return JsonResponse() + + # all other operations require the requesting user to specify a role + if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body: + try: + payload = json.loads(request.body) + except: + return JsonResponse({"error": _("malformed JSON")}, 400) + try: + role = payload["role"] + except KeyError: + return JsonResponse({"error": _("`role` is required")}, 400) + else: + if not "role" in request.POST: + return JsonResponse({"error": _("`role` is required")}, 400) + role = request.POST["role"] + + if role == "instructor": + if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): + msg = { + "error": _("Only instructors may create other instructors") + } + 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. + instructors = set(groups["instructor"].user_set.all()) + if user in instructors: + if len(instructors) == 1: + msg = { + "error": _("You may not remove the last instructor from a course") + } + return JsonResponse(msg, 400) + 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() -@expect_json -@login_required -@ensure_csrf_cookie -def remove_user(request, location): - ''' - This POST-back view will remove a user - specified by email - from the list of editors for - the specified course - ''' +def _get_course_creator_status(user): + """ + Helper method for returning the course creator status for a particular user, + taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP. - email = request.POST["email"] + If the user passed in has not previously visited the index page, it will be + added with status 'unrequested' if the course creator group is in use. + """ + if user.is_staff: + course_creator_status = 'granted' + elif settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False): + course_creator_status = 'disallowed_for_this_site' + elif settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False): + course_creator_status = get_course_creator_status(user) + if course_creator_status is None: + # User not grandfathered in as an existing user, has not previously visited the dashboard page. + # Add the user to the course creator admin table with status 'unrequested'. + add_user_with_status_unrequested(user) + course_creator_status = get_course_creator_status(user) + else: + course_creator_status = 'granted' - # check that logged in user has admin permissions on this course - if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): - raise PermissionDenied() - - user = get_user_by_email(email) - if user is None: - msg = { - 'Status': 'Failed', - 'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email), - } - return JsonResponse(msg, 404) - - # make sure we're not removing ourselves - if user.id == request.user.id: - raise PermissionDenied() - - remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME) - - return JsonResponse({"Status": "OK"}) + return course_creator_status diff --git a/cms/djangoapps/course_creators/admin.py b/cms/djangoapps/course_creators/admin.py index 7518946270..df2baa1aa2 100644 --- a/cms/djangoapps/course_creators/admin.py +++ b/cms/djangoapps/course_creators/admin.py @@ -2,11 +2,19 @@ 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 + +log = logging.getLogger("studio.coursecreatoradmin") def get_email(obj): @@ -22,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 @@ -37,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 @@ -60,4 +78,60 @@ def update_creator_group_callback(sender, **kwargs): """ Callback for when the model's creator status has changed. """ - update_course_creator_group(kwargs['caller'], kwargs['user'], kwargs['add']) + user = kwargs['user'] + updated_state = kwargs['state'] + update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED) + + +@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) + subject = ''.join(subject.splitlines()) + if updated_state == CourseCreator.GRANTED: + message_template = 'emails/course_creator_granted.txt' + elif updated_state == CourseCreator.DENIED: + message_template = 'emails/course_creator_denied.txt' + else: + # changed to unrequested or pending + message_template = 'emails/course_creator_revoked.txt' + message = render_to_string(message_template, context) + + try: + 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 607dae4af2..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): @@ -39,7 +45,7 @@ class CourseCreator(models.Model): "why course creation access was denied)")) def __unicode__(self): - return u'%str | %str [%str] | %str' % (self.user, self.state, self.state_changed, self.note) + return u"{0} | {1} [{2}]".format(self.user, self.state, self.state_changed) @receiver(post_init, sender=CourseCreator) @@ -54,18 +60,40 @@ def post_init_callback(sender, **kwargs): @receiver(post_save, sender=CourseCreator) def post_save_callback(sender, **kwargs): """ - Extend to update state_changed time and modify the course creator group in authz.py. + Extend to update state_changed time and fire event to update course creator group, if appropriate. """ instance = kwargs['instance'] # 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: - update_creator_state.send( - sender=sender, - caller=instance.admin, - user=instance.user, - add=instance.state == CourseCreator.GRANTED - ) + 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 granted_state_change: + assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group' + update_creator_state.send( + sender=sender, + caller=instance.admin, + user=instance.user, + 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 6ef48746e7..aa293e008e 100644 --- a/cms/djangoapps/course_creators/tests/test_admin.py +++ b/cms/djangoapps/course_creators/tests/test_admin.py @@ -11,6 +11,12 @@ 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): + """Return a string that encodes template_name and context""" + return str((template_name, context)) class CourseCreatorAdminTest(TestCase): @@ -32,31 +38,104 @@ class CourseCreatorAdminTest(TestCase): self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite()) - def test_change_status(self): + 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. + Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent. """ - 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)) - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + context = {'studio_request_email': self.studio_request_email} + if state == CourseCreator.GRANTED: + template = 'emails/course_creator_granted.txt' + elif state == CourseCreator.DENIED: + template = 'emails/course_creator_denied.txt' + else: + template = 'emails/course_creator_revoked.txt' + email_user.assert_called_with( + mock_render_to_string('emails/course_creator_subject.txt', context), + mock_render_to_string(template, context), + self.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): """ @@ -78,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/course_creators/tests/test_views.py b/cms/djangoapps/course_creators/tests/test_views.py index bd91208b9c..95c50ffb76 100644 --- a/cms/djangoapps/course_creators/tests/test_views.py +++ b/cms/djangoapps/course_creators/tests/test_views.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied from course_creators.views import add_user_with_status_unrequested, add_user_with_status_granted -from course_creators.views import get_course_creator_status, update_course_creator_group +from course_creators.views import get_course_creator_status, update_course_creator_group, user_requested_access from course_creators.models import CourseCreator from auth.authz import is_user_in_creator_group import mock @@ -26,14 +26,11 @@ class CourseCreatorView(TestCase): def test_staff_permission_required(self): """ - Tests that add methods and course creator group method must be called with staff permissions. + Tests that any method changing the course creator authz group must be called with staff permissions. """ with self.assertRaises(PermissionDenied): add_user_with_status_granted(self.user, self.user) - with self.assertRaises(PermissionDenied): - add_user_with_status_unrequested(self.user, self.user) - with self.assertRaises(PermissionDenied): update_course_creator_group(self.user, self.user, True) @@ -41,7 +38,7 @@ class CourseCreatorView(TestCase): self.assertIsNone(get_course_creator_status(self.user)) def test_add_unrequested(self): - add_user_with_status_unrequested(self.admin, self.user) + add_user_with_status_unrequested(self.user) self.assertEqual('unrequested', get_course_creator_status(self.user)) # Calling add again will be a no-op (even if state is different). @@ -57,7 +54,7 @@ class CourseCreatorView(TestCase): self.assertEqual('granted', get_course_creator_status(self.user)) # Calling add again will be a no-op (even if state is different). - add_user_with_status_unrequested(self.admin, self.user) + add_user_with_status_unrequested(self.user) self.assertEqual('granted', get_course_creator_status(self.user)) self.assertTrue(is_user_in_creator_group(self.user)) @@ -69,3 +66,27 @@ class CourseCreatorView(TestCase): self.assertTrue(is_user_in_creator_group(self.user)) update_course_creator_group(self.admin, self.user, False) self.assertFalse(is_user_in_creator_group(self.user)) + + def test_user_requested_access(self): + add_user_with_status_unrequested(self.user) + self.assertEqual('unrequested', get_course_creator_status(self.user)) + user_requested_access(self.user) + self.assertEqual('pending', get_course_creator_status(self.user)) + + def test_user_requested_already_granted(self): + add_user_with_status_granted(self.admin, self.user) + self.assertEqual('granted', get_course_creator_status(self.user)) + # Will not "downgrade" to pending because that would require removing the + # user from the authz course creator group (and that can only be done by an admin). + user_requested_access(self.user) + self.assertEqual('granted', get_course_creator_status(self.user)) + + def test_add_user_unrequested_staff(self): + # Users marked as is_staff will not be added to the course creator table. + add_user_with_status_unrequested(self.admin) + self.assertIsNone(get_course_creator_status(self.admin)) + + def test_add_user_granted_staff(self): + # Users marked as is_staff will not be added to the course creator table. + add_user_with_status_granted(self.admin, self.admin) + self.assertIsNone(get_course_creator_status(self.admin)) diff --git a/cms/djangoapps/course_creators/views.py b/cms/djangoapps/course_creators/views.py index 902406e620..e9b38ed169 100644 --- a/cms/djangoapps/course_creators/views.py +++ b/cms/djangoapps/course_creators/views.py @@ -2,32 +2,38 @@ Methods for interacting programmatically with the user creator table. """ from course_creators.models import CourseCreator -from django.core.exceptions import PermissionDenied from auth.authz import add_user_to_creator_group, remove_user_from_creator_group -def add_user_with_status_unrequested(caller, user): +def add_user_with_status_unrequested(user): """ Adds a user to the course creator table with status 'unrequested'. If the user is already in the table, this method is a no-op - (state will not be changed). Caller must have staff permissions. + (state will not be changed). + + If the user is marked as is_staff, this method is a no-op (user + will not be added to table). """ - _add_user(caller, user, CourseCreator.UNREQUESTED) + _add_user(user, CourseCreator.UNREQUESTED) def add_user_with_status_granted(caller, user): """ Adds a user to the course creator table with status 'granted'. - If the user is already in the table, this method is a no-op - (state will not be changed). Caller must have staff permissions. + If appropriate, this method also adds the user to the course creator group maintained by authz.py. + Caller must have staff permissions. - This method also adds the user to the course creator group maintained by authz.py. + If the user is already in the table, this method is a no-op + (state will not be changed). + + If the user is marked as is_staff, this method is a no-op (user + will not be added to table, nor added to authz.py group). """ - _add_user(caller, user, CourseCreator.GRANTED) - update_course_creator_group(caller, user, True) + if _add_user(user, CourseCreator.GRANTED): + update_course_creator_group(caller, user, True) def update_course_creator_group(caller, user, add): @@ -61,16 +67,33 @@ def get_course_creator_status(user): return user[0].state -def _add_user(caller, user, state): +def user_requested_access(user): + """ + User has requested course creator access. + + This changes the user state to CourseCreator.PENDING, unless the user + state is already CourseCreator.GRANTED, in which case this method is a no-op. + """ + user = CourseCreator.objects.get(user=user) + if user.state != CourseCreator.GRANTED: + user.state = CourseCreator.PENDING + user.save() + + +def _add_user(user, state): """ Adds a user to the course creator table with the specified state. - If the user is already in the table, this method is a no-op - (state will not be changed). - """ - if not caller.is_active or not caller.is_authenticated or not caller.is_staff: - raise PermissionDenied + Returns True if user was added to table, else False. - if CourseCreator.objects.filter(user=user).count() == 0: + If the user is already in the table, this method is a no-op + (state will not be changed, method will return False). + + If the user is marked as is_staff, this method is a no-op (False will be returned). + """ + if not user.is_staff and CourseCreator.objects.filter(user=user).count() == 0: entry = CourseCreator(user=user, state=state) entry.save() + return True + + return False 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/aws_migrate.py b/cms/envs/aws_migrate.py new file mode 100644 index 0000000000..54ced39c7c --- /dev/null +++ b/cms/envs/aws_migrate.py @@ -0,0 +1,24 @@ +""" +A Django settings file for use on AWS while running +database migrations, since we don't want to normally run the +LMS with enough privileges to modify the database schema. +""" + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + +# Import everything from .aws so that our settings are based on those. +from .aws import * +import os +from django.core.exceptions import ImproperlyConfigured + +USER = os.environ.get('DB_MIGRATION_USER', 'root') +PASSWORD = os.environ.get('DB_MIGRATION_PASS', None) + +if not PASSWORD: + raise ImproperlyConfigured("No database password was provided for running " + "migrations. This is fatal.") + +DATABASES['default']['USER'] = USER +DATABASES['default']['PASSWORD'] = PASSWORD diff --git a/cms/envs/common.py b/cms/envs/common.py index 260aa30cd2..40084c20ae 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -42,8 +42,8 @@ MITX_FEATURES = { # do not display video when running automated acceptance tests 'STUB_VIDEO_FOR_TESTING': False, - # email address for staff (eg to request course creation) - 'STAFF_EMAIL': '', + # email address for studio staff (eg to request course creation) + 'STUDIO_REQUEST_EMAIL': '', 'STUDIO_NPS_SURVEY': True, @@ -62,9 +62,6 @@ MITX_FEATURES = { } ENABLE_JASMINE = False -# needed to use lms student app -GENERATE_RANDOM_USER_CREDENTIALS = False - ############################# SET PATH INFORMATION ############################# PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/cms @@ -108,7 +105,12 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.core.context_processors.static', 'django.contrib.messages.context_processors.messages', 'django.contrib.auth.context_processors.auth', # this is required for admin - 'django.core.context_processors.csrf', # necessary for csrf protection + 'django.core.context_processors.csrf' +) + +# use the ratelimit backend to prevent brute force attacks +AUTHENTICATION_BACKENDS = ( + 'ratelimitbackend.backends.RateLimitModelBackend', ) LMS_BASE = None @@ -141,8 +143,8 @@ MIDDLEWARE_CLASSES = ( 'request_cache.middleware.RequestCache', 'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', 'method_override.middleware.MethodOverrideMiddleware', # Instead of AuthenticationMiddleware, we use a cache-backed version @@ -155,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 ################################ @@ -179,9 +184,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'registration@edx.org' DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org' SERVER_EMAIL = 'devops@edx.org' -ADMINS = ( - ('edX Admins', 'admin@edx.org'), -) +ADMINS = () MANAGERS = ADMINS # Static content @@ -193,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 @@ -241,10 +244,11 @@ PIPELINE_JS = { rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') + rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js') ) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js', + 'js/models/course.js', 'js/models/section.js', 'js/views/section.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', 'js/models/textbook.js', 'js/views/textbook.js', - 'js/views/assets.js'], + 'js/views/assets.js', 'js/utility.js'], 'output_filename': 'js/cms-application.js', 'test_order': 0 }, 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/models/course_spec.coffee b/cms/static/coffee/spec/models/course_spec.coffee new file mode 100644 index 0000000000..07fe6930f4 --- /dev/null +++ b/cms/static/coffee/spec/models/course_spec.coffee @@ -0,0 +1,9 @@ +describe "CMS.Models.Course", -> + describe "basic", -> + beforeEach -> + @model = new CMS.Models.Course({ + name: "Greek Hero" + }) + + it "should take a name argument", -> + expect(@model.get("name")).toEqual("Greek Hero") 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", ->

    2. - """#" + """ 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 14b64b7b9e..fd679c289b 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -41,7 +41,12 @@ class CMS.Views.UnitEdit extends Backbone.View id: unit_location_analytics payload = children : @components() - options = success : => @model.unset('children') + saving = new CMS.Views.Notification.Mini + title: gettext('Saving') + '…' + saving.show() + options = success : => + @model.unset('children') + saving.hide() @model.save(payload, options) helper: 'clone' opacity: '0.5' diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 3a30e0bc81..80b24776da 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -60,12 +60,10 @@ $(document).ready(function() { $('.nav-dd .nav-item .title').removeClass('is-selected'); }); - $('.nav-dd .nav-item .title').click(function(e) { + $('.nav-dd .nav-item').click(function(e) { - $subnav = $(this).parent().find('.wrapper-nav-sub'); - $title = $(this).parent().find('.title'); - e.preventDefault(); - e.stopPropagation(); + $subnav = $(this).find('.wrapper-nav-sub'); + $title = $(this).find('.title'); if ($subnav.hasClass('is-shown')) { $subnav.removeClass('is-shown'); @@ -75,6 +73,9 @@ $(document).ready(function() { $('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown'); $title.addClass('is-selected'); $subnav.addClass('is-shown'); + // if propogation is not stopped, the event will bubble up to the + // body element, which will close the dropdown. + e.stopPropagation(); } }); @@ -252,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) { @@ -306,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", @@ -596,11 +604,9 @@ function cancelNewSection(e) { function addNewCourse(e) { e.preventDefault(); - - $(e.target).hide(); - var $newCourse = $($('#new-course-template').html()); + $('.new-course-button').addClass('is-disabled'); + var $newCourse = $('.wrapper-create-course').addClass('is-shown'); var $cancelButton = $newCourse.find('.new-course-cancel'); - $('.inner-wrapper').prepend($newCourse); $newCourse.find('.new-course-name').focus().select(); $newCourse.find('form').bind('submit', saveNewCourse); $cancelButton.bind('click', cancelNewCourse); @@ -612,41 +618,97 @@ function addNewCourse(e) { function saveNewCourse(e) { e.preventDefault(); - var $newCourse = $(this).closest('.new-course'); - var org = $newCourse.find('.new-course-org').val(); - var number = $newCourse.find('.new-course-number').val(); - var display_name = $newCourse.find('.new-course-name').val(); + var $newCourseForm = $(this).closest('#create-course-form'); + var display_name = $newCourseForm.find('.new-course-name').val(); + var org = $newCourseForm.find('.new-course-org').val(); + var number = $newCourseForm.find('.new-course-number').val(); + var run = $newCourseForm.find('.new-course-run').val(); - if (org == '' || number == '' || display_name == '') { - alert(gettext('You must specify all fields in order to create a new course.')); - return; + var required_field_text = gettext('Required field'); + + var display_name_errMsg = (display_name === '') ? required_field_text : null; + var org_errMsg = (org === '') ? required_field_text : null; + var number_errMsg = (number === '') ? required_field_text : null; + var run_errMsg = (run === '') ? required_field_text : null; + + var bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg); + + // check for suitable encoding + if (!bInErr) { + var encoding_errMsg = gettext('Please do not use any spaces or special characters in this field.'); + + if (encodeURIComponent(org) != org) + org_errMsg = encoding_errMsg; + if (encodeURIComponent(number) != number) + number_errMsg = encoding_errMsg; + if (encodeURIComponent(run) != run) + run_errMsg = encoding_errMsg; + + bInErr = (org_errMsg || number_errMsg || run_errMsg); } + var header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null; + + var setNewCourseErrMsgs = function(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) { + if (header_err_msg) { + $('.wrapper-create-course').addClass('has-errors'); + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

      ' + header_err_msg + '

      '); + } else { + $('.wrap-error').removeClass('is-shown'); + $('#course_creation_error').html(''); + } + + var setNewCourseFieldInErr = function(el, msg) { + el.children('.tip-error').remove(); + if (msg !== null && msg !== '') { + el.addClass('error'); + el.append('' + msg + ''); + } else { + el.removeClass('error'); + } + }; + + setNewCourseFieldInErr($('#field-course-name'), display_name_errMsg); + setNewCourseFieldInErr($('#field-organization'), org_errMsg); + setNewCourseFieldInErr($('#field-course-number'), number_errMsg); + setNewCourseFieldInErr($('#field-course-run'), run_errMsg); + }; + + setNewCourseErrMsgs(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg); + + if (bInErr) + return; + analytics.track('Created a Course', { 'org': org, 'number': number, - 'display_name': display_name + 'display_name': display_name, + 'run': run }); $.post('/create_new_course', { - 'org': org, - 'number': number, - 'display_name': display_name - }, - - function(data) { - if (data.id != undefined) { - window.location = '/' + data.id.replace(/.*:\/\//, ''); - } else if (data.ErrMsg != undefined) { - alert(data.ErrMsg); + 'org': org, + 'number': number, + 'display_name': display_name, + 'run': run + }, + function(data) { + if (data.id !== undefined) { + window.location = '/' + data.id.replace(/.*:\/\//, ''); + } else if (data.ErrMsg !== undefined) { + var orgErrMsg = (data.OrgErrMsg !== undefined) ? data.OrgErrMsg : null; + var courseErrMsg = (data.CourseErrMsg !== undefined) ? data.CourseErrMsg : null; + setNewCourseErrMsgs(data.ErrMsg, null, orgErrMsg, courseErrMsg, null); + } } - }); + ); } function cancelNewCourse(e) { e.preventDefault(); - $('.new-course-button').show(); - $(this).parents('section.new-course').remove(); + $('.new-course-button').removeClass('is-disabled'); + $('.wrapper-create-course').removeClass('is-shown'); } function addNewSubsection(e) { @@ -717,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 @@ -743,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/course.js b/cms/static/js/models/course.js new file mode 100644 index 0000000000..4f2325e94c --- /dev/null +++ b/cms/static/js/models/course.js @@ -0,0 +1,10 @@ +CMS.Models.Course = Backbone.Model.extend({ + defaults: { + "name": "" + }, + validate: function(attrs, options) { + if (!attrs.name) { + return gettext("You must specify a name"); + } + } +}); 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/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/course_info_edit.js b/cms/static/js/views/course_info_edit.js index ecd9ebe78d..a12fd5eb76 100644 --- a/cms/static/js/views/course_info_edit.js +++ b/cms/static/js/views/course_info_edit.js @@ -34,16 +34,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ }, initialize: function() { - var self = this; - // instantiates an editor template for each update in the collection - window.templateLoader.loadRemoteTemplate("course_info_update", - // TODO Where should the template reside? how to use the static.url to create the path? - "/static/client_templates/course_info_update.html", - function (raw_template) { - self.template = _.template(raw_template); - self.render(); - } - ); + this.template = _.template($("#course_info_update-tpl").text()); + this.render(); // when the client refetches the updates as a whole, re-render them this.listenTo(this.collection, 'reset', this.render); }, @@ -105,7 +97,19 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ var targetModel = this.eventModel(event); targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() }); // push change to display, hide the editor, submit the change - targetModel.save({}); + var saving = new CMS.Views.Notification.Mini({ + title: gettext('Saving') + '…' + }); + saving.show(); + var ele = this.modelDom(event); + targetModel.save({}, { + success: function() { + saving.hide(); + }, + error: function() { + ele.remove(); + } + }); this.closeEditor(this); analytics.track('Saved Course Update', { @@ -148,29 +152,48 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ onDelete: function(event) { event.preventDefault(); - if (!confirm('Are you sure you want to delete this update? This action cannot be undone.')) { - return; - } - - analytics.track('Deleted Course Update', { - 'course': course_location_analytics, - 'date': this.dateEntry(event).val() - }); - + var self = this; var targetModel = this.eventModel(event); - this.modelDom(event).remove(); - var cacheThis = this; - targetModel.destroy({ - success: function (model, response) { - cacheThis.collection.fetch({ - success: function() { - cacheThis.render(); - }, - reset: true - }); + var confirm = new CMS.Views.Prompt.Warning({ + title: gettext('Are you sure you want to delete this update?'), + message: gettext('This action cannot be undone.'), + actions: { + primary: { + text: gettext('OK'), + click: function () { + analytics.track('Deleted Course Update', { + 'course': course_location_analytics, + 'date': self.dateEntry(event).val() + }); + self.modelDom(event).remove(); + var deleting = new CMS.Views.Notification.Mini({ + title: gettext('Deleting') + '…' + }); + deleting.show(); + targetModel.destroy({ + success: function (model, response) { + self.collection.fetch({ + success: function() { + self.render(); + deleting.hide(); + }, + reset: true + }); + } + }); + confirm.hide(); + } + }, + secondary: { + text: gettext('Cancel'), + click: function() { + confirm.hide(); + } + } } }); - }, + confirm.show(); +}, closeEditor: function(self, removePost) { var targetModel = self.collection.get(self.$currentPost.attr('name')); @@ -241,16 +264,11 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({ }, initialize: function() { + this.template = _.template($("#course_info_handouts-tpl").text()); var self = this; this.model.fetch({ complete: function() { - window.templateLoader.loadRemoteTemplate("course_info_handouts", - "/static/client_templates/course_info_handouts.html", - function (raw_template) { - self.template = _.template(raw_template); - self.render(); - } - ); + self.render(); }, reset: true }); @@ -293,7 +311,15 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({ onSave: function(event) { this.model.set('data', this.$codeMirror.getValue()); this.render(); - this.model.save({}); + var saving = new CMS.Views.Notification.Mini({ + title: gettext('Saving') + '…' + }); + saving.show(); + this.model.save({}, { + success: function() { + saving.hide(); + } + }); this.$form.hide(); this.closeEditor(this); 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( + '
    3. ' + + '' + + 'Remove' + + '
    4. ' + ); + 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 42ed2d6920..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) { @@ -225,12 +225,19 @@ function _handleReorder(event, ui, parentIdField, childrenSelector) { ui.draggable.attr("style", "position:relative;"); // STYLE hack too children.push(ui.draggable.data('id')); } + var saving = new CMS.Views.Notification.Mini({ + title: gettext('Saving') + '…' + }); + saving.show(); $.ajax({ url: "/save_item", type: "POST", dataType: "json", contentType: "application/json", - data:JSON.stringify({ 'id' : subsection_id, 'children' : children}) + data:JSON.stringify({ 'id' : subsection_id, 'children' : children}), + success: function() { + saving.hide(); + } }); } diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index 90e84adf2b..5ae0c19570 100644 --- a/cms/static/js/views/settings/advanced_view.js +++ b/cms/static/js/views/settings/advanced_view.js @@ -11,16 +11,9 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ // TODO enable/disable save based on validation (currently enabled whenever there are changes) }, initialize : function() { - var self = this; - // instantiates an editor template for each update in the collection - window.templateLoader.loadRemoteTemplate("advanced_entry", - "/static/client_templates/advanced_entry.html", - function (raw_template) { - self.template = _.template(raw_template); - self.render(); - } - ); + this.template = _.template($("#advanced_entry-tpl").text()); this.listenTo(this.model, 'invalid', this.handleValidationError); + this.render(); }, render: function() { // catch potential outside call before template loaded @@ -56,7 +49,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ CodeMirror.fromTextArea(textarea, { mode: "application/json", lineNumbers: false, lineWrapping: false, onChange: function(instance, changeobj) { - instance.save() + instance.save(); // this event's being called even when there's no change :-( if (instance.getValue() !== oldValue) { var message = gettext("Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented."); @@ -105,8 +98,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ // call validateKey on each to ensure proper format // check for dupes var self = this; - this.model.save({}, - { + this.model.save({}, { success : function() { self.render(); var title = gettext("Your policy changes have been saved."); diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 054a401e4b..e4495d0248 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -368,42 +368,6 @@ p, ul, ol, dl { color: $gray-d3; } } - - .introduction { - @include box-sizing(border-box); - @extend .t-copy-sub1; - width: flex-grid(12); - margin: 0 0 $baseline 0; - - .copy strong { - font-weight: 600; - } - - &.has-links { - @include clearfix(); - - .copy { - float: left; - width: flex-grid(8,12); - margin-right: flex-gutter(); - } - - .nav-introduction-supplementary { - @extend .t-copy-sub2; - float: right; - width: flex-grid(4,12); - display: block; - text-align: right; - - .icon { - @extend .t-action3; - display: inline-block; - vertical-align: middle; - margin-right: ($baseline/4); - } - } - } - } } .content-primary, .content-supplementary { @@ -482,6 +446,24 @@ p, ul, ol, dl { } } + // actions + .list-actions { + @extend .cont-no-list; + + .action-item { + margin-bottom: ($baseline/4); + border-bottom: 1px dotted $gray-l4; + padding-bottom: ($baseline/4); + + + &:last-child { + margin-bottom: 0; + border: none; + padding-bottom: 0; + } + } + } + // navigation .nav-related, .nav-page { diff --git a/cms/static/sass/_shame.scss b/cms/static/sass/_shame.scss index 2a11037007..85d133af1f 100644 --- a/cms/static/sass/_shame.scss +++ b/cms/static/sass/_shame.scss @@ -2,10 +2,39 @@ // // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) // ==================== +// view - dashboard +body.dashboard { + + // elements - authorship controls + .wrapper-authorshiprights { + + .ui-toggle-control { + // needed to override general a element transition properties - need to fix. + transition-duration: 0.25s; + transition-timing-function: ease-in-out; + } + + .icon-remove-sign { + // needed to override general a element transition properties - need to fix. + transition-duration: 0.25s; + transition-timing-function: ease-in-out; + } + } +} + + +// yes we have no boldness today - need to fix the resets +body strong, +body b { + font-weight: 700; +} // known things to do (paint the fence, sand the floor, wax on/off) // ==================== + + +// known things to do (paint the fence, sand the floor, wax on/off): + // * centralize and move form styling into forms.scss - cms/static/sass/views/_textbooks.scss and cms/static/sass/views/_settings.scss // * move dialogue styles into cms/static/sass/elements/_modal.scss // * use the @include placeholder Bourbon mixin (http://bourbon.io/docs/#placeholder) for any placeholder styling - diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss index 660ba23e8c..7011089527 100644 --- a/cms/static/sass/base-style.scss +++ b/cms/static/sass/base-style.scss @@ -66,6 +66,7 @@ // xmodule @import 'xmodule/modules/css/module-styles.scss'; @import 'xmodule/descriptors/css/module-styles.scss'; +@import 'elements/xmodules'; // styling for Studio-specific contexts @import 'shame'; // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 9907b05995..c78e2f3692 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -93,6 +93,227 @@ form { } } +// ELEM: form wrapper +.wrapper-create-element { + height: 0; + margin-bottom: $baseline; + opacity: 0.0; + pointer-events: none; + overflow: hidden; + + &.animate { + @include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s); + } + + &.is-shown { + height: auto; // define a specific height for the animating version of this UI to work properly + opacity: 1.0; + pointer-events: auto; + } +} + +// ELEM: form +// form styling for creating a new content item (course, user, textbook) +form[class^="create-"] { + @extend .ui-window; + + .title { + @extend .t-title4; + font-weight: 600; + padding: $baseline ($baseline*1.5) 0 ($baseline*1.5); + } + + fieldset { + padding: $baseline ($baseline*1.5); + } + + + .list-input { + @extend .cont-no-list; + + .field { + margin: 0 0 ($baseline*0.75) 0; + + &:last-child { + margin-bottom: 0; + } + + &.required { + + label { + font-weight: 600; + } + + label:after { + margin-left: ($baseline/4); + content: "*"; + } + } + + label, input, textarea { + display: block; + } + + label { + @extend .t-copy-sub1; + @include transition(color $tmg-f3 ease-in-out 0s); + margin: 0 0 ($baseline/4) 0; + + &.is-focused { + color: $blue; + } + } + + + input, textarea { + @extend .t-copy-base; + @include transition(all $tmg-f2 ease-in-out 0s); + height: 100%; + width: 100%; + padding: ($baseline/2); + + &.long { + width: 100%; + } + + &.short { + width: 25%; + } + + /*@include placeholder { + color: $gray-l3; + }*/ + + &:focus { + + + .tip { + color: $gray; + } + } + } + + textarea.long { + height: ($baseline*5); + } + + input[type="checkbox"] { + display: inline-block; + margin-right: ($baseline/4); + width: auto; + height: auto; + + & + label { + display: inline-block; + } + } + + .tip { + @extend .t-copy-sub2; + @include transition(color, 0.15s, ease-in-out); + display: block; + margin-top: ($baseline/4); + color: $gray-l3; + } + + .tip-error { + display: none; + float: none; + } + + &.error { + label { + color: $red; + } + + .tip-error { + @extend .anim-fadeIn; + display: block; + color: $red; + } + + input { + border-color: $red; + } + } + } + + .field-inline { + + input, textarea, select { + width: 62%; + display: inline-block; + } + + .tip-stacked { + display: inline-block; + float: right; + width: 35%; + margin-top: 0; + } + + &.error { + .tip-error { + } + } + + } + + .field-group { + @include clearfix(); + margin: 0 0 ($baseline/2) 0; + + .field { + display: block; + width: 47%; + border-bottom: none; + margin: 0 ($baseline*0.75) 0 0; + padding: ($baseline/4) 0 0 0; + float: left; + position: relative; + + &:nth-child(odd) { + float: left; + } + + &:nth-child(even) { + float: right; + margin-right: 0; + } + + input, textarea { + width: 100%; + } + } + } + } + + .actions { + box-shadow: inset 0 1px 2px $shadow; + margin-top: ($baseline*0.75); + border-top: 1px solid $gray-l1; + padding: ($baseline*0.75) ($baseline*1.5); + background: $gray-l6; + + .action { + @include transition(all $tmg-f2 linear 0s); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + text-transform: uppercase; + } + + .action-primary { + @include blue-button; + @extend .t-action2; + } + + .action-secondary { + @include grey-button; + @extend .t-action2; + } + } +} + // ==================== // forms - grandfathered diff --git a/cms/static/sass/elements/_icons.scss b/cms/static/sass/elements/_icons.scss index a75c97ea76..9bbb72d67e 100644 --- a/cms/static/sass/elements/_icons.scss +++ b/cms/static/sass/elements/_icons.scss @@ -1,4 +1,4 @@ -// studio - elements - icons +// studio - elements - icons & badges // ==================== .icon { @@ -14,3 +14,45 @@ vertical-align: middle; margin-right: ($baseline/4); } + +// ui - badges +.wrapper-ui-badge { + position: absolute; + top: -1px; + left: ($baseline*1.5); + width: 100%; +} + +.ui-badge { + @extend .t-title9; + position: relative; + border-bottom-right-radius: ($baseline/10); + border-bottom-left-radius: ($baseline/10); + padding: ($baseline/4) ($baseline/2) ($baseline/4) ($baseline/2); + font-weight: 600; + text-transform: uppercase; + + * [class^="icon-"] { + margin-right: ($baseline/5); + } + + // OPTION: add this class for a visual hanging display + &.is-hanging { + @include box-sizing(border-box); + @extend .ui-depth2; + top: -($baseline/4); + + &:after { + position: absolute; + top: 0; + right: -($baseline/4); + display: block; + height: 0; + width: 0; + border-bottom: ($baseline/4) solid $black-t3; + border-right: ($baseline/4) solid transparent; + content: ""; + opacity: 0.5; + } + } +} diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index d05965d83c..a9e2f45533 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -64,12 +64,16 @@ nav { opacity: 0.0; pointer-events: none; width: ($baseline*8); + overflow: hidden; + height: 0; // dropped down state &.is-shown { opacity: 1.0; pointer-events: auto; + overflow: visible; + height: auto; } } diff --git a/cms/static/sass/elements/_system-feedback.scss b/cms/static/sass/elements/_system-feedback.scss index be1b41bf29..74187aca62 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -1,4 +1,46 @@ // studio - elements - system feedback +// ==================== + +// messages +.message { + @extend .t-copy-sub1; + display: block; +} + +.message-status { + display: none; + @include border-top-radius(2px); + @include box-sizing(border-box); + border-bottom: 2px solid $yellow-d2; + margin: 0 0 $baseline 0; + padding: ($baseline/2) $baseline; + font-weight: 500; + background: $yellow-d1; + color: $white; + + [class^="icon-"] { + position: relative; + top: 1px; + @include font-size(16); + display: inline-block; + margin-right: ($baseline/2); + } + + .text { + display: inline-block; + } + + &.error { + border-color: $red-d3; + background: $red-l1; + } + + &.is-shown { + display: block; + } +} + + // alerts, notifications, prompts, and status communication // ==================== diff --git a/cms/static/sass/elements/_system-help.scss b/cms/static/sass/elements/_system-help.scss index d1b0584fc4..c5fcc6a0ec 100644 --- a/cms/static/sass/elements/_system-help.scss +++ b/cms/static/sass/elements/_system-help.scss @@ -1,40 +1,213 @@ // studio - elements - system help // ==================== -// notices - in-context: to be used as notices to users within the context of a form/action -.notice-incontext { - @extend .ui-well; - border-radius: ($baseline/10); +// view introductions - common greeting/starting points for the UI +.content .introduction { + @include box-sizing(border-box); + margin-bottom: $baseline; .title { - @extend .t-title7; - margin-bottom: ($baseline/4); + @extend .t-title4; font-weight: 600; } .copy { @extend .t-copy-sub1; - @include transition(opacity $tmg-f2 ease-in-out 0s); - opacity: 0.75; } strong { font-weight: 600; } - &:hover { + // CASE: has links alongside + &.has-links { + @include clearfix(); .copy { - opacity: 1.0; + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .nav-introduction-supplementary { + @extend .t-copy-sub2; + float: right; + width: flex-grid(4,12); + display: block; + text-align: right; + + .icon { + @extend .t-action3; + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } } } } -// particular warnings around a workflow for something +// notices - in-context: to be used as notices to users within the context of a form/action +.notice-incontext { + @extend .ui-well; + border-radius: ($baseline/10); + position: relative; + overflow: hidden; + margin-bottom: $baseline; + + .title { + @extend .t-title6; + margin-bottom: ($baseline/2); + font-weight: 700; + } + + .copy { + @extend .t-copy-sub1; + @include transition(opacity $tmg-f2 ease-in-out 0s); + opacity: 0.75; + margin-bottom: $baseline; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + font-weight: 600; + } + + &.has-status { + + .status-indicator { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: ($baseline/4); + opacity: 0.40; + } + } + + // CASE: notice has actions { + &.has-actions { + + .list-actions { + margin-top: ($baseline*0.75); + + .action-item { + + } + + .action-primary { + @extend .btn-primary-blue; + @extend .t-action3; + } + } + } + + // list of notices all in one + &.list-notices { + + .notice-item { + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + padding-bottom: $baseline; + + &:last-child { + margin-bottom: 0; + border: none; + padding-bottom: 0; + } + } + } +} + +// particular notice - warnings around a workflow for something .notice-workflow { background: $yellow-l5; - .copy { + .status-indicator { + background: $yellow; + } + + title { color: $gray-d1; } + + .copy { + color: $gray; + } +} + +// particular notice - instructional +.notice-instruction { + background-color: $gray-l4; + + .title { + color: $gray-d2; + } + + .copy { + color: $gray-d2; + } + + &.has-actions { + + .list-actions { + + .action-item { + + } + + .action-primary { + @extend .btn-primary-blue; + @extend .t-action3; + } + } + } +} + +// particular notice - create +.notice-create { + background-color: $gray-l4; + + .title { + color: $gray-d2; + } + + .copy { + color: $gray-d2; + } + + &.has-actions { + + .list-actions { + + .action-item { + + } + + .action-primary { + @extend .btn-primary-green; + @extend .t-action3; + } + } + } +} + +// particular notice - confirmation +.notice-confirmation { + background-color: $green-l5; + + .status-indicator { + background: $green-s1; + } + + .title { + color: $green; + } + + .copy { + color: $gray; + } } diff --git a/cms/static/sass/elements/_xmodules.scss b/cms/static/sass/elements/_xmodules.scss new file mode 100644 index 0000000000..a8ec208b02 --- /dev/null +++ b/cms/static/sass/elements/_xmodules.scss @@ -0,0 +1,15 @@ +// studio - elements - xmodules +// ==================== + +// Video Alpha +.xmodule_VideoAlphaModule { + + // display mode + &.xmodule_display { + + // full screen + .video-controls .add-fullscreen { + display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors + } + } +} diff --git a/cms/static/sass/views/_account.scss b/cms/static/sass/views/_account.scss index 53f01eee6d..c2cf139400 100644 --- a/cms/static/sass/views/_account.scss +++ b/cms/static/sass/views/_account.scss @@ -252,44 +252,3 @@ body.signup, body.signin { } } } - -// ==================== - -// messages -.message { - @extend .t-copy-sub1; - display: block; -} - -.message-status { - display: none; - @include border-top-radius(2px); - @include box-sizing(border-box); - border-bottom: 2px solid $yellow-d2; - margin: 0 0 $baseline 0; - padding: ($baseline/2) $baseline; - font-weight: 500; - background: $yellow-d1; - color: $white; - - [class^="icon-"] { - position: relative; - top: 1px; - @include font-size(16); - display: inline-block; - margin-right: ($baseline/2); - } - - .text { - display: inline-block; - } - - &.error { - border-color: shade($red, 50%); - background: tint($red, 20%); - } - - &.is-shown { - display: block; - } -} diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index 8d1b068256..817fc726c0 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -3,84 +3,438 @@ body.dashboard { - .my-classes { - margin-top: $baseline; + // temp + .content { + margin-bottom: ($baseline*5); + + &:last-child { + margin-bottom: 0; + } } - .class-list { - margin-top: 20px; - border-radius: 3px; - border: 1px solid $darkGrey; - background: #fff; - box-shadow: 0 1px 2px rgba(0, 0, 0, .1); + // ==================== - li { - position: relative; - border-bottom: 1px solid $mediumGrey; + // basic layout + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } - &:last-child { - border-bottom: none; + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } + + .content-supplementary { + width: flex-grid(3, 12); + } + + // ==================== + + // elements - notices + .content .notice-incontext { + width: flexgrid(9, 9); + + // CASE: notice has actions { + &.has-actions, &.list-notices .notice-item.has-actions { + + .msg, .list-actions { + display: inline-block; + vertical-align: middle; + } + + .msg { + width: flex-grid(6, 9); + margin-right: flex-gutter(); + } + + .list-actions { + width: flex-grid(3, 9); + text-align: right; + margin-top: 0; + + .action-item { + + } + + .action-create-course { + @extend .btn-primary-green; + @extend .t-action3; + } + } + } + } + + + + // elements - course creation rights controls + .wrapper-creationrights { + overflow: hidden; + + .ui-toggle-control { + @extend .ui-depth2; + @extend .btn-secondary-gray; + @include clearfix(); + display: block; + text-align: left; + + // STATE: hover - syncing up colors with current so transition is smoother + &:hover { + background: $gray-d1; + color: $white; } - .class-link { - z-index: 100; + .label { + @extend .t-action3; + float: left; + width: flex-grid(8, 9); + margin: 3px flex-gutter() 0 0; + } + + .icon-remove-sign { + @extend .t-action1; + @include transform(rotate(45deg)); + @include transform-origin(center center); + @include transition(all $tmg-f1 linear 0s); + float: right; + text-align: right; + } + } + + .ui-toggle-target { + @extend .ui-depth1; + @include transition(opacity $tmg-f1 ease-in-out 0s); + position: relative; + top: -2px; + display: none; + opacity: 0; + } + + // CASE: when the content area is shown + &.is-shown { + + .ui-toggle-control { + @include border-bottom-radius(0); + + .icon-remove-sign { + @include transform(rotate(90deg)); + @include transform-origin(center center); + } + } + + .ui-toggle-target { display: block; - padding: 20px 25px; - line-height: 1.3; + opacity: 1.0; + } + } - &:hover { - background: $paleYellow; - + .view-live-button { + } + + // elements - course creation rights controls + .status-creationrights { + margin-top: $baseline; + + .title { + @extend .t-title7; + margin-bottom: ($baseline/4); + font-weight: 700; + color: $gray-d1; + } + + .copy { + + } + + .list-actions, .form-actions { + margin-top: ($baseline*0.75); + + .action-item { + + } + + .action-primary { + @extend .btn-primary-blue; + @extend .t-action3; + } + + // specific - request button + // BT: should we abstract these states out for all buttons like this + .action-request { + position: relative; + overflow: hidden; + + .icon-cog { + @include transition(all $tmg-f1 ease-in-out $tmg-f1); + @include font-size(20); + position: absolute; + top: ($baseline/2); + left: -($baseline); + visibility: hidden; + opacity: 0.0; + } + + // state: submitting + &.is-submitting { + padding-left: ($baseline*2); + + .icon-cog { + left: ($baseline*0.75); + visibility: visible; opacity: 1.0; - pointer-events: auto; } + } + + // state: has an error + &.has-error { + padding-left: ($baseline*2); + background: $red; + border-color: $red-d1; + + .icon-cog { + left: ($baseline*0.75); + visibility: visible; + opacity: 1.0; + } + } + } + } + + .status-update { + + .label { + @extend .cont-text-sr; + } + + .value { + border-radius: ($baseline/4); + position: relative; + overflow: hidden; + padding: ($baseline/5) ($baseline/2); + background: $gray; + + .status-indicator { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: ($baseline/4); + opacity: 0.40; + } + } + + .value-formal, .value-description { + border-radius: ($baseline/10); + display: inline-block; + vertical-align: middle; + color: $white; + } + + .value-formal { + @extend .t-title5; + margin: ($baseline/2); + font-weight: 700; + + [class^="icon-"] { + margin-right: ($baseline/4); + } + } + + .value-description { + @extend .t-copy-sub1; + position: relative; + color: $white; + opacity: 0.85; + } + } + + // CASE: rights are not requested yet + &.is-unrequested { + + .title { + @extend .cont-text-sr; + } + } + + // CASE: status is pending + &.is-pending { + + .status-update { + + .value { + background: $orange; + } + + .status-indicator { + background: $orange-d1; } } } - .class-name { - display: block; - font-size: 19px; - font-weight: 300; - } - .detail { - font-size: 14px; - font-weight: 400; - margin-right: 20px; - color: #3c3c3c; - } + // CASE: status is denied + &.is-denied { - // view live button - .view-live-button { - z-index: 10000; - position: absolute; - top: 15px; - right: $baseline; - padding: ($baseline/4) ($baseline/2); - opacity: 0.0; - pointer-events: none; + .status-update { - &:hover { - opacity: 1.0; - pointer-events: auto; - } + .value { + background: $red-l1; + } + + .status-indicator { + background: $red-s1; + } + } + } + } + + // ==================== + + // ELEM: course listings + .courses { + margin: $baseline 0; + } + + .list-courses { + margin-top: $baseline; + border-radius: 3px; + border: 1px solid $gray; + background: $white; + 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; + + // STATE: hover/focus + &:hover { + background: $paleYellow; + + .course-actions .view-live-button { + opacity: 1.0; + pointer-events: auto; + } + + .course-title { + color: $orange-d1; + } + + .course-metadata { + opacity: 1.0; + } + } + + .course-link, .course-actions { + @include box-sizing(border-box); + display: inline-block; + vertical-align: middle; + } + + // encompassing course link + .course-link { + @extend .ui-depth2; + width: flex-grid(7, 9); + margin-right: flex-gutter(); + } + + // course title + .course-title { + @extend .t-title4; + margin: 0 ($baseline*2) ($baseline/4) 0; + font-weight: 300; + } + + // 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; + } + } } } - .new-course { - padding: 15px 25px; - margin-top: 20px; - border-radius: 3px; - border: 1px solid $darkGrey; - background: #fff; - box-shadow: 0 1px 2px rgba(0, 0, 0, .1); - @include clearfix; + // ELEM: new user form + .wrapper-create-course { + + // CASE: when form is animating + &.animate { + + // STATE: shown + &.is-shown { + height: ($baseline*26); + + // STATE: errors + &.has-errors { + height: ($baseline*33); + } + } + } + } + + // ==================== + + // course listings + + .create-course { .row { - margin-bottom: 15px; - @include clearfix; + @include clearfix(); + margin-bottom: ($baseline*0.75); } .column { @@ -92,24 +446,21 @@ body.dashboard { margin-right: 4%; } - .course-info { - width: 600px; - } - label { + @extend .t-title7; display: block; - font-size: 13px; font-weight: 700; } .new-course-org, .new-course-number, - .new-course-name { + .new-course-name, + .new-course-run { width: 100%; } .new-course-name { - font-size: 19px; + @extend .t-title5; font-weight: 300; } @@ -120,5 +471,29 @@ body.dashboard { .new-course-cancel { @include white-button; } + + .item-details { + padding-bottom: 0; + } + + .wrap-error { + @include transition(all $tmg-f2 ease 0s); + height: 0; + overflow: hidden; + opacity: 0; + } + + .wrap-error.is-shown { + height: 65px; + opacity: 1; + } + + .message-status { + display: block; + margin-bottom: 0; + padding: ($baseline*.5) ($baseline*1.5) 8px ($baseline*1.5); + font-weight: bold; + } + } } diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index 8d2b2d9489..b83d22414b 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -30,7 +30,7 @@ body.course.textbooks { } .textbook { - @extend .window; + @extend .ui-window; position: relative; .view-textbook { @@ -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/static/sass/views/_users.scss b/cms/static/sass/views/_users.scss index ecaa319707..7e88edd38c 100644 --- a/cms/static/sass/views/_users.scss +++ b/cms/static/sass/views/_users.scss @@ -3,80 +3,227 @@ body.course.users { - .new-user-form { - display: none; - padding: 15px 20px; - background-color: $lightBluishGrey2; + // LAYOUT: page + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } - #result { - display: none; - float: left; - margin-bottom: 15px; - padding: 3px 15px; - border-radius: 3px; - background: $error-red; - font-size: 14px; - color: #fff; - } + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } - .form-elements { - clear: both; - } + .content-supplementary { + width: flex-grid(3, 12); + } - label { - display: inline-block; - margin-right: 10px; - } + // ELEM: content + .content { - .email-input { - width: 350px; - padding: 8px 8px 10px; - border-color: $darkGrey; + .introduction { + @extend .t-copy-sub1; + margin: 0 0 ($baseline*2) 0; } + } - .add-button { - @include blue-button; - padding: 5px 20px 9px; - } + // ELEM: no users notice + .content .notice-create { + width: flexgrid(9, 9); + margin-top: $baseline; - .cancel-button { - @include white-button; - padding: 5px 20px 9px; + // CASE: notice has actions { + &.has-actions { + + .msg, .list-actions { + display: inline-block; + vertical-align: middle; + } + + .msg { + width: flex-grid(6, 9); + margin-right: flex-gutter(); + } + + .list-actions { + width: flex-grid(3, 9); + text-align: right; + margin-top: 0; + + .action-item { + + } + + .action-primary { + @include green-button(); // overwriting for the sake of syncing older green button styles for now + @extend .t-action3; + padding: ($baseline/2) $baseline; + } + } } } + + // ELEM: new user form + .wrapper-create-user { + + &.is-shown { + height: ($baseline*15); + } + } + + // ELEM: listing of users + .user-list, .user-item, .item-metadata, .item-actions { + @include box-sizing(border-box); + } + .user-list { - border: 1px solid $mediumGrey; - background: #fff; - li { + .user-item { + @extend .ui-window; + @include clearfix(); position: relative; - padding: 20px; - border-bottom: 1px solid $mediumGrey; + width: flex-grid(9, 9); + margin: 0 0 ($baseline/2) 0; + padding: ($baseline*1.25) ($baseline*1.5) $baseline ($baseline*1.5); &:last-child { - border-bottom: none; + margin-bottom: 0; } - span { + .item-metadata, .item-actions { display: inline-block; + vertical-align: middle; } - .user-name { - margin-right: 10px; - font-size: 24px; - font-weight: 300; + // ELEM: item - flag + .flag-role { + @extend .ui-badge; + color: $white; + + .msg-you { + margin-left: ($baseline/5); + text-transform: none; + font-weight: 500; + color: $pink-l3; + } + + &:after { + border-bottom-color: $pink-d4; + } + + &.flag-role-staff { + background: $pink-u3; + } + + &.flag-role-admin { + background: $pink; + } } - .user-email { - font-size: 14px; - font-style: italic; - color: $mediumGrey; + // ELEM: item - metadata + .item-metadata { + width: flex-grid(5, 9); + margin-right: flex-gutter(); + + .user-username, .user-email { + display: inline-block; + vertical-align: middle; + } + + .user-username { + @extend .t-title4; + @include transition(color $tmg-f2 ease-in-out 0s); + margin: 0 ($baseline/2) ($baseline/10) 0; + color: $gray-d4; + font-weight: 600; + } + + .user-email { + @extend .t-title6; + } } + // ELEM: item - actions .item-actions { - top: 24px; + width: flex-grid(4, 9); + position: static; // nasty reset needed due to base.scss + text-align: right; + + .action { + display: inline-block; + vertical-align: middle; + } + + .action-role { + width: flex-grid(3, 4); + margin-right: flex-gutter(); + } + + .action-delete { + width: flex-grid(1, 4); + + // STATE: disabled + &.is-disabled { + opacity: 0.0; + visibility: hidden; + pointer-events: none; + } + } + + .delete { + @extend .ui-btn-non; + } + + // HACK: nasty reset needed due to base.scss + .delete-button { + margin-right: 0; + float: none; + color: inherit; + } + + // ELEM: admin role controls + .toggle-admin-role { + + &.add-admin-role { + @include blue-button; + @extend .t-action2; + @include transition(all .15s); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + } + + &.remove-admin-role { + @include grey-button; + @extend .t-action2; + @include transition(all .15s); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + } + } + + .notoggleforyou { + @extend .t-copy-sub1; + color: $gray-l2; + } + } + + // STATE: hover + &:hover { + + .user-username { + } + + .user-email { + + } + + .item-actions { + + } } } } -} \ No newline at end of file +} diff --git a/cms/templates/activation_active.html b/cms/templates/activation_active.html index 9a4ebd7e4e..d7133062de 100644 --- a/cms/templates/activation_active.html +++ b/cms/templates/activation_active.html @@ -2,14 +2,31 @@ <%inherit file="base.html" /> <%block name="content"> - -
      -
      - -
      -

      ${_("Account already active!")}

      -

      ${_('This account has already been activated.')}${_("Log in here.")}

      +
      +
      +

      ${_("Studio Account Activation")}

      +
      -
      +
      +
      +
      +
      + +
      +
      +

      ${_("Your account is already active")}

      +
      +

      ${_("This account, set up using {0}, has already been activated. Please sign in to start working within edX Studio.".format(user.email))}

      +
      +
      + + +
      +
      +
      diff --git a/cms/templates/activation_complete.html b/cms/templates/activation_complete.html index d845c5153b..27efbdc34f 100644 --- a/cms/templates/activation_complete.html +++ b/cms/templates/activation_complete.html @@ -2,12 +2,31 @@ <%inherit file="base.html" /> <%block name="content"> - -
      -
      -

      ${_("Activation Complete!")}

      -

      ${_('Thanks for activating your account.')}${_("Log in here.")}

      +
      +
      +

      ${_("Studio Account Activation")}

      +
      -
      +
      +
      +
      +
      + +
      +
      +

      ${_("Your account activation is complete!")}

      +
      +

      ${_("Thank you for activating your account. You may now sign in and start using edX Studio to author courses.")}

      +
      +
      + + +
      +
      +
      diff --git a/cms/templates/activation_invalid.html b/cms/templates/activation_invalid.html index 3ee4e8ec4e..7f3fbed5a9 100644 --- a/cms/templates/activation_invalid.html +++ b/cms/templates/activation_invalid.html @@ -2,14 +2,32 @@ <%inherit file="base.html" /> <%block name="content"> -
      -
      -

      ${_("Activation Invalid")}

      - -

      ${_('Something went wrong. Check to make sure the URL you went to was correct -- e-mail programs will sometimes split it into two lines. If you still have issues, e-mail us to let us know what happened at {email}.').format(email='bugs@mitx.mit.edu')}

      - -

      ${_('Or you can go back to the {link_start}home page{link_end}.').format( - link_start='', link_end='')}

      +
      +
      +

      ${_("Studio Account Activation")}

      +
      +
      + +
      +
      +
      +
      + +
      +
      +

      ${_('Your account activation is invalid')}

      +
      +

      ${_("We're sorry. Something went wrong with your activation. Check to make sure the URL you went to was correct — e-mail programs will sometimes split it into two lines.")}

      +

      ${_("If you still have issues, contact edX Support. In the meatime, you can also return to")} {_('the Studio homepage.')}

      +
      +
      + + +
      +
      -
      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/base.html b/cms/templates/base.html index 44ebf59170..b53dc2657d 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -58,6 +58,18 @@ + % if context_course: + + % endif
      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/course_info.html b/cms/templates/course_info.html index dcfffd1d5a..03f4c35d14 100644 --- a/cms/templates/course_info.html +++ b/cms/templates/course_info.html @@ -6,9 +6,15 @@ <%block name="title">${_("Course Updates")} <%block name="bodyclass">is-signedin course course-info updates +<%block name="header_extras"> +% for template_name in ["course_info_update", "course_info_handouts"]: + +% endfor + <%block name="jsextra"> - diff --git a/cms/templates/dev/dev_mode.html b/cms/templates/dev/dev_mode.html new file mode 100644 index 0000000000..9ee409d5de --- /dev/null +++ b/cms/templates/dev/dev_mode.html @@ -0,0 +1,4 @@ +<%inherit file="../base.html" /> +<%block name="content"> +You're in dev mode! + 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/emails/course_creator_denied.txt b/cms/templates/emails/course_creator_denied.txt new file mode 100644 index 0000000000..739ece6b6f --- /dev/null +++ b/cms/templates/emails/course_creator_denied.txt @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Your request for course creation rights to edX Studio have been denied. If you believe this was in error, please contact: ")} + +${ studio_request_email } diff --git a/cms/templates/emails/course_creator_granted.txt b/cms/templates/emails/course_creator_granted.txt new file mode 100644 index 0000000000..a867f94334 --- /dev/null +++ b/cms/templates/emails/course_creator_granted.txt @@ -0,0 +1,9 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Your request for course creation rights to edX Studio have been granted. To create your first course, visit:")} + +% if is_secure: +https://${ site } +% else: +http://${ site } +% endif diff --git a/cms/templates/emails/course_creator_revoked.txt b/cms/templates/emails/course_creator_revoked.txt new file mode 100644 index 0000000000..839c5a0d78 --- /dev/null +++ b/cms/templates/emails/course_creator_revoked.txt @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Your course creation rights to edX Studio have been revoked. If you believe this was in error, please contact: ")} + +${ studio_request_email } diff --git a/cms/templates/emails/course_creator_subject.txt b/cms/templates/emails/course_creator_subject.txt new file mode 100644 index 0000000000..35ec0ecc48 --- /dev/null +++ b/cms/templates/emails/course_creator_subject.txt @@ -0,0 +1,2 @@ +<%! from django.utils.translation import ugettext as _ %> +${_("Your course creator status for edX Studio")} diff --git a/cms/templates/index.html b/cms/templates/index.html index f0baef4f09..9702eedada 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -5,89 +5,347 @@ <%block name="title">${_("My Courses")} <%block name="bodyclass">is-signedin index dashboard -<%block name="header_extras"> - + + + +<%block name="jsextra"> + <%block name="content"> -
      -
      -

      ${_("My Courses")}

      +
      +
      +

      ${_("My Courses")}

      - % if user.is_active: - - % endif -
      -
      + % if user.is_active: + + % endif +
      +
      + +
      + % if user.is_active: +
      +
      -
      -
      -

      - ${_("Welcome, %(name)s") % dict(name= user.username)}. - ${_("Here are all of the courses you are currently authoring in Studio:")}

      -
      -
      -
      +

      ${_("Welcome, {0}!".format(user.username))}

      -
      -
      -
      - % if user.is_active: - - % else: -
      -

      - ${_("In order to start authoring courses using edX Studio, please click on the activation link in your email.")} -

      + %if len(courses) > 0: +
      +

      ${_("Here are all of the courses you currently have access to in Studio:")}

      +
      + + %else: +
      +

      ${_("You currently aren't associated with any Studio Courses.")}

      +
      + %endif +
      + + % if course_creator_status=='granted': +
      +
      +
      +
      + +
      +

      ${_("Create a New Course")}

      + +
      + ${_("Required Information to Create a New Course")} + +
        +
      1. + + + ${_("The public display name for your course.")} +
      2. +
      3. + + + ${_("The name of the organization sponsoring the course")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
      4. + +
      5. + + + ${_("The unique number that identifies your course within your organization")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
      6. + +
      7. + + + ${_("The term in which your course will run")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
      8. +
      + +
      +
      + +
      + + +
      +
      +
      + % endif + + %if len(courses) > 0: +
      + +
      + + %else: +
      +
      + +
      +
      +
      +

      ${_("Are you staff on an existing Studio course?")}

      +
      +

      ${_('You will need to be added to the course in Studio by the course creator. Please get in touch with the course creator or administrator for the specific course you are helping to author.')}

      +
      +
      +
      + + %if course_creator_status == "granted": +
      +
      +

      ${_('Create Your First Course')}

      +
      +

      ${_('Your new course is just a click away!')}

      +
      +
      + + +
      % endif -
      -
      -
      + +
      + % endif + + + %if course_creator_status == "unrequested": +
      +

      + ${_('Becoming a Course Creator in Studio')} +

      + +
      +
      +

      ${_('edX Studio is a hosted solution for our xConsortium partners and selected guests. Courses for which you are a team member appear above for you to edit, while course creator privileges are granted by edX. Our team will evaluate your request and provide you feedback within 24 hours during the work week.')}

      +
      + +
      +

      ${_('Your Course Creator Request Status:')}

      + +
      + +
      + +
      +
      +
      +
      +
      + + %elif course_creator_status == "denied": +
      +

      + ${_('Your Course Creator Request Status')} +

      + +
      +
      +

      ${_('edX Studio is a hosted solution for our xConsortium partners and selected guests. Courses for which you are a team member appear above for you to edit, while course creator privileges are granted by edX. Our team is has completed evaluating your request.')}

      +
      + +
      +

      ${_('Your Course Creator Request Status:')}

      + +
      +
      ${_('Your Course Creator request is:')}
      +
      + + ${_('Denied')} + ${_('Your request did not meet the criteria/guidelines specified by edX Staff.')} +
      +
      +
      +
      +
      + + %elif course_creator_status == "pending": +
      +

      + ${_('Your Course Creator Request Status')} +

      + +
      +
      +

      ${_('edX Studio is a hosted solution for our xConsortium partners and selected guests. Courses for which you are a team member appear above for you to edit, while course creator privileges are granted by edX. Our team is currently evaluating your request.')}

      +
      + +
      +

      ${_('Your Course Creator Request Status:')}

      + +
      +
      ${_('Your Course Creator request is:')}
      +
      + + ${_('Pending')} + ${_('Your request is currently being reviewed by edX staff and should be updated shortly.')} +
      +
      +
      +
      +
      + % endif + + + + +
      + + + % else: +
      +
      +
      +

      ${_("Thanks for signing up, %(name)s!") % dict(name= user.username)}

      +
      + +
      +
      +

      ${_('We need to verify your email address')}

      +
      +

      ${_('Almost there! In order to complete your sign up we need you to verify your email address (%(email)s). An activation message and next steps should be waiting for you there.') % dict(email=user.email)}

      +
      +
      +
      +
      + + +
      + + %endif + diff --git a/cms/templates/js/advanced_entry.underscore b/cms/templates/js/advanced_entry.underscore new file mode 100644 index 0000000000..26b1a386f6 --- /dev/null +++ b/cms/templates/js/advanced_entry.underscore @@ -0,0 +1,11 @@ +
    5. +
      + + +
      + +
      + + +
      +
    6. diff --git a/cms/static/client_templates/course_info_handouts.html b/cms/templates/js/course_info_handouts.underscore similarity index 100% rename from cms/static/client_templates/course_info_handouts.html rename to cms/templates/js/course_info_handouts.underscore diff --git a/cms/static/client_templates/course_info_update.html b/cms/templates/js/course_info_update.underscore similarity index 100% rename from cms/static/client_templates/course_info_update.html rename to cms/templates/js/course_info_update.underscore 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/login.html b/cms/templates/login.html index 1997fd23fe..3b41be1a7d 100644 --- a/cms/templates/login.html +++ b/cms/templates/login.html @@ -79,7 +79,7 @@ function(json) { if(json.success) { var next = /next=([^&]*)/g.exec(decodeURIComponent(window.location.search)); - if (next && next.length > 1) { + if (next && next.length > 1 && !isExternal(next[1])) { location.href = next[1]; } else location.href = "${reverse('homepage')}"; diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 22d57be41d..e8026677e8 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -1,14 +1,17 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%! from auth.authz import is_user_in_course_group_role %> +<%! import json %> <%inherit file="base.html" /> <%block name="title">${_("Course Team Settings")} -<%block name="bodyclass">is-signedin course users settings team +<%block name="bodyclass">is-signedin course users team <%block name="content">

      - ${_("Course Settings")} + ${_("Settings")} > ${_("Course Team")}

      @@ -17,7 +20,7 @@ @@ -25,111 +28,348 @@
      -
      -
      - -
      -

      ${_("The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.")}

      -
      - -
      +
      +
      +
      %if allow_actions: -
      -
      -
      - - - -
      -
      - %endif -
      -
        - % for user in staff: -
      1. - ${user.username} - ${user.email} - %if allow_actions : -
        - %if request_user_id != user.id: - - %endif -
        - %endif -
      2. - % endfor -
      +
      +
      +
      +

      ${_("Add a User to Your Course's Team")}

      + +
      + ${_("New Team Member Information")} + +
        +
      1. + + + ${_("Please provide the email address of the course staff member you'd like to add")} +
      2. +
      +
      +
      + +
      + + +
      +
      + %endif + +
        + % for user in staff: + <% api_url = reverse('course_team_user', kwargs=dict( + org=context_course.location.org, + course=context_course.location.course, + name=context_course.location.name, + email=user.email, + )) + %> +
      1. + + <% is_instuctor = is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False) %> + % if is_instuctor: + + + ${_("Current Role:")} + + ${_("Admin")} + % if request.user.id == user.id: + ${_("You!")} + % endif + + + + % else: + + + ${_("Current Role:")} + + ${_("Staff")} + % if request.user.id == user.id: + ${_("You!")} + % endif + + + + % endif + + + + % if allow_actions: + + % endif + +
      2. + % endfor +
      + + <% user_is_instuctor = is_user_in_course_group_role(request.user, context_course.location, 'instructor', check_staff=False) %> + % if user_is_instuctor and len(staff) == 1: +
      +
      +

      ${_('Add Team Members to This Course')}

      +
      +

      ${_('Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account. ')}

      +
      +
      + + +
      + %endif
      -
      + + +
      <%block name="jsextra"> 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/settings.html b/cms/templates/settings.html index 1dde9b6c0d..1f5d89b2b9 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -262,7 +262,7 @@ from contentstore import utils diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index e1b1913c87..d87f3586b6 100644 --- a/cms/templates/settings_advanced.html +++ b/cms/templates/settings_advanced.html @@ -1,15 +1,17 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> +<%namespace name='static' file='static_content.html'/> <%! from django.core.urlresolvers import reverse %> +<%! from django.utils.translation import ugettext as _ %> +<%! from contentstore import utils %> <%block name="title">${_("Advanced Settings")} <%block name="bodyclass">is-signedin course advanced settings -<%namespace name='static' file='static_content.html'/> -<%! -from contentstore import utils -%> - <%block name="jsextra"> +% for template_name in ["advanced_entry"]: + +% endfor @@ -96,7 +98,7 @@ editor.render(); % endif diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index d9040009cc..f3a4584a26 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -140,7 +140,7 @@ from contentstore import utils 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 f001611b67..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}

      @@ -60,7 +60,7 @@ ${_("Grading")}
    diff --git a/lms/templates/courseware/courses.html b/lms/templates/courseware/courses.html index f731f0d989..1f1d96a0df 100644 --- a/lms/templates/courseware/courses.html +++ b/lms/templates/courseware/courses.html @@ -1,8 +1,9 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="../main.html" /> <%namespace name='static' file='../static_content.html'/> -<%block name="title">Courses +<%block name="title">${_("Courses")}
    - ## I'm removing this for now since we aren't using it for the fall. - ## <%include file="course_filter.html" />
      %for course in courses: diff --git a/lms/templates/courseware/courseware-error.html b/lms/templates/courseware/courseware-error.html index e289e1c99d..f0f7969026 100644 --- a/lms/templates/courseware/courseware-error.html +++ b/lms/templates/courseware/courseware-error.html @@ -1,7 +1,9 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="/main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">courseware -<%block name="title">Courseware – edX +## Translators: "edX" should *not* be translated +<%block name="title">${_("Courseware")} - ${settings.PLATFORM_NAME} <%block name="headextra"> <%static:css group='course'/> @@ -11,7 +13,7 @@
      -

      There has been an error on the edX servers

      -

      We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

      +

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

      +

      ${_("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(tech_support_email=settings.TECH_SUPPORT_EMAIL)}

      diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index e009e535e3..2a52b50b09 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -1,7 +1,8 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="/main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="bodyclass">courseware ${course.css_class} -<%block name="title">${course.number} Courseware +<%block name="title">${_("{course_number} Courseware").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='course'/> @@ -155,7 +156,7 @@
      % if timer_navigation_return_url: - Return to Exam + ${_("Return to Exam")} % endif
      Time Remaining:
       
      @@ -170,9 +171,9 @@
      % if accordion: -
      +
      - close + ${_("close")}
    - ## `news` should be `None` whenever a non-edX theme is enabled: - ## see common/djangoapps/student/views.py#_get_news - %if news: - - %endif
    -

    Current Courses

    +

    ${_("Current Courses")}

    % if len(courses) > 0: @@ -211,11 +140,11 @@ % if course.id in show_courseware_links_for: - ${course.number} ${course.display_name_with_default} Cover Image + ${_('{course_number} {course_name} Cover Image').format(course_number='${course.number}', course_name='${course.display_name_with_default}') |h} % else:
    - ${course.number} ${course.display_name_with_default} Cover Image + ${_('{course_number} {course_name} Cover Image').format(course_number='${course.number}', course_name='${course.display_name_with_default}')}
    % endif @@ -223,19 +152,19 @@

    % if course.has_ended(): - Course Completed - ${course.end_date_text} + ${_("Course Completed - {end_date}").format(end_date=course.end_date_text)} % elif course.has_started(): - Course Started - ${course.start_date_text} + ${_("Course Started - {start_date}").format(start_date=course.start_date_text)} % else: # hasn't started yet - Course Starts - ${course.start_date_text} + ${_("Course Starts - {start_date}").format(start_date=course.start_date_text)} % endif

    ${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

    @@ -249,27 +178,41 @@ % if registration is None and testcenter_exam_info.is_registering():
    - Register for Pearson exam -

    Registration for the Pearson exam is now open and will close on ${testcenter_exam_info.registration_end_date_text}

    + ${_("Register for Pearson exam")} +

    ${_("Registration for the Pearson exam is now open and will close on {end_date}").format(end_date="{}".format(testcenter_exam_info.registration_end_date_text))}

    % endif % if registration is not None: % if registration.is_accepted:
    - Schedule Pearson exam -

    Registration number: ${registration.client_candidate_id}

    -

    Write this down! You’ll need it to schedule your exam.

    + ${_("Schedule Pearson exam")} +

    ${_("{link_start}Registration{link_end} number: {number}").format( + link_start=''.format(url=testcenter_register_target), + link_end='', + number=registration.client_candidate_id, + )}

    +

    ${_("Write this down! You'll need it to schedule your exam.")}

    % endif % if registration.is_rejected:
    -

    Your registration for the Pearson exam has been rejected. Please see your registration status details. Otherwise contact edX at exam-help@edx.org for further help.

    +

    + ${_("Your registration for the Pearson exam has been rejected. Please {link_start}see your registration status details{link_end}.").format( + 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.display_number_with_default), + link_end='', + email="exam-help@edx.org", + )}

    % endif % if not registration.is_accepted and not registration.is_rejected:
    -

    Your registration for the Pearson exam is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.

    +

    ${_("Your {link_start}registration for the Pearson exam{link_end} is pending.").format(link_start=''.format(url=testcenter_register_target), link_end='')} + ${_("Within a few days, you should see a confirmation number here, which can be used to schedule your exam.")} +

    % endif % endif @@ -292,17 +235,16 @@
    % if cert_status['status'] == 'processing': -

    Final course details are being wrapped up at - this time. Your final standing will be available shortly.

    +

    ${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}

    % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'): -

    Your final grade: +

    ${_("Your final grade:")} ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. % if cert_status['status'] == 'notpassing': - Grade required for a certificate: + ${_("Grade required for a certificate:")} ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}. % elif cert_status['status'] == 'restricted':

    - Your certificate is being held pending confirmation that the issuance of your certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting ${settings.CONTACT_EMAIL}. + ${_("Your certificate is being held pending confirmation that the issuance of your certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {email}.").format(email='{email}.'.format(email=settings.CONTACT_EMAIL))}

    % endif

    @@ -312,17 +254,17 @@ % endif @@ -332,12 +274,12 @@ % if course.id in show_courseware_links_for: % if course.has_ended(): - View Archived Course + ${_('View Archived Course')} % else: - View Course + ${_('View Course')} % endif % endif - Unregister + ${_('Unregister')}
    @@ -346,16 +288,16 @@ % endfor % else:
    -

    Looks like you haven't registered for any courses yet.

    +

    ${_("Looks like you haven't registered for any courses yet.")}

    - Find courses now! + ${_("Find courses now!")}
    % endif % if staff_access and len(errored_courses) > 0:
    -

    Course-loading errors

    +

    ${_("Course-loading errors")}

    % for course_dir, errors in errored_courses.items():

    ${course_dir | h}

    @@ -374,7 +316,7 @@ @@ -48,7 +49,7 @@
    -

    Prompt (Hide)

    +

    ${_('Prompt')} ${_("(Hide)")}

    @@ -56,14 +57,14 @@
    - +
    -

    Student Response

    +

    ${_("Student Response")}

    @@ -72,18 +73,18 @@

    -

    Written Feedback

    -

    - Flag as inappropriate content for later review + ${_("Flag as inappropriate content for later review")}

    - +
    - - + +
    diff --git a/lms/templates/invalid_email_key.html b/lms/templates/invalid_email_key.html index 212f91fa9d..5fdcdb4b12 100644 --- a/lms/templates/invalid_email_key.html +++ b/lms/templates/invalid_email_key.html @@ -1,16 +1,18 @@ +<%! from django.utils.translation import ugettext as _ %> + <%inherit file="main.html" />
    -

    Invalid email change key

    +

    ${_("Invalid email change key")}


    -

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

    +

    ${_("This e-mail key is not valid. Please check:")}

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

    Go back to the home page.

    +

    ${_('Go back to the {link_start}home page{link_end}.').format(link_start='', link_end='')}

    diff --git a/lms/templates/licenses/serial_numbers.html b/lms/templates/licenses/serial_numbers.html index 18f0ff8a9b..a9f048f18f 100644 --- a/lms/templates/licenses/serial_numbers.html +++ b/lms/templates/licenses/serial_numbers.html @@ -1,10 +1,11 @@ +<%! from django.utils.translation import ugettext as _ %>
    % for license in licenses:
    ${license.software.name}:
    % if license.serial:
    ${license.serial}
    % else: -
    None Available
    +
    ${_("None Available")}
    % endif % endfor
    diff --git a/lms/templates/login.html b/lms/templates/login.html index 5ab63a86c2..b737255a0d 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -1,3 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + <%inherit file="main.html" /> <%namespace name='static' file='static_content.html'/> @@ -5,7 +7,7 @@ <%! from django.core.urlresolvers import reverse %> <%! from django.utils.translation import ugettext as _ %> -<%block name="title">Log into your ${settings.PLATFORM_NAME} Account +<%block name="title">${_("Log into your {platform_name} Account").format(platform_name=settings.PLATFORM_NAME)} <%block name="js_extra"> @@ -90,46 +92,46 @@

    - Please provide the following information to log into your ${settings.PLATFORM_NAME} account. Required fields are noted by bold text and an asterisk (*). + ${_('Please provide the following information to log into your {platform_name} account. Required fields are noted by bold text and an asterisk (*).').format(platform_name=settings.PLATFORM_NAME)}

    - Required Information + ${_('Required Information')}
    1. - +
    2. - + - Forgot password? + ${_('Forgot password?')}
    - Account Preferences + ${_('Account Preferences')}
    1. - +
    @@ -147,27 +149,27 @@