Commit 91b23c40 by Julian Arni

Merge branch 'master' into jkarni/fix/descriptorsystemruntime

parents b38750e1 6cf8643d
...@@ -53,7 +53,7 @@ Christina Roberts <christina@edx.org> ...@@ -53,7 +53,7 @@ Christina Roberts <christina@edx.org>
Robert Chirwa <robert@edx.org> Robert Chirwa <robert@edx.org>
Ed Zarecor <ed@edx.org> Ed Zarecor <ed@edx.org>
Deena Wang <thedeenawang@gmail.com> Deena Wang <thedeenawang@gmail.com>
Jean Manuel-Nater <jnater@edx.org> Jean Manuelter <jnater@edx.org>
Emily Zhang <1800.ehz.hang@gmail.com> Emily Zhang <1800.ehz.hang@gmail.com>
Jennifer Akana <jaakana@gmail.com> Jennifer Akana <jaakana@gmail.com>
Peter Baratta <peter.baratta@gmail.com> Peter Baratta <peter.baratta@gmail.com>
......
...@@ -5,6 +5,14 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,14 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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. 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 Studio: Send e-mails to new Studio users (on edge only) when their course creator
...@@ -40,6 +48,9 @@ Common: Add a manage.py that knows about edx-platform specific settings and proj ...@@ -40,6 +48,9 @@ Common: Add a manage.py that knows about edx-platform specific settings and proj
Common: Added *experimental* support for jsinput type. 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: Added setting to specify Celery Broker vhost
Common: Utilize new XBlock bulk save API in LMS and CMS. Common: Utilize new XBlock bulk save API in LMS and CMS.
......
...@@ -40,6 +40,7 @@ Feature: Advanced (manual) course policy ...@@ -40,6 +40,7 @@ Feature: Advanced (manual) course policy
And I reload the page And I reload the page
Then the policy key value is unchanged 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 Scenario: Test automatic quoting of non-JSON values
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I create a non-JSON value not in quotes When I create a non-JSON value not in quotes
......
...@@ -10,6 +10,7 @@ Feature: Course checklists ...@@ -10,6 +10,7 @@ Feature: Course checklists
Then I can check and uncheck tasks in a checklist Then I can check and uncheck tasks in a checklist
And They are correctly selected after reloading the page 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 Scenario: A task can link to a location within Studio
Given I have opened Checklists Given I have opened Checklists
When I select a link to the course outline When I select a link to the course outline
...@@ -17,6 +18,7 @@ Feature: Course checklists ...@@ -17,6 +18,7 @@ Feature: Course checklists
And I press the browser back button And I press the browser back button
Then I am brought back to the course outline in the correct state 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 Scenario: A task can link to a location outside Studio
Given I have opened Checklists Given I have opened Checklists
When I select a link to help page When I select a link to help page
......
...@@ -56,10 +56,8 @@ def i_have_opened_a_new_course(_step): ...@@ -56,10 +56,8 @@ def i_have_opened_a_new_course(_step):
@step('(I select|s?he selects) the new course') @step('(I select|s?he selects) the new course')
def select_new_course(_step, whom): def select_new_course(_step, whom):
course_link_xpath = '//div[contains(@class, "courses")]//a[contains(@class, "class-link")]//span[contains(., "{name}")]/..'.format( course_link_css = 'a.course-link'
name="Robot Super Course") world.css_click(course_link_css)
element = world.browser.find_by_xpath(course_link_xpath)
element.click()
@step(u'I press the "([^"]*)" notification button$') @step(u'I press the "([^"]*)" notification button$')
...@@ -72,8 +70,12 @@ def press_the_notification_button(_step, name): ...@@ -72,8 +70,12 @@ def press_the_notification_button(_step, name):
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning') confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
error_showing = world.is_css_present('.is-shown.wrapper-notification-error') error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
return confirmation_dismissed or error_showing return confirmation_dismissed or error_showing
if world.is_firefox():
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name # 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 "(.*)"$') @step('I change the "(.*)" field to "(.*)"$')
...@@ -144,24 +146,13 @@ def fill_in_course_info( ...@@ -144,24 +146,13 @@ def fill_in_course_info(
def log_into_studio( def log_into_studio(
uname='robot', uname='robot',
email='robot+studio@edx.org', email='robot+studio@edx.org',
password='test'): password='test',
name='Robot Studio'):
world.browser.cookies.delete() world.log_in(username=uname, password=password, email=email, name=name)
# Navigate to the studio dashboard
world.visit('/') world.visit('/')
world.wait_for(lambda _driver: uname in world.css_find('h2.title')[0].text)
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(): def create_a_course():
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')
...@@ -178,9 +169,10 @@ def create_a_course(): ...@@ -178,9 +169,10 @@ def create_a_course():
group, __ = Group.objects.get_or_create(name=groupname) group, __ = Group.objects.get_or_create(name=groupname)
user.groups.add(group) user.groups.add(group)
user.save() 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) world.css_click(course_link_css)
course_title_css = 'span.course-title' course_title_css = 'span.course-title'
assert_true(world.is_css_present(course_title_css)) assert_true(world.is_css_present(course_title_css))
...@@ -228,6 +220,26 @@ def i_created_a_video_component(step): ...@@ -228,6 +220,26 @@ def i_created_a_video_component(step):
) )
@step('I have created a Video Alpha component$')
def i_created_video_alpha(step):
step.given('I have enabled the videoalpha advanced module')
world.css_click('a.course-link')
step.given('I have added a new subsection')
step.given('I expand the first section')
world.css_click('a.new-unit-item')
world.css_click('.large-advanced-icon')
world.click_component_from_menu('videoalpha', None, '.xmodule_VideoAlphaModule')
@step('I have enabled the (.*) advanced module$')
def i_enabled_the_advanced_module(step, module):
step.given('I have opened a new course section in Studio')
world.css_click('.nav-course-settings')
world.css_click('.nav-course-settings-advanced a')
type_in_codemirror(0, '["%s"]' % module)
press_the_notification_button(step, 'Save')
@step('I have clicked the new unit button') @step('I have clicked the new unit button')
def open_new_unit(step): def open_new_unit(step):
step.given('I have opened a new course section in Studio') step.given('I have opened a new course section in Studio')
...@@ -236,14 +248,14 @@ def open_new_unit(step): ...@@ -236,14 +248,14 @@ def open_new_unit(step):
world.css_click('a.new-unit-item') world.css_click('a.new-unit-item')
@step('when I view the video it (.*) show the captions') @step('when I view the (video.*) it (.*) show the captions')
def shows_captions(step, show_captions): def shows_captions(_step, video_type, show_captions):
# Prevent cookies from overriding course settings # Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions') world.browser.cookies.delete('hide_captions')
if show_captions == 'does not': if show_captions == 'does not':
assert world.css_has_class('.video', 'closed') assert world.css_has_class('.%s' % video_type, 'closed')
else: 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$') @step('the save button is disabled$')
...@@ -265,7 +277,7 @@ def i_am_shown_a_notification(step, notification_type): ...@@ -265,7 +277,7 @@ def i_am_shown_a_notification(step, notification_type):
def type_in_codemirror(index, text): 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', '')") world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')")
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
if world.is_mac(): if world.is_mac():
...@@ -274,3 +286,5 @@ def type_in_codemirror(index, text): ...@@ -274,3 +286,5 @@ def type_in_codemirror(index, text):
g._element.send_keys(Keys.CONTROL + 'a') g._element.send_keys(Keys.CONTROL + 'a')
g._element.send_keys(Keys.DELETE) g._element.send_keys(Keys.DELETE)
g._element.send_keys(text) g._element.send_keys(text)
if world.is_firefox():
world.trigger_event('div.CodeMirror', index=index, event='blur')
...@@ -56,13 +56,24 @@ def click_component_from_menu(category, boilerplate, expected_css): ...@@ -56,13 +56,24 @@ def click_component_from_menu(category, boilerplate, expected_css):
def edit_component_and_select_settings(): def edit_component_and_select_settings():
world.wait_for(lambda _driver: world.css_visible('a.edit-button')) world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
world.css_click('a.edit-button') world.css_click('a.edit-button')
world.css_click('#settings-mode') world.css_click('#settings-mode a')
@world.absorb
def edit_component():
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
world.css_click('a.edit-button')
@world.absorb @world.absorb
def verify_setting_entry(setting, display_name, value, explicitly_set): def verify_setting_entry(setting, display_name, value, explicitly_set):
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value) 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] settingClearButton = setting.find_by_css('.setting-clear')[0]
assert_equal(explicitly_set, settingClearButton.has_class('active')) assert_equal(explicitly_set, settingClearButton.has_class('active'))
assert_equal(not explicitly_set, settingClearButton.has_class('inactive')) assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
...@@ -103,8 +114,20 @@ def revert_setting_entry(label): ...@@ -103,8 +114,20 @@ def revert_setting_entry(label):
@world.absorb @world.absorb
def get_setting_entry(label): def get_setting_entry(label):
settings = world.browser.find_by_css('.wrapper-comp-setting') def get_setting():
for setting in settings: settings = world.css_find('.wrapper-comp-setting')
if setting.find_by_css('.setting-label')[0].value == label: for setting in settings:
return setting if setting.find_by_css('.setting-label')[0].value == label:
return None 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)
...@@ -52,7 +52,7 @@ def have_a_course_with_two_sections(step): ...@@ -52,7 +52,7 @@ def have_a_course_with_two_sections(step):
def navigate_to_the_course_overview_page(step): def navigate_to_the_course_overview_page(step):
create_studio_user(is_staff=True) create_studio_user(is_staff=True)
log_into_studio() log_into_studio()
course_locator = '.class-name' course_locator = 'a.course-link'
world.css_click(course_locator) world.css_click(course_locator)
......
...@@ -15,6 +15,8 @@ Feature: Course Team ...@@ -15,6 +15,8 @@ Feature: Course Team
And I am viewing the course team settings And I am viewing the course team settings
When I add "bob" to the course team When I add "bob" to the course team
And "bob" logs in And "bob" logs in
And he selects the new course
And he views the course team settings
Then he cannot delete users Then he cannot delete users
And he cannot add users And he cannot add users
...@@ -69,7 +71,7 @@ Feature: Course Team ...@@ -69,7 +71,7 @@ Feature: Course Team
And she selects the new course And she selects the new course
And she views the course team settings And she views the course team settings
And she deletes me from the course team And she deletes me from the course team
And I log in And I am logged into studio
Then I do not see the course on my page Then I do not see the course on my page
Scenario: Admins should be able to remove their own admin rights Scenario: Admins should be able to remove their own admin rights
......
...@@ -2,9 +2,10 @@ ...@@ -2,9 +2,10 @@
#pylint: disable=W0621 #pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import create_studio_user, log_into_studio from common import create_studio_user
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from auth.authz import get_course_groupname_for_role from auth.authz import get_course_groupname_for_role, get_user_by_email
from nose.tools import assert_true
PASSWORD = 'test' PASSWORD = 'test'
EMAIL_EXTENSION = '@edx.org' EMAIL_EXTENSION = '@edx.org'
...@@ -42,9 +43,9 @@ def add_other_user(_step, name): ...@@ -42,9 +43,9 @@ def add_other_user(_step, name):
world.wait(0.5) world.wait(0.5)
email_css = 'input#user-email-input' email_css = 'input#user-email-input'
f = world.css_find(email_css) world.css_fill(email_css, name + EMAIL_EXTENSION)
f._element.send_keys(name, EMAIL_EXTENSION) if world.is_firefox():
world.trigger_event(email_css)
confirm_css = 'form.create-user button.action-primary' confirm_css = 'form.create-user button.action-primary'
world.css_click(confirm_css) world.css_click(confirm_css)
...@@ -55,6 +56,8 @@ def delete_other_user(_step, name): ...@@ -55,6 +56,8 @@ def delete_other_user(_step, name):
email="{0}{1}".format(name, EMAIL_EXTENSION)) email="{0}{1}".format(name, EMAIL_EXTENSION))
world.css_click(to_delete_css) world.css_click(to_delete_css)
# confirm prompt # 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") world.css_click(".wrapper-prompt-warning .action-primary")
...@@ -64,6 +67,7 @@ def other_delete_self(_step): ...@@ -64,6 +67,7 @@ def other_delete_self(_step):
email="robot+studio@edx.org") email="robot+studio@edx.org")
world.css_click(to_delete_css) world.css_click(to_delete_css)
# confirm prompt # confirm prompt
world.wait(.5)
world.css_click(".wrapper-prompt-warning .action-primary") world.css_click(".wrapper-prompt-warning .action-primary")
...@@ -87,13 +91,27 @@ def remove_course_team_admin(_step, outer_capture, name): ...@@ -87,13 +91,27 @@ def remove_course_team_admin(_step, outer_capture, name):
@step(u'"([^"]*)" logs in$') @step(u'"([^"]*)" logs in$')
def other_user_login(_step, name): def other_user_login(_step, name):
log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION) world.browser.cookies.delete()
world.visit('/')
signin_css = 'a.action-signin'
world.is_css_present(signin_css)
world.css_click(signin_css)
def fill_login_form():
login_form = world.browser.find_by_css('form#login_form')
login_form.find_by_name('email').fill(name + EMAIL_EXTENSION)
login_form.find_by_name('password').fill(PASSWORD)
login_form.find_by_name('submit').click()
world.retry_on_exception(fill_login_form)
assert_true(world.is_css_present('.new-course-button'))
world.scenario_dict['USER'] = get_user_by_email(name + EMAIL_EXTENSION)
@step(u'I( do not)? see the course on my page') @step(u'I( do not)? see the course on my page')
@step(u's?he does( not)? see the course on (his|her) page') @step(u's?he does( not)? see the course on (his|her) page')
def see_course(_step, inverted, gender='self'): def see_course(_step, inverted, gender='self'):
class_css = 'span.class-name' class_css = 'h3.course-title'
all_courses = world.css_find(class_css, wait_time=1) all_courses = world.css_find(class_css, wait_time=1)
all_names = [item.html for item in all_courses] all_names = [item.html for item in all_courses]
if inverted: if inverted:
......
...@@ -9,7 +9,7 @@ from common import type_in_codemirror ...@@ -9,7 +9,7 @@ from common import type_in_codemirror
@step(u'I go to the course updates page') @step(u'I go to the course updates page')
def go_to_updates(_step): def go_to_updates(_step):
menu_css = 'li.nav-course-courseware' 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(menu_css)
world.css_click(updates_css) world.css_click(updates_css)
......
...@@ -30,7 +30,7 @@ def i_create_a_course(step): ...@@ -30,7 +30,7 @@ def i_create_a_course(step):
@step('I click the course link in My Courses$') @step('I click the course link in My Courses$')
def i_click_the_course_link_in_my_courses(step): 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) world.css_click(course_css)
############ ASSERTIONS ################### ############ ASSERTIONS ###################
...@@ -44,7 +44,7 @@ def courseware_page_has_loaded_in_studio(step): ...@@ -44,7 +44,7 @@ def courseware_page_has_loaded_in_studio(step):
@step('I see the course listed in My Courses$') @step('I see the course listed in My Courses$')
def i_see_the_course_in_my_courses(step): 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) assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name)
......
...@@ -47,12 +47,12 @@ Feature: Problem Editor ...@@ -47,12 +47,12 @@ Feature: Problem Editor
Scenario: User cannot type decimal values integer number field Scenario: User cannot type decimal values integer number field
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
When I edit and select Settings When I edit and select Settings
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234" 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 Scenario: User cannot type out of range values in an integer number field
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
When I edit and select Settings When I edit and select Settings
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0" Then if I set the max attempts to "-3", it will persist as a valid integer
Scenario: Settings changes are not saved on Cancel Scenario: Settings changes are not saved on Cancel
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
...@@ -66,6 +66,7 @@ Feature: Problem Editor ...@@ -66,6 +66,7 @@ Feature: Problem Editor
When I edit and select Settings When I edit and select Settings
Then Edit High Level Source is visible 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) Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
Given I have created a LaTeX Problem Given I have created a LaTeX Problem
When I edit and compile the High Level Source When I edit and compile the High Level Source
......
...@@ -45,7 +45,10 @@ def i_see_five_settings_with_values(step): ...@@ -45,7 +45,10 @@ def i_see_five_settings_with_values(step):
def i_can_modify_the_display_name(step): def i_can_modify_the_display_name(step):
# Verifying that the display name can be a string containing a floating point value # 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). # (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() verify_modified_display_name()
...@@ -57,7 +60,10 @@ def my_display_name_change_is_persisted_on_save(step): ...@@ -57,7 +60,10 @@ def my_display_name_change_is_persisted_on_save(step):
@step('I can specify special characters in the display name') @step('I can specify special characters in the display name')
def i_can_modify_the_display_name_with_special_chars(step): 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() verify_modified_display_name_with_special_chars()
...@@ -127,12 +133,16 @@ def set_the_weight_to_abc(step, bad_weight): ...@@ -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) 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 "(.*)"') @step('if I set the max attempts to "(.*)", it will persist as a valid integer$')
def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted): def set_the_max_attempts(step, max_attempts_set):
world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set) # on firefox with selenium, the behaviour is different. eg 2.34 displays as 2.34 and is persisted as 2
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True) 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.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') @step('Edit High Level Source is not visible')
...@@ -213,7 +223,11 @@ def verify_unset_display_name(): ...@@ -213,7 +223,11 @@ def verify_unset_display_name():
def set_weight(weight): 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(): def open_high_level_source():
......
...@@ -3,7 +3,6 @@ Feature: Create Section ...@@ -3,7 +3,6 @@ Feature: Create Section
As a course author As a course author
I want to create and edit sections I want to create and edit sections
@skip
Scenario: Add a new section to a course Scenario: Add a new section to a course
Given I have opened a new course in Studio Given I have opened a new course in Studio
When I click the New Section link When I click the New Section link
...@@ -24,7 +23,7 @@ Feature: Create Section ...@@ -24,7 +23,7 @@ Feature: Create Section
Given I have opened a new course in Studio Given I have opened a new course in Studio
And I have added a new section And I have added a new section
When I click the Edit link for the release date 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 Then the section release date is updated
And I see a "saving" notification And I see a "saving" notification
......
...@@ -35,10 +35,15 @@ def i_click_the_edit_link_for_the_release_date(_step): ...@@ -35,10 +35,15 @@ def i_click_the_edit_link_for_the_release_date(_step):
world.css_click(button_css) world.css_click(button_css)
@step('I save a new section release date$') @step('I set the section release date to ([0-9/-]+)( [0-9:]+)?')
def i_save_a_new_section_release_date(_step): def set_section_release_date(_step, datestring, timestring):
set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013', if hasattr(timestring, "strip"):
'input.start-time.time.ui-timepicker-input', '00:00') 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') world.browser.click_link_by_text('Save')
......
...@@ -8,7 +8,7 @@ from selenium.webdriver.common.keys import Keys ...@@ -8,7 +8,7 @@ from selenium.webdriver.common.keys import Keys
@step(u'I go to the static pages page') @step(u'I go to the static pages page')
def go_to_static(_step): def go_to_static(_step):
menu_css = 'li.nav-course-courseware' 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(menu_css)
world.css_click(static_css) world.css_click(static_css)
...@@ -38,14 +38,12 @@ def click_edit_delete(_step, edit_delete, page): ...@@ -38,14 +38,12 @@ def click_edit_delete(_step, edit_delete, page):
@step(u'I change the name to "([^"]*)"$') @step(u'I change the name to "([^"]*)"$')
def change_name(_step, new_name): def change_name(_step, new_name):
settings_css = '#settings-mode' settings_css = '#settings-mode a'
world.css_click(settings_css) world.css_click(settings_css)
input_css = 'input.setting-input' input_css = 'input.setting-input'
name_input = world.css_find(input_css) world.css_fill(input_css, new_name)
old_name = name_input.value if world.is_firefox():
for count in range(len(old_name)): world.trigger_event(input_css)
name_input._element.send_keys(Keys.END, Keys.BACK_SPACE)
name_input._element.send_keys(new_name)
save_button = 'a.save-button' save_button = 'a.save-button'
world.css_click(save_button) world.css_click(save_button)
......
...@@ -14,7 +14,7 @@ Feature: Create Subsection ...@@ -14,7 +14,7 @@ Feature: Create Subsection
When I click the New Subsection link When I click the New Subsection link
And I enter a subsection name with a quote and click save 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 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 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) Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
...@@ -27,10 +27,13 @@ Feature: Create Subsection ...@@ -27,10 +27,13 @@ Feature: Create Subsection
Scenario: Set a due date in a different year (bug #256) Scenario: Set a due date in a different year (bug #256)
Given I have opened a new subsection in Studio Given I have opened a new subsection in Studio
And I have set a release date and due date in different years And I set the subsection release date to 12/25/2011 03:00
Then I see the correct dates 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 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 Scenario: Delete a subsection
Given I have opened a new course section in Studio Given I have opened a new course section in Studio
...@@ -40,3 +43,16 @@ Feature: Create Subsection ...@@ -40,3 +43,16 @@ Feature: Create Subsection
And I press the "subsection" delete icon And I press the "subsection" delete icon
And I confirm the prompt And I confirm the prompt
Then the subsection does not exist 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
...@@ -41,8 +41,8 @@ def i_save_subsection_name_with_quote(step): ...@@ -41,8 +41,8 @@ def i_save_subsection_name_with_quote(step):
save_subsection_name('Subsection With "Quote"') save_subsection_name('Subsection With "Quote"')
@step('I click to edit the subsection name$') @step('I click on the subsection$')
def i_click_to_edit_subsection_name(step): def click_on_subsection(step):
world.css_click('span.subsection-name-value') world.css_click('span.subsection-name-value')
...@@ -53,12 +53,28 @@ def i_see_complete_subsection_name_with_quote_in_editor(step): ...@@ -53,12 +53,28 @@ def i_see_complete_subsection_name_with_quote_in_editor(step):
assert_equal(world.css_value(css), 'Subsection With "Quote"') assert_equal(world.css_value(css), 'Subsection With "Quote"')
@step('I have set a release date and due date in different years$') @step('I set the subsection release date to ([0-9/-]+)( [0-9:]+)?')
def test_have_set_dates_in_different_years(step): def set_subsection_release_date(_step, datestring, timestring):
set_date_and_time('input#start_date', '12/25/2011', 'input#start_time', '03:00') if hasattr(timestring, "strip"):
world.css_click('.set-date') timestring = timestring.strip()
# Use a year in the past so that current year will always be different. if not timestring:
set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00') 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$') @step('I mark it as Homework$')
...@@ -72,6 +88,11 @@ def i_see_it_marked__as_homework(step): ...@@ -72,6 +88,11 @@ def i_see_it_marked__as_homework(step):
assert_equal(world.css_value(".status-label"), 'Homework') 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 ################### ############ ASSERTIONS ###################
...@@ -91,16 +112,25 @@ def the_subsection_does_not_exist(step): ...@@ -91,16 +112,25 @@ def the_subsection_does_not_exist(step):
assert world.browser.is_element_not_present_by_css(css) assert world.browser.is_element_not_present_by_css(css)
@step('I see the correct dates$') @step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?')
def i_see_the_correct_dates(step): def i_see_subsection_release(_step, datestring, timestring):
assert_equal('12/25/2011', get_date('input#start_date')) if hasattr(timestring, "strip"):
assert_equal('03:00', get_date('input#start_time')) timestring = timestring.strip()
assert_equal('01/02/2012', get_date('input#due_date')) assert_equal(datestring, get_date('input#start_date'))
assert_equal('04:00', get_date('input#due_time')) if timestring:
assert_equal(timestring, get_date('input#start_time'))
############ HELPER METHODS ################### @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): def get_date(css):
return world.css_find(css).first.value.strip() return world.css_find(css).first.value.strip()
......
...@@ -11,8 +11,8 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT ...@@ -11,8 +11,8 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
@step(u'I go to the textbooks page') @step(u'I go to the textbooks page')
def go_to_uploads(_step): def go_to_uploads(_step):
world.click_course_content() world.click_course_content()
menu_css = 'li.nav-course-courseware-textbooks' menu_css = 'li.nav-course-courseware-textbooks a'
world.css_find(menu_css).click() world.css_click(menu_css)
@step(u'I should see a message telling me to create a new textbook') @step(u'I should see a message telling me to create a new textbook')
...@@ -45,6 +45,8 @@ def click_new_textbook(_step, on): ...@@ -45,6 +45,8 @@ def click_new_textbook(_step, on):
def name_textbook(_step, name): def name_textbook(_step, name):
input_css = ".textbook input[name=textbook-name]" input_css = ".textbook input[name=textbook-name]"
world.css_fill(input_css, 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 "([^"]*)"') @step(u'I name the (first|second|third) chapter "([^"]*)"')
...@@ -52,6 +54,8 @@ def name_chapter(_step, ordinal, name): ...@@ -52,6 +54,8 @@ def name_chapter(_step, ordinal, name):
index = ["first", "second", "third"].index(ordinal) index = ["first", "second", "third"].index(ordinal)
input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1) input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1)
world.css_fill(input_css, name) 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') @step(u'I type in "([^"]*)" for the (first|second|third) chapter asset')
...@@ -59,6 +63,8 @@ def asset_chapter(_step, name, ordinal): ...@@ -59,6 +63,8 @@ def asset_chapter(_step, name, ordinal):
index = ["first", "second", "third"].index(ordinal) index = ["first", "second", "third"].index(ordinal)
input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1) input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1)
world.css_fill(input_css, name) 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') @step(u'I click the Upload Asset link for the (first|second|third) chapter')
......
...@@ -9,13 +9,11 @@ import random ...@@ -9,13 +9,11 @@ import random
import os import os
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT 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') @step(u'I go to the files and uploads page')
def go_to_uploads(_step): def go_to_uploads(_step):
menu_css = 'li.nav-course-courseware' 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(menu_css)
world.css_click(uploads_css) world.css_click(uploads_css)
...@@ -24,13 +22,10 @@ def go_to_uploads(_step): ...@@ -24,13 +22,10 @@ def go_to_uploads(_step):
def upload_file(_step, file_name): def upload_file(_step, file_name):
upload_css = 'a.upload-button' upload_css = 'a.upload-button'
world.css_click(upload_css) world.css_click(upload_css)
file_css = 'input.file-input'
upload = world.css_find(file_css)
#uploading the file itself #uploading the file itself
path = os.path.join(TEST_ROOT, 'uploads/', file_name) 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' close_css = 'a.close-button'
world.css_click(close_css) world.css_click(close_css)
...@@ -80,6 +75,9 @@ def check_download(_step, file_name): ...@@ -80,6 +75,9 @@ def check_download(_step, file_name):
r = get_file(file_name) r = get_file(file_name)
downloaded_text = r.text downloaded_text = r.text
assert cur_text == downloaded_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 "([^"]*)"$') @step(u'I modify "([^"]*)"$')
...@@ -109,6 +107,8 @@ def get_file(file_name): ...@@ -109,6 +107,8 @@ def get_file(file_name):
index = get_index(file_name) index = get_index(file_name)
assert index != -1 assert index != -1
url_css = 'input.embeddable-xml-input' url_css = 'a.filename'
url = world.css_find(url_css)[index].value def get_url():
return requests.get(HTTP_PREFIX + url) return world.css_find(url_css)[index]._element.get_attribute('href')
url = world.retry_on_exception(get_url)
return requests.get(url)
...@@ -22,3 +22,19 @@ def set_show_captions(step, setting): ...@@ -22,3 +22,19 @@ def set_show_captions(step, setting):
world.wait_for(lambda _driver: world.css_visible('a.save-button')) world.wait_for(lambda _driver: world.css_visible('a.save-button'))
world.browser.select('Show Captions', setting) world.browser.select('Show Captions', setting)
world.css_click('a.save-button') 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]])
...@@ -22,3 +22,33 @@ Feature: Video Component ...@@ -22,3 +22,33 @@ Feature: Video Component
Given I have created a Video component Given I have created a Video component
And I have toggled captions And I have toggled captions
Then when I view the video it does show the 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
#pylint: disable=C0111 #pylint: disable=C0111
from lettuce import world, step from lettuce import world, step
from terrain.steps import reload_the_page
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
############### ACTIONS #################### ############### ACTIONS ####################
@step('when I view the video it does not have autoplay enabled') @step('when I view the (.*) it does not have autoplay enabled')
def does_not_autoplay(_step): def does_not_autoplay(_step, video_type):
assert world.css_find('.video')[0]['data-autoplay'] == 'False' assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
assert world.css_has_class('.video_control', 'play') assert world.css_has_class('.video_control', 'play')
...@@ -29,5 +33,55 @@ def hide_or_show_captions(step, shown): ...@@ -29,5 +33,55 @@ def hide_or_show_captions(step, shown):
# click the button rather than the tooltip, so move the mouse # click the button rather than the tooltip, so move the mouse
# away to make it disappear. # away to make it disappear.
button = world.css_find(button_css) 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) 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='<video youtube="1.00:%s"></video>' % 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']
...@@ -12,7 +12,14 @@ from auth.authz import _copy_course_group ...@@ -12,7 +12,14 @@ from auth.authz import _copy_course_group
# #
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3 # 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): class Command(BaseCommand):
"""Clone a MongoDB-backed course to another location""" """Clone a MongoDB-backed course to another location"""
...@@ -21,19 +28,27 @@ class Command(BaseCommand): ...@@ -21,19 +28,27 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
"Execute the command" "Execute the command"
if len(args) != 2: if len(args) != 2:
raise CommandError("clone requires two arguments: <source-location> <dest-location>") raise CommandError("clone requires two arguments: <source-course_id> <dest-course_id>")
source_location_str = args[0] source_course_id = args[0]
dest_location_str = args[1] dest_course_id = args[1]
mstore = modulestore('direct') mstore = modulestore('direct')
cstore = contentstore() cstore = contentstore()
print("Cloning course {0} to {1}".format(source_location_str, dest_location_str)) mstore.metadata_inheritance_cache_subsystem = CACHE
mstore.request_cache = RequestCache.get_request_cache()
org, course_num, run = dest_course_id.split("/")
mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
source_location = CourseDescriptor.id_to_location(source_location_str) print("Cloning course {0} to {1}".format(source_course_id, dest_course_id))
dest_location = CourseDescriptor.id_to_location(dest_location_str)
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): 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...") print("copying User permissions...")
_copy_course_group(source_location, dest_location) _copy_course_group(source_location, dest_location)
...@@ -9,12 +9,14 @@ from xmodule.course_module import CourseDescriptor ...@@ -9,12 +9,14 @@ from xmodule.course_module import CourseDescriptor
from .prompt import query_yes_no from .prompt import query_yes_no
from auth.authz import _delete_course_group 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 # To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
# #
CACHE = get_cache('mongo_metadata_inheritance')
class Command(BaseCommand): class Command(BaseCommand):
help = '''Delete a MongoDB backed course''' help = '''Delete a MongoDB backed course'''
...@@ -22,7 +24,7 @@ class Command(BaseCommand): ...@@ -22,7 +24,7 @@ class Command(BaseCommand):
if len(args) != 1 and len(args) != 2: if len(args) != 1 and len(args) != 2:
raise CommandError("delete_course requires one or more arguments: <location> |commit|") raise CommandError("delete_course requires one or more arguments: <location> |commit|")
loc_str = args[0] course_id = args[0]
commit = False commit = False
if len(args) == 2: if len(args) == 2:
...@@ -34,9 +36,14 @@ class Command(BaseCommand): ...@@ -34,9 +36,14 @@ class Command(BaseCommand):
ms = modulestore('direct') ms = modulestore('direct')
cs = contentstore() 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"): 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): if delete_course(ms, cs, loc, commit):
print 'removing User permissions from course....' print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course # in the django layer, we need to remove all the user permissions groups associated with this course
......
...@@ -10,6 +10,8 @@ from unittest import TestCase, skip ...@@ -10,6 +10,8 @@ from unittest import TestCase, skip
from .utils import CourseTestCase from .utils import CourseTestCase
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from contentstore.views import assets from contentstore.views import assets
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore import Location
class AssetsTestCase(CourseTestCase): class AssetsTestCase(CourseTestCase):
...@@ -35,6 +37,11 @@ class AssetsTestCase(CourseTestCase): ...@@ -35,6 +37,11 @@ class AssetsTestCase(CourseTestCase):
content = json.loads(resp.content) content = json.loads(resp.content)
self.assertIsInstance(content, list) 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): class UploadTestCase(CourseTestCase):
""" """
......
"""
Tests for contentstore/views/user.py.
"""
import json import json
from .utils import CourseTestCase from .utils import CourseTestCase
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from auth.authz import get_course_groupname_for_role from auth.authz import get_course_groupname_for_role
from student.views import is_enrolled_in_course
class UsersTestCase(CourseTestCase): class UsersTestCase(CourseTestCase):
...@@ -90,6 +94,7 @@ class UsersTestCase(CourseTestCase): ...@@ -90,6 +94,7 @@ class UsersTestCase(CourseTestCase):
# no content: should not be in any roles # no content: should not be in any roles
self.assertNotIn(self.staff_groupname, groups) self.assertNotIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups) self.assertNotIn(self.inst_groupname, groups)
self.assert_not_enrolled()
def test_detail_post_staff(self): def test_detail_post_staff(self):
resp = self.client.post( resp = self.client.post(
...@@ -104,6 +109,7 @@ class UsersTestCase(CourseTestCase): ...@@ -104,6 +109,7 @@ class UsersTestCase(CourseTestCase):
groups = [g.name for g in ext_user.groups.all()] groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups) self.assertIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups) self.assertNotIn(self.inst_groupname, groups)
self.assert_enrolled()
def test_detail_post_staff_other_inst(self): def test_detail_post_staff_other_inst(self):
inst_group, _ = Group.objects.get_or_create(name=self.inst_groupname) inst_group, _ = Group.objects.get_or_create(name=self.inst_groupname)
...@@ -122,6 +128,7 @@ class UsersTestCase(CourseTestCase): ...@@ -122,6 +128,7 @@ class UsersTestCase(CourseTestCase):
groups = [g.name for g in ext_user.groups.all()] groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups) self.assertIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups) self.assertNotIn(self.inst_groupname, groups)
self.assert_enrolled()
# check that other user is unchanged # check that other user is unchanged
user = User.objects.get(email=self.user.email) user = User.objects.get(email=self.user.email)
groups = [g.name for g in user.groups.all()] groups = [g.name for g in user.groups.all()]
...@@ -141,6 +148,7 @@ class UsersTestCase(CourseTestCase): ...@@ -141,6 +148,7 @@ class UsersTestCase(CourseTestCase):
groups = [g.name for g in ext_user.groups.all()] groups = [g.name for g in ext_user.groups.all()]
self.assertNotIn(self.staff_groupname, groups) self.assertNotIn(self.staff_groupname, groups)
self.assertIn(self.inst_groupname, groups) self.assertIn(self.inst_groupname, groups)
self.assert_enrolled()
def test_detail_post_missing_role(self): def test_detail_post_missing_role(self):
resp = self.client.post( resp = self.client.post(
...@@ -152,6 +160,7 @@ class UsersTestCase(CourseTestCase): ...@@ -152,6 +160,7 @@ class UsersTestCase(CourseTestCase):
self.assert4XX(resp.status_code) self.assert4XX(resp.status_code)
result = json.loads(resp.content) result = json.loads(resp.content)
self.assertIn("error", result) self.assertIn("error", result)
self.assert_not_enrolled()
def test_detail_post_bad_json(self): def test_detail_post_bad_json(self):
resp = self.client.post( resp = self.client.post(
...@@ -163,6 +172,7 @@ class UsersTestCase(CourseTestCase): ...@@ -163,6 +172,7 @@ class UsersTestCase(CourseTestCase):
self.assert4XX(resp.status_code) self.assert4XX(resp.status_code)
result = json.loads(resp.content) result = json.loads(resp.content)
self.assertIn("error", result) self.assertIn("error", result)
self.assert_not_enrolled()
def test_detail_post_no_json(self): def test_detail_post_no_json(self):
resp = self.client.post( resp = self.client.post(
...@@ -176,6 +186,7 @@ class UsersTestCase(CourseTestCase): ...@@ -176,6 +186,7 @@ class UsersTestCase(CourseTestCase):
groups = [g.name for g in ext_user.groups.all()] groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups) self.assertIn(self.staff_groupname, groups)
self.assertNotIn(self.inst_groupname, groups) self.assertNotIn(self.inst_groupname, groups)
self.assert_enrolled()
def test_detail_delete_staff(self): def test_detail_delete_staff(self):
group, _ = Group.objects.get_or_create(name=self.staff_groupname) group, _ = Group.objects.get_or_create(name=self.staff_groupname)
...@@ -317,3 +328,57 @@ class UsersTestCase(CourseTestCase): ...@@ -317,3 +328,57 @@ class UsersTestCase(CourseTestCase):
ext_user = User.objects.get(email=self.ext_user.email) ext_user = User.objects.get(email=self.ext_user.email)
groups = [g.name for g in ext_user.groups.all()] groups = [g.name for g in ext_user.groups.all()]
self.assertIn(self.staff_groupname, groups) 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'
)
from django.test.client import Client from django.test.client import Client
from django.core.cache import cache
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from .utils import parse_json, user, registration from .utils import parse_json, user, registration
...@@ -79,6 +80,8 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -79,6 +80,8 @@ class AuthTestCase(ContentStoreTestCase):
self.pw = 'xyz' self.pw = 'xyz'
self.username = 'testuser' self.username = 'testuser'
self.client = Client() self.client = Client()
# clear the cache so ratelimiting won't affect these tests
cache.clear()
def check_page_get(self, url, expected): def check_page_get(self, url, expected):
resp = self.client.get(url) resp = self.client.get(url)
...@@ -119,6 +122,18 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -119,6 +122,18 @@ class AuthTestCase(ContentStoreTestCase):
# Now login should work # Now login should work
self.login(self.email, self.pw) 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): def test_login_link_on_activation_age(self):
self.create_account(self.username, self.email, self.pw) 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 # we want to test the rendering of the activation page when the user isn't logged in
......
...@@ -3,6 +3,7 @@ import json ...@@ -3,6 +3,7 @@ import json
import os import os
import tarfile import tarfile
import shutil import shutil
import cgi
from tempfile import mkdtemp from tempfile import mkdtemp
from path import path from path import path
...@@ -27,7 +28,7 @@ from xmodule.modulestore import Location ...@@ -27,7 +28,7 @@ from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.util.date_utils import get_default_time_display from xmodule.util.date_utils import get_default_time_display
from xmodule.modulestore import InvalidLocationError from xmodule.modulestore import InvalidLocationError
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError, SerializationError
from .access import get_location_and_verify_access from .access import get_location_and_verify_access
from util.json_request import JsonResponse from util.json_request import JsonResponse
...@@ -105,6 +106,7 @@ def asset_index(request, org, course, name): ...@@ -105,6 +106,7 @@ def asset_index(request, org, course, name):
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name']) 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['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 # note, due to the schema change we may not have a 'thumbnail_location' in the result set
_thumbnail_location = asset.get('thumbnail_location', None) _thumbnail_location = asset.get('thumbnail_location', None)
...@@ -187,12 +189,12 @@ def upload_asset(request, org, course, coursename): ...@@ -187,12 +189,12 @@ def upload_asset(request, org, course, coursename):
response_payload = {'displayname': content.name, response_payload = {'displayname': content.name,
'uploadDate': get_default_time_display(readback.last_modified_at), 'uploadDate': get_default_time_display(readback.last_modified_at),
'url': StaticContent.get_url_path_from_location(content.location), '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, 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
'msg': 'Upload completed' 'msg': 'Upload completed'
} }
response = JsonResponse(response_payload) response = JsonResponse(response_payload)
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
return response return response
...@@ -313,6 +315,8 @@ def import_course(request, org, course, name): ...@@ -313,6 +315,8 @@ def import_course(request, org, course, name):
create_all_course_groups(request.user, course_items[0].location) create_all_course_groups(request.user, course_items[0].location)
logging.debug('created all course groups at {0}'.format(course_items[0].location))
return HttpResponse(json.dumps({'Status': 'OK'})) return HttpResponse(json.dumps({'Status': 'OK'}))
else: else:
course_module = modulestore().get_item(location) course_module = modulestore().get_item(location)
...@@ -335,16 +339,59 @@ def generate_export_course(request, org, course, name): ...@@ -335,16 +339,59 @@ def generate_export_course(request, org, course, name):
the course the course
""" """
location = get_location_and_verify_access(request, org, course, name) location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_instance(location.course_id, location)
loc = Location(location) loc = Location(location)
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
root_dir = path(mkdtemp()) root_dir = path(mkdtemp())
# export out to a tempdir try:
logging.debug('root = {0}'.format(root_dir)) export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
except SerializationError, e:
unit = None
failed_item = None
parent = None
try:
failed_item = modulestore().get_instance(course_module.location.course_id, e.location)
parent_locs = modulestore().get_parent_locations(failed_item.location, course_module.location.course_id)
if len(parent_locs) > 0:
parent = modulestore().get_item(parent_locs[0])
if parent.location.category == 'vertical':
unit = parent
except:
# if we have a nested exception, then we'll show the more generic error message
pass
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore()) return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': '',
'in_err': True,
'raw_err_msg': str(e),
'failed_module': failed_item,
'unit': unit,
'edit_unit_url': reverse('edit_unit', kwargs={
'location': parent.location
}) if parent else '',
'course_home_url': reverse('course_index', kwargs={
'org': org,
'course': course,
'name': name
})
})
except Exception, e:
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': '',
'in_err': True,
'unit': None,
'raw_err_msg': str(e),
'course_home_url': reverse('course_index', kwargs={
'org': org,
'course': course,
'name': name
})
})
logging.debug('tar file being generated at {0}'.format(export_file.name)) logging.debug('tar file being generated at {0}'.format(export_file.name))
tar_file = tarfile.open(name=export_file.name, mode='w:gz') tar_file = tarfile.open(name=export_file.name, mode='w:gz')
......
...@@ -44,6 +44,8 @@ from .component import ( ...@@ -44,6 +44,8 @@ from .component import (
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
from student.views import enroll_in_course
from xmodule.html_module import AboutDescriptor from xmodule.html_module import AboutDescriptor
__all__ = ['course_index', 'create_new_course', 'course_info', __all__ = ['course_index', 'create_new_course', 'course_info',
'course_info_updates', 'get_course_settings', 'course_info_updates', 'get_course_settings',
...@@ -162,6 +164,9 @@ def create_new_course(request): ...@@ -162,6 +164,9 @@ def create_new_course(request):
# seed the forums # seed the forums
seed_permissions_roles(new_course.location.course_id) 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()}) return JsonResponse({'id': new_course.location.url()})
......
...@@ -13,6 +13,7 @@ from django.core.context_processors import csrf ...@@ -13,6 +13,7 @@ from django.core.context_processors import csrf
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.error_module import ErrorDescriptor
from contentstore.utils import get_lms_link_for_item from contentstore.utils import get_lms_link_for_item
from util.json_request import JsonResponse from util.json_request import JsonResponse
from auth.authz import ( from auth.authz import (
...@@ -23,6 +24,8 @@ from course_creators.views import ( ...@@ -23,6 +24,8 @@ from course_creators.views import (
from .access import has_access from .access import has_access
from student.views import enroll_in_course
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -54,10 +57,13 @@ def index(request): ...@@ -54,10 +57,13 @@ def index(request):
course.location, course.location,
course_id=course.location.course_id, 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', { return render_to_response('index.html', {
'courses': [format_course_for_view(c) for c in courses], 'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)],
'user': request.user, 'user': request.user,
'request_course_creator_url': reverse('request_course_creator'), 'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user), 'course_creator_status': _get_course_creator_status(request.user),
...@@ -179,7 +185,7 @@ def course_team_user(request, org, course, name, email): ...@@ -179,7 +185,7 @@ def course_team_user(request, org, course, name, email):
return JsonResponse() return JsonResponse()
# all other operations require the requesting user to specify a role # all other operations require the requesting user to specify a role
if request.META.get("CONTENT_TYPE", "") == "application/json" and request.body: if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body:
try: try:
payload = json.loads(request.body) payload = json.loads(request.body)
except: except:
...@@ -201,6 +207,8 @@ def course_team_user(request, org, course, name, email): ...@@ -201,6 +207,8 @@ def course_team_user(request, org, course, name, email):
return JsonResponse(msg, 400) return JsonResponse(msg, 400)
user.groups.add(groups["instructor"]) user.groups.add(groups["instructor"])
user.save() 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": elif role == "staff":
# if we're trying to downgrade a user from "instructor" to "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. # make sure we have at least one other instructor in the course team.
...@@ -214,6 +222,9 @@ def course_team_user(request, org, course, name, email): ...@@ -214,6 +222,9 @@ def course_team_user(request, org, course, name, email):
user.groups.remove(groups["instructor"]) user.groups.remove(groups["instructor"])
user.groups.add(groups["staff"]) user.groups.add(groups["staff"])
user.save() 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() return JsonResponse()
......
...@@ -2,13 +2,15 @@ ...@@ -2,13 +2,15 @@
django admin page for the course creators table 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 course_creators.views import update_course_creator_group
from django.contrib import admin from ratelimitbackend import admin
from django.conf import settings from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from mitxmako.shortcuts import render_to_string from mitxmako.shortcuts import render_to_string
from django.core.mail import send_mail
from smtplib import SMTPException
import logging import logging
...@@ -28,12 +30,12 @@ class CourseCreatorAdmin(admin.ModelAdmin): ...@@ -28,12 +30,12 @@ class CourseCreatorAdmin(admin.ModelAdmin):
""" """
# Fields to display on the overview page. # Fields to display on the overview page.
list_display = ['user', get_email, 'state', 'state_changed', 'note'] list_display = ['username', get_email, 'state', 'state_changed', 'note']
readonly_fields = ['user', 'state_changed'] readonly_fields = ['username', 'state_changed']
# Controls the order on the edit form (without this, read-only fields appear at the end). # Controls the order on the edit form (without this, read-only fields appear at the end).
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ['user', 'state', 'state_changed', 'note'] 'fields': ['username', 'state', 'state_changed', 'note']
}), }),
) )
# Fields that filtering support # Fields that filtering support
...@@ -43,6 +45,16 @@ class CourseCreatorAdmin(admin.ModelAdmin): ...@@ -43,6 +45,16 @@ class CourseCreatorAdmin(admin.ModelAdmin):
# Turn off the action bar (we have no bulk actions) # Turn off the action bar (we have no bulk actions)
actions = None 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): def has_add_permission(self, request):
return False return False
...@@ -70,7 +82,16 @@ def update_creator_group_callback(sender, **kwargs): ...@@ -70,7 +82,16 @@ def update_creator_group_callback(sender, **kwargs):
updated_state = kwargs['state'] updated_state = kwargs['state']
update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED) update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED)
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL','')
@receiver(send_user_notification, sender=CourseCreator)
def send_user_notification_callback(sender, **kwargs):
"""
Callback for notifying user about course creator status change.
"""
user = kwargs['user']
updated_state = kwargs['state']
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '')
context = {'studio_request_email': studio_request_email} context = {'studio_request_email': studio_request_email}
subject = render_to_string('emails/course_creator_subject.txt', context) subject = render_to_string('emails/course_creator_subject.txt', context)
...@@ -88,3 +109,29 @@ def update_creator_group_callback(sender, **kwargs): ...@@ -88,3 +109,29 @@ def update_creator_group_callback(sender, **kwargs):
user.email_user(subject, message, studio_request_email) user.email_user(subject, message, studio_request_email)
except: except:
log.warning("Unable to send course creator status e-mail to %s", user.email) 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)
...@@ -10,7 +10,13 @@ from django.utils import timezone ...@@ -10,7 +10,13 @@ from django.utils import timezone
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
# A signal that will be sent when users should be added or removed from the creator group # 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): class CourseCreator(models.Model):
...@@ -60,9 +66,10 @@ def post_save_callback(sender, **kwargs): ...@@ -60,9 +66,10 @@ def post_save_callback(sender, **kwargs):
# We only wish to modify the state_changed time if the state has been modified. We don't wish to # 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. # modify it for changes to the notes field.
if instance.state != instance.orig_state: if instance.state != instance.orig_state:
granted_state_change = instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED
# If either old or new state is 'granted', we must manipulate the course creator # If either old or new state is 'granted', we must manipulate the course creator
# group maintained by authz. That requires staff permissions (stored admin). # group maintained by authz. That requires staff permissions (stored admin).
if instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED: if granted_state_change:
assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group' assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group'
update_creator_state.send( update_creator_state.send(
sender=sender, sender=sender,
...@@ -71,6 +78,22 @@ def post_save_callback(sender, **kwargs): ...@@ -71,6 +78,22 @@ def post_save_callback(sender, **kwargs):
state=instance.state 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.state_changed = timezone.now()
instance.orig_state = instance.state instance.orig_state = instance.state
instance.save() instance.save()
...@@ -11,6 +11,7 @@ import mock ...@@ -11,6 +11,7 @@ import mock
from course_creators.admin import CourseCreatorAdmin from course_creators.admin import CourseCreatorAdmin
from course_creators.models import CourseCreator from course_creators.models import CourseCreator
from auth.authz import is_user_in_creator_group from auth.authz import is_user_in_creator_group
from django.core import mail
def mock_render_to_string(template_name, context): def mock_render_to_string(template_name, context):
...@@ -37,21 +38,25 @@ class CourseCreatorAdminTest(TestCase): ...@@ -37,21 +38,25 @@ class CourseCreatorAdminTest(TestCase):
self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite()) self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite())
self.studio_request_email = 'mark@marky.mark'
self.enable_creator_group_patch = {
"ENABLE_CREATOR_GROUP": True,
"STUDIO_REQUEST_EMAIL": self.studio_request_email
}
@mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True)) @mock.patch('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') @mock.patch('django.contrib.auth.models.User.email_user')
def test_change_status(self, email_user): def test_change_status(self, email_user):
""" """
Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent. Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent.
""" """
STUDIO_REQUEST_EMAIL = 'mark@marky.mark'
def change_state_and_verify_email(state, is_creator):
def change_state(state, is_creator): """ Changes user state, verifies creator status, and verifies e-mail is sent based on transition """
""" Helper method for changing state """ self._change_state(state)
self.table_entry.state = state
self.creator_admin.save_model(self.request, self.table_entry, None, True)
self.assertEqual(is_creator, is_user_in_creator_group(self.user)) self.assertEqual(is_creator, is_user_in_creator_group(self.user))
context = {'studio_request_email': STUDIO_REQUEST_EMAIL} context = {'studio_request_email': self.studio_request_email}
if state == CourseCreator.GRANTED: if state == CourseCreator.GRANTED:
template = 'emails/course_creator_granted.txt' template = 'emails/course_creator_granted.txt'
elif state == CourseCreator.DENIED: elif state == CourseCreator.DENIED:
...@@ -61,30 +66,76 @@ class CourseCreatorAdminTest(TestCase): ...@@ -61,30 +66,76 @@ class CourseCreatorAdminTest(TestCase):
email_user.assert_called_with( email_user.assert_called_with(
mock_render_to_string('emails/course_creator_subject.txt', context), mock_render_to_string('emails/course_creator_subject.txt', context),
mock_render_to_string(template, context), mock_render_to_string(template, context),
STUDIO_REQUEST_EMAIL self.studio_request_email
) )
with mock.patch.dict( with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch):
'django.conf.settings.MITX_FEATURES',
{
"ENABLE_CREATOR_GROUP": True,
"STUDIO_REQUEST_EMAIL": STUDIO_REQUEST_EMAIL
}):
# User is initially unrequested. # User is initially unrequested.
self.assertFalse(is_user_in_creator_group(self.user)) 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): def test_add_permission(self):
""" """
...@@ -106,3 +157,18 @@ class CourseCreatorAdminTest(TestCase): ...@@ -106,3 +157,18 @@ class CourseCreatorAdminTest(TestCase):
self.request.user = self.user self.request.user = self.user
self.assertFalse(self.creator_admin.has_change_permission(self.request)) 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)
...@@ -173,7 +173,7 @@ class CourseDetails(object): ...@@ -173,7 +173,7 @@ class CourseDetails(object):
# the right thing # the right thing
result = None result = None
if video_key: if video_key:
result = '<iframe width="560" height="315" src="http://www.youtube.com/embed/' + \ result = '<iframe width="560" height="315" src="//www.youtube.com/embed/' + \
video_key + '?autoplay=1&rel=0" frameborder="0" allowfullscreen=""></iframe>' video_key + '?autoplay=1&rel=0" frameborder="0" allowfullscreen=""></iframe>'
return result return result
......
...@@ -72,6 +72,9 @@ DATABASES = { ...@@ -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 # Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',) INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',) LETTUCE_APPS = ('contentstore',)
......
...@@ -108,6 +108,11 @@ TEMPLATE_CONTEXT_PROCESSORS = ( ...@@ -108,6 +108,11 @@ TEMPLATE_CONTEXT_PROCESSORS = (
'django.core.context_processors.csrf' 'django.core.context_processors.csrf'
) )
# use the ratelimit backend to prevent brute force attacks
AUTHENTICATION_BACKENDS = (
'ratelimitbackend.backends.RateLimitModelBackend',
)
LMS_BASE = None LMS_BASE = None
#################### CAPA External Code Evaluation ############################# #################### CAPA External Code Evaluation #############################
...@@ -152,7 +157,10 @@ MIDDLEWARE_CLASSES = ( ...@@ -152,7 +157,10 @@ MIDDLEWARE_CLASSES = (
# Detects user-requested locale from 'accept-language' header in http request # Detects user-requested locale from 'accept-language' header in http request
'django.middleware.locale.LocaleMiddleware', '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 ################################ ############################ SIGNAL HANDLERS ################################
...@@ -188,8 +196,8 @@ STATICFILES_DIRS = [ ...@@ -188,8 +196,8 @@ STATICFILES_DIRS = [
COMMON_ROOT / "static", COMMON_ROOT / "static",
PROJECT_ROOT / "static", PROJECT_ROOT / "static",
# This is how you would use the textbook images locally # This is how you would use the textbook images locally
# ("book", ENV_ROOT / "book_images") # ("book", ENV_ROOT / "book_images")
] ]
# Locale/Internationalization # Locale/Internationalization
......
...@@ -150,14 +150,13 @@ DEBUG_TOOLBAR_PANELS = ( ...@@ -150,14 +150,13 @@ DEBUG_TOOLBAR_PANELS = (
'debug_toolbar.panels.sql.SQLDebugPanel', 'debug_toolbar.panels.sql.SQLDebugPanel',
'debug_toolbar.panels.signals.SignalDebugPanel', 'debug_toolbar.panels.signals.SignalDebugPanel',
'debug_toolbar.panels.logger.LoggingPanel', 'debug_toolbar.panels.logger.LoggingPanel',
'debug_toolbar_mongo.panel.MongoDebugPanel',
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets # Django=1.3.1/1.4 where requests to views get duplicated (your method gets
# hit twice). So you can uncomment when you need to diagnose performance # hit twice). So you can uncomment when you need to diagnose performance
# problems, but you shouldn't leave it on. # problems, but you shouldn't leave it on.
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel', # 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
) )
DEBUG_TOOLBAR_CONFIG = { DEBUG_TOOLBAR_CONFIG = {
'INTERCEPT_REDIRECTS': False 'INTERCEPT_REDIRECTS': False
...@@ -165,7 +164,7 @@ DEBUG_TOOLBAR_CONFIG = { ...@@ -165,7 +164,7 @@ DEBUG_TOOLBAR_CONFIG = {
# To see stacktraces for MongoDB queries, set this to True. # To see stacktraces for MongoDB queries, set this to True.
# Stacktraces slow down page loads drastically (for pages with lots of queries). # Stacktraces slow down page loads drastically (for pages with lots of queries).
DEBUG_TOOLBAR_MONGO_STACKTRACES = True DEBUG_TOOLBAR_MONGO_STACKTRACES = False
# disable NPS survey in dev mode # disable NPS survey in dev mode
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
......
"""
This configuration is to turn on the Django Toolbar stats for DB access stats, for performance analysis
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .dev import *
DEBUG_TOOLBAR_PANELS = (
'debug_toolbar.panels.version.VersionDebugPanel',
'debug_toolbar.panels.timer.TimerDebugPanel',
'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
'debug_toolbar.panels.headers.HeaderDebugPanel',
'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
'debug_toolbar.panels.sql.SQLDebugPanel',
'debug_toolbar.panels.signals.SignalDebugPanel',
'debug_toolbar.panels.logger.LoggingPanel',
'debug_toolbar_mongo.panel.MongoDebugPanel'
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
# hit twice). So you can uncomment when you need to diagnose performance
# problems, but you shouldn't leave it on.
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
)
# To see stacktraces for MongoDB queries, set this to True.
# Stacktraces slow down page loads drastically (for pages with lots of queries).
DEBUG_TOOLBAR_MONGO_STACKTRACES = True
...@@ -15,6 +15,7 @@ sessions. Assumes structure: ...@@ -15,6 +15,7 @@ sessions. Assumes structure:
from .common import * from .common import *
import os import os
from path import path from path import path
from warnings import filterwarnings
# Nose Test Runner # Nose Test Runner
INSTALLED_APPS += ('django_nose',) INSTALLED_APPS += ('django_nose',)
...@@ -124,6 +125,9 @@ CACHES = { ...@@ -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 ######################################
CELERY_ALWAYS_EAGER = True CELERY_ALWAYS_EAGER = True
......
../../../templates/js/metadata-list-entry.underscore
\ No newline at end of file
<div class="base_wrapper">
<section class="editor-with-tabs">
<div class="wrapper-comp-editor" id="editor-tab-id" data-html_id='test_id'>
<div class="edit-header">
<ul class="editor-tabs">
<li class="inner_tab_wrap"><a href="#tab-0" class="tab">Tab 0 Editor</a></li>
<li class="inner_tab_wrap"><a href="#tab-1" class="tab">Tab 1 Transcripts</a></li>
<li class="inner_tab_wrap" id="settings"><a href="#tab-2" class="tab">Tab 2 Settings</a></li>
</ul>
</div>
<div class="tabs-wrapper">
<div class="component-tab" id="tab-0">
<textarea name="" class="edit-box">XML Editor Text</textarea>
</div>
<div class="component-tab" id="tab-1">
Transcripts
</div>
<div class="component-tab" id="tab-2">
Subtitles
</div>
</div>
<div class="wrapper-comp-settings">
<ul>
<li id="editor-mode"><a>Editor</a></li>
<li id="settings-mode"><a>Settings</a></li>
</ul>
</div>
</div>
</section>
<div class="component-edit-header" style="display: block"/>
</div>
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)
...@@ -3,12 +3,14 @@ describe "Test Metadata Editor", -> ...@@ -3,12 +3,14 @@ describe "Test Metadata Editor", ->
numberEntryTemplate = readFixtures('metadata-number-entry.underscore') numberEntryTemplate = readFixtures('metadata-number-entry.underscore')
stringEntryTemplate = readFixtures('metadata-string-entry.underscore') stringEntryTemplate = readFixtures('metadata-string-entry.underscore')
optionEntryTemplate = readFixtures('metadata-option-entry.underscore') optionEntryTemplate = readFixtures('metadata-option-entry.underscore')
listEntryTemplate = readFixtures('metadata-list-entry.underscore')
beforeEach -> beforeEach ->
setFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate)) setFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate))
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate)) appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate))
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate)) appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate))
appendSetFixtures($("<script>", {id: "metadata-option-entry", type: "text/template"}).text(optionEntryTemplate)) appendSetFixtures($("<script>", {id: "metadata-option-entry", type: "text/template"}).text(optionEntryTemplate))
appendSetFixtures($("<script>", {id: "metadata-list-entry", type: "text/template"}).text(listEntryTemplate))
genericEntry = { genericEntry = {
default_value: 'default value', default_value: 'default value',
...@@ -62,6 +64,18 @@ describe "Test Metadata Editor", -> ...@@ -62,6 +64,18 @@ describe "Test Metadata Editor", ->
value: 10.2 value: 10.2
} }
listEntry = {
default_value: ["a thing", "another thing"],
display_name: "List",
explicitly_set: false,
field_name: "list",
help: "A list of things.",
inheritable: false,
options: [],
type: CMS.Models.Metadata.LIST_TYPE,
value: ["the first display value", "the second"]
}
# Test for the editor that creates the individual views. # Test for the editor that creates the individual views.
describe "CMS.Views.Metadata.Editor creates editors for each field", -> describe "CMS.Views.Metadata.Editor creates editors for each field", ->
beforeEach -> beforeEach ->
...@@ -84,16 +98,18 @@ describe "Test Metadata Editor", -> ...@@ -84,16 +98,18 @@ describe "Test Metadata Editor", ->
{"display_name": "Never", "value": "never"}], {"display_name": "Never", "value": "never"}],
type: "unknown type", type: "unknown type",
value: null value: null
} },
listEntry
] ]
) )
it "creates child views on initialize, and sorts them alphabetically", -> it "creates child views on initialize, and sorts them alphabetically", ->
view = new CMS.Views.Metadata.Editor({collection: @model}) view = new CMS.Views.Metadata.Editor({collection: @model})
childModels = view.collection.models childModels = view.collection.models
expect(childModels.length).toBe(5) expect(childModels.length).toBe(6)
childViews = view.$el.find('.setting-input') # Be sure to check list view as well as other input types
expect(childViews.length).toBe(5) childViews = view.$el.find('.setting-input, .list-settings')
expect(childViews.length).toBe(6)
verifyEntry = (index, display_name, type) -> verifyEntry = (index, display_name, type) ->
expect(childModels[index].get('display_name')).toBe(display_name) expect(childModels[index].get('display_name')).toBe(display_name)
...@@ -101,9 +117,10 @@ describe "Test Metadata Editor", -> ...@@ -101,9 +117,10 @@ describe "Test Metadata Editor", ->
verifyEntry(0, 'Display Name', 'text') verifyEntry(0, 'Display Name', 'text')
verifyEntry(1, 'Inputs', 'number') verifyEntry(1, 'Inputs', 'number')
verifyEntry(2, 'Show Answer', 'select-one') verifyEntry(2, 'List', '')
verifyEntry(3, 'Unknown', 'text') verifyEntry(3, 'Show Answer', 'select-one')
verifyEntry(4, 'Weight', 'number') verifyEntry(4, 'Unknown', 'text')
verifyEntry(5, 'Weight', 'number')
it "returns its display name", -> it "returns its display name", ->
view = new CMS.Views.Metadata.Editor({collection: @model}) view = new CMS.Views.Metadata.Editor({collection: @model})
...@@ -146,27 +163,27 @@ describe "Test Metadata Editor", -> ...@@ -146,27 +163,27 @@ describe "Test Metadata Editor", ->
# Tests for individual views. # Tests for individual views.
assertInputType = (view, expectedType) -> assertInputType = (view, expectedType) ->
input = view.$el.find('.setting-input') input = view.$el.find('.setting-input')
expect(input.length).toBe(1) expect(input.length).toEqual(1)
expect(input[0].type).toBe(expectedType) expect(input[0].type).toEqual(expectedType)
assertValueInView = (view, expectedValue) -> assertValueInView = (view, expectedValue) ->
expect(view.getValueFromEditor()).toBe(expectedValue) expect(view.getValueFromEditor()).toEqual(expectedValue)
assertCanUpdateView = (view, newValue) -> assertCanUpdateView = (view, newValue) ->
view.setValueInEditor(newValue) view.setValueInEditor(newValue)
expect(view.getValueFromEditor()).toBe(newValue) expect(view.getValueFromEditor()).toEqual(newValue)
assertClear = (view, modelValue, editorValue=modelValue) -> assertClear = (view, modelValue, editorValue=modelValue) ->
view.clear() view.clear()
expect(view.model.getValue()).toBe(null) expect(view.model.getValue()).toBe(null)
expect(view.model.getDisplayValue()).toBe(modelValue) expect(view.model.getDisplayValue()).toEqual(modelValue)
expect(view.getValueFromEditor()).toBe(editorValue) expect(view.getValueFromEditor()).toEqual(editorValue)
assertUpdateModel = (view, originalValue, newValue) -> assertUpdateModel = (view, originalValue, newValue) ->
view.setValueInEditor(newValue) view.setValueInEditor(newValue)
expect(view.model.getValue()).toBe(originalValue) expect(view.model.getValue()).toEqual(originalValue)
view.updateModel() view.updateModel()
expect(view.model.getValue()).toBe(newValue) expect(view.model.getValue()).toEqual(newValue)
describe "CMS.Views.Metadata.String is a basic string input with clear functionality", -> describe "CMS.Views.Metadata.String is a basic string input with clear functionality", ->
beforeEach -> beforeEach ->
...@@ -298,3 +315,45 @@ describe "Test Metadata Editor", -> ...@@ -298,3 +315,45 @@ describe "Test Metadata Editor", ->
verifyDisallowedChars(@integerView) verifyDisallowedChars(@integerView)
verifyDisallowedChars(@floatView) verifyDisallowedChars(@floatView)
describe "CMS.Views.Metadata.List allows the user to enter an ordered list of strings", ->
beforeEach ->
listModel = new CMS.Models.Metadata(listEntry)
@listView = new CMS.Views.Metadata.List({model: listModel})
@el = @listView.$el
it "returns the initial value upon initialization", ->
assertValueInView(@listView, ['the first display value', 'the second'])
it "updates its value correctly", ->
assertCanUpdateView(@listView, ['a new item', 'another new item', 'a third'])
it "has a clear method to revert to the model default", ->
assertClear(@listView, ['a thing', 'another thing'])
it "has an update model method", ->
assertUpdateModel(@listView, null, ['a new value'])
it "can add an entry", ->
expect(@listView.model.get('value').length).toEqual(2)
@el.find('.create-setting').click()
expect(@el.find('input.input').length).toEqual(3)
it "can remove an entry", ->
expect(@listView.model.get('value').length).toEqual(2)
@el.find('.remove-setting').first().click()
expect(@listView.model.get('value').length).toEqual(1)
it "only allows one blank entry at a time", ->
expect(@el.find('input').length).toEqual(2)
@el.find('.create-setting').click()
@el.find('.create-setting').click()
expect(@el.find('input').length).toEqual(3)
it "re-enables the add setting button after entering a new value", ->
expect(@el.find('input').length).toEqual(2)
@el.find('.create-setting').click()
expect(@el.find('.create-setting')).toHaveClass('is-disabled')
@el.find('input').last().val('third setting')
@el.find('input').last().trigger('input')
expect(@el.find('.create-setting')).not.toHaveClass('is-disabled')
describe "Course Overview", -> describe "Course Overview", ->
beforeEach -> beforeEach ->
appendSetFixtures """ _.each ["/static/js/vendor/date.js", "/static/js/vendor/timepicker/jquery.timepicker.js", "/jsi18n/"], (path) ->
<script src="/static/js/vendor/date.js"></script> appendSetFixtures """
""" <script type="text/javascript" src="#{path}"></script>
"""
appendSetFixtures """
<script type="text/javascript" src="/jsi18n/"></script>
"""
appendSetFixtures """ appendSetFixtures """
<div class="section-published-date"> <div class="section-published-date">
<span class="published-status"> <span class="published-status">
<strong>Will Release:</strong> 06/12/2013 at 04:00 UTC <strong>Will Release:</strong> 06/12/2013 at 04:00 UTC
</span> </span>
<a href="#" class="edit-button" "="" data-date="06/12/2013" data-time="04:00" data-id="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2">Edit</a> <a href="#" class="edit-button" data-date="06/12/2013" data-time="04:00" data-id="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2">Edit</a>
</div> </div>
"""#" """
appendSetFixtures """ appendSetFixtures """
<div class="edit-subsection-publish-settings"> <div class="edit-subsection-publish-settings">
...@@ -38,7 +35,7 @@ describe "Course Overview", -> ...@@ -38,7 +35,7 @@ describe "Course Overview", ->
<a href="#" class="save-button">Save</a><a href="#" class="cancel-button">Cancel</a> <a href="#" class="save-button">Save</a><a href="#" class="cancel-button">Cancel</a>
</div> </div>
</div> </div>
"""#" """
appendSetFixtures """ appendSetFixtures """
<section class="courseware-section branch" data-id="a-location-goes-here"> <section class="courseware-section branch" data-id="a-location-goes-here">
...@@ -46,12 +43,13 @@ describe "Course Overview", -> ...@@ -46,12 +43,13 @@ describe "Course Overview", ->
<a href="#" class="delete-section-button"></a> <a href="#" class="delete-section-button"></a>
</li> </li>
</section> </section>
"""#" """
spyOn(window, 'saveSetSectionScheduleDate').andCallThrough() spyOn(window, 'saveSetSectionScheduleDate').andCallThrough()
# Have to do this here, as it normally gets bound in document.ready() # Have to do this here, as it normally gets bound in document.ready()
$('a.save-button').click(saveSetSectionScheduleDate) $('a.save-button').click(saveSetSectionScheduleDate)
$('a.delete-section-button').click(deleteSection) $('a.delete-section-button').click(deleteSection)
$(".edit-subsection-publish-settings .start-date").datepicker()
@notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough() @notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough()
window.analytics = jasmine.createSpyObj('analytics', ['track']) window.analytics = jasmine.createSpyObj('analytics', ['track'])
......
...@@ -120,6 +120,7 @@ class CMS.Views.UnitEdit extends Backbone.View ...@@ -120,6 +120,7 @@ class CMS.Views.UnitEdit extends Backbone.View
@model.save() @model.save()
deleteComponent: (event) => deleteComponent: (event) =>
event.preventDefault()
msg = new CMS.Views.Prompt.Warning( msg = new CMS.Views.Prompt.Warning(
title: gettext('Delete this component?'), title: gettext('Delete this component?'),
message: gettext('Deleting this component is permanent and cannot be undone.'), message: gettext('Deleting this component is permanent and cannot be undone.'),
......
...@@ -253,21 +253,20 @@ function syncReleaseDate(e) { ...@@ -253,21 +253,20 @@ function syncReleaseDate(e) {
$("#start_time").val(""); $("#start_time").val("");
} }
function getEdxTimeFromDateTimeVals(date_val, time_val) { function getDatetime(datepickerInput, timepickerInput) {
if (date_val != '') { // given a pair of inputs (datepicker and timepicker), return a JS Date
if (time_val == '') time_val = '00:00'; // object that corresponds to the datetime that they represent. Assume
// UTC timezone, NOT the timezone of the user's browser.
return new Date(date_val + " " + time_val + "Z"); 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) { function autosaveInput(e) {
...@@ -307,9 +306,17 @@ function saveSubsection() { ...@@ -307,9 +306,17 @@ function saveSubsection() {
metadata[$(el).data("metadata-name")] = el.value; metadata[$(el).data("metadata-name")] = el.value;
} }
// Piece back together the date/time UI elements into one date/time string // get datetimes for start and due, stick into metadata
metadata['start'] = getEdxTimeFromDateTimeInputs('start_date', 'start_time'); _(["start", "due"]).each(function(name) {
metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time');
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({ $.ajax({
url: "/save_item", url: "/save_item",
...@@ -772,21 +779,21 @@ function cancelSetSectionScheduleDate(e) { ...@@ -772,21 +779,21 @@ function cancelSetSectionScheduleDate(e) {
function saveSetSectionScheduleDate(e) { function saveSetSectionScheduleDate(e) {
e.preventDefault(); e.preventDefault();
var input_date = $('.edit-subsection-publish-settings .start-date').val(); var datetime = getDatetime(
var input_time = $('.edit-subsection-publish-settings .start-time').val(); $('.edit-subsection-publish-settings .start-date'),
$('.edit-subsection-publish-settings .start-time')
var start = getEdxTimeFromDateTimeVals(input_date, input_time); );
var id = $modal.attr('data-id'); var id = $modal.attr('data-id');
analytics.track('Edited Section Release Date', { analytics.track('Edited Section Release Date', {
'course': course_location_analytics, 'course': course_location_analytics,
'id': id, 'id': id,
'start': start 'start': datetime
}); });
var saving = new CMS.Views.Notification.Mini({ var saving = new CMS.Views.Notification.Mini({
title: gettext("Saving") + "&hellip;", title: gettext("Saving") + "&hellip;"
}); });
saving.show(); saving.show();
// call into server to commit the new order // call into server to commit the new order
...@@ -798,20 +805,29 @@ function saveSetSectionScheduleDate(e) { ...@@ -798,20 +805,29 @@ function saveSetSectionScheduleDate(e) {
data: JSON.stringify({ data: JSON.stringify({
'id': id, 'id': id,
'metadata': { 'metadata': {
'start': start 'start': datetime
} }
}) })
}).success(function() { }).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 $thisSection = $('.courseware-section[data-id="' + id + '"]');
var html = _.template( var html = _.template(
'<span class="published-status">' + '<span class="published-status">' +
'<strong>' + gettext("Will Release:") + '&nbsp;</strong>' + '<strong>' + gettext("Will Release:") + '&nbsp;</strong>' +
gettext("<%= date %> at <%= time %> UTC") + gettext("{month}/{day}/{year} at {hour}:{minute} UTC") +
'</span>' + '</span>' +
'<a href="#" class="edit-button" data-date="<%= date %>" data-time="<%= time %>" data-id="<%= id %>">' + '<a href="#" class="edit-button" data-date="{month}/{day}/{year}" data-time="{hour}:{minute}" data-id="{id}">' +
gettext("Edit") + gettext("Edit") +
'</a>', '</a>',
{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); $thisSection.find('.section-published-date').html(html);
hideModal(); hideModal();
saving.hide(); saving.hide();
......
...@@ -111,3 +111,4 @@ CMS.Models.Metadata.SELECT_TYPE = "Select"; ...@@ -111,3 +111,4 @@ CMS.Models.Metadata.SELECT_TYPE = "Select";
CMS.Models.Metadata.INTEGER_TYPE = "Integer"; CMS.Models.Metadata.INTEGER_TYPE = "Integer";
CMS.Models.Metadata.FLOAT_TYPE = "Float"; CMS.Models.Metadata.FLOAT_TYPE = "Float";
CMS.Models.Metadata.GENERIC_TYPE = "Generic"; CMS.Models.Metadata.GENERIC_TYPE = "Generic";
CMS.Models.Metadata.LIST_TYPE = "List";
...@@ -75,7 +75,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -75,7 +75,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
return this.videosourceSample(); return this.videosourceSample();
}, },
videosourceSample : function() { videosourceSample : function() {
if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video'); if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video');
else return ""; else return "";
} }
}); });
...@@ -96,7 +96,7 @@ function displayFinishedUpload(xhr) { ...@@ -96,7 +96,7 @@ function displayFinishedUpload(xhr) {
} }
var resp = JSON.parse(xhr.responseText); 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 .embeddable').show();
$('.upload-modal .file-name').hide(); $('.upload-modal .file-name').hide();
$('.upload-modal .progress-fill').html(resp.msg); $('.upload-modal .progress-fill').html(resp.msg);
......
...@@ -27,6 +27,9 @@ CMS.Views.Metadata.Editor = Backbone.View.extend({ ...@@ -27,6 +27,9 @@ CMS.Views.Metadata.Editor = Backbone.View.extend({
model.getType() === CMS.Models.Metadata.FLOAT_TYPE) { model.getType() === CMS.Models.Metadata.FLOAT_TYPE) {
new CMS.Views.Metadata.Number(data); new CMS.Views.Metadata.Number(data);
} }
else if(model.getType() === CMS.Models.Metadata.LIST_TYPE) {
new CMS.Views.Metadata.List(data);
}
else { else {
// Everything else is treated as GENERIC_TYPE, which uses String editor. // Everything else is treated as GENERIC_TYPE, which uses String editor.
new CMS.Views.Metadata.String(data); new CMS.Views.Metadata.String(data);
...@@ -310,3 +313,59 @@ CMS.Views.Metadata.Option = CMS.Views.Metadata.AbstractEditor.extend({ ...@@ -310,3 +313,59 @@ CMS.Views.Metadata.Option = CMS.Views.Metadata.AbstractEditor.extend({
}).prop('selected', true); }).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(
'<li class="list-settings-item">' +
'<input type="text" class="input" value="<%= ele %>">' +
'<a href="#" class="remove-action remove-setting" data-index="<%= index %>"><i class="icon-remove-sign"></i><span class="sr">Remove</span></a>' +
'</li>'
);
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');
}
});
...@@ -148,7 +148,7 @@ function generateCheckHoverState(selectorsToOpen, selectorsToShove) { ...@@ -148,7 +148,7 @@ function generateCheckHoverState(selectorsToOpen, selectorsToShove) {
} }
}); });
} };
} }
function removeHesitate(event, ui) { function removeHesitate(event, ui) {
......
...@@ -65,6 +65,7 @@ nav { ...@@ -65,6 +65,7 @@ nav {
pointer-events: none; pointer-events: none;
width: ($baseline*8); width: ($baseline*8);
overflow: hidden; overflow: hidden;
height: 0;
// dropped down state // dropped down state
...@@ -72,6 +73,7 @@ nav { ...@@ -72,6 +73,7 @@ nav {
opacity: 1.0; opacity: 1.0;
pointer-events: auto; pointer-events: auto;
overflow: visible; overflow: visible;
height: auto;
} }
} }
......
...@@ -291,7 +291,7 @@ body.dashboard { ...@@ -291,7 +291,7 @@ body.dashboard {
// ==================== // ====================
// course listings // ELEM: course listings
.courses { .courses {
margin: $baseline 0; margin: $baseline 0;
} }
...@@ -304,61 +304,110 @@ body.dashboard { ...@@ -304,61 +304,110 @@ body.dashboard {
box-shadow: 0 1px 2px $shadow-l1; box-shadow: 0 1px 2px $shadow-l1;
.course-item { .course-item {
@include box-sizing(border-box);
width: flex-grid(9, 9);
position: relative; position: relative;
border-bottom: 1px solid $gray-l1; border-bottom: 1px solid $gray-l1;
padding: $baseline;
&:last-child { // STATE: hover/focus
border-bottom: none; &:hover {
} background: $paleYellow;
.class-link { .course-actions .view-live-button {
z-index: 100; opacity: 1.0;
display: block; pointer-events: auto;
padding: 20px 25px; }
line-height: 1.3;
&:hover { .course-title {
background: $paleYellow; color: $orange-d1;
}
+ .view-live-button { .course-metadata {
opacity: 1.0; opacity: 1.0;
pointer-events: auto; }
} }
}
}
}
.class-name { .course-link, .course-actions {
display: block; @include box-sizing(border-box);
font-size: 19px; display: inline-block;
font-weight: 300; vertical-align: middle;
} }
.detail { // encompassing course link
font-size: 14px; .course-link {
font-weight: 400; @extend .ui-depth2;
margin-right: 20px; width: flex-grid(7, 9);
color: #3c3c3c; margin-right: flex-gutter();
} }
// view live button // course title
.view-live-button { .course-title {
z-index: 10000; @extend .t-title4;
position: absolute; margin: 0 ($baseline*2) ($baseline/4) 0;
top: ($baseline*0.75); font-weight: 300;
right: $baseline; }
padding: ($baseline/4) ($baseline/2);
opacity: 0.0;
pointer-events: none;
&:hover { // course metadata
opacity: 1.0; .course-metadata {
pointer-events: auto; @extend .t-copy-sub1;
} @include transition(opacity $tmg-f1 ease-in-out 0);
color: $gray;
opacity: 0.75;
.metadata-item {
display: inline-block;
&:after {
content: "/";
margin-left: ($baseline/10);
margin-right: ($baseline/10);
color: $gray-l4;
}
&:last-child {
&:after {
content: "";
margin-left: 0;
margin-right: 0;
}
}
.label {
@extend .cont-text-sr;
}
}
}
.course-actions {
@extend .ui-depth3;
position: static;
width: flex-grid(2, 9);
text-align: right;
// view live button
.view-live-button {
@extend .ui-depth3;
@include transition(opacity $tmg-f2 ease-in-out 0);
@include box-sizing(border-box);
padding: ($baseline/2);
opacity: 0.0;
pointer-events: none;
&:hover {
opacity: 1.0;
pointer-events: auto;
}
}
&:last-child {
border-bottom: none;
}
}
} }
} }
// ELEM: new user form // ELEM: new user form
.wrapper-create-course { .wrapper-create-course {
......
...@@ -148,6 +148,14 @@ body.course.textbooks { ...@@ -148,6 +148,14 @@ body.course.textbooks {
padding: ($baseline*0.75) $baseline; padding: ($baseline*0.75) $baseline;
background: $gray-l6; background: $gray-l6;
.action {
margin-right: ($baseline/4);
&:last-child {
margin-right: 0;
}
}
// add a chapter is below with chapters styling // add a chapter is below with chapters styling
.action-primary { .action-primary {
......
...@@ -449,12 +449,39 @@ body.course.unit { ...@@ -449,12 +449,39 @@ body.course.unit {
// Module Actions, also used for Static Pages // Module Actions, also used for Static Pages
.module-actions { .module-actions {
box-shadow: inset 0 1px 1px $shadow; box-shadow: inset 0 1px 2px $shadow;
padding: 0 0 $baseline $baseline; border-top: 1px solid $gray-l1;
background-color: $gray-l6; padding: ($baseline*0.75) $baseline;
background: $gray-l6;
.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;
}
.save-button { .action-secondary {
margin: ($baseline/2) 8px 0 0; @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 { ...@@ -599,26 +626,27 @@ body.course.unit {
} }
} }
.wrapper-comp-setting{ .wrapper-comp-setting {
display: inline-block; display: inline-block;
min-width: 300px; min-width: 300px;
width: 45%; width: 55%;
top: 0; top: 0;
vertical-align: top; vertical-align: top;
margin-bottom:5px; margin-bottom:5px;
position: relative; position: relative;
} }
label.setting-label { .setting-label {
@extend .t-copy-sub1; @extend .t-copy-sub1;
@include transition(color $tmg-f2 ease-in-out 0s); @include transition(color $tmg-f2 ease-in-out 0s);
font-weight: 400;
vertical-align: middle; vertical-align: middle;
display: inline-block; display: inline-block;
position: relative; position: relative;
left: 0; left: 0;
width: 33%;
min-width: 100px; min-width: 100px;
width: 35%; margin-right: ($baseline/2);
font-weight: 600;
&.is-focused { &.is-focused {
color: $blue; color: $blue;
...@@ -708,14 +736,98 @@ body.course.unit { ...@@ -708,14 +736,98 @@ body.course.unit {
} }
} }
.tip.setting-help { .setting-help {
@include font-size(12); @include font-size(12);
display: inline-block; display: inline-block;
font-color: $gray-l6; font-color: $gray-l6;
min-width: 260px; min-width: ($baseline*10);
width: 50%; width: 35%;
vertical-align: top; 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 {
}
}
}
} }
} }
} }
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
{{uploadDate}} {{uploadDate}}
</td> </td>
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value='{{url}}' readonly> <input type="text" class="embeddable-xml-input" value='{{portable_url}}' readonly>
</td> </td>
<td class="delete-col"> <td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a> <a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
...@@ -89,7 +89,7 @@ ...@@ -89,7 +89,7 @@
${asset['uploadDate']} ${asset['uploadDate']}
</td> </td>
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly> <input type="text" class="embeddable-xml-input" value="${asset['portable_url']}" readonly>
</td> </td>
<td class="delete-col"> <td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a> <a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
......
...@@ -26,8 +26,8 @@ ...@@ -26,8 +26,8 @@
</div> </div>
</div> </div>
<div class="row module-actions"> <div class="row module-actions">
<a href="#" class="save-button">${_("Save")}</a> <a href="#" class="save-button action-primary action">${_("Save")}</a>
<a href="#" class="cancel-button">${_("Cancel")}</a> <a href="#" class="cancel-button action-secondary action">${_("Cancel")}</a>
</div> <!-- Module Actions--> </div> <!-- Module Actions-->
</div> </div>
</div> </div>
......
<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! <%!
import logging import logging
from xmodule.util.date_utils import get_default_time_display, almost_same_datetime 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> <%block name="title">${_("CMS Subsection")}</%block>
<%block name="bodyclass">is-signedin course subsection</%block> <%block name="bodyclass">is-signedin course subsection</%block>
......
<%! 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
<%! 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
...@@ -6,6 +6,62 @@ ...@@ -6,6 +6,62 @@
<%block name="title">${_("Course Export")}</%block> <%block name="title">${_("Course Export")}</%block>
<%block name="bodyclass">is-signedin course tools export</%block> <%block name="bodyclass">is-signedin course tools export</%block>
<%block name="jsextra">
% if in_err:
<script type='text/javascript'>
$(document).ready(function() {
%if unit:
var dialog = new CMS.Views.Prompt({
title: gettext('There has been an error while exporting.'),
message: gettext("There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages."),
intent: "error",
actions: {
primary: {
text: gettext('Correct failed component'),
click: function(view) {
view.hide();
document.location = "${edit_unit_url}"
}
},
secondary: {
text: gettext('Return to Export'),
click: function(view) {
view.hide();
}
}
}
});
% else:
var msg = "<p>" + gettext("There has been a failure to export your course to XML. Unfortunately, we do not have specific enough information to assist you in identifying the failed component. It is recommended that you inspect your courseware to identify any components in error and try again.") + "</p><p>" + gettext("The raw error message is:") + "</p>";
msg = msg + "${raw_err_msg}";
var dialog = new CMS.Views.Prompt({
title: gettext('There has been an error with your export.'),
message: msg,
intent: "error",
actions: {
primary: {
text: gettext('Yes, take me to the main course page'),
click: function(view) {
view.hide();
document.location = "${course_home_url}"
}
},
secondary: {
text: gettext('Cancel'),
click: function(view) {
view.hide();
}
}
}
});
%endif
dialog.show();
})
</script>
%endif
</%block>
<%block name="content"> <%block name="content">
<div class="wrapper-mast wrapper"> <div class="wrapper-mast wrapper">
<header class="mast has-subtitle"> <header class="mast has-subtitle">
...@@ -18,6 +74,7 @@ ...@@ -18,6 +74,7 @@
<div class="main-wrapper"> <div class="main-wrapper">
<div class="inner-wrapper"> <div class="inner-wrapper">
<article class="export-overview"> <article class="export-overview">
<div class="description"> <div class="description">
<h2>${_("About Exporting Courses")}</h2> <h2>${_("About Exporting Courses")}</h2>
......
...@@ -133,12 +133,30 @@ ...@@ -133,12 +133,30 @@
%if len(courses) > 0: %if len(courses) > 0:
<div class="courses"> <div class="courses">
<ul class="list-courses"> <ul class="list-courses">
%for course, url, lms_link in sorted(courses, key=lambda s: s[0].lower() if s[0] is not None else ''): %for course, url, lms_link, org, num, run in sorted(courses, key=lambda s: s[0].lower() if s[0] is not None else ''):
<li class="course-item"> <li class="course-item">
<a class="class-link" href="${url}" class="class-name"> <a class="course-link" href="${url}">
<span class="class-name">${course}</span> <h3 class="course-title">${course}</h3>
<div class="course-metadata">
<span class="course-org metadata-item">
<span class="label">${_("Organization:")}</span> <span class="value">${org}</span>
</span>
<span class="course-num metadata-item">
<span class="label">${_("Course Number:")}</span>
<span class="value">${num}</span>
</span>
<span class="course-run metadata-item">
<span class="label">${_("Course Run:")}</span> <span class="value">${run}</span>
</span>
</div>
</a> </a>
<a href="${lms_link}" rel="external" class="button view-button view-live-button">${_("View Live")}</a>
<ul class="item-actions course-actions">
<li class="action">
<a href="${lms_link}" rel="external" class="button view-button view-live-button">${_("View Live")}</a>
</li>
</ul>
</li> </li>
%endfor %endfor
</ul> </ul>
......
<ul class="list-input settings-list"> <ul class="list-input settings-list">
<% _.each(_.range(numEntries), function() { %> <% _.each(_.range(numEntries), function() { %>
<li class="field comp-setting-entry metadata_entry" id="settings-listing"> <li class="field comp-setting-entry metadata_entry">
</li> </li>
<% }) %> <% }) %>
</ul> </ul>
<div class="wrapper-comp-setting metadata-list-enum">
<label class="label setting-label" for="<%= uniqueId %>"><%= model.get('display_name')%></label>
<div id="<%= uniqueId %>" class="wrapper-list-settings">
<ol class="list-settings">
</ol>
<a href="#" class="create-action create-setting">
<i class="icon-plus"></i><%= gettext("Add") %> <span class="sr"><%= model.get('display_name')%></span>
</a>
</div>
<button class="action setting-clear inactive" type="button" name="setting-clear" value="<%= gettext("Clear") %>" data-tooltip="<%= gettext("Clear") %>">
<i class="icon-undo"></i>
<span class="sr">"<%= gettext("Clear Value") %>"</span>
</button>
</div>
<span class="tip setting-help"><%= model.get('help') %></span>
<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! <%!
import logging import logging
from xmodule.util import date_utils 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> <%block name="title">${_("Course Outline")}</%block>
<%block name="bodyclass">is-signedin course outline</%block> <%block name="bodyclass">is-signedin course outline</%block>
......
...@@ -76,7 +76,7 @@ $(function() { ...@@ -76,7 +76,7 @@ $(function() {
</div> </div>
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("What if my book isn't divided into chapters?")}</h3> <h3 class="title-3">${_("What if my book isn't divided into chapters?")}</h3>
<p>${_("If you haven't broken your textbook into chapters, you can upload the entire text as Chapter 1.")}</p> <p>${_("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.")}</p>
</div> </div>
</aside> </aside>
</section> </section>
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
<h2 class="info-course"> <h2 class="info-course">
<span class="sr">${_("Current Course:")}</span> <span class="sr">${_("Current Course:")}</span>
<a class="course-link" href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}"> <a class="course-link" href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">
<span class="course-org">${ctx_loc.org}</span><span class="course-number">${ctx_loc.course}</span> <span class="course-org">${context_course.display_org_with_default | h}</span><span class="course-number">${context_course.display_number_with_default | h}</span>
<span class="course-title" title="${context_course.display_name_with_default}">${context_course.display_name_with_default}</span> <span class="course-title" title="${context_course.display_name_with_default}">${context_course.display_name_with_default}</span>
</a> </a>
</h2> </h2>
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<div class="wrapper-comp-editor" id="editor-tab"> <div class="wrapper-comp-editor" id="editor-tab" data-base-asset-url="${base_asset_url}">
<section class="html-editor editor"> <section class="html-editor editor">
<ul class="editor-tabs"> <ul class="editor-tabs">
<li><a href="#" class="visual-tab tab current" data-tab="visual">${_("Visual")}</a></li> <li><a href="#" class="visual-tab tab current" data-tab="visual">${_("Visual")}</a></li>
......
...@@ -25,6 +25,10 @@ ...@@ -25,6 +25,10 @@
<%static:include path="js/metadata-option-entry.underscore" /> <%static:include path="js/metadata-option-entry.underscore" />
</script> </script>
<script id="metadata-list-entry" type="text/template">
<%static:include path="js/metadata-list-entry.underscore" />
</script>
<% showHighLevelSource='source_code' in editable_metadata_fields and editable_metadata_fields['source_code']['explicitly_set'] %> <% showHighLevelSource='source_code' in editable_metadata_fields and editable_metadata_fields['source_code']['explicitly_set'] %>
<% metadata_field_copy = copy.copy(editable_metadata_fields) %> <% metadata_field_copy = copy.copy(editable_metadata_fields) %>
## Delete 'source_code' field (if it exists) so metadata editor view does not attempt to render it. ## Delete 'source_code' field (if it exists) so metadata editor view does not attempt to render it.
...@@ -40,4 +44,4 @@ ...@@ -40,4 +44,4 @@
<%include file="source-edit.html" /> <%include file="source-edit.html" />
% endif % endif
<div class="wrapper-comp-settings metadata_edit" id="settings-tab" data-metadata='${json.dumps(metadata_field_copy) | h}'/> <div class="wrapper-comp-settings metadata_edit" id="settings-tab" data-metadata='${json.dumps(metadata_field_copy) | h}'/>
\ No newline at end of file
<%! from django.utils.translation import ugettext as _ %>
<div class="wrapper-comp-editor" id="editor-tab-${html_id}" data-html_id="${html_id}">
<section class="editor-with-tabs">
<div class="edit-header">
<span class="component-name"></span>
<ul class="${'editor-tabs' if (len(tabs) != 1) else 'editor-single-tab-name' }">
% for tab in tabs:
<li class="inner_tab_wrap"><a href="#tab-${html_id}-${loop.index}" class="tab ${'current' if tab.get('current', False) else ''}">${_(tab['name'])}</a></li>
% endfor
</ul>
</div>
<div class="tabs-wrapper">
% for tab in tabs:
<div class="component-tab ${'is-inactive' if not tab.get('current', False) else ''}" id="tab-${html_id}-${loop.index}" >
<%include file="${tab['template']}" args="tabName=tab['name']"/>
</div>
% endfor
</div>
</section>
</div>
<%namespace name='static' file='../../static_content.html'/>
<%
import json
%>
## js templates
<script id="metadata-editor-tpl" type="text/template">
<%static:include path="js/metadata-editor.underscore" />
</script>
<script id="metadata-number-entry" type="text/template">
<%static:include path="js/metadata-number-entry.underscore" />
</script>
<script id="metadata-string-entry" type="text/template">
<%static:include path="js/metadata-string-entry.underscore" />
</script>
<script id="metadata-option-entry" type="text/template">
<%static:include path="js/metadata-option-entry.underscore" />
</script>
<script id="metadata-list-entry" type="text/template">
<%static:include path="js/metadata-list-entry.underscore" />
</script>
<div class="wrapper-comp-settings metadata_edit" id="settings-tab" data-metadata='${json.dumps(editable_metadata_fields) | h}'/>
<%! from django.utils.translation import ugettext as _ %>
<%page args="tabName"/>
<div>
<textarea id="xml-${html_id}" class="edit-box">${data | h}</textarea>
</div>
<script type='text/javascript'>
$(document).ready(function(){
## Init CodeMirror editor
var el = $("#xml-${html_id}"),
xml_editor = CodeMirror.fromTextArea(el.get(0), {
mode: "application/xml",
lineNumbers: true,
lineWrapping: true
});
TabsEditingDescriptor.Model.addModelUpdate(
'${html_id}',
'${tabName}',
function() { return xml_editor.getValue(); })
TabsEditingDescriptor.Model.addOnSwitch(
'${html_id}',
'${tabName}',
function(){
## CodeMirror should get focus when tab is active
xml_editor.refresh();
xml_editor.focus();
}
)
});
</script>
...@@ -6,7 +6,7 @@ from django.conf.urls import patterns, include, url ...@@ -6,7 +6,7 @@ from django.conf.urls import patterns, include, url
from . import one_time_startup from . import one_time_startup
# There is a course creators admin table. # There is a course creators admin table.
from django.contrib import admin from ratelimitbackend import admin
admin.autodiscover() admin.autodiscover()
urlpatterns = ('', # nopep8 urlpatterns = ('', # nopep8
...@@ -147,7 +147,7 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): ...@@ -147,7 +147,7 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'):
urlpatterns += (url(r'^admin/', include(admin.site.urls)),) urlpatterns += (url(r'^admin/', include(admin.site.urls)),)
# enable automatic login # enable automatic login
if settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING'): if settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
urlpatterns += ( urlpatterns += (
url(r'^auto_auth$', 'student.views.auto_auth'), url(r'^auto_auth$', 'student.views.auto_auth'),
) )
......
...@@ -3,7 +3,7 @@ django admin pages for courseware model ...@@ -3,7 +3,7 @@ django admin pages for courseware model
''' '''
from external_auth.models import * from external_auth.models import *
from django.contrib import admin from ratelimitbackend import admin
class ExternalAuthMapAdmin(admin.ModelAdmin): class ExternalAuthMapAdmin(admin.ModelAdmin):
......
...@@ -9,12 +9,15 @@ from urlparse import parse_qs ...@@ -9,12 +9,15 @@ from urlparse import parse_qs
from django.conf import settings from django.conf import settings
from django.test import TestCase, LiveServerTestCase from django.test import TestCase, LiveServerTestCase
from django.core.cache import cache
from django.test.utils import override_settings from django.test.utils import override_settings
# from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.client import RequestFactory from django.test.client import RequestFactory
from unittest import skipUnless from unittest import skipUnless
from student.tests.factories import UserFactory
from external_auth.views import provider_login
class MyFetcher(HTTPFetcher): class MyFetcher(HTTPFetcher):
"""A fetcher that uses server-internal calls for performing HTTP """A fetcher that uses server-internal calls for performing HTTP
...@@ -199,6 +202,49 @@ class OpenIdProviderTest(TestCase): ...@@ -199,6 +202,49 @@ class OpenIdProviderTest(TestCase):
""" Test for 403 error code when the url""" """ Test for 403 error code when the url"""
self.attempt_login(403, return_to="http://apps.cs50.edx.or") self.attempt_login(403, return_to="http://apps.cs50.edx.or")
def _send_bad_redirection_login(self):
"""
Attempt to log in to the provider with setup parameters
Intentionally fail the login to force a redirect
"""
user = UserFactory()
factory = RequestFactory()
post_params = {'email': user.email, 'password': 'password'}
fake_url = 'fake url'
request = factory.post(reverse('openid-provider-login'), post_params)
openid_setup = {
'request': factory.request(),
'url': fake_url
}
request.session = {
'openid_setup': openid_setup
}
response = provider_login(request)
return response
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_login_openid_handle_redirection(self):
""" Test to see that we can handle login redirection properly"""
response = self._send_bad_redirection_login()
self.assertEquals(response.status_code, 302)
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_login_openid_handle_redirection_ratelimited(self):
# try logging in 30 times, the default limit in the number of failed
# log in attempts before the rate gets limited
for _ in xrange(30):
self._send_bad_redirection_login()
response = self._send_bad_redirection_login()
# verify that we are not returning the default 403
self.assertEquals(response.status_code, 302)
# clear the ratelimit cache so that we don't fail other logins
cache.clear()
class OpenIdProviderLiveServerTest(LiveServerTestCase): class OpenIdProviderLiveServerTest(LiveServerTestCase):
""" """
......
...@@ -39,6 +39,7 @@ from openid.consumer.consumer import SUCCESS ...@@ -39,6 +39,7 @@ from openid.consumer.consumer import SUCCESS
from openid.server.server import Server, ProtocolError, UntrustedReturnURL from openid.server.server import Server, ProtocolError, UntrustedReturnURL
from openid.server.trustroot import TrustRoot from openid.server.trustroot import TrustRoot
from openid.extensions import ax, sreg from openid.extensions import ax, sreg
from ratelimitbackend.exceptions import RateLimitException
import student.views as student_views import student.views as student_views
# Required for Pearson # Required for Pearson
...@@ -191,7 +192,7 @@ def _external_login_or_signup(request, ...@@ -191,7 +192,7 @@ def _external_login_or_signup(request,
user.backend = auth_backend user.backend = auth_backend
AUDIT_LOG.info('Linked user "%s" logged in via Shibboleth', user.email) AUDIT_LOG.info('Linked user "%s" logged in via Shibboleth', user.email)
else: else:
user = authenticate(username=uname, password=eamap.internal_password) user = authenticate(username=uname, password=eamap.internal_password, request=request)
if user is None: if user is None:
# we want to log the failure, but don't want to log the password attempted: # we want to log the failure, but don't want to log the password attempted:
AUDIT_LOG.warning('External Auth Login failed for "%s"', uname) AUDIT_LOG.warning('External Auth Login failed for "%s"', uname)
...@@ -718,7 +719,12 @@ def provider_login(request): ...@@ -718,7 +719,12 @@ def provider_login(request):
# Failure is again redirected to the login dialog. # Failure is again redirected to the login dialog.
username = user.username username = user.username
password = request.POST.get('password', None) password = request.POST.get('password', None)
user = authenticate(username=username, password=password) try:
user = authenticate(username=username, password=password, request=request)
except RateLimitException:
AUDIT_LOG.warning('OpenID - Too many failed login attempts.')
return HttpResponseRedirect(openid_request_url)
if user is None: if user is None:
request.session['openid_error'] = True request.session['openid_error'] = True
msg = "OpenID login failed - password for %s is invalid" msg = "OpenID login failed - password for %s is invalid"
......
...@@ -4,7 +4,7 @@ django admin pages for courseware model ...@@ -4,7 +4,7 @@ django admin pages for courseware model
from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed
from student.models import CourseEnrollment, Registration, PendingNameChange from student.models import CourseEnrollment, Registration, PendingNameChange
from django.contrib import admin from ratelimitbackend import admin
admin.site.register(UserProfile) admin.site.register(UserProfile)
......
...@@ -21,7 +21,7 @@ class Migration(SchemaMigration): ...@@ -21,7 +21,7 @@ class Migration(SchemaMigration):
('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)), ('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)),
('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)), ('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)),
('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), ('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)),
('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=1024, blank=True)), ('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=False, max_length=1024, blank=True)),
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), ('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), ('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
('upload_status', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)), ('upload_status', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)),
...@@ -163,7 +163,7 @@ class Migration(SchemaMigration): ...@@ -163,7 +163,7 @@ class Migration(SchemaMigration):
'student.testcenterregistration': { 'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'}, 'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
......
...@@ -93,7 +93,7 @@ class Migration(SchemaMigration): ...@@ -93,7 +93,7 @@ class Migration(SchemaMigration):
'student.testcenterregistration': { 'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'}, 'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
......
...@@ -94,7 +94,7 @@ class Migration(SchemaMigration): ...@@ -94,7 +94,7 @@ class Migration(SchemaMigration):
'student.testcenterregistration': { 'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'}, 'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
......
...@@ -370,7 +370,7 @@ class TestCenterRegistration(models.Model): ...@@ -370,7 +370,7 @@ class TestCenterRegistration(models.Model):
accommodation_code = models.CharField(max_length=64, blank=True) accommodation_code = models.CharField(max_length=64, blank=True)
# store the original text of the accommodation request. # store the original text of the accommodation request.
accommodation_request = models.CharField(max_length=1024, blank=True, db_index=True) accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False)
# time at which edX sent the registration to the test center # time at which edX sent the registration to the test center
uploaded_at = models.DateTimeField(null=True, db_index=True) uploaded_at = models.DateTimeField(null=True, db_index=True)
......
...@@ -11,9 +11,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase): ...@@ -11,9 +11,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
Tests for the Auto auth view that we have for load testing. Tests for the Auto auth view that we have for load testing.
""" """
@patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_LOAD_TESTING": True}) @patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": True})
def setUp(self): def setUp(self):
# Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_LOAD_TESTING'] # Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING']
# value affects the contents of urls.py, # value affects the contents of urls.py,
# so we need to call super.setUp() which reloads urls.py (because # so we need to call super.setUp() which reloads urls.py (because
# of the UrlResetMixin) # of the UrlResetMixin)
...@@ -37,6 +37,26 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase): ...@@ -37,6 +37,26 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
user = qset[0] user = qset[0]
assert user.is_active assert user.is_active
def test_create_defined_user(self):
"""
Test that the user gets created with the correct attributes
when they are passed as parameters on the auto-auth page.
"""
self.client.get(
self.url,
{'username': 'robot', 'password': 'test', 'email': 'robot@edx.org'}
)
qset = User.objects.all()
# assert user was created with the correct username and password
self.assertEqual(qset.count(), 1)
user = qset[0]
self.assertEqual(user.username, 'robot')
self.assertTrue(user.check_password('test'))
self.assertEqual(user.email, 'robot@edx.org')
@patch('student.views.random.randint') @patch('student.views.random.randint')
def test_create_multiple_users(self, randint): def test_create_multiple_users(self, randint):
""" """
...@@ -50,8 +70,13 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase): ...@@ -50,8 +70,13 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
qset = User.objects.all() qset = User.objects.all()
# make sure that USER_1 and USER_2 were created # make sure that USER_1 and USER_2 were created correctly
self.assertEqual(qset.count(), 2) self.assertEqual(qset.count(), 2)
user1 = qset[0]
self.assertEqual(user1.username, 'USER_1')
self.assertTrue(user1.check_password('PASS_1'))
self.assertEqual(user1.email, 'USER_1_dummy_test@mitx.mit.edu')
self.assertEqual(qset[1].username, 'USER_2')
@patch.dict("django.conf.settings.MITX_FEATURES", {"MAX_AUTO_AUTH_USERS": 1}) @patch.dict("django.conf.settings.MITX_FEATURES", {"MAX_AUTO_AUTH_USERS": 1})
def test_login_already_created_user(self): def test_login_already_created_user(self):
...@@ -77,9 +102,9 @@ class AutoAuthDisabledTestCase(UrlResetMixin, TestCase): ...@@ -77,9 +102,9 @@ class AutoAuthDisabledTestCase(UrlResetMixin, TestCase):
Test that the page is inaccessible with default settings Test that the page is inaccessible with default settings
""" """
@patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_LOAD_TESTING": False}) @patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": False})
def setUp(self): def setUp(self):
# Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_LOAD_TESTING'] # Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING']
# value affects the contents of urls.py, # value affects the contents of urls.py,
# so we need to call super.setUp() which reloads urls.py (because # so we need to call super.setUp() which reloads urls.py (because
# of the UrlResetMixin) # of the UrlResetMixin)
......
...@@ -6,6 +6,7 @@ from mock import patch ...@@ -6,6 +6,7 @@ from mock import patch
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.core.cache import cache
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
...@@ -29,6 +30,7 @@ class LoginTest(TestCase): ...@@ -29,6 +30,7 @@ class LoginTest(TestCase):
# Create the test client # Create the test client
self.client = Client() self.client = Client()
cache.clear()
# Store the login url # Store the login url
try: try:
...@@ -95,6 +97,27 @@ class LoginTest(TestCase): ...@@ -95,6 +97,27 @@ class LoginTest(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test']) self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test'])
def test_login_ratelimited_success(self):
# Try (and fail) logging in with fewer attempts than the limit of 30
# and verify that you can still successfully log in afterwards.
for i in xrange(20):
password = u'test_password{0}'.format(i)
response, _audit_log = self._login_response('test@edx.org', password)
self._assert_response(response, success=False)
# now try logging in with a valid password
response, _audit_log = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=True)
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):
password = u'test_password{0}'.format(i)
self._login_response('test@edx.org', password)
# check to see if this response indicates that this was ratelimited
response, _audit_log = self._login_response('test@edx.org', 'wrong_password')
self._assert_response(response, success=False, value='Too many failed login attempts')
def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG'): def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG'):
''' Post the login info ''' ''' Post the login info '''
post_params = {'email': email, 'password': password} post_params = {'email': email, 'password': password}
......
...@@ -23,6 +23,7 @@ from textwrap import dedent ...@@ -23,6 +23,7 @@ from textwrap import dedent
from student.models import unique_id_for_user from student.models import unique_id_for_user
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
from student.views import enroll_in_course, is_enrolled_in_course
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string from student.tests.test_email import mock_render_to_string
COURSE_1 = 'edX/toy/2012_Fall' COURSE_1 = 'edX/toy/2012_Fall'
...@@ -205,3 +206,15 @@ class CourseEndingTest(TestCase): ...@@ -205,3 +206,15 @@ class CourseEndingTest(TestCase):
'show_survey_button': False, 'show_survey_button': False,
'grade': '67' 'grade': '67'
}) })
class EnrollInCourseTest(TestCase):
""" Tests the helper method for enrolling a user in a class """
def test_enroll_in_course(self):
user = User.objects.create_user("joe", "joe@joe.com", "password")
user.save()
course_id = "course_id"
self.assertFalse(is_enrolled_in_course(user, course_id))
enroll_in_course(user, course_id)
self.assertTrue(is_enrolled_in_course(user, course_id))
...@@ -28,6 +28,8 @@ from django.utils.http import cookie_date ...@@ -28,6 +28,8 @@ from django.utils.http import cookie_date
from django.utils.http import base36_to_int from django.utils.http import base36_to_int
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from ratelimitbackend.exceptions import RateLimitException
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
...@@ -376,7 +378,7 @@ def change_enrollment(request): ...@@ -376,7 +378,7 @@ def change_enrollment(request):
"run:{0}".format(run)]) "run:{0}".format(run)])
try: try:
enrollment, _created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) enroll_in_course(user, course.id)
except IntegrityError: except IntegrityError:
# If we've already created this enrollment in a separate transaction, # If we've already created this enrollment in a separate transaction,
# then just continue # then just continue
...@@ -401,6 +403,23 @@ def change_enrollment(request): ...@@ -401,6 +403,23 @@ def change_enrollment(request):
return HttpResponseBadRequest(_("Enrollment action is invalid")) return HttpResponseBadRequest(_("Enrollment action is invalid"))
def enroll_in_course(user, course_id):
"""
Helper method to enroll a user in a particular class.
It is expected that this method is called from a method which has already
verified the user authentication and access.
"""
CourseEnrollment.objects.get_or_create(user=user, course_id=course_id)
def is_enrolled_in_course(user, course_id):
"""
Helper method that returns whether or not the user is enrolled in a particular course.
"""
return CourseEnrollment.objects.filter(user=user, course_id=course_id).count() > 0
@ensure_csrf_cookie @ensure_csrf_cookie
def accounts_login(request, error=""): def accounts_login(request, error=""):
...@@ -421,13 +440,23 @@ def login_user(request, error=""): ...@@ -421,13 +440,23 @@ def login_user(request, error=""):
user = User.objects.get(email=email) user = User.objects.get(email=email)
except User.DoesNotExist: except User.DoesNotExist:
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
return HttpResponse(json.dumps({'success': False, user = None
'value': _('Email or password is incorrect.')})) # TODO: User error message
username = user.username # if the user doesn't exist, we want to set the username to an invalid
user = authenticate(username=username, password=password) # username so that authentication is guaranteed to fail and we can take
# advantage of the ratelimited backend
username = user.username if user else ""
try:
user = authenticate(username=username, password=password, request=request)
# this occurs when there are too many attempts from the same IP address
except RateLimitException:
return HttpResponse(json.dumps({'success': False,
'value': _('Too many failed login attempts. Try again later.')}))
if user is None: if user is None:
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email)) # if we didn't find this username earlier, the account for this email
# doesn't exist, and doesn't have a corresponding password
if username != "":
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email))
return HttpResponse(json.dumps({'success': False, return HttpResponse(json.dumps({'success': False,
'value': _('Email or password is incorrect.')})) 'value': _('Email or password is incorrect.')}))
...@@ -674,7 +703,7 @@ def create_account(request, post_override=None): ...@@ -674,7 +703,7 @@ def create_account(request, post_override=None):
message = render_to_string('emails/activation_email.txt', d) message = render_to_string('emails/activation_email.txt', d)
# dont send email if we are doing load testing or random user generation for some reason # dont send email if we are doing load testing or random user generation for some reason
if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING')): if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING')):
try: try:
if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'): if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL'] dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL']
...@@ -913,41 +942,46 @@ def auto_auth(request): ...@@ -913,41 +942,46 @@ def auto_auth(request):
""" """
Automatically logs the user in with a generated random credentials Automatically logs the user in with a generated random credentials
This view is only accessible when This view is only accessible when
settings.MITX_SETTINGS['AUTOMATIC_AUTH_FOR_LOAD_TESTING'] is true. settings.MITX_SETTINGS['AUTOMATIC_AUTH_FOR_TESTING'] is true.
""" """
def get_dummy_post_data(username, password): def get_dummy_post_data(username, password, email, name):
""" """
Return a dictionary suitable for passing to post_vars of _do_create_account or post_override Return a dictionary suitable for passing to post_vars of _do_create_account or post_override
of create_account, with specified username and password. of create_account, with specified values.
""" """
return {'username': username, return {'username': username,
'email': username + "_dummy_test@mitx.mit.edu", 'email': email,
'password': password, 'password': password,
'name': username + " " + username, 'name': name,
'honor_code': u'true', 'honor_code': u'true',
'terms_of_service': u'true', } 'terms_of_service': u'true', }
# generate random user ceredentials from a small name space (determined by settings) # generate random user credentials from a small name space (determined by settings)
name_base = 'USER_' name_base = 'USER_'
pass_base = 'PASS_' pass_base = 'PASS_'
max_users = settings.MITX_FEATURES.get('MAX_AUTO_AUTH_USERS', 200) max_users = settings.MITX_FEATURES.get('MAX_AUTO_AUTH_USERS', 200)
number = random.randint(1, max_users) number = random.randint(1, max_users)
username = name_base + str(number) # Get the params from the request to override default user attributes if specified
password = pass_base + str(number) qdict = request.GET
# Use the params from the request, otherwise use these defaults
username = qdict.get('username', name_base + str(number))
password = qdict.get('password', pass_base + str(number))
email = qdict.get('email', '%s_dummy_test@mitx.mit.edu' % username)
name = qdict.get('name', '%s Test' % username)
# if they already are a user, log in # if they already are a user, log in
try: try:
user = User.objects.get(username=username) user = User.objects.get(username=username)
user = authenticate(username=username, password=password) user = authenticate(username=username, password=password, request=request)
login(request, user) login(request, user)
# else create and activate account info # else create and activate account info
except ObjectDoesNotExist: except ObjectDoesNotExist:
post_override = get_dummy_post_data(username, password) post_override = get_dummy_post_data(username, password, email, name)
create_account(request, post_override=post_override) create_account(request, post_override=post_override)
request.user.is_active = True request.user.is_active = True
request.user.save() request.user.save()
......
...@@ -34,33 +34,17 @@ def create_user(uname, password): ...@@ -34,33 +34,17 @@ def create_user(uname, password):
@world.absorb @world.absorb
def log_in(username, password): def log_in(username='robot', password='test', email='robot@edx.org', name='Robot'):
""" """
Log the user in programatically. Use the auto_auth feature to programmatically log the user in
This will delete any existing cookies to ensure that the user
logs in to the correct session.
""" """
url = '/auto_auth?username=%s&password=%s&name=%s&email=%s' % (username,
password, name, email)
world.visit(url)
# Authenticate the user # Save the user info in the world scenario_dict for use in the tests
world.scenario_dict['USER'] = authenticate(username=username, password=password) user = User.objects.get(username=username)
assert(world.scenario_dict['USER'] is not None and world.scenario_dict['USER'].is_active) world.scenario_dict['USER'] = user
# Send a fake HttpRequest to log the user in
# We need to process the request using
# Session middleware and Authentication middleware
# to ensure that session state can be stored
request = HttpRequest()
SessionMiddleware().process_request(request)
AuthenticationMiddleware().process_request(request)
login(request, world.scenario_dict['USER'])
# Save the session
request.session.save()
# Retrieve the sessionid and add it to the browser's cookies
cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key}
world.browser.cookies.delete()
world.browser.cookies.add(cookie_dict)
@world.absorb @world.absorb
......
...@@ -88,13 +88,13 @@ def the_page_title_should_contain(step, title): ...@@ -88,13 +88,13 @@ def the_page_title_should_contain(step, title):
@step('I log in$') @step('I log in$')
def i_log_in(step): def i_log_in(step):
world.log_in('robot', 'test') world.log_in(username='robot', password='test')
@step('I am a logged in user$') @step('I am a logged in user$')
def i_am_logged_in_user(step): def i_am_logged_in_user(step):
world.create_user('robot', 'test') world.create_user('robot', 'test')
world.log_in('robot', 'test') world.log_in(username='robot', password='test')
@step('I am not logged in$') @step('I am not logged in$')
...@@ -147,7 +147,7 @@ def should_see_in_the_page(step, doesnt_appear, text): ...@@ -147,7 +147,7 @@ def should_see_in_the_page(step, doesnt_appear, text):
@step('I am logged in$') @step('I am logged in$')
def i_am_logged_in(step): def i_am_logged_in(step):
world.create_user('robot', 'test') world.create_user('robot', 'test')
world.log_in('robot', 'test') world.log_in(username='robot', password='test')
world.browser.visit(django_url('/')) world.browser.visit(django_url('/'))
# You should not see the login link # You should not see the login link
assert_equals(world.browser.find_by_css('a#login'), []) assert_equals(world.browser.find_by_css('a#login'), [])
......
...@@ -44,8 +44,8 @@ def is_css_not_present(css_selector, wait_time=5): ...@@ -44,8 +44,8 @@ def is_css_not_present(css_selector, wait_time=5):
@world.absorb @world.absorb
def css_has_text(css_selector, text): def css_has_text(css_selector, text, index=0, max_attempts=5):
return world.css_text(css_selector) == text return world.css_text(css_selector, index=index, max_attempts=max_attempts) == text
@world.absorb @world.absorb
...@@ -235,6 +235,13 @@ def click_tools(): ...@@ -235,6 +235,13 @@ def click_tools():
def is_mac(): def is_mac():
return platform.mac_ver()[0] is not '' return platform.mac_ver()[0] is not ''
@world.absorb
def is_firefox():
return world.browser.driver_name is 'Firefox'
@world.absorb
def trigger_event(css_selector, event='change', index=0):
world.browser.execute_script("$('{}:eq({})').trigger('{}')".format(css_selector, index, event))
@world.absorb @world.absorb
def retry_on_exception(func, max_attempts=5): def retry_on_exception(func, max_attempts=5):
......
...@@ -3,6 +3,6 @@ django admin pages for courseware model ...@@ -3,6 +3,6 @@ django admin pages for courseware model
''' '''
from track.models import TrackingLog from track.models import TrackingLog
from django.contrib import admin from ratelimitbackend import admin
admin.site.register(TrackingLog) admin.site.register(TrackingLog)
...@@ -534,8 +534,16 @@ class CapaModule(CapaFields, XModule): ...@@ -534,8 +534,16 @@ class CapaModule(CapaFields, XModule):
id=self.location.html_id(), ajax_url=self.system.ajax_url id=self.location.html_id(), ajax_url=self.system.ajax_url
) + html + "</div>" ) + html + "</div>"
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes # now do all the substitutions which the LMS module_render normally does, but
return self.system.replace_urls(html) # we need to do here explicitly since we can get called for our HTML via AJAX
html = self.system.replace_urls(html)
if self.system.replace_course_urls:
html = self.system.replace_course_urls(html)
if self.system.replace_jump_to_id_urls:
html = self.system.replace_jump_to_id_urls(html)
return html
def handle_ajax(self, dispatch, data): def handle_ajax(self, dispatch, data):
""" """
......
...@@ -59,6 +59,20 @@ class StaticContent(object): ...@@ -59,6 +59,20 @@ class StaticContent(object):
return None return None
@staticmethod @staticmethod
def get_static_path_from_location(location):
"""
This utility static method will take a location identifier and create a 'durable' /static/.. URL representation of it.
This link is 'durable' as it can maintain integrity across cloning of courseware across course-ids, e.g. reruns of
courses.
In the LMS/CMS, we have runtime link-rewriting, so at render time, this /static/... format will get translated into
the actual /c4x/... path which the client needs to reference static content
"""
if location is not None:
return "/static/{name}".format(**location.dict())
else:
return None
@staticmethod
def get_base_url_path_for_course_assets(loc): def get_base_url_path_for_course_assets(loc):
if loc is not None: if loc is not None:
return "/c4x/{org}/{course}/asset".format(**loc.dict()) return "/c4x/{org}/{course}/asset".format(**loc.dict())
......
...@@ -362,6 +362,11 @@ class CourseFields(object): ...@@ -362,6 +362,11 @@ class CourseFields(object):
# Explicit comparison to True because we always want to return a bool. # Explicit comparison to True because we always want to return a bool.
hide_progress_tab = Boolean(help="DO NOT USE THIS", scope=Scope.settings) hide_progress_tab = Boolean(help="DO NOT USE THIS", scope=Scope.settings)
display_organization = String(help="An optional display string for the course organization that will get rendered in the LMS",
scope=Scope.settings)
display_coursenumber = String(help="An optional display string for the course number that will get rendered in the LMS",
scope=Scope.settings)
class CourseDescriptor(CourseFields, SequenceDescriptor): class CourseDescriptor(CourseFields, SequenceDescriptor):
module_class = SequenceModule module_class = SequenceModule
...@@ -934,5 +939,25 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -934,5 +939,25 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
return self.location.course return self.location.course
@property @property
def display_number_with_default(self):
"""
Return a display course number if it has been specified, otherwise return the 'course' that is in the location
"""
if self.display_coursenumber:
return self.display_coursenumber
return self.number
@property
def org(self): def org(self):
return self.location.org return self.location.org
@property
def display_org_with_default(self):
"""
Return a display organization if it has been specified, otherwise return the 'org' that is in the location
"""
if self.display_organization:
return self.display_organization
return self.org
.editor{
@include clearfix();
.CodeMirror {
@include box-sizing(border-box);
width: 100%;
position: relative;
height: 379px;
border: 1px solid #3c3c3c;
border-top: 1px solid #8891a1;
background: $white;
color: #3c3c3c;
}
.CodeMirror-scroll {
height: 100%;
}
}
// styles duped from _unit.scss - Edit Header (Component Name, Mode-Editor, Mode-Settings)
.tabs-wrapper{
padding-top: 0;
position: relative;
.wrapper-comp-settings {
// set visibility to metadata editor
display: block;
}
}
.editor-single-tab-name {
display: none;
}
.editor-with-tabs {
@include clearfix();
position: relative;
.edit-header {
@include box-sizing(border-box);
padding: 18px 0 18px $baseline;
top: 0 !important; // ugly override for second level tab override
right: 0;
background-color: $blue;
border-bottom: 1px solid $blue-d2;
color: $white;
//Component Name
.component-name {
@extend .t-copy-sub1;
position: relative;
top: 0;
left: 0;
width: 50%;
color: $white;
font-weight: 600;
em {
display: inline-block;
margin-right: ($baseline/4);
font-weight: 400;
color: $white;
}
}
//Nav-Edit Modes
.editor-tabs {
list-style: none;
right: 0;
top: ($baseline/4);
position: absolute;
padding: 12px ($baseline*0.75);
.inner_tab_wrap {
display: inline-block;
margin-left: 8px;
a.tab {
@include font-size(14);
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
border: 1px solid $blue-d1;
border-radius: 3px;
padding: ($baseline/4) ($baseline);
background-color: $blue;
font-weight: bold;
color: $white;
&.current {
@include linear-gradient($blue, $blue);
color: $blue-d1;
box-shadow: inset 0 1px 2px 1px $shadow-l1;
background-color: $blue-d4;
cursor: default;
}
&:hover {
box-shadow: inset 0 1px 2px 1px $shadow;
background-image: linear-gradient(#009FE6, #009FE6) !important;
}
}
}
}
}
.is-inactive {
display: none;
}
.comp-subtitles-entry {
text-align: center;
.file-upload {
display: none;
}
.comp-subtitles-import-list {
> li {
display: block;
margin: $baseline/2 0px $baseline/2 0;
}
.blue-button {
font-size: 1em;
display: block;
width: 70%;
margin: 0 auto;
text-align: center;
}
}
}
}
.component-tab {
background: $white;
position: relative;
border-top: 1px solid #8891a1;
&#advanced {
padding: 0;
border: none;
}
.blue-button {
@include blue-button;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment