Merge branch 'master' into jkarni/fix/descriptorsystemruntime
This commit is contained in:
2
AUTHORS
2
AUTHORS
@@ -53,7 +53,7 @@ Christina Roberts <christina@edx.org>
|
||||
Robert Chirwa <robert@edx.org>
|
||||
Ed Zarecor <ed@edx.org>
|
||||
Deena Wang <thedeenawang@gmail.com>
|
||||
Jean Manuel-Nater <jnater@edx.org>
|
||||
Jean Manuel Náter <jnater@edx.org>
|
||||
Emily Zhang <1800.ehz.hang@gmail.com>
|
||||
Jennifer Akana <jaakana@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,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Studio: Email will be sent to admin address when a user requests course creator
|
||||
privileges for Studio (edge only).
|
||||
|
||||
Studio: Studio course authors (both instructors and staff) will be auto-enrolled
|
||||
for their courses so that "View Live" works.
|
||||
|
||||
Common: Added ratelimiting to our authentication backend.
|
||||
|
||||
Common: Add additional logging to cover login attempts and logouts.
|
||||
|
||||
Studio: Send e-mails to new Studio users (on edge only) when their course creator
|
||||
@@ -40,6 +48,9 @@ Common: Add a manage.py that knows about edx-platform specific settings and proj
|
||||
|
||||
Common: Added *experimental* support for jsinput type.
|
||||
|
||||
Studio: Remove XML from HTML5 video component editor. All settings are
|
||||
moved to be edited as metadata.
|
||||
|
||||
Common: Added setting to specify Celery Broker vhost
|
||||
|
||||
Common: Utilize new XBlock bulk save API in LMS and CMS.
|
||||
|
||||
@@ -40,6 +40,7 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
# This feature will work in Firefox only when Firefox is the active window
|
||||
Scenario: Test automatic quoting of non-JSON values
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a non-JSON value not in quotes
|
||||
|
||||
@@ -10,6 +10,7 @@ Feature: Course checklists
|
||||
Then I can check and uncheck tasks in a checklist
|
||||
And They are correctly selected after reloading the page
|
||||
|
||||
# CHROME ONLY, due to issues getting link to be active in firefox
|
||||
Scenario: A task can link to a location within Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to the course outline
|
||||
@@ -17,6 +18,7 @@ Feature: Course checklists
|
||||
And I press the browser back button
|
||||
Then I am brought back to the course outline in the correct state
|
||||
|
||||
# CHROME ONLY, due to issues getting link to be active in firefox
|
||||
Scenario: A task can link to a location outside Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to help page
|
||||
|
||||
@@ -56,10 +56,8 @@ def i_have_opened_a_new_course(_step):
|
||||
|
||||
@step('(I select|s?he selects) the new course')
|
||||
def select_new_course(_step, whom):
|
||||
course_link_xpath = '//div[contains(@class, "courses")]//a[contains(@class, "class-link")]//span[contains(., "{name}")]/..'.format(
|
||||
name="Robot Super Course")
|
||||
element = world.browser.find_by_xpath(course_link_xpath)
|
||||
element.click()
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
@@ -72,8 +70,12 @@ def press_the_notification_button(_step, name):
|
||||
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
|
||||
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
return confirmation_dismissed or error_showing
|
||||
|
||||
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name
|
||||
if world.is_firefox():
|
||||
# This is done to explicitly make the changes save on firefox. It will remove focus from the previously focused element
|
||||
world.trigger_event(css, event='focus')
|
||||
world.browser.execute_script("$('{}').click()".format(css))
|
||||
else:
|
||||
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name
|
||||
|
||||
|
||||
@step('I change the "(.*)" field to "(.*)"$')
|
||||
@@ -144,24 +146,13 @@ def fill_in_course_info(
|
||||
def log_into_studio(
|
||||
uname='robot',
|
||||
email='robot+studio@edx.org',
|
||||
password='test'):
|
||||
password='test',
|
||||
name='Robot Studio'):
|
||||
|
||||
world.browser.cookies.delete()
|
||||
world.log_in(username=uname, password=password, email=email, name=name)
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
|
||||
signin_css = 'a.action-signin'
|
||||
world.is_css_present(signin_css)
|
||||
world.css_click(signin_css)
|
||||
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(email)
|
||||
login_form.find_by_name('password').fill(password)
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
assert_true(world.is_css_present('.new-course-button'))
|
||||
world.scenario_dict['USER'] = get_user_by_email(email)
|
||||
|
||||
world.wait_for(lambda _driver: uname in world.css_find('h2.title')[0].text)
|
||||
|
||||
def create_a_course():
|
||||
course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
@@ -178,9 +169,10 @@ def create_a_course():
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
world.browser.reload()
|
||||
|
||||
course_link_css = 'span.class-name'
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
course_title_css = 'span.course-title'
|
||||
assert_true(world.is_css_present(course_title_css))
|
||||
@@ -228,6 +220,26 @@ def i_created_a_video_component(step):
|
||||
)
|
||||
|
||||
|
||||
@step('I have created a Video Alpha component$')
|
||||
def i_created_video_alpha(step):
|
||||
step.given('I have enabled the videoalpha advanced module')
|
||||
world.css_click('a.course-link')
|
||||
step.given('I have added a new subsection')
|
||||
step.given('I expand the first section')
|
||||
world.css_click('a.new-unit-item')
|
||||
world.css_click('.large-advanced-icon')
|
||||
world.click_component_from_menu('videoalpha', None, '.xmodule_VideoAlphaModule')
|
||||
|
||||
|
||||
@step('I have enabled the (.*) advanced module$')
|
||||
def i_enabled_the_advanced_module(step, module):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
world.css_click('.nav-course-settings')
|
||||
world.css_click('.nav-course-settings-advanced a')
|
||||
type_in_codemirror(0, '["%s"]' % module)
|
||||
press_the_notification_button(step, 'Save')
|
||||
|
||||
|
||||
@step('I have clicked the new unit button')
|
||||
def open_new_unit(step):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
@@ -236,14 +248,14 @@ def open_new_unit(step):
|
||||
world.css_click('a.new-unit-item')
|
||||
|
||||
|
||||
@step('when I view the video it (.*) show the captions')
|
||||
def shows_captions(step, show_captions):
|
||||
@step('when I view the (video.*) it (.*) show the captions')
|
||||
def shows_captions(_step, video_type, show_captions):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
if show_captions == 'does not':
|
||||
assert world.css_has_class('.video', 'closed')
|
||||
assert world.css_has_class('.%s' % video_type, 'closed')
|
||||
else:
|
||||
assert world.is_css_not_present('.video.closed')
|
||||
assert world.is_css_not_present('.%s.closed' % video_type)
|
||||
|
||||
|
||||
@step('the save button is disabled$')
|
||||
@@ -265,7 +277,7 @@ def i_am_shown_a_notification(step, notification_type):
|
||||
|
||||
|
||||
def type_in_codemirror(index, text):
|
||||
world.css_click(".CodeMirror", index=index)
|
||||
world.css_click("div.CodeMirror-lines", index=index)
|
||||
world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')")
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
if world.is_mac():
|
||||
@@ -274,3 +286,5 @@ def type_in_codemirror(index, text):
|
||||
g._element.send_keys(Keys.CONTROL + 'a')
|
||||
g._element.send_keys(Keys.DELETE)
|
||||
g._element.send_keys(text)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('div.CodeMirror', index=index, event='blur')
|
||||
|
||||
@@ -56,13 +56,24 @@ def click_component_from_menu(category, boilerplate, expected_css):
|
||||
def edit_component_and_select_settings():
|
||||
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
|
||||
world.css_click('a.edit-button')
|
||||
world.css_click('#settings-mode')
|
||||
world.css_click('#settings-mode a')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component():
|
||||
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
|
||||
world.css_click('a.edit-button')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def verify_setting_entry(setting, display_name, value, explicitly_set):
|
||||
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value)
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
# Check specifically for the list type; it has a different structure
|
||||
if setting.has_class('metadata-list-enum'):
|
||||
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
else:
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
settingClearButton = setting.find_by_css('.setting-clear')[0]
|
||||
assert_equal(explicitly_set, settingClearButton.has_class('active'))
|
||||
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
|
||||
@@ -103,8 +114,20 @@ def revert_setting_entry(label):
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry(label):
|
||||
settings = world.browser.find_by_css('.wrapper-comp-setting')
|
||||
for setting in settings:
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return setting
|
||||
return None
|
||||
def get_setting():
|
||||
settings = world.css_find('.wrapper-comp-setting')
|
||||
for setting in settings:
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return setting
|
||||
return None
|
||||
return world.retry_on_exception(get_setting)
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry_index(label):
|
||||
def get_index():
|
||||
settings = world.css_find('.wrapper-comp-setting')
|
||||
for index, setting in enumerate(settings):
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return index
|
||||
return None
|
||||
return world.retry_on_exception(get_index)
|
||||
|
||||
@@ -52,7 +52,7 @@ def have_a_course_with_two_sections(step):
|
||||
def navigate_to_the_course_overview_page(step):
|
||||
create_studio_user(is_staff=True)
|
||||
log_into_studio()
|
||||
course_locator = '.class-name'
|
||||
course_locator = 'a.course-link'
|
||||
world.css_click(course_locator)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ Feature: Course Team
|
||||
And I am viewing the course team settings
|
||||
When I add "bob" to the course team
|
||||
And "bob" logs in
|
||||
And he selects the new course
|
||||
And he views the course team settings
|
||||
Then he cannot delete users
|
||||
And he cannot add users
|
||||
|
||||
@@ -69,7 +71,7 @@ Feature: Course Team
|
||||
And she selects the new course
|
||||
And she views the course team settings
|
||||
And she deletes me from the course team
|
||||
And I log in
|
||||
And I am logged into studio
|
||||
Then I do not see the course on my page
|
||||
|
||||
Scenario: Admins should be able to remove their own admin rights
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import create_studio_user, log_into_studio
|
||||
from common import create_studio_user
|
||||
from django.contrib.auth.models import Group
|
||||
from auth.authz import get_course_groupname_for_role
|
||||
from auth.authz import get_course_groupname_for_role, get_user_by_email
|
||||
from nose.tools import assert_true
|
||||
|
||||
PASSWORD = 'test'
|
||||
EMAIL_EXTENSION = '@edx.org'
|
||||
@@ -42,9 +43,9 @@ def add_other_user(_step, name):
|
||||
world.wait(0.5)
|
||||
|
||||
email_css = 'input#user-email-input'
|
||||
f = world.css_find(email_css)
|
||||
f._element.send_keys(name, EMAIL_EXTENSION)
|
||||
|
||||
world.css_fill(email_css, name + EMAIL_EXTENSION)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(email_css)
|
||||
confirm_css = 'form.create-user button.action-primary'
|
||||
world.css_click(confirm_css)
|
||||
|
||||
@@ -55,6 +56,8 @@ def delete_other_user(_step, name):
|
||||
email="{0}{1}".format(name, EMAIL_EXTENSION))
|
||||
world.css_click(to_delete_css)
|
||||
# confirm prompt
|
||||
# need to wait for the animation to be done, there isn't a good success condition that won't work both on latest chrome and jenkins
|
||||
world.wait(.5)
|
||||
world.css_click(".wrapper-prompt-warning .action-primary")
|
||||
|
||||
|
||||
@@ -64,6 +67,7 @@ def other_delete_self(_step):
|
||||
email="robot+studio@edx.org")
|
||||
world.css_click(to_delete_css)
|
||||
# confirm prompt
|
||||
world.wait(.5)
|
||||
world.css_click(".wrapper-prompt-warning .action-primary")
|
||||
|
||||
|
||||
@@ -87,13 +91,27 @@ def remove_course_team_admin(_step, outer_capture, name):
|
||||
|
||||
@step(u'"([^"]*)" logs in$')
|
||||
def other_user_login(_step, name):
|
||||
log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION)
|
||||
world.browser.cookies.delete()
|
||||
world.visit('/')
|
||||
|
||||
signin_css = 'a.action-signin'
|
||||
world.is_css_present(signin_css)
|
||||
world.css_click(signin_css)
|
||||
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(name + EMAIL_EXTENSION)
|
||||
login_form.find_by_name('password').fill(PASSWORD)
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
assert_true(world.is_css_present('.new-course-button'))
|
||||
world.scenario_dict['USER'] = get_user_by_email(name + EMAIL_EXTENSION)
|
||||
|
||||
|
||||
@step(u'I( do not)? see the course on my page')
|
||||
@step(u's?he does( not)? see the course on (his|her) page')
|
||||
def see_course(_step, inverted, gender='self'):
|
||||
class_css = 'span.class-name'
|
||||
class_css = 'h3.course-title'
|
||||
all_courses = world.css_find(class_css, wait_time=1)
|
||||
all_names = [item.html for item in all_courses]
|
||||
if inverted:
|
||||
|
||||
@@ -9,7 +9,7 @@ from common import type_in_codemirror
|
||||
@step(u'I go to the course updates page')
|
||||
def go_to_updates(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
updates_css = 'li.nav-course-courseware-updates'
|
||||
updates_css = 'li.nav-course-courseware-updates a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(updates_css)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def i_create_a_course(step):
|
||||
|
||||
@step('I click the course link in My Courses$')
|
||||
def i_click_the_course_link_in_my_courses(step):
|
||||
course_css = 'span.class-name'
|
||||
course_css = 'a.course-link'
|
||||
world.css_click(course_css)
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
@@ -44,7 +44,7 @@ def courseware_page_has_loaded_in_studio(step):
|
||||
|
||||
@step('I see the course listed in My Courses$')
|
||||
def i_see_the_course_in_my_courses(step):
|
||||
course_css = 'span.class-name'
|
||||
course_css = 'h3.class-title'
|
||||
assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name)
|
||||
|
||||
|
||||
|
||||
@@ -47,12 +47,12 @@ Feature: Problem Editor
|
||||
Scenario: User cannot type decimal values integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234"
|
||||
Then if I set the max attempts to "2.34", it will persist as a valid integer
|
||||
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0"
|
||||
Then if I set the max attempts to "-3", it will persist as a valid integer
|
||||
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
@@ -66,6 +66,7 @@ Feature: Problem Editor
|
||||
When I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
# This feature will work in Firefox only when Firefox is the active window
|
||||
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
|
||||
Given I have created a LaTeX Problem
|
||||
When I edit and compile the High Level Source
|
||||
|
||||
@@ -45,7 +45,10 @@ def i_see_five_settings_with_values(step):
|
||||
def i_can_modify_the_display_name(step):
|
||||
# Verifying that the display name can be a string containing a floating point value
|
||||
# (to confirm that we don't throw an error because it is of the wrong type).
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4')
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', '3.4', index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@@ -57,7 +60,10 @@ def my_display_name_change_is_persisted_on_save(step):
|
||||
|
||||
@step('I can specify special characters in the display name')
|
||||
def i_can_modify_the_display_name_with_special_chars(step):
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &")
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', "updated ' \" &", index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
|
||||
verify_modified_display_name_with_special_chars()
|
||||
|
||||
|
||||
@@ -127,12 +133,16 @@ def set_the_weight_to_abc(step, bad_weight):
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
|
||||
|
||||
|
||||
@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"')
|
||||
def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted):
|
||||
world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set)
|
||||
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True)
|
||||
@step('if I set the max attempts to "(.*)", it will persist as a valid integer$')
|
||||
def set_the_max_attempts(step, max_attempts_set):
|
||||
# on firefox with selenium, the behaviour is different. eg 2.34 displays as 2.34 and is persisted as 2
|
||||
index = world.get_setting_entry_index(MAXIMUM_ATTEMPTS)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', max_attempts_set, index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
|
||||
world.save_component_and_reopen(step)
|
||||
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True)
|
||||
value = int(world.css_value('input.setting-input', index=index))
|
||||
assert value >= 0
|
||||
|
||||
|
||||
@step('Edit High Level Source is not visible')
|
||||
@@ -213,7 +223,11 @@ def verify_unset_display_name():
|
||||
|
||||
|
||||
def set_weight(weight):
|
||||
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight)
|
||||
index = world.get_setting_entry_index(PROBLEM_WEIGHT)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', weight, index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index, event='blur')
|
||||
world.trigger_event('a.save-button', event='focus')
|
||||
|
||||
|
||||
def open_high_level_source():
|
||||
|
||||
@@ -3,7 +3,6 @@ Feature: Create Section
|
||||
As a course author
|
||||
I want to create and edit sections
|
||||
|
||||
@skip
|
||||
Scenario: Add a new section to a course
|
||||
Given I have opened a new course in Studio
|
||||
When I click the New Section link
|
||||
@@ -24,7 +23,7 @@ Feature: Create Section
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
When I click the Edit link for the release date
|
||||
And I save a new section release date
|
||||
And I set the section release date to 12/25/2013
|
||||
Then the section release date is updated
|
||||
And I see a "saving" notification
|
||||
|
||||
|
||||
@@ -35,10 +35,15 @@ def i_click_the_edit_link_for_the_release_date(_step):
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step('I save a new section release date$')
|
||||
def i_save_a_new_section_release_date(_step):
|
||||
set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013',
|
||||
'input.start-time.time.ui-timepicker-input', '00:00')
|
||||
@step('I set the section release date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_section_release_date(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
set_date_and_time(
|
||||
'input.start-date.date.hasDatepicker', datestring,
|
||||
'input.start-time.time.ui-timepicker-input', timestring)
|
||||
world.browser.click_link_by_text('Save')
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from selenium.webdriver.common.keys import Keys
|
||||
@step(u'I go to the static pages page')
|
||||
def go_to_static(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
static_css = 'li.nav-course-courseware-pages'
|
||||
static_css = 'li.nav-course-courseware-pages a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(static_css)
|
||||
|
||||
@@ -38,14 +38,12 @@ def click_edit_delete(_step, edit_delete, page):
|
||||
|
||||
@step(u'I change the name to "([^"]*)"$')
|
||||
def change_name(_step, new_name):
|
||||
settings_css = '#settings-mode'
|
||||
settings_css = '#settings-mode a'
|
||||
world.css_click(settings_css)
|
||||
input_css = 'input.setting-input'
|
||||
name_input = world.css_find(input_css)
|
||||
old_name = name_input.value
|
||||
for count in range(len(old_name)):
|
||||
name_input._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
name_input._element.send_keys(new_name)
|
||||
world.css_fill(input_css, new_name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
save_button = 'a.save-button'
|
||||
world.css_click(save_button)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Feature: Create Subsection
|
||||
When I click the New Subsection link
|
||||
And I enter a subsection name with a quote and click save
|
||||
Then I see my subsection name with a quote on the Courseware page
|
||||
And I click to edit the subsection name
|
||||
And I click on the subsection
|
||||
Then I see the complete subsection name with a quote in the editor
|
||||
|
||||
Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
|
||||
@@ -27,10 +27,13 @@ Feature: Create Subsection
|
||||
|
||||
Scenario: Set a due date in a different year (bug #256)
|
||||
Given I have opened a new subsection in Studio
|
||||
And I have set a release date and due date in different years
|
||||
Then I see the correct dates
|
||||
And I set the subsection release date to 12/25/2011 03:00
|
||||
And I set the subsection due date to 01/02/2012 04:00
|
||||
Then I see the subsection release date is 12/25/2011 03:00
|
||||
And I see the subsection due date is 01/02/2012 04:00
|
||||
And I reload the page
|
||||
Then I see the correct dates
|
||||
Then I see the subsection release date is 12/25/2011 03:00
|
||||
And I see the subsection due date is 01/02/2012 04:00
|
||||
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
@@ -40,3 +43,16 @@ Feature: Create Subsection
|
||||
And I press the "subsection" delete icon
|
||||
And I confirm the prompt
|
||||
Then the subsection does not exist
|
||||
|
||||
Scenario: Sync to Section
|
||||
Given I have opened a new course section in Studio
|
||||
And I click the Edit link for the release date
|
||||
And I set the section release date to 01/02/2103
|
||||
And I have added a new subsection
|
||||
And I click on the subsection
|
||||
And I set the subsection release date to 01/20/2103
|
||||
And I reload the page
|
||||
And I click the link to sync release date to section
|
||||
And I wait for "1" second
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 01/02/2103
|
||||
|
||||
@@ -41,8 +41,8 @@ def i_save_subsection_name_with_quote(step):
|
||||
save_subsection_name('Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I click to edit the subsection name$')
|
||||
def i_click_to_edit_subsection_name(step):
|
||||
@step('I click on the subsection$')
|
||||
def click_on_subsection(step):
|
||||
world.css_click('span.subsection-name-value')
|
||||
|
||||
|
||||
@@ -53,12 +53,28 @@ def i_see_complete_subsection_name_with_quote_in_editor(step):
|
||||
assert_equal(world.css_value(css), 'Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I have set a release date and due date in different years$')
|
||||
def test_have_set_dates_in_different_years(step):
|
||||
set_date_and_time('input#start_date', '12/25/2011', 'input#start_time', '03:00')
|
||||
world.css_click('.set-date')
|
||||
# Use a year in the past so that current year will always be different.
|
||||
set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00')
|
||||
@step('I set the subsection release date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_release_date(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
set_date_and_time(
|
||||
'input#start_date', datestring,
|
||||
'input#start_time', timestring)
|
||||
|
||||
|
||||
@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_due_date(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
if not world.css_visible('input#due_date'):
|
||||
world.css_click('.due-date-input .set-date')
|
||||
set_date_and_time(
|
||||
'input#due_date', datestring,
|
||||
'input#due_time', timestring)
|
||||
|
||||
|
||||
@step('I mark it as Homework$')
|
||||
@@ -72,6 +88,11 @@ def i_see_it_marked__as_homework(step):
|
||||
assert_equal(world.css_value(".status-label"), 'Homework')
|
||||
|
||||
|
||||
@step('I click the link to sync release date to section')
|
||||
def click_sync_release_date(step):
|
||||
world.css_click('.sync-date')
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
|
||||
@@ -91,16 +112,25 @@ def the_subsection_does_not_exist(step):
|
||||
assert world.browser.is_element_not_present_by_css(css)
|
||||
|
||||
|
||||
@step('I see the correct dates$')
|
||||
def i_see_the_correct_dates(step):
|
||||
assert_equal('12/25/2011', get_date('input#start_date'))
|
||||
assert_equal('03:00', get_date('input#start_time'))
|
||||
assert_equal('01/02/2012', get_date('input#due_date'))
|
||||
assert_equal('04:00', get_date('input#due_time'))
|
||||
@step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?')
|
||||
def i_see_subsection_release(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
assert_equal(datestring, get_date('input#start_date'))
|
||||
if timestring:
|
||||
assert_equal(timestring, get_date('input#start_time'))
|
||||
|
||||
|
||||
@step('I see the subsection due date is ([0-9/-]+)( [0-9:]+)?')
|
||||
def i_see_subsection_due(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
assert_equal(datestring, get_date('input#due_date'))
|
||||
if timestring:
|
||||
assert_equal(timestring, get_date('input#due_time'))
|
||||
|
||||
|
||||
############ HELPER METHODS ###################
|
||||
|
||||
def get_date(css):
|
||||
return world.css_find(css).first.value.strip()
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
@step(u'I go to the textbooks page')
|
||||
def go_to_uploads(_step):
|
||||
world.click_course_content()
|
||||
menu_css = 'li.nav-course-courseware-textbooks'
|
||||
world.css_find(menu_css).click()
|
||||
menu_css = 'li.nav-course-courseware-textbooks a'
|
||||
world.css_click(menu_css)
|
||||
|
||||
|
||||
@step(u'I should see a message telling me to create a new textbook')
|
||||
@@ -45,6 +45,8 @@ def click_new_textbook(_step, on):
|
||||
def name_textbook(_step, name):
|
||||
input_css = ".textbook input[name=textbook-name]"
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I name the (first|second|third) chapter "([^"]*)"')
|
||||
@@ -52,6 +54,8 @@ def name_chapter(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1)
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I type in "([^"]*)" for the (first|second|third) chapter asset')
|
||||
@@ -59,6 +63,8 @@ def asset_chapter(_step, name, ordinal):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1)
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I click the Upload Asset link for the (first|second|third) chapter')
|
||||
|
||||
@@ -9,13 +9,11 @@ import random
|
||||
import os
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
HTTP_PREFIX = "http://localhost:%s" % settings.LETTUCE_SERVER_PORT
|
||||
|
||||
|
||||
@step(u'I go to the files and uploads page')
|
||||
def go_to_uploads(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
uploads_css = 'li.nav-course-courseware-uploads'
|
||||
uploads_css = 'li.nav-course-courseware-uploads a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(uploads_css)
|
||||
|
||||
@@ -24,13 +22,10 @@ def go_to_uploads(_step):
|
||||
def upload_file(_step, file_name):
|
||||
upload_css = 'a.upload-button'
|
||||
world.css_click(upload_css)
|
||||
|
||||
file_css = 'input.file-input'
|
||||
upload = world.css_find(file_css)
|
||||
#uploading the file itself
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
upload._element.send_keys(os.path.abspath(path))
|
||||
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
close_css = 'a.close-button'
|
||||
world.css_click(close_css)
|
||||
|
||||
@@ -80,6 +75,9 @@ def check_download(_step, file_name):
|
||||
r = get_file(file_name)
|
||||
downloaded_text = r.text
|
||||
assert cur_text == downloaded_text
|
||||
#resetting the file back to its original state
|
||||
with open(os.path.abspath(path), 'w') as cur_file:
|
||||
cur_file.write("This is an arbitrary file for testing uploads")
|
||||
|
||||
|
||||
@step(u'I modify "([^"]*)"$')
|
||||
@@ -109,6 +107,8 @@ def get_file(file_name):
|
||||
index = get_index(file_name)
|
||||
assert index != -1
|
||||
|
||||
url_css = 'input.embeddable-xml-input'
|
||||
url = world.css_find(url_css)[index].value
|
||||
return requests.get(HTTP_PREFIX + url)
|
||||
url_css = 'a.filename'
|
||||
def get_url():
|
||||
return world.css_find(url_css)[index]._element.get_attribute('href')
|
||||
url = world.retry_on_exception(get_url)
|
||||
return requests.get(url)
|
||||
|
||||
@@ -22,3 +22,19 @@ def set_show_captions(step, setting):
|
||||
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
|
||||
world.browser.select('Show Captions', setting)
|
||||
world.css_click('a.save-button')
|
||||
|
||||
|
||||
@step('I see the correct videoalpha settings and default values$')
|
||||
def correct_videoalpha_settings(_step):
|
||||
world.verify_all_setting_entries([['Display Name', 'Video Alpha', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['End Time', '0', False],
|
||||
['HTML5 Subtitles', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['Start Time', '0', False],
|
||||
['Video Sources', '', False],
|
||||
['Youtube ID', 'OEoXaMPEzfM', False],
|
||||
['Youtube ID for .75x speed', '', False],
|
||||
['Youtube ID for 1.25x speed', '', False],
|
||||
['Youtube ID for 1.5x speed', '', False]])
|
||||
|
||||
@@ -22,3 +22,33 @@ Feature: Video Component
|
||||
Given I have created a Video component
|
||||
And I have toggled captions
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
# Video Alpha Features will work in Firefox only when Firefox is the active window
|
||||
Scenario: Autoplay is disabled in Studio for Video Alpha
|
||||
Given I have created a Video Alpha component
|
||||
Then when I view the videoalpha it does not have autoplay enabled
|
||||
|
||||
Scenario: User can view Video Alpha metadata
|
||||
Given I have created a Video Alpha component
|
||||
And I edit the component
|
||||
Then I see the correct videoalpha settings and default values
|
||||
|
||||
Scenario: User can modify Video Alpha display name
|
||||
Given I have created a Video Alpha component
|
||||
And I edit the component
|
||||
Then I can modify the display name
|
||||
And my videoalpha display name change is persisted on save
|
||||
|
||||
Scenario: Video Alpha captions are hidden when "show captions" is false
|
||||
Given I have created a Video Alpha component
|
||||
And I have set "show captions" to False
|
||||
Then when I view the videoalpha it does not show the captions
|
||||
|
||||
Scenario: Video Alpha captions are shown when "show captions" is true
|
||||
Given I have created a Video Alpha component
|
||||
And I have set "show captions" to True
|
||||
Then when I view the videoalpha it does show the captions
|
||||
|
||||
Scenario: Video data is shown correctly
|
||||
Given I have created a video with only XML data
|
||||
Then the correct Youtube video is shown
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('when I view the video it does not have autoplay enabled')
|
||||
def does_not_autoplay(_step):
|
||||
assert world.css_find('.video')[0]['data-autoplay'] == 'False'
|
||||
@step('when I view the (.*) it does not have autoplay enabled')
|
||||
def does_not_autoplay(_step, video_type):
|
||||
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
|
||||
assert world.css_has_class('.video_control', 'play')
|
||||
|
||||
|
||||
@@ -29,5 +33,55 @@ def hide_or_show_captions(step, shown):
|
||||
# click the button rather than the tooltip, so move the mouse
|
||||
# away to make it disappear.
|
||||
button = world.css_find(button_css)
|
||||
button.mouse_out()
|
||||
# mouse_out is not implemented on firefox with selenium
|
||||
if not world.is_firefox:
|
||||
button.mouse_out()
|
||||
world.css_click(button_css)
|
||||
|
||||
@step('I edit the component')
|
||||
def i_edit_the_component(_step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step('my videoalpha display name change is persisted on save')
|
||||
def videoalpha_name_persisted(step):
|
||||
world.css_click('a.save-button')
|
||||
reload_the_page(step)
|
||||
world.edit_component()
|
||||
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)
|
||||
|
||||
|
||||
@step('I have created a video with only XML data')
|
||||
def xml_only_video(step):
|
||||
# Create a new video *without* metadata. This requires a certain
|
||||
# amount of rummaging to make sure all the correct data is present
|
||||
step.given('I have clicked the new unit button')
|
||||
|
||||
# Wait for the new unit to be created and to load the page
|
||||
world.wait(1)
|
||||
|
||||
location = world.scenario_dict['COURSE'].location
|
||||
store = get_modulestore(location)
|
||||
|
||||
parent_location = store.get_items(Location(category='vertical', revision='draft'))[0].location
|
||||
|
||||
youtube_id = 'ABCDEFG'
|
||||
world.scenario_dict['YOUTUBE_ID'] = youtube_id
|
||||
|
||||
# Create a new Video component, but ensure that it doesn't have
|
||||
# metadata. This allows us to test that we are correctly parsing
|
||||
# out XML
|
||||
video = world.ItemFactory.create(
|
||||
parent_location=parent_location,
|
||||
category='video',
|
||||
data='<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
|
||||
#
|
||||
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3
|
||||
#
|
||||
from request_cache.middleware import RequestCache
|
||||
from django.core.cache import get_cache
|
||||
|
||||
#
|
||||
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
|
||||
#
|
||||
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Clone a MongoDB-backed course to another location"""
|
||||
@@ -21,19 +28,27 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) != 2:
|
||||
raise CommandError("clone requires two arguments: <source-location> <dest-location>")
|
||||
raise CommandError("clone requires two arguments: <source-course_id> <dest-course_id>")
|
||||
|
||||
source_location_str = args[0]
|
||||
dest_location_str = args[1]
|
||||
source_course_id = args[0]
|
||||
dest_course_id = args[1]
|
||||
|
||||
mstore = modulestore('direct')
|
||||
cstore = contentstore()
|
||||
|
||||
print("Cloning course {0} to {1}".format(source_location_str, dest_location_str))
|
||||
mstore.metadata_inheritance_cache_subsystem = CACHE
|
||||
mstore.request_cache = RequestCache.get_request_cache()
|
||||
org, course_num, run = dest_course_id.split("/")
|
||||
mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
source_location = CourseDescriptor.id_to_location(source_location_str)
|
||||
dest_location = CourseDescriptor.id_to_location(dest_location_str)
|
||||
print("Cloning course {0} to {1}".format(source_course_id, dest_course_id))
|
||||
|
||||
source_location = CourseDescriptor.id_to_location(source_course_id)
|
||||
dest_location = CourseDescriptor.id_to_location(dest_course_id)
|
||||
|
||||
if clone_course(mstore, cstore, source_location, dest_location):
|
||||
# be sure to recompute metadata inheritance after all those updates
|
||||
mstore.refresh_cached_metadata_inheritance_tree(dest_location)
|
||||
|
||||
print("copying User permissions...")
|
||||
_copy_course_group(source_location, dest_location)
|
||||
@@ -9,12 +9,14 @@ from xmodule.course_module import CourseDescriptor
|
||||
from .prompt import query_yes_no
|
||||
|
||||
from auth.authz import _delete_course_group
|
||||
from request_cache.middleware import RequestCache
|
||||
from django.core.cache import get_cache
|
||||
|
||||
#
|
||||
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
|
||||
#
|
||||
|
||||
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
class Command(BaseCommand):
|
||||
help = '''Delete a MongoDB backed course'''
|
||||
|
||||
@@ -22,7 +24,7 @@ class Command(BaseCommand):
|
||||
if len(args) != 1 and len(args) != 2:
|
||||
raise CommandError("delete_course requires one or more arguments: <location> |commit|")
|
||||
|
||||
loc_str = args[0]
|
||||
course_id = args[0]
|
||||
|
||||
commit = False
|
||||
if len(args) == 2:
|
||||
@@ -34,9 +36,14 @@ class Command(BaseCommand):
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
|
||||
ms.metadata_inheritance_cache_subsystem = CACHE
|
||||
ms.request_cache = RequestCache.get_request_cache()
|
||||
org, course_num, run = course_id.split("/")
|
||||
ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(course_id), default="no"):
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
loc = CourseDescriptor.id_to_location(loc_str)
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
if delete_course(ms, cs, loc, commit):
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
|
||||
@@ -10,6 +10,8 @@ from unittest import TestCase, skip
|
||||
from .utils import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from contentstore.views import assets
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
|
||||
class AssetsTestCase(CourseTestCase):
|
||||
@@ -35,6 +37,11 @@ class AssetsTestCase(CourseTestCase):
|
||||
content = json.loads(resp.content)
|
||||
self.assertIsInstance(content, list)
|
||||
|
||||
def test_static_url_generation(self):
|
||||
location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg'])
|
||||
path = StaticContent.get_static_path_from_location(location)
|
||||
self.assertEquals(path, '/static/my_file_name.jpg')
|
||||
|
||||
|
||||
class UploadTestCase(CourseTestCase):
|
||||
"""
|
||||
|
||||
@@ -49,7 +49,7 @@ import datetime
|
||||
from pytz import UTC
|
||||
from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
|
||||
from student.views import is_enrolled_in_course
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
@@ -303,6 +303,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
num_drafts = self._get_draft_counts(course)
|
||||
self.assertEqual(num_drafts, 1)
|
||||
|
||||
def test_no_static_link_rewrites_on_import(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course_info', 'handouts', None]))
|
||||
self.assertIn('/static/', handouts.data)
|
||||
|
||||
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None]))
|
||||
self.assertIn('/static/', handouts.data)
|
||||
|
||||
def test_import_textbook_as_content_element(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
@@ -607,8 +617,26 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
}
|
||||
|
||||
module_store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
source_course_id = 'edX/toy/2012_Fall'
|
||||
dest_course_id = 'MITx/999/2013_Spring'
|
||||
source_location = CourseDescriptor.id_to_location(source_course_id)
|
||||
dest_location = CourseDescriptor.id_to_location(dest_course_id)
|
||||
|
||||
# get a vertical (and components in it) to put into 'draft'
|
||||
# this is to assert that draft content is also cloned over
|
||||
vertical = module_store.get_instance(source_course_id, Location([
|
||||
source_location.tag, source_location.org, source_location.course, 'vertical', 'vertical_test', None]), depth=1)
|
||||
|
||||
draft_store.convert_to_draft(vertical.location)
|
||||
for child in vertical.get_children():
|
||||
draft_store.convert_to_draft(child.location)
|
||||
|
||||
items = module_store.get_items(Location([source_location.tag, source_location.org, source_location.course, None, None, 'draft']))
|
||||
self.assertGreater(len(items), 0)
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
@@ -616,22 +644,105 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
content_store = contentstore()
|
||||
|
||||
source_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
dest_location = CourseDescriptor.id_to_location('MITx/999/2013_Spring')
|
||||
|
||||
# now do the actual cloning
|
||||
clone_course(module_store, content_store, source_location, dest_location)
|
||||
|
||||
# first assert that all draft content got cloned as well
|
||||
items = module_store.get_items(Location([source_location.tag, source_location.org, source_location.course, None, None, 'draft']))
|
||||
self.assertGreater(len(items), 0)
|
||||
clone_items = module_store.get_items(Location([dest_location.tag, dest_location.org, dest_location.course, None, None, 'draft']))
|
||||
self.assertGreater(len(clone_items), 0)
|
||||
self.assertEqual(len(items), len(clone_items))
|
||||
|
||||
# now loop through all the units in the course and verify that the clone can render them, which
|
||||
# means the objects are at least present
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'toy', 'poll_question', None]))
|
||||
items = module_store.get_items(Location([source_location.tag, source_location.org, source_location.course, None, None]))
|
||||
self.assertGreater(len(items), 0)
|
||||
clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'poll_question', None]))
|
||||
clone_items = module_store.get_items(Location([dest_location.tag, dest_location.org, dest_location.course, None, None]))
|
||||
self.assertGreater(len(clone_items), 0)
|
||||
|
||||
for descriptor in items:
|
||||
new_loc = descriptor.location.replace(org='MITx', course='999')
|
||||
source_item = module_store.get_instance(source_course_id, descriptor.location)
|
||||
if descriptor.location.category == 'course':
|
||||
new_loc = descriptor.location.replace(org=dest_location.org, course=dest_location.course, name='2013_Spring')
|
||||
else:
|
||||
new_loc = descriptor.location.replace(org=dest_location.org, course=dest_location.course)
|
||||
print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url())
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
lookup_item = module_store.get_item(new_loc)
|
||||
|
||||
# we want to assert equality between the objects, but we know the locations
|
||||
# differ, so just make them equal for testing purposes
|
||||
source_item.location = new_loc
|
||||
if hasattr(source_item, 'data') and hasattr(lookup_item, 'data'):
|
||||
self.assertEqual(source_item.data, lookup_item.data)
|
||||
|
||||
# also make sure that metadata was cloned over and filtered with own_metadata, i.e. inherited
|
||||
# values were not explicitly set
|
||||
self.assertEqual(own_metadata(source_item), own_metadata(lookup_item))
|
||||
|
||||
# check that the children are as expected
|
||||
self.assertEqual(source_item.has_children, lookup_item.has_children)
|
||||
if source_item.has_children:
|
||||
expected_children = []
|
||||
for child_loc_url in source_item.children:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(
|
||||
tag=dest_location.tag,
|
||||
org=dest_location.org,
|
||||
course=dest_location.course
|
||||
)
|
||||
expected_children.append(child_loc.url())
|
||||
self.assertEqual(expected_children, lookup_item.children)
|
||||
|
||||
def test_portable_link_rewrites_during_clone_course(self):
|
||||
course_data = {
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
'run': '2013_Spring'
|
||||
}
|
||||
|
||||
module_store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
source_course_id = 'edX/toy/2012_Fall'
|
||||
dest_course_id = 'MITx/999/2013_Spring'
|
||||
source_location = CourseDescriptor.id_to_location(source_course_id)
|
||||
dest_location = CourseDescriptor.id_to_location(dest_course_id)
|
||||
|
||||
# let's force a non-portable link in the clone source
|
||||
# as a final check, make sure that any non-portable links are rewritten during cloning
|
||||
html_module_location = Location([
|
||||
source_location.tag, source_location.org, source_location.course, 'html', 'nonportable'])
|
||||
html_module = module_store.get_instance(source_location.course_id, html_module_location)
|
||||
|
||||
self.assertTrue(isinstance(html_module.data, basestring))
|
||||
new_data = html_module.data.replace('/static/', '/c4x/{0}/{1}/asset/'.format(
|
||||
source_location.org, source_location.course))
|
||||
module_store.update_item(html_module_location, new_data)
|
||||
|
||||
html_module = module_store.get_instance(source_location.course_id, html_module_location)
|
||||
self.assertEqual(new_data, html_module.data)
|
||||
|
||||
# create the destination course
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/2013_Spring')
|
||||
|
||||
# do the actual cloning
|
||||
clone_course(module_store, content_store, source_location, dest_location)
|
||||
|
||||
# make sure that any non-portable links are rewritten during cloning
|
||||
html_module_location = Location([
|
||||
dest_location.tag, dest_location.org, dest_location.course, 'html', 'nonportable'])
|
||||
html_module = module_store.get_instance(dest_location.course_id, html_module_location)
|
||||
|
||||
self.assertIn('/static/foo.jpg', html_module.data)
|
||||
|
||||
def test_illegal_draft_crud_ops(self):
|
||||
draft_store = modulestore('draft')
|
||||
@@ -660,6 +771,22 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_rewrite_nonportable_links_on_import(self):
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
|
||||
# first check a static asset link
|
||||
html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable'])
|
||||
html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location)
|
||||
self.assertIn('/static/foo.jpg', html_module.data)
|
||||
|
||||
# then check a intra courseware link
|
||||
html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable_link'])
|
||||
html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location)
|
||||
self.assertIn('/jump_to_id/nonportable_link', html_module.data)
|
||||
|
||||
def test_delete_course(self):
|
||||
"""
|
||||
This test will import a course, make a draft item, and delete it. This will also assert that the
|
||||
@@ -1041,12 +1168,18 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
data = parse_json(resp)
|
||||
self.assertNotIn('ErrMsg', data)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/{0}/course/2013_Spring'.format(test_course_data['number']))
|
||||
# Verify that the creator is now registered in the course.
|
||||
self.assertTrue(is_enrolled_in_course(self.user, self._get_course_id(test_course_data)))
|
||||
return test_course_data
|
||||
|
||||
def test_create_course_check_forum_seeding(self):
|
||||
"""Test new course creation and verify forum seeding """
|
||||
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
self.assertTrue(are_permissions_roles_seeded('MITx/{0}/2013_Spring'.format(test_course_data['number'])))
|
||||
self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data)))
|
||||
|
||||
def _get_course_id(self, test_course_data):
|
||||
"""Returns the course ID (org/number/run)."""
|
||||
return "{org}/{number}/{run}".format(**test_course_data)
|
||||
|
||||
def test_create_course_duplicate_course(self):
|
||||
"""Test new course creation - error path"""
|
||||
@@ -1057,10 +1190,15 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Checks that the course did not get created
|
||||
"""
|
||||
course_id = self._get_course_id(self.course_data)
|
||||
initially_enrolled = is_enrolled_in_course(self.user, course_id)
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['ErrMsg'], error_message)
|
||||
# One test case involves trying to create the same course twice. Hence for that course,
|
||||
# the user will be enrolled. In the other cases, initially_enrolled will be False.
|
||||
self.assertEqual(initially_enrolled, is_enrolled_in_course(self.user, course_id))
|
||||
|
||||
def test_create_course_duplicate_number(self):
|
||||
"""Test new course creation - error path"""
|
||||
@@ -1141,7 +1279,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(
|
||||
resp,
|
||||
'<span class="class-name">Robot Super Educational Course</span>',
|
||||
'<h3 class="course-title">Robot Super Educational Course</h3>',
|
||||
status_code=200,
|
||||
html=True
|
||||
)
|
||||
@@ -1313,6 +1451,31 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
def test_import_into_new_course_id(self):
|
||||
module_store = modulestore('direct')
|
||||
target_location = Location(['i4x', 'MITx', '999', 'course', '2013_Spring'])
|
||||
|
||||
course_data = {
|
||||
'org': target_location.org,
|
||||
'number': target_location.course,
|
||||
'display_name': 'Robot Super Course',
|
||||
'run': target_location.name
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], target_location.url())
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['simple'], target_location_namespace=target_location)
|
||||
|
||||
modules = module_store.get_items(Location([
|
||||
target_location.tag, target_location.org, target_location.course, None, None, None]))
|
||||
|
||||
# we should have a number of modules in there
|
||||
# we can't specify an exact number since it'll always be changing
|
||||
self.assertGreater(len(modules), 10)
|
||||
|
||||
def test_import_metadata_with_attempts_empty_string(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['simple'])
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
Tests for contentstore/views/user.py.
|
||||
"""
|
||||
import json
|
||||
from .utils import CourseTestCase
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.core.urlresolvers import reverse
|
||||
from auth.authz import get_course_groupname_for_role
|
||||
from student.views import is_enrolled_in_course
|
||||
|
||||
|
||||
class UsersTestCase(CourseTestCase):
|
||||
@@ -90,6 +94,7 @@ class UsersTestCase(CourseTestCase):
|
||||
# no content: should not be in any roles
|
||||
self.assertNotIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_detail_post_staff(self):
|
||||
resp = self.client.post(
|
||||
@@ -104,6 +109,7 @@ class UsersTestCase(CourseTestCase):
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_detail_post_staff_other_inst(self):
|
||||
inst_group, _ = Group.objects.get_or_create(name=self.inst_groupname)
|
||||
@@ -122,6 +128,7 @@ class UsersTestCase(CourseTestCase):
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
# check that other user is unchanged
|
||||
user = User.objects.get(email=self.user.email)
|
||||
groups = [g.name for g in user.groups.all()]
|
||||
@@ -141,6 +148,7 @@ class UsersTestCase(CourseTestCase):
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertNotIn(self.staff_groupname, groups)
|
||||
self.assertIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_detail_post_missing_role(self):
|
||||
resp = self.client.post(
|
||||
@@ -152,6 +160,7 @@ class UsersTestCase(CourseTestCase):
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_detail_post_bad_json(self):
|
||||
resp = self.client.post(
|
||||
@@ -163,6 +172,7 @@ class UsersTestCase(CourseTestCase):
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_detail_post_no_json(self):
|
||||
resp = self.client.post(
|
||||
@@ -176,6 +186,7 @@ class UsersTestCase(CourseTestCase):
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_detail_delete_staff(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
|
||||
@@ -317,3 +328,57 @@ class UsersTestCase(CourseTestCase):
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
|
||||
def test_user_not_initially_enrolled(self):
|
||||
# Verify that ext_user is not enrolled in the new course before being added as a staff member.
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_remove_staff_does_not_unenroll(self):
|
||||
# Add user with staff permissions.
|
||||
self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "staff"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert_enrolled()
|
||||
# Remove user from staff on course. Will not un-enroll them from the course.
|
||||
resp = self.client.delete(
|
||||
self.detail_url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_staff_to_instructor_still_enrolled(self):
|
||||
# Add user with staff permission.
|
||||
self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "staff"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert_enrolled()
|
||||
# Now add with instructor permission. Verify still enrolled.
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "instructor"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
self.assert_enrolled()
|
||||
|
||||
def assert_not_enrolled(self):
|
||||
""" Asserts that self.ext_user is not enrolled in self.course. """
|
||||
self.assertFalse(
|
||||
is_enrolled_in_course(self.ext_user, self.course.location.course_id),
|
||||
'Did not expect ext_user to be enrolled in course'
|
||||
)
|
||||
|
||||
def assert_enrolled(self):
|
||||
""" Asserts that self.ext_user is enrolled in self.course. """
|
||||
self.assertTrue(
|
||||
is_enrolled_in_course(self.ext_user, self.course.location.course_id),
|
||||
'User ext_user should have been enrolled in the course'
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.test.client import Client
|
||||
from django.core.cache import cache
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from .utils import parse_json, user, registration
|
||||
@@ -79,6 +80,8 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
self.pw = 'xyz'
|
||||
self.username = 'testuser'
|
||||
self.client = Client()
|
||||
# clear the cache so ratelimiting won't affect these tests
|
||||
cache.clear()
|
||||
|
||||
def check_page_get(self, url, expected):
|
||||
resp = self.client.get(url)
|
||||
@@ -119,6 +122,18 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
# Now login should work
|
||||
self.login(self.email, self.pw)
|
||||
|
||||
def test_login_ratelimited(self):
|
||||
# try logging in 30 times, the default limit in the number of failed
|
||||
# login attempts in one 5 minute period before the rate gets limited
|
||||
for i in xrange(30):
|
||||
resp = self._login(self.email, 'wrong_password{0}'.format(i))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp = self._login(self.email, 'wrong_password')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('Too many failed login attempts.', data['value'])
|
||||
|
||||
def test_login_link_on_activation_age(self):
|
||||
self.create_account(self.username, self.email, self.pw)
|
||||
# we want to test the rendering of the activation page when the user isn't logged in
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import os
|
||||
import tarfile
|
||||
import shutil
|
||||
import cgi
|
||||
from tempfile import mkdtemp
|
||||
from path import path
|
||||
|
||||
@@ -27,7 +28,7 @@ from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore import InvalidLocationError
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.exceptions import NotFoundError, SerializationError
|
||||
|
||||
from .access import get_location_and_verify_access
|
||||
from util.json_request import JsonResponse
|
||||
@@ -105,6 +106,7 @@ def asset_index(request, org, course, name):
|
||||
|
||||
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
display_info['portable_url'] = StaticContent.get_static_path_from_location(asset_location)
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
@@ -187,12 +189,12 @@ def upload_asset(request, org, course, coursename):
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'portable_url': StaticContent.get_static_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = JsonResponse(response_payload)
|
||||
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
|
||||
return response
|
||||
|
||||
|
||||
@@ -313,6 +315,8 @@ def import_course(request, org, course, name):
|
||||
|
||||
create_all_course_groups(request.user, course_items[0].location)
|
||||
|
||||
logging.debug('created all course groups at {0}'.format(course_items[0].location))
|
||||
|
||||
return HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
else:
|
||||
course_module = modulestore().get_item(location)
|
||||
@@ -335,16 +339,59 @@ def generate_export_course(request, org, course, name):
|
||||
the course
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_instance(location.course_id, location)
|
||||
loc = Location(location)
|
||||
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
|
||||
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
try:
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
except SerializationError, e:
|
||||
unit = None
|
||||
failed_item = None
|
||||
parent = None
|
||||
try:
|
||||
failed_item = modulestore().get_instance(course_module.location.course_id, e.location)
|
||||
parent_locs = modulestore().get_parent_locations(failed_item.location, course_module.location.course_id)
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
if len(parent_locs) > 0:
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
if parent.location.category == 'vertical':
|
||||
unit = parent
|
||||
except:
|
||||
# if we have a nested exception, then we'll show the more generic error message
|
||||
pass
|
||||
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'successful_import_redirect_url': '',
|
||||
'in_err': True,
|
||||
'raw_err_msg': str(e),
|
||||
'failed_module': failed_item,
|
||||
'unit': unit,
|
||||
'edit_unit_url': reverse('edit_unit', kwargs={
|
||||
'location': parent.location
|
||||
}) if parent else '',
|
||||
'course_home_url': reverse('course_index', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'name': name
|
||||
})
|
||||
})
|
||||
except Exception, e:
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'successful_import_redirect_url': '',
|
||||
'in_err': True,
|
||||
'unit': None,
|
||||
'raw_err_msg': str(e),
|
||||
'course_home_url': reverse('course_index', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'name': name
|
||||
})
|
||||
})
|
||||
|
||||
logging.debug('tar file being generated at {0}'.format(export_file.name))
|
||||
tar_file = tarfile.open(name=export_file.name, mode='w:gz')
|
||||
|
||||
@@ -44,6 +44,8 @@ from .component import (
|
||||
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
from student.views import enroll_in_course
|
||||
|
||||
from xmodule.html_module import AboutDescriptor
|
||||
__all__ = ['course_index', 'create_new_course', 'course_info',
|
||||
'course_info_updates', 'get_course_settings',
|
||||
@@ -162,6 +164,9 @@ def create_new_course(request):
|
||||
# seed the forums
|
||||
seed_permissions_roles(new_course.location.course_id)
|
||||
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(request.user, new_course.location.course_id)
|
||||
|
||||
return JsonResponse({'id': new_course.location.url()})
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.core.context_processors import csrf
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from contentstore.utils import get_lms_link_for_item
|
||||
from util.json_request import JsonResponse
|
||||
from auth.authz import (
|
||||
@@ -23,6 +24,8 @@ from course_creators.views import (
|
||||
|
||||
from .access import has_access
|
||||
|
||||
from student.views import enroll_in_course
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -54,10 +57,13 @@ def index(request):
|
||||
course.location,
|
||||
course_id=course.location.course_id,
|
||||
),
|
||||
course.display_org_with_default,
|
||||
course.display_number_with_default,
|
||||
course.location.name
|
||||
)
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'courses': [format_course_for_view(c) for c in courses],
|
||||
'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)],
|
||||
'user': request.user,
|
||||
'request_course_creator_url': reverse('request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
@@ -179,7 +185,7 @@ def course_team_user(request, org, course, name, email):
|
||||
return JsonResponse()
|
||||
|
||||
# all other operations require the requesting user to specify a role
|
||||
if request.META.get("CONTENT_TYPE", "") == "application/json" and request.body:
|
||||
if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body:
|
||||
try:
|
||||
payload = json.loads(request.body)
|
||||
except:
|
||||
@@ -201,6 +207,8 @@ def course_team_user(request, org, course, name, email):
|
||||
return JsonResponse(msg, 400)
|
||||
user.groups.add(groups["instructor"])
|
||||
user.save()
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(user, location.course_id)
|
||||
elif role == "staff":
|
||||
# if we're trying to downgrade a user from "instructor" to "staff",
|
||||
# make sure we have at least one other instructor in the course team.
|
||||
@@ -214,6 +222,9 @@ def course_team_user(request, org, course, name, email):
|
||||
user.groups.remove(groups["instructor"])
|
||||
user.groups.add(groups["staff"])
|
||||
user.save()
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(user, location.course_id)
|
||||
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
django admin page for the course creators table
|
||||
"""
|
||||
|
||||
from course_creators.models import CourseCreator, update_creator_state
|
||||
from course_creators.models import CourseCreator, update_creator_state, send_user_notification, send_admin_notification
|
||||
from course_creators.views import update_course_creator_group
|
||||
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from django.core.mail import send_mail
|
||||
from smtplib import SMTPException
|
||||
|
||||
import logging
|
||||
|
||||
@@ -28,12 +30,12 @@ class CourseCreatorAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
|
||||
# Fields to display on the overview page.
|
||||
list_display = ['user', get_email, 'state', 'state_changed', 'note']
|
||||
readonly_fields = ['user', 'state_changed']
|
||||
list_display = ['username', get_email, 'state', 'state_changed', 'note']
|
||||
readonly_fields = ['username', 'state_changed']
|
||||
# Controls the order on the edit form (without this, read-only fields appear at the end).
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ['user', 'state', 'state_changed', 'note']
|
||||
'fields': ['username', 'state', 'state_changed', 'note']
|
||||
}),
|
||||
)
|
||||
# Fields that filtering support
|
||||
@@ -43,6 +45,16 @@ class CourseCreatorAdmin(admin.ModelAdmin):
|
||||
# Turn off the action bar (we have no bulk actions)
|
||||
actions = None
|
||||
|
||||
def username(self, inst):
|
||||
"""
|
||||
Returns the username for a given user.
|
||||
|
||||
Implemented to make sorting by username instead of by user object.
|
||||
"""
|
||||
return inst.user.username
|
||||
|
||||
username.admin_order_field = 'user__username'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
@@ -70,7 +82,16 @@ def update_creator_group_callback(sender, **kwargs):
|
||||
updated_state = kwargs['state']
|
||||
update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED)
|
||||
|
||||
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL','')
|
||||
|
||||
@receiver(send_user_notification, sender=CourseCreator)
|
||||
def send_user_notification_callback(sender, **kwargs):
|
||||
"""
|
||||
Callback for notifying user about course creator status change.
|
||||
"""
|
||||
user = kwargs['user']
|
||||
updated_state = kwargs['state']
|
||||
|
||||
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '')
|
||||
context = {'studio_request_email': studio_request_email}
|
||||
|
||||
subject = render_to_string('emails/course_creator_subject.txt', context)
|
||||
@@ -88,3 +109,29 @@ def update_creator_group_callback(sender, **kwargs):
|
||||
user.email_user(subject, message, studio_request_email)
|
||||
except:
|
||||
log.warning("Unable to send course creator status e-mail to %s", user.email)
|
||||
|
||||
|
||||
@receiver(send_admin_notification, sender=CourseCreator)
|
||||
def send_admin_notification_callback(sender, **kwargs):
|
||||
"""
|
||||
Callback for notifying admin of a user in the 'pending' state.
|
||||
"""
|
||||
user = kwargs['user']
|
||||
|
||||
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '')
|
||||
context = {'user_name': user.username, 'user_email': user.email}
|
||||
|
||||
subject = render_to_string('emails/course_creator_admin_subject.txt', context)
|
||||
subject = ''.join(subject.splitlines())
|
||||
message = render_to_string('emails/course_creator_admin_user_pending.txt', context)
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject,
|
||||
message,
|
||||
studio_request_email,
|
||||
[studio_request_email],
|
||||
fail_silently=False
|
||||
)
|
||||
except SMTPException:
|
||||
log.warning("Failure sending 'pending state' e-mail for %s to %s", user.email, studio_request_email)
|
||||
|
||||
@@ -10,7 +10,13 @@ from django.utils import timezone
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
# A signal that will be sent when users should be added or removed from the creator group
|
||||
update_creator_state = Signal(providing_args=["caller", "user", "add"])
|
||||
update_creator_state = Signal(providing_args=["caller", "user", "state"])
|
||||
|
||||
# A signal that will be sent when admin should be notified of a pending user request
|
||||
send_admin_notification = Signal(providing_args=["user"])
|
||||
|
||||
# A signal that will be sent when user should be notified of change in course creator privileges
|
||||
send_user_notification = Signal(providing_args=["user", "state"])
|
||||
|
||||
|
||||
class CourseCreator(models.Model):
|
||||
@@ -60,9 +66,10 @@ def post_save_callback(sender, **kwargs):
|
||||
# We only wish to modify the state_changed time if the state has been modified. We don't wish to
|
||||
# modify it for changes to the notes field.
|
||||
if instance.state != instance.orig_state:
|
||||
granted_state_change = instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED
|
||||
# If either old or new state is 'granted', we must manipulate the course creator
|
||||
# group maintained by authz. That requires staff permissions (stored admin).
|
||||
if instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED:
|
||||
if granted_state_change:
|
||||
assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group'
|
||||
update_creator_state.send(
|
||||
sender=sender,
|
||||
@@ -71,6 +78,22 @@ def post_save_callback(sender, **kwargs):
|
||||
state=instance.state
|
||||
)
|
||||
|
||||
# If user has been denied access, granted access, or previously granted access has been
|
||||
# revoked, send a notification message to the user.
|
||||
if instance.state == CourseCreator.DENIED or granted_state_change:
|
||||
send_user_notification.send(
|
||||
sender=sender,
|
||||
user=instance.user,
|
||||
state=instance.state
|
||||
)
|
||||
|
||||
# If the user has gone into the 'pending' state, send a notification to interested admin.
|
||||
if instance.state == CourseCreator.PENDING:
|
||||
send_admin_notification.send(
|
||||
sender=sender,
|
||||
user=instance.user
|
||||
)
|
||||
|
||||
instance.state_changed = timezone.now()
|
||||
instance.orig_state = instance.state
|
||||
instance.save()
|
||||
|
||||
@@ -11,6 +11,7 @@ import mock
|
||||
from course_creators.admin import CourseCreatorAdmin
|
||||
from course_creators.models import CourseCreator
|
||||
from auth.authz import is_user_in_creator_group
|
||||
from django.core import mail
|
||||
|
||||
|
||||
def mock_render_to_string(template_name, context):
|
||||
@@ -37,21 +38,25 @@ class CourseCreatorAdminTest(TestCase):
|
||||
|
||||
self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite())
|
||||
|
||||
self.studio_request_email = 'mark@marky.mark'
|
||||
self.enable_creator_group_patch = {
|
||||
"ENABLE_CREATOR_GROUP": True,
|
||||
"STUDIO_REQUEST_EMAIL": self.studio_request_email
|
||||
}
|
||||
|
||||
@mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
@mock.patch('django.contrib.auth.models.User.email_user')
|
||||
def test_change_status(self, email_user):
|
||||
"""
|
||||
Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent.
|
||||
"""
|
||||
STUDIO_REQUEST_EMAIL = 'mark@marky.mark'
|
||||
|
||||
def change_state(state, is_creator):
|
||||
""" Helper method for changing state """
|
||||
self.table_entry.state = state
|
||||
self.creator_admin.save_model(self.request, self.table_entry, None, True)
|
||||
|
||||
def change_state_and_verify_email(state, is_creator):
|
||||
""" Changes user state, verifies creator status, and verifies e-mail is sent based on transition """
|
||||
self._change_state(state)
|
||||
self.assertEqual(is_creator, is_user_in_creator_group(self.user))
|
||||
|
||||
context = {'studio_request_email': STUDIO_REQUEST_EMAIL}
|
||||
|
||||
context = {'studio_request_email': self.studio_request_email}
|
||||
if state == CourseCreator.GRANTED:
|
||||
template = 'emails/course_creator_granted.txt'
|
||||
elif state == CourseCreator.DENIED:
|
||||
@@ -61,30 +66,76 @@ class CourseCreatorAdminTest(TestCase):
|
||||
email_user.assert_called_with(
|
||||
mock_render_to_string('emails/course_creator_subject.txt', context),
|
||||
mock_render_to_string(template, context),
|
||||
STUDIO_REQUEST_EMAIL
|
||||
self.studio_request_email
|
||||
)
|
||||
|
||||
with mock.patch.dict(
|
||||
'django.conf.settings.MITX_FEATURES',
|
||||
{
|
||||
"ENABLE_CREATOR_GROUP": True,
|
||||
"STUDIO_REQUEST_EMAIL": STUDIO_REQUEST_EMAIL
|
||||
}):
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch):
|
||||
|
||||
# User is initially unrequested.
|
||||
self.assertFalse(is_user_in_creator_group(self.user))
|
||||
|
||||
change_state(CourseCreator.GRANTED, True)
|
||||
change_state_and_verify_email(CourseCreator.GRANTED, True)
|
||||
|
||||
change_state(CourseCreator.DENIED, False)
|
||||
change_state_and_verify_email(CourseCreator.DENIED, False)
|
||||
|
||||
change_state(CourseCreator.GRANTED, True)
|
||||
change_state_and_verify_email(CourseCreator.GRANTED, True)
|
||||
|
||||
change_state(CourseCreator.PENDING, False)
|
||||
change_state_and_verify_email(CourseCreator.PENDING, False)
|
||||
|
||||
change_state(CourseCreator.GRANTED, True)
|
||||
change_state_and_verify_email(CourseCreator.GRANTED, True)
|
||||
|
||||
change_state(CourseCreator.UNREQUESTED, False)
|
||||
change_state_and_verify_email(CourseCreator.UNREQUESTED, False)
|
||||
|
||||
change_state_and_verify_email(CourseCreator.DENIED, False)
|
||||
|
||||
@mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
def test_mail_admin_on_pending(self):
|
||||
"""
|
||||
Tests that the admin account is notified when a user is in the 'pending' state.
|
||||
"""
|
||||
|
||||
def check_admin_message_state(state, expect_sent_to_admin, expect_sent_to_user):
|
||||
""" Changes user state and verifies e-mail sent to admin address only when pending. """
|
||||
mail.outbox = []
|
||||
self._change_state(state)
|
||||
|
||||
# If a message is sent to the user about course creator status change, it will be the first
|
||||
# message sent. Admin message will follow.
|
||||
base_num_emails = 1 if expect_sent_to_user else 0
|
||||
if expect_sent_to_admin:
|
||||
context = {'user_name': "test_user", 'user_email': 'test_user+courses@edx.org'}
|
||||
self.assertEquals(base_num_emails + 1, len(mail.outbox), 'Expected admin message to be sent')
|
||||
sent_mail = mail.outbox[base_num_emails]
|
||||
self.assertEquals(
|
||||
mock_render_to_string('emails/course_creator_admin_subject.txt', context),
|
||||
sent_mail.subject
|
||||
)
|
||||
self.assertEquals(
|
||||
mock_render_to_string('emails/course_creator_admin_user_pending.txt', context),
|
||||
sent_mail.body
|
||||
)
|
||||
self.assertEquals(self.studio_request_email, sent_mail.from_email)
|
||||
self.assertEqual([self.studio_request_email], sent_mail.to)
|
||||
else:
|
||||
self.assertEquals(base_num_emails, len(mail.outbox))
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch):
|
||||
# E-mail message should be sent to admin only when new state is PENDING, regardless of what
|
||||
# previous state was (unless previous state was already PENDING).
|
||||
# E-mail message sent to user only on transition into and out of GRANTED state.
|
||||
check_admin_message_state(CourseCreator.UNREQUESTED, expect_sent_to_admin=False, expect_sent_to_user=False)
|
||||
check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=True, expect_sent_to_user=False)
|
||||
check_admin_message_state(CourseCreator.GRANTED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.DENIED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.GRANTED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=True, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=False, expect_sent_to_user=False)
|
||||
check_admin_message_state(CourseCreator.DENIED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
|
||||
def _change_state(self, state):
|
||||
""" Helper method for changing state """
|
||||
self.table_entry.state = state
|
||||
self.creator_admin.save_model(self.request, self.table_entry, None, True)
|
||||
|
||||
def test_add_permission(self):
|
||||
"""
|
||||
@@ -106,3 +157,18 @@ class CourseCreatorAdminTest(TestCase):
|
||||
|
||||
self.request.user = self.user
|
||||
self.assertFalse(self.creator_admin.has_change_permission(self.request))
|
||||
|
||||
def test_rate_limit_login(self):
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}):
|
||||
post_params = {'username': self.user.username, 'password': 'wrong_password'}
|
||||
# try logging in 30 times, the default limit in the number of failed
|
||||
# login attempts in one 5 minute period before the rate gets limited
|
||||
for _ in xrange(30):
|
||||
response = self.client.post('/admin/', post_params)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
response = self.client.post('/admin/', post_params)
|
||||
# Since we are using the default rate limit behavior, we are
|
||||
# expecting this to return a 403 error to indicate that there have
|
||||
# been too many attempts
|
||||
self.assertEquals(response.status_code, 403)
|
||||
|
||||
@@ -173,7 +173,7 @@ class CourseDetails(object):
|
||||
# the right thing
|
||||
result = None
|
||||
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>'
|
||||
return result
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
INSTALLED_APPS += ('lettuce.django',)
|
||||
LETTUCE_APPS = ('contentstore',)
|
||||
|
||||
@@ -108,6 +108,11 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
'django.core.context_processors.csrf'
|
||||
)
|
||||
|
||||
# use the ratelimit backend to prevent brute force attacks
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'ratelimitbackend.backends.RateLimitModelBackend',
|
||||
)
|
||||
|
||||
LMS_BASE = None
|
||||
|
||||
#################### CAPA External Code Evaluation #############################
|
||||
@@ -152,7 +157,10 @@ MIDDLEWARE_CLASSES = (
|
||||
# Detects user-requested locale from 'accept-language' header in http request
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
|
||||
'django.middleware.transaction.TransactionMiddleware'
|
||||
'django.middleware.transaction.TransactionMiddleware',
|
||||
|
||||
# catches any uncaught RateLimitExceptions and returns a 403 instead of a 500
|
||||
'ratelimitbackend.middleware.RateLimitMiddleware',
|
||||
)
|
||||
|
||||
############################ SIGNAL HANDLERS ################################
|
||||
@@ -188,8 +196,8 @@ STATICFILES_DIRS = [
|
||||
COMMON_ROOT / "static",
|
||||
PROJECT_ROOT / "static",
|
||||
|
||||
# This is how you would use the textbook images locally
|
||||
# ("book", ENV_ROOT / "book_images")
|
||||
# This is how you would use the textbook images locally
|
||||
# ("book", ENV_ROOT / "book_images")
|
||||
]
|
||||
|
||||
# Locale/Internationalization
|
||||
|
||||
@@ -150,14 +150,13 @@ DEBUG_TOOLBAR_PANELS = (
|
||||
'debug_toolbar.panels.sql.SQLDebugPanel',
|
||||
'debug_toolbar.panels.signals.SignalDebugPanel',
|
||||
'debug_toolbar.panels.logger.LoggingPanel',
|
||||
'debug_toolbar_mongo.panel.MongoDebugPanel',
|
||||
|
||||
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
|
||||
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
|
||||
# hit twice). So you can uncomment when you need to diagnose performance
|
||||
# problems, but you shouldn't leave it on.
|
||||
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
|
||||
)
|
||||
)
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
'INTERCEPT_REDIRECTS': False
|
||||
@@ -165,7 +164,7 @@ DEBUG_TOOLBAR_CONFIG = {
|
||||
|
||||
# To see stacktraces for MongoDB queries, set this to True.
|
||||
# Stacktraces slow down page loads drastically (for pages with lots of queries).
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = True
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = False
|
||||
|
||||
# disable NPS survey in dev mode
|
||||
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
|
||||
|
||||
31
cms/envs/dev_dbperf.py
Normal file
31
cms/envs/dev_dbperf.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
This configuration is to turn on the Django Toolbar stats for DB access stats, for performance analysis
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
from .dev import *
|
||||
|
||||
DEBUG_TOOLBAR_PANELS = (
|
||||
'debug_toolbar.panels.version.VersionDebugPanel',
|
||||
'debug_toolbar.panels.timer.TimerDebugPanel',
|
||||
'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
|
||||
'debug_toolbar.panels.headers.HeaderDebugPanel',
|
||||
'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
|
||||
'debug_toolbar.panels.sql.SQLDebugPanel',
|
||||
'debug_toolbar.panels.signals.SignalDebugPanel',
|
||||
'debug_toolbar.panels.logger.LoggingPanel',
|
||||
'debug_toolbar_mongo.panel.MongoDebugPanel'
|
||||
|
||||
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
|
||||
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
|
||||
# hit twice). So you can uncomment when you need to diagnose performance
|
||||
# problems, but you shouldn't leave it on.
|
||||
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
|
||||
)
|
||||
|
||||
# To see stacktraces for MongoDB queries, set this to True.
|
||||
# Stacktraces slow down page loads drastically (for pages with lots of queries).
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = True
|
||||
@@ -15,6 +15,7 @@ sessions. Assumes structure:
|
||||
from .common import *
|
||||
import os
|
||||
from path import path
|
||||
from warnings import filterwarnings
|
||||
|
||||
# Nose Test Runner
|
||||
INSTALLED_APPS += ('django_nose',)
|
||||
@@ -124,6 +125,9 @@ CACHES = {
|
||||
}
|
||||
}
|
||||
|
||||
# hide ratelimit warnings while running tests
|
||||
filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit')
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
CELERY_ALWAYS_EAGER = True
|
||||
|
||||
1
cms/static/coffee/fixtures/metadata-list-entry.underscore
Symbolic link
1
cms/static/coffee/fixtures/metadata-list-entry.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/metadata-list-entry.underscore
|
||||
33
cms/static/coffee/fixtures/tabs-edit.html
Normal file
33
cms/static/coffee/fixtures/tabs-edit.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<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>
|
||||
|
||||
95
cms/static/coffee/spec/tabs/edit.coffee
Normal file
95
cms/static/coffee/spec/tabs/edit.coffee
Normal file
@@ -0,0 +1,95 @@
|
||||
describe "TabsEditingDescriptor", ->
|
||||
beforeEach ->
|
||||
@isInactiveClass = "is-inactive"
|
||||
@isCurrent = "current"
|
||||
loadFixtures 'tabs-edit.html'
|
||||
@descriptor = new TabsEditingDescriptor($('.base_wrapper'))
|
||||
@html_id = 'test_id'
|
||||
@tab_0_switch = jasmine.createSpy('tab_0_switch');
|
||||
@tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate');
|
||||
@tab_1_switch = jasmine.createSpy('tab_1_switch');
|
||||
@tab_1_modelUpdate = jasmine.createSpy('tab_1_modelUpdate');
|
||||
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate)
|
||||
TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 0 Editor', @tab_0_switch)
|
||||
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 1 Transcripts', @tab_1_modelUpdate)
|
||||
TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 1 Transcripts', @tab_1_switch)
|
||||
|
||||
spyOn($.fn, 'hide').andCallThrough()
|
||||
spyOn($.fn, 'show').andCallThrough()
|
||||
spyOn(TabsEditingDescriptor.Model, 'initialize')
|
||||
spyOn(TabsEditingDescriptor.Model, 'updateValue')
|
||||
|
||||
afterEach ->
|
||||
TabsEditingDescriptor.Model.modules= {}
|
||||
|
||||
describe "constructor", ->
|
||||
it "first tab should be visible", ->
|
||||
expect(@descriptor.$tabs.first()).toHaveClass(@isCurrent)
|
||||
expect(@descriptor.$content.first()).not.toHaveClass(@isInactiveClass)
|
||||
|
||||
describe "onSwitchEditor", ->
|
||||
it "switching tabs changes styles", ->
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
expect(@descriptor.$tabs.eq(0)).not.toHaveClass(@isCurrent)
|
||||
expect(@descriptor.$content.eq(0)).toHaveClass(@isInactiveClass)
|
||||
expect(@descriptor.$tabs.eq(1)).toHaveClass(@isCurrent)
|
||||
expect(@descriptor.$content.eq(1)).not.toHaveClass(@isInactiveClass)
|
||||
expect(@tab_1_switch).toHaveBeenCalled()
|
||||
|
||||
it "if click on current tab, nothing should happen", ->
|
||||
spyOn($.fn, 'trigger').andCallThrough()
|
||||
currentTab = @descriptor.$tabs.filter('.' + @isCurrent)
|
||||
@descriptor.$tabs.eq(0).trigger("click")
|
||||
expect(@descriptor.$tabs.filter('.' + @isCurrent)).toEqual(currentTab)
|
||||
expect($.fn.trigger.calls.length).toEqual(1)
|
||||
|
||||
it "onSwitch function call", ->
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
expect(TabsEditingDescriptor.Model.updateValue).toHaveBeenCalled()
|
||||
expect(@tab_1_switch).toHaveBeenCalled()
|
||||
|
||||
describe "save", ->
|
||||
it "function for current tab should be called", ->
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
data = @descriptor.save().data
|
||||
expect(@tab_1_modelUpdate).toHaveBeenCalled()
|
||||
|
||||
it "detach click event", ->
|
||||
spyOn($.fn, "off")
|
||||
@descriptor.save()
|
||||
expect($.fn.off).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'.editor-tabs .tab',
|
||||
@descriptor.onSwitchEditor
|
||||
)
|
||||
|
||||
describe "editor/settings header", ->
|
||||
it "is hidden", ->
|
||||
expect(@descriptor.element.find(".component-edit-header").css('display')).toEqual('none')
|
||||
|
||||
describe "TabsEditingDescriptor special save cases", ->
|
||||
beforeEach ->
|
||||
@isInactiveClass = "is-inactive"
|
||||
@isCurrent = "current"
|
||||
loadFixtures 'tabs-edit.html'
|
||||
@descriptor = new window.TabsEditingDescriptor($('.base_wrapper'))
|
||||
@html_id = 'test_id'
|
||||
|
||||
describe "save", ->
|
||||
it "case: no init", ->
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual(null)
|
||||
|
||||
it "case: no function in model update", ->
|
||||
TabsEditingDescriptor.Model.initialize(@html_id)
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual(null)
|
||||
|
||||
it "case: no function in model update, but value presented", ->
|
||||
@tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate').andReturn(1)
|
||||
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate)
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
expect(@tab_0_modelUpdate).toHaveBeenCalled()
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual(1)
|
||||
|
||||
@@ -3,12 +3,14 @@ describe "Test Metadata Editor", ->
|
||||
numberEntryTemplate = readFixtures('metadata-number-entry.underscore')
|
||||
stringEntryTemplate = readFixtures('metadata-string-entry.underscore')
|
||||
optionEntryTemplate = readFixtures('metadata-option-entry.underscore')
|
||||
listEntryTemplate = readFixtures('metadata-list-entry.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<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-string-entry", type: "text/template"}).text(stringEntryTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-option-entry", type: "text/template"}).text(optionEntryTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-list-entry", type: "text/template"}).text(listEntryTemplate))
|
||||
|
||||
genericEntry = {
|
||||
default_value: 'default value',
|
||||
@@ -62,6 +64,18 @@ describe "Test Metadata Editor", ->
|
||||
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.
|
||||
describe "CMS.Views.Metadata.Editor creates editors for each field", ->
|
||||
beforeEach ->
|
||||
@@ -84,16 +98,18 @@ describe "Test Metadata Editor", ->
|
||||
{"display_name": "Never", "value": "never"}],
|
||||
type: "unknown type",
|
||||
value: null
|
||||
}
|
||||
},
|
||||
listEntry
|
||||
]
|
||||
)
|
||||
|
||||
it "creates child views on initialize, and sorts them alphabetically", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
childModels = view.collection.models
|
||||
expect(childModels.length).toBe(5)
|
||||
childViews = view.$el.find('.setting-input')
|
||||
expect(childViews.length).toBe(5)
|
||||
expect(childModels.length).toBe(6)
|
||||
# Be sure to check list view as well as other input types
|
||||
childViews = view.$el.find('.setting-input, .list-settings')
|
||||
expect(childViews.length).toBe(6)
|
||||
|
||||
verifyEntry = (index, display_name, type) ->
|
||||
expect(childModels[index].get('display_name')).toBe(display_name)
|
||||
@@ -101,9 +117,10 @@ describe "Test Metadata Editor", ->
|
||||
|
||||
verifyEntry(0, 'Display Name', 'text')
|
||||
verifyEntry(1, 'Inputs', 'number')
|
||||
verifyEntry(2, 'Show Answer', 'select-one')
|
||||
verifyEntry(3, 'Unknown', 'text')
|
||||
verifyEntry(4, 'Weight', 'number')
|
||||
verifyEntry(2, 'List', '')
|
||||
verifyEntry(3, 'Show Answer', 'select-one')
|
||||
verifyEntry(4, 'Unknown', 'text')
|
||||
verifyEntry(5, 'Weight', 'number')
|
||||
|
||||
it "returns its display name", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
@@ -146,27 +163,27 @@ describe "Test Metadata Editor", ->
|
||||
# Tests for individual views.
|
||||
assertInputType = (view, expectedType) ->
|
||||
input = view.$el.find('.setting-input')
|
||||
expect(input.length).toBe(1)
|
||||
expect(input[0].type).toBe(expectedType)
|
||||
expect(input.length).toEqual(1)
|
||||
expect(input[0].type).toEqual(expectedType)
|
||||
|
||||
assertValueInView = (view, expectedValue) ->
|
||||
expect(view.getValueFromEditor()).toBe(expectedValue)
|
||||
expect(view.getValueFromEditor()).toEqual(expectedValue)
|
||||
|
||||
assertCanUpdateView = (view, newValue) ->
|
||||
view.setValueInEditor(newValue)
|
||||
expect(view.getValueFromEditor()).toBe(newValue)
|
||||
expect(view.getValueFromEditor()).toEqual(newValue)
|
||||
|
||||
assertClear = (view, modelValue, editorValue=modelValue) ->
|
||||
view.clear()
|
||||
expect(view.model.getValue()).toBe(null)
|
||||
expect(view.model.getDisplayValue()).toBe(modelValue)
|
||||
expect(view.getValueFromEditor()).toBe(editorValue)
|
||||
expect(view.model.getDisplayValue()).toEqual(modelValue)
|
||||
expect(view.getValueFromEditor()).toEqual(editorValue)
|
||||
|
||||
assertUpdateModel = (view, originalValue, newValue) ->
|
||||
view.setValueInEditor(newValue)
|
||||
expect(view.model.getValue()).toBe(originalValue)
|
||||
expect(view.model.getValue()).toEqual(originalValue)
|
||||
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", ->
|
||||
beforeEach ->
|
||||
@@ -298,3 +315,45 @@ describe "Test Metadata Editor", ->
|
||||
|
||||
verifyDisallowedChars(@integerView)
|
||||
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')
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
describe "Course Overview", ->
|
||||
|
||||
beforeEach ->
|
||||
appendSetFixtures """
|
||||
<script src="/static/js/vendor/date.js"></script>
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<script type="text/javascript" src="/jsi18n/"></script>
|
||||
"""
|
||||
_.each ["/static/js/vendor/date.js", "/static/js/vendor/timepicker/jquery.timepicker.js", "/jsi18n/"], (path) ->
|
||||
appendSetFixtures """
|
||||
<script type="text/javascript" src="#{path}"></script>
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<div class="section-published-date">
|
||||
<span class="published-status">
|
||||
<strong>Will Release:</strong> 06/12/2013 at 04:00 UTC
|
||||
</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>
|
||||
"""#"
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<div class="edit-subsection-publish-settings">
|
||||
@@ -38,7 +35,7 @@ describe "Course Overview", ->
|
||||
<a href="#" class="save-button">Save</a><a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
"""#"
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<section class="courseware-section branch" data-id="a-location-goes-here">
|
||||
@@ -46,12 +43,13 @@ describe "Course Overview", ->
|
||||
<a href="#" class="delete-section-button"></a>
|
||||
</li>
|
||||
</section>
|
||||
"""#"
|
||||
"""
|
||||
|
||||
spyOn(window, 'saveSetSectionScheduleDate').andCallThrough()
|
||||
# Have to do this here, as it normally gets bound in document.ready()
|
||||
$('a.save-button').click(saveSetSectionScheduleDate)
|
||||
$('a.delete-section-button').click(deleteSection)
|
||||
$(".edit-subsection-publish-settings .start-date").datepicker()
|
||||
|
||||
@notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough()
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track'])
|
||||
|
||||
@@ -120,6 +120,7 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
@model.save()
|
||||
|
||||
deleteComponent: (event) =>
|
||||
event.preventDefault()
|
||||
msg = new CMS.Views.Prompt.Warning(
|
||||
title: gettext('Delete this component?'),
|
||||
message: gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
|
||||
@@ -253,21 +253,20 @@ function syncReleaseDate(e) {
|
||||
$("#start_time").val("");
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeVals(date_val, time_val) {
|
||||
if (date_val != '') {
|
||||
if (time_val == '') time_val = '00:00';
|
||||
|
||||
return new Date(date_val + " " + time_val + "Z");
|
||||
function getDatetime(datepickerInput, timepickerInput) {
|
||||
// given a pair of inputs (datepicker and timepicker), return a JS Date
|
||||
// object that corresponds to the datetime that they represent. Assume
|
||||
// UTC timezone, NOT the timezone of the user's browser.
|
||||
var date = $(datepickerInput).datepicker("getDate");
|
||||
var time = $(timepickerInput).timepicker("getTime");
|
||||
if(date && time) {
|
||||
return new Date(Date.UTC(
|
||||
date.getFullYear(), date.getMonth(), date.getDate(),
|
||||
time.getHours(), time.getMinutes()
|
||||
));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
else return null;
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeInputs(date_id, time_id) {
|
||||
var input_date = $('#' + date_id).val();
|
||||
var input_time = $('#' + time_id).val();
|
||||
|
||||
return getEdxTimeFromDateTimeVals(input_date, input_time);
|
||||
}
|
||||
|
||||
function autosaveInput(e) {
|
||||
@@ -307,9 +306,17 @@ function saveSubsection() {
|
||||
metadata[$(el).data("metadata-name")] = el.value;
|
||||
}
|
||||
|
||||
// Piece back together the date/time UI elements into one date/time string
|
||||
metadata['start'] = getEdxTimeFromDateTimeInputs('start_date', 'start_time');
|
||||
metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time');
|
||||
// get datetimes for start and due, stick into metadata
|
||||
_(["start", "due"]).each(function(name) {
|
||||
|
||||
var datetime = getDatetime(
|
||||
document.getElementById(name+"_date"),
|
||||
document.getElementById(name+"_time")
|
||||
);
|
||||
// if datetime is null, we want to set that in metadata anyway;
|
||||
// its an indication to the server to clear the datetime in the DB
|
||||
metadata[name] = datetime;
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
@@ -772,21 +779,21 @@ function cancelSetSectionScheduleDate(e) {
|
||||
function saveSetSectionScheduleDate(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var input_date = $('.edit-subsection-publish-settings .start-date').val();
|
||||
var input_time = $('.edit-subsection-publish-settings .start-time').val();
|
||||
|
||||
var start = getEdxTimeFromDateTimeVals(input_date, input_time);
|
||||
var datetime = getDatetime(
|
||||
$('.edit-subsection-publish-settings .start-date'),
|
||||
$('.edit-subsection-publish-settings .start-time')
|
||||
);
|
||||
|
||||
var id = $modal.attr('data-id');
|
||||
|
||||
analytics.track('Edited Section Release Date', {
|
||||
'course': course_location_analytics,
|
||||
'id': id,
|
||||
'start': start
|
||||
'start': datetime
|
||||
});
|
||||
|
||||
var saving = new CMS.Views.Notification.Mini({
|
||||
title: gettext("Saving") + "…",
|
||||
title: gettext("Saving") + "…"
|
||||
});
|
||||
saving.show();
|
||||
// call into server to commit the new order
|
||||
@@ -798,20 +805,29 @@ function saveSetSectionScheduleDate(e) {
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'metadata': {
|
||||
'start': start
|
||||
'start': datetime
|
||||
}
|
||||
})
|
||||
}).success(function() {
|
||||
var pad2 = function(number) {
|
||||
// pad a number to two places: useful for formatting months, days, hours, etc
|
||||
// when displaying a date/time
|
||||
return (number < 10 ? '0' : '') + number;
|
||||
};
|
||||
|
||||
var $thisSection = $('.courseware-section[data-id="' + id + '"]');
|
||||
var html = _.template(
|
||||
'<span class="published-status">' +
|
||||
'<strong>' + gettext("Will Release:") + ' </strong>' +
|
||||
gettext("<%= date %> at <%= time %> UTC") +
|
||||
gettext("{month}/{day}/{year} at {hour}:{minute} UTC") +
|
||||
'</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") +
|
||||
'</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);
|
||||
hideModal();
|
||||
saving.hide();
|
||||
|
||||
@@ -111,3 +111,4 @@ CMS.Models.Metadata.SELECT_TYPE = "Select";
|
||||
CMS.Models.Metadata.INTEGER_TYPE = "Integer";
|
||||
CMS.Models.Metadata.FLOAT_TYPE = "Float";
|
||||
CMS.Models.Metadata.GENERIC_TYPE = "Generic";
|
||||
CMS.Models.Metadata.LIST_TYPE = "List";
|
||||
|
||||
@@ -75,7 +75,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
|
||||
return this.videosourceSample();
|
||||
},
|
||||
videosourceSample : function() {
|
||||
if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video');
|
||||
if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video');
|
||||
else return "";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ function displayFinishedUpload(xhr) {
|
||||
}
|
||||
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
|
||||
$('.upload-modal .embeddable-xml-input').val(resp.portable_url);
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
|
||||
@@ -27,6 +27,9 @@ CMS.Views.Metadata.Editor = Backbone.View.extend({
|
||||
model.getType() === CMS.Models.Metadata.FLOAT_TYPE) {
|
||||
new CMS.Views.Metadata.Number(data);
|
||||
}
|
||||
else if(model.getType() === CMS.Models.Metadata.LIST_TYPE) {
|
||||
new CMS.Views.Metadata.List(data);
|
||||
}
|
||||
else {
|
||||
// Everything else is treated as GENERIC_TYPE, which uses String editor.
|
||||
new CMS.Views.Metadata.String(data);
|
||||
@@ -310,3 +313,59 @@ CMS.Views.Metadata.Option = CMS.Views.Metadata.AbstractEditor.extend({
|
||||
}).prop('selected', true);
|
||||
}
|
||||
});
|
||||
|
||||
CMS.Views.Metadata.List = CMS.Views.Metadata.AbstractEditor.extend({
|
||||
|
||||
events : {
|
||||
"click .setting-clear" : "clear",
|
||||
"keypress .setting-input" : "showClearButton",
|
||||
"change input" : "updateModel",
|
||||
"input input" : "enableAdd",
|
||||
"click .create-setting" : "addEntry",
|
||||
"click .remove-setting" : "removeEntry"
|
||||
},
|
||||
|
||||
templateName: "metadata-list-entry",
|
||||
|
||||
getValueFromEditor: function () {
|
||||
return _.map(
|
||||
this.$el.find('li input'),
|
||||
function (ele) { return ele.value.trim(); }
|
||||
).filter(_.identity);
|
||||
},
|
||||
|
||||
setValueInEditor: function (value) {
|
||||
var list = this.$el.find('ol');
|
||||
list.empty();
|
||||
_.each(value, function(ele, index) {
|
||||
var template = _.template(
|
||||
'<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) {
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function removeHesitate(event, ui) {
|
||||
|
||||
@@ -65,6 +65,7 @@ nav {
|
||||
pointer-events: none;
|
||||
width: ($baseline*8);
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
|
||||
|
||||
// dropped down state
|
||||
@@ -72,6 +73,7 @@ nav {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ body.dashboard {
|
||||
|
||||
// ====================
|
||||
|
||||
// course listings
|
||||
// ELEM: course listings
|
||||
.courses {
|
||||
margin: $baseline 0;
|
||||
}
|
||||
@@ -304,61 +304,110 @@ body.dashboard {
|
||||
box-shadow: 0 1px 2px $shadow-l1;
|
||||
|
||||
.course-item {
|
||||
@include box-sizing(border-box);
|
||||
width: flex-grid(9, 9);
|
||||
position: relative;
|
||||
border-bottom: 1px solid $gray-l1;
|
||||
padding: $baseline;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
// STATE: hover/focus
|
||||
&:hover {
|
||||
background: $paleYellow;
|
||||
|
||||
.class-link {
|
||||
z-index: 100;
|
||||
display: block;
|
||||
padding: 20px 25px;
|
||||
line-height: 1.3;
|
||||
.course-actions .view-live-button {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $paleYellow;
|
||||
.course-title {
|
||||
color: $orange-d1;
|
||||
}
|
||||
|
||||
+ .view-live-button {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.course-metadata {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
.class-name {
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
font-weight: 300;
|
||||
}
|
||||
.course-link, .course-actions {
|
||||
@include box-sizing(border-box);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-right: 20px;
|
||||
color: #3c3c3c;
|
||||
}
|
||||
// encompassing course link
|
||||
.course-link {
|
||||
@extend .ui-depth2;
|
||||
width: flex-grid(7, 9);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
// view live button
|
||||
.view-live-button {
|
||||
z-index: 10000;
|
||||
position: absolute;
|
||||
top: ($baseline*0.75);
|
||||
right: $baseline;
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
// course title
|
||||
.course-title {
|
||||
@extend .t-title4;
|
||||
margin: 0 ($baseline*2) ($baseline/4) 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
// course metadata
|
||||
.course-metadata {
|
||||
@extend .t-copy-sub1;
|
||||
@include transition(opacity $tmg-f1 ease-in-out 0);
|
||||
color: $gray;
|
||||
opacity: 0.75;
|
||||
|
||||
.metadata-item {
|
||||
display: inline-block;
|
||||
|
||||
&:after {
|
||||
content: "/";
|
||||
margin-left: ($baseline/10);
|
||||
margin-right: ($baseline/10);
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@extend .cont-text-sr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.course-actions {
|
||||
@extend .ui-depth3;
|
||||
position: static;
|
||||
width: flex-grid(2, 9);
|
||||
text-align: right;
|
||||
|
||||
// view live button
|
||||
.view-live-button {
|
||||
@extend .ui-depth3;
|
||||
@include transition(opacity $tmg-f2 ease-in-out 0);
|
||||
@include box-sizing(border-box);
|
||||
padding: ($baseline/2);
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ELEM: new user form
|
||||
.wrapper-create-course {
|
||||
|
||||
|
||||
@@ -148,6 +148,14 @@ body.course.textbooks {
|
||||
padding: ($baseline*0.75) $baseline;
|
||||
background: $gray-l6;
|
||||
|
||||
.action {
|
||||
margin-right: ($baseline/4);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// add a chapter is below with chapters styling
|
||||
|
||||
.action-primary {
|
||||
|
||||
@@ -449,12 +449,39 @@ body.course.unit {
|
||||
|
||||
// Module Actions, also used for Static Pages
|
||||
.module-actions {
|
||||
box-shadow: inset 0 1px 1px $shadow;
|
||||
padding: 0 0 $baseline $baseline;
|
||||
background-color: $gray-l6;
|
||||
box-shadow: inset 0 1px 2px $shadow;
|
||||
border-top: 1px solid $gray-l1;
|
||||
padding: ($baseline*0.75) $baseline;
|
||||
background: $gray-l6;
|
||||
|
||||
.save-button {
|
||||
margin: ($baseline/2) 8px 0 0;
|
||||
.action {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@include blue-button;
|
||||
@extend .t-action2;
|
||||
@include transition(all .15s);
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
@include grey-button;
|
||||
@extend .t-action2;
|
||||
@include transition(all .15s);
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -599,26 +626,27 @@ body.course.unit {
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-comp-setting{
|
||||
.wrapper-comp-setting {
|
||||
display: inline-block;
|
||||
min-width: 300px;
|
||||
width: 45%;
|
||||
width: 55%;
|
||||
top: 0;
|
||||
vertical-align: top;
|
||||
margin-bottom:5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label.setting-label {
|
||||
.setting-label {
|
||||
@extend .t-copy-sub1;
|
||||
@include transition(color $tmg-f2 ease-in-out 0s);
|
||||
font-weight: 400;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
left: 0;
|
||||
width: 33%;
|
||||
min-width: 100px;
|
||||
width: 35%;
|
||||
margin-right: ($baseline/2);
|
||||
font-weight: 600;
|
||||
|
||||
&.is-focused {
|
||||
color: $blue;
|
||||
@@ -708,14 +736,98 @@ body.course.unit {
|
||||
}
|
||||
}
|
||||
|
||||
.tip.setting-help {
|
||||
.setting-help {
|
||||
@include font-size(12);
|
||||
display: inline-block;
|
||||
font-color: $gray-l6;
|
||||
min-width: 260px;
|
||||
width: 50%;
|
||||
min-width: ($baseline*10);
|
||||
width: 35%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// TYPE: enumerated lists of metadata sets
|
||||
.metadata-list-enum {
|
||||
|
||||
* {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
|
||||
// label
|
||||
.setting-label {
|
||||
vertical-align: top;
|
||||
margin-top: ($baseline/2);
|
||||
}
|
||||
|
||||
// inputs and labels
|
||||
.wrapper-list-settings {
|
||||
@include size(45%,100%);
|
||||
display: inline-block;
|
||||
min-width: ($baseline*5);
|
||||
|
||||
// enumerated fields
|
||||
.list-settings {
|
||||
margin: 0;
|
||||
|
||||
.list-settings-item {
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
|
||||
// inputs
|
||||
.input {
|
||||
width: 80%;
|
||||
margin-right: ($baseline/2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// actions
|
||||
.create-action, .remove-action, .setting-clear {
|
||||
|
||||
}
|
||||
|
||||
.setting-clear {
|
||||
vertical-align: top;
|
||||
margin-top: ($baseline/4);
|
||||
}
|
||||
|
||||
.create-setting {
|
||||
@extend .ui-btn-flat-outline;
|
||||
@extend .t-action3;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
font-weight: 600;
|
||||
|
||||
*[class^="icon-"] {
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
|
||||
// STATE: disabled
|
||||
&.is-disabled {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.remove-setting {
|
||||
@include transition(color 0.25s ease-in-out);
|
||||
@include font-size(20);
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: $blue-l3;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
// STATE: disabled
|
||||
&.is-disabled {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{{uploadDate}}
|
||||
</td>
|
||||
<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 class="delete-col">
|
||||
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
|
||||
@@ -89,7 +89,7 @@
|
||||
${asset['uploadDate']}
|
||||
</td>
|
||||
<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 class="delete-col">
|
||||
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row module-actions">
|
||||
<a href="#" class="save-button">${_("Save")}</a>
|
||||
<a href="#" class="cancel-button">${_("Cancel")}</a>
|
||||
<a href="#" class="save-button action-primary action">${_("Save")}</a>
|
||||
<a href="#" class="cancel-button action-secondary action">${_("Cancel")}</a>
|
||||
</div> <!-- Module Actions-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import logging
|
||||
from xmodule.util.date_utils import get_default_time_display, almost_same_datetime
|
||||
import logging
|
||||
from xmodule.util.date_utils import get_default_time_display, almost_same_datetime
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">${_("CMS Subsection")}</%block>
|
||||
<%block name="bodyclass">is-signedin course subsection</%block>
|
||||
|
||||
|
||||
2
cms/templates/emails/course_creator_admin_subject.txt
Normal file
2
cms/templates/emails/course_creator_admin_subject.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
${_("{email} has requested Studio course creator privileges on edge".format(email=user_email))}
|
||||
@@ -0,0 +1,9 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
${_("User '{user}' with e-mail {email} has requested Studio course creator privileges on edge.".format(user=user_name, email=user_email))}
|
||||
${_("To grant or deny this request, use the course creator admin table.")}
|
||||
|
||||
% if is_secure:
|
||||
https://${ site }/admin/course_creators/coursecreator/
|
||||
% else:
|
||||
http://${ site }/admin/course_creators/coursecreator/
|
||||
% endif
|
||||
@@ -6,6 +6,62 @@
|
||||
<%block name="title">${_("Course 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">
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-subtitle">
|
||||
@@ -18,6 +74,7 @@
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
|
||||
<article class="export-overview">
|
||||
<div class="description">
|
||||
<h2>${_("About Exporting Courses")}</h2>
|
||||
|
||||
@@ -133,12 +133,30 @@
|
||||
%if len(courses) > 0:
|
||||
<div class="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">
|
||||
<a class="class-link" href="${url}" class="class-name">
|
||||
<span class="class-name">${course}</span>
|
||||
<a class="course-link" href="${url}">
|
||||
<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 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>
|
||||
%endfor
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<ul class="list-input settings-list">
|
||||
<% _.each(_.range(numEntries), function() { %>
|
||||
<li class="field comp-setting-entry metadata_entry" id="settings-listing">
|
||||
<li class="field comp-setting-entry metadata_entry">
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
|
||||
17
cms/templates/js/metadata-list-entry.underscore
Normal file
17
cms/templates/js/metadata-list-entry.underscore
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
@@ -1,10 +1,10 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import logging
|
||||
from xmodule.util import date_utils
|
||||
import logging
|
||||
from xmodule.util import date_utils
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">${_("Course Outline")}</%block>
|
||||
<%block name="bodyclass">is-signedin course outline</%block>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ $(function() {
|
||||
</div>
|
||||
<div class="bit">
|
||||
<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>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<h2 class="info-course">
|
||||
<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))}">
|
||||
<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>
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%! 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">
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="visual-tab tab current" data-tab="visual">${_("Visual")}</a></li>
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<%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>
|
||||
|
||||
<% showHighLevelSource='source_code' in editable_metadata_fields and editable_metadata_fields['source_code']['explicitly_set'] %>
|
||||
<% metadata_field_copy = copy.copy(editable_metadata_fields) %>
|
||||
## Delete 'source_code' field (if it exists) so metadata editor view does not attempt to render it.
|
||||
@@ -40,4 +44,4 @@
|
||||
<%include file="source-edit.html" />
|
||||
% endif
|
||||
|
||||
<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}'/>
|
||||
|
||||
21
cms/templates/widgets/tabs-aggregator.html
Normal file
21
cms/templates/widgets/tabs-aggregator.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<%! 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>
|
||||
|
||||
28
cms/templates/widgets/tabs/metadata-edit-tab.html
Normal file
28
cms/templates/widgets/tabs/metadata-edit-tab.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<%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}'/>
|
||||
|
||||
33
cms/templates/widgets/videoalpha/codemirror-edit.html
Normal file
33
cms/templates/widgets/videoalpha/codemirror-edit.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<%! 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>
|
||||
|
||||
0
cms/templates/widgets/videoalpha/subtitles.html
Normal file
0
cms/templates/widgets/videoalpha/subtitles.html
Normal file
@@ -6,7 +6,7 @@ from django.conf.urls import patterns, include, url
|
||||
from . import one_time_startup
|
||||
|
||||
# There is a course creators admin table.
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
admin.autodiscover()
|
||||
|
||||
urlpatterns = ('', # nopep8
|
||||
@@ -147,7 +147,7 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'):
|
||||
urlpatterns += (url(r'^admin/', include(admin.site.urls)),)
|
||||
|
||||
# enable automatic login
|
||||
if settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING'):
|
||||
if settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
|
||||
urlpatterns += (
|
||||
url(r'^auto_auth$', 'student.views.auto_auth'),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from external_auth.models import *
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
|
||||
|
||||
class ExternalAuthMapAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -9,12 +9,15 @@ from urlparse import parse_qs
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, LiveServerTestCase
|
||||
from django.core.cache import cache
|
||||
from django.test.utils import override_settings
|
||||
# from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.client import RequestFactory
|
||||
from unittest import skipUnless
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from external_auth.views import provider_login
|
||||
|
||||
|
||||
class MyFetcher(HTTPFetcher):
|
||||
"""A fetcher that uses server-internal calls for performing HTTP
|
||||
@@ -199,6 +202,49 @@ class OpenIdProviderTest(TestCase):
|
||||
""" Test for 403 error code when the url"""
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -39,6 +39,7 @@ from openid.consumer.consumer import SUCCESS
|
||||
from openid.server.server import Server, ProtocolError, UntrustedReturnURL
|
||||
from openid.server.trustroot import TrustRoot
|
||||
from openid.extensions import ax, sreg
|
||||
from ratelimitbackend.exceptions import RateLimitException
|
||||
|
||||
import student.views as student_views
|
||||
# Required for Pearson
|
||||
@@ -191,7 +192,7 @@ def _external_login_or_signup(request,
|
||||
user.backend = auth_backend
|
||||
AUDIT_LOG.info('Linked user "%s" logged in via Shibboleth', user.email)
|
||||
else:
|
||||
user = authenticate(username=uname, password=eamap.internal_password)
|
||||
user = authenticate(username=uname, password=eamap.internal_password, request=request)
|
||||
if user is None:
|
||||
# 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)
|
||||
@@ -718,7 +719,12 @@ def provider_login(request):
|
||||
# Failure is again redirected to the login dialog.
|
||||
username = user.username
|
||||
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:
|
||||
request.session['openid_error'] = True
|
||||
msg = "OpenID login failed - password for %s is invalid"
|
||||
|
||||
@@ -4,7 +4,7 @@ django admin pages for courseware model
|
||||
|
||||
from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed
|
||||
from student.models import CourseEnrollment, Registration, PendingNameChange
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
|
||||
admin.site.register(UserProfile)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class Migration(SchemaMigration):
|
||||
('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)),
|
||||
('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)),
|
||||
('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)),
|
||||
@@ -163,7 +163,7 @@ class Migration(SchemaMigration):
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'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'}),
|
||||
'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'}),
|
||||
|
||||
@@ -93,7 +93,7 @@ class Migration(SchemaMigration):
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'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'}),
|
||||
'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'}),
|
||||
|
||||
@@ -94,7 +94,7 @@ class Migration(SchemaMigration):
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'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'}),
|
||||
'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'}),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
from django.db.utils import DatabaseError
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
"""
|
||||
Remove an unwanted index from environments that have it.
|
||||
This is a one-way migration in that backwards is a no-op and will not undo the removal.
|
||||
This migration is only relevant to dev environments that existed before a migration rewrite
|
||||
which removed the creation of this index.
|
||||
"""
|
||||
|
||||
def forwards(self, orm):
|
||||
try:
|
||||
# Removing index on 'TestCenterRegistration', fields ['accommodation_request']
|
||||
db.delete_index('student_testcenterregistration', ['accommodation_request'])
|
||||
except DatabaseError:
|
||||
print "-- skipping delete_index of student_testcenterregistration.accommodation_request (index does not exist)"
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
pass
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollmentallowed': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': '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'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.testcenteruser': {
|
||||
'Meta': {'object_name': 'TestCenterUser'},
|
||||
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
|
||||
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
|
||||
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
|
||||
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
|
||||
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
|
||||
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
@@ -370,7 +370,7 @@ class TestCenterRegistration(models.Model):
|
||||
accommodation_code = models.CharField(max_length=64, blank=True)
|
||||
|
||||
# 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
|
||||
uploaded_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
@@ -11,9 +11,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
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):
|
||||
# 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,
|
||||
# so we need to call super.setUp() which reloads urls.py (because
|
||||
# of the UrlResetMixin)
|
||||
@@ -37,6 +37,26 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
user = qset[0]
|
||||
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')
|
||||
def test_create_multiple_users(self, randint):
|
||||
"""
|
||||
@@ -50,8 +70,13 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
|
||||
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)
|
||||
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})
|
||||
def test_login_already_created_user(self):
|
||||
@@ -77,9 +102,9 @@ class AutoAuthDisabledTestCase(UrlResetMixin, TestCase):
|
||||
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):
|
||||
# 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,
|
||||
# so we need to call super.setUp() which reloads urls.py (because
|
||||
# of the UrlResetMixin)
|
||||
|
||||
@@ -6,6 +6,7 @@ from mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.core.cache import cache
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
|
||||
|
||||
@@ -29,6 +30,7 @@ class LoginTest(TestCase):
|
||||
|
||||
# Create the test client
|
||||
self.client = Client()
|
||||
cache.clear()
|
||||
|
||||
# Store the login url
|
||||
try:
|
||||
@@ -95,6 +97,27 @@ class LoginTest(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
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'):
|
||||
''' Post the login info '''
|
||||
post_params = {'email': email, 'password': password}
|
||||
|
||||
@@ -23,6 +23,7 @@ from textwrap import dedent
|
||||
|
||||
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 enroll_in_course, is_enrolled_in_course
|
||||
from student.tests.factories import UserFactory
|
||||
from student.tests.test_email import mock_render_to_string
|
||||
COURSE_1 = 'edX/toy/2012_Fall'
|
||||
@@ -205,3 +206,15 @@ class CourseEndingTest(TestCase):
|
||||
'show_survey_button': False,
|
||||
'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
|
||||
from django.utils.http import base36_to_int
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from ratelimitbackend.exceptions import RateLimitException
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
@@ -376,7 +378,7 @@ def change_enrollment(request):
|
||||
"run:{0}".format(run)])
|
||||
|
||||
try:
|
||||
enrollment, _created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
enroll_in_course(user, course.id)
|
||||
except IntegrityError:
|
||||
# If we've already created this enrollment in a separate transaction,
|
||||
# then just continue
|
||||
@@ -401,6 +403,23 @@ def change_enrollment(request):
|
||||
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
|
||||
def accounts_login(request, error=""):
|
||||
|
||||
@@ -421,13 +440,23 @@ def login_user(request, error=""):
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'value': _('Email or password is incorrect.')})) # TODO: User error message
|
||||
user = None
|
||||
|
||||
username = user.username
|
||||
user = authenticate(username=username, password=password)
|
||||
# if the user doesn't exist, we want to set the username to an invalid
|
||||
# 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:
|
||||
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,
|
||||
'value': _('Email or password is incorrect.')}))
|
||||
|
||||
@@ -674,7 +703,7 @@ def create_account(request, post_override=None):
|
||||
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
|
||||
if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING')):
|
||||
if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING')):
|
||||
try:
|
||||
if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
|
||||
dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL']
|
||||
@@ -913,41 +942,46 @@ def auto_auth(request):
|
||||
"""
|
||||
Automatically logs the user in with a generated random credentials
|
||||
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
|
||||
of create_account, with specified username and password.
|
||||
of create_account, with specified values.
|
||||
"""
|
||||
|
||||
return {'username': username,
|
||||
'email': username + "_dummy_test@mitx.mit.edu",
|
||||
'email': email,
|
||||
'password': password,
|
||||
'name': username + " " + username,
|
||||
'name': name,
|
||||
'honor_code': 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_'
|
||||
pass_base = 'PASS_'
|
||||
|
||||
max_users = settings.MITX_FEATURES.get('MAX_AUTO_AUTH_USERS', 200)
|
||||
number = random.randint(1, max_users)
|
||||
|
||||
username = name_base + str(number)
|
||||
password = pass_base + str(number)
|
||||
# Get the params from the request to override default user attributes if specified
|
||||
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
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
user = authenticate(username=username, password=password)
|
||||
user = authenticate(username=username, password=password, request=request)
|
||||
login(request, user)
|
||||
|
||||
# else create and activate account info
|
||||
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)
|
||||
request.user.is_active = True
|
||||
request.user.save()
|
||||
|
||||
@@ -34,33 +34,17 @@ def create_user(uname, password):
|
||||
|
||||
|
||||
@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.
|
||||
This will delete any existing cookies to ensure that the user
|
||||
logs in to the correct session.
|
||||
Use the auto_auth feature to programmatically log the user in
|
||||
"""
|
||||
url = '/auto_auth?username=%s&password=%s&name=%s&email=%s' % (username,
|
||||
password, name, email)
|
||||
world.visit(url)
|
||||
|
||||
# Authenticate the user
|
||||
world.scenario_dict['USER'] = authenticate(username=username, password=password)
|
||||
assert(world.scenario_dict['USER'] is not None and world.scenario_dict['USER'].is_active)
|
||||
|
||||
# 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)
|
||||
# Save the user info in the world scenario_dict for use in the tests
|
||||
user = User.objects.get(username=username)
|
||||
world.scenario_dict['USER'] = user
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
@@ -88,13 +88,13 @@ def the_page_title_should_contain(step, title):
|
||||
|
||||
@step('I log in$')
|
||||
def i_log_in(step):
|
||||
world.log_in('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@step('I am a logged in user$')
|
||||
def i_am_logged_in_user(step):
|
||||
world.create_user('robot', 'test')
|
||||
world.log_in('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@step('I am not logged in$')
|
||||
@@ -147,7 +147,7 @@ def should_see_in_the_page(step, doesnt_appear, text):
|
||||
@step('I am logged in$')
|
||||
def i_am_logged_in(step):
|
||||
world.create_user('robot', 'test')
|
||||
world.log_in('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
world.browser.visit(django_url('/'))
|
||||
# You should not see the login link
|
||||
assert_equals(world.browser.find_by_css('a#login'), [])
|
||||
|
||||
@@ -44,8 +44,8 @@ def is_css_not_present(css_selector, wait_time=5):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_has_text(css_selector, text):
|
||||
return world.css_text(css_selector) == text
|
||||
def css_has_text(css_selector, text, index=0, max_attempts=5):
|
||||
return world.css_text(css_selector, index=index, max_attempts=max_attempts) == text
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -235,6 +235,13 @@ def click_tools():
|
||||
def is_mac():
|
||||
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
|
||||
def retry_on_exception(func, max_attempts=5):
|
||||
|
||||
@@ -3,6 +3,6 @@ django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from track.models import TrackingLog
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
|
||||
admin.site.register(TrackingLog)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.supertestclass{
|
||||
color: red;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.supertestclass{
|
||||
color: red;
|
||||
}
|
||||
|
||||
@@ -534,8 +534,16 @@ class CapaModule(CapaFields, XModule):
|
||||
id=self.location.html_id(), ajax_url=self.system.ajax_url
|
||||
) + html + "</div>"
|
||||
|
||||
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes
|
||||
return self.system.replace_urls(html)
|
||||
# now do all the substitutions which the LMS module_render normally does, but
|
||||
# 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):
|
||||
"""
|
||||
|
||||
@@ -58,6 +58,20 @@ class StaticContent(object):
|
||||
else:
|
||||
return None
|
||||
|
||||
@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):
|
||||
if loc is not None:
|
||||
|
||||
@@ -362,6 +362,11 @@ class CourseFields(object):
|
||||
# Explicit comparison to True because we always want to return a bool.
|
||||
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):
|
||||
module_class = SequenceModule
|
||||
@@ -933,6 +938,26 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
def number(self):
|
||||
return self.location.course
|
||||
|
||||
@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):
|
||||
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
|
||||
|
||||
19
common/lib/xmodule/xmodule/css/tabs/codemirror.scss
Normal file
19
common/lib/xmodule/xmodule/css/tabs/codemirror.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
|
||||
137
common/lib/xmodule/xmodule/css/tabs/tabs.scss
Normal file
137
common/lib/xmodule/xmodule/css/tabs/tabs.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user