diff --git a/.gitignore b/.gitignore index 05e76c4caa..69bc47afdd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ .AppleDouble database.sqlite requirements/private.txt +lms/envs/private.py +cms/envs/private.py courseware/static/js/mathjax/* flushdb.sh build @@ -27,6 +29,7 @@ conf/locale/en/LC_MESSAGES/*.po !messages.po lms/static/sass/*.css lms/static/sass/application.scss +lms/static/sass/course.scss cms/static/sass/*.css lms/lib/comment_client/python nosetests.xml diff --git a/AUTHORS b/AUTHORS index ca6e14b252..7d6397629f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -72,4 +72,7 @@ Giulio Gratta David Baumgold Jason Bau Frances Botsford +Jonah Stanley Slater Victoroff +Peter Fogg +Renzo Lucioni \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000000..89872937bf --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,82 @@ +Change Log +---------- + +These are notable changes in edx-platform. This is a rolling list of changes, +in roughly chronological order, most recent first. Add your entries at or near +the top. Include a label indicating the component affected. + + +LMS: Some errors handling Non-ASCII data in XML courses have been fixed. + +LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and +SEGMENT_IO_LMS feature flag is on) + +LMS: Background colors on login, register, and courseware have been corrected +back to white. + +LMS: Accessibility improvements have been made to several courseware and +navigation elements. + +LMS: Small design/presentation changes to login and register views. + +LMS: Functionality added to instructor enrollment tab in LMS such that invited +student can be auto-enrolled in course or when activating if not current +student. + +Blades: Staff debug info is now accessible for Graphical Slider Tool problems. + +Blades: For Video Alpha the events ready, play, pause, seek, and speed change +are logged on the server (in the logs). + +Common: Developers can now have private Django settings files. + +Common: Safety code added to prevent anything above the vertical level in the +course tree from being marked as version='draft'. It will raise an exception if +the code tries to so mark a node. We need the backtraces to figure out where +this very infrequent intermittent marking was occurring. It was making courses +look different in Studio than in LMS. + +Deploy: MKTG_URLS is now read from env.json. + +Common: Theming makes it possible to change the look of the site, from +Stanford. + +Common: Accessibility UI fixes. + +Common: The "duplicate email" error message is more informative. + +Studio: Component metadata settings editor. + +Studio: Autoplay is disabled (only in Studio). + +Studio: Single-click creation for video and discussion components. + +Studio: fixed a bad link in the activation page. + +LMS: Changed the help button text. + +LMS: Fixed failing numeric response (decimal but no trailing digits). + +LMS: XML Error module no longer shows students a stack trace. + +Blades: Videoalpha. + +XModules: Added partial credit for foldit module. + +XModules: Added "randomize" XModule to list of XModule types. + +XModules: Show errors with full descriptors. + +XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is +dropped suddenly. + +XQueue: Upload file submissions to a specially named bucket in S3. + +Common: Removed request debugger. + +Common: Updated Django to version 1.4.5. + +Common: Updated CodeJail. + +Common: Allow setting of authentication session cookie name. + diff --git a/README.md b/README.md index ba3c99e6e2..92a4116354 100644 --- a/README.md +++ b/README.md @@ -111,11 +111,11 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run: $ rake django-admin[syncdb] $ rake django-admin[migrate] - $ rake django-admin[update_templates] + $ rake cms:update_templates If you are running these commands using the [`zsh`](http://www.zsh.org/) shell, zsh will assume that you are doing -[shell globbing](https://en.wikipedia.org/wiki/Glob_(programming)), search for +[shell globbing](https://en.wikipedia.org/wiki/Glob_%28programming%29), search for a file in your directory named `django-adminsyncdb` or `django-adminmigrate`, and fail. To fix this, just surround the argument with quotation marks, so that you're running `rake "django-admin[syncdb]"`. diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 6f6cc50702..558294e890 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -11,8 +11,6 @@ Feature: Advanced (manual) course policy Given I am on the Advanced Course Settings page in Studio Then the settings are alphabetized - # Skipped because Ubuntu ChromeDriver cannot click notification "Cancel" - @skip Scenario: Test cancel editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key @@ -21,8 +19,6 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is unchanged - # Skipped because Ubuntu ChromeDriver cannot click notification "Save" - @skip Scenario: Test editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key and save @@ -30,8 +26,6 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input - @skip Scenario: Test how multi-line input appears Given I am on the Advanced Course Settings page in Studio When I create a JSON object as a value @@ -39,8 +33,6 @@ Feature: Advanced (manual) course policy And I reload the page Then it is displayed as formatted - # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input - @skip Scenario: Test automatic quoting of non-JSON values Given I am on the Advanced Course Settings page in Studio When I create a non-JSON value not in quotes diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 3acebecac8..eb00c06ba9 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -42,8 +42,9 @@ def edit_the_value_of_a_policy_key(step): It is hard to figure out how to get into the CodeMirror area, so cheat and do it from the policy key field :) """ - e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] - e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X') + world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click() + g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") + g._element.send_keys(Keys.ARROW_LEFT, ' ', 'X') @step(u'I edit the value of a policy key and save$') @@ -123,10 +124,12 @@ def get_display_name_value(): def change_display_name_value(step, new_value): - e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] + + world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click() + g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") display_name = get_display_name_value() for count in range(len(display_name)): - e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE) + g._element.send_keys(Keys.END, Keys.BACK_SPACE) # Must delete "" before typing the JSON value - e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value) + g._element.send_keys(Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value) press_the_notification_button(step, "Save") diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 96b840ae96..494192ad06 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -161,3 +161,11 @@ def i_created_a_video_component(step): 'i4x://edx/templates/video/default', '.xmodule_VideoModule' ) + + +@step('I have clicked the new unit button') +def open_new_unit(step): + step.given('I have opened a new course section in Studio') + step.given('I have added a new subsection') + step.given('I expand the first section') + world.css_click('a.new-unit-item') diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 4c674dc34c..43164f62be 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -14,20 +14,27 @@ def create_component_instance(step, component_button_css, instance_id, expected_ @world.absorb def click_new_component_button(step, component_button_css): - step.given('I have opened a new course section in Studio') - step.given('I have added a new subsection') - step.given('I expand the first section') - world.css_click('a.new-unit-item') + step.given('I have clicked the new unit button') world.css_click(component_button_css) @world.absorb def click_component_from_menu(instance_id, expected_css): + """ + Creates a component from `instance_id`. For components with more + than one template, clicks on `elem_css` to create the new + component. Components with only one template are created as soon + as the user clicks the appropriate button, so we assert that the + expected component is present. + """ elem_css = "a[data-location='%s']" % instance_id - assert_equal(1, len(world.css_find(elem_css))) - world.css_click(elem_css) + elements = world.css_find(elem_css) + assert(len(elements) == 1) + if elements[0]['id'] == instance_id: # If this is a component with multiple templates + world.css_click(elem_css) assert_equal(1, len(world.css_find(expected_css))) + @world.absorb def edit_component_and_select_settings(): world.css_click('a.edit-button') diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index aa2e9d68f8..a3e838a9d1 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -47,12 +47,6 @@ def i_see_the_course_in_my_courses(step): assert world.css_has_text(course_css, 'Robot Super Course') -@step('the course is loaded$') -def course_is_loaded(step): - class_css = 'a.class-name' - assert world.css_has_text(course_css, 'Robot Super Cousre') - - @step('I am on the "([^"]*)" tab$') def i_am_on_tab(step, tab_name): header_css = 'div.inner-wrapper h1' diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature index 24683c3297..8fb14c3205 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.feature +++ b/cms/djangoapps/contentstore/features/discussion-editor.feature @@ -11,3 +11,7 @@ Feature: Discussion Component Editor And I edit and select Settings Then I can modify the display name And my display name change is persisted on save + + Scenario: Creating a discussion takes a single click + Given I have clicked the new unit button + Then creating a discussion takes a single click diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index aced4c2c88..ae3da3c458 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -21,3 +21,10 @@ def i_see_only_the_settings_and_values(step): ['Display Name', "Discussion Tag", True], ['Subcategory', "Topic-Level Student-Visible Label", True] ]) + + +@step('creating a discussion takes a single click') +def discussion_takes_a_single_click(step): + assert(not world.is_css_present('.xmodule_DiscussionModule')) + world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']") + assert(world.is_css_present('.xmodule_DiscussionModule')) diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature index 6ed8c1619b..bde350d8a3 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.feature +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -52,7 +52,7 @@ Feature: Problem Editor Scenario: User cannot type out of range values in an integer number field Given I have created a Blank Common Problem And I edit and select Settings - Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "1" + Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0" Scenario: Settings changes are not saved on Cancel Given I have created a Blank Common Problem diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 236cf501fc..80ccb6cc7a 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -26,11 +26,9 @@ Feature: Create Section And I save a new section release date Then the section release date is updated - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip Scenario: Delete section Given I have opened a new course in Studio And I have added a new section - When I press the "section" delete icon - And I confirm the alert + When I will confirm all alerts + And I press the "section" delete icon Then the section does not exist diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 9a896d8ebe..9d63fa73c8 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -9,34 +9,34 @@ from nose.tools import assert_equal @step('I click the new section link$') -def i_click_new_section_link(step): +def i_click_new_section_link(_step): link_css = 'a.new-courseware-section-button' world.css_click(link_css) @step('I enter the section name and click save$') -def i_save_section_name(step): +def i_save_section_name(_step): save_section_name('My Section') @step('I enter a section name with a quote and click save$') -def i_save_section_name_with_quote(step): +def i_save_section_name_with_quote(_step): save_section_name('Section with "Quote"') @step('I have added a new section$') -def i_have_added_new_section(step): +def i_have_added_new_section(_step): add_section() @step('I click the Edit link for the release date$') -def i_click_the_edit_link_for_the_release_date(step): +def i_click_the_edit_link_for_the_release_date(_step): button_css = 'div.section-published-date a.edit-button' world.css_click(button_css) @step('I save a new section release date$') -def i_save_a_new_section_release_date(step): +def i_save_a_new_section_release_date(_step): set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013', 'input.start-time.time.ui-timepicker-input', '00:00') world.browser.click_link_by_text('Save') @@ -46,35 +46,35 @@ def i_save_a_new_section_release_date(step): @step('I see my section on the Courseware page$') -def i_see_my_section_on_the_courseware_page(step): +def i_see_my_section_on_the_courseware_page(_step): see_my_section_on_the_courseware_page('My Section') @step('I see my section name with a quote on the Courseware page$') -def i_see_my_section_name_with_quote_on_the_courseware_page(step): +def i_see_my_section_name_with_quote_on_the_courseware_page(_step): see_my_section_on_the_courseware_page('Section with "Quote"') @step('I click to edit the section name$') -def i_click_to_edit_section_name(step): +def i_click_to_edit_section_name(_step): world.css_click('span.section-name-span') @step('I see the complete section name with a quote in the editor$') -def i_see_complete_section_name_with_quote_in_editor(step): +def i_see_complete_section_name_with_quote_in_editor(_step): css = '.section-name-edit input[type=text]' assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') @step('the section does not exist$') -def section_does_not_exist(step): - css = 'span.section-name-span' - assert world.browser.is_element_not_present_by_css(css) +def section_does_not_exist(_step): + css = 'h3[data-name="My Section"]' + assert world.is_css_not_present(css) @step('I see a release date for my section$') -def i_see_a_release_date_for_my_section(step): +def i_see_a_release_date_for_my_section(_step): import re css = 'span.published-status' @@ -83,26 +83,32 @@ def i_see_a_release_date_for_my_section(step): # e.g. 11/06/2012 at 16:25 msg = 'Will Release:' - date_regex = '[01][0-9]\/[0-3][0-9]\/[12][0-9][0-9][0-9]' - time_regex = '[0-2][0-9]:[0-5][0-9]' - match_string = '%s %s at %s' % (msg, date_regex, time_regex) + date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}' + if not re.search(date_regex, status_text): + print status_text, date_regex + time_regex = r'[0-2]\d:[0-5]\d( \w{3})?' + if not re.search(time_regex, status_text): + print status_text, time_regex + match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex) + if not re.match(match_string, status_text): + print status_text, match_string assert re.match(match_string, status_text) @step('I see a link to create a new subsection$') -def i_see_a_link_to_create_a_new_subsection(step): +def i_see_a_link_to_create_a_new_subsection(_step): css = 'a.new-subsection-item' assert world.is_css_present(css) @step('the section release date picker is not visible$') -def the_section_release_date_picker_not_visible(step): +def the_section_release_date_picker_not_visible(_step): css = 'div.edit-subsection-publish-settings' assert not world.css_visible(css) @step('the section release date is updated$') -def the_section_release_date_is_updated(step): +def the_section_release_date_is_updated(_step): css = 'span.published-status' status_text = world.css_text(css) assert_equal(status_text, 'Will Release: 12/25/2013 at 00:00 UTC') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index c9f5b43dfb..e746f3629a 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -1,61 +1,59 @@ Feature: Overview Toggle Section - In order to quickly view the details of a course's section or to scan the inventory of sections + In order to quickly view the details of a course's section or to scan the inventory of sections As a course author I want to toggle the visibility of each section's subsection details in the overview listing - Scenario: The default layout for the overview page is to show sections in expanded view - Given I have a course with multiple sections - When I navigate to the course overview page - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: The default layout for the overview page is to show sections in expanded view + Given I have a course with multiple sections + When I navigate to the course overview page + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expand /collapse for a course with no sections - Given I have a course with no sections - When I navigate to the course overview page - Then I do not see the "Collapse All Sections" link + Scenario: Expand /collapse for a course with no sections + Given I have a course with no sections + When I navigate to the course overview page + Then I do not see the "Collapse All Sections" link - Scenario: Collapse link appears after creating first section of a course - Given I have a course with no sections - When I navigate to the course overview page - And I add a section - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Collapse link appears after creating first section of a course + Given I have a course with no sections + When I navigate to the course overview page + And I add a section + Then I see the "Collapse All Sections" link + And all sections are expanded - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip - Scenario: Collapse link is not removed after last section of a course is deleted - Given I have a course with 1 section - And I navigate to the course overview page - When I press the "section" delete icon - And I confirm the alert - Then I see the "Collapse All Sections" link + Scenario: Collapse link is not removed after last section of a course is deleted + Given I have a course with 1 section + And I navigate to the course overview page + When I will confirm all alerts + And I press the "section" delete icon + Then I see the "Collapse All Sections" link - Scenario: Collapsing all sections when all sections are expanded - Given I navigate to the courseware page of a course with multiple sections - And all sections are expanded - When I click the "Collapse All Sections" link - Then I see the "Expand All Sections" link - And all sections are collapsed + Scenario: Collapsing all sections when all sections are expanded + Given I navigate to the courseware page of a course with multiple sections + And all sections are expanded + When I click the "Collapse All Sections" link + Then I see the "Expand All Sections" link + And all sections are collapsed - Scenario: Collapsing all sections when 1 or more sections are already collapsed - Given I navigate to the courseware page of a course with multiple sections - And all sections are expanded - When I collapse the first section - And I click the "Collapse All Sections" link - Then I see the "Expand All Sections" link - And all sections are collapsed + Scenario: Collapsing all sections when 1 or more sections are already collapsed + Given I navigate to the courseware page of a course with multiple sections + And all sections are expanded + When I collapse the first section + And I click the "Collapse All Sections" link + Then I see the "Expand All Sections" link + And all sections are collapsed - Scenario: Expanding all sections when all sections are collapsed - Given I navigate to the courseware page of a course with multiple sections - And I click the "Collapse All Sections" link - When I click the "Expand All Sections" link - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Expanding all sections when all sections are collapsed + Given I navigate to the courseware page of a course with multiple sections + And I click the "Collapse All Sections" link + When I click the "Expand All Sections" link + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expanding all sections when 1 or more sections are already expanded - Given I navigate to the courseware page of a course with multiple sections - And I click the "Collapse All Sections" link - When I expand the first section - And I click the "Expand All Sections" link - Then I see the "Collapse All Sections" link - And all sections are expanded + Scenario: Expanding all sections when 1 or more sections are already expanded + Given I navigate to the courseware page of a course with multiple sections + And I click the "Collapse All Sections" link + When I expand the first section + And I click the "Expand All Sections" link + Then I see the "Collapse All Sections" link + And all sections are expanded diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 7f717b731c..3a39f3cc15 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -112,7 +112,7 @@ def all_sections_are_expanded(step): @step(u'all sections are collapsed$') -def all_sections_are_expanded(step): +def all_sections_are_collapsed(step): subsection_locator = 'div.subsection-list' subsections = world.css_find(subsection_locator) for s in subsections: diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 8bb12467ff..a11467e3f9 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -32,12 +32,10 @@ Feature: Create Subsection And I reload the page Then I see the correct dates - # Skipped because Ubuntu ChromeDriver hangs on alert - @skip Scenario: Delete a subsection Given I have opened a new course section in Studio And I have added a new subsection And I see my subsection on the Courseware page - When I press the "subsection" delete icon - And I confirm the alert + When I will confirm all alerts + And I press the "subsection" delete icon Then the subsection does not exist diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index a4cf84d978..0129732d30 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -4,3 +4,12 @@ Feature: Video Component Scenario: Autoplay is disabled in Studio Given I have created a Video component Then when I view the video it does not have autoplay enabled + + Scenario: Creating a video takes a single click + Given I have clicked the new unit button + Then creating a video takes a single click + + Scenario: Captions are shown correctly + Given I have created a Video component + And I have hidden captions + Then when I view the video it does not show the captions diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index f25b8d6d7e..fd8624999e 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -9,3 +9,20 @@ from lettuce import world, step def does_not_autoplay(step): assert world.css_find('.video')[0]['data-autoplay'] == 'False' assert world.css_find('.video_control')[0].has_class('play') + + +@step('creating a video takes a single click') +def video_takes_a_single_click(step): + assert(not world.is_css_present('.xmodule_VideoModule')) + world.css_click("a[data-location='i4x://edx/templates/video/default']") + assert(world.is_css_present('.xmodule_VideoModule')) + + +@step('I have hidden captions') +def set_show_captions_false(step): + world.css_click('a.hide-subtitles') + + +@step('when I view the video it does not show the captions') +def does_not_show_captions(step): + assert world.css_find('.video')[0].has_class('closed') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 0b4535bb70..03449fc22f 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -37,6 +37,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from contentstore.views.component import ADVANCED_COMPONENT_TYPES from django_comment_common.utils import are_permissions_roles_seeded +from xmodule.exceptions import InvalidVersionError +import datetime +from pytz import UTC TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') @@ -77,14 +80,25 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) - def test_advanced_components_in_edit_unit(self): + def check_components_on_page(self, component_types, expected_types): + """ + Ensure that the right types end up on the page. + + component_types is the list of advanced components. + + expected_types is the list of elements that should appear on the page. + + expected_types and component_types should be similar, but not + exactly the same -- for example, 'videoalpha' in + component_types should cause 'Video Alpha' to be present. + """ store = modulestore('direct') import_from_xml(store, 'common/test/data/', ['simple']) course = store.get_item(Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), depth=None) - course.advanced_modules = ADVANCED_COMPONENT_TYPES + course.advanced_modules = component_types store.update_metadata(course.location, own_metadata(course)) @@ -94,13 +108,31 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) self.assertEqual(resp.status_code, 200) + for expected in expected_types: + self.assertIn(expected, resp.content) + + def test_advanced_components_in_edit_unit(self): # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page # response HTML - self.assertIn('Video Alpha', resp.content) - self.assertIn('Word cloud', resp.content) - self.assertIn('Annotation', resp.content) - self.assertIn('Open Ended Response', resp.content) - self.assertIn('Peer Grading Interface', resp.content) + self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha', + 'Word cloud', + 'Annotation', + 'Open Ended Response', + 'Peer Grading Interface']) + + def test_advanced_components_require_two_clicks(self): + self.check_components_on_page(['videoalpha'], ['Video Alpha']) + + def test_malformed_edit_unit_request(self): + store = modulestore('direct') + import_from_xml(store, 'common/test/data/', ['simple']) + + # just pick one vertical + descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] + location = descriptor.location._replace(name='.' + descriptor.location.name) + + resp = self.client.get(reverse('edit_unit', kwargs={'location': location.url()})) + self.assertEqual(resp.status_code, 400) def check_edit_unit(self, test_course_name): import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) @@ -239,7 +271,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ) self.assertTrue(getattr(draft_problem, 'is_draft', False)) - #now requery with depth + # now requery with depth course = modulestore('draft').get_item( Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), depth=None @@ -386,6 +418,32 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) + def test_illegal_draft_crud_ops(self): + draft_store = modulestore('draft') + direct_store = modulestore('direct') + + CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + location = Location('i4x://MITx/999/chapter/neuvo') + self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty', + location) + direct_store.clone_item('i4x://edx/templates/chapter/Empty', location) + self.assertRaises(InvalidVersionError, draft_store.clone_item, location, + location) + + self.assertRaises(InvalidVersionError, draft_store.update_item, location, + 'chapter data') + + # taking advantage of update_children and other functions never checking that the ids are valid + self.assertRaises(InvalidVersionError, draft_store.update_children, location, + ['i4x://MITx/999/problem/doesntexist']) + + self.assertRaises(InvalidVersionError, draft_store.update_metadata, location, + {'due': datetime.datetime.now(UTC)}) + + self.assertRaises(InvalidVersionError, draft_store.unpublish, location) + + def test_bad_contentstore_request(self): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) @@ -468,6 +526,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # check for custom_tags self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template') + # check for about content + self.verify_content_existence(module_store, root_dir, location, 'about', 'about', '.html') + # check for graiding_policy.json filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') self.assertTrue(filesystem.exists('grading_policy.json')) @@ -478,7 +539,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): on_disk = loads(grading_policy.read()) self.assertEqual(on_disk, course.grading_policy) - #check for policy.json + # check for policy.json self.assertTrue(filesystem.exists('policy.json')) # compare what's on disk to what we have in the course module diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2a4ff46038..8c15b1ae95 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -54,6 +54,7 @@ class CourseDetailsTestCase(CourseTestCase): def test_virgin_fetch(self): details = CourseDetails.fetch(self.course_location) self.assertEqual(details.course_location, self.course_location, "Location not copied into") + self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end)) @@ -67,7 +68,6 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=") - # Note, start_date is being initialized someplace. I'm not sure why b/c the default will make no sense. self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") @@ -76,6 +76,23 @@ class CourseDetailsTestCase(CourseTestCase): self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized") self.assertIsNone(jsondetails['effort'], "effort somehow initialized") + def test_ooc_encoder(self): + """ + Test the encoder out of its original constrained purpose to see if it functions for general use + """ + details = {'location': Location(['tag', 'org', 'course', 'category', 'name']), + 'number': 1, + 'string': 'string', + 'datetime': datetime.datetime.now(UTC())} + jsondetails = json.dumps(details, cls=CourseSettingsEncoder) + jsondetails = json.loads(jsondetails) + + self.assertIn('location', jsondetails) + self.assertIn('org', jsondetails['location']) + self.assertEquals('org', jsondetails['location'][1]) + self.assertEquals(1, jsondetails['number']) + self.assertEqual(jsondetails['string'], 'string') + def test_update_and_fetch(self): # # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions jsondetails = CourseDetails.fetch(self.course_location) @@ -116,11 +133,8 @@ class CourseDetailsViewTest(CourseTestCase): self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val)) @staticmethod - def convert_datetime_to_iso(datetime): - if datetime is not None: - return datetime.isoformat("T") - else: - return None + def convert_datetime_to_iso(dt): + return Date().to_json(dt) def test_update_and_fetch(self): details = CourseDetails.fetch(self.course_location) @@ -151,22 +165,12 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") - @staticmethod - def struct_to_datetime(struct_time): - return datetime.datetime(*struct_time[:6], tzinfo=UTC()) - def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: date = Date() if field in encoded and encoded[field] is not None: - encoded_encoded = date.from_json(encoded[field]) - dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded) - - if isinstance(details[field], datetime.datetime): - dt2 = details[field] - else: - details_encoded = date.from_json(details[field]) - dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded) + dt1 = date.from_json(encoded[field]) + dt2 = details[field] expected_delta = datetime.timedelta(0) self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context) diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 0757992f2f..fec82db1bb 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -4,6 +4,7 @@ import mock import collections import copy from django.test import TestCase +from django.test.utils import override_settings from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -11,11 +12,52 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase class LMSLinksTestCase(TestCase): """ Tests for LMS links. """ def about_page_test(self): - """ Get URL for about page. """ + """ Get URL for about page, no marketing site """ + # default for ENABLE_MKTG_SITE is False. + self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) + def about_page_marketing_site_test(self): + """ Get URL for about page, marketing root present. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//dummy-root/courses/mitX/101/test/about") + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): + self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'http://www.dummy'}) + def about_page_marketing_site_remove_http_test(self): + """ Get URL for about page, marketing root present, remove http://. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'https://www.dummy'}) + def about_page_marketing_site_remove_https_test(self): + """ Get URL for about page, marketing root present, remove https://. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={'ROOT': 'www.dummyhttps://x'}) + def about_page_marketing_site_https__edge_test(self): + """ Get URL for about page, only remove https:// at the beginning of the string. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), "//www.dummyhttps://x/courses/mitX/101/test/about") + + @override_settings(MKTG_URLS={}) + def about_page_marketing_urls_not_set_test(self): + """ Error case. ENABLE_MKTG_SITE is True, but there is either no MKTG_URLS, or no MKTG_URLS Root property. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + self.assertEquals(self.get_about_page_link(), None) + + @override_settings(LMS_BASE=None) + def about_page_no_lms_base_test(self): + """ No LMS_BASE, nor is ENABLE_MKTG_SITE True """ + self.assertEquals(self.get_about_page_link(), None) + + def get_about_page_link(self): + """ create mock course and return the about page link """ location = 'i4x', 'mitX', '101', 'course', 'test' utils.get_course_id = mock.Mock(return_value="mitX/101/test") - link = utils.get_lms_link_for_about_page(location) - self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about") + return utils.get_lms_link_for_about_page(location) def lms_link_test(self): """ Tests get_lms_link_for_item. """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 35451cf7cc..6f766ff7f5 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,8 +4,11 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from django.core.urlresolvers import reverse import copy +import logging +import re +from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES -DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] +log = logging.getLogger(__name__) #In order to instantiate an open ended tab automatically, need to have this data OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"} @@ -107,9 +110,29 @@ def get_lms_link_for_about_page(location): """ Returns the url to the course about page from the location tuple. """ - if settings.LMS_BASE is not None: - lms_link = "//{lms_base}/courses/{course_id}/about".format( - lms_base=settings.LMS_BASE, + if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False): + if not hasattr(settings, 'MKTG_URLS'): + log.exception("ENABLE_MKTG_SITE is True, but MKTG_URLS is not defined.") + about_base = None + else: + marketing_urls = settings.MKTG_URLS + if marketing_urls.get('ROOT', None) is None: + log.exception('There is no ROOT defined in MKTG_URLS') + about_base = None + else: + # Root will be "https://www.edx.org". The complete URL will still not be exactly correct, + # but redirects exist from www.edx.org to get to the Drupal course about page URL. + about_base = marketing_urls.get('ROOT') + # Strip off https:// (or http://) to be consistent with the formatting of LMS_BASE. + about_base = re.sub(r"^https?://", "", about_base) + elif settings.LMS_BASE is not None: + about_base = settings.LMS_BASE + else: + about_base = None + + if about_base is not None: + lms_link = "//{about_base_url}/courses/{course_id}/about".format( + about_base_url=about_base, course_id=get_course_id(location) ) else: @@ -205,7 +228,7 @@ def add_extra_panel_tab(tab_type, course): course_tabs = copy.copy(course.tabs) changed = False #Check to see if open ended panel is defined in the course - + tab_panel = EXTRA_TAB_PANELS.get(tab_type) if tab_panel not in course_tabs: #Add panel to the tabs if it is not defined diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index b5041d3e9f..229788f24d 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -62,7 +62,7 @@ def asset_index(request, org, course, name): asset_id = asset['_id'] display_info = {} display_info['displayname'] = asset['displayname'] - display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple()) + display_info['uploadDate'] = get_default_time_display(asset['uploadDate']) asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name']) display_info['url'] = StaticContent.get_url_path_from_location(asset_location) @@ -103,6 +103,9 @@ def upload_asset(request, org, course, coursename): logging.error('Could not find course' + location) return HttpResponseBadRequest() + if 'file' not in request.FILES: + return HttpResponseBadRequest() + # compute a 'filename' which is similar to the location formatting, we're using the 'filename' # nomenclature since we're using a FileSystem paradigm here. We're just imposing # the Location string formatting expectations to keep things a bit more consistent @@ -131,7 +134,7 @@ def upload_asset(request, org, course, coursename): readback = contentstore().find(content.location) response_payload = {'displayname': content.name, - 'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()), + 'uploadDate': get_default_time_display(readback.last_modified_at), 'url': StaticContent.get_url_path_from_location(content.location), 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None, 'msg': 'Upload completed' @@ -227,11 +230,9 @@ def generate_export_course(request, org, course, name): root_dir = path(mkdtemp()) # export out to a tempdir - logging.debug('root = {0}'.format(root_dir)) export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore()) - #filename = root_dir / name + '.tar.gz' logging.debug('tar file being generated at {0}'.format(export_file.name)) tar_file = tarfile.open(name=export_file.name, mode='w:gz') diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 8120e08107..039deb2740 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -7,7 +7,7 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie from django.conf import settings - +from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from mitxmako.shortcuts import render_to_response from xmodule.modulestore import Location @@ -50,11 +50,18 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @login_required def edit_subsection(request, location): # check that we have permissions to edit this item - course = get_course_for_item(location) + try: + course = get_course_for_item(location) + except InvalidLocationError: + return HttpResponseBadRequest() + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location, depth=1) + try: + item = modulestore().get_item(location, depth=1) + except ItemNotFoundError: + return HttpResponseBadRequest() lms_link = get_lms_link_for_item(location, course_id=course.location.course_id) preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True) @@ -113,11 +120,18 @@ def edit_unit(request, location): id: A Location URL """ - course = get_course_for_item(location) + try: + course = get_course_for_item(location) + except InvalidLocationError: + return HttpResponseBadRequest() + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location, depth=1) + try: + item = modulestore().get_item(location, depth=1) + except ItemNotFoundError: + return HttpResponseBadRequest() lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 07f6b9669c..e1c176eebe 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -2,7 +2,6 @@ Views related to operations on course objects """ import json -import time from django.contrib.auth.decorators import login_required from django_future.csrf import ensure_csrf_cookie @@ -32,6 +31,8 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \ NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY from django_comment_common.utils import seed_permissions_roles +import datetime +from django.utils.timezone import UTC # TODO: should explicitly enumerate exports with __all__ @@ -130,7 +131,7 @@ def create_new_course(request): new_course.display_name = display_name # set a default start date to now - new_course.start = time.gmtime() + new_course.start = datetime.datetime.now(UTC()) initialize_course_tabs(new_course) @@ -357,49 +358,49 @@ def course_advanced_updates(request, org, course, name): # Whether or not to filter the tabs key out of the settings metadata filter_tabs = True - #Check to see if the user instantiated any advanced components. This is a hack - #that does the following : - # 1) adds/removes the open ended panel tab to a course automatically if the user + # Check to see if the user instantiated any advanced components. This is a hack + # that does the following : + # 1) adds/removes the open ended panel tab to a course automatically if the user # has indicated that they want to edit the combinedopendended or peergrading module # 2) adds/removes the notes panel tab to a course automatically if the user has # indicated that they want the notes module enabled in their course # TODO refactor the above into distinct advanced policy settings if ADVANCED_COMPONENT_POLICY_KEY in request_body: - #Get the course so that we can scrape current tabs + # Get the course so that we can scrape current tabs course_module = modulestore().get_item(location) - #Maps tab types to components + # Maps tab types to components tab_component_map = { - 'open_ended': OPEN_ENDED_COMPONENT_TYPES, + 'open_ended': OPEN_ENDED_COMPONENT_TYPES, 'notes': NOTE_COMPONENT_TYPES, } - #Check to see if the user instantiated any notes or open ended components + # Check to see if the user instantiated any notes or open ended components for tab_type in tab_component_map.keys(): component_types = tab_component_map.get(tab_type) found_ac_type = False for ac_type in component_types: if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: - #Add tab to the course if needed + # Add tab to the course if needed changed, new_tabs = add_extra_panel_tab(tab_type, course_module) - #If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json + # If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json if changed: course_module.tabs = new_tabs request_body.update({'tabs': new_tabs}) - #Indicate that tabs should not be filtered out of the metadata + # Indicate that tabs should not be filtered out of the metadata filter_tabs = False - #Set this flag to avoid the tab removal code below. + # Set this flag to avoid the tab removal code below. found_ac_type = True break - #If we did not find a module type in the advanced settings, + # If we did not find a module type in the advanced settings, # we may need to remove the tab from the course. if not found_ac_type: - #Remove tab from the course if needed + # Remove tab from the course if needed changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) if changed: course_module.tabs = new_tabs request_body.update({'tabs': new_tabs}) - #Indicate that tabs should *not* be filtered out of the metadata + # Indicate that tabs should *not* be filtered out of the metadata filter_tabs = False response_json = json.dumps(CourseMetadata.update_from_json(location, diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 0dbb47b31b..07eb4bc309 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -3,26 +3,26 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder -import time from contentstore.utils import get_modulestore from models.settings import course_grading from contentstore.utils import update_item from xmodule.fields import Date import re import logging +import datetime class CourseDetails(object): def __init__(self, location): - self.course_location = location # a Location obj + self.course_location = location # a Location obj self.start_date = None # 'start' - self.end_date = None # 'end' + self.end_date = None # 'end' self.enrollment_start = None self.enrollment_end = None - self.syllabus = None # a pdf file asset - self.overview = "" # html to render as the overview - self.intro_video = None # a video pointer - self.effort = None # int hours/week + self.syllabus = None # a pdf file asset + self.overview = "" # html to render as the overview + self.intro_video = None # a video pointer + self.effort = None # int hours/week @classmethod def fetch(cls, course_location): @@ -73,9 +73,9 @@ class CourseDetails(object): """ Decode the json into CourseDetails and save any changed attrs to the db """ - ## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore + # TODO make it an error for this to be undefined & for it to not be retrievable from modulestore course_location = jsondict['course_location'] - ## Will probably want to cache the inflight courses because every blur generates an update + # Will probably want to cache the inflight courses because every blur generates an update descriptor = get_modulestore(course_location).get_item(course_location) dirty = False @@ -181,7 +181,7 @@ class CourseSettingsEncoder(json.JSONEncoder): return obj.__dict__ elif isinstance(obj, Location): return obj.dict() - elif isinstance(obj, time.struct_time): + elif isinstance(obj, datetime.datetime): return Date().to_json(obj) else: return JSONEncoder.default(self, obj) diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 36616ab257..6293219f43 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -23,7 +23,7 @@ MODULESTORE_OPTIONS = { 'db': 'test_xmodule', 'collection': 'acceptance_modulestore', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'mitxmako.shortcuts.render_to_string' } MODULESTORE = { diff --git a/cms/envs/aws.py b/cms/envs/aws.py index f6064229e6..35b15fe6ba 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -91,11 +91,19 @@ CACHES = ENV_TOKENS['CACHES'] SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') +# allow for environments to specify what cookie name our login subsystem should use +# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can +# happen with some browsers (e.g. Firefox) +if ENV_TOKENS.get('SESSION_COOKIE_NAME', None): + # NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str() + SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME')) + #Email overrides DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL) ADMINS = ENV_TOKENS.get('ADMINS', ADMINS) SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) +MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) #Timezone overrides TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) diff --git a/cms/envs/common.py b/cms/envs/common.py index ed90572715..5962cec340 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -25,6 +25,7 @@ Longer TODO: import sys import lms.envs.common +from lms.envs.common import USE_TZ from path import path ############################ FEATURE CONFIGURATION ############################# @@ -34,8 +35,8 @@ MITX_FEATURES = { 'GITHUB_PUSH': False, 'ENABLE_DISCUSSION_SERVICE': False, 'AUTH_USE_MIT_CERTIFICATES': False, - 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests - 'STAFF_EMAIL': '', # email address for staff (eg to request course creation) + 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests + 'STAFF_EMAIL': '', # email address for staff (eg to request course creation) 'STUDIO_NPS_SURVEY': True, 'SEGMENT_IO': True, @@ -183,7 +184,7 @@ STATICFILES_DIRS = [ # Locale/Internationalization TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html USE_I18N = True USE_L10N = True @@ -323,6 +324,7 @@ INSTALLED_APPS = ( 'track', # For asset pipelining + 'mitxmako', 'pipeline', 'staticfiles', 'static_replace', @@ -334,3 +336,14 @@ INSTALLED_APPS = ( ################# EDX MARKETING SITE ################################## EDXMKTG_COOKIE_NAME = 'edxloggedin' +MKTG_URLS = {} +MKTG_URL_LINK_MAP = { + 'ABOUT': 'about_edx', + 'CONTACT': 'contact', + 'FAQ': 'help_edx', + 'COURSES': 'courses', + 'ROOT': 'root', + 'TOS': 'tos', + 'HONOR': 'honor', + 'PRIVACY': 'privacy_edx', +} diff --git a/cms/envs/dev.py b/cms/envs/dev.py index e63968d338..cbe47a1fe1 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -22,7 +22,7 @@ modulestore_options = { 'db': 'xmodule', 'collection': 'modulestore', 'fs_root': GITHUB_REPO_ROOT, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'mitxmako.shortcuts.render_to_string' } MODULESTORE = { @@ -64,7 +64,7 @@ REPOS = { }, 'content-mit-6002x': { 'branch': 'master', - #'origin': 'git@github.com:MITx/6002x-fall-2012.git', + # 'origin': 'git@github.com:MITx/6002x-fall-2012.git', 'origin': 'git@github.com:MITx/content-mit-6002x.git', }, '6.00x': { @@ -165,3 +165,11 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True # segment-io key for dev SEGMENT_IO_KEY = 'mty8edrrsg' + + +##################################################################### +# Lastly, see if the developer has any local overrides. +try: + from .private import * +except ImportError: + pass diff --git a/cms/envs/test.py b/cms/envs/test.py index 8a3f9ba158..1569d0a42d 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -48,7 +48,7 @@ MODULESTORE_OPTIONS = { 'db': 'test_xmodule', 'collection': 'test_modulestore', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'mitxmako.shortcuts.render_to_string' } MODULESTORE = { @@ -121,7 +121,7 @@ CELERY_RESULT_BACKEND = 'cache' BROKER_TRANSPORT = 'memory' ################### Make tests faster -#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/ +# http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/ PASSWORD_HASHERS = ( 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', diff --git a/cms/pydev_manage.py b/cms/pydev_manage.py new file mode 100644 index 0000000000..22c38d89eb --- /dev/null +++ b/cms/pydev_manage.py @@ -0,0 +1,11 @@ +''' +Used for pydev eclipse. Should be innocuous for everyone else. +Created on May 8, 2013 + +@author: dmitchell +''' +#!/home//mitx_all/python/bin/python +from django.core import management + +if __name__ == '__main__': + management.execute_from_command_line() diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index e23477ccfa..058bcf0ce1 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -1,6 +1,7 @@ class CMS.Views.UnitEdit extends Backbone.View events: - 'click .new-component .new-component-type a': 'showComponentTemplates' + 'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates' + 'click .new-component .new-component-type a.single-template': 'saveNewComponent' 'click .new-component .cancel-button': 'closeNewComponent' 'click .new-component-templates .new-component-template a': 'saveNewComponent' 'click .new-component-templates .cancel-button': 'closeNewComponent' diff --git a/cms/static/img/large-advanced-icon.png b/cms/static/img/large-advanced-icon.png index c6a19ea5a9..97442f5aa0 100644 Binary files a/cms/static/img/large-advanced-icon.png and b/cms/static/img/large-advanced-icon.png differ diff --git a/cms/static/js/base.js b/cms/static/js/base.js index fe60d80239..c626fa1b3f 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -411,8 +411,12 @@ function showFileSelectionMenu(e) { } function startUpload(e) { + var files = $('.file-input').get(0).files; + if (files.length === 0) + return; + $('.upload-modal h1').html(gettext('Uploading…')); - $('.upload-modal .file-name').html($('.file-input').val().replace('C:\\fakepath\\', '')); + $('.upload-modal .file-name').html(files[0].name); $('.upload-modal .file-chooser').ajaxSubmit({ beforeSend: resetUploadBar, uploadProgress: showUploadFeedback, diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index e0e7543b8e..3713f83ae3 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -14,7 +14,7 @@ body { color: $gray-d2; } -body, input { +body, input, button { font-family: 'Open Sans', sans-serif; } diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index 9bb9b3a506..cbce91ab44 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! import logging - from xmodule.util.date_utils import get_time_struct_display + from xmodule.util.date_utils import get_default_time_display %> <%! from django.core.urlresolvers import reverse %> @@ -36,11 +36,15 @@
- +
- +
% if subsection.lms.start != parent_item.lms.start and subsection.lms.start: @@ -48,7 +52,7 @@

The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset. % else:

The date above differs from the release date of ${parent_item.display_name_with_default} – - ${get_time_struct_display(parent_item.lms.start, '%m/%d/%Y at %H:%M UTC')}. + ${get_default_time_display(parent_item.lms.start)}. % endif Sync to ${parent_item.display_name_with_default}.

% endif @@ -65,11 +69,15 @@
- +
- +
Remove due date
diff --git a/cms/templates/overview.html b/cms/templates/overview.html index d327c8b324..43d0afc263 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! import logging - from xmodule.util.date_utils import get_time_struct_display + from xmodule.util import date_utils %> <%! from django.core.urlresolvers import reverse %> <%block name="title">Course Outline @@ -154,14 +154,19 @@

diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 851e3da260..b9a83265d9 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -53,9 +53,15 @@
Add New Component
% for type, templates in sorted(component_templates.items()): + % if len(templates) > 1 or type == 'advanced':
% if type == "problem": -
- - % endif -
-
    - % for name, location, has_markdown in templates: - % if has_markdown or type != "problem": -
  • - - ${name} - -
  • - % endif - - %endfor -
-
- % if type == "problem": -
-
- % endif - Cancel -
+ % endif +
+
    + % for name, location, has_markdown in templates: + % if has_markdown or type != "problem": +
  • + + ${name} + +
  • + % endif + + %endfor +
+
+ % if type == "problem": +
+
    + % for name, location, has_markdown in templates: + % if not has_markdown: +
  • + + ${name} + +
  • + % endif + % endfor +
+
+
+ % endif + Cancel + + % endif % endfor diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py index 8e9e70046d..7deb0901aa 100644 --- a/common/djangoapps/contentserver/middleware.py +++ b/common/djangoapps/contentserver/middleware.py @@ -1,7 +1,4 @@ -import logging -import time - -from django.http import HttpResponse, Http404, HttpResponseNotModified +from django.http import HttpResponse, HttpResponseNotModified from xmodule.contentstore.django import contentstore from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG @@ -20,7 +17,7 @@ class StaticContentServer(object): # return a 'Bad Request' to browser as we have a malformed Location response = HttpResponse() response.status_code = 400 - return response + return response # first look in our cache so we don't have to round-trip to the DB content = get_cached_content(loc) diff --git a/common/djangoapps/mitxmako/makoloader.py b/common/djangoapps/mitxmako/makoloader.py index d623e8bcff..6b6b31d464 100644 --- a/common/djangoapps/mitxmako/makoloader.py +++ b/common/djangoapps/mitxmako/makoloader.py @@ -44,7 +44,11 @@ class MakoLoader(object): if source.startswith("## mako\n"): # This is a mako template - template = Template(filename=file_path, module_directory=self.module_directory, uri=template_name) + template = Template(filename=file_path, + module_directory=self.module_directory, + input_encoding='utf-8', + output_encoding='utf-8', + uri=template_name) return template, None else: # This is a regular template diff --git a/common/djangoapps/mitxmako/management/__init__.py b/common/djangoapps/mitxmako/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/mitxmako/management/commands/__init__.py b/common/djangoapps/mitxmako/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/mitxmako/management/commands/preprocess_assets.py b/common/djangoapps/mitxmako/management/commands/preprocess_assets.py new file mode 100644 index 0000000000..6afcbe896e --- /dev/null +++ b/common/djangoapps/mitxmako/management/commands/preprocess_assets.py @@ -0,0 +1,73 @@ +""" +Preprocess templatized asset files, enabling asset authors to use +Python/Django inside of Sass and CoffeeScript. This preprocessing +will happen before the invocation of the asset compiler (currently +handled by the asset Rakefile). + +For this to work, assets need to be named with the appropriate +template extension (e.g., .mako for Mako templates). Currently Mako +is the only template engine supported. +""" +import os + +from django.core.management.base import NoArgsCommand +from django.conf import settings + +from mako.template import Template +import textwrap + +class Command(NoArgsCommand): + """ + Basic management command to preprocess asset template files. + """ + + help = "Preprocess asset template files to ready them for compilation." + + def handle_noargs(self, **options): + """ + Walk over all of the static files directories specified in the + settings file, looking for asset template files (indicated by + a file extension like .mako). + """ + for staticfiles_dir in getattr(settings, "STATICFILES_DIRS", []): + # Cribbed from the django-staticfiles app at: + # https://github.com/jezdez/django-staticfiles/blob/develop/staticfiles/finders.py#L52 + if isinstance(staticfiles_dir, (list, tuple)): + prefix, staticfiles_dir = staticfiles_dir + + # Walk over the current static files directory tree, + # preprocessing files that have a template extension. + for root, dirs, files in os.walk(staticfiles_dir): + for filename in files: + outfile, extension = os.path.splitext(filename) + # We currently only handle Mako templates + if extension == ".mako": + self.__preprocess(os.path.join(root, filename), + os.path.join(root, outfile)) + + + def __context(self): + """ + Return a dict that contains all of the available context + variables to the asset template. + """ + # TODO: do we need to include anything else? + # TODO: do this with the django-settings-context-processor + return { "THEME_NAME" : getattr(settings, "THEME_NAME", None) } + + + def __preprocess(self, infile, outfile): + """ + Run `infile` through the Mako template engine, storing the + result in `outfile`. + """ + with open(outfile, "w") as _outfile: + _outfile.write(textwrap.dedent("""\ + /* + * This file is dynamically generated and ignored by Git. + * DO NOT MAKE CHANGES HERE. Instead, go edit its template: + * %s + */ + """ % infile)) + _outfile.write(Template(filename=str(infile)).render(env=self.__context())) + diff --git a/common/djangoapps/mitxmako/shortcuts.py b/common/djangoapps/mitxmako/shortcuts.py index 0766564027..7f7c3f9ebe 100644 --- a/common/djangoapps/mitxmako/shortcuts.py +++ b/common/djangoapps/mitxmako/shortcuts.py @@ -41,7 +41,9 @@ def marketing_link(name): return settings.MKTG_URLS.get('ROOT') + settings.MKTG_URLS.get(name) # only link to the old pages when the marketing site isn't on elif not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE') and name in link_map: - return reverse(link_map[name]) + # don't try to reverse disabled marketing links + if link_map[name] is not None: + return reverse(link_map[name]) else: log.warning("Cannot find corresponding link for name: {name}".format(name=name)) return '#' diff --git a/common/djangoapps/mitxmako/tests.py b/common/djangoapps/mitxmako/tests.py index 21866eb9b5..e7e56a9472 100644 --- a/common/djangoapps/mitxmako/tests.py +++ b/common/djangoapps/mitxmako/tests.py @@ -1,16 +1,15 @@ from django.test import TestCase from django.test.utils import override_settings from django.core.urlresolvers import reverse -from django.conf import settings from mitxmako.shortcuts import marketing_link from mock import patch +from util.testing import UrlResetMixin -class ShortcutsTests(TestCase): +class ShortcutsTests(UrlResetMixin, TestCase): """ Test the mitxmako shortcuts file """ - @override_settings(MKTG_URLS={'ROOT': 'dummy-root', 'ABOUT': '/about-us'}) @override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'}) def test_marketing_link(self): diff --git a/common/djangoapps/student/management/commands/assigngroups.py b/common/djangoapps/student/management/commands/assigngroups.py index fb7bfc85cd..5269c8690e 100644 --- a/common/djangoapps/student/management/commands/assigngroups.py +++ b/common/djangoapps/student/management/commands/assigngroups.py @@ -14,6 +14,7 @@ import sys import datetime import json +from pytz import UTC middleware.MakoMiddleware() @@ -32,7 +33,7 @@ def group_from_value(groups, v): class Command(BaseCommand): - help = \ + help = \ ''' Assign users to test groups. Takes a list of groups: a:0.3,b:0.4,c:0.3 file.txt "Testing something" @@ -75,7 +76,7 @@ Will log what happened to file.txt. utg = UserTestGroup() utg.name = group utg.description = json.dumps({"description": args[2]}, - {"time": datetime.datetime.utcnow().isoformat()}) + {"time": datetime.datetime.now(UTC).isoformat()}) group_objects[group] = utg group_objects[group].save() diff --git a/common/djangoapps/student/management/commands/pearson_export_cdd.py b/common/djangoapps/student/management/commands/pearson_export_cdd.py index bad98b9d25..efb4a55387 100644 --- a/common/djangoapps/student/management/commands/pearson_export_cdd.py +++ b/common/djangoapps/student/management/commands/pearson_export_cdd.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from student.models import TestCenterUser +from pytz import UTC class Command(BaseCommand): @@ -58,7 +59,7 @@ class Command(BaseCommand): def handle(self, **options): # update time should use UTC in order to be comparable to the user_updated_at # field - uploaded_at = datetime.utcnow() + uploaded_at = datetime.now(UTC) # if specified destination is an existing directory, then # create a filename for it automatically. If it doesn't exist, @@ -100,7 +101,7 @@ class Command(BaseCommand): extrasaction='ignore') writer.writeheader() for tcu in TestCenterUser.objects.order_by('id'): - if tcu.needs_uploading: # or dump_all + if tcu.needs_uploading: # or dump_all record = dict((csv_field, ensure_encoding(getattr(tcu, model_field))) for csv_field, model_field in Command.CSV_TO_MODEL_FIELDS.items()) diff --git a/common/djangoapps/student/management/commands/pearson_export_ead.py b/common/djangoapps/student/management/commands/pearson_export_ead.py index 03dbce0024..ec10ab1599 100644 --- a/common/djangoapps/student/management/commands/pearson_export_ead.py +++ b/common/djangoapps/student/management/commands/pearson_export_ead.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from student.models import TestCenterRegistration, ACCOMMODATION_REJECTED_CODE +from pytz import UTC class Command(BaseCommand): @@ -51,7 +52,7 @@ class Command(BaseCommand): def handle(self, **options): # update time should use UTC in order to be comparable to the user_updated_at # field - uploaded_at = datetime.utcnow() + uploaded_at = datetime.now(UTC) # if specified destination is an existing directory, then # create a filename for it automatically. If it doesn't exist, diff --git a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py b/common/djangoapps/student/management/commands/pearson_import_conf_zip.py index d0b2938df0..2339383719 100644 --- a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py +++ b/common/djangoapps/student/management/commands/pearson_import_conf_zip.py @@ -13,6 +13,7 @@ from django.core.management.base import BaseCommand, CommandError from django.conf import settings from student.models import TestCenterUser, TestCenterRegistration +from pytz import UTC class Command(BaseCommand): @@ -68,7 +69,7 @@ class Command(BaseCommand): Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name) # now update the record: registration.upload_status = row['Status'] - registration.upload_error_message = row['Message'] + registration.upload_error_message = row['Message'] try: registration.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S')) except ValueError as ve: @@ -80,7 +81,7 @@ class Command(BaseCommand): except ValueError as ve: Command.datadog_error("Bad AuthorizationID value found for {}: message {}".format(client_authorization_id, ve), eacfile.name) - registration.confirmed_at = datetime.utcnow() + registration.confirmed_at = datetime.now(UTC) registration.save() except TestCenterRegistration.DoesNotExist: Command.datadog_error("Failed to find record for client_auth_id {}".format(client_authorization_id), eacfile.name) diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py index b10cf143a0..50e56bb4be 100644 --- a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py +++ b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py @@ -1,5 +1,4 @@ from optparse import make_option -from time import strftime from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError @@ -128,8 +127,8 @@ class Command(BaseCommand): exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info) # update option values for date_first and date_last to use YYYY-MM-DD format # instead of YYYY-MM-DDTHH:MM - our_options['eligibility_appointment_date_first'] = strftime("%Y-%m-%d", exam.first_eligible_appointment_date) - our_options['eligibility_appointment_date_last'] = strftime("%Y-%m-%d", exam.last_eligible_appointment_date) + our_options['eligibility_appointment_date_first'] = exam.first_eligible_appointment_date.strftime("%Y-%m-%d") + our_options['eligibility_appointment_date_last'] = exam.last_eligible_appointment_date.strftime("%Y-%m-%d") if exam is None: raise CommandError("Exam for course_id {} does not exist".format(course_id)) diff --git a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py new file mode 100644 index 0000000000..8ce1d0cda1 --- /dev/null +++ b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'CourseEnrollmentAllowed.auto_enroll' + db.add_column('student_courseenrollmentallowed', 'auto_enroll', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'CourseEnrollmentAllowed.auto_enroll' + db.delete_column('student_courseenrollmentallowed', 'auto_enroll') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 56b1293c2d..af93c34317 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -16,7 +16,6 @@ import json import logging import uuid from random import randint -from time import strftime from django.conf import settings @@ -27,6 +26,7 @@ from django.dispatch import receiver from django.forms import ModelForm, forms import comment_client as cc +from pytz import UTC log = logging.getLogger(__name__) @@ -54,7 +54,7 @@ class UserProfile(models.Model): class Meta: db_table = "auth_userprofile" - ## CRITICAL TODO/SECURITY + # CRITICAL TODO/SECURITY # Sanitize all fields. # This is not visible to other users, but could introduce holes later user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile') @@ -254,7 +254,7 @@ class TestCenterUserForm(ModelForm): def update_and_save(self): new_user = self.save(commit=False) # create additional values here: - new_user.user_updated_at = datetime.utcnow() + new_user.user_updated_at = datetime.now(UTC) new_user.upload_status = '' new_user.save() log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.user.username)) @@ -429,8 +429,8 @@ class TestCenterRegistration(models.Model): registration.course_id = exam.course_id registration.accommodation_request = accommodation_request.strip() registration.exam_series_code = exam.exam_series_code - registration.eligibility_appointment_date_first = strftime("%Y-%m-%d", exam.first_eligible_appointment_date) - registration.eligibility_appointment_date_last = strftime("%Y-%m-%d", exam.last_eligible_appointment_date) + registration.eligibility_appointment_date_first = exam.first_eligible_appointment_date.strftime("%Y-%m-%d") + registration.eligibility_appointment_date_last = exam.last_eligible_appointment_date.strftime("%Y-%m-%d") registration.client_authorization_id = cls._create_client_authorization_id() # accommodation_code remains blank for now, along with Pearson confirmation information return registration @@ -556,7 +556,7 @@ class TestCenterRegistrationForm(ModelForm): def update_and_save(self): registration = self.save(commit=False) # create additional values here: - registration.user_updated_at = datetime.utcnow() + registration.user_updated_at = datetime.now(UTC) registration.upload_status = '' registration.save() log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code)) @@ -598,7 +598,7 @@ def unique_id_for_user(user): return h.hexdigest() -## TODO: Should be renamed to generic UserGroup, and possibly +# TODO: Should be renamed to generic UserGroup, and possibly # Given an optional field for type of group class UserTestGroup(models.Model): users = models.ManyToManyField(User, db_index=True) @@ -626,7 +626,6 @@ class Registration(models.Model): def activate(self): self.user.is_active = True self.user.save() - #self.delete() class PendingNameChange(models.Model): @@ -648,7 +647,7 @@ class CourseEnrollment(models.Model): created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) class Meta: - unique_together = (('user', 'course_id'), ) + unique_together = (('user', 'course_id'),) def __unicode__(self): return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created) @@ -662,16 +661,17 @@ class CourseEnrollmentAllowed(models.Model): """ email = models.CharField(max_length=255, db_index=True) course_id = models.CharField(max_length=255, db_index=True) + auto_enroll = models.BooleanField(default=0) created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) class Meta: - unique_together = (('email', 'course_id'), ) + unique_together = (('email', 'course_id'),) def __unicode__(self): return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created) -#cache_relation(User.profile) +# cache_relation(User.profile) #### Helper methods for use from python manage.py shell and other classes. diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index d73bb6f01d..49864fcbd4 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import Group from datetime import datetime from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence from uuid import uuid4 +from pytz import UTC # Factories don't have __init__ methods, and are self documenting # pylint: disable=W0232 @@ -46,8 +47,8 @@ class UserFactory(DjangoModelFactory): is_staff = False is_active = True is_superuser = False - last_login = datetime(2012, 1, 1) - date_joined = datetime(2011, 1, 1) + last_login = datetime(2012, 1, 1, tzinfo=UTC) + date_joined = datetime(2011, 1, 1, tzinfo=UTC) @post_generation def profile(obj, create, extracted, **kwargs): diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 463ad33316..f129f1b4b1 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -32,7 +32,7 @@ from student.models import (Registration, UserProfile, TestCenterUser, TestCente TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, - get_testcenter_registration) + get_testcenter_registration, CourseEnrollmentAllowed) from certificates.models import CertificateStatuses, certificate_status_for_student @@ -49,6 +49,7 @@ from courseware.views import get_module_for_descriptor, jump_to from courseware.model_data import ModelDataCache from statsd import statsd +from pytz import UTC log = logging.getLogger("mitx.student") Article = namedtuple('Article', 'title url author image deck publication publish_date') @@ -77,7 +78,7 @@ def index(request, extra_context={}, user=None): ''' # 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 if domain == False: domain = request.META.get('HTTP_HOST') @@ -264,7 +265,6 @@ def dashboard(request): if not user.is_active: message = render_to_string('registration/activate_account_notice.html', {'email': user.email}) - # Global staff can see what courses errored on their dashboard staff_access = False errored_courses = {} @@ -355,7 +355,7 @@ def change_enrollment(request): course = course_from_id(course_id) except ItemNotFoundError: log.warning("User {0} tried to enroll in non-existent course {1}" - .format(user.username, course_id)) + .format(user.username, course_id)) return HttpResponseBadRequest("Course id is invalid") if not has_access(user, course, 'enroll'): @@ -363,9 +363,9 @@ def change_enrollment(request): org, course_num, run = course_id.split("/") statsd.increment("common.student.enrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)]) + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) try: enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) @@ -382,9 +382,9 @@ def change_enrollment(request): org, course_num, run = course_id.split("/") statsd.increment("common.student.unenrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)]) + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) return HttpResponse() except CourseEnrollment.DoesNotExist: @@ -454,7 +454,6 @@ def login_user(request, error=""): expires_time = time.time() + max_age expires = cookie_date(expires_time) - response.set_cookie(settings.EDXMKTG_COOKIE_NAME, 'true', max_age=max_age, expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, @@ -515,8 +514,8 @@ def _do_create_account(post_vars): Note: this function is also used for creating test users. """ user = User(username=post_vars['username'], - email=post_vars['email'], - is_active=False) + email=post_vars['email'], + is_active=False) user.set_password(post_vars['password']) registration = Registration() # TODO: Rearrange so that if part of the process fails, the whole process fails. @@ -632,7 +631,7 @@ def create_account(request, post_override=None): # Ok, looks like everything is legit. Create the account. ret = _do_create_account(post_vars) - if isinstance(ret, HttpResponse): # if there was an error then return that + if isinstance(ret, HttpResponse): # if there was an error then return that return ret (user, profile, registration) = ret @@ -670,7 +669,7 @@ def create_account(request, post_override=None): if DoExternalAuth: eamap.user = login_user - eamap.dtsignup = datetime.datetime.now() + eamap.dtsignup = datetime.datetime.now(UTC) eamap.save() log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap)) @@ -698,7 +697,6 @@ def create_account(request, post_override=None): expires_time = time.time() + max_age expires = cookie_date(expires_time) - response.set_cookie(settings.EDXMKTG_COOKIE_NAME, 'true', max_age=max_age, expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, @@ -708,7 +706,6 @@ def create_account(request, post_override=None): return response - def exam_registration_info(user, course): """ Returns a Registration object if the user is currently registered for a current exam of the course. Returns None if the user is not registered, or if there is no @@ -849,7 +846,6 @@ def create_exam_registration(request, post_override=None): response_data['non_field_errors'] = form.non_field_errors() return HttpResponse(json.dumps(response_data), mimetype="application/json") - # only do the following if there is accommodation text to send, # and a destination to which to send it. # TODO: still need to create the accommodation email templates @@ -872,7 +868,6 @@ def create_exam_registration(request, post_override=None): # response_data['non_field_errors'] = [ 'Could not send accommodation e-mail.', ] # return HttpResponse(json.dumps(response_data), mimetype="application/json") - js = {'success': True} return HttpResponse(json.dumps(js), mimetype="application/json") @@ -916,6 +911,16 @@ def activate_account(request, key): if not r[0].user.is_active: r[0].activate() already_active = False + + #Enroll student in any pending courses he/she may have if auto_enroll flag is set + student = User.objects.filter(id=r[0].user_id) + if student: + ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email) + for cea in ceas: + if cea.auto_enroll: + course_id = cea.course_id + enrollment, created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id) + resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active}) return resp if len(r) == 0: @@ -1194,6 +1199,10 @@ def accept_name_change(request): def _get_news(top=None): "Return the n top news items on settings.RSS_URL" + # Don't return anything if we're in a themed site + if settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + return None + feed_data = cache.get("students_index_rss_feed_data") if feed_data is None: if hasattr(settings, 'RSS_URL'): diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py index a932863322..d2a9480b35 100644 --- a/common/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -87,8 +87,8 @@ def reset_data(scenario): LOGGER.debug("Flushing the test database...") call_command('flush', interactive=False) - -@after.each_scenario +# Uncomment below to trigger a screenshot on error +# @after.each_scenario def screenshot_on_error(scenario): """ Save a screenshot to help with debugging. diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index d2be2eeea8..6e512982b7 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -129,9 +129,12 @@ def should_have_link_with_id_and_text(step, link_id, text): assert_equals(link.text, text) -@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') -def should_see_in_the_page(step, text): - assert_in(text, world.css_text('body')) +@step(r'should( not)? see "(.*)" (?:somewhere|anywhere) (?:in|on) (?:the|this) page') +def should_see_in_the_page(step, doesnt_appear, text): + if doesnt_appear: + assert world.browser.is_text_not_present(text, wait_time=5) + else: + assert world.browser.is_text_present(text, wait_time=5) @step('I am logged in$') @@ -156,3 +159,33 @@ def registered_edx_user(step, uname): @step(u'All dialogs should be closed$') def dialogs_are_closed(step): assert world.dialogs_closed() + + +@step('I will confirm all alerts') +def i_confirm_all_alerts(step): + """ + Please note: This method must be called RIGHT BEFORE an expected alert + Window variables are page local and thus all changes are removed upon navigating to a new page + In addition, this method changes the functionality of ONLY future alerts + """ + world.browser.execute_script('window.confirm = function(){return true;} ; window.alert = function(){return;}') + + +@step('I will cancel all alerts') +def i_cancel_all_alerts(step): + """ + Please note: This method must be called RIGHT BEFORE an expected alert + Window variables are page local and thus all changes are removed upon navigating to a new page + In addition, this method changes the functionality of ONLY future alerts + """ + world.browser.execute_script('window.confirm = function(){return false;} ; window.alert = function(){return;}') + + +@step('I will answer all prompts with "([^"]*)"') +def i_answer_prompts_with(step, prompt): + """ + Please note: This method must be called RIGHT BEFORE an expected alert + Window variables are page local and thus all changes are removed upon navigating to a new page + In addition, this method changes the functionality of ONLY future alerts + """ + world.browser.execute_script('window.prompt = function(){return %s;}') % prompt diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 45252dbb7b..ecd43eb719 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -32,8 +32,13 @@ def url_equals(url): @world.absorb -def is_css_present(css_selector): - return world.browser.is_element_present_by_css(css_selector, wait_time=4) +def is_css_present(css_selector, wait_time=5): + return world.browser.is_element_present_by_css(css_selector, wait_time=wait_time) + + +@world.absorb +def is_css_not_present(css_selector, wait_time=5): + return world.browser.is_element_not_present_by_css(css_selector, wait_time=wait_time) @world.absorb @@ -42,11 +47,11 @@ def css_has_text(css_selector, text): @world.absorb -def css_find(css): +def css_find(css, wait_time=5): def is_visible(driver): return EC.visibility_of_element_located((By.CSS_SELECTOR, css,)) - world.browser.is_element_present_by_css(css, 5) + world.browser.is_element_present_by_css(css, wait_time=wait_time) wait_for(is_visible) return world.browser.find_by_css(css) @@ -56,6 +61,7 @@ def css_click(css_selector): """ Perform a click on a CSS selector, retrying if it initially fails """ + assert is_css_present(css_selector) try: world.browser.find_by_css(css_selector).click() @@ -89,6 +95,7 @@ def id_click(elem_id): @world.absorb def css_fill(css_selector, text): + assert is_css_present(css_selector) world.browser.find_by_css(css_selector).first.fill(text) @@ -114,6 +121,7 @@ def css_text(css_selector): @world.absorb def css_visible(css_selector): + assert is_css_present(css_selector) return world.browser.find_by_css(css_selector).visible diff --git a/common/djangoapps/tests.py b/common/djangoapps/tests.py new file mode 100644 index 0000000000..8e78ee7f37 --- /dev/null +++ b/common/djangoapps/tests.py @@ -0,0 +1,49 @@ +''' +Created on Jun 6, 2013 + +@author: dmitchell +''' +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from student.tests.factories import AdminFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +import xmodule_modifiers +import datetime +from pytz import UTC +from xmodule.modulestore.tests import factories + +class TestXmoduleModfiers(ModuleStoreTestCase): + + # FIXME disabled b/c start date inheritance is not occuring and render_... in get_html is failing due + # to middleware.lookup['main'] not being defined + def _test_add_histogram(self): + instructor = AdminFactory.create() + self.client.login(username=instructor.username, password='test') + + course = CourseFactory.create(org='test', + number='313', display_name='histogram test') + section = ItemFactory.create( + parent_location=course.location, display_name='chapter hist', + template='i4x://edx/templates/chapter/Empty') + problem = ItemFactory.create( + parent_location=section.location, display_name='problem hist 1', + template='i4x://edx/templates/problem/Blank_Common_Problem') + problem.has_score = False # don't trip trying to retrieve db data + + late_problem = ItemFactory.create( + parent_location=section.location, display_name='problem hist 2', + template='i4x://edx/templates/problem/Blank_Common_Problem') + late_problem.lms.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32) + late_problem.has_score = False + + + problem_module = factories.get_test_xmodule_for_descriptor(problem) + problem_module.get_html = xmodule_modifiers.add_histogram(lambda:'', problem_module, instructor) + + self.assertRegexpMatches( + problem_module.get_html(), r'.*Not yet.*') + + problem_module = factories.get_test_xmodule_for_descriptor(late_problem) + problem_module.get_html = xmodule_modifiers.add_histogram(lambda: '', problem_module, instructor) + + self.assertRegexpMatches( + problem_module.get_html(), r'.*Yes!.*') diff --git a/common/djangoapps/track/views.py b/common/djangoapps/track/views.py index ae3a1dcb3e..b2935a6a89 100644 --- a/common/djangoapps/track/views.py +++ b/common/djangoapps/track/views.py @@ -14,6 +14,7 @@ from mitxmako.shortcuts import render_to_response from django_future.csrf import ensure_csrf_cookie from track.models import TrackingLog +from pytz import UTC log = logging.getLogger("tracking") @@ -59,7 +60,7 @@ def user_track(request): "event": request.GET['event'], "agent": agent, "page": request.GET['page'], - "time": datetime.datetime.utcnow().isoformat(), + "time": datetime.datetime.now(UTC).isoformat(), "host": request.META['SERVER_NAME'], } log_event(event) @@ -85,11 +86,11 @@ def server_track(request, event_type, event, page=None): "event": event, "agent": agent, "page": page, - "time": datetime.datetime.utcnow().isoformat(), + "time": datetime.datetime.now(UTC).isoformat(), "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: # don't log return log_event(event) diff --git a/common/djangoapps/util/testing.py b/common/djangoapps/util/testing.py new file mode 100644 index 0000000000..d33f1c8f8b --- /dev/null +++ b/common/djangoapps/util/testing.py @@ -0,0 +1,34 @@ +import sys + +from django.conf import settings +from django.core.urlresolvers import clear_url_caches + + +class UrlResetMixin(object): + """Mixin to reset urls.py before and after a test + + Django memoizes the function that reads the urls module (whatever module + urlconf names). The module itself is also stored by python in sys.modules. + To fully reload it, we need to reload the python module, and also clear django's + cache of the parsed urls. + + However, the order in which we do this doesn't matter, because neither one will + get reloaded until the next request + + Doing this is expensive, so it should only be added to tests that modify settings + that affect the contents of urls.py + """ + + def _reset_urls(self, urlconf=None): + if urlconf is None: + urlconf = settings.ROOT_URLCONF + + if urlconf in sys.modules: + reload(sys.modules[urlconf]) + clear_url_caches() + + def setUp(self): + """Reset django default urlconf before tests and after tests""" + super(UrlResetMixin, self).setUp() + self._reset_urls() + self.addCleanup(self._reset_urls) diff --git a/common/djangoapps/util/tests/test_zendesk.py b/common/djangoapps/util/tests/test_submit_feedback.py similarity index 69% rename from common/djangoapps/util/tests/test_zendesk.py rename to common/djangoapps/util/tests/test_submit_feedback.py index 51d06a92ed..b66d3d642b 100644 --- a/common/djangoapps/util/tests/test_zendesk.py +++ b/common/djangoapps/util/tests/test_submit_feedback.py @@ -15,8 +15,9 @@ import mock @mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True}) @override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy") +@mock.patch("util.views.dog_stats_api") @mock.patch("util.views._ZendeskApi", autospec=True) -class SubmitFeedbackViaZendeskTest(TestCase): +class SubmitFeedbackTest(TestCase): def setUp(self): """Set up data for the test case""" self._request_factory = RequestFactory() @@ -26,18 +27,19 @@ class SubmitFeedbackViaZendeskTest(TestCase): username="test", profile__name="Test User" ) - # This contains a tag to ensure that tags are submitted correctly + # This contains issue_type and course_id to ensure that tags are submitted correctly self._anon_fields = { "email": "test@edx.org", "name": "Test User", "subject": "a subject", "details": "some details", - "tag": "a tag" + "issue_type": "test_issue", + "course_id": "test_course" } - # This does not contain a tag to ensure that tag is optional + # This does not contain issue_type nor course_id to ensure that they are optional self._auth_fields = {"subject": "a subject", "details": "some details"} - def _test_request(self, user, fields): + def _build_and_run_request(self, user, fields): """ Generate a request and invoke the view, returning the response. @@ -48,12 +50,14 @@ class SubmitFeedbackViaZendeskTest(TestCase): "/submit_feedback", data=fields, HTTP_REFERER="test_referer", - HTTP_USER_AGENT="test_user_agent" + HTTP_USER_AGENT="test_user_agent", + REMOTE_ADDR="1.2.3.4", + SERVER_NAME="test_server" ) req.user = user - return views.submit_feedback_via_zendesk(req) + return views.submit_feedback(req) - def _assert_bad_request(self, response, field, zendesk_mock_class): + def _assert_bad_request(self, response, field, zendesk_mock_class, datadog_mock): """ Assert that the given `response` contains correct failure data. @@ -67,8 +71,9 @@ class SubmitFeedbackViaZendeskTest(TestCase): self.assertTrue("error" in resp_json) # There should be absolutely no interaction with Zendesk self.assertFalse(zendesk_mock_class.return_value.mock_calls) + self.assertFalse(datadog_mock.mock_calls) - def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class): + def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class, datadog_mock): """ Invoke the view with a request missing a field and assert correctness. @@ -79,10 +84,10 @@ class SubmitFeedbackViaZendeskTest(TestCase): have been invoked. """ filtered_fields = {k: v for (k, v) in fields.items() if k != omit_field} - resp = self._test_request(user, filtered_fields) - self._assert_bad_request(resp, omit_field, zendesk_mock_class) + resp = self._build_and_run_request(user, filtered_fields) + self._assert_bad_request(resp, omit_field, zendesk_mock_class, datadog_mock) - def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class): + def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class, datadog_mock): """ Invoke the view with an empty field and assert correctness. @@ -94,8 +99,8 @@ class SubmitFeedbackViaZendeskTest(TestCase): """ altered_fields = fields.copy() altered_fields[empty_field] = "" - resp = self._test_request(user, altered_fields) - self._assert_bad_request(resp, empty_field, zendesk_mock_class) + resp = self._build_and_run_request(user, altered_fields) + self._assert_bad_request(resp, empty_field, zendesk_mock_class, datadog_mock) def _test_success(self, user, fields): """ @@ -105,30 +110,46 @@ class SubmitFeedbackViaZendeskTest(TestCase): `fields` in the POST body. The response should have a 200 (success) status code. """ - resp = self._test_request(user, fields) + resp = self._build_and_run_request(user, fields) self.assertEqual(resp.status_code, 200) - def test_bad_request_anon_user_no_name(self, zendesk_mock_class): + def _assert_datadog_called(self, datadog_mock, with_tags): + expected_datadog_calls = [ + mock.call.increment( + views.DATADOG_FEEDBACK_METRIC, + tags=(["course_id:test_course", "issue_type:test_issue"] if with_tags else []) + ) + ] + self.assertEqual(datadog_mock.mock_calls, expected_datadog_calls) + + def test_bad_request_anon_user_no_name(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `name`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class, datadog_mock) - def test_bad_request_anon_user_no_email(self, zendesk_mock_class): + def test_bad_request_anon_user_no_email(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `email`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class, datadog_mock) - def test_bad_request_anon_user_no_subject(self, zendesk_mock_class): + def test_bad_request_anon_user_invalid_email(self, zendesk_mock_class, datadog_mock): + """Test a request from an anonymous user specifying an invalid `email`.""" + fields = self._anon_fields.copy() + fields["email"] = "This is not a valid email address!" + resp = self._build_and_run_request(self._anon_user, fields) + self._assert_bad_request(resp, "email", zendesk_mock_class, datadog_mock) + + def test_bad_request_anon_user_no_subject(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `subject`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class, datadog_mock) - def test_bad_request_anon_user_no_details(self, zendesk_mock_class): + def test_bad_request_anon_user_no_details(self, zendesk_mock_class, datadog_mock): """Test a request from an anonymous user not specifying `details`.""" - self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class) - self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class) + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class, datadog_mock) - def test_valid_request_anon_user(self, zendesk_mock_class): + def test_valid_request_anon_user(self, zendesk_mock_class, datadog_mock): """ Test a valid request from an anonymous user. @@ -138,14 +159,14 @@ class SubmitFeedbackViaZendeskTest(TestCase): zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.create_ticket.return_value = 42 self._test_success(self._anon_user, self._anon_fields) - expected_calls = [ + expected_zendesk_calls = [ mock.call.create_ticket( { "ticket": { "requester": {"name": "Test User", "email": "test@edx.org"}, "subject": "a subject", "comment": {"body": "some details"}, - "tags": ["a tag"] + "tags": ["test_course", "test_issue", "LMS"] } } ), @@ -157,26 +178,29 @@ class SubmitFeedbackViaZendeskTest(TestCase): "public": False, "body": "Additional information:\n\n" - "HTTP_USER_AGENT: test_user_agent\n" - "HTTP_REFERER: test_referer" + "Client IP: 1.2.3.4\n" + "Host: test_server\n" + "Page: test_referer\n" + "Browser: test_user_agent" } } } ) ] - self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls) + self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) + self._assert_datadog_called(datadog_mock, with_tags=True) - def test_bad_request_auth_user_no_subject(self, zendesk_mock_class): + def test_bad_request_auth_user_no_subject(self, zendesk_mock_class, datadog_mock): """Test a request from an authenticated user not specifying `subject`.""" - self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class) - self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class) + self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class, datadog_mock) - def test_bad_request_auth_user_no_details(self, zendesk_mock_class): + def test_bad_request_auth_user_no_details(self, zendesk_mock_class, datadog_mock): """Test a request from an authenticated user not specifying `details`.""" - self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class) - self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class) + self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class, datadog_mock) + self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class, datadog_mock) - def test_valid_request_auth_user(self, zendesk_mock_class): + def test_valid_request_auth_user(self, zendesk_mock_class, datadog_mock): """ Test a valid request from an authenticated user. @@ -186,14 +210,14 @@ class SubmitFeedbackViaZendeskTest(TestCase): zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.create_ticket.return_value = 42 self._test_success(self._auth_user, self._auth_fields) - expected_calls = [ + expected_zendesk_calls = [ mock.call.create_ticket( { "ticket": { "requester": {"name": "Test User", "email": "test@edx.org"}, "subject": "a subject", "comment": {"body": "some details"}, - "tags": [] + "tags": ["LMS"] } } ), @@ -206,27 +230,31 @@ class SubmitFeedbackViaZendeskTest(TestCase): "body": "Additional information:\n\n" "username: test\n" - "HTTP_USER_AGENT: test_user_agent\n" - "HTTP_REFERER: test_referer" + "Client IP: 1.2.3.4\n" + "Host: test_server\n" + "Page: test_referer\n" + "Browser: test_user_agent" } } } ) ] - self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls) + self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls) + self._assert_datadog_called(datadog_mock, with_tags=False) - def test_get_request(self, zendesk_mock_class): + def test_get_request(self, zendesk_mock_class, datadog_mock): """Test that a GET results in a 405 even with all required fields""" req = self._request_factory.get("/submit_feedback", data=self._anon_fields) req.user = self._anon_user - resp = views.submit_feedback_via_zendesk(req) + resp = views.submit_feedback(req) self.assertEqual(resp.status_code, 405) self.assertIn("Allow", resp) self.assertEqual(resp["Allow"], "POST") # There should be absolutely no interaction with Zendesk self.assertFalse(zendesk_mock_class.mock_calls) + self.assertFalse(datadog_mock.mock_calls) - def test_zendesk_error_on_create(self, zendesk_mock_class): + def test_zendesk_error_on_create(self, zendesk_mock_class, datadog_mock): """ Test Zendesk returning an error on ticket creation. @@ -235,11 +263,12 @@ class SubmitFeedbackViaZendeskTest(TestCase): err = ZendeskError(msg="", error_code=404) zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.create_ticket.side_effect = err - resp = self._test_request(self._anon_user, self._anon_fields) + resp = self._build_and_run_request(self._anon_user, self._anon_fields) self.assertEqual(resp.status_code, 500) self.assertFalse(resp.content) + self._assert_datadog_called(datadog_mock, with_tags=True) - def test_zendesk_error_on_update(self, zendesk_mock_class): + def test_zendesk_error_on_update(self, zendesk_mock_class, datadog_mock): """ Test for Zendesk returning an error on ticket update. @@ -250,20 +279,21 @@ class SubmitFeedbackViaZendeskTest(TestCase): err = ZendeskError(msg="", error_code=500) zendesk_mock_instance = zendesk_mock_class.return_value zendesk_mock_instance.update_ticket.side_effect = err - resp = self._test_request(self._anon_user, self._anon_fields) + resp = self._build_and_run_request(self._anon_user, self._anon_fields) self.assertEqual(resp.status_code, 200) + self._assert_datadog_called(datadog_mock, with_tags=True) @mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": False}) - def test_not_enabled(self, zendesk_mock_class): + def test_not_enabled(self, zendesk_mock_class, datadog_mock): """ Test for Zendesk submission not enabled in `settings`. We should raise Http404. """ with self.assertRaises(Http404): - self._test_request(self._anon_user, self._anon_fields) + self._build_and_run_request(self._anon_user, self._anon_fields) - def test_zendesk_not_configured(self, zendesk_mock_class): + def test_zendesk_not_configured(self, zendesk_mock_class, datadog_mock): """ Test for Zendesk not fully configured in `settings`. @@ -273,7 +303,7 @@ class SubmitFeedbackViaZendeskTest(TestCase): def test_case(missing_config): with mock.patch(missing_config, None): with self.assertRaises(Exception): - self._test_request(self._anon_user, self._anon_fields) + self._build_and_run_request(self._anon_user, self._anon_fields) test_case("django.conf.settings.ZENDESK_URL") test_case("django.conf.settings.ZENDESK_USER") diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index d0aa0dc680..aa592d25e8 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -12,6 +12,7 @@ from django.core.validators import ValidationError, validate_email from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie +from dogapi import dog_stats_api from mitxmako.shortcuts import render_to_response, render_to_string from urllib import urlencode import zendesk @@ -73,11 +74,64 @@ class _ZendeskApi(object): self._zendesk_instance.update_ticket(ticket_id=ticket_id, data=update) -def submit_feedback_via_zendesk(request): +def _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info): """ Create a new user-requested Zendesk ticket. - If Zendesk submission is not enabled, any request will raise `Http404`. + Once created, the ticket will be updated with a private comment containing + additional information from the browser and server, such as HTTP headers + and user state. Returns a boolean value indicating whether ticket creation + was successful, regardless of whether the private comment update succeeded. + """ + zendesk_api = _ZendeskApi() + + additional_info_string = ( + "Additional information:\n\n" + + "\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None) + ) + + # Tag all issues with LMS to distinguish channel in Zendesk; requested by student support team + zendesk_tags = list(tags.values()) + ["LMS"] + new_ticket = { + "ticket": { + "requester": {"name": realname, "email": email}, + "subject": subject, + "comment": {"body": details}, + "tags": zendesk_tags + } + } + try: + ticket_id = zendesk_api.create_ticket(new_ticket) + except zendesk.ZendeskError as err: + log.error("Error creating Zendesk ticket: %s", str(err)) + return False + + # Additional information is provided as a private update so the information + # is not visible to the user. + ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}} + try: + zendesk_api.update_ticket(ticket_id, ticket_update) + except zendesk.ZendeskError as err: + log.error("Error updating Zendesk ticket: %s", str(err)) + # The update is not strictly necessary, so do not indicate failure to the user + pass + + return True + + +DATADOG_FEEDBACK_METRIC = "lms_feedback_submissions" + + +def _record_feedback_in_datadog(tags): + datadog_tags = ["{k}:{v}".format(k=k, v=v) for k, v in tags.items()] + dog_stats_api.increment(DATADOG_FEEDBACK_METRIC, tags=datadog_tags) + + +def submit_feedback(request): + """ + Create a new user-requested ticket, currently implemented with Zendesk. + + If feedback submission is not enabled, any request will raise `Http404`. If any configuration parameter (`ZENDESK_URL`, `ZENDESK_USER`, or `ZENDESK_API_KEY`) is missing, any request will raise an `Exception`. The request must be a POST request specifying `subject` and `details`. @@ -85,12 +139,9 @@ def submit_feedback_via_zendesk(request): `email`. If the user is authenticated, the `name` and `email` will be populated from the user's information. If any required parameter is missing, a 400 error will be returned indicating which field is missing and - providing an error message. If Zendesk returns any error on ticket - creation, a 500 error will be returned with no body. Once created, the - ticket will be updated with a private comment containing additional - information from the browser and server, such as HTTP headers and user - state. Whether or not the update succeeds, if the user's ticket is - successfully created, an empty successful response (200) will be returned. + providing an error message. If Zendesk ticket creation fails, 500 error + will be returned with no body; if ticket creation succeeds, an empty + successful response (200) will be returned. """ if not settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False): raise Http404() @@ -124,9 +175,9 @@ def submit_feedback_via_zendesk(request): subject = request.POST["subject"] details = request.POST["details"] - tags = [] - if "tag" in request.POST: - tags = [request.POST["tag"]] + tags = dict( + [(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if tag in request.POST] + ) if request.user.is_authenticated(): realname = request.user.profile.name @@ -140,41 +191,18 @@ def submit_feedback_via_zendesk(request): except ValidationError: return build_error_response(400, "email", required_field_errs["email"]) - for header in ["HTTP_REFERER", "HTTP_USER_AGENT"]: - additional_info[header] = request.META.get(header) + for header, pretty in [ + ("HTTP_REFERER", "Page"), + ("HTTP_USER_AGENT", "Browser"), + ("REMOTE_ADDR", "Client IP"), + ("SERVER_NAME", "Host") + ]: + additional_info[pretty] = request.META.get(header) - zendesk_api = _ZendeskApi() + success = _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info) + _record_feedback_in_datadog(tags) - additional_info_string = ( - "Additional information:\n\n" + - "\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None) - ) - - new_ticket = { - "ticket": { - "requester": {"name": realname, "email": email}, - "subject": subject, - "comment": {"body": details}, - "tags": tags - } - } - try: - ticket_id = zendesk_api.create_ticket(new_ticket) - except zendesk.ZendeskError as err: - log.error("Error creating Zendesk ticket: %s", str(err)) - return HttpResponse(status=500) - - # Additional information is provided as a private update so the information - # is not visible to the user. - ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}} - try: - zendesk_api.update_ticket(ticket_id, ticket_update) - except zendesk.ZendeskError as err: - log.error("Error updating Zendesk ticket: %s", str(err)) - # The update is not strictly necessary, so do not indicate failure to the user - pass - - return HttpResponse() + return HttpResponse(status=(200 if success else 500)) def info(request): diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 45691cd854..570b38c942 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -1,7 +1,6 @@ import re import json import logging -import time import static_replace from django.conf import settings @@ -9,6 +8,8 @@ from functools import wraps from mitxmako.shortcuts import render_to_string from xmodule.seq_module import SequenceModule from xmodule.vertical_module import VerticalModule +import datetime +from django.utils.timezone import UTC log = logging.getLogger("mitx.xmodule_modifiers") @@ -83,7 +84,7 @@ def grade_histogram(module_id): cursor.execute(q, [module_id]) grades = list(cursor.fetchall()) - grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query? + grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query? if len(grades) >= 1 and grades[0][0] is None: return [] return grades @@ -101,7 +102,7 @@ def add_histogram(get_html, module, user): @wraps(get_html) def _get_html(): - if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead + if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead return get_html() module_id = module.id @@ -132,7 +133,7 @@ def add_histogram(get_html, module, user): # useful to indicate to staff if problem has been released or not # TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here - now = time.gmtime() + now = datetime.datetime.now(UTC()) is_released = "unknown" mstart = module.descriptor.lms.start diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 150b3b3c9b..7dcd7b925e 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -470,6 +470,7 @@ class LoncapaProblem(object): python_path=python_path, cache=self.system.cache, slug=self.problem_id, + unsafely=self.system.can_execute_unsafe_code(), ) except Exception as err: log.exception("Error while execing script code: " + all_code) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 65280d6d29..446b832dd7 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -144,11 +144,11 @@ class InputTypeBase(object): self.tag = xml.tag self.system = system - ## NOTE: ID should only come from one place. If it comes from multiple, - ## we use state first, XML second (in case the xml changed, but we have - ## existing state with an old id). Since we don't make this guarantee, - ## we can swap this around in the future if there's a more logical - ## order. + # NOTE: ID should only come from one place. If it comes from multiple, + # we use state first, XML second (in case the xml changed, but we have + # existing state with an old id). Since we don't make this guarantee, + # we can swap this around in the future if there's a more logical + # order. self.input_id = state.get('id', xml.get('id')) if self.input_id is None: @@ -769,7 +769,7 @@ class MatlabInput(CodeInput): # construct xqueue headers qinterface = self.system.xqueue['interface'] - qtime = datetime.strftime(datetime.utcnow(), xqueue_interface.dateformat) + qtime = datetime.utcnow().strftime(xqueue_interface.dateformat) callback_url = self.system.xqueue['construct_callback']('ungraded_response') anonymous_student_id = self.system.anonymous_student_id queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 314d01e7e8..80227490da 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -288,7 +288,14 @@ class LoncapaResponse(object): } try: - safe_exec.safe_exec(code, globals_dict, python_path=self.context['python_path'], slug=self.id) + safe_exec.safe_exec( + code, + globals_dict, + python_path=self.context['python_path'], + slug=self.id, + random_seed=self.context['seed'], + unsafely=self.system.can_execute_unsafe_code(), + ) except Exception as err: msg = 'Error %s in evaluating hint function %s' % (err, hintfn) msg += "\nSee XML source line %s" % getattr( @@ -973,7 +980,14 @@ class CustomResponse(LoncapaResponse): 'ans': ans, } globals_dict.update(kwargs) - safe_exec.safe_exec(code, globals_dict, python_path=self.context['python_path'], slug=self.id) + safe_exec.safe_exec( + code, + globals_dict, + python_path=self.context['python_path'], + slug=self.id, + random_seed=self.context['seed'], + unsafely=self.system.can_execute_unsafe_code(), + ) return globals_dict['cfn_return'] return check_function @@ -1090,7 +1104,14 @@ class CustomResponse(LoncapaResponse): # exec the check function if isinstance(self.code, basestring): try: - safe_exec.safe_exec(self.code, self.context, cache=self.system.cache, slug=self.id) + safe_exec.safe_exec( + self.code, + self.context, + cache=self.system.cache, + slug=self.id, + random_seed=self.context['seed'], + unsafely=self.system.can_execute_unsafe_code(), + ) except Exception as err: self._handle_exec_exception(err) @@ -1815,7 +1836,14 @@ class SchematicResponse(LoncapaResponse): ] self.context.update({'submission': submission}) try: - safe_exec.safe_exec(self.code, self.context, cache=self.system.cache, slug=self.id) + safe_exec.safe_exec( + self.code, + self.context, + cache=self.system.cache, + slug=self.id, + random_seed=self.context['seed'], + unsafely=self.system.can_execute_unsafe_code(), + ) except Exception as err: msg = 'Error %s in evaluating SchematicResponse' % err raise ResponseError(msg) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 67e93be46f..3ab8f0bf9e 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -1,6 +1,7 @@ """Capa's specialized use of codejail.safe_exec.""" 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 json_safe, SafeExecException from . import lazymod from statsd import statsd @@ -71,7 +72,7 @@ def update_hash(hasher, obj): @statsd.timed('capa.safe_exec.time') -def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None): +def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None, unsafely=False): """ Execute python code safely. @@ -90,6 +91,8 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None `slug` is an arbitrary string, a description that's meaningful to the caller, that will be used in log messages. + If `unsafely` is true, then the code will actually be executed without sandboxing. + """ # Check the cache for a previous result. if cache: @@ -111,9 +114,15 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None # Create the complete code we'll run. code_prolog = CODE_PROLOG % random_seed + # Decide which code executor to use. + if unsafely: + exec_fn = codejail_not_safe_exec + else: + exec_fn = codejail_safe_exec + # Run the code! Results are side effects in globals_dict. try: - codejail_safe_exec( + exec_fn( code_prolog + LAZY_IMPORTS + code, globals_dict, python_path=python_path, slug=slug, ) diff --git a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py index 4592af8305..f8a8a32297 100644 --- a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py +++ b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py @@ -1,13 +1,17 @@ """Test safe_exec.py""" import hashlib +import os import os.path import random import textwrap import unittest +from nose.plugins.skip import SkipTest + from capa.safe_exec import safe_exec, update_hash from codejail.safe_exec import SafeExecException +from codejail.jail_code import is_configured class TestSafeExec(unittest.TestCase): @@ -68,6 +72,24 @@ class TestSafeExec(unittest.TestCase): self.assertIn("ZeroDivisionError", cm.exception.message) +class TestSafeOrNot(unittest.TestCase): + def test_cant_do_something_forbidden(self): + # Can't test for forbiddenness if CodeJail isn't configured for python. + if not is_configured("python"): + raise SkipTest + + g = {} + with self.assertRaises(SafeExecException) as cm: + safe_exec("import os; files = os.listdir('/')", g) + self.assertIn("OSError", cm.exception.message) + self.assertIn("Permission denied", cm.exception.message) + + def test_can_do_something_forbidden_if_run_unsafely(self): + g = {} + safe_exec("import os; files = os.listdir('/')", g, unsafely=True) + self.assertEqual(g['files'], os.listdir('/')) + + class DictCache(object): """A cache implementation over a simple dict, for testing.""" diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index e0172bb13b..145a7c2cad 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -14,7 +14,7 @@
${comment}
${comment_prompt}
- +
${tag_prompt}
    @@ -22,11 +22,11 @@
  • % if has_options_value: % if all([c == 'correct' for c in option['choice'], status]): - + Status: Correct % elif all([c == 'partially-correct' for c in option['choice'], status]): - + Status: Partially Correct % elif all([c == 'incorrect' for c in option['choice'], status]): - + Status: Incorrect % endif % endif @@ -53,11 +53,11 @@ % endif % if status == 'unsubmitted': - + Status: Unanswered % elif status == 'incomplete': - + Status: Incorrect % elif status == 'incorrect' and not has_options_value: - + Status: Incorrect % endif

    diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html index 17c84114e5..34709c3e5e 100644 --- a/common/lib/capa/capa/templates/chemicalequationinput.html +++ b/common/lib/capa/capa/templates/chemicalequationinput.html @@ -11,13 +11,13 @@
    % endif - -

    +

    % if status == 'unsubmitted': unanswered % elif status == 'correct': diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index c9cc3fd28d..17f7efcec4 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -3,12 +3,12 @@ % if input_type == 'checkbox' or not value: % if status == 'unsubmitted' or show_correctness == 'never': - % elif status == 'correct': - + % elif status == 'correct': + Status: correct % elif status == 'incorrect': - + Status: incorrect % elif status == 'incomplete': - + Status: incomplete % endif % endif

    @@ -18,7 +18,7 @@ % for choice_id, choice_description in choices: + /> ${choice_description} + + % if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ): + <% + if status == 'correct': + correctness = 'correct' + elif status == 'incorrect': + correctness = 'incorrect' + else: + correctness = None + %> + % if correctness and not show_correctness=='never': + Status: ${correctness} + % endif + % endif + % endfor diff --git a/common/lib/capa/capa/templates/codeinput.html b/common/lib/capa/capa/templates/codeinput.html index eb8cad0d70..08ad4ff062 100644 --- a/common/lib/capa/capa/templates/codeinput.html +++ b/common/lib/capa/capa/templates/codeinput.html @@ -1,5 +1,5 @@
    - +

    Enroll or un-enroll one or many students: enter emails, separated by new lines or commas;

    + +

    + Auto-enroll students when they activate +

    + %endif @@ -539,17 +541,17 @@ function goto( mode)


    -

    ${datatable['title']}

    +

    ${datatable['title'] | h}

    %for hname in datatable['header']: - + %endfor %for row in datatable['data']: %for value in row: - + %endfor %endfor diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index fcd4348f96..bf1414cc07 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -35,7 +35,7 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", %if not course.disable_progress_graph: -
    + %endif
      @@ -54,7 +54,12 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", %>

      - ${ section['display_name'] } + ${ section['display_name'] } + %if total > 0 or earned > 0: + + ${"{0:.3n} of {1:.3n} possible points".format( float(earned), float(total) )} + + %endif %if total > 0 or earned > 0: ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )} %endif diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 75c0cafabd..e2fbaed9cf 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -150,6 +150,8 @@ + ## `news` should be `None` whenever a non-edX theme is enabled: + ## see common/djangoapps/student/views.py#_get_news %if news:

    diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html index 350f858334..deb2db3610 100644 --- a/lms/templates/help_modal.html +++ b/lms/templates/help_modal.html @@ -21,9 +21,12 @@ discussion_link = get_discussion_link(course) if course else None %> +% if discussion_link:

    For questions on course lectures, homework, tools, or materials for this course, post in the course discussion forum.

    +% endif +

    Have general questions about edX? You can find lots of helpful information in the edX FAQ.

    @@ -61,7 +64,10 @@ discussion_link = get_discussion_link(course) if course else None - + +% if course: + +% endif
    @@ -111,21 +117,41 @@ discussion_link = get_discussion_link(course) if course else None $("#feedback_success_wrapper").css("display", "none"); $("#help_wrapper").css("display", "block"); }); - showFeedback = function(e, tag, title) { + showFeedback = function(event, issue_type, title, subject_label, details_label) { $("#help_wrapper").css("display", "none"); - $("#feedback_form input[name='tag']").val(tag); + $("#feedback_form input[name='issue_type']").val(issue_type); $("#feedback_form_wrapper").css("display", "block"); $("#feedback_form_wrapper header").html("

    " + title + "


    "); - e.preventDefault(); + $("#feedback_form_wrapper label[data-field='subject']").html(subject_label); + $("#feedback_form_wrapper label[data-field='details']").html(details_label); + event.preventDefault(); }; - $("#feedback_link_problem").click(function(e) { - showFeedback(e, "problem", "Report a Problem"); + $("#feedback_link_problem").click(function(event) { + showFeedback( + event, + "problem", + "Report a Problem", + "Brief description of the problem*", + "Details of the problem you are encountering* Include error messages, steps which lead to the issue, etc." + ); }); - $("#feedback_link_suggestion").click(function(e) { - showFeedback(e, "suggestion", "Make a Suggestion"); + $("#feedback_link_suggestion").click(function(event) { + showFeedback( + event, + "suggestion", + "Make a Suggestion", + "Brief description of your suggestion*", + "Details*" + ); }); - $("#feedback_link_question").click(function(e) { - showFeedback(e, "question", "Ask a Question"); + $("#feedback_link_question").click(function(event) { + showFeedback( + event, + "question", + "Ask a Question", + "Brief summary of your question*", + "Details*" + ); }); $("#feedback_form").submit(function() { $("input[type='submit']", this).attr("disabled", "disabled"); diff --git a/lms/templates/index.html b/lms/templates/index.html index 109ab6fb2f..e6b9c86ba4 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -8,30 +8,37 @@
    -

    The Future of Online Education

    + % if self.stanford_theme_enabled(): +

    Free courses from Stanford

    + % else: +

    The Future of Online Education

    + % endif

    For anyone, anywhere, anytime

    -
    -
    - Sign Up -
    - -
    - -
    + +
    + +
    + + % endif
    @@ -45,113 +52,116 @@
    -

    Explore free courses from edX universities

    + ## Disable university partner logos and sites for non-edX sites + % if not self.theme_enabled(): +

    Explore free courses from edX universities

    -
    -
      -
    1. - - -
      - MITx -
      -
      -
    2. -
    3. - - -
      - HarvardX -
      -
      -
    4. -
    5. - - -
      - BerkeleyX -
      -
      -
    6. -
    7. - - -
      - UTx -
      -
      -
    8. -
    9. - - -
      - McGillX -
      -
      -
    10. -
    11. - - -
      - ANUx -
      -
      -
    12. -
    +
    +
      +
    1. + + +
      + MITx +
      +
      +
    2. +
    3. + + +
      + HarvardX +
      +
      +
    4. +
    5. + + +
      + BerkeleyX +
      +
      +
    6. +
    7. + + +
      + UTx +
      +
      +
    8. +
    9. + + +
      + McGillX +
      +
      +
    10. +
    11. + + +
      + ANUx +
      +
      +
    12. +
    -
    +
    -
      -
    1. - - -
      - WellesleyX -
      -
      -
    2. -
    3. - - -
      - GeorgetownX -
      -
      -
    4. -
    5. - - -
      - University of TorontoX -
      -
      -
    6. -
    7. - - -
      - EPFLx -
      -
      -
    8. -
    9. - - -
      - DelftX -
      -
      -
    10. -
    11. - - -
      - RiceX -
      -
      -
    12. -
    -
    +
      +
    1. + + +
      + WellesleyX +
      +
      +
    2. +
    3. + + +
      + GeorgetownX +
      +
      +
    4. +
    5. + + +
      + University of TorontoX +
      +
      +
    6. +
    7. + + +
      + EPFLx +
      +
      +
    8. +
    9. + + +
      + DelftX +
      +
      +
    10. +
    11. + + +
      + RiceX +
      +
      +
    12. +
    +
    + % endif
      @@ -165,47 +175,56 @@
    -
    -
    -
    -

    edX News & Announcements

    - edX MEDIA KIT -
    -
    -
    - %for entry in news: -
    - %if entry.image: - - %endif -
    - ${entry.title} - %if entry.summary: -

    ${entry.summary}

    - %endif - -
    -
    - %endfor -
    - + % endif
    diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index 1c5f7364ad..0a28a2b026 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -29,7 +29,7 @@

    Instructions

    -

    This is the list of problems that current need to be graded in order to train the machine learning models. Each problem needs to be trained separately, and we have indicated the number of student submissions that need to be graded in order for a model to be generated. You can grade more than the minimum required number of submissions--this will improve the accuracy of machine learning, though with diminishing returns. You can see the current accuracy of machine learning while grading.

    +

    This is the list of problems that currently need to be graded in order to train the machine learning models. Each problem needs to be trained separately, and we have indicated the number of student submissions that need to be graded in order for a model to be generated. You can grade more than the minimum required number of submissions--this will improve the accuracy of machine learning, though with diminishing returns. You can see the current accuracy of machine learning while grading.

    Problem List

    diff --git a/lms/templates/invalid_email_key.html b/lms/templates/invalid_email_key.html index 437dfa151d..212f91fa9d 100644 --- a/lms/templates/invalid_email_key.html +++ b/lms/templates/invalid_email_key.html @@ -1,8 +1,16 @@ -

    Invalid key

    +<%inherit file="main.html" /> -

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

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

    Invalid email change key

    +
    +

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

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

    Go back to the home page.

    +
    +
    diff --git a/lms/templates/login.html b/lms/templates/login.html index 3e33c84b7a..fbd2f6d07c 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -1,8 +1,11 @@ <%inherit file="main.html" /> <%namespace name='static' file='static_content.html'/> + <%! from django.core.urlresolvers import reverse %> -<%block name="title">Log into your edX Account +<%! from django.utils.translation import ugettext as _ %> + +<%block name="title">Log into your ${settings.PLATFORM_NAME} Account <%block name="js_extra"> - + <%static:css group='application'/> <%static:js group='main_vendor'/> <%block name="headextra"/> + % if theme_enabled(): + <%include file="theme-head-extra.html" /> + % endif @@ -105,7 +103,7 @@

    - Please complete the following fields to register for an edX account.
    + Please complete the following fields to register for an account.
    Required fields are noted by bold text and an asterisk (*).

    @@ -117,20 +115,20 @@
    1. - +
    2. - +
    3. - + Will be shown in any discussions or forums you participate in
    4. - + Needed for any certificates you may earn (cannot be changed later)
    @@ -145,7 +143,7 @@
    1. - + Will be shown in any discussions or forums you participate in
    @@ -201,7 +199,7 @@
  • - +
  • @@ -213,13 +211,20 @@
    1. - +
      - + <% + ## TODO: provide a better way to override these links + if self.stanford_theme_enabled(): + honor_code_path = marketing_link('TOS') + "#honor" + else: + honor_code_path = marketing_link('HONOR') + %> +
    @@ -250,23 +255,33 @@

    -
    -

    Welcome to edX

    -

    Registering with edX gives you access to all of our current and future free courses. Not ready to take a course just yet? Registering puts you on our mailing list – we will update you as courses are added.

    -
    + ## TODO: Use a %block tag or something to allow themes to + ## override in a more generalizable fashion. + % if not self.stanford_theme_enabled(): +
    +

    Welcome to ${settings.PLATFORM_NAME}

    +

    Registering with ${settings.PLATFORM_NAME} gives you access to all of our current and future free courses. Not ready to take a course just yet? Registering puts you on our mailing list – we will update you as courses are added.

    +
    + % endif

    Next Steps

    -

    As part of joining edX, you will receive an activation email. You must click on the activation link to complete the process. Don’t see the email? Check your spam folder and mark edX emails as ‘not spam’. At edX, we communicate mostly through email.

    + % if self.stanford_theme_enabled(): +

    You will receive an activation email. You must click on the activation link to complete the process. Don’t see the email? Check your spam folder and mark emails from class.stanford.edu as ‘not spam’, since you'll want to be able to receive email from your courses.

    + % else: +

    As part of joining ${settings.PLATFORM_NAME}, you will receive an activation email. You must click on the activation link to complete the process. Don’t see the email? Check your spam folder and mark ${settings.PLATFORM_NAME} emails as ‘not spam’. At ${settings.PLATFORM_NAME}, we communicate mostly through email.

    + % endif
    -
    -

    Need Help?

    -

    Need help in registering with edX? - - View our FAQs for answers to commonly asked questions. - - Once registered, most questions can be answered in the course specific discussion forums or through the FAQs.

    -
    + % if settings.MKTG_URL_LINK_MAP.get('FAQ'): +
    +

    Need Help?

    +

    Need help in registering with ${settings.PLATFORM_NAME}? + + View our FAQs for answers to commonly asked questions. + + Once registered, most questions can be answered in the course specific discussion forums or through the FAQs.

    +
    + % endif diff --git a/lms/templates/registration/activation_invalid.html b/lms/templates/registration/activation_invalid.html index 09d373a39d..0a6d6d30c9 100644 --- a/lms/templates/registration/activation_invalid.html +++ b/lms/templates/registration/activation_invalid.html @@ -12,7 +12,7 @@

    Something went wrong. Check to make sure the URL you went to was correct -- e-mail programs will sometimes split it into two lines. If you still have issues, e-mail us to let us know what happened - at bugs@edx.org.

    + at ${settings.BUGS_EMAIL}.

    Or you can go back to the home page.

    diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index 304e7834f1..8b94ff5658 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -1,5 +1,9 @@
    diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 7b4abf13fd..d24d6528ac 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -1,6 +1,6 @@ ## The JS for this is defined in xqa_interface.html ${module_content} -%if location.category in ['problem','video','html','combinedopenended']: +%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool']: % if edit_link:
    Edit diff --git a/lms/templates/static_templates/404.html b/lms/templates/static_templates/404.html index f29968e2f5..c297cec881 100644 --- a/lms/templates/static_templates/404.html +++ b/lms/templates/static_templates/404.html @@ -4,5 +4,5 @@

    Page not found

    -

    The page that you were looking for was not found. Go back to the homepage or let us know about any pages that may have been moved at technical@edx.org.

    +

    The page that you were looking for was not found. Go back to the homepage or let us know about any pages that may have been moved at ${settings.TECH_SUPPORT_EMAIL}.

    diff --git a/lms/templates/static_templates/server-down.html b/lms/templates/static_templates/server-down.html index 7fada34a53..ac847db9ee 100644 --- a/lms/templates/static_templates/server-down.html +++ b/lms/templates/static_templates/server-down.html @@ -1,6 +1,6 @@ <%inherit file="../main.html" />
    -

    Currently the edX servers are down

    -

    Our staff is currently working to get the site back up as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

    +

    Currently the ${settings.PLATFORM_NAME} servers are down

    +

    Our staff is currently working to get the site back up as soon as possible. Please email us at ${settings.TECH_SUPPORT_EMAIL} to report any problems or downtime.

    diff --git a/lms/templates/static_templates/server-error.html b/lms/templates/static_templates/server-error.html index 5564ea082e..04fc11d11a 100644 --- a/lms/templates/static_templates/server-error.html +++ b/lms/templates/static_templates/server-error.html @@ -1,6 +1,6 @@ <%inherit file="../main.html" />
    -

    There has been a 500 error on the edX servers

    -

    Please wait a few seconds and then reload the page. If the problem persists, please email us at technical@edx.org.

    +

    There has been a 500 error on the ${settings.PLATFORM_NAME} servers

    +

    Please wait a few seconds and then reload the page. If the problem persists, please email us at ${settings.TECH_SUPPORT_EMAIL}.

    diff --git a/lms/templates/static_templates/server-overloaded.html b/lms/templates/static_templates/server-overloaded.html index bbf4550ff4..2432f2b481 100644 --- a/lms/templates/static_templates/server-overloaded.html +++ b/lms/templates/static_templates/server-overloaded.html @@ -1,6 +1,6 @@ <%inherit file="../main.html" />
    -

    Currently the edX servers are overloaded

    -

    Our staff is currently working to get the site back up as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

    +

    Currently the ${settings.PLATFORM_NAME} servers are overloaded

    +

    Our staff is currently working to get the site back up as soon as possible. Please email us at ${settings.TECH_SUPPORT_EMAIL} to report any problems or downtime.

    diff --git a/lms/templates/widgets/segment-io.html b/lms/templates/widgets/segment-io.html new file mode 100644 index 0000000000..dea222653e --- /dev/null +++ b/lms/templates/widgets/segment-io.html @@ -0,0 +1,18 @@ + + + diff --git a/lms/urls.py b/lms/urls.py index 851731e6ec..8f393584ac 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -58,66 +58,92 @@ urlpatterns = ('', # nopep8 name='auth_password_reset_done'), url(r'^heartbeat$', include('heartbeat.urls')), +) - ## - ## Only universities without courses should be included here. If - ## courses exist, the dynamic profile rule below should win. - ## - url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'WellesleyX'}), - url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'McGillX'}), - url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'TorontoX'}), - url(r'^(?i)university_profile/RiceX$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'RiceX'}), - url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'ANUx'}), - url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'EPFLx'}), +# University profiles only make sense in the default edX context +if not settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + urlpatterns += ( + ## + ## Only universities without courses should be included here. If + ## courses exist, the dynamic profile rule below should win. + ## + url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'WellesleyX'}), + url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'McGillX'}), + url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'TorontoX'}), + url(r'^(?i)university_profile/RiceX$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'RiceX'}), + url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'ANUx'}), + url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile', + name="static_university_profile", kwargs={'org_id': 'EPFLx'}), - url(r'^university_profile/(?P[^/]+)$', 'courseware.views.university_profile', - name="university_profile"), + url(r'^university_profile/(?P[^/]+)$', 'courseware.views.university_profile', + name="university_profile"), + ) - #Semi-static views (these need to be rendered and have the login bar, but don't change) +#Semi-static views (these need to be rendered and have the login bar, but don't change) +urlpatterns += ( url(r'^404$', 'static_template_view.views.render', {'template': '404.html'}, name="404"), - url(r'^about$', 'static_template_view.views.render', - {'template': 'about.html'}, name="about_edx"), - url(r'^jobs$', 'static_template_view.views.render', - {'template': 'jobs.html'}, name="jobs"), - url(r'^contact$', 'static_template_view.views.render', - {'template': 'contact.html'}, name="contact"), - url(r'^press$', 'student.views.press', name="press"), - url(r'^media-kit$', 'static_template_view.views.render', - {'template': 'media-kit.html'}, name="media-kit"), - url(r'^faq$', 'static_template_view.views.render', - {'template': 'faq.html'}, name="faq_edx"), - url(r'^help$', 'static_template_view.views.render', - {'template': 'help.html'}, name="help_edx"), - - url(r'^tos$', 'static_template_view.views.render', - {'template': 'tos.html'}, name="tos"), - url(r'^privacy$', 'static_template_view.views.render', - {'template': 'privacy.html'}, name="privacy_edx"), - # TODO: (bridger) The copyright has been removed until it is updated for edX - # url(r'^copyright$', 'static_template_view.views.render', - # {'template': 'copyright.html'}, name="copyright"), - url(r'^honor$', 'static_template_view.views.render', - {'template': 'honor.html'}, name="honor"), - - #Press releases - url(r'^press/([_a-zA-Z0-9-]+)$', 'static_template_view.views.render_press_release', name='press_release'), - - # Favicon - (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), - - url(r'^submit_feedback$', 'util.views.submit_feedback_via_zendesk'), - - # TODO: These urls no longer work. They need to be updated before they are re-enabled - # url(r'^reactivate/(?P[^/]*)$', 'student.views.reactivation_email'), ) +# Semi-static views only used by edX, not by themes +if not settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + urlpatterns += ( + url(r'^jobs$', 'static_template_view.views.render', + {'template': 'jobs.html'}, name="jobs"), + url(r'^press$', 'student.views.press', name="press"), + url(r'^media-kit$', 'static_template_view.views.render', + {'template': 'media-kit.html'}, name="media-kit"), + url(r'^help$', 'static_template_view.views.render', + {'template': 'help.html'}, name="help_edx"), + + # TODO: (bridger) The copyright has been removed until it is updated for edX + # url(r'^copyright$', 'static_template_view.views.render', + # {'template': 'copyright.html'}, name="copyright"), + + #Press releases + url(r'^press/([_a-zA-Z0-9-]+)$', 'static_template_view.views.render_press_release', name='press_release'), + + # Favicon + (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), + + url(r'^submit_feedback$', 'util.views.submit_feedback'), + + # TODO: These urls no longer work. They need to be updated before they are re-enabled + # url(r'^reactivate/(?P[^/]*)$', 'student.views.reactivation_email'), + ) + +# Only enable URLs for those marketing links actually enabled in the +# settings. Disable URLs by marking them as None. +for key, value in settings.MKTG_URL_LINK_MAP.items(): + # Skip disabled URLs + if value is None: + continue + + # These urls are enabled separately + if key == "ROOT" or key == "COURSES": + continue + + # Make the assumptions that the templates are all in the same dir + # and that they all match the name of the key (plus extension) + template = "%s.html" % key.lower() + + # To allow theme templates to inherit from default templates, + # prepend a standard prefix + if settings.MITX_FEATURES["USE_CUSTOM_THEME"]: + template = "theme-" + template + + # Make the assumption that the URL we want is the lowercased + # version of the map key + urlpatterns += (url(r'^%s' % key.lower(), + 'static_template_view.views.render', + {'template': template}, name=value),) + + if settings.PERFSTATS: urlpatterns += (url(r'^reprofile$', 'perfstats.views.end_profile'),) @@ -241,8 +267,6 @@ if settings.COURSEWARE_ENABLED: 'instructor.views.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', 'instructor.views.grade_summary', name='grade_summary'), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/enroll_students$', - 'instructor.views.enroll_students', name='enroll_students'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/staff_grading$', 'open_ended_grading.views.staff_grading', name='staff_grading'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/staff_grading/get_next$', diff --git a/rakefiles/assets.rake b/rakefiles/assets.rake index 68127a317f..009c87048c 100644 --- a/rakefiles/assets.rake +++ b/rakefiles/assets.rake @@ -6,38 +6,15 @@ if USE_CUSTOM_THEME THEME_SASS = File.join(THEME_ROOT, "static", "sass") end -# Run the specified file through the Mako templating engine, providing -# the ENV_TOKENS to the templating context. -def preprocess_with_mako(filename) - # simple command-line invocation of Mako engine - # cdodge: the .gsub() are used to translate true->True and false->False to make the generated - # python actually valid python. This is just a short term hack to unblock the release train - # until a real fix can be made by people who know this better - mako = "from mako.template import Template;" + - "print Template(filename=\"#{filename}\")" + - # Total hack. It works because a Python dict literal has - # the same format as a JSON object. - ".render(env=#{ENV_TOKENS.to_json.gsub("true","True").gsub("false","False")});" - - # strip off the .mako extension - output_filename = filename.chomp(File.extname(filename)) - - # just pipe from stdout into the new file, exiting on failure - File.open(output_filename, 'w') do |file| - file.write(`python -c '#{mako}'`) - exit_code = $?.to_i - abort "#{mako} failed with #{exit_code}" if exit_code.to_i != 0 - end -end - def xmodule_cmd(watch=false, debug=false) xmodule_cmd = 'xmodule_assets common/static/xmodule' if watch "watchmedo shell-command " + - "--patterns='*.js;*.coffee;*.sass;*.scss;*.css' " + - "--recursive " + - "--command='#{xmodule_cmd}' " + - "common/lib/xmodule" + "--patterns='*.js;*.coffee;*.sass;*.scss;*.css' " + + "--recursive " + + "--command='#{xmodule_cmd}' " + + "--wait " + + "common/lib/xmodule" else xmodule_cmd end @@ -51,14 +28,16 @@ def coffee_cmd(watch=false, debug=false) # # Ref: https://github.com/joyent/node/issues/2479 # - # Instead, watch 50 files per process in parallel - cmds = [] - Dir['*/static/**/*.coffee'].each_slice(50) do |coffee_files| - cmds << "node_modules/.bin/coffee --watch --compile #{coffee_files.join(' ')}" - end - cmds + # So, instead, we use watchmedo, which works around the problem + "watchmedo shell-command " + + "--command 'node_modules/.bin/coffee -c ${watch_src_path}' " + + "--recursive " + + "--patterns '*.coffee' " + + "--ignore-directories " + + "--wait " + + "." else - 'node_modules/.bin/coffee --compile */static' + 'node_modules/.bin/coffee --compile .' end end @@ -84,11 +63,12 @@ namespace :assets do desc "Compile all assets in debug mode" multitask :debug - desc "Preprocess all static assets that have the .mako extension" - task :preprocess do - # Run assets through the Mako templating engine. Right now we - # just hardcode the asset filenames. - preprocess_with_mako("lms/static/sass/application.scss.mako") + desc "Preprocess all templatized static asset files" + task :preprocess, [:system, :env] do |t, args| + args.with_defaults(:system => "lms", :env => "dev") + sh(django_admin(args.system, args.env, "preprocess_assets")) do |ok, status| + abort "asset preprocessing failed!" if !ok + end end desc "Watch all assets for changes and automatically recompile" @@ -97,8 +77,8 @@ namespace :assets do $stdin.gets end - {:xmodule => :install_python_prereqs, - :coffee => :install_node_prereqs, + {:xmodule => [:install_python_prereqs], + :coffee => [:install_node_prereqs], :sass => [:install_ruby_prereqs, :preprocess]}.each_pair do |asset_type, prereq_tasks| desc "Compile all #{asset_type} assets" task asset_type => prereq_tasks do @@ -127,7 +107,8 @@ namespace :assets do $stdin.gets end - task :_watch => prereq_tasks do + # Fully compile before watching for changes + task :_watch => (prereq_tasks + ["assets:#{asset_type}:debug"]) do cmd = send(asset_type.to_s + "_cmd", watch=true, debug=true) if cmd.kind_of?(Array) cmd.each {|c| background_process(c)} @@ -138,12 +119,8 @@ namespace :assets do end end - multitask :sass => 'assets:xmodule' namespace :sass do - # In watch mode, sass doesn't immediately compile out of date files, - # so force a recompile first - task :_watch => 'assets:sass:debug' multitask :debug => 'assets:xmodule:debug' end @@ -151,18 +128,32 @@ namespace :assets do namespace :coffee do multitask :debug => 'assets:xmodule:debug' end + + namespace :xmodule do + # Only start the xmodule watcher after the coffee and sass watchers have already started + task :_watch => ['assets:coffee:_watch', 'assets:sass:_watch'] + end +end + +# This task does the real heavy lifting to gather all of the static +# assets. We want people to call it via the wrapper below, so we +# don't provide a description so that it won't show up in rake -T. +task :gather_assets, [:system, :env] => :assets do |t, args| + sh("#{django_admin(args.system, args.env, 'collectstatic', '--noinput')} > /dev/null") do |ok, status| + if !ok + abort "collectstatic failed!" + end + end end [:lms, :cms].each do |system| # Per environment tasks environments(system).each do |env| + # This task wraps the one above, since we need the system and + # env arguments to be passed to all dependent tasks. desc "Compile coffeescript and sass, and then run collectstatic in the specified environment" - task "#{system}:gather_assets:#{env}" => :assets do - sh("#{django_admin(system, env, 'collectstatic', '--noinput')} > /dev/null") do |ok, status| - if !ok - abort "collectstatic failed!" - end - end + task "#{system}:gather_assets:#{env}" do + Rake::Task[:gather_assets].invoke(system, env) end end end diff --git a/rakefiles/deprecated.rake b/rakefiles/deprecated.rake new file mode 100644 index 0000000000..00c1987bd5 --- /dev/null +++ b/rakefiles/deprecated.rake @@ -0,0 +1,23 @@ + +require 'colorize' + +def deprecated(deprecated, deprecated_by) + task deprecated do + puts("Task #{deprecated} has been deprecated. Use #{deprecated_by} instead. Waiting 5 seconds...".red) + sleep(5) + Rake::Task[deprecated_by].invoke + end +end + +[:lms, :cms].each do |system| + deprecated("browse_jasmine_#{system}", "jasmine:#{system}:browser") + deprecated("phantomjs_jasmine_#{system}", "jasmine:#{system}:phantomjs") +end + +Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| + deprecated("browse_jasmine_#{lib}", "jasmine:#{lib}:browser") + deprecated("phantomjs_jasmine_#{lib}", "jasmine:#{lib}:phantomjs") +end + +deprecated("browse_jasmine_discussion", "jasmine:common/static/coffee:browser") +deprecated("phantomjs_jasmine_discussion", "jasmine:common/static/coffee:phantomjs") \ No newline at end of file diff --git a/rakefiles/django.rake b/rakefiles/django.rake index 8b42192130..b1adf24050 100644 --- a/rakefiles/django.rake +++ b/rakefiles/django.rake @@ -15,14 +15,22 @@ task :fastlms do sh("#{django_admin} runserver --traceback --settings=lms.envs.dev --pythonpath=.") end +# Start :system locally with the specified :env and :options. +# +# This task should be invoked via the wrapper below, so we don't +# include a description to keep it from showing up in rake -T. +task :runserver, [:system, :env, :options] => [:install_prereqs, 'assets:_watch', :predjango] do |t, args| + sh(django_admin(args.system, args.env, 'runserver', args.options)) +end + [:lms, :cms].each do |system| desc <<-desc Start the #{system} locally with the specified environment (defaults to dev). Other useful environments are devplus (for dev testing with a real local database) desc - task system, [:env, :options] => [:install_prereqs, 'assets:_watch', :predjango] do |t, args| + task system, [:env, :options] do |t, args| args.with_defaults(:env => 'dev', :options => default_options[system]) - sh(django_admin(system, args.env, 'runserver', args.options)) + Rake::Task[:runserver].invoke(system, args.env, args.options) end desc "Start #{system} Celery worker" diff --git a/rakefiles/helpers.rb b/rakefiles/helpers.rb index f344aa2042..4b10bef709 100644 --- a/rakefiles/helpers.rb +++ b/rakefiles/helpers.rb @@ -1,8 +1,12 @@ require 'digest/md5' +def find_executable(exec) + path = %x(which #{exec}).strip + $?.exitstatus == 0 ? path : nil +end def select_executable(*cmds) - cmds.find_all{ |cmd| system("which #{cmd} > /dev/null 2>&1") }[0] || fail("No executables found from #{cmds.join(', ')}") + cmds.find_all{ |cmd| !find_executable(cmd).nil? }[0] || fail("No executables found from #{cmds.join(', ')}") end def django_admin(system, env, command, *args) @@ -85,3 +89,31 @@ def environments(system) env_file.gsub("#{system}/envs/", '').gsub(/\.py/, '').gsub('/', '.') end end + +$failed_tests = 0 + +# Run sh on args. If TESTS_FAIL_FAST is set, then stop on the first shell failure. +# Otherwise, a final task will be added that will fail if any tests have failed +def test_sh(*args) + sh(*args) do |ok, res| + if ok + return + end + + if ENV['TESTS_FAIL_FAST'] + fail("Test failed!") + else + $failed_tests += 1 + end + end +end + +# Add a task after all other tasks that fails if any tests have failed +if !ENV['TESTS_FAIL_FAST'] + task :fail_tests do + fail("#{$failed_tests} tests failed!") if $failed_tests > 0 + end + + Rake.application.top_level_tasks << :fail_tests +end + diff --git a/rakefiles/jasmine.rake b/rakefiles/jasmine.rake index 4182bef9e2..ab3209c9ec 100644 --- a/rakefiles/jasmine.rake +++ b/rakefiles/jasmine.rake @@ -3,6 +3,11 @@ require 'erb' require 'launchy' require 'net/http' +PHANTOMJS_PATH = find_executable(ENV['PHANTOMJS_PATH'] || 'phantomjs') +PREFERRED_METHOD = PHANTOMJS_PATH.nil? ? 'browser' : 'phantomjs' +if PHANTOMJS_PATH.nil? + puts("phantomjs not found on path. Set $PHANTOMJS_PATH. Using browser for jasmine tests".blue) +end def django_for_jasmine(system, django_reload) if !django_reload @@ -35,18 +40,6 @@ def django_for_jasmine(system, django_reload) end def template_jasmine_runner(lib) - case lib - when /common\/lib\/.+/ - coffee_files = Dir["#{lib}/**/js/**/*.coffee", "common/static/coffee/src/**/*.coffee"] - when /common\/static\/coffee/ - coffee_files = Dir["#{lib}/**/*.coffee"] - else - puts('I do not know how to run jasmine tests for #{lib}') - exit - end - if !coffee_files.empty? - sh("node_modules/.bin/coffee -c #{coffee_files.join(' ')}") - end phantom_jasmine_path = File.expand_path("node_modules/phantom-jasmine") jasmine_reporters_path = File.expand_path("node_modules/jasmine-reporters") common_js_root = File.expand_path("common/static/js") @@ -54,8 +47,8 @@ def template_jasmine_runner(lib) # Get arrays of spec and source files, ordered by how deep they are nested below the library # (and then alphabetically) and expanded from a relative to an absolute path - spec_glob = File.join("#{lib}", "**", "spec", "**", "*.js") - src_glob = File.join("#{lib}", "**", "src", "**", "*.js") + spec_glob = File.join(lib, "**", "spec", "**", "*.js") + src_glob = File.join(lib, "**", "src", "**", "*.js") js_specs = Dir[spec_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} js_source = Dir[src_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} @@ -68,52 +61,90 @@ def template_jasmine_runner(lib) yield File.expand_path(template_output) end -def run_phantom_js(url) - phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' - sh("#{phantomjs} node_modules/jasmine-reporters/test/phantomjs-testrunner.js #{url}") +def jasmine_browser(url, wait=10) + # Jitter starting the browser so that the tests don't all try and + # start the browser simultaneously + sleep(rand(3)) + sh("python -m webbrowser -t '#{url}'") + sleep(wait) end +def jasmine_phantomjs(url) + fail("phantomjs not found. Add it to your path, or set $PHANTOMJS_PATH") if PHANTOMJS_PATH.nil? + test_sh("#{PHANTOMJS_PATH} node_modules/jasmine-reporters/test/phantomjs-testrunner.js #{url}") +end + +# Wrapper tasks for the real browse_jasmine and phantomjs_jasmine +# tasks above. These have a nicer UI since there's no arg passing. [:lms, :cms].each do |system| - desc "Open jasmine tests for #{system} in your default browser" - task "browse_jasmine_#{system}" => :assets do - django_for_jasmine(system, true) do |jasmine_url| - Launchy.open(jasmine_url) - puts "Press ENTER to terminate".red - $stdin.gets - end - end + namespace :jasmine do + namespace system do + desc "Open jasmine tests for #{system} in your default browser" + task :browser do + Rake::Task[:assets].invoke(system, 'jasmine') + django_for_jasmine(system, true) do |jasmine_url| + jasmine_browser(jasmine_url) + end + end - desc "Use phantomjs to run jasmine tests for #{system} from the console" - task "phantomjs_jasmine_#{system}" => :assets do - django_for_jasmine(system, false) do |jasmine_url| - run_phantom_js(jasmine_url) + desc "Use phantomjs to run jasmine tests for #{system} from the console" + task :phantomjs do + Rake::Task[:assets].invoke(system, 'jasmine') + phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' + django_for_jasmine(system, false) do |jasmine_url| + jasmine_phantomjs(jasmine_url) + end + end end + + desc "Run jasmine tests for #{system} using #{PREFERRED_METHOD}" + task system => "jasmine:#{system}:#{PREFERRED_METHOD}" + + task :phantomjs => "jasmine:#{system}:phantomjs" + multitask :browser => "jasmine:#{system}:browser" end end -STATIC_JASMINE_TESTS = Dir["common/lib/*"].select{|lib| File.directory?(lib)} -STATIC_JASMINE_TESTS << 'common/static/coffee' +static_js_dirs = Dir["common/lib/*"].select{|lib| File.directory?(lib)} +static_js_dirs << 'common/static/coffee' +static_js_dirs.select!{|lib| !Dir["#{lib}/**/spec"].empty?} -STATIC_JASMINE_TESTS.each do |lib| - desc "Open jasmine tests for #{lib} in your default browser" - task "browse_jasmine_#{lib}" do - template_jasmine_runner(lib) do |f| - sh("python -m webbrowser -t 'file://#{f}'") - puts "Press ENTER to terminate".red - $stdin.gets - end - end +static_js_dirs.each do |dir| + namespace :jasmine do + namespace dir do + desc "Open jasmine tests for #{dir} in your default browser" + task :browser do + # We need to use either CMS or LMS to preprocess files. Use LMS by default + Rake::Task['assets:coffee'].invoke('lms', 'jasmine') + template_jasmine_runner(dir) do |f| + jasmine_browser("file://#{f}") + end + end - desc "Use phantomjs to run jasmine tests for #{lib} from the console" - task "phantomjs_jasmine_#{lib}" do - template_jasmine_runner(lib) do |f| - run_phantom_js(f) + desc "Use phantomjs to run jasmine tests for #{dir} from the console" + task :phantomjs do + # We need to use either CMS or LMS to preprocess files. Use LMS by default + Rake::Task[:assets].invoke('lms', 'jasmine') + template_jasmine_runner(dir) do |f| + jasmine_phantomjs(f) + end + end end + + desc "Run jasmine tests for #{dir} using #{PREFERRED_METHOD}" + task dir => "jasmine:#{dir}:#{PREFERRED_METHOD}" + + task :phantomjs => "jasmine:#{dir}:phantomjs" + multitask :browser => "jasmine:#{dir}:browser" end end -desc "Open jasmine tests for discussion in your default browser" -task "browse_jasmine_discussion" => "browse_jasmine_common/static/coffee" +desc "Run all jasmine tests using #{PREFERRED_METHOD}" +task :jasmine => "jasmine:#{PREFERRED_METHOD}" -desc "Use phantomjs to run jasmine tests for discussion from the console" -task "phantomjs_jasmine_discussion" => "phantomjs_jasmine_common/static/coffee" +['phantomjs', 'browser'].each do |method| + desc "Run all jasmine tests using #{method}" + task "jasmine:#{method}" +end + +task :test => :jasmine diff --git a/rakefiles/tests.rake b/rakefiles/tests.rake index 448a482f04..b4754c2c3c 100644 --- a/rakefiles/tests.rake +++ b/rakefiles/tests.rake @@ -1,9 +1,6 @@ - # Set up the clean and clobber tasks CLOBBER.include(REPORT_DIR, 'test_root/*_repo', 'test_root/staticfiles') -$failed_tests = 0 - def run_under_coverage(cmd, root) cmd0, cmd_rest = cmd.split(" ", 2) # We use "python -m coverage" so that the proper python will run the importable coverage @@ -17,12 +14,7 @@ def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) dirs = Dir["common/djangoapps/*"] + Dir["#{system}/djangoapps/*"] test_id = dirs.join(' ') if test_id.nil? or test_id == '' cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', test_id) - sh(run_under_coverage(cmd, system)) do |ok, res| - if !ok and stop_on_failure - abort "Test failed!" - end - $failed_tests += 1 unless ok - end + test_sh(run_under_coverage(cmd, system)) end def run_acceptance_tests(system, report_dir, harvest_args) @@ -38,7 +30,7 @@ def run_acceptance_tests(system, report_dir, harvest_args) end sh(django_admin(system, 'acceptance', 'syncdb', '--noinput')) sh(django_admin(system, 'acceptance', 'migrate', '--noinput')) - sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--tag -skip', harvest_args)) + test_sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--tag -skip', harvest_args)) end @@ -55,13 +47,13 @@ TEST_TASK_DIRS = [] # Per System tasks desc "Run all django tests on our djangoapps for the #{system}" - task "test_#{system}", [:test_id, :stop_on_failure] => ["clean_test_files", :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] + task "test_#{system}", [:test_id] => ["clean_test_files", :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] # Have a way to run the tests without running collectstatic -- useful when debugging without # messing with static files. - task "fasttest_#{system}", [:test_id, :stop_on_failure] => [report_dir, :install_prereqs, :predjango] do |t, args| - args.with_defaults(:stop_on_failure => 'true', :test_id => nil) - run_tests(system, report_dir, args.test_id, args.stop_on_failure) + task "fasttest_#{system}", [:test_id] => [report_dir, :install_prereqs, :predjango] do |t, args| + args.with_defaults(:test_id => nil) + run_tests(system, report_dir, args.test_id) end # Run acceptance tests @@ -81,24 +73,24 @@ TEST_TASK_DIRS = [] end Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| - task_name = "test_#{lib}" report_dir = report_dir_path(lib) desc "Run tests for common lib #{lib}" - task task_name => report_dir do + task "test_#{lib}" => ["clean_test_files", report_dir] do ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") cmd = "nosetests #{lib}" - sh(run_under_coverage(cmd, lib)) do |ok, res| - $failed_tests += 1 unless ok - end + test_sh(run_under_coverage(cmd, lib)) end TEST_TASK_DIRS << lib - desc "Run tests for common lib #{lib} (without coverage)" - task "fasttest_#{lib}" do - sh("nosetests #{lib}") - end + # There used to be a fasttest_#{lib} command that ran without coverage. + # However, this is an inconsistent usage of "fast": + # When running tests for lms and cms, "fast" means skipping + # staticfiles collection, but still running under coverage. + # We keep the fasttest_#{lib} command for backwards compatibility, + # but make it an alias to the normal test command. + task "fasttest_#{lib}" => "test_#{lib}" end task :report_dirs @@ -107,42 +99,41 @@ TEST_TASK_DIRS.each do |dir| report_dir = report_dir_path(dir) directory report_dir task :report_dirs => [REPORT_DIR, report_dir] + task :test => "test_#{dir}" end -task :test do +desc "Run all tests" +task :test + +desc "Build the html, xml, and diff coverage reports" +task :coverage => :report_dirs do + + found_coverage_info = false + TEST_TASK_DIRS.each do |dir| - Rake::Task["test_#{dir}"].invoke(nil, false) + report_dir = report_dir_path(dir) + + if !File.file?("#{report_dir}/.coverage") + next + else + found_coverage_info = true + end + + # Generate the coverage.py HTML report + sh("coverage html --rcfile=#{dir}/.coveragerc") + + # Generate the coverage.py XML report + sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc") + + # Generate the diff coverage HTML report, based on the XML report + sh("diff-cover #{report_dir}/coverage.xml --html-report #{report_dir}/diff_cover.html") + + # Print the diff coverage report to the console + sh("diff-cover #{report_dir}/coverage.xml") + puts "\n" end - if $failed_tests > 0 - abort "Tests failed!" - end -end - -namespace :coverage do - desc "Build the html coverage reports" - task :html => :report_dirs do - TEST_TASK_DIRS.each do |dir| - report_dir = report_dir_path(dir) - - if !File.file?("#{report_dir}/.coverage") - next - end - - sh("coverage html --rcfile=#{dir}/.coveragerc") - end - end - - desc "Build the xml coverage reports" - task :xml => :report_dirs do - TEST_TASK_DIRS.each do |dir| - report_dir = report_dir_path(dir) - - if !File.file?("#{report_dir}/.coverage") - next - end - # Why doesn't the rcfile control the xml output file properly?? - sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc") - end + if not found_coverage_info + puts "No coverage info found. Run `rake test` before running `rake coverage`." end end diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index d801f46c8e..d5f05083c8 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -1 +1,3 @@ numpy==1.6.2 +networkx==1.7 +sympy==0.7.1 \ No newline at end of file diff --git a/requirements/edx-sandbox/local.txt b/requirements/edx-sandbox/local.txt index ba24805057..c21a50338a 100644 --- a/requirements/edx-sandbox/local.txt +++ b/requirements/edx-sandbox/local.txt @@ -4,3 +4,4 @@ common/lib/calc common/lib/chem common/lib/sandbox-packages +common/lib/symmath diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 88d93be05c..0db55bacb2 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -32,7 +32,7 @@ nltk==2.0.4 paramiko==1.9.0 path.py==3.0.1 Pillow==1.7.8 -pip +pip>=1.3 polib==1.0.3 pygments==1.5 pygraphviz==1.1 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index b1aef0a108..8f3d4594ac 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -9,4 +9,5 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@2144a25d#egg=XBlock --e git+https://github.com/edx/codejail.git@5fb5fa0#egg=codejail +-e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail +-e git+https://github.com/edx/diff-cover.git@v0.1.1#egg=diff_cover diff --git a/requirements/edx/local.txt b/requirements/edx/local.txt index a72f1f6dea..f5ba60e21b 100644 --- a/requirements/edx/local.txt +++ b/requirements/edx/local.txt @@ -2,5 +2,6 @@ -e common/lib/calc -e common/lib/capa -e common/lib/chem +-e common/lib/symmath -e common/lib/xmodule -e . diff --git a/requirements/system/ubuntu/apt-packages.txt b/requirements/system/ubuntu/apt-packages.txt index c61c658111..5dc47157f6 100644 --- a/requirements/system/ubuntu/apt-packages.txt +++ b/requirements/system/ubuntu/apt-packages.txt @@ -30,7 +30,6 @@ libreadline6-dev mongodb nodejs coffeescript -mysql -libmysqlclient-dev +mysql-client virtualenvwrapper libgeos-ruby1.8 diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh index 520ce05b5c..ede86b123a 100755 --- a/scripts/create-dev-env.sh +++ b/scripts/create-dev-env.sh @@ -207,7 +207,8 @@ case `uname -s` in distro=`lsb_release -cs` case $distro in wheezy|jessie|maya|olivia|nadia|precise|quantal) - warning "Debian support is not fully debugged. Assuming you have standard + warning " + Debian support is not fully debugged. Assuming you have standard development packages already working like scipy rvm, the installation should go fine, but this is still a work in progress. @@ -219,7 +220,8 @@ case `uname -s` in read dummy sudo apt-get install git ;; squeeze|lisa|katya|oneiric|natty|raring) - warning "It seems like you're using $distro which has been deprecated. + warning " + It seems like you're using $distro which has been deprecated. While we don't technically support this release, the install script will probably still work. @@ -310,13 +312,13 @@ case `uname -s` in curl -sL get.rvm.io | bash -s -- --version 1.15.7 ;; - squeeze|wheezy|jessie|maya|lisa|olivia|nadia|natty|oneiric|precise|quantal|raring) + [Ll]inux) warning "Setting up rvm on linux. This is a known pain point. If the script fails here refer to the following stack overflow question: http://stackoverflow.com/questions/9056008/installed-ruby-1-9-3-with-rvm-but-command-line-doesnt-show-ruby-v/9056395#9056395" sudo apt-get --purge remove ruby-rvm sudo rm -rf /usr/share/ruby-rvm /etc/rvmrc /etc/profile.d/rvm.sh - curl -sL https://get.rvm.io | bash -s stable --ruby --autolibs=enable --autodotfiles + curl -sL https://get.rvm.io | bash -s stable --ruby --autolibs=enable --auto-dotfiles ;; esac @@ -392,7 +394,7 @@ if [[ `type -t mkvirtualenv` != "function" ]]; then source `which virtualenvwrapper.sh` ;; - squeeze|wheezy|jessie|maya|lisa|olivia|nadia|natty|oneiric|precise|quantal|raring) + [Ll]inux) if [[ -f "/etc/bash_completion.d/virtualenvwrapper" ]]; then source /etc/bash_completion.d/virtualenvwrapper else @@ -483,7 +485,7 @@ pip install -r $BASE/edx-platform/requirements/edx/pre.txt output "Installing edX requirements" # Install prereqs cd $BASE/edx-platform -rvm use $RUBY_VER +rvm use "$RUBY_VER@edx-platform" rake install_prereqs # Final dependecy @@ -499,7 +501,7 @@ mkdir "$BASE/data" || true rake django-admin[syncdb] rake django-admin[migrate] -rake django-admin[update-templates] +rake cms:update_templates # Configure Git output "Fixing your git default settings" diff --git a/scripts/release-email-list.sh b/scripts/release-email-list.sh new file mode 100755 index 0000000000..92f7a9aef4 --- /dev/null +++ b/scripts/release-email-list.sh @@ -0,0 +1,31 @@ +#! /bin/bash + +LOG_SPEC="$1..$2" +LOG_CMD="git --no-pager log $LOG_SPEC" + +RESPONSIBLE=$(sort -u <($LOG_CMD --format='tformat:%ae' && $LOG_CMD --format='tformat:%ce')) + +echo -n 'To: ' +echo ${RESPONSIBLE} | sed "s/ /, /g" +echo + +echo "You've made changes that are about to be released. All of the commits +that you either authored or committed are listed below. Please verify them on +\$ENVIRONMENT" +echo + +for EMAIL in $RESPONSIBLE; do + AUTHORED_BY="$LOG_CMD --author=<${EMAIL}>" + COMMITTED_BY="$LOG_CMD --committer=<${EMAIL}>" + COMMITTED_NOT_AUTHORED="$COMMITTED_BY $($AUTHORED_BY --format='tformat:^%h')" + + echo $EMAIL "authored the following commits:" + $AUTHORED_BY --format='tformat: %s - https://github.com/edx/edx-platform/commit/%h' + echo + + if [[ $($COMMITTED_NOT_AUTHORED) != "" ]]; then + echo $EMAIL "committed but didn't author the following commits:" + $COMMITTED_NOT_AUTHORED --format='tformat: %s - https://github.com/edx/edx-platform/commit/%h' + echo + fi +done \ No newline at end of file
    ${hname}${hname | h}
    ${value}${value | h}