Commit 9de80043 by Jason Bau

Merge commit 'c7546271' into edx-west/rc-20130918

Conflicts:
	CHANGELOG.rst
	common/djangoapps/track/views.py
	common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee
parents a729f686 c7546271
...@@ -86,3 +86,5 @@ Yarko Tymciurak <yarkot1@gmail.com> ...@@ -86,3 +86,5 @@ Yarko Tymciurak <yarkot1@gmail.com>
Miles Steele <miles@milessteele.com> Miles Steele <miles@milessteele.com>
Kevin Luo <kevluo@edx.org> Kevin Luo <kevluo@edx.org>
Akshay Jagadeesh <akjags@gmail.com> Akshay Jagadeesh <akjags@gmail.com>
Marko Seric <marko.seric@math.uzh.ch>
...@@ -5,10 +5,15 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,10 +5,15 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
LMS: Added bulk email for course feature, with option to optout of individual Studio/LMS: Added ability to set due date formatting through Studio's Advanced Settings.
course emails. The key is due_date_display_format, and the value should be a format supported by Python's
strftime function.
Blades: Added Learning Tools Interoperability (LTI) blade. Now LTI components Common: Added configurable backends for tracking events. Tracking events using
the python logging module is the default backend. Support for MongoDB and a
Django database is also available.
Blades: Added Learning Tools Interoperability (LTI) blade. Now LTI components
can be included to courses. can be included to courses.
LMS: Added alphabetical sorting of forum categories and subcategories. LMS: Added alphabetical sorting of forum categories and subcategories.
...@@ -289,4 +294,3 @@ Common: Updated CodeJail. ...@@ -289,4 +294,3 @@ Common: Updated CodeJail.
Common: Allow setting of authentication session cookie name. Common: Allow setting of authentication session cookie name.
LMS: Option to email students when enroll/un-enroll them. LMS: Option to email students when enroll/un-enroll them.
...@@ -11,7 +11,6 @@ DISPLAY_NAME_KEY = "display_name" ...@@ -11,7 +11,6 @@ DISPLAY_NAME_KEY = "display_name"
DISPLAY_NAME_VALUE = '"Robot Super Course"' DISPLAY_NAME_VALUE = '"Robot Super Course"'
############### ACTIONS ####################
@step('I select the Advanced Settings$') @step('I select the Advanced Settings$')
def i_select_advanced_settings(step): def i_select_advanced_settings(step):
world.click_course_settings() world.click_course_settings()
...@@ -45,7 +44,6 @@ def create_value_not_in_quotes(step): ...@@ -45,7 +44,6 @@ def create_value_not_in_quotes(step):
change_display_name_value(step, 'quote me') change_display_name_value(step, 'quote me')
############### RESULTS ####################
@step('I see default advanced settings$') @step('I see default advanced settings$')
def i_see_default_advanced_settings(step): def i_see_default_advanced_settings(step):
# Test only a few of the existing properties (there are around 34 of them) # Test only a few of the existing properties (there are around 34 of them)
...@@ -88,12 +86,13 @@ def the_policy_key_value_is_changed(step): ...@@ -88,12 +86,13 @@ def the_policy_key_value_is_changed(step):
assert_equal(get_display_name_value(), '"foo"') assert_equal(get_display_name_value(), '"foo"')
############# HELPERS ###############
def assert_policy_entries(expected_keys, expected_values): def assert_policy_entries(expected_keys, expected_values):
for key, value in zip(expected_keys, expected_values): for key, value in zip(expected_keys, expected_values):
index = get_index_of(key) index = get_index_of(key)
assert_false(index == -1, "Could not find key: {key}".format(key=key)) assert_false(index == -1, "Could not find key: {key}".format(key=key))
assert_equal(value, world.css_find(VALUE_CSS)[index].value, "value is incorrect") found_value = world.css_find(VALUE_CSS)[index].value
assert_equal(value, found_value,
"Expected {} to have value {} but found {}".format(key, value, found_value))
def get_index_of(expected_key): def get_index_of(expected_key):
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
# pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_true # pylint: disable=E0611 from nose.tools import assert_true, assert_in, assert_false # pylint: disable=E0611
from auth.authz import get_user_by_email, get_course_groupname_for_role from auth.authz import get_user_by_email, get_course_groupname_for_role
from django.conf import settings from django.conf import settings
...@@ -19,8 +19,6 @@ from terrain.browser import reset_data ...@@ -19,8 +19,6 @@ from terrain.browser import reset_data
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$') @step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(_step): def i_visit_the_studio_homepage(_step):
...@@ -66,20 +64,32 @@ def select_new_course(_step, whom): ...@@ -66,20 +64,32 @@ def select_new_course(_step, whom):
@step(u'I press the "([^"]*)" notification button$') @step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(_step, name): def press_the_notification_button(_step, name):
css = 'a.action-%s' % name.lower() # TODO: fix up this code. Selenium is not dealing well with css transforms,
# as it thinks that the notification and the buttons are always visible
# The button was clicked if either the notification bar is gone,
# or we see an error overlaying it (expected for invalid inputs). # First wait for the notification to pop up
def button_clicked(): notification_css = 'div#page-notification div.wrapper-notification'
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning') world.wait_for_visible(notification_css)
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
return confirmation_dismissed or error_showing # You would think that the above would have worked, but it doesn't.
# Brute force wait for now.
world.wait(.5)
# Now make sure the button is there
btn_css = 'div#page-notification a.action-%s' % name.lower()
world.wait_for_visible(btn_css)
# You would think that the above would have worked, but it doesn't.
# Brute force wait for now.
world.wait(.5)
if world.is_firefox(): if world.is_firefox():
# This is done to explicitly make the changes save on firefox. It will remove focus from the previously focused element # This is done to explicitly make the changes save on firefox.
world.trigger_event(css, event='focus') # It will remove focus from the previously focused element
world.browser.execute_script("$('{}').click()".format(css)) world.trigger_event(btn_css, event='focus')
world.browser.execute_script("$('{}').click()".format(btn_css))
else: else:
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name world.css_click(btn_css)
@step('I change the "(.*)" field to "(.*)"$') @step('I change the "(.*)" field to "(.*)"$')
...@@ -110,7 +120,6 @@ def i_see_a_confirmation(step): ...@@ -110,7 +120,6 @@ def i_see_a_confirmation(step):
assert world.is_css_present(confirmation_css) assert world.is_css_present(confirmation_css)
####### HELPER FUNCTIONS ##############
def open_new_course(): def open_new_course():
world.clear_courses() world.clear_courses()
create_studio_user() create_studio_user()
...@@ -156,8 +165,8 @@ def log_into_studio( ...@@ -156,8 +165,8 @@ def log_into_studio(
world.log_in(username=uname, password=password, email=email, name=name) world.log_in(username=uname, password=password, email=email, name=name)
# Navigate to the studio dashboard # Navigate to the studio dashboard
world.visit('/') world.visit('/')
assert_in(uname, world.css_text('h2.title', timeout=10))
assert uname in world.css_text('h2.title', max_attempts=15)
def create_a_course(): def create_a_course():
course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
...@@ -232,17 +241,37 @@ def open_new_unit(step): ...@@ -232,17 +241,37 @@ def open_new_unit(step):
world.css_click('a.new-unit-item') world.css_click('a.new-unit-item')
@step('the save button is disabled$') @step('the save notification button is disabled')
def save_button_disabled(step): def save_button_disabled(step):
button_css = '.action-save' button_css = '.action-save'
disabled = 'is-disabled' disabled = 'is-disabled'
assert world.css_has_class(button_css, disabled) assert world.css_has_class(button_css, disabled)
@step('the "([^"]*)" button is disabled')
def button_disabled(step, value):
button_css = 'input[value="%s"]' % value
assert world.css_has_class(button_css, 'is-disabled')
@step('I confirm the prompt') @step('I confirm the prompt')
def confirm_the_prompt(step): def confirm_the_prompt(step):
prompt_css = 'a.button.action-primary'
world.css_click(prompt_css, success_condition=lambda: not world.css_visible(prompt_css)) def click_button(btn_css):
world.css_click(btn_css)
return world.css_find(btn_css).visible == False
prompt_css = 'div.prompt.has-actions'
world.wait_for_visible(prompt_css)
btn_css = 'a.button.action-primary'
world.wait_for_visible(btn_css)
# Sometimes you can do a click before the prompt is up.
# Thus we need some retry logic here.
world.wait_for(lambda _driver: click_button(btn_css))
assert_false(world.css_find(btn_css).visible)
@step(u'I am shown a (.*)$') @step(u'I am shown a (.*)$')
...@@ -251,6 +280,7 @@ def i_am_shown_a_notification(step, notification_type): ...@@ -251,6 +280,7 @@ def i_am_shown_a_notification(step, notification_type):
def type_in_codemirror(index, text): def type_in_codemirror(index, text):
world.wait(1) # For now, slow this down so that it works. TODO: fix it.
world.css_click("div.CodeMirror-lines", index=index) world.css_click("div.CodeMirror-lines", index=index)
world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')") world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')")
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
......
...@@ -48,9 +48,7 @@ def click_component_from_menu(category, boilerplate, expected_css): ...@@ -48,9 +48,7 @@ def click_component_from_menu(category, boilerplate, expected_css):
elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category) elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category)
elements = world.css_find(elem_css) elements = world.css_find(elem_css)
assert_equal(len(elements), 1) assert_equal(len(elements), 1)
world.wait_for(lambda _driver: world.css_visible(elem_css)) world.css_click(elem_css)
world.css_click(elem_css, success_condition=lambda: 1 == len(world.css_find(expected_css)))
@world.absorb @world.absorb
def edit_component_and_select_settings(): def edit_component_and_select_settings():
......
...@@ -87,7 +87,7 @@ Feature: Course Settings ...@@ -87,7 +87,7 @@ Feature: Course Settings
Given I have opened a new course in Studio Given I have opened a new course in Studio
When I select Schedule and Details When I select Schedule and Details
And I change the "Course Start Date" field to "" And I change the "Course Start Date" field to ""
Then the save button is disabled Then the save notification button is disabled
Scenario: User can upload course image Scenario: User can upload course image
Given I have opened a new course in Studio Given I have opened a new course in Studio
......
...@@ -113,7 +113,7 @@ def test_i_have_entered_a_new_course_start_date(step): ...@@ -113,7 +113,7 @@ def test_i_have_entered_a_new_course_start_date(step):
@step('The warning about course start date goes away$') @step('The warning about course start date goes away$')
def test_the_warning_about_course_start_date_goes_away(step): def test_the_warning_about_course_start_date_goes_away(step):
assert_equal(0, len(world.css_find('.message-error'))) assert world.is_css_not_present('.message-error')
assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class'))
assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
......
...@@ -5,7 +5,7 @@ from lettuce import world, step ...@@ -5,7 +5,7 @@ from lettuce import world, step
from common import create_studio_user from common import create_studio_user
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from auth.authz import get_course_groupname_for_role, get_user_by_email from auth.authz import get_course_groupname_for_role, get_user_by_email
from nose.tools import assert_true # pylint: disable=E0611 from nose.tools import assert_true, assert_in # pylint: disable=E0611
PASSWORD = 'test' PASSWORD = 'test'
EMAIL_EXTENSION = '@edx.org' EMAIL_EXTENSION = '@edx.org'
...@@ -110,36 +110,36 @@ def other_user_login(_step, name): ...@@ -110,36 +110,36 @@ def other_user_login(_step, name):
@step(u'I( do not)? see the course on my page') @step(u'I( do not)? see the course on my page')
@step(u's?he does( not)? see the course on (his|her) page') @step(u's?he does( not)? see the course on (his|her) page')
def see_course(_step, inverted, gender='self'): def see_course(_step, do_not_see, gender='self'):
class_css = 'h3.course-title' class_css = 'h3.course-title'
all_courses = world.css_find(class_css, wait_time=1) if do_not_see:
all_names = [item.html for item in all_courses] assert world.is_css_not_present(class_css)
if inverted:
assert not world.scenario_dict['COURSE'].display_name in all_names
else: else:
assert world.scenario_dict['COURSE'].display_name in all_names all_courses = world.css_find(class_css)
all_names = [item.html for item in all_courses]
assert_in(world.scenario_dict['COURSE'].display_name, all_names)
@step(u'"([^"]*)" should( not)? be marked as an admin') @step(u'"([^"]*)" should( not)? be marked as an admin')
def marked_as_admin(_step, name, inverted): def marked_as_admin(_step, name, not_marked_admin):
flag_css = '.user-item[data-email="{email}"] .flag-role.flag-role-admin'.format( flag_css = '.user-item[data-email="{email}"] .flag-role.flag-role-admin'.format(
email=name+EMAIL_EXTENSION) email=name+EMAIL_EXTENSION)
if inverted: if not_marked_admin:
assert world.is_css_not_present(flag_css) assert world.is_css_not_present(flag_css)
else: else:
assert world.is_css_present(flag_css) assert world.is_css_present(flag_css)
@step(u'I should( not)? be marked as an admin') @step(u'I should( not)? be marked as an admin')
def self_marked_as_admin(_step, inverted): def self_marked_as_admin(_step, not_marked_admin):
return marked_as_admin(_step, "robot+studio", inverted) return marked_as_admin(_step, "robot+studio", not_marked_admin)
@step(u'I can(not)? delete users') @step(u'I can(not)? delete users')
@step(u's?he can(not)? delete users') @step(u's?he can(not)? delete users')
def can_delete_users(_step, inverted): def can_delete_users(_step, can_not_delete):
to_delete_css = 'a.remove-user' to_delete_css = 'a.remove-user'
if inverted: if can_not_delete:
assert world.is_css_not_present(to_delete_css) assert world.is_css_not_present(to_delete_css)
else: else:
assert world.is_css_present(to_delete_css) assert world.is_css_present(to_delete_css)
...@@ -147,9 +147,9 @@ def can_delete_users(_step, inverted): ...@@ -147,9 +147,9 @@ def can_delete_users(_step, inverted):
@step(u'I can(not)? add users') @step(u'I can(not)? add users')
@step(u's?he can(not)? add users') @step(u's?he can(not)? add users')
def can_add_users(_step, inverted): def can_add_users(_step, can_not_add):
add_css = 'a.create-user-button' add_css = 'a.create-user-button'
if inverted: if can_not_add:
assert world.is_css_not_present(add_css) assert world.is_css_not_present(add_css)
else: else:
assert world.is_css_present(add_css) assert world.is_css_present(add_css)
...@@ -157,13 +157,13 @@ def can_add_users(_step, inverted): ...@@ -157,13 +157,13 @@ def can_add_users(_step, inverted):
@step(u'I can(not)? make ("([^"]*)"|myself) a course team admin') @step(u'I can(not)? make ("([^"]*)"|myself) a course team admin')
@step(u's?he can(not)? make ("([^"]*)"|me) a course team admin') @step(u's?he can(not)? make ("([^"]*)"|me) a course team admin')
def can_make_course_admin(_step, inverted, outer_capture, name): def can_make_course_admin(_step, can_not_make_admin, outer_capture, name):
if outer_capture == "myself": if outer_capture == "myself":
email = world.scenario_dict["USER"].email email = world.scenario_dict["USER"].email
else: else:
email = name + EMAIL_EXTENSION email = name + EMAIL_EXTENSION
add_button_css = '.user-item[data-email="{email}"] .add-admin-role'.format(email=email) add_button_css = '.user-item[data-email="{email}"] .add-admin-role'.format(email=email)
if inverted: if can_not_make_admin:
assert world.is_css_not_present(add_button_css) assert world.is_css_not_present(add_button_css)
else: else:
assert world.is_css_present(add_button_css) assert world.is_css_present(add_button_css)
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
from lettuce import world, step from lettuce import world, step
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from common import type_in_codemirror from common import type_in_codemirror
from nose.tools import assert_in # pylint: disable=E0611
@step(u'I go to the course updates page') @step(u'I go to the course updates page')
...@@ -21,14 +22,17 @@ def add_update(_step, text): ...@@ -21,14 +22,17 @@ def add_update(_step, text):
change_text(text) change_text(text)
@step(u'I should( not)? see the update "([^"]*)"$') @step(u'I should see the update "([^"]*)"$')
def check_update(_step, doesnt_see_update, text): def check_update(_step, text):
update_css = 'div.update-contents' update_css = 'div.update-contents'
update = world.css_find(update_css, wait_time=1) update_html = world.css_find(update_css).html
if doesnt_see_update: assert_in(text, update_html)
assert len(update) == 0 or not text in update.html
else:
assert text in update.html @step(u'I should not see the update "([^"]*)"$')
def check_no_update(_step, text):
update_css = 'div.update-contents'
assert world.is_css_not_present(update_css)
@step(u'I modify the text to "([^"]*)"$') @step(u'I modify the text to "([^"]*)"$')
......
...@@ -11,3 +11,19 @@ Feature: Create Course ...@@ -11,3 +11,19 @@ Feature: Create Course
And I press the "Create" button And I press the "Create" button
Then the Courseware page has loaded in Studio Then the Courseware page has loaded in Studio
And I see a link for adding a new section And I see a link for adding a new section
Scenario: Error message when org/course/run tuple is too long
Given There are no courses
And I am logged into Studio
When I click the New Course button
And I create a course with "course name", "012345678901234567890123456789", "012345678901234567890123456789", and "0123456"
Then I see an error about the length of the org/course/run tuple
And the "Create" button is disabled
Scenario: Course name is not included in the "too long" computation
Given There are no courses
And I am logged into Studio
When I click the New Course button
And I create a course with "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789", "org", "coursenum", and "run"
And I press the "Create" button
Then the Courseware page has loaded in Studio
...@@ -23,6 +23,11 @@ def i_fill_in_a_new_course_information(step): ...@@ -23,6 +23,11 @@ def i_fill_in_a_new_course_information(step):
fill_in_course_info() fill_in_course_info()
@step('I create a course with "([^"]*)", "([^"]*)", "([^"]*)", and "([^"]*)"')
def i_create_course(step, name, org, number, run):
fill_in_course_info(name=name, org=org, num=number, run=run)
@step('I create a new course$') @step('I create a new course$')
def i_create_a_course(step): def i_create_a_course(step):
create_a_course() create_a_course()
...@@ -33,6 +38,11 @@ def i_click_the_course_link_in_my_courses(step): ...@@ -33,6 +38,11 @@ def i_click_the_course_link_in_my_courses(step):
course_css = 'a.course-link' course_css = 'a.course-link'
world.css_click(course_css) world.css_click(course_css)
@step('I see an error about the length of the org/course/run tuple')
def i_see_error_about_length(step):
assert world.css_has_text('#course_creation_error', 'The combined length of the organization, course number, and course run fields cannot be more than 65 characters.')
############ ASSERTIONS ################### ############ ASSERTIONS ###################
......
...@@ -86,7 +86,7 @@ Feature: Course Grading ...@@ -86,7 +86,7 @@ Feature: Course Grading
And I have populated the course And I have populated the course
And I am viewing the grading settings And I am viewing the grading settings
When I change assignment type "Homework" to "" When I change assignment type "Homework" to ""
Then the save button is disabled Then the save notification button is disabled
# IE and Safari cannot type in grade range name # IE and Safari cannot type in grade range name
@skip_internetexplorer @skip_internetexplorer
......
...@@ -5,6 +5,7 @@ from lettuce import world, step ...@@ -5,6 +5,7 @@ from lettuce import world, step
from common import * from common import *
from terrain.steps import reload_the_page from terrain.steps import reload_the_page
from selenium.common.exceptions import InvalidElementStateException from selenium.common.exceptions import InvalidElementStateException
from nose.tools import assert_in, assert_not_in # pylint: disable=E0611
@step(u'I am viewing the grading settings') @step(u'I am viewing the grading settings')
...@@ -65,21 +66,25 @@ def change_assignment_name(step, old_name, new_name): ...@@ -65,21 +66,25 @@ def change_assignment_name(step, old_name, new_name):
@step(u'I go back to the main course page') @step(u'I go back to the main course page')
def main_course_page(step): def main_course_page(step):
main_page_link_css = 'a[href="/%s/%s/course/%s"]' % (world.scenario_dict['COURSE'].org, main_page_link = '/{}/{}/course/{}'.format(world.scenario_dict['COURSE'].org,
world.scenario_dict['COURSE'].number, world.scenario_dict['COURSE'].number,
world.scenario_dict['COURSE'].display_name.replace(' ', '_'),) world.scenario_dict['COURSE'].display_name.replace(' ', '_'),)
world.css_click(main_page_link_css) world.visit(main_page_link)
assert_in('Course Outline', world.css_text('h1.page-header'))
@step(u'I do( not)? see the assignment name "([^"]*)"$') @step(u'I do( not)? see the assignment name "([^"]*)"$')
def see_assignment_name(step, do_not, name): def see_assignment_name(step, do_not, name):
assignment_menu_css = 'ul.menu > li > a' assignment_menu_css = 'ul.menu > li > a'
# First assert that it is there, make take a bit to redraw
assert world.css_find(assignment_menu_css)
assignment_menu = world.css_find(assignment_menu_css) assignment_menu = world.css_find(assignment_menu_css)
allnames = [item.html for item in assignment_menu] allnames = [item.html for item in assignment_menu]
if do_not: if do_not:
assert not name in allnames assert_not_in(name, allnames)
else: else:
assert name in allnames assert_in(name, allnames)
@step(u'I delete the assignment type "([^"]*)"$') @step(u'I delete the assignment type "([^"]*)"$')
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
#pylint: disable=C0111 #pylint: disable=C0111
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_equal # pylint: disable=E0611 from nose.tools import assert_equal, assert_true # pylint: disable=E0611
from common import type_in_codemirror from common import type_in_codemirror
DISPLAY_NAME = "Display Name" DISPLAY_NAME = "Display Name"
...@@ -197,9 +197,20 @@ def high_level_source_in_editor(step): ...@@ -197,9 +197,20 @@ def high_level_source_in_editor(step):
def verify_high_level_source_links(step, visible): def verify_high_level_source_links(step, visible):
assert_equal(visible, world.is_css_present('.launch-latex-compiler')) if visible:
assert_true(world.is_css_present('.launch-latex-compiler'),
msg="Expected to find the latex button but it is not present.")
else:
assert_true(world.is_css_not_present('.launch-latex-compiler'),
msg="Expected not to find the latex button but it is present.")
world.cancel_component(step) world.cancel_component(step)
assert_equal(visible, world.is_css_present('.upload-button')) if visible:
assert_true(world.is_css_present('.upload-button'),
msg="Expected to find the upload button but it is not present.")
else:
assert_true(world.is_css_not_present('.upload-button'),
msg="Expected not to find the upload button but it is present.")
def verify_modified_weight(): def verify_modified_weight():
......
...@@ -12,7 +12,7 @@ def i_fill_in_the_registration_form(step): ...@@ -12,7 +12,7 @@ def i_fill_in_the_registration_form(step):
register_form.find_by_name('password').fill('test') register_form.find_by_name('password').fill('test')
register_form.find_by_name('username').fill('robot-studio') register_form.find_by_name('username').fill('robot-studio')
register_form.find_by_name('name').fill('Robot Studio') register_form.find_by_name('name').fill('Robot Studio')
register_form.find_by_name('terms_of_service').check() register_form.find_by_name('terms_of_service').click()
world.retry_on_exception(fill_in_reg_form) world.retry_on_exception(fill_in_reg_form)
......
...@@ -2,12 +2,11 @@ ...@@ -2,12 +2,11 @@
#pylint: disable=W0621 #pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from selenium.webdriver.common.keys import Keys
from nose.tools import assert_true # pylint: disable=E0611 from nose.tools import assert_true # pylint: disable=E0611
@step(u'I go to the static pages page') @step(u'I go to the static pages page')
def go_to_static(_step): def go_to_static(step):
menu_css = 'li.nav-course-courseware' menu_css = 'li.nav-course-courseware'
static_css = 'li.nav-course-courseware-pages a' static_css = 'li.nav-course-courseware-pages a'
world.css_click(menu_css) world.css_click(menu_css)
...@@ -15,25 +14,37 @@ def go_to_static(_step): ...@@ -15,25 +14,37 @@ def go_to_static(_step):
@step(u'I add a new page') @step(u'I add a new page')
def add_page(_step): def add_page(step):
button_css = 'a.new-button' button_css = 'a.new-button'
world.css_click(button_css) world.css_click(button_css)
@step(u'I should( not)? see a "([^"]*)" static page$') @step(u'I should not see a "([^"]*)" static page$')
def see_page(_step, doesnt, page): def not_see_page(step, page):
# Either there are no pages, or there are pages but
# not the one I expect not to exist.
should_exist = not doesnt # Since our only test for deletion right now deletes
# the only static page that existed, our success criteria
# will be that there are no static pages.
# In the future we can refactor if necessary.
tabs_css = 'li.component'
assert (world.is_css_not_present(tabs_css, wait_time=30))
@step(u'I should see a "([^"]*)" static page$')
def see_page(step, page):
# Need to retry here because the element # Need to retry here because the element
# will sometimes exist before the HTML content is loaded # will sometimes exist before the HTML content is loaded
exists_func = lambda(driver): page_exists(page) == should_exist exists_func = lambda(driver): page_exists(page)
world.wait_for(exists_func) world.wait_for(exists_func)
assert_true(exists_func(None)) assert_true(exists_func(None))
@step(u'I "([^"]*)" the "([^"]*)" page$') @step(u'I "([^"]*)" the "([^"]*)" page$')
def click_edit_delete(_step, edit_delete, page): def click_edit_delete(step, edit_delete, page):
button_css = 'a.%s-button' % edit_delete button_css = 'a.%s-button' % edit_delete
index = get_index(page) index = get_index(page)
assert index is not None assert index is not None
...@@ -41,7 +52,7 @@ def click_edit_delete(_step, edit_delete, page): ...@@ -41,7 +52,7 @@ def click_edit_delete(_step, edit_delete, page):
@step(u'I change the name to "([^"]*)"$') @step(u'I change the name to "([^"]*)"$')
def change_name(_step, new_name): def change_name(step, new_name):
settings_css = '#settings-mode a' settings_css = '#settings-mode a'
world.css_click(settings_css) world.css_click(settings_css)
input_css = 'input.setting-input' input_css = 'input.setting-input'
...@@ -56,9 +67,10 @@ def get_index(name): ...@@ -56,9 +67,10 @@ def get_index(name):
page_name_css = 'section[data-type="HTMLModule"]' page_name_css = 'section[data-type="HTMLModule"]'
all_pages = world.css_find(page_name_css) all_pages = world.css_find(page_name_css)
for i in range(len(all_pages)): for i in range(len(all_pages)):
if world.css_html(page_name_css, index=i) == '\n {name}\n'.format(name=name): if world.retry_on_exception(lambda: all_pages[i].html) == '\n {name}\n'.format(name=name):
return i return i
return None return None
def page_exists(page): def page_exists(page):
return get_index(page) is not None return get_index(page) is not None
...@@ -7,6 +7,8 @@ import requests ...@@ -7,6 +7,8 @@ import requests
import string import string
import random import random
import os import os
from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
...@@ -32,7 +34,7 @@ def upload_file(_step, file_name): ...@@ -32,7 +34,7 @@ def upload_file(_step, file_name):
@step(u'I upload the files (".*")$') @step(u'I upload the files (".*")$')
def upload_file(_step, files_string): def upload_files(_step, files_string):
# Turn files_string to a list of file names # Turn files_string to a list of file names
files = files_string.split(",") files = files_string.split(",")
files = map(lambda x: string.strip(x, ' "\''), files) files = map(lambda x: string.strip(x, ' "\''), files)
...@@ -48,19 +50,29 @@ def upload_file(_step, files_string): ...@@ -48,19 +50,29 @@ def upload_file(_step, files_string):
world.css_click(close_css) world.css_click(close_css)
@step(u'I should( not)? see the file "([^"]*)" was uploaded$') @step(u'I should not see the file "([^"]*)" was uploaded$')
def check_upload(_step, do_not_see_file, file_name): def check_not_there(_step, file_name):
# Either there are no files, or there are files but
# not the one I expect not to exist.
# Since our only test for deletion right now deletes
# the only file that was uploaded, our success criteria
# will be that there are no files.
# In the future we can refactor if necessary.
names_css = 'td.name-col > a.filename'
assert(world.is_css_not_present(names_css))
@step(u'I should see the file "([^"]*)" was uploaded$')
def check_upload(_step, file_name):
index = get_index(file_name) index = get_index(file_name)
if do_not_see_file: assert_not_equal(index, -1)
assert index == -1
else:
assert index != -1
@step(u'The url for the file "([^"]*)" is valid$') @step(u'The url for the file "([^"]*)" is valid$')
def check_url(_step, file_name): def check_url(_step, file_name):
r = get_file(file_name) r = get_file(file_name)
assert r.status_code == 200 assert_equal(r.status_code , 200)
@step(u'I delete the file "([^"]*)"$') @step(u'I delete the file "([^"]*)"$')
...@@ -71,7 +83,7 @@ def delete_file(_step, file_name): ...@@ -71,7 +83,7 @@ def delete_file(_step, file_name):
world.css_click(delete_css, index=index) world.css_click(delete_css, index=index)
prompt_confirm_css = 'li.nav-item > a.action-primary' prompt_confirm_css = 'li.nav-item > a.action-primary'
world.css_click(prompt_confirm_css, success_condition=lambda: not world.css_visible(prompt_confirm_css)) world.css_click(prompt_confirm_css)
@step(u'I should see only one "([^"]*)"$') @step(u'I should see only one "([^"]*)"$')
......
...@@ -2,13 +2,8 @@ ...@@ -2,13 +2,8 @@
### Script for cloning a course ### Script for cloning a course
### ###
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.course_module import CourseDescriptor
from .prompt import query_yes_no from .prompt import query_yes_no
from contentstore.utils import delete_course_and_groups
from auth.authz import _delete_course_group
# #
...@@ -30,20 +25,6 @@ class Command(BaseCommand): ...@@ -30,20 +25,6 @@ class Command(BaseCommand):
if commit: if commit:
print 'Actually going to delete the course from DB....' print 'Actually going to delete the course from DB....'
ms = modulestore('direct')
cs = contentstore()
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("Deleting course {0}. Confirm?".format(course_id), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"): if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(course_id) delete_course_and_groups(course_id, commit)
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
if commit:
try:
_delete_course_group(loc)
except Exception as err:
print("Error in deleting course groups for {0}: {1}".format(loc, err))
""" Unit tests for checklist methods in views.py. """ """ Unit tests for checklist methods in views.py. """
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from contentstore.views.checklist import expand_checklist_action_url
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -22,20 +23,16 @@ class ChecklistTestCase(CourseTestCase): ...@@ -22,20 +23,16 @@ class ChecklistTestCase(CourseTestCase):
def compare_checklists(self, persisted, request): def compare_checklists(self, persisted, request):
""" """
Handles url expansion as possible difference and descends into guts Handles url expansion as possible difference and descends into guts
:param persisted:
:param request:
""" """
self.assertEqual(persisted['short_description'], request['short_description']) self.assertEqual(persisted['short_description'], request['short_description'])
compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded')) expanded_checklist = expand_checklist_action_url(self.course, persisted)
pers, req = None, None for pers, req in zip(expanded_checklist['items'], request['items']):
for pers, req in zip(persisted['items'], request['items']):
self.assertEqual(pers['short_description'], req['short_description']) self.assertEqual(pers['short_description'], req['short_description'])
self.assertEqual(pers['long_description'], req['long_description']) self.assertEqual(pers['long_description'], req['long_description'])
self.assertEqual(pers['is_checked'], req['is_checked']) self.assertEqual(pers['is_checked'], req['is_checked'])
if compare_urls:
self.assertEqual(pers['action_url'], req['action_url']) self.assertEqual(pers['action_url'], req['action_url'])
self.assertEqual(pers['action_text'], req['action_text']) self.assertEqual(pers['action_text'], req['action_text'])
self.assertEqual(pers['action_external'], req['action_external']) self.assertEqual(pers['action_external'], req['action_external'])
def test_get_checklists(self): def test_get_checklists(self):
""" Tests the get checklists method. """ """ Tests the get checklists method. """
...@@ -46,6 +43,11 @@ class ChecklistTestCase(CourseTestCase): ...@@ -46,6 +43,11 @@ class ChecklistTestCase(CourseTestCase):
}) })
response = self.client.get(checklists_url) response = self.client.get(checklists_url)
self.assertContains(response, "Getting Started With Studio") self.assertContains(response, "Getting Started With Studio")
# Verify expansion of action URL happened.
self.assertContains(response, '/mitX/333/team/Checklists_Course')
# Verify persisted checklist does NOT have expanded URL.
checklist_0 = self.get_persisted_checklists()[0]
self.assertEqual('ManageUsers', get_action_url(checklist_0, 0))
payload = response.content payload = response.content
# Now delete the checklists from the course and verify they get repopulated (for courses # Now delete the checklists from the course and verify they get repopulated (for courses
...@@ -67,7 +69,11 @@ class ChecklistTestCase(CourseTestCase): ...@@ -67,7 +69,11 @@ class ChecklistTestCase(CourseTestCase):
'name': self.course.location.name}) 'name': self.course.location.name})
returned_checklists = json.loads(self.client.get(update_url).content) returned_checklists = json.loads(self.client.get(update_url).content)
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists): # Verify that persisted checklists do not have expanded action URLs.
# compare_checklists will verify that returned_checklists DO have expanded action URLs.
pers = self.get_persisted_checklists()
self.assertEqual('CourseOutline', get_first_item(pers[1]).get('action_url'))
for pay, resp in zip(pers, returned_checklists):
self.compare_checklists(pay, resp) self.compare_checklists(pay, resp)
def test_update_checklists_index_ignored_on_get(self): def test_update_checklists_index_ignored_on_get(self):
...@@ -103,19 +109,21 @@ class ChecklistTestCase(CourseTestCase): ...@@ -103,19 +109,21 @@ class ChecklistTestCase(CourseTestCase):
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org, update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
'course': self.course.location.course, 'course': self.course.location.course,
'name': self.course.location.name, 'name': self.course.location.name,
'checklist_index': 2}) 'checklist_index': 1})
def get_first_item(checklist):
return checklist['items'][0]
payload = self.course.checklists[2] payload = self.course.checklists[1]
self.assertFalse(get_first_item(payload).get('is_checked')) self.assertFalse(get_first_item(payload).get('is_checked'))
self.assertEqual('CourseOutline', get_first_item(payload).get('action_url'))
get_first_item(payload)['is_checked'] = True get_first_item(payload)['is_checked'] = True
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content) returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
self.assertTrue(get_first_item(returned_checklist).get('is_checked')) self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
pers = self.get_persisted_checklists() persisted_checklist = self.get_persisted_checklists()[1]
self.compare_checklists(pers[2], returned_checklist) # Verify that persisted checklist does not have expanded action URLs.
# compare_checklists will verify that returned_checklist DOES have expanded action URLs.
self.assertEqual('CourseOutline', get_first_item(persisted_checklist).get('action_url'))
self.compare_checklists(persisted_checklist, returned_checklist)
def test_update_checklists_delete_unsupported(self): def test_update_checklists_delete_unsupported(self):
""" Delete operation is not supported. """ """ Delete operation is not supported. """
...@@ -125,3 +133,36 @@ class ChecklistTestCase(CourseTestCase): ...@@ -125,3 +133,36 @@ class ChecklistTestCase(CourseTestCase):
'checklist_index': 100}) 'checklist_index': 100})
response = self.client.delete(update_url) response = self.client.delete(update_url)
self.assertEqual(response.status_code, 405) self.assertEqual(response.status_code, 405)
def test_expand_checklist_action_url(self):
"""
Tests the method to expand checklist action url.
"""
def test_expansion(checklist, index, stored, expanded):
"""
Tests that the expected expanded value is returned for the item at the given index.
Also verifies that the original checklist is not modified.
"""
self.assertEqual(get_action_url(checklist, index), stored)
expanded_checklist = expand_checklist_action_url(self.course, checklist)
self.assertEqual(get_action_url(expanded_checklist, index), expanded)
# Verify no side effect in the original list.
self.assertEqual(get_action_url(checklist, index), stored)
test_expansion(self.course.checklists[0], 0, 'ManageUsers', '/mitX/333/team/Checklists_Course')
test_expansion(self.course.checklists[1], 1, 'CourseOutline', '/mitX/333/course/Checklists_Course')
test_expansion(self.course.checklists[2], 0, 'http://help.edge.edx.org/', 'http://help.edge.edx.org/')
def get_first_item(checklist):
""" Returns the first item from the checklist. """
return checklist['items'][0]
def get_action_url(checklist, index):
"""
Returns the action_url for the item at the specified index in the given checklist.
"""
return checklist['items'][index]['action_url']
...@@ -55,6 +55,8 @@ from uuid import uuid4 ...@@ -55,6 +55,8 @@ from uuid import uuid4
from pymongo import MongoClient from pymongo import MongoClient
from student.models import CourseEnrollment from student.models import CourseEnrollment
from contentstore.utils import delete_course_and_groups
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
...@@ -1290,6 +1292,28 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1290,6 +1292,28 @@ class ContentStoreTest(ModuleStoreTestCase):
test_course_data = self.assert_created_course(number_suffix=uuid4().hex) test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data))) self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data)))
def test_forum_unseeding_on_delete(self):
"""Test new course creation and verify forum unseeding """
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data)))
course_id = self._get_course_id(test_course_data)
delete_course_and_groups(course_id, commit=True)
self.assertFalse(are_permissions_roles_seeded(course_id))
def test_forum_unseeding_with_multiple_courses(self):
"""Test new course creation and verify forum unseeding when there are multiple courses"""
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
second_course_data = self.assert_created_course(number_suffix=uuid4().hex)
# unseed the forums for the first course
course_id = self._get_course_id(test_course_data)
delete_course_and_groups(course_id, commit=True)
self.assertFalse(are_permissions_roles_seeded(course_id))
second_course_id = self._get_course_id(second_course_data)
# permissions should still be there for the other course
self.assertTrue(are_permissions_roles_seeded(second_course_id))
def _get_course_id(self, test_course_data): def _get_course_id(self, test_course_data):
"""Returns the course ID (org/number/run).""" """Returns the course ID (org/number/run)."""
return "{org}/{number}/{run}".format(**test_course_data) return "{org}/{number}/{run}".format(**test_course_data)
......
...@@ -5,12 +5,16 @@ from xmodule.modulestore import Location ...@@ -5,12 +5,16 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from django.core.urlresolvers import reverse from xmodule.contentstore.django import contentstore
import copy import copy
import logging import logging
import re import re
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_comment_common.utils import unseed_permissions_roles
from auth.authz import _delete_course_group
from xmodule.modulestore.store_utilities import delete_course
from xmodule.course_module import CourseDescriptor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -20,6 +24,31 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"} ...@@ -20,6 +24,31 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]]) EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
def delete_course_and_groups(course_id, commit=False):
"""
This deletes the courseware associated with a course_id as well as cleaning update_item
the various user table stuff (groups, permissions, etc.)
"""
module_store = modulestore('direct')
content_store = contentstore()
org, course_num, run = course_id.split("/")
module_store.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
loc = CourseDescriptor.id_to_location(course_id)
if delete_course(module_store, content_store, loc, commit):
print 'removing forums permissions and roles...'
unseed_permissions_roles(course_id)
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
if commit:
try:
_delete_course_group(loc)
except Exception as err:
log.error("Error in deleting course groups for {0}: {1}".format(loc, err))
def get_modulestore(category_or_location): def get_modulestore(category_or_location):
""" """
Returns the correct modulestore to use for modifying the specified location Returns the correct modulestore to use for modifying the specified location
......
import json import json
import copy
from util.json_request import JsonResponse from util.json_request import JsonResponse
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
...@@ -32,19 +33,16 @@ def get_checklists(request, org, course, name): ...@@ -32,19 +33,16 @@ def get_checklists(request, org, course, name):
# If course was created before checklists were introduced, copy them over # If course was created before checklists were introduced, copy them over
# from the template. # from the template.
copied = False
if not course_module.checklists: if not course_module.checklists:
course_module.checklists = CourseDescriptor.checklists.default course_module.checklists = CourseDescriptor.checklists.default
copied = True
checklists, modified = expand_checklist_action_urls(course_module)
if copied or modified:
course_module.save() course_module.save()
modulestore.update_metadata(location, own_metadata(course_module)) modulestore.update_metadata(location, own_metadata(course_module))
expanded_checklists = expand_all_action_urls(course_module)
return render_to_response('checklists.html', return render_to_response('checklists.html',
{ {
'context_course': course_module, 'context_course': course_module,
'checklists': checklists 'checklists': expanded_checklists
}) })
...@@ -68,14 +66,20 @@ def update_checklist(request, org, course, name, checklist_index=None): ...@@ -68,14 +66,20 @@ def update_checklist(request, org, course, name, checklist_index=None):
if request.method in ("POST", "PUT"): if request.method in ("POST", "PUT"):
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists): if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
index = int(checklist_index) index = int(checklist_index)
course_module.checklists[index] = json.loads(request.body) persisted_checklist = course_module.checklists[index]
modified_checklist = json.loads(request.body)
# Only thing the user can modify is the "checked" state.
# We don't want to persist what comes back from the client because it will
# include the expanded action URLs (which are non-portable).
for item_index, item in enumerate(modified_checklist.get('items')):
persisted_checklist['items'][item_index]['is_checked'] = item['is_checked']
# seeming noop which triggers kvs to record that the metadata is # seeming noop which triggers kvs to record that the metadata is
# not default # not default
course_module.checklists = course_module.checklists course_module.checklists = course_module.checklists
checklists, _ = expand_checklist_action_urls(course_module)
course_module.save() course_module.save()
modulestore.update_metadata(location, own_metadata(course_module)) modulestore.update_metadata(location, own_metadata(course_module))
return JsonResponse(checklists[index]) expanded_checklist = expand_checklist_action_url(course_module, persisted_checklist)
return JsonResponse(expanded_checklist)
else: else:
return HttpResponseBadRequest( return HttpResponseBadRequest(
( "Could not save checklist state because the checklist index " ( "Could not save checklist state because the checklist index "
...@@ -85,23 +89,30 @@ def update_checklist(request, org, course, name, checklist_index=None): ...@@ -85,23 +89,30 @@ def update_checklist(request, org, course, name, checklist_index=None):
elif request.method == 'GET': elif request.method == 'GET':
# In the JavaScript view initialize method, we do a fetch to get all # In the JavaScript view initialize method, we do a fetch to get all
# the checklists. # the checklists.
checklists, modified = expand_checklist_action_urls(course_module) expanded_checklists = expand_all_action_urls(course_module)
if modified: return JsonResponse(expanded_checklists)
course_module.save()
modulestore.update_metadata(location, own_metadata(course_module))
return JsonResponse(checklists) def expand_all_action_urls(course_module):
"""
Gets the checklists out of the course module and expands their action urls.
Returns a copy of the checklists with modified urls, without modifying the persisted version
of the checklists.
"""
expanded_checklists = []
for checklist in course_module.checklists:
expanded_checklists.append(expand_checklist_action_url(course_module, checklist))
return expanded_checklists
def expand_checklist_action_urls(course_module): def expand_checklist_action_url(course_module, checklist):
""" """
Gets the checklists out of the course module and expands their action urls Expands the action URLs for a given checklist and returns the modified version.
if they have not yet been expanded.
Returns the checklists with modified urls, as well as a boolean The method does a copy of the input checklist and does not modify the input argument.
indicating whether or not the checklists were modified.
""" """
checklists = course_module.checklists expanded_checklist = copy.deepcopy(checklist)
modified = False
urlconf_map = { urlconf_map = {
"ManageUsers": "manage_users", "ManageUsers": "manage_users",
"SettingsDetails": "settings_details", "SettingsDetails": "settings_details",
...@@ -109,19 +120,15 @@ def expand_checklist_action_urls(course_module): ...@@ -109,19 +120,15 @@ def expand_checklist_action_urls(course_module):
"CourseOutline": "course_index", "CourseOutline": "course_index",
"Checklists": "checklists", "Checklists": "checklists",
} }
for checklist in checklists: for item in expanded_checklist.get('items'):
if not checklist.get('action_urls_expanded', False): action_url = item.get('action_url')
for item in checklist.get('items'): if action_url not in urlconf_map:
action_url = item.get('action_url') continue
if action_url not in urlconf_map: urlconf_name = urlconf_map[action_url]
continue item['action_url'] = reverse(urlconf_name, kwargs={
urlconf_name = urlconf_map[action_url] 'org': course_module.location.org,
item['action_url'] = reverse(urlconf_name, kwargs={ 'course': course_module.location.course,
'org': course_module.location.org, 'name': course_module.location.name,
'course': course_module.location.course, })
'name': course_module.location.name,
}) return expanded_checklist
checklist['action_urls_expanded'] = True
modified = True
return checklists, modified
...@@ -60,7 +60,7 @@ def import_course(request, org, course, name): ...@@ -60,7 +60,7 @@ def import_course(request, org, course, name):
`filename` is truncted on creation. Additionally removes dirname on `filename` is truncted on creation. Additionally removes dirname on
exit. exit.
""" """
open("file", "w").close() open(filename, "w").close()
try: try:
yield filename yield filename
finally: finally:
...@@ -90,7 +90,7 @@ def import_course(request, org, course, name): ...@@ -90,7 +90,7 @@ def import_course(request, org, course, name):
try: try:
matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"])
content_range = matches.groupdict() content_range = matches.groupdict()
except KeyError: # Single chunk except KeyError: # Single chunk
# no Content-Range header, so make one that will work # no Content-Range header, so make one that will work
content_range = {'start': 0, 'stop': 1, 'end': 2} content_range = {'start': 0, 'stop': 1, 'end': 2}
...@@ -154,7 +154,7 @@ def import_course(request, org, course, name): ...@@ -154,7 +154,7 @@ def import_course(request, org, course, name):
sf.write("Extracting") sf.write("Extracting")
tar_file = tarfile.open(temp_filepath) tar_file = tarfile.open(temp_filepath)
tar_file.extractall(course_dir + '/') tar_file.extractall((course_dir + '/').encode('utf-8'))
with open(status_file, 'w+') as sf: with open(status_file, 'w+') as sf:
sf.write("Verifying") sf.write("Verifying")
......
import logging
from uuid import uuid4 from uuid import uuid4
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -18,6 +20,7 @@ __all__ = ['save_item', 'create_item', 'delete_item'] ...@@ -18,6 +20,7 @@ __all__ = ['save_item', 'create_item', 'delete_item']
# cdodge: these are categories which should not be parented, they are detached from the hierarchy # cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
log = logging.getLogger(__name__)
@login_required @login_required
@expect_json @expect_json
...@@ -32,7 +35,25 @@ def save_item(request): ...@@ -32,7 +35,25 @@ def save_item(request):
""" """
# The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a # The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a
# little smarter and able to pass something more akin to {unset: [field, field]} # little smarter and able to pass something more akin to {unset: [field, field]}
item_location = request.POST['id']
try:
item_location = request.POST['id']
except KeyError:
import inspect
log.exception(
'''Request missing required attribute 'id'.
Request info:
%s
Caller:
Function %s in file %s
''',
request.META,
inspect.currentframe().f_back.f_code.co_name,
inspect.currentframe().f_back.f_code.co_filename
)
return HttpResponseBadRequest()
# check permissions for this user within this course # check permissions for this user within this course
if not has_access(request.user, item_location): if not has_access(request.user, item_location):
......
...@@ -2,7 +2,6 @@ from xmodule.modulestore import Location ...@@ -2,7 +2,6 @@ from xmodule.modulestore import Location
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xblock.fields import Scope from xblock.fields import Scope
from xmodule.course_module import CourseDescriptor
from cms.xmodule_namespace import CmsBlockMixin from cms.xmodule_namespace import CmsBlockMixin
...@@ -20,7 +19,9 @@ class CourseMetadata(object): ...@@ -20,7 +19,9 @@ class CourseMetadata(object):
'enrollment_end', 'enrollment_end',
'tabs', 'tabs',
'graceperiod', 'graceperiod',
'checklists'] 'checklists',
'show_timezone'
]
@classmethod @classmethod
def fetch(cls, course_location): def fetch(cls, course_location):
......
...@@ -127,6 +127,10 @@ LOGGING = get_logger_config(LOG_DIR, ...@@ -127,6 +127,10 @@ LOGGING = get_logger_config(LOG_DIR,
#theming start: #theming start:
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX') PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX')
# Event Tracking
if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS:
TRACKING_IGNORE_URL_PATTERNS = ENV_TOKENS.get("TRACKING_IGNORE_URL_PATTERNS")
################ SECURE AUTH ITEMS ############################### ################ SECURE AUTH ITEMS ###############################
# Secret things: passwords, access keys, etc. # Secret things: passwords, access keys, etc.
...@@ -147,7 +151,12 @@ MODULESTORE = AUTH_TOKENS['MODULESTORE'] ...@@ -147,7 +151,12 @@ MODULESTORE = AUTH_TOKENS['MODULESTORE']
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
# Datadog for events! # Datadog for events!
DATADOG_API = AUTH_TOKENS.get("DATADOG_API") DATADOG = AUTH_TOKENS.get("DATADOG", {})
DATADOG.update(ENV_TOKENS.get("DATADOG", {}))
# TODO: deprecated (compatibility with previous settings)
if 'DATADOG_API' in AUTH_TOKENS:
DATADOG['api_key'] = AUTH_TOKENS['DATADOG_API']
# Celery Broker # Celery Broker
CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "") CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "")
...@@ -161,3 +170,6 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT, ...@@ -161,3 +170,6 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
CELERY_BROKER_PASSWORD, CELERY_BROKER_PASSWORD,
CELERY_BROKER_HOSTNAME, CELERY_BROKER_HOSTNAME,
CELERY_BROKER_VHOST) CELERY_BROKER_VHOST)
# Event tracking
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
...@@ -218,9 +218,6 @@ USE_L10N = True ...@@ -218,9 +218,6 @@ USE_L10N = True
# Localization strings (e.g. django.po) are under this directory # Localization strings (e.g. django.po) are under this directory
LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # mitx/conf/locale/ LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # mitx/conf/locale/
# Tracking
TRACK_MAX_EVENT = 10000
# Messages # Messages
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
...@@ -358,6 +355,9 @@ INSTALLED_APPS = ( ...@@ -358,6 +355,9 @@ INSTALLED_APPS = (
# Tracking # Tracking
'track', 'track',
# Monitoring
'datadog',
# For asset pipelining # For asset pipelining
'mitxmako', 'mitxmako',
'pipeline', 'pipeline',
...@@ -391,3 +391,20 @@ MKTG_URL_LINK_MAP = { ...@@ -391,3 +391,20 @@ MKTG_URL_LINK_MAP = {
} }
COURSES_WITH_UNSAFE_CODE = [] COURSES_WITH_UNSAFE_CODE = []
############################## EVENT TRACKING #################################
TRACK_MAX_EVENT = 10000
TRACKING_BACKENDS = {
'logger': {
'ENGINE': 'track.backends.logger.LoggerBackend',
'OPTIONS': {
'name': 'tracking'
}
}
}
# We're already logging events, and we don't want to capture user
# names/passwords. Heartbeat events are likely not interesting.
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
...@@ -60,8 +60,8 @@ class CMS.Views.ModuleEdit extends Backbone.View ...@@ -60,8 +60,8 @@ class CMS.Views.ModuleEdit extends Backbone.View
payload.parent_location = parent payload.parent_location = parent
$.post( $.post(
"/create_item" "/create_item"
payload payload
(data) => (data) =>
@model.set(id: data.id) @model.set(id: data.id)
@$el.data('id', data.id) @$el.data('id', data.id)
@render() @render()
...@@ -85,7 +85,7 @@ class CMS.Views.ModuleEdit extends Backbone.View ...@@ -85,7 +85,7 @@ class CMS.Views.ModuleEdit extends Backbone.View
data.metadata = _.extend(data.metadata || {}, @changedMetadata()) data.metadata = _.extend(data.metadata || {}, @changedMetadata())
@hideModal() @hideModal()
saving = new CMS.Views.Notification.Mini saving = new CMS.Views.Notification.Mini
title: gettext('Saving') + '&hellip;' title: gettext('Saving&hellip;')
saving.show() saving.show()
@model.save(data).done( => @model.save(data).done( =>
# # showToastMessage("Your changes have been saved.", null, 3) # # showToastMessage("Your changes have been saved.", null, 3)
......
...@@ -21,7 +21,7 @@ class CMS.Views.TabsEdit extends Backbone.View ...@@ -21,7 +21,7 @@ class CMS.Views.TabsEdit extends Backbone.View
forcePlaceholderSize: true forcePlaceholderSize: true
axis: 'y' axis: 'y'
items: '> .component' items: '> .component'
) )
tabMoved: (event, ui) => tabMoved: (event, ui) =>
tabs = [] tabs = []
...@@ -34,7 +34,7 @@ class CMS.Views.TabsEdit extends Backbone.View ...@@ -34,7 +34,7 @@ class CMS.Views.TabsEdit extends Backbone.View
$.ajax({ $.ajax({
type:'POST', type:'POST',
url: '/reorder_static_tabs', url: '/reorder_static_tabs',
data: JSON.stringify({ data: JSON.stringify({
tabs : tabs tabs : tabs
}), }),
...@@ -78,7 +78,7 @@ class CMS.Views.TabsEdit extends Backbone.View ...@@ -78,7 +78,7 @@ class CMS.Views.TabsEdit extends Backbone.View
course: course_location_analytics course: course_location_analytics
id: $component.data('id') id: $component.data('id')
deleting = new CMS.Views.Notification.Mini deleting = new CMS.Views.Notification.Mini
title: gettext('Deleting') + '&hellip;' title: gettext('Deleting&hellip;')
deleting.show() deleting.show()
$.post('/delete_item', { $.post('/delete_item', {
id: $component.data('id') id: $component.data('id')
......
...@@ -42,7 +42,7 @@ class CMS.Views.UnitEdit extends Backbone.View ...@@ -42,7 +42,7 @@ class CMS.Views.UnitEdit extends Backbone.View
payload = children : @components() payload = children : @components()
saving = new CMS.Views.Notification.Mini saving = new CMS.Views.Notification.Mini
title: gettext('Saving') + '&hellip;' title: gettext('Saving&hellip;')
saving.show() saving.show()
options = success : => options = success : =>
@model.unset('children') @model.unset('children')
...@@ -130,7 +130,7 @@ class CMS.Views.UnitEdit extends Backbone.View ...@@ -130,7 +130,7 @@ class CMS.Views.UnitEdit extends Backbone.View
click: (view) => click: (view) =>
view.hide() view.hide()
deleting = new CMS.Views.Notification.Mini deleting = new CMS.Views.Notification.Mini
title: gettext('Deleting') + '&hellip;', title: gettext('Deleting&hellip;'),
deleting.show() deleting.show()
$component = $(event.currentTarget).parents('.component') $component = $(event.currentTarget).parents('.component')
$.post('/delete_item', { $.post('/delete_item', {
......
...@@ -395,7 +395,7 @@ function _deleteItem($el, type) { ...@@ -395,7 +395,7 @@ function _deleteItem($el, type) {
}); });
var deleting = new CMS.Views.Notification.Mini({ var deleting = new CMS.Views.Notification.Mini({
title: gettext('Deleting') + '&hellip;' title: gettext('Deleting&hellip;')
}); });
deleting.show(); deleting.show();
...@@ -626,25 +626,25 @@ function addNewCourse(e) { ...@@ -626,25 +626,25 @@ function addNewCourse(e) {
return gettext('Please do not use any spaces or special characters in this field.'); return gettext('Please do not use any spaces or special characters in this field.');
} }
return ''; return '';
} };
// Ensure that all items are less than 80 characters. // Ensure that org/course_num/run < 65 chars.
var validateTotalCourseItemsLength = function() { var validateTotalCourseItemsLength = function() {
var totalLength = _.reduce( var totalLength = _.reduce(
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], ['.new-course-org', '.new-course-number', '.new-course-run'],
function(sum, ele) { function(sum, ele) {
return sum + $(ele).val().length; return sum + $(ele).val().length;
}, 0 }, 0
); );
if(totalLength > 80) { if(totalLength > 65) {
$('.wrap-error').addClass('is-shown'); $('.wrap-error').addClass('is-shown');
$('#course_creation_error').html('<p>' + gettext('Course fields must have a combined length of no more than 80 characters.') + '</p>'); $('#course_creation_error').html('<p>' + gettext('The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + '</p>');
$('.new-course-save').addClass('is-disabled'); $('.new-course-save').addClass('is-disabled');
} }
else { else {
$('.wrap-error').removeClass('is-shown'); $('.wrap-error').removeClass('is-shown');
} }
} };
// Handle validation asynchronously // Handle validation asynchronously
_.each( _.each(
...@@ -840,7 +840,7 @@ function saveSetSectionScheduleDate(e) { ...@@ -840,7 +840,7 @@ function saveSetSectionScheduleDate(e) {
}); });
var saving = new CMS.Views.Notification.Mini({ var saving = new CMS.Views.Notification.Mini({
title: gettext("Saving") + "&hellip;" title: gettext("Saving&hellip;")
}); });
saving.show(); saving.show();
// call into server to commit the new order // call into server to commit the new order
......
...@@ -23,7 +23,7 @@ CMS.Models.Section = Backbone.Model.extend({ ...@@ -23,7 +23,7 @@ CMS.Models.Section = Backbone.Model.extend({
showNotification: function() { showNotification: function() {
if(!this.msg) { if(!this.msg) {
this.msg = new CMS.Views.Notification.Mini({ this.msg = new CMS.Views.Notification.Mini({
title: gettext("Saving") + "&hellip;" title: gettext("Saving&hellip;")
}); });
} }
this.msg.show(); this.msg.show();
......
...@@ -118,7 +118,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -118,7 +118,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() }); targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() });
// push change to display, hide the editor, submit the change // push change to display, hide the editor, submit the change
var saving = new CMS.Views.Notification.Mini({ var saving = new CMS.Views.Notification.Mini({
title: gettext('Saving') + '&hellip;' title: gettext('Saving&hellip;')
}); });
saving.show(); saving.show();
var ele = this.modelDom(event); var ele = this.modelDom(event);
...@@ -183,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -183,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
}); });
self.modelDom(event).remove(); self.modelDom(event).remove();
var deleting = new CMS.Views.Notification.Mini({ var deleting = new CMS.Views.Notification.Mini({
title: gettext('Deleting') + '&hellip;' title: gettext('Deleting&hellip;')
}); });
deleting.show(); deleting.show();
targetModel.destroy({ targetModel.destroy({
...@@ -327,7 +327,7 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({ ...@@ -327,7 +327,7 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
onSave: function(event) { onSave: function(event) {
this.model.set('data', this.$codeMirror.getValue()); this.model.set('data', this.$codeMirror.getValue());
var saving = new CMS.Views.Notification.Mini({ var saving = new CMS.Views.Notification.Mini({
title: gettext('Saving') + '&hellip;' title: gettext('Saving&hellip;')
}); });
saving.show(); saving.show();
this.model.save({}, { this.model.save({}, {
......
CMS.Models.AssignmentGrade = Backbone.Model.extend({ CMS.Models.AssignmentGrade = Backbone.Model.extend({
defaults : { defaults : {
graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral
location : null // A location object location : null // A location object
}, },
initialize : function(attrs) { initialize : function(attrs) {
if (attrs['assignmentUrl']) { if (attrs['assignmentUrl']) {
this.set('location', new CMS.Models.Location(attrs['assignmentUrl'], {parse: true})); this.set('location', new CMS.Models.Location(attrs['assignmentUrl'], {parse: true}));
} }
}, },
parse : function(attrs) { parse : function(attrs) {
if (attrs && attrs['location']) { if (attrs && attrs['location']) {
attrs.location = new CMS.Models.Location(attrs['location'], {parse: true}); attrs.location = new CMS.Models.Location(attrs['location'], {parse: true});
} }
}, },
urlRoot : function() { urlRoot : function() {
if (this.has('location')) { if (this.has('location')) {
var location = this.get('location'); var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/' + location.get('category') + '/' return '/' + location.get('org') + "/" + location.get('course') + '/' + location.get('category') + '/'
+ location.get('name') + '/gradeas/'; + location.get('name') + '/gradeas/';
} }
else return ""; else return "";
} }
}); });
CMS.Views.OverviewAssignmentGrader = Backbone.View.extend({ CMS.Views.OverviewAssignmentGrader = Backbone.View.extend({
// instantiate w/ { graders : CourseGraderCollection, el : <the gradable-status div> } // instantiate w/ { graders : CourseGraderCollection, el : <the gradable-status div> }
events : { events : {
"click .menu-toggle" : "showGradeMenu", "click .menu-toggle" : "showGradeMenu",
"click .menu li" : "selectGradeType" "click .menu li" : "selectGradeType"
}, },
initialize : function() { initialize : function() {
// call template w/ {assignmentType : formatname, graders : CourseGraderCollection instance } // call template w/ {assignmentType : formatname, graders : CourseGraderCollection instance }
this.template = _.template( this.template = _.template(
// TODO move to a template file // TODO move to a template file
'<h4 class="status-label"><%= assignmentType %></h4>' + '<h4 class="status-label"><%= assignmentType %></h4>' +
'<a data-tooltip="Mark/unmark this subsection as graded" class="menu-toggle" href="#">' + '<a data-tooltip="Mark/unmark this subsection as graded" class="menu-toggle" href="#">' +
'<% if (!hideSymbol) {%><i class="icon-ok"></i><%};%>' + '<% if (!hideSymbol) {%><i class="icon-ok"></i><%};%>' +
'</a>' + '</a>' +
'<ul class="menu">' + '<ul class="menu">' +
'<% graders.each(function(option) { %>' + '<% graders.each(function(option) { %>' +
'<li><a <% if (option.get("type") == assignmentType) {%>class="is-selected" <%}%> href="#"><%= option.get("type") %></a></li>' + '<li><a <% if (option.get("type") == assignmentType) {%>class="is-selected" <%}%> href="#"><%= option.get("type") %></a></li>' +
'<% }) %>' + '<% }) %>' +
'<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' + '<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' +
'</ul>'); '</ul>');
this.assignmentGrade = new CMS.Models.AssignmentGrade({ this.assignmentGrade = new CMS.Models.AssignmentGrade({
assignmentUrl : this.$el.closest('.id-holder').data('id'), assignmentUrl : this.$el.closest('.id-holder').data('id'),
graderType : this.$el.data('initial-status')}); graderType : this.$el.data('initial-status')});
// TODO throw exception if graders is null // TODO throw exception if graders is null
this.graders = this.options['graders']; this.graders = this.options['graders'];
var cachethis = this; var cachethis = this;
// defining here to get closure around this // defining here to get closure around this
this.removeMenu = function(e) { this.removeMenu = function(e) {
e.preventDefault(); e.preventDefault();
cachethis.$el.removeClass('is-active'); cachethis.$el.removeClass('is-active');
$(document).off('click', cachethis.removeMenu); $(document).off('click', cachethis.removeMenu);
} };
this.hideSymbol = this.options['hideSymbol']; this.hideSymbol = this.options['hideSymbol'];
this.render(); this.render();
}, },
render : function() { render : function() {
this.$el.html(this.template({ assignmentType : this.assignmentGrade.get('graderType'), graders : this.graders, this.$el.html(this.template({ assignmentType : this.assignmentGrade.get('graderType'), graders : this.graders,
hideSymbol : this.hideSymbol })); hideSymbol : this.hideSymbol }));
if (this.assignmentGrade.has('graderType') && this.assignmentGrade.get('graderType') != "Not Graded") { if (this.assignmentGrade.has('graderType') && this.assignmentGrade.get('graderType') != "Not Graded") {
this.$el.addClass('is-set'); this.$el.addClass('is-set');
} }
else { else {
this.$el.removeClass('is-set'); this.$el.removeClass('is-set');
} }
}, },
showGradeMenu : function(e) { showGradeMenu : function(e) {
e.preventDefault(); e.preventDefault();
// I sure hope this doesn't break anything but it's needed to keep the removeMenu from activating // I sure hope this doesn't break anything but it's needed to keep the removeMenu from activating
e.stopPropagation(); e.stopPropagation();
// nasty global event trap :-( // nasty global event trap :-(
$(document).on('click', this.removeMenu); $(document).on('click', this.removeMenu);
this.$el.addClass('is-active'); this.$el.addClass('is-active');
}, },
selectGradeType : function(e) { selectGradeType : function(e) {
e.preventDefault(); e.preventDefault();
this.removeMenu(e); this.removeMenu(e);
var saving = new CMS.Views.Notification.Mini({ var saving = new CMS.Views.Notification.Mini({
title: gettext('Saving') + '&hellip;' title: gettext('Saving&hellip;')
}); });
saving.show(); saving.show();
// TODO I'm not happy with this string fetch via the html for what should be an id. I'd rather use the id attr // TODO I'm not happy with this string fetch via the html for what should be an id. I'd rather use the id attr
// of the CourseGradingPolicy model or null for Not Graded (NOTE, change template's if check for is-selected accordingly) // of the CourseGradingPolicy model or null for Not Graded (NOTE, change template's if check for is-selected accordingly)
this.assignmentGrade.save( this.assignmentGrade.save(
'graderType', 'graderType',
$(e.target).text(), $(e.target).text(),
{success: function () { saving.hide(); }} {success: function () { saving.hide(); }}
); );
this.render(); this.render();
} }
}) });
...@@ -226,7 +226,7 @@ function _handleReorder(event, ui, parentIdField, childrenSelector) { ...@@ -226,7 +226,7 @@ function _handleReorder(event, ui, parentIdField, childrenSelector) {
children.push(ui.draggable.data('id')); children.push(ui.draggable.data('id'));
} }
var saving = new CMS.Views.Notification.Mini({ var saving = new CMS.Views.Notification.Mini({
title: gettext('Saving') + '&hellip;' title: gettext('Saving&hellip;')
}); });
saving.show(); saving.show();
$.ajax({ $.ajax({
......
...@@ -35,7 +35,7 @@ CMS.Views.ShowTextbook = Backbone.View.extend({ ...@@ -35,7 +35,7 @@ CMS.Views.ShowTextbook = Backbone.View.extend({
click: function(view) { click: function(view) {
view.hide(); view.hide();
var delmsg = new CMS.Views.Notification.Mini({ var delmsg = new CMS.Views.Notification.Mini({
title: gettext("Deleting") + "&hellip;" title: gettext("Deleting&hellip;")
}).show(); }).show();
textbook.destroy({ textbook.destroy({
complete: function() { complete: function() {
...@@ -122,7 +122,7 @@ CMS.Views.EditTextbook = Backbone.View.extend({ ...@@ -122,7 +122,7 @@ CMS.Views.EditTextbook = Backbone.View.extend({
this.setValues(); this.setValues();
if(!this.model.isValid()) { return; } if(!this.model.isValid()) { return; }
var saving = new CMS.Views.Notification.Mini({ var saving = new CMS.Views.Notification.Mini({
title: gettext("Saving") + "&hellip;" title: gettext("Saving&hellip;")
}).show(); }).show();
var that = this; var that = this;
this.model.save({}, { this.model.save({}, {
......
...@@ -66,7 +66,7 @@ class ChooseModeView(View): ...@@ -66,7 +66,7 @@ class ChooseModeView(View):
return HttpResponseBadRequest(_("Enrollment mode not supported")) return HttpResponseBadRequest(_("Enrollment mode not supported"))
if requested_mode in ("audit", "honor"): if requested_mode in ("audit", "honor"):
CourseEnrollment.enroll(user, course_id) CourseEnrollment.enroll(user, course_id, requested_mode)
return redirect('dashboard') return redirect('dashboard')
mode_info = allowed_modes[requested_mode] mode_info = allowed_modes[requested_mode]
......
from django.conf import settings from django.conf import settings
from dogapi import dog_http_api, dog_stats_api
from dogapi import dog_stats_api, dog_http_api
def run(): def run():
""" """
Initialize connection to datadog during django startup. Initialize connection to datadog during django startup.
Expects the datadog api key in the DATADOG_API settings key Can be configured using a dictionary named DATADOG in the django
project settings.
""" """
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API # By default use the statsd agent
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) options = {'statsd': True}
if hasattr(settings, 'DATADOG'):
options.update(settings.DATADOG)
# Not all arguments are documented.
# Look at the source code for details.
dog_stats_api.start(**options)
dog_http_api.api_key = options.get('api_key')
...@@ -32,8 +32,30 @@ def seed_permissions_roles(course_id): ...@@ -32,8 +32,30 @@ def seed_permissions_roles(course_id):
administrator_role.inherit_permissions(moderator_role) administrator_role.inherit_permissions(moderator_role)
def are_permissions_roles_seeded(course_id): def _remove_permission_role(course_id, name):
try:
role = Role.objects.get(name=name, course_id=course_id)
if role.course_id == course_id:
role.delete()
except Role.DoesNotExist:
pass
def unseed_permissions_roles(course_id):
"""
A utility method to clean up all forum related permissions and roles
"""
_remove_permission_role(name="Administrator", course_id=course_id)
_remove_permission_role(name="Moderator", course_id=course_id)
_remove_permission_role(name="Community TA", course_id=course_id)
_remove_permission_role(name="Student", course_id=course_id)
def are_permissions_roles_seeded(course_id):
"""
Returns whether the forums permissions for a course have been provisioned in
the database
"""
try: try:
administrator_role = Role.objects.get(name="Administrator", course_id=course_id) administrator_role = Role.objects.get(name="Administrator", course_id=course_id)
moderator_role = Role.objects.get(name="Moderator", course_id=course_id) moderator_role = Role.objects.get(name="Moderator", course_id=course_id)
......
"""
Tests for utility functions in external_auth module
"""
from django.test import TestCase
from external_auth.views import _safe_postlogin_redirect
class ExternalAuthHelperFnTest(TestCase):
"""
Unit tests for the external_auth.views helper function
"""
def test__safe_postlogin_redirect(self):
"""
Tests the _safe_postlogin_redirect function with different values of next
"""
HOST = 'testserver' # pylint: disable=C0103
ONSITE1 = '/dashboard' # pylint: disable=C0103
ONSITE2 = '/courses/org/num/name/courseware' # pylint: disable=C0103
ONSITE3 = 'http://{}/my/custom/url'.format(HOST) # pylint: disable=C0103
OFFSITE1 = 'http://www.attacker.com' # pylint: disable=C0103
for redirect_to in [ONSITE1, ONSITE2, ONSITE3]:
redir = _safe_postlogin_redirect(redirect_to, HOST)
self.assertEqual(redir.status_code, 302)
self.assertEqual(redir['location'], redirect_to)
redir2 = _safe_postlogin_redirect(OFFSITE1, HOST)
self.assertEqual(redir2.status_code, 302)
self.assertEqual("/", redir2['location'])
from courseware import grades, courses from courseware import grades, courses
from certificates.models import GeneratedCertificate
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
import os import os
...@@ -28,6 +29,13 @@ class Command(BaseCommand): ...@@ -28,6 +29,13 @@ class Command(BaseCommand):
Generate a list of grades for all students Generate a list of grades for all students
that are enrolled in a course. that are enrolled in a course.
CSV will include the following:
- username
- email
- grade in the certificate table if it exists
- computed grade
- grade breakdown
Outputs grades to a csv file. Outputs grades to a csv file.
Example: Example:
...@@ -57,8 +65,7 @@ class Command(BaseCommand): ...@@ -57,8 +65,7 @@ class Command(BaseCommand):
course_id = options['course'] course_id = options['course']
print "Fetching enrolled students for {0}".format(course_id) print "Fetching enrolled students for {0}".format(course_id)
enrolled_students = User.objects.filter( enrolled_students = User.objects.filter(
courseenrollment__course_id=course_id).prefetch_related( courseenrollment__course_id=course_id)
"groups").order_by('username')
factory = RequestMock() factory = RequestMock()
request = factory.get('/') request = factory.get('/')
...@@ -69,6 +76,11 @@ class Command(BaseCommand): ...@@ -69,6 +76,11 @@ class Command(BaseCommand):
start = datetime.datetime.now() start = datetime.datetime.now()
rows = [] rows = []
header = None header = None
print "Fetching certificate data"
cert_grades = {cert.user.username: cert.grade
for cert in list(GeneratedCertificate.objects.filter(
course_id=course_id).prefetch_related('user'))}
print "Grading students"
for count, student in enumerate(enrolled_students): for count, student in enumerate(enrolled_students):
count += 1 count += 1
if count % STATUS_INTERVAL == 0: if count % STATUS_INTERVAL == 0:
...@@ -86,10 +98,13 @@ class Command(BaseCommand): ...@@ -86,10 +98,13 @@ class Command(BaseCommand):
grade = grades.grade(student, request, course) grade = grades.grade(student, request, course)
if not header: if not header:
header = [section['label'] for section in grade[u'section_breakdown']] header = [section['label'] for section in grade[u'section_breakdown']]
rows.append(["email", "username"] + header) rows.append(["email", "username", "certificate-grade", "grade"] + header)
percents = {section['label']: section['percent'] for section in grade[u'section_breakdown']} percents = {section['label']: section['percent'] for section in grade[u'section_breakdown']}
row_percents = [percents[label] for label in header] row_percents = [percents[label] for label in header]
rows.append([student.email, student.username] + row_percents) if student.username in cert_grades:
rows.append([student.email, student.username, cert_grades[student.username], grade['percent']] + row_percents)
else:
rows.append([student.email, student.username, "N/A", grade['percent']] + row_percents)
with open(options['output'], 'wb') as f: with open(options['output'], 'wb') as f:
writer = csv.writer(f) writer = csv.writer(f)
writer.writerows(rows) writer.writerows(rows)
import csv import csv
from zipfile import ZipFile, is_zipfile
from time import strptime, strftime from time import strptime, strftime
from datetime import datetime from datetime import datetime
from zipfile import ZipFile, is_zipfile
from dogapi import dog_http_api from dogapi import dog_http_api
from pytz import UTC
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.conf import settings from django.conf import settings
import django_startup
from student.models import TestCenterUser, TestCenterRegistration from student.models import TestCenterUser, TestCenterRegistration
from pytz import UTC
django_startup.autostartup()
class Command(BaseCommand): class Command(BaseCommand):
dog_http_api.api_key = settings.DATADOG_API
args = '<input zip file>' args = '<input zip file>'
help = """ help = """
Import Pearson confirmation files and update TestCenterUser Import Pearson confirmation files and update TestCenterUser
......
import os
from optparse import make_option from optparse import make_option
import os
from stat import S_ISDIR from stat import S_ISDIR
from django.conf import settings import boto
from django.core.management.base import BaseCommand, CommandError
from django.core.management import call_command
from dogapi import dog_http_api, dog_stats_api from dogapi import dog_http_api, dog_stats_api
import paramiko import paramiko
import boto
dog_http_api.api_key = settings.DATADOG_API from django.conf import settings
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
import django_startup
django_startup.autostartup()
class Command(BaseCommand): class Command(BaseCommand):
......
...@@ -303,15 +303,13 @@ class PearsonTransferTestCase(PearsonTestCase): ...@@ -303,15 +303,13 @@ class PearsonTransferTestCase(PearsonTestCase):
''' '''
def test_transfer_config(self): def test_transfer_config(self):
with self.settings(DATADOG_API='FAKE_KEY'): stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'})
# TODO: why is this failing with the wrong error message?! self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'})
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') stderrmsg = get_command_error_text('pearson_transfer')
with self.settings(DATADOG_API='FAKE_KEY'): self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
stderrmsg = get_command_error_text('pearson_transfer')
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
with self.settings(DATADOG_API='FAKE_KEY',
PEARSON={'LOCAL_EXPORT': self.export_dir,
'LOCAL_IMPORT': self.import_dir}): 'LOCAL_IMPORT': self.import_dir}):
stderrmsg = get_command_error_text('pearson_transfer') stderrmsg = get_command_error_text('pearson_transfer')
self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings') self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings')
...@@ -319,8 +317,7 @@ class PearsonTransferTestCase(PearsonTestCase): ...@@ -319,8 +317,7 @@ class PearsonTransferTestCase(PearsonTestCase):
def test_transfer_export_missing_dest_dir(self): def test_transfer_export_missing_dest_dir(self):
raise SkipTest() raise SkipTest()
create_multiple_registrations('export_missing_dest') create_multiple_registrations('export_missing_dest')
with self.settings(DATADOG_API='FAKE_KEY', with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
PEARSON={'LOCAL_EXPORT': self.export_dir,
'SFTP_EXPORT': 'this/does/not/exist', 'SFTP_EXPORT': 'this/does/not/exist',
'SFTP_HOSTNAME': SFTP_HOSTNAME, 'SFTP_HOSTNAME': SFTP_HOSTNAME,
'SFTP_USERNAME': SFTP_USERNAME, 'SFTP_USERNAME': SFTP_USERNAME,
...@@ -336,8 +333,7 @@ class PearsonTransferTestCase(PearsonTestCase): ...@@ -336,8 +333,7 @@ class PearsonTransferTestCase(PearsonTestCase):
def test_transfer_export(self): def test_transfer_export(self):
raise SkipTest() raise SkipTest()
create_multiple_registrations("transfer_export") create_multiple_registrations("transfer_export")
with self.settings(DATADOG_API='FAKE_KEY', with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
PEARSON={'LOCAL_EXPORT': self.export_dir,
'SFTP_EXPORT': 'results/topvue', 'SFTP_EXPORT': 'results/topvue',
'SFTP_HOSTNAME': SFTP_HOSTNAME, 'SFTP_HOSTNAME': SFTP_HOSTNAME,
'SFTP_USERNAME': SFTP_USERNAME, 'SFTP_USERNAME': SFTP_USERNAME,
...@@ -354,8 +350,7 @@ class PearsonTransferTestCase(PearsonTestCase): ...@@ -354,8 +350,7 @@ class PearsonTransferTestCase(PearsonTestCase):
def test_transfer_import_missing_source_dir(self): def test_transfer_import_missing_source_dir(self):
raise SkipTest() raise SkipTest()
create_multiple_registrations('import_missing_src') create_multiple_registrations('import_missing_src')
with self.settings(DATADOG_API='FAKE_KEY', with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir,
PEARSON={'LOCAL_IMPORT': self.import_dir,
'SFTP_IMPORT': 'this/does/not/exist', 'SFTP_IMPORT': 'this/does/not/exist',
'SFTP_HOSTNAME': SFTP_HOSTNAME, 'SFTP_HOSTNAME': SFTP_HOSTNAME,
'SFTP_USERNAME': SFTP_USERNAME, 'SFTP_USERNAME': SFTP_USERNAME,
...@@ -371,8 +366,7 @@ class PearsonTransferTestCase(PearsonTestCase): ...@@ -371,8 +366,7 @@ class PearsonTransferTestCase(PearsonTestCase):
def test_transfer_import(self): def test_transfer_import(self):
raise SkipTest() raise SkipTest()
create_multiple_registrations('import_missing_src') create_multiple_registrations('import_missing_src')
with self.settings(DATADOG_API='FAKE_KEY', with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir,
PEARSON={'LOCAL_IMPORT': self.import_dir,
'SFTP_IMPORT': 'results', 'SFTP_IMPORT': 'results',
'SFTP_HOSTNAME': SFTP_HOSTNAME, 'SFTP_HOSTNAME': SFTP_HOSTNAME,
'SFTP_USERNAME': SFTP_USERNAME, 'SFTP_USERNAME': SFTP_USERNAME,
......
...@@ -2,14 +2,26 @@ ...@@ -2,14 +2,26 @@
Tests for student activation and login Tests for student activation and login
''' '''
import json import json
import unittest
from mock import patch from mock import patch
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.test.utils import override_settings
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
from student.views import _parse_course_id_from_string, _get_course_enrollment_domain
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.django import editable_modulestore
from external_auth.models import ExternalAuthMap
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {})
class LoginTest(TestCase): class LoginTest(TestCase):
''' '''
...@@ -154,13 +166,109 @@ class LoginTest(TestCase): ...@@ -154,13 +166,109 @@ class LoginTest(TestCase):
def _assert_audit_log(self, mock_audit_log, level, log_strings): def _assert_audit_log(self, mock_audit_log, level, log_strings):
""" """
Check that the audit log has received the expected call. Check that the audit log has received the expected call as its last call.
""" """
method_calls = mock_audit_log.method_calls method_calls = mock_audit_log.method_calls
self.assertEquals(len(method_calls), 1) name, args, _kwargs = method_calls[-1]
name, args, _kwargs = method_calls[0]
self.assertEquals(name, level) self.assertEquals(name, level)
self.assertEquals(len(args), 1) self.assertEquals(len(args), 1)
format_string = args[0] format_string = args[0]
for log_string in log_strings: for log_string in log_strings:
self.assertIn(log_string, format_string) self.assertIn(log_string, format_string)
class UtilFnTest(TestCase):
"""
Tests for utility functions in student.views
"""
def test__parse_course_id_from_string(self):
"""
Tests the _parse_course_id_from_string util function
"""
COURSE_ID = u'org/num/run' # pylint: disable=C0103
COURSE_URL = u'/courses/{}/otherstuff'.format(COURSE_ID) # pylint: disable=C0103
NON_COURSE_URL = u'/blahblah' # pylint: disable=C0103
self.assertEqual(_parse_course_id_from_string(COURSE_URL), COURSE_ID)
self.assertIsNone(_parse_course_id_from_string(NON_COURSE_URL))
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class ExternalAuthShibTest(ModuleStoreTestCase):
"""
Tests how login_user() interacts with ExternalAuth, in particular Shib
"""
def setUp(self):
self.store = editable_modulestore()
self.course = CourseFactory.create(org='Stanford', number='456', display_name='NO SHIB')
self.shib_course = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only')
self.shib_course.enrollment_domain = 'shib:https://idp.stanford.edu/'
metadata = own_metadata(self.shib_course)
metadata['enrollment_domain'] = self.shib_course.enrollment_domain
self.store.update_metadata(self.shib_course.location.url(), metadata)
self.user_w_map = UserFactory.create(email='withmap@stanford.edu')
self.extauth = ExternalAuthMap(external_id='withmap@stanford.edu',
external_email='withmap@stanford.edu',
external_domain='shib:https://idp.stanford.edu/',
external_credentials="",
user=self.user_w_map)
self.user_w_map.save()
self.extauth.save()
self.user_wo_map = UserFactory.create(email='womap@gmail.com')
self.user_wo_map.save()
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test_login_page_redirect(self):
"""
Tests that when a shib user types their email address into the login page, they get redirected
to the shib login.
"""
response = self.client.post(reverse('login'), {'email': self.user_w_map.email, 'password': ''})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, json.dumps({'success': False, 'redirect': reverse('shib-login')}))
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test__get_course_enrollment_domain(self):
"""
Tests the _get_course_enrollment_domain utility function
"""
self.assertIsNone(_get_course_enrollment_domain("I/DONT/EXIST"))
self.assertIsNone(_get_course_enrollment_domain(self.course.id))
self.assertEqual(self.shib_course.enrollment_domain, _get_course_enrollment_domain(self.shib_course.id))
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test_login_required_dashboard(self):
"""
Tests redirects to when @login_required to dashboard, which should always be the normal login,
since there is no course context
"""
response = self.client.get(reverse('dashboard'))
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/accounts/login?next=/dashboard')
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test_externalauth_login_required_course_context(self):
"""
Tests the redirects when visiting course-specific URL with @login_required.
Should vary by course depending on its enrollment_domain
"""
TARGET_URL = reverse('courseware', args=[self.course.id]) # pylint: disable=C0103
noshib_response = self.client.get(TARGET_URL, follow=True)
self.assertEqual(noshib_response.redirect_chain[-1],
('http://testserver/accounts/login?next={url}'.format(url=TARGET_URL), 302))
self.assertContains(noshib_response, ("<title>Log into your {platform_name} Account</title>"
.format(platform_name=settings.PLATFORM_NAME)))
self.assertEqual(noshib_response.status_code, 200)
TARGET_URL_SHIB = reverse('courseware', args=[self.shib_course.id]) # pylint: disable=C0103
shib_response = self.client.get(**{'path': TARGET_URL_SHIB,
'follow': True,
'REMOTE_USER': self.extauth.external_id,
'Shib-Identity-Provider': 'https://idp.stanford.edu/'})
# Test that the shib-login redirect page with ?next= and the desired page are part of the redirect chain
# The 'courseware' page actually causes a redirect itself, so it's not the end of the chain and we
# won't test its contents
self.assertEqual(shib_response.redirect_chain[-3],
('http://testserver/shib-login/?next={url}'.format(url=TARGET_URL_SHIB), 302))
self.assertEqual(shib_response.redirect_chain[-2],
('http://testserver{url}'.format(url=TARGET_URL_SHIB), 302))
self.assertEqual(shib_response.status_code, 200)
...@@ -23,7 +23,8 @@ from django.core.urlresolvers import reverse ...@@ -23,7 +23,8 @@ from django.core.urlresolvers import reverse
from django.core.validators import validate_email, validate_slug, ValidationError from django.core.validators import validate_email, validate_slug, ValidationError
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, Http404 from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
HttpResponseNotAllowed, Http404)
from django.shortcuts import redirect from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date, base36_to_int, urlencode from django.utils.http import cookie_date, base36_to_int, urlencode
...@@ -54,12 +55,13 @@ from courseware.courses import get_courses, sort_by_announcement ...@@ -54,12 +55,13 @@ from courseware.courses import get_courses, sort_by_announcement
from courseware.access import has_access from courseware.access import has_access
from external_auth.models import ExternalAuthMap from external_auth.models import ExternalAuthMap
import external_auth.views
from bulk_email.models import Optout from bulk_email.models import Optout
from cme_registration.views import cme_register_user, cme_create_account from cme_registration.views import cme_register_user, cme_create_account
import track.views import track.views
from statsd import statsd from dogapi import dog_stats_api
from pytz import UTC from pytz import UTC
log = logging.getLogger("mitx.student") log = logging.getLogger("mitx.student")
...@@ -105,7 +107,7 @@ def index(request, extra_context={}, user=None): ...@@ -105,7 +107,7 @@ def index(request, extra_context={}, user=None):
# The course selection work is done in courseware.courses. # The course selection work is done in courseware.courses.
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
# do explicit check, because domain=None is valid # do explicit check, because domain=None is valid
if domain == False: if domain is False:
domain = request.META.get('HTTP_HOST') domain = request.META.get('HTTP_HOST')
courses = get_courses(user, domain=domain) courses = get_courses(user, domain=domain)
...@@ -268,6 +270,8 @@ def register_user(request, extra_context=None): ...@@ -268,6 +270,8 @@ def register_user(request, extra_context=None):
if extra_context is not None: if extra_context is not None:
context.update(extra_context) context.update(extra_context)
if context.get("extauth_domain", '').startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
return render_to_response('register-shib.html', context)
return render_to_response('register.html', context) return render_to_response('register.html', context)
...@@ -404,10 +408,12 @@ def change_enrollment(request): ...@@ -404,10 +408,12 @@ def change_enrollment(request):
) )
org, course_num, run = course_id.split("/") org, course_num, run = course_id.split("/")
statsd.increment("common.student.enrollment", dog_stats_api.increment(
tags=["org:{0}".format(org), "common.student.enrollment",
"course:{0}".format(course_num), tags=["org:{0}".format(org),
"run:{0}".format(run)]) "course:{0}".format(course_num),
"run:{0}".format(run)]
)
CourseEnrollment.enroll(user, course.id) CourseEnrollment.enroll(user, course.id)
...@@ -418,10 +424,12 @@ def change_enrollment(request): ...@@ -418,10 +424,12 @@ def change_enrollment(request):
CourseEnrollment.unenroll(user, course_id) CourseEnrollment.unenroll(user, course_id)
org, course_num, run = course_id.split("/") org, course_num, run = course_id.split("/")
statsd.increment("common.student.unenrollment", dog_stats_api.increment(
tags=["org:{0}".format(org), "common.student.unenrollment",
"course:{0}".format(course_num), tags=["org:{0}".format(org),
"run:{0}".format(run)]) "course:{0}".format(course_num),
"run:{0}".format(run)]
)
return HttpResponse() return HttpResponse()
except CourseEnrollment.DoesNotExist: except CourseEnrollment.DoesNotExist:
...@@ -429,11 +437,49 @@ def change_enrollment(request): ...@@ -429,11 +437,49 @@ def change_enrollment(request):
else: else:
return HttpResponseBadRequest(_("Enrollment action is invalid")) return HttpResponseBadRequest(_("Enrollment action is invalid"))
def _parse_course_id_from_string(input_str):
"""
Helper function to determine if input_str (typically the queryparam 'next') contains a course_id.
@param input_str:
@return: the course_id if found, None if not
"""
m_obj = re.match(r'^/courses/(?P<course_id>[^/]+/[^/]+/[^/]+)', input_str)
if m_obj:
return m_obj.group('course_id')
return None
def _get_course_enrollment_domain(course_id):
"""
Helper function to get the enrollment domain set for a course with id course_id
@param course_id:
@return:
"""
try:
course = course_from_id(course_id)
return course.enrollment_domain
except ItemNotFoundError:
return None
@ensure_csrf_cookie @ensure_csrf_cookie
def accounts_login(request, error=""): def accounts_login(request):
"""
This view is mainly used as the redirect from the @login_required decorator. I don't believe that
the login path linked from the homepage uses it.
"""
if settings.MITX_FEATURES.get('AUTH_USE_CAS'): if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
return redirect(reverse('cas-login')) return redirect(reverse('cas-login'))
return render_to_response('login.html', {'error': error}) # see if the "next" parameter has been set, whether it has a course context, and if so, whether
# there is a course-specific place to redirect
redirect_to = request.GET.get('next')
if redirect_to:
course_id = _parse_course_id_from_string(redirect_to)
if course_id and _get_course_enrollment_domain(course_id):
return external_auth.views.course_specific_login(request, course_id)
return render_to_response('login.html')
# Need different levels of logging # Need different levels of logging
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -451,6 +497,18 @@ def login_user(request, error=""): ...@@ -451,6 +497,18 @@ def login_user(request, error=""):
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
user = None user = None
# check if the user has a linked shibboleth account, if so, redirect the user to shib-login
# This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu
# address into the Gmail login.
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and user:
try:
eamap = ExternalAuthMap.objects.get(user=user)
if eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
return HttpResponse(json.dumps({'success': False, 'redirect': reverse('shib-login')}))
except ExternalAuthMap.DoesNotExist:
# This is actually the common case, logging in user without external linked login
AUDIT_LOG.info("User %s w/o external auth attempting login", user)
# if the user doesn't exist, we want to set the username to an invalid # 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 # username so that authentication is guaranteed to fail and we can take
# advantage of the ratelimited backend # advantage of the ratelimited backend
...@@ -487,7 +545,7 @@ def login_user(request, error=""): ...@@ -487,7 +545,7 @@ def login_user(request, error=""):
redirect_url = try_change_enrollment(request) redirect_url = try_change_enrollment(request)
statsd.increment("common.student.successful_login") dog_stats_api.increment("common.student.successful_login")
response = HttpResponse(json.dumps({'success': True, 'redirect_url': redirect_url})) response = HttpResponse(json.dumps({'success': True, 'redirect_url': redirect_url}))
# set the login cookie for the edx marketing site # set the login cookie for the edx marketing site
...@@ -655,9 +713,10 @@ def create_account(request, post_override=None): ...@@ -655,9 +713,10 @@ def create_account(request, post_override=None):
return HttpResponse(json.dumps(js)) return HttpResponse(json.dumps(js))
# Can't have terms of service for certain SHIB users, like at Stanford # Can't have terms of service for certain SHIB users, like at Stanford
tos_not_required = settings.MITX_FEATURES.get("AUTH_USE_SHIB") \ tos_not_required = (settings.MITX_FEATURES.get("AUTH_USE_SHIB") and
and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') \ settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') and
and DoExternalAuth and ("shib" in eamap.external_domain) DoExternalAuth and
eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX))
if not tos_not_required: if not tos_not_required:
if post_vars.get('terms_of_service', 'false') != u'true': if post_vars.get('terms_of_service', 'false') != u'true':
...@@ -759,7 +818,7 @@ def create_account(request, post_override=None): ...@@ -759,7 +818,7 @@ def create_account(request, post_override=None):
redirect_url = try_change_enrollment(request) redirect_url = try_change_enrollment(request)
statsd.increment("common.student.account_created") dog_stats_api.increment("common.student.account_created")
response_params = {'success': True, response_params = {'success': True,
'redirect_url': redirect_url} 'redirect_url': redirect_url}
......
...@@ -12,7 +12,7 @@ from django.core.management import call_command ...@@ -12,7 +12,7 @@ from django.core.management import call_command
from django.conf import settings from django.conf import settings
from selenium.common.exceptions import WebDriverException from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from requests import put import requests
from base64 import encodestring from base64 import encodestring
from json import dumps from json import dumps
...@@ -54,12 +54,12 @@ def set_job_status(jobid, passed=True): ...@@ -54,12 +54,12 @@ def set_job_status(jobid, passed=True):
""" """
Sets the job status on sauce labs Sets the job status on sauce labs
""" """
body_content = dumps({"passed": passed})
config = get_username_and_key() config = get_username_and_key()
url = 'http://saucelabs.com/rest/v1/{}/jobs/{}'.format(config['username'], world.jobid)
body_content = dumps({"passed": passed})
base64string = encodestring('{}:{}'.format(config['username'], config['access-key']))[:-1] base64string = encodestring('{}:{}'.format(config['username'], config['access-key']))[:-1]
result = put('http://saucelabs.com/rest/v1/{}/jobs/{}'.format(config['username'], world.jobid), headers = {"Authorization": "Basic {}".format(base64string)}
data=body_content, result = requests.put(url, data=body_content, headers=headers)
headers={"Authorization": "Basic {}".format(base64string)})
return result.status_code == 200 return result.status_code == 200
...@@ -75,7 +75,8 @@ def make_desired_capabilities(): ...@@ -75,7 +75,8 @@ def make_desired_capabilities():
desired_capabilities['build'] = settings.SAUCE.get('BUILD') desired_capabilities['build'] = settings.SAUCE.get('BUILD')
desired_capabilities['video-upload-on-pass'] = False desired_capabilities['video-upload-on-pass'] = False
desired_capabilities['sauce-advisor'] = False desired_capabilities['sauce-advisor'] = False
desired_capabilities['record-screenshots'] = False desired_capabilities['capture-html'] = True
desired_capabilities['record-screenshots'] = True
desired_capabilities['selenium-version'] = "2.34.0" desired_capabilities['selenium-version'] = "2.34.0"
desired_capabilities['max-duration'] = 3600 desired_capabilities['max-duration'] = 3600
desired_capabilities['public'] = 'public restricted' desired_capabilities['public'] = 'public restricted'
...@@ -164,15 +165,18 @@ def reset_databases(scenario): ...@@ -164,15 +165,18 @@ def reset_databases(scenario):
xmodule.modulestore.django.clear_existing_modulestores() xmodule.modulestore.django.clear_existing_modulestores()
# Uncomment below to trigger a screenshot on error @after.each_scenario
# @after.each_scenario
def screenshot_on_error(scenario): def screenshot_on_error(scenario):
""" """
Save a screenshot to help with debugging. Save a screenshot to help with debugging.
""" """
if scenario.failed: if scenario.failed:
world.browser.driver.save_screenshot('/tmp/last_failed_scenario.png') try:
output_dir = '{}/log'.format(settings.TEST_ROOT)
image_name = '{}/{}.png'.format(output_dir, scenario.name.replace(' ', '_'))
world.browser.driver.save_screenshot(image_name)
except WebDriverException:
LOGGER.error('Could not capture a screenshot')
@after.all @after.all
def teardown_browser(total): def teardown_browser(total):
......
"""
Event tracking backend module.
Contains the base class for event trackers, and implementation of some
backends.
"""
from __future__ import absolute_import
import abc
# pylint: disable=unused-argument
class BaseBackend(object):
"""
Abstract Base Class for event tracking backends.
"""
__metaclass__ = abc.ABCMeta
def __init__(self, **kwargs):
pass
@abc.abstractmethod
def send(self, event):
"""Send event to tracker."""
pass
"""
Event tracker backend that saves events to a Django database.
"""
# TODO: this module is very specific to the event schema, and is only
# brought here for legacy support. It should be updated when the
# schema changes or eventually deprecated.
from __future__ import absolute_import
import logging
from django.db import models
from track.backends import BaseBackend
log = logging.getLogger('track.backends.django')
LOGFIELDS = [
'username',
'ip',
'event_source',
'event_type',
'event',
'agent',
'page',
'time',
'host'
]
class TrackingLog(models.Model):
"""Defines the fields that are stored in the tracking log database."""
dtcreated = models.DateTimeField('creation date', auto_now_add=True)
username = models.CharField(max_length=32, blank=True)
ip = models.CharField(max_length=32, blank=True)
event_source = models.CharField(max_length=32)
event_type = models.CharField(max_length=512, blank=True)
event = models.TextField(blank=True)
agent = models.CharField(max_length=256, blank=True)
page = models.CharField(max_length=512, blank=True, null=True)
time = models.DateTimeField('event time')
host = models.CharField(max_length=64, blank=True)
class Meta:
app_label = 'track'
db_table = 'track_trackinglog'
def __unicode__(self):
fmt = (
u"[{self.time}] {self.username}@{self.ip}: "
u"{self.event_source}| {self.event_type} | "
u"{self.page} | {self.event}"
)
return fmt.format(self=self)
class DjangoBackend(BaseBackend):
"""Event tracker backend that saves to a Django database"""
def __init__(self, name='default', **options):
"""
Configure database used by the backend.
:Parameters:
- `name` is the name of the database as specified in the project
settings.
"""
super(DjangoBackend, self).__init__(**options)
self.name = name
def send(self, event):
field_values = {x: event.get(x, '') for x in LOGFIELDS}
tldat = TrackingLog(**field_values)
try:
tldat.save(using=self.name)
except Exception as e: # pylint: disable=broad-except
log.exception(e)
"""Event tracker backend that saves events to a python logger."""
from __future__ import absolute_import
import logging
import json
from django.conf import settings
from track.backends import BaseBackend
from track.utils import DateTimeJSONEncoder
log = logging.getLogger('track.backends.logger')
class LoggerBackend(BaseBackend):
"""Event tracker backend that uses a python logger.
Events are logged to the INFO level as JSON strings.
"""
def __init__(self, name, **kwargs):
"""Event tracker backend that uses a python logger.
:Parameters:
- `name`: identifier of the logger, which should have
been configured using the default python mechanisms.
"""
super(LoggerBackend, self).__init__(**kwargs)
self.event_logger = logging.getLogger(name)
def send(self, event):
event_str = json.dumps(event, cls=DateTimeJSONEncoder)
# TODO: remove trucation of the serialized event, either at a
# higher level during the emittion of the event, or by
# providing warnings when the events exceed certain size.
event_str = event_str[:settings.TRACK_MAX_EVENT]
self.event_logger.info(event_str)
"""MongoDB event tracker backend."""
from __future__ import absolute_import
import logging
import pymongo
from pymongo import MongoClient
from pymongo.errors import PyMongoError
from track.backends import BaseBackend
log = logging.getLogger('track.backends.mongodb')
class MongoBackend(BaseBackend):
"""Class for a MongoDB event tracker Backend"""
def __init__(self, **kwargs):
"""
Connect to a MongoDB.
:Parameters:
- `host`: hostname
- `port`: port
- `user`: collection username
- `password`: collection user password
- `database`: name of the database
- `collection`: name of the collection
- `extra`: parameters to pymongo.MongoClient not listed above
"""
super(MongoBackend, self).__init__(**kwargs)
# Extract connection parameters from kwargs
host = kwargs.get('host', 'localhost')
port = kwargs.get('port', 27017)
user = kwargs.get('user', '')
password = kwargs.get('password', '')
db_name = kwargs.get('database', 'track')
collection_name = kwargs.get('collection', 'events')
# Other mongo connection arguments
extra = kwargs.get('extra', {})
# By default disable write acknowledgments, reducing the time
# blocking during an insert
extra['w'] = extra.get('w', 0)
# Make timezone aware by default
extra['tz_aware'] = extra.get('tz_aware', True)
# Connect to database and get collection
self.connection = MongoClient(
host=host,
port=port,
**extra
)
self.collection = self.connection[db_name][collection_name]
if user or password:
self.collection.database.authenticate(user, password)
self._create_indexes()
def _create_indexes(self):
# WARNING: The collection will be locked during the index
# creation. If the collection has a large number of
# documents in it, the operation can take a long time.
# TODO: The creation of indexes can be moved to a Django
# management command or equivalent. There is also an option to
# run the indexing on the background, without locking.
self.collection.ensure_index([('time', pymongo.DESCENDING)])
self.collection.ensure_index('event_type')
def send(self, event):
try:
self.collection.insert(event, manipulate=False)
except PyMongoError:
msg = 'Error inserting to MongoDB event tracker backend'
log.exception(msg)
from __future__ import absolute_import
from django.test import TestCase
from track.backends.django import DjangoBackend, TrackingLog
class TestDjangoBackend(TestCase):
def setUp(self):
self.backend = DjangoBackend()
def test_django_backend(self):
event = {
'username': 'test',
'time': '2013-01-01T12:01:00-05:00'
}
self.backend.send(event)
results = list(TrackingLog.objects.all())
self.assertEqual(len(results), 1)
self.assertEqual(results[0].username, 'test')
# Check if time is stored in UTC
self.assertEqual(str(results[0].time), '2013-01-01 17:01:00+00:00')
from __future__ import absolute_import
import json
import logging
import datetime
from django.test import TestCase
from track.backends.logger import LoggerBackend
class TestLoggerBackend(TestCase):
def setUp(self):
self.handler = MockLoggingHandler()
self.handler.setLevel(logging.INFO)
logger_name = 'track.backends.logger.test'
logger = logging.getLogger(logger_name)
logger.addHandler(self.handler)
self.backend = LoggerBackend(name=logger_name)
def test_logger_backend(self):
self.handler.reset()
# Send a couple of events and check if they were recorded
# by the logger. The events are serialized to JSON.
event = {
'test': True,
'time': datetime.datetime(2012, 05, 01, 07, 27, 01, 200),
'date': datetime.date(2012, 05, 07),
}
self.backend.send(event)
self.backend.send(event)
saved_events = [json.loads(e) for e in self.handler.messages['info']]
unpacked_event = {
'test': True,
'time': '2012-05-01T07:27:01.000200+00:00',
'date': '2012-05-07'
}
self.assertEqual(saved_events[0], unpacked_event)
self.assertEqual(saved_events[1], unpacked_event)
class MockLoggingHandler(logging.Handler):
"""
Mock logging handler.
Stores records in a dictionry of lists by level.
"""
def __init__(self, *args, **kwargs):
super(MockLoggingHandler, self).__init__(*args, **kwargs)
self.messages = None
self.reset()
def emit(self, record):
level = record.levelname.lower()
message = record.getMessage()
self.messages[level].append(message)
def reset(self):
self.messages = {
'debug': [],
'info': [],
'warning': [],
'error': [],
'critical': [],
}
from __future__ import absolute_import
from uuid import uuid4
from mock import patch
from django.test import TestCase
from track.backends.mongodb import MongoBackend
class TestMongoBackend(TestCase):
def setUp(self):
self.mongo_patcher = patch('track.backends.mongodb.MongoClient')
self.addCleanup(self.mongo_patcher.stop)
self.mongo_patcher.start()
self.backend = MongoBackend()
def test_mongo_backend(self):
events = [{'test': 1}, {'test': 2}]
self.backend.send(events[0])
self.backend.send(events[1])
# Check if we inserted events into the database
calls = self.backend.collection.insert.mock_calls
self.assertEqual(len(calls), 2)
# Unpack the arguments and check if the events were used
# as the first argument to collection.insert
def first_argument(call):
_, args, _ = call
return args[0]
self.assertEqual(events[0], first_argument(calls[0]))
self.assertEqual(events[1], first_argument(calls[1]))
import json import json
import re
from django.conf import settings
import views import views
class TrackMiddleware: class TrackMiddleware(object):
def process_request(self, request): def process_request(self, request):
try: try:
# We're already logging events, and we don't want to capture user if not self._should_process_request(request):
# names/passwords.
if request.META['PATH_INFO'] in ['/event', '/login']:
return return
# Removes passwords from the tracking logs # Removes passwords from the tracking logs
...@@ -45,3 +46,14 @@ class TrackMiddleware: ...@@ -45,3 +46,14 @@ class TrackMiddleware:
views.server_track(request, request.META['PATH_INFO'], event) views.server_track(request, request.META['PATH_INFO'], event)
except: except:
pass pass
def _should_process_request(self, request):
path = request.META['PATH_INFO']
ignored_url_patterns = getattr(settings, 'TRACKING_IGNORE_URL_PATTERNS', [])
for pattern in ignored_url_patterns:
# Note we are explicitly relying on python's internal caching of
# compiled regular expressions here.
if re.match(pattern, path):
return False
return True
from django.db import models from track.backends.django import TrackingLog
class TrackingLog(models.Model):
"""Defines the fields that are stored in the tracking log database"""
dtcreated = models.DateTimeField('creation date', auto_now_add=True)
username = models.CharField(max_length=32, blank=True)
ip = models.CharField(max_length=32, blank=True)
event_source = models.CharField(max_length=32)
event_type = models.CharField(max_length=512, blank=True)
event = models.TextField(blank=True)
agent = models.CharField(max_length=256, blank=True)
page = models.CharField(max_length=512, blank=True, null=True)
time = models.DateTimeField('event time')
host = models.CharField(max_length=64, blank=True)
def __unicode__(self):
fmt = (
u"[{self.time}] {self.username}@{self.ip}: "
u"{self.event_source}| {self.event_type} | "
u"{self.page} | {self.event}"
)
return fmt.format(self=self)
import re
from mock import patch
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
from track.middleware import TrackMiddleware
@patch('track.views.server_track')
class TrackMiddlewareTestCase(TestCase):
def setUp(self):
self.track_middleware = TrackMiddleware()
self.request_factory = RequestFactory()
def test_normal_request(self, mock_server_track):
request = self.request_factory.get('/somewhere')
self.track_middleware.process_request(request)
self.assertTrue(mock_server_track.called)
def test_default_filters_do_not_render_view(self, mock_server_track):
for url in ['/event', '/event/1', '/login', '/heartbeat']:
request = self.request_factory.get(url)
self.track_middleware.process_request(request)
self.assertFalse(mock_server_track.called)
mock_server_track.reset_mock()
@override_settings(TRACKING_IGNORE_URL_PATTERNS=[])
def test_reading_filtered_urls_from_settings(self, mock_server_track):
request = self.request_factory.get('/event')
self.track_middleware.process_request(request)
self.assertTrue(mock_server_track.called)
@override_settings(TRACKING_IGNORE_URL_PATTERNS=[r'^/some/excluded.*'])
def test_anchoring_of_patterns_at_beginning(self, mock_server_track):
request = self.request_factory.get('/excluded')
self.track_middleware.process_request(request)
self.assertTrue(mock_server_track.called)
mock_server_track.reset_mock()
request = self.request_factory.get('/some/excluded/url')
self.track_middleware.process_request(request)
self.assertFalse(mock_server_track.called)
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
import track.tracker as tracker
from track.backends import BaseBackend
SIMPLE_SETTINGS = {
'default': {
'ENGINE': 'track.tests.test_tracker.DummyBackend',
'OPTIONS': {
'flag': True
}
}
}
MULTI_SETTINGS = {
'first': {
'ENGINE': 'track.tests.test_tracker.DummyBackend',
},
'second': {
'ENGINE': 'track.tests.test_tracker.DummyBackend',
}
}
class TestTrackerInstantiation(TestCase):
"""Test that a helper function can instantiate backends from their name."""
def setUp(self):
# pylint: disable=protected-access
self.get_backend = tracker._instantiate_backend_from_name
def test_instatiate_backend(self):
name = 'track.tests.test_tracker.DummyBackend'
options = {'flag': True}
backend = self.get_backend(name, options)
self.assertIsInstance(backend, DummyBackend)
self.assertTrue(backend.flag)
def test_instatiate_backends_with_invalid_values(self):
def get_invalid_backend(name, parameters):
return self.get_backend(name, parameters)
options = {}
name = 'track.backends.logger'
self.assertRaises(ValueError, get_invalid_backend, name, options)
name = 'track.backends.logger.Foo'
self.assertRaises(ValueError, get_invalid_backend, name, options)
name = 'this.package.does.not.exists'
self.assertRaises(ValueError, get_invalid_backend, name, options)
name = 'unittest.TestCase'
self.assertRaises(ValueError, get_invalid_backend, name, options)
class TestTrackerDjangoInstantiation(TestCase):
"""Test if backends are initialized properly from Django settings."""
@override_settings(TRACKING_BACKENDS=SIMPLE_SETTINGS)
def test_django_simple_settings(self):
"""Test configuration of a simple backend"""
backends = self._reload_backends()
self.assertEqual(len(backends), 1)
tracker.send({})
self.assertEqual(backends.values()[0].count, 1)
@override_settings(TRACKING_BACKENDS=MULTI_SETTINGS)
def test_django_multi_settings(self):
"""Test if multiple backends can be configured properly."""
backends = self._reload_backends().values()
self.assertEqual(len(backends), 2)
event_count = 10
for _ in xrange(event_count):
tracker.send({})
self.assertEqual(backends[0].count, event_count)
self.assertEqual(backends[1].count, event_count)
@override_settings(TRACKING_BACKENDS=MULTI_SETTINGS)
def test_django_remove_settings(self):
"""Test if a backend can be remove by setting it to None."""
settings.TRACKING_BACKENDS.update({'second': None})
backends = self._reload_backends()
self.assertEqual(len(backends), 1)
def _reload_backends(self):
# pylint: disable=protected-access
# Reset backends
tracker._initialize_backends_from_django_settings()
return tracker.backends
class DummyBackend(BaseBackend):
def __init__(self, **options):
super(DummyBackend, self).__init__(**options)
self.flag = options.get('flag', False)
self.count = 0
# pylint: disable=unused-argument
def send(self, event):
self.count += 1
from datetime import datetime
import json
from pytz import UTC
from django.test import TestCase
from track.utils import DateTimeJSONEncoder
class TestDateTimeJSONEncoder(TestCase):
def test_datetime_encoding(self):
a_naive_datetime = datetime(2012, 05, 01, 07, 27, 10, 20000)
a_tz_datetime = datetime(2012, 05, 01, 07, 27, 10, 20000, tzinfo=UTC)
a_date = a_naive_datetime.date()
an_iso_datetime = '2012-05-01T07:27:10.020000+00:00'
an_iso_date = '2012-05-01'
obj = {
'number': 100,
'string': 'hello',
'object': {'a': 1},
'a_datetime': a_naive_datetime,
'a_tz_datetime': a_tz_datetime,
'a_date': a_date,
}
to_json = json.dumps(obj, cls=DateTimeJSONEncoder)
from_json = json.loads(to_json)
self.assertEqual(from_json['number'], 100)
self.assertEqual(from_json['string'], 'hello')
self.assertEqual(from_json['object'], {'a': 1})
self.assertEqual(from_json['a_datetime'], an_iso_datetime)
self.assertEqual(from_json['a_tz_datetime'], an_iso_datetime)
self.assertEqual(from_json['a_date'], an_iso_date)
"""
Module that tracks analytics events by sending them to different
configurable backends.
The backends can be configured using Django settings as the example
below::
TRACKING_BACKENDS = {
'tracker_name': {
'ENGINE': 'class.name.for.backend',
'OPTIONS': {
'host': ... ,
'port': ... ,
...
}
}
}
"""
import inspect
from importlib import import_module
from dogapi import dog_stats_api
from django.conf import settings
from track.backends import BaseBackend
__all__ = ['send']
backends = {}
def _initialize_backends_from_django_settings():
"""
Initialize the event tracking backends according to the
configuration in django settings
"""
backends.clear()
config = getattr(settings, 'TRACKING_BACKENDS', {})
for name, values in config.iteritems():
# Ignore empty values to turn-off default tracker backends
if values:
engine = values['ENGINE']
options = values.get('OPTIONS', {})
backends[name] = _instantiate_backend_from_name(engine, options)
def _instantiate_backend_from_name(name, options):
"""
Instantiate an event tracker backend from the full module path to
the backend class. Useful when setting backends from configuration
files.
"""
# Parse backend name
try:
parts = name.split('.')
module_name = '.'.join(parts[:-1])
class_name = parts[-1]
except IndexError:
raise ValueError('Invalid event track backend %s' % name)
# Get and verify the backend class
try:
module = import_module(module_name)
cls = getattr(module, class_name)
if not inspect.isclass(cls) or not issubclass(cls, BaseBackend):
raise TypeError
except (ValueError, AttributeError, TypeError, ImportError):
raise ValueError('Cannot find event track backend %s' % name)
backend = cls(**options)
return backend
@dog_stats_api.timed('track.send')
def send(event):
"""
Send an event object to all the initialized backends.
"""
dog_stats_api.increment('track.send.count')
for name, backend in backends.iteritems():
with dog_stats_api.timer('track.send.backend.{0}'.format(name)):
backend.send(event)
_initialize_backends_from_django_settings()
"""Utility functions and classes for track backends"""
from datetime import datetime, date
import json
from pytz import UTC
class DateTimeJSONEncoder(json.JSONEncoder):
"""JSON encoder aware of datetime.datetime and datetime.date objects"""
def default(self, obj): # pylint: disable=method-hidden
"""
Serialize datetime and date objects of iso format.
datatime objects are converted to UTC.
"""
if isinstance(obj, datetime):
if obj.tzinfo is None:
# Localize to UTC naive datetime objects
obj = UTC.localize(obj)
else:
# Convert to UTC datetime objects from other timezones
obj = obj.astimezone(UTC)
return obj.isoformat()
elif isinstance(obj, date):
return obj.isoformat()
return super(DateTimeJSONEncoder, self).default(obj)
import json
import logging
import pytz
import datetime import datetime
import dateutil.parser
import pytz
from pytz import UTC
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf import settings
from mitxmako.shortcuts import render_to_response
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from track.models import TrackingLog
from pytz import UTC
log = logging.getLogger("tracking") from mitxmako.shortcuts import render_to_response
LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', 'page', 'time', 'host'] from track import tracker
from track.models import TrackingLog
def log_event(event): def log_event(event):
"""Write tracking event to log file, and optionally to TrackingLog model.""" """Capture a event by sending it to the register trackers"""
event_str = json.dumps(event) tracker.send(event)
log.info(event_str[:settings.TRACK_MAX_EVENT])
if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
event['time'] = dateutil.parser.parse(event['time'])
tldat = TrackingLog(**dict((x, event[x]) for x in LOGFIELDS))
try:
tldat.save()
except Exception as err:
log.exception(err)
def user_track(request): def user_track(request):
...@@ -64,11 +52,12 @@ def user_track(request): ...@@ -64,11 +52,12 @@ def user_track(request):
"event": request.REQUEST.get('event','unknown'), "event": request.REQUEST.get('event','unknown'),
"agent": agent, "agent": agent,
"page": request.REQUEST.get('page', 'unknown'), "page": request.REQUEST.get('page', 'unknown'),
"time": datetime.datetime.now(UTC).isoformat(), "time": datetime.datetime.now(UTC),
"host": request.META['SERVER_NAME'], "host": request.META['SERVER_NAME'],
} }
log_event(event) log_event(event)
return HttpResponse('success') return HttpResponse('success')
...@@ -92,12 +81,13 @@ def server_track(request, event_type, event, page=None): ...@@ -92,12 +81,13 @@ def server_track(request, event_type, event, page=None):
"event": event, "event": event,
"agent": agent, "agent": agent,
"page": page, "page": page,
"time": datetime.datetime.now(UTC).isoformat(), "time": datetime.datetime.now(UTC),
"host": request.META['SERVER_NAME'], "host": request.META['SERVER_NAME'],
} }
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log if event_type.startswith("/event_logs") and request.user.is_staff:
return return # don't log
log_event(event) log_event(event)
...@@ -136,7 +126,7 @@ def task_track(request_info, task_info, event_type, event, page=None): ...@@ -136,7 +126,7 @@ def task_track(request_info, task_info, event_type, event, page=None):
"event": full_event, "event": full_event,
"agent": request_info.get('agent', 'unknown'), "agent": request_info.get('agent', 'unknown'),
"page": page, "page": page,
"time": datetime.datetime.utcnow().isoformat(), "time": datetime.datetime.now(UTC),
"host": request_info.get('host', 'unknown') "host": request_info.get('host', 'unknown')
} }
......
...@@ -113,6 +113,12 @@ def enrich_varname(varname): ...@@ -113,6 +113,12 @@ def enrich_varname(varname):
"vartheta iota kappa lambda mu nu xi pi rho sigma tau upsilon " "vartheta iota kappa lambda mu nu xi pi rho sigma tau upsilon "
"phi varphi chi psi omega").split() "phi varphi chi psi omega").split()
# add capital greek letters
greek += [x.capitalize() for x in greek]
# add hbar for QM
greek.append('hbar')
if varname in greek: if varname in greek:
return ur"\{letter}".format(letter=varname) return ur"\{letter}".format(letter=varname)
else: else:
......
...@@ -4,7 +4,7 @@ from codejail.safe_exec import safe_exec as codejail_safe_exec ...@@ -4,7 +4,7 @@ from codejail.safe_exec import safe_exec as codejail_safe_exec
from codejail.safe_exec import not_safe_exec as codejail_not_safe_exec from codejail.safe_exec import not_safe_exec as codejail_not_safe_exec
from codejail.safe_exec import json_safe, SafeExecException from codejail.safe_exec import json_safe, SafeExecException
from . import lazymod from . import lazymod
from statsd import statsd from dogapi import dog_stats_api
import hashlib import hashlib
...@@ -70,7 +70,7 @@ def update_hash(hasher, obj): ...@@ -70,7 +70,7 @@ def update_hash(hasher, obj):
hasher.update(repr(obj)) hasher.update(repr(obj))
@statsd.timed('capa.safe_exec.time') @dog_stats_api.timed('capa.safe_exec.time')
def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None, unsafely=False): def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None, unsafely=False):
""" """
Execute python code safely. Execute python code safely.
......
...@@ -36,6 +36,7 @@ def test_system(): ...@@ -36,6 +36,7 @@ def test_system():
user=Mock(), user=Mock(),
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
debug=True, debug=True,
hostname="edx.org",
xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10}, xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
anonymous_student_id='student', anonymous_student_id='student',
......
...@@ -64,7 +64,8 @@ class XQueueInterface(object): ...@@ -64,7 +64,8 @@ class XQueueInterface(object):
def __init__(self, url, django_auth, requests_auth=None): def __init__(self, url, django_auth, requests_auth=None):
self.url = url self.url = url
self.auth = django_auth self.auth = django_auth
self.session = requests.session(auth=requests_auth) self.session = requests.Session()
self.session.auth = requests_auth
def send_to_queue(self, header, body, files_to_upload=None): def send_to_queue(self, header, body, files_to_upload=None):
""" """
......
...@@ -39,7 +39,7 @@ username = prompt('username on server', 'victor@edx.org') ...@@ -39,7 +39,7 @@ username = prompt('username on server', 'victor@edx.org')
password = prompt('password', 'abc123', safe=True) password = prompt('password', 'abc123', safe=True)
print "get csrf cookie" print "get csrf cookie"
session = requests.session() session = requests.Session()
r = session.get(server + '/') r = session.get(server + '/')
r.raise_for_status() r.raise_for_status()
......
...@@ -337,7 +337,14 @@ class CourseFields(object): ...@@ -337,7 +337,14 @@ class CourseFields(object):
"action_external": False}]} "action_external": False}]}
]) ])
info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) show_timezone = Boolean(
help="True if timezones should be shown on dates in the courseware. Deprecated in favor of due_date_display_format.",
scope=Scope.settings, default=True
)
due_date_display_format = String(
help="Format supported by strftime for displaying due dates. Takes precedence over show_timezone.",
scope=Scope.settings, default=None
)
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
scope=Scope.settings) scope=Scope.settings)
course_image = String( course_image = String(
...@@ -391,7 +398,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -391,7 +398,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
elif isinstance(self.location, CourseLocator): elif isinstance(self.location, CourseLocator):
self.wiki_slug = self.location.course_id or self.display_name self.wiki_slug = self.location.course_id or self.display_name
msg = None if self.due_date_display_format is None and self.show_timezone is False:
# For existing courses with show_timezone set to False (and no due_date_display_format specified),
# set the due_date_display_format to what would have been shown previously (with no timezone).
# Then remove show_timezone so that if the user clears out the due_date_display_format,
# they get the default date display.
self.due_date_display_format = u"%b %d, %Y at %H:%M"
delattr(self, 'show_timezone')
# NOTE: relies on the modulestore to call set_grading_policy() right after # NOTE: relies on the modulestore to call set_grading_policy() right after
# init. (Modulestore is in charge of figuring out where to load the policy from) # init. (Modulestore is in charge of figuring out where to load the policy from)
......
...@@ -32,7 +32,6 @@ ...@@ -32,7 +32,6 @@
//Component Name //Component Name
.component-name { .component-name {
@extend .t-copy-sub1;
position: relative; position: relative;
top: 0; top: 0;
left: 0; left: 0;
......
...@@ -225,6 +225,19 @@ div.video { ...@@ -225,6 +225,19 @@ div.video {
@include transition(none); @include transition(none);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
width: 116px; width: 116px;
@media (max-width: 1024px) {
width: 86px;
}
h3 {
display: block;
@media (max-width: 1024px) {
display: none;
}
}
outline: 0; outline: 0;
&:focus { &:focus {
...@@ -247,6 +260,11 @@ div.video { ...@@ -247,6 +260,11 @@ div.video {
font-weight: bold; font-weight: bold;
margin-bottom: 0; margin-bottom: 0;
padding: 0 lh(.5) 0 0; padding: 0 lh(.5) 0 0;
@media (max-width: 1024px) {
padding: 0 lh(.5) 0 lh(.5);
}
line-height: 46px; line-height: 46px;
color: #fff; color: #fff;
} }
...@@ -268,6 +286,11 @@ div.video { ...@@ -268,6 +286,11 @@ div.video {
opacity: 0.0; opacity: 0.0;
position: absolute; position: absolute;
width: 131px; width: 131px;
@media (max-width: 1024px) {
width: 101px;
}
z-index: 10; z-index: 10;
li { li {
......
<div id="lti_id" class="lti"> <div id="lti_id" class="lti">
<form <form
action="" action="http://www.example.com"
name="ltiLaunchForm" name="ltiLaunchForm"
class="ltiLaunchForm" class="ltiLaunchForm"
method="post" method="post"
target="ltiLaunchFrame" target="ltiLaunchFrame"
encType="application/x-www-form-urlencoded" enctype="application/x-www-form-urlencoded"
> >
<input name="launch_presentation_return_url" value="" />
<input type="hidden" name="launch_presentation_return_url" value=""> <input name="lti_version" value="LTI-1p0" />
<input type="hidden" name="lis_outcome_service_url" value=""> <input name="user_id" value="student" />
<input type="hidden" name="lis_result_sourcedid" value=""> <input name="oauth_nonce" value="28347958723982798572" />
<input type="hidden" name="lti_message_type" value="basic-lti-launch-request"> <input name="oauth_timestamp" value="2389479832" />
<input type="hidden" name="lti_version" value="LTI-1p0"> <input name="oauth_consumer_key" value="" />
<input type="hidden" name="oauth_callback" value="about:blank"> <input name="lis_result_sourcedid" value="" />
<input type="hidden" name="oauth_consumer_key" value=""/> <input name="oauth_signature_method" value="HMAC-SHA1" />
<input type="hidden" name="oauth_nonce" value=""/> <input name="oauth_version" value="1.0" />
<input type="hidden" name="oauth_signature_method" value="HMAC-SHA1"/> <input name="role" value="student" />
<input type="hidden" name="oauth_timestamp" value=""/> <input name="lis_outcome_service_url" value="" />
<input type="hidden" name="oauth_version" value="1.0"/> <input name="oauth_signature" value="89ru3289r3ry283y3r82ryr38yr" />
<input type="hidden" name="user_id" value="default_user_id"> <input name="lti_message_type" value="basic-lti-launch-request" />
<input type="hidden" name="oauth_signature" value=""/> <input name="oauth_callback" value="about:blank" />
<input type="submit" value="Press to Launch" /> <input type="submit" value="Press to Launch" />
</form> </form>
...@@ -31,10 +31,6 @@ ...@@ -31,10 +31,6 @@
required fields. required fields.
</h3> </h3>
<iframe <iframe name="ltiLaunchFrame" class="ltiLaunchFrame" src=""></iframe>
name="ltiLaunchFrame"
class="ltiLaunchFrame"
src=""
></iframe>
</div> </div>
...@@ -46,7 +46,7 @@ ...@@ -46,7 +46,7 @@
}); });
it( it(
'when URL setting is filled form is not submited', 'when URL setting is not filled form is not submited',
function () { function () {
expect('submit').not.toHaveBeenTriggeredOn(form); expect('submit').not.toHaveBeenTriggeredOn(form);
...@@ -70,7 +70,7 @@ ...@@ -70,7 +70,7 @@
// The user "fills in" the necessary settings, and the // The user "fills in" the necessary settings, and the
// form will get an action URL. // form will get an action URL.
form.attr('action', 'http://www.example.com/'); form.attr('action', 'http://www.example.com/test_submit');
LTI(element); LTI(element);
}); });
......
...@@ -16,9 +16,9 @@ window.LTI = (function () { ...@@ -16,9 +16,9 @@ window.LTI = (function () {
// If the Form's action attribute is set (i.e. we can perform a normal // If the Form's action attribute is set (i.e. we can perform a normal
// submit), then we submit the form and make the frame shown. // submit), then we submit the form and make the frame shown.
if (form.attr('action')) { if (form.attr('action') && form.attr('action') !== 'http://www.example.com') {
form.submit(); form.submit();
element.find('.lti').addClass('rendered') element.find('.lti').addClass('rendered');
} }
} }
......
""" """
Module that allows to insert LTI tools to page. Module that allows to insert LTI tools to page.
Module uses current edx-platform 0.14.2 version of requests (oauth part).
Please update code when upgrading requests.
Protocol is oauth1, LTI version is 1.1.1: Protocol is oauth1, LTI version is 1.1.1:
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
""" """
import logging import logging
import requests import oauthlib.oauth1
import urllib import urllib
from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.editing_module import MetadataOnlyEditingDescriptor
...@@ -41,9 +38,12 @@ class LTIFields(object): ...@@ -41,9 +38,12 @@ class LTIFields(object):
vbid=put_book_id_here vbid=put_book_id_here
book_location=page/put_page_number_here book_location=page/put_page_number_here
Default non-empty url for `launch_url` is needed due to oauthlib demand (url scheme should be presented)::
https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
""" """
lti_id = String(help="Id of the tool", default='', scope=Scope.settings) lti_id = String(help="Id of the tool", default='', scope=Scope.settings)
launch_url = String(help="URL of the tool", default='', scope=Scope.settings) launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings)
custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings) custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
...@@ -192,7 +192,7 @@ class LTIModule(LTIFields, XModule): ...@@ -192,7 +192,7 @@ class LTIModule(LTIFields, XModule):
Also *anonymous student id* is passed to template and therefore to LTI provider. Also *anonymous student id* is passed to template and therefore to LTI provider.
""" """
client = requests.auth.Client( client = oauthlib.oauth1.Client(
client_key=unicode(client_key), client_key=unicode(client_key),
client_secret=unicode(client_secret) client_secret=unicode(client_secret)
) )
...@@ -215,14 +215,26 @@ class LTIModule(LTIFields, XModule): ...@@ -215,14 +215,26 @@ class LTIModule(LTIFields, XModule):
# appending custom parameter for signing # appending custom parameter for signing
body.update(custom_parameters) body.update(custom_parameters)
# This is needed for body encoding: headers = {
headers = {'Content-Type': 'application/x-www-form-urlencoded'} # This is needed for body encoding:
'Content-Type': 'application/x-www-form-urlencoded',
}
try:
__, headers, __ = client.sign(
unicode(self.launch_url),
http_method=u'POST',
body=body,
headers=headers)
except ValueError: # scheme not in url
#https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
#Stubbing headers for now:
headers = {
u'Content-Type': u'application/x-www-form-urlencoded',
u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \
oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \
oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
__, headers, __ = client.sign(
unicode(self.launch_url),
http_method=u'POST',
body=body,
headers=headers)
params = headers['Authorization'] params = headers['Authorization']
# parse headers to pass to template as part of context: # parse headers to pass to template as part of context:
params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')]) params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')])
...@@ -230,8 +242,8 @@ class LTIModule(LTIFields, XModule): ...@@ -230,8 +242,8 @@ class LTIModule(LTIFields, XModule):
params[u'oauth_nonce'] = params[u'OAuth oauth_nonce'] params[u'oauth_nonce'] = params[u'OAuth oauth_nonce']
del params[u'OAuth oauth_nonce'] del params[u'OAuth oauth_nonce']
# 0.14.2 (current) version of requests oauth library encodes signature, # oauthlib encodes signature with
# with 'Content-Type': 'application/x-www-form-urlencoded' # 'Content-Type': 'application/x-www-form-urlencoded'
# so '='' becomes '%3D'. # so '='' becomes '%3D'.
# We send form via browser, so browser will encode it again, # We send form via browser, so browser will encode it again,
# So we need to decode signature back: # So we need to decode signature back:
......
...@@ -10,7 +10,6 @@ from collections import namedtuple ...@@ -10,7 +10,6 @@ from collections import namedtuple
from .exceptions import InvalidLocationError, InsufficientSpecificationError from .exceptions import InvalidLocationError, InsufficientSpecificationError
from xmodule.errortracker import make_error_tracker from xmodule.errortracker import make_error_tracker
from bson.son import SON
log = logging.getLogger('mitx.' + 'modulestore') log = logging.getLogger('mitx.' + 'modulestore')
...@@ -449,13 +448,3 @@ class ModuleStoreBase(ModuleStore): ...@@ -449,13 +448,3 @@ class ModuleStoreBase(ModuleStore):
if c.id == course_id: if c.id == course_id:
return c return c
return None return None
def namedtuple_to_son(namedtuple, prefix=''):
"""
Converts a namedtuple into a SON object with the same key order
"""
son = SON()
for idx, field_name in enumerate(namedtuple._fields):
son[prefix + field_name] = namedtuple[idx]
return son
...@@ -17,6 +17,7 @@ import sys ...@@ -17,6 +17,7 @@ import sys
import logging import logging
import copy import copy
from bson.son import SON
from fs.osfs import OSFS from fs.osfs import OSFS
from itertools import repeat from itertools import repeat
from path import path from path import path
...@@ -31,7 +32,7 @@ from xblock.runtime import DbModel ...@@ -31,7 +32,7 @@ from xblock.runtime import DbModel
from xblock.exceptions import InvalidScopeError from xblock.exceptions import InvalidScopeError
from xblock.fields import Scope, ScopeIds from xblock.fields import Scope, ScopeIds
from xmodule.modulestore import ModuleStoreBase, Location, namedtuple_to_son, MONGO_MODULESTORE_TYPE from xmodule.modulestore import ModuleStoreBase, Location, MONGO_MODULESTORE_TYPE
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata, InheritanceMixin, inherit_metadata, InheritanceKeyValueStore from xmodule.modulestore.inheritance import own_metadata, InheritanceMixin, inherit_metadata, InheritanceKeyValueStore
...@@ -215,6 +216,16 @@ class CachingDescriptorSystem(MakoDescriptorSystem): ...@@ -215,6 +216,16 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
) )
def namedtuple_to_son(namedtuple, prefix=''):
"""
Converts a namedtuple into a SON object with the same key order
"""
son = SON()
for idx, field_name in enumerate(namedtuple._fields):
son[prefix + field_name] = namedtuple[idx]
return son
def location_to_query(location, wildcard=True): def location_to_query(location, wildcard=True):
""" """
Takes a Location and returns a SON object that will query for that location. Takes a Location and returns a SON object that will query for that location.
...@@ -605,8 +616,8 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -605,8 +616,8 @@ class MongoModuleStore(ModuleStoreBase):
) )
xblock_class = XModuleDescriptor.load_class(location.category, self.default_class) xblock_class = XModuleDescriptor.load_class(location.category, self.default_class)
if definition_data is None: if definition_data is None:
if hasattr(xblock_class, 'data') and getattr(xblock_class, 'data').default is not None: if hasattr(xblock_class, 'data') and xblock_class.data.default is not None:
definition_data = getattr(xblock_class, 'data').default definition_data = xblock_class.data.default
else: else:
definition_data = {} definition_data = {}
dbmodel = self._create_new_field_data(location.category, location, definition_data, metadata) dbmodel = self._create_new_field_data(location.category, location, definition_data, metadata)
......
...@@ -9,10 +9,10 @@ and otherwise returns i4x://org/course/cat/name). ...@@ -9,10 +9,10 @@ and otherwise returns i4x://org/course/cat/name).
from datetime import datetime from datetime import datetime
from xmodule.exceptions import InvalidVersionError from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import Location, namedtuple_to_son from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.mongo.base import location_to_query, get_course_id_no_run, MongoModuleStore from xmodule.modulestore.mongo.base import location_to_query, namedtuple_to_son, get_course_id_no_run, MongoModuleStore
import pymongo import pymongo
from pytz import UTC from pytz import UTC
......
...@@ -2,8 +2,10 @@ from pprint import pprint ...@@ -2,8 +2,10 @@ from pprint import pprint
# pylint: disable=E0611 # pylint: disable=E0611
from nose.tools import assert_equals, assert_raises, \ from nose.tools import assert_equals, assert_raises, \
assert_not_equals, assert_false assert_not_equals, assert_false
from itertools import ifilter
# pylint: enable=E0611 # pylint: enable=E0611
import pymongo import pymongo
import logging
from uuid import uuid4 from uuid import uuid4
from xblock.fields import Scope from xblock.fields import Scope
...@@ -19,6 +21,7 @@ from xmodule.contentstore.mongo import MongoContentStore ...@@ -19,6 +21,7 @@ from xmodule.contentstore.mongo import MongoContentStore
from xmodule.modulestore.tests.test_modulestore import check_path_to_location from xmodule.modulestore.tests.test_modulestore import check_path_to_location
log = logging.getLogger(__name__)
HOST = 'localhost' HOST = 'localhost'
PORT = 27017 PORT = 27017
...@@ -59,7 +62,7 @@ class TestMongoModuleStore(object): ...@@ -59,7 +62,7 @@ class TestMongoModuleStore(object):
# #
draft_store = DraftModuleStore(HOST, DB, COLLECTION, FS_ROOT, RENDER_TEMPLATE, default_class=DEFAULT_CLASS) draft_store = DraftModuleStore(HOST, DB, COLLECTION, FS_ROOT, RENDER_TEMPLATE, default_class=DEFAULT_CLASS)
# Explicitly list the courses to load (don't want the big one) # Explicitly list the courses to load (don't want the big one)
courses = ['toy', 'simple', 'simple_with_draft'] courses = ['toy', 'simple', 'simple_with_draft', 'test_unicode']
import_from_xml(store, DATA_DIR, courses, draft_store=draft_store, static_content_store=content_store) import_from_xml(store, DATA_DIR, courses, draft_store=draft_store, static_content_store=content_store)
# also test a course with no importing of static content # also test a course with no importing of static content
...@@ -86,6 +89,19 @@ class TestMongoModuleStore(object): ...@@ -86,6 +89,19 @@ class TestMongoModuleStore(object):
def tearDown(self): def tearDown(self):
pass pass
def get_course_by_id(self, name):
"""
Returns the first course with `id` of `name`, or `None` if there are none.
"""
courses = self.store.get_courses()
return next(ifilter(lambda x: x.id == name, courses), None)
def course_with_id_exists(self, name):
"""
Returns true iff there exists some course with `id` of `name`.
"""
return (self.get_course_by_id(name) is not None)
def test_init(self): def test_init(self):
'''Make sure the db loads, and print all the locations in the db. '''Make sure the db loads, and print all the locations in the db.
Call this directly from failing tests to see what is loaded''' Call this directly from failing tests to see what is loaded'''
...@@ -100,12 +116,12 @@ class TestMongoModuleStore(object): ...@@ -100,12 +116,12 @@ class TestMongoModuleStore(object):
def test_get_courses(self): def test_get_courses(self):
'''Make sure the course objects loaded properly''' '''Make sure the course objects loaded properly'''
courses = self.store.get_courses() courses = self.store.get_courses()
assert_equals(len(courses), 4) assert_equals(len(courses), 5)
courses.sort(key=lambda c: c.id) assert self.course_with_id_exists('edX/simple/2012_Fall')
assert_equals(courses[0].id, 'edX/simple/2012_Fall') assert self.course_with_id_exists('edX/simple_with_draft/2012_Fall')
assert_equals(courses[1].id, 'edX/simple_with_draft/2012_Fall') assert self.course_with_id_exists('edX/test_import_course/2012_Fall')
assert_equals(courses[2].id, 'edX/test_import_course/2012_Fall') assert self.course_with_id_exists('edX/test_unicode/2012_Fall')
assert_equals(courses[3].id, 'edX/toy/2012_Fall') assert self.course_with_id_exists('edX/toy/2012_Fall')
def test_loads(self): def test_loads(self):
assert_not_equals( assert_not_equals(
...@@ -120,6 +136,22 @@ class TestMongoModuleStore(object): ...@@ -120,6 +136,22 @@ class TestMongoModuleStore(object):
self.store.get_item("i4x://edX/toy/video/Welcome"), self.store.get_item("i4x://edX/toy/video/Welcome"),
None) None)
def test_unicode_loads(self):
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/course/2012_Fall"),
None)
# All items with ascii-only filenames should load properly.
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/video/Welcome"),
None)
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/video/Welcome"),
None)
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/chapter/Overview"),
None)
def test_find_one(self): def test_find_one(self):
assert_not_equals( assert_not_equals(
self.store._find_one(Location("i4x://edX/toy/course/2012_Fall")), self.store._find_one(Location("i4x://edX/toy/course/2012_Fall")),
...@@ -153,15 +185,15 @@ class TestMongoModuleStore(object): ...@@ -153,15 +185,15 @@ class TestMongoModuleStore(object):
) )
def test_static_tab_names(self): def test_static_tab_names(self):
courses = self.store.get_courses()
def get_tab_name(index): def get_tab_name(index):
""" """
Helper function for pulling out the name of a given static tab. Helper function for pulling out the name of a given static tab.
Assumes the information is desired for courses[1] ('toy' course). Assumes the information is desired for courses[4] ('toy' course).
""" """
return courses[2].tabs[index]['name'] course = self.get_course_by_id('edX/toy/2012_Fall')
return course.tabs[index]['name']
# There was a bug where model.save was not getting called after the static tab name # There was a bug where model.save was not getting called after the static tab name
# was set set for tabs that have a URL slug. 'Syllabus' and 'Resources' fall into that # was set set for tabs that have a URL slug. 'Syllabus' and 'Resources' fall into that
......
...@@ -29,7 +29,7 @@ from .exceptions import ItemNotFoundError ...@@ -29,7 +29,7 @@ from .exceptions import ItemNotFoundError
from .inheritance import compute_inherited_metadata from .inheritance import compute_inherited_metadata
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True) remove_comments=False, remove_blank_text=True)
etree.set_default_parser(edx_xml_parser) etree.set_default_parser(edx_xml_parser)
...@@ -173,7 +173,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): ...@@ -173,7 +173,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
# Didn't load properly. Fall back on loading as an error # Didn't load properly. Fall back on loading as an error
# descriptor. This should never error due to formatting. # descriptor. This should never error due to formatting.
msg = "Error loading from xml. " + str(err)[:200] msg = "Error loading from xml. " + unicode(err)[:200]
log.warning(msg) log.warning(msg)
# Normally, we don't want lots of exception traces in our logs from common # Normally, we don't want lots of exception traces in our logs from common
# content problems. But if you're debugging the xml loading code itself, # content problems. But if you're debugging the xml loading code itself,
...@@ -190,7 +190,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): ...@@ -190,7 +190,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
err_msg err_msg
) )
setattr(descriptor, 'data_dir', course_dir) descriptor.data_dir = course_dir
xmlstore.modules[course_id][descriptor.location] = descriptor xmlstore.modules[course_id][descriptor.location] = descriptor
...@@ -317,7 +317,8 @@ class XMLModuleStore(ModuleStoreBase): ...@@ -317,7 +317,8 @@ class XMLModuleStore(ModuleStoreBase):
try: try:
course_descriptor = self.load_course(course_dir, errorlog.tracker) course_descriptor = self.load_course(course_dir, errorlog.tracker)
except Exception as e: except Exception as e:
msg = "ERROR: Failed to load course '{0}': {1}".format(course_dir, str(e)) msg = "ERROR: Failed to load course '{0}': {1}".format(course_dir.encode("utf-8"),
unicode(e))
log.exception(msg) log.exception(msg)
errorlog.tracker(msg) errorlog.tracker(msg)
...@@ -493,8 +494,9 @@ class XMLModuleStore(ModuleStoreBase): ...@@ -493,8 +494,9 @@ class XMLModuleStore(ModuleStoreBase):
module.save() module.save()
self.modules[course_descriptor.id][module.location] = module self.modules[course_descriptor.id][module.location] = module
except Exception, e: except Exception, e:
logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e))) logging.exception("Failed to load %s. Skipping... \
system.error_tracker("ERROR: " + str(e)) Exception: %s", filepath, unicode(e))
system.error_tracker("ERROR: " + unicode(e))
def get_instance(self, course_id, location, depth=0): def get_instance(self, course_id, location, depth=0):
""" """
......
...@@ -31,7 +31,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_ ...@@ -31,7 +31,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
try: try:
content_path = os.path.join(dirname, filename) content_path = os.path.join(dirname, filename)
if verbose: if verbose:
log.debug('importing static content {0}...'.format(content_path)) log.debug('importing static content %s...', content_path)
fullname_with_subpath = content_path.replace(static_dir, '') # strip away leading path from the name fullname_with_subpath = content_path.replace(static_dir, '') # strip away leading path from the name
if fullname_with_subpath.startswith('/'): if fullname_with_subpath.startswith('/'):
......
...@@ -25,7 +25,7 @@ class GradingService(object): ...@@ -25,7 +25,7 @@ class GradingService(object):
def __init__(self, config): def __init__(self, config):
self.username = config['username'] self.username = config['username']
self.password = config['password'] self.password = config['password']
self.session = requests.session() self.session = requests.Session()
self.system = config['system'] self.system = config['system']
def _login(self): def _login(self):
...@@ -42,7 +42,7 @@ class GradingService(object): ...@@ -42,7 +42,7 @@ class GradingService(object):
response.raise_for_status() response.raise_for_status()
return response.json return response.json()
def post(self, url, data, allow_redirects=False): def post(self, url, data, allow_redirects=False):
""" """
...@@ -88,9 +88,10 @@ class GradingService(object): ...@@ -88,9 +88,10 @@ class GradingService(object):
Returns the result of operation(). Does not catch exceptions. Returns the result of operation(). Does not catch exceptions.
""" """
response = operation() response = operation()
if (response.json resp_json = response.json()
and response.json.get('success') is False if (resp_json
and response.json.get('error') == 'login_required'): and resp_json.get('success') is False
and resp_json.get('error') == 'login_required'):
# apparrently we aren't logged in. Try to fix that. # apparrently we aren't logged in. Try to fix that.
r = self._login() r = self._login()
if r and not r.get('success'): if r and not r.get('success'):
......
...@@ -133,7 +133,7 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor): ...@@ -133,7 +133,7 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
except Exception as e: except Exception as e:
log.exception("Unable to load child when parsing Sequence. Continuing...") log.exception("Unable to load child when parsing Sequence. Continuing...")
if system.error_tracker is not None: if system.error_tracker is not None:
system.error_tracker("ERROR: " + str(e)) system.error_tracker("ERROR: " + unicode(e))
continue continue
return {}, children return {}, children
......
...@@ -62,6 +62,7 @@ def get_test_system(course_id=''): ...@@ -62,6 +62,7 @@ def get_test_system(course_id=''):
user=Mock(is_staff=False), user=Mock(is_staff=False),
filestore=Mock(), filestore=Mock(),
debug=True, debug=True,
hostname="edx.org",
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10, 'construct_callback' : Mock(side_effect="/")}, xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10, 'construct_callback' : Mock(side_effect="/")},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
xblock_field_data=lambda descriptor: descriptor._field_data, xblock_field_data=lambda descriptor: descriptor._field_data,
......
...@@ -13,7 +13,7 @@ class ConditionalModuleTest(LogicTest): ...@@ -13,7 +13,7 @@ class ConditionalModuleTest(LogicTest):
"Make shure that ajax request works correctly" "Make shure that ajax request works correctly"
# Mock is_condition_satisfied # Mock is_condition_satisfied
self.xmodule.is_condition_satisfied = lambda: True self.xmodule.is_condition_satisfied = lambda: True
setattr(self.xmodule.descriptor, 'get_children', lambda: []) self.xmodule.descriptor.get_children = lambda: []
response = self.ajax_request('No', {}) response = self.ajax_request('No', {})
html = response['html'] html = response['html']
......
"""Tests for xmodule.util.date_utils""" """Tests for xmodule.util.date_utils"""
from nose.tools import assert_equals, assert_false # pylint: disable=E0611 from nose.tools import assert_equals, assert_false # pylint: disable=E0611
from xmodule.util.date_utils import get_default_time_display, almost_same_datetime from xmodule.util.date_utils import get_default_time_display, get_time_display, almost_same_datetime
from datetime import datetime, timedelta, tzinfo from datetime import datetime, timedelta, tzinfo
from pytz import UTC from pytz import UTC
...@@ -12,25 +12,34 @@ def test_get_default_time_display(): ...@@ -12,25 +12,34 @@ def test_get_default_time_display():
assert_equals( assert_equals(
"Mar 12, 1992 at 15:03 UTC", "Mar 12, 1992 at 15:03 UTC",
get_default_time_display(test_time)) get_default_time_display(test_time))
assert_equals(
"Mar 12, 1992 at 15:03 UTC",
get_default_time_display(test_time, True))
assert_equals(
"Mar 12, 1992 at 15:03",
get_default_time_display(test_time, False))
def test_get_default_time_display_notz(): def test_get_dflt_time_disp_notz():
test_time = datetime(1992, 3, 12, 15, 3, 30) test_time = datetime(1992, 3, 12, 15, 3, 30)
assert_equals( assert_equals(
"Mar 12, 1992 at 15:03 UTC", "Mar 12, 1992 at 15:03 UTC",
get_default_time_display(test_time)) get_default_time_display(test_time))
assert_equals(
"Mar 12, 1992 at 15:03 UTC",
get_default_time_display(test_time, True)) def test_get_time_disp_ret_empty():
assert_equals( assert_equals("", get_time_display(None))
"Mar 12, 1992 at 15:03", test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC)
get_default_time_display(test_time, False)) assert_equals("", get_time_display(test_time, ""))
def test_get_time_display():
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC)
assert_equals("dummy text", get_time_display(test_time, 'dummy text'))
assert_equals("Mar 12 1992", get_time_display(test_time, '%b %d %Y'))
assert_equals("Mar 12 1992 UTC", get_time_display(test_time, '%b %d %Y %Z'))
assert_equals("Mar 12 15:03", get_time_display(test_time, '%b %d %H:%M'))
def test_get_time_pass_through():
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC)
assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time))
assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, None))
assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, "%"))
# pylint: disable=W0232 # pylint: disable=W0232
...@@ -50,12 +59,6 @@ def test_get_default_time_display_no_tzname(): ...@@ -50,12 +59,6 @@ def test_get_default_time_display_no_tzname():
assert_equals( assert_equals(
"Mar 12, 1992 at 15:03-0300", "Mar 12, 1992 at 15:03-0300",
get_default_time_display(test_time)) get_default_time_display(test_time))
assert_equals(
"Mar 12, 1992 at 15:03-0300",
get_default_time_display(test_time, True))
assert_equals(
"Mar 12, 1992 at 15:03",
get_default_time_display(test_time, False))
def test_almost_same_datetime(): def test_almost_same_datetime():
......
...@@ -91,6 +91,7 @@ class ImportTestCase(BaseCourseTestCase): ...@@ -91,6 +91,7 @@ class ImportTestCase(BaseCourseTestCase):
self.assertNotEqual(descriptor1.location, descriptor2.location) self.assertNotEqual(descriptor1.location, descriptor2.location)
@unittest.skip('Temporarily disabled')
def test_reimport(self): def test_reimport(self):
'''Make sure an already-exported error xml tag loads properly''' '''Make sure an already-exported error xml tag loads properly'''
...@@ -368,6 +369,32 @@ class ImportTestCase(BaseCourseTestCase): ...@@ -368,6 +369,32 @@ class ImportTestCase(BaseCourseTestCase):
html = modulestore.get_instance(course_id, loc) html = modulestore.get_instance(course_id, loc)
self.assertEquals(html.display_name, "Toy lab") self.assertEquals(html.display_name, "Toy lab")
def test_unicode(self):
"""Check that courses with unicode characters in filenames and in
org/course/name import properly. Currently, this means: (a) Having
files with unicode names does not prevent import; (b) if files are not
loaded because of unicode filenames, there are appropriate
exceptions/errors to that effect."""
print("Starting import")
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['test_unicode'])
courses = modulestore.get_courses()
self.assertEquals(len(courses), 1)
course = courses[0]
print("course errors:")
# Expect to find an error/exception about characters in "®esources"
expect = "Invalid characters in '®esources'"
errors = [(msg.encode("utf-8"), err.encode("utf-8"))
for msg, err in
modulestore.get_item_errors(course.location)]
self.assertTrue(any(expect in msg or expect in err
for msg, err in errors))
chapters = course.get_children()
self.assertEqual(len(chapters), 3)
def test_url_name_mangling(self): def test_url_name_mangling(self):
""" """
Make sure that url_names are only mangled once. Make sure that url_names are only mangled once.
......
...@@ -48,6 +48,12 @@ class VideoModuleTest(LogicTest): ...@@ -48,6 +48,12 @@ class VideoModuleTest(LogicTest):
output = VideoDescriptor._parse_time('00:04:07') output = VideoDescriptor._parse_time('00:04:07')
self.assertEqual(output, expected) self.assertEqual(output, expected)
def test_parse_time_with_float(self):
"""Ensure that times are parsed correctly into seconds."""
expected = 247
output = VideoDescriptor._parse_time('247.0')
self.assertEqual(output, expected)
def test_parse_youtube(self): def test_parse_youtube(self):
"""Test parsing old-style Youtube ID strings into a dict.""" """Test parsing old-style Youtube ID strings into a dict."""
youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg' youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg'
...@@ -412,6 +418,35 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -412,6 +418,35 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'data': '' 'data': ''
}) })
def test_import_with_float_times(self):
"""
Ensure that Video is able to read VideoModule's model data.
"""
module_system = DummySystem(load_error_modules=True)
xml_data = """
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
from="1.0"
to="60.0">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</video>
"""
video = VideoDescriptor.from_xml(xml_data, module_system)
self.assert_attributes_equal(video, {
'youtube_id_0_75': 'izygArpw-Qo',
'youtube_id_1_0': 'p2Q6BrNhdh8',
'youtube_id_1_25': '1EeWXzPdhSA',
'youtube_id_1_5': 'rABDYkeK0x8',
'show_captions': False,
'start_time': 1.0,
'end_time': 60.0,
'track': 'http://www.example.com/track',
'html5_sources': ['http://www.example.com/source.mp4'],
'data': ''
})
class VideoExportTestCase(unittest.TestCase): class VideoExportTestCase(unittest.TestCase):
""" """
......
...@@ -2,32 +2,46 @@ ...@@ -2,32 +2,46 @@
Convenience methods for working with datetime objects Convenience methods for working with datetime objects
""" """
from datetime import timedelta from datetime import timedelta
from django.utils.translation import ugettext as _
def get_default_time_display(dt, show_timezone=True): def get_default_time_display(dtime):
""" """
Converts a datetime to a string representation. This is the default Converts a datetime to a string representation. This is the default
representation used in Studio and LMS. representation used in Studio and LMS.
It is of the form "Apr 09, 2013 at 16:00" or "Apr 09, 2013 at 16:00 UTC", It is of the form "Apr 09, 2013 at 16:00 UTC".
depending on the value of show_timezone.
If None is passed in for dt, an empty string will be returned. If None is passed in for dt, an empty string will be returned.
The default value of show_timezone is True.
""" """
if dt is None: if dtime is None:
return u"" return u""
timezone = u"" if dtime.tzinfo is not None:
if show_timezone: try:
if dt.tzinfo is not None: timezone = u" " + dtime.tzinfo.tzname(dtime)
try: except NotImplementedError:
timezone = u" " + dt.tzinfo.tzname(dt) timezone = dtime.strftime('%z')
except NotImplementedError: else:
timezone = dt.strftime('%z') timezone = u" UTC"
else: return unicode(dtime.strftime(u"%b %d, %Y at %H:%M{tz}")).format(
timezone = u" UTC" tz=timezone).strip()
return unicode(dt.strftime(u"%b %d, %Y {at} %H:%M{tz}")).format(
at=_(u"at"), tz=timezone).strip()
def get_time_display(dtime, format_string=None):
"""
Converts a datetime to a string representation.
If None is passed in for dt, an empty string will be returned.
If the format_string is None, or if format_string is improperly
formatted, this method will return the value from `get_default_time_display`.
format_string should be a unicode string that is a valid argument for datetime's strftime method.
"""
if dtime is None or format_string is None:
return get_default_time_display(dtime)
try:
return unicode(dtime.strftime(format_string))
except ValueError:
return get_default_time_display(dtime)
def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)): def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)):
......
...@@ -385,12 +385,16 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor ...@@ -385,12 +385,16 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
if not str_time: if not str_time:
return '' return ''
else: else:
obj_time = time.strptime(str_time, '%H:%M:%S') try:
return datetime.timedelta( obj_time = time.strptime(str_time, '%H:%M:%S')
hours=obj_time.tm_hour, return datetime.timedelta(
minutes=obj_time.tm_min, hours=obj_time.tm_hour,
seconds=obj_time.tm_sec minutes=obj_time.tm_min,
).total_seconds() seconds=obj_time.tm_sec
).total_seconds()
except ValueError:
# We've seen serialized versions of float in this field
return float(str_time)
def _create_youtube_string(module): def _create_youtube_string(module):
......
...@@ -349,7 +349,7 @@ class ResourceTemplates(object): ...@@ -349,7 +349,7 @@ class ResourceTemplates(object):
@classmethod @classmethod
def get_template_dir(cls): def get_template_dir(cls):
if getattr(cls, 'template_dir_name', None): if getattr(cls, 'template_dir_name', None):
dirname = os.path.join('templates', getattr(cls, 'template_dir_name')) dirname = os.path.join('templates', cls.template_dir_name)
if not resource_isdir(__name__, dirname): if not resource_isdir(__name__, dirname):
log.warning("No resource directory {dir} found when loading {cls_name} templates".format( log.warning("No resource directory {dir} found when loading {cls_name} templates".format(
dir=dirname, dir=dirname,
...@@ -619,14 +619,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): ...@@ -619,14 +619,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
raise NotImplementedError( raise NotImplementedError(
'Modules must implement export_to_xml to enable xml export') 'Modules must implement export_to_xml to enable xml export')
# =============================== Testing ==================================
def get_sample_state(self):
"""
Return a list of tuples of instance_state, shared_state. Each tuple
defines a sample case for this module
"""
return [('{}', '{}')]
@property @property
def xblock_kvs(self): def xblock_kvs(self):
""" """
...@@ -847,7 +839,7 @@ class ModuleSystem(Runtime): ...@@ -847,7 +839,7 @@ class ModuleSystem(Runtime):
def __init__( def __init__(
self, ajax_url, track_function, get_module, render_template, self, ajax_url, track_function, get_module, render_template,
replace_urls, xblock_field_data, user=None, filestore=None, replace_urls, xblock_field_data, user=None, filestore=None,
debug=False, xqueue=None, publish=None, node_path="", debug=False, hostname="", xqueue=None, publish=None, node_path="",
anonymous_student_id='', course_id=None, anonymous_student_id='', course_id=None,
open_ended_grading_interface=None, s3_interface=None, open_ended_grading_interface=None, s3_interface=None,
cache=None, can_execute_unsafe_code=None, replace_course_urls=None, cache=None, can_execute_unsafe_code=None, replace_course_urls=None,
...@@ -911,6 +903,7 @@ class ModuleSystem(Runtime): ...@@ -911,6 +903,7 @@ class ModuleSystem(Runtime):
self.get_module = get_module self.get_module = get_module
self.render_template = render_template self.render_template = render_template
self.DEBUG = self.debug = debug self.DEBUG = self.debug = debug
self.HOSTNAME = self.hostname = hostname
self.seed = user.id if user is not None else 0 self.seed = user.id if user is not None else 0
self.replace_urls = replace_urls self.replace_urls = replace_urls
self.node_path = node_path self.node_path = node_path
......
...@@ -164,7 +164,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -164,7 +164,7 @@ class XmlDescriptor(XModuleDescriptor):
# Used for storing xml attributes between import and export, for roundtrips # Used for storing xml attributes between import and export, for roundtrips
'xml_attributes') 'xml_attributes')
metadata_to_export_to_policy = ('discussion_topics') metadata_to_export_to_policy = ('discussion_topics', 'checklists')
@classmethod @classmethod
def get_map_for_field(cls, attr): def get_map_for_field(cls, attr):
......
...@@ -64,10 +64,8 @@ if Backbone? ...@@ -64,10 +64,8 @@ if Backbone?
sidebar = $(".sidebar") sidebar = $(".sidebar")
if scrollTop > discussionsBodyTop - @sidebar_padding if scrollTop > discussionsBodyTop - @sidebar_padding
sidebar.addClass('fixed'); sidebar.css('top', scrollTop - discussionsBodyTop + @sidebar_padding);
sidebar.css('top', @sidebar_padding);
else else
sidebar.removeClass('fixed');
sidebar.css('top', '0'); sidebar.css('top', '0');
sidebarWidth = .31 * $(".discussion-body").width(); sidebarWidth = .31 * $(".discussion-body").width();
......
...@@ -82,7 +82,7 @@ $(document).ready(function() { ...@@ -82,7 +82,7 @@ $(document).ready(function() {
<dl class="list-faq"> <dl class="list-faq">
<dt class="faq-question">${_("Why do I have to pay?")}</dt> <dt class="faq-question">${_("Why do I have to pay?")}</dt>
<dd class="faq-answer"> <dd class="faq-answer">
<p>${_("As a not-for-profit, edX uses your contribution to support our mission to provide quality education to everyone around the world. While we have established a minimum fee, we ask that you contribute as much as you can.")}</p> <p>${_("As a not-for-profit, edX uses your contribution to support our mission to provide quality education to everyone around the world, and to improve learning through research. While we have established a minimum fee, we ask that you contribute as much as you can.")}</p>
</dd> </dd>
<dt class="faq-question">${_("I'd like to pay more than the minimum. Is my contribution tax deductible?")}</dt> <dt class="faq-question">${_("I'd like to pay more than the minimum. Is my contribution tax deductible?")}</dt>
...@@ -93,7 +93,7 @@ $(document).ready(function() { ...@@ -93,7 +93,7 @@ $(document).ready(function() {
% if "honor" in modes: % if "honor" in modes:
<dt class="faq-question">${_("What if I can't afford it or don't have the necessary equipment?")}</dt> <dt class="faq-question">${_("What if I can't afford it or don't have the necessary equipment?")}</dt>
<dd class="faq-answer"> <dd class="faq-answer">
<p>${_("If you can't afford the minimum fee or don't meet the requirements, you can audit the course for free. You may also elect to pursue an Honor Code certificate, but you will need to tell us why you would like the fee waived below. Then click the 'Select Certificate' button to complete your registration.")}</p> <p>${_("If you can't afford the minimum fee or don't meet the requirements, you can audit the course or elect to pursue an honor code certificate at no cost. If you would like to pursue the honor code certificate, please check the honor code certificate box, tell us why you can't pursue the verified certificate below, and then click the 'Select Certificate' button to complete your registration.")}</p>
<ul class="list-fields"> <ul class="list-fields">
<li class="field field-honor-code checkbox"> <li class="field field-honor-code checkbox">
...@@ -102,7 +102,7 @@ $(document).ready(function() { ...@@ -102,7 +102,7 @@ $(document).ready(function() {
</li> </li>
<li class="field field-explain"> <li class="field field-explain">
<label for="explain"><span class="sr">${_("Explain your situation: ")}</span>${_("Please write a few sentences about why you would like the fee waived for this course")}</label> <label for="explain"><span class="sr">${_("Explain your situation: ")}</span>${_("Please write a few sentences about why you'd like to opt out of the paid verified certificate to pursue the honor code certificate:")}</label>
<textarea name="explain"></textarea> <textarea name="explain"></textarea>
</li> </li>
</ul> </ul>
......
<section class="about">
<h2>About This Course</h2>
<p>Include your long course description here. The long course description should contain 150-400 words.</p>
<p>This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.</p>
</section>
<section class="prerequisites">
<h2>Prerequisites</h2>
<p>Add information about course prerequisites here.</p>
</section>
<section class="course-staff">
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">
</div>
<h3>Staff Member #1</h3>
<p>Biography of instructor/staff member #1</p>
</article>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">
</div>
<h3>Staff Member #2</h3>
<p>Biography of instructor/staff member #2</p>
</article>
</section>
<section class="faq">
<section class="responses">
<h2>Frequently Asked Questions</h2>
<article class="response">
<h3>Do I need to buy a textbook?</h3>
<p>No, a free online version of Chemistry: Principles, Patterns, and Applications, First Edition by Bruce Averill and Patricia Eldredge will be available, though you can purchase a printed version (published by FlatWorld Knowledge) if you’d like.</p>
</article>
<article class="response">
<h3>Question #2</h3>
<p>Your answer would be displayed here.</p>
</article>
</section>
</section>
<chapter display_name="Section">
<sequential url_name="c804fa32227142a1bd9d5bc183d4a20d"/>
</chapter>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment