diff --git a/.gitignore b/.gitignore index d01baf055a..69bc47afdd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,14 @@ *.swp *.orig *.DS_Store +*.mo :2e_* :2e# .AppleDouble database.sqlite -private-requirements.txt +requirements/private.txt +lms/envs/private.py +cms/envs/private.py courseware/static/js/mathjax/* flushdb.sh build @@ -22,7 +25,11 @@ reports/ *.egg-info Gemfile.lock .env/ +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 @@ -33,3 +40,7 @@ chromedriver.log /nbproject ghostdriver.log node_modules +.pip_download_cache/ +.prereqs_cache +autodeploy.properties +.ws_migrations_complete diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 253bae3686..0000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "common/test/phantom-jasmine"] - path = common/test/phantom-jasmine - url = https://github.com/jcarver989/phantom-jasmine.git \ No newline at end of file diff --git a/.reviewboardrc b/.reviewboardrc new file mode 100644 index 0000000000..b79235a4a4 --- /dev/null +++ b/.reviewboardrc @@ -0,0 +1,2 @@ +REVIEWBOARD_URL = "https://rbcommons.com/s/edx/" +GUESS_FIELDS = True diff --git a/.ruby-gemset b/.ruby-gemset index 93a8706d3e..77266c35f0 100644 --- a/.ruby-gemset +++ b/.ruby-gemset @@ -1 +1 @@ -mitx +edx-platform diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000000..540c4732af --- /dev/null +++ b/.tx/config @@ -0,0 +1,26 @@ +[main] +host = https://www.transifex.com + +[edx-studio.django-partial] +file_filter = conf/locale//LC_MESSAGES/django-partial.po +source_file = conf/locale/en/LC_MESSAGES/django-partial.po +source_lang = en +type = PO + +[edx-studio.djangojs] +file_filter = conf/locale//LC_MESSAGES/djangojs.po +source_file = conf/locale/en/LC_MESSAGES/djangojs.po +source_lang = en +type = PO + +[edx-studio.mako] +file_filter = conf/locale//LC_MESSAGES/mako.po +source_file = conf/locale/en/LC_MESSAGES/mako.po +source_lang = en +type = PO + +[edx-studio.messages] +file_filter = conf/locale//LC_MESSAGES/messages.po +source_file = conf/locale/en/LC_MESSAGES/messages.po +source_lang = en +type = PO diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000..154b0c9b98 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,77 @@ +Piotr Mitros +Kyle Fiedler +Ernie Park +Bridger Maxwell +Lyla Fischer +David Ormsbee +Chris Terman +Reda Lemeden +Anant Agarwal +Jean-Michel Claus +Calen Pennington +JM Van Thong +Prem Sichanugrist +Isaac Chuang +Galen Frechette +Edward Loveall +Matt Jankowski +John Jarvis +Victor Shnayder +Matthew Mongeau +Tony Kim +Arjun Singh +John Hess +Carlos Andrés Rocha +Mike Chen +Rocky Duan +Sidhanth Rao +Brittany Cheng +Dhaval Adjodah +Tom Giannattasio +Ibrahim Awwal +Sarina Canelake +Mark L. Chang +Dean Dieker +Tommy MacWilliam +Nate Hardison +Chris Dodge +Kevin Chugh +Ned Batchelder +Alexander Kryklia +Vik Paruchuri +Louis Sobel +Brian Wilson +Ashley Penney +Don Mitchell +Aaron Culich +Brian Talbot +Jay Zoldak +Valera Rozuvan +Diana Huang +Marco Morales +Christina Roberts +Robert Chirwa +Ed Zarecor +Deena Wang +Jean Manuel-Nater +Emily Zhang <1800.ehz.hang@gmail.com> +Jennifer Akana +Peter Baratta +Julian Arni +Arthur Barrett +Vasyl Nakvasiuk +Will Daly +James Tauber +Greg Price +Joe Blaylock +Sef Kloninger +Anto Stupak +David Adams +Steve Strassmann +Giulio Gratta +David Baumgold +Jason Bau +Frances Botsford +Jonah Stanley +Slater Victoroff +Peter Fogg diff --git a/LICENSE.TXT b/LICENSE similarity index 100% rename from LICENSE.TXT rename to LICENSE diff --git a/README.md b/README.md index ec17d7c9a4..3a6236ea70 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ -This is edX, a platform for online course delivery. The project is primarily -written in [Python](http://python.org/), using the -[Django](https://www.djangoproject.com/) framework. We also use some -[Ruby](http://www.ruby-lang.org/) and some [NodeJS](http://nodejs.org/). +This is the main edX platform which consists of LMS and Studio. + +See [code.edx.org](http://code.edx.org/) for other parts of the edX code base. Installation ============ -The installation process is a bit messy at the moment. Here's a high-level -overview of what you should do to get started. -**TLDR:** There is a `create-dev-env.sh` script that will attempt to set all -of this up for you. If you're in a hurry, run that script. Otherwise, I suggest -that you understand what the script is doing, and why, by reading this document. +There is a `scripts/create-dev-env.sh` that will attempt to set up a development +environment. + +If you want to better understand what the script is doing, keep reading. Directory Hierarchy ------------------- + This code assumes that it is checked out in a directory that has three sibling directories: `data` (used for XML course data), `db` (used to hold a [sqlite](https://sqlite.org/) database), and `log` (used to hold logs). If you @@ -77,11 +76,17 @@ environment), and Node has a library installer called Once you've got your languages and virtual environments set up, install the libraries like so: - $ pip install -r pre-requirements.txt - $ pip install -r requirements.txt + $ pip install -r requirements/edx/pre.txt + $ pip install -r requirements/edx/base.txt + $ pip install -r requirements/edx/post.txt $ bundle install $ npm install +You can also use [`rake`](http://rake.rubyforge.org/) to get all of the prerequisites (or to update) +them if they've changed + + $ rake install_prereqs + Other Dependencies ------------------ You'll also need to install [MongoDB](http://www.mongodb.org/), since our @@ -106,7 +111,7 @@ 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 @@ -137,12 +142,30 @@ Studio, visit `127.0.0.1:8001` in your web browser; to view the LMS, visit There's also an older version of the LMS that saves its information in XML files in the `data` directory, instead of in Mongo. To run this older version, run: -$ rake lms + $ rake lms -Further Documentation -===================== -Once you've got your project up and running, you can check out the `docs` -directory to see more documentation about how edX is structured. +License +------- +The code in this repository is licensed under version 3 of the AGPL unless +otherwise noted. +Please see ``LICENSE.txt`` for details. +How to Contribute +----------------- + +Contributions are very welcome. The easiest way is to fork this repo, and then +make a pull request from your fork. The first time you make a pull request, you +may be asked to sign a Contributor Agreement. + +Reporting Security Issues +------------------------- + +Please do not report security issues in public. Please email security@edx.org + +Mailing List and IRC Channel +---------------------------- + +You can discuss this code on the [edx-code Google Group](https://groups.google.com/forum/#!forum/edx-code) or in the +`edx-code` IRC channel on Freenode. diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index ca5b62e596..558294e890 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -11,7 +11,6 @@ Feature: Advanced (manual) course policy Given I am on the Advanced Course Settings page in Studio Then the settings are alphabetized - @skip-phantom 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 @@ -20,7 +19,6 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is unchanged - @skip-phantom 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 @@ -28,7 +26,6 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - @skip-phantom 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 @@ -36,7 +33,6 @@ Feature: Advanced (manual) course policy And I reload the page Then it is displayed as formatted - @skip-phantom 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 ea5b24b21f..eb00c06ba9 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -19,9 +19,7 @@ DISPLAY_NAME_VALUE = '"Robot Super Course"' ############### ACTIONS #################### @step('I select the Advanced Settings$') def i_select_advanced_settings(step): - expand_icon_css = 'li.nav-course-settings i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_course_settings() link_css = 'li.nav-course-settings-advanced a' world.css_click(link_css) @@ -44,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$') @@ -125,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/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature index ddf1adf263..3767144c99 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -10,8 +10,6 @@ Feature: Course checklists Then I can check and uncheck tasks in a checklist And They are correctly selected after I reload the page - @skip-phantom - @skip-firefox Scenario: A task can link to a location within Studio Given I have opened Checklists When I select a link to the course outline @@ -19,8 +17,6 @@ Feature: Course checklists And I press the browser back button Then I am brought back to the course outline in the correct state - @skip-phantom - @skip-firefox Scenario: A task can link to a location outside Studio Given I have opened Checklists When I select a link to help page diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index d433dbbf0d..9552d35036 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -2,7 +2,7 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true, assert_equal +from nose.tools import assert_true, assert_equal, assert_in from terrain.steps import reload_the_page from selenium.common.exceptions import StaleElementReferenceException @@ -10,9 +10,7 @@ from selenium.common.exceptions import StaleElementReferenceException ############### ACTIONS #################### @step('I select Checklists from the Tools menu$') def i_select_checklists(step): - expand_icon_css = 'li.nav-course-tools i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_tools() link_css = 'li.nav-course-tools-checklists a' world.css_click(link_css) @@ -63,7 +61,7 @@ def i_select_a_link_to_the_course_outline(step): @step('I am brought to the course outline page$') def i_am_brought_to_course_outline(step): - assert_equal('Course Outline', world.css_find('.outline .title-1')[0].text) + assert_in('Course Outline', world.css_find('.outline .page-header')[0].text) assert_equal(1, len(world.browser.windows)) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index afb38c3f9e..494192ad06 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -5,8 +5,6 @@ from lettuce import world, step from nose.tools import assert_true from nose.tools import assert_equal -from xmodule.modulestore.django import _MODULESTORES, modulestore -from xmodule.templates import update_templates from auth.authz import get_user_by_email from selenium.webdriver.common.keys import Keys @@ -50,31 +48,31 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') def i_have_opened_a_new_course(step): + open_new_course() + + +####### HELPER FUNCTIONS ############## +def open_new_course(): world.clear_courses() log_into_studio() create_a_course() -####### HELPER FUNCTIONS ############## def create_studio_user( uname='robot', email='robot+studio@edx.org', password='test', is_staff=False): - studio_user = world.UserFactory.build( + studio_user = world.UserFactory( username=uname, email=email, password=password, is_staff=is_staff) - studio_user.set_password(password) - studio_user.save() registration = world.RegistrationFactory(user=studio_user) registration.register(studio_user) registration.activate() - user_profile = world.UserProfileFactory(user=studio_user) - def fill_in_course_info( name='Robot Super Course', @@ -153,4 +151,21 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): world.css_fill(time_css, desired_time) e = world.css_find(time_css).first e._element.send_keys(Keys.TAB) - time.sleep(float(1)) + time.sleep(float(1)) + + +@step('I have created a Video component$') +def i_created_a_video_component(step): + world.create_component_instance( + step, '.large-video-icon', + '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 new file mode 100644 index 0000000000..43164f62be --- /dev/null +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -0,0 +1,92 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world +from nose.tools import assert_equal +from terrain.steps import reload_the_page + + +@world.absorb +def create_component_instance(step, component_button_css, instance_id, expected_css): + click_new_component_button(step, component_button_css) + click_component_from_menu(instance_id, expected_css) + + +@world.absorb +def click_new_component_button(step, component_button_css): + 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 + 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') + world.css_click('#settings-mode') + + +@world.absorb +def verify_setting_entry(setting, display_name, value, explicitly_set): + assert_equal(display_name, setting.find_by_css('.setting-label')[0].value) + assert_equal(value, setting.find_by_css('.setting-input')[0].value) + settingClearButton = setting.find_by_css('.setting-clear')[0] + assert_equal(explicitly_set, settingClearButton.has_class('active')) + assert_equal(not explicitly_set, settingClearButton.has_class('inactive')) + + +@world.absorb +def verify_all_setting_entries(expected_entries): + settings = world.browser.find_by_css('.wrapper-comp-setting') + assert_equal(len(expected_entries), len(settings)) + for (counter, setting) in enumerate(settings): + world.verify_setting_entry( + setting, expected_entries[counter][0], + expected_entries[counter][1], expected_entries[counter][2] + ) + + +@world.absorb +def save_component_and_reopen(step): + world.css_click("a.save-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save. + reload_the_page(step) + edit_component_and_select_settings() + + +@world.absorb +def cancel_component(step): + world.css_click("a.cancel-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes were not persisted. + reload_the_page(step) + + +@world.absorb +def revert_setting_entry(label): + get_setting_entry(label).find_by_css('.setting-clear')[0].click() + + +@world.absorb +def get_setting_entry(label): + settings = world.browser.find_by_css('.wrapper-comp-setting') + for setting in settings: + if setting.find_by_css('.setting-label')[0].value == label: + return setting + return None diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index fc9641cb46..e869bfe47a 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -1,20 +1,17 @@ Feature: Course Settings As a course author, I want to be able to configure my course settings. - @skip-phantom Scenario: User can set course dates Given I have opened a new course in Studio When I select Schedule and Details And I set course dates Then I see the set dates on refresh - @skip-phantom Scenario: User can clear previously set course dates (except start date) Given I have set course dates And I clear all the dates except start Then I see cleared dates on refresh - @skip-phantom Scenario: User cannot clear the course start date Given I have set course dates And I clear the course start date diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index d69266b7de..bd86fff9b7 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -25,9 +25,7 @@ DEFAULT_TIME = "00:00" ############### ACTIONS #################### @step('I select Schedule and Details$') def test_i_select_schedule_and_details(step): - expand_icon_css = 'li.nav-course-settings i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_course_settings() link_css = 'li.nav-course-settings-schedule a' world.css_click(link_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 5da7720945..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' @@ -62,4 +56,4 @@ def i_am_on_tab(step, tab_name): @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert world.css_has_text(link_css, '+ New Section') + assert world.css_has_text(link_css, 'New Section') diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature new file mode 100644 index 0000000000..8fb14c3205 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.feature @@ -0,0 +1,17 @@ +Feature: Discussion Component Editor + As a course author, I want to be able to create discussion components. + + Scenario: User can view metadata + Given I have created a Discussion Tag + And I edit and select Settings + Then I see three alphabetized settings and their expected values + + Scenario: User can modify display name + Given I have created a Discussion Tag + 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 new file mode 100644 index 0000000000..ae3da3c458 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -0,0 +1,30 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I have created a Discussion Tag$') +def i_created_discussion_tag(step): + world.create_component_instance( + step, '.large-discussion-icon', + 'i4x://edx/templates/discussion/Discussion_Tag', + '.xmodule_DiscussionModule' + ) + + +@step('I see three alphabetized settings and their expected values$') +def i_see_only_the_settings_and_values(step): + world.verify_all_setting_entries( + [ + ['Category', "Week 1", True], + ['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/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature new file mode 100644 index 0000000000..6cd455d681 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.feature @@ -0,0 +1,13 @@ +Feature: HTML Editor + As a course author, I want to be able to create HTML blocks. + + Scenario: User can view metadata + Given I have created a Blank HTML Page + And I edit and select Settings + Then I see only the HTML display name setting + + Scenario: User can modify display name + Given I have created a Blank HTML Page + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py new file mode 100644 index 0000000000..054c0ea642 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -0,0 +1,17 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I have created a Blank HTML Page$') +def i_created_blank_html_page(step): + world.create_component_instance( + step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page', + '.xmodule_HtmlModule' + ) + + +@step('I see only the HTML display name setting$') +def i_see_only_the_html_display_name(step): + world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]]) diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature new file mode 100644 index 0000000000..bde350d8a3 --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -0,0 +1,67 @@ +Feature: Problem Editor + As a course author, I want to be able to create problems and edit their settings. + + Scenario: User can view metadata + Given I have created a Blank Common Problem + And I edit and select Settings + Then I see five alphabetized settings and their expected values + And Edit High Level Source is not visible + + Scenario: User can modify String values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save + + Scenario: User can specify special characters in String values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can specify special characters in the display name + And my special characters and persisted on save + + Scenario: User can revert display name to unset + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can revert the display name to unset + And my display name is unset on save + + Scenario: User can select values in a Select + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can select Per Student for Randomization + And my change to randomization is persisted + And I can revert to the default value for randomization + + Scenario: User can modify float input values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can set the weight to "3.5" + And my change to weight is persisted + And I can revert to the default value of unset for weight + + Scenario: User cannot type letters in float number field + Given I have created a Blank Common Problem + And I edit and select Settings + Then if I set the weight to "abc", it remains unset + + Scenario: User cannot type decimal values 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 "2.34", it displays initially as "234", and is persisted as "234" + + 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 "0" + + Scenario: Settings changes are not saved on Cancel + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can set the weight to "3.5" + And I can modify the display name + Then If I press Cancel my changes are not persisted + + Scenario: Edit High Level source is available for LaTeX problem + Given I have created a LaTeX Problem + And I edit and select Settings + Then Edit High Level Source is visible diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py new file mode 100644 index 0000000000..5dfcf55046 --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -0,0 +1,187 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step +from nose.tools import assert_equal + +DISPLAY_NAME = "Display Name" +MAXIMUM_ATTEMPTS = "Maximum Attempts" +PROBLEM_WEIGHT = "Problem Weight" +RANDOMIZATION = 'Randomization' +SHOW_ANSWER = "Show Answer" + + +############### ACTIONS #################### +@step('I have created a Blank Common Problem$') +def i_created_blank_common_problem(step): + world.create_component_instance( + step, + '.large-problem-icon', + 'i4x://edx/templates/problem/Blank_Common_Problem', + '.xmodule_CapaModule' + ) + + +@step('I edit and select Settings$') +def i_edit_and_select_settings(step): + world.edit_component_and_select_settings() + + +@step('I see five alphabetized settings and their expected values$') +def i_see_five_settings_with_values(step): + world.verify_all_setting_entries( + [ + [DISPLAY_NAME, "Blank Common Problem", True], + [MAXIMUM_ATTEMPTS, "", False], + [PROBLEM_WEIGHT, "", False], + [RANDOMIZATION, "Never", True], + [SHOW_ANSWER, "Finished", True] + ]) + + +@step('I can modify the display name') +def i_can_modify_the_display_name(step): + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('modified') + verify_modified_display_name() + + +@step('my display name change is persisted on save') +def my_display_name_change_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name() + + +@step('I can specify special characters in the display name') +def i_can_modify_the_display_name_with_special_chars(step): + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &") + verify_modified_display_name_with_special_chars() + + +@step('my special characters and persisted on save') +def special_chars_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name_with_special_chars() + + +@step('I can revert the display name to unset') +def can_revert_display_name_to_unset(step): + world.revert_setting_entry(DISPLAY_NAME) + verify_unset_display_name() + + +@step('my display name is unset on save') +def my_display_name_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_unset_display_name() + + +@step('I can select Per Student for Randomization') +def i_can_select_per_student_for_randomization(step): + world.browser.select(RANDOMIZATION, "Per Student") + verify_modified_randomization() + + +@step('my change to randomization is persisted') +def my_change_to_randomization_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_randomization() + + +@step('I can revert to the default value for randomization') +def i_can_revert_to_default_for_randomization(step): + world.revert_setting_entry(RANDOMIZATION) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False) + + +@step('I can set the weight to "(.*)"?') +def i_can_set_weight(step, weight): + set_weight(weight) + verify_modified_weight() + + +@step('my change to weight is persisted') +def my_change_to_weight_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_weight() + + +@step('I can revert to the default value of unset for weight') +def i_can_revert_to_default_for_unset_weight(step): + world.revert_setting_entry(PROBLEM_WEIGHT) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the weight to "(.*)", it remains unset') +def set_the_weight_to_abc(step, bad_weight): + set_weight(bad_weight) + # We show the clear button immediately on type, hence the "True" here. + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", True) + world.save_component_and_reopen(step) + # But no change was actually ever sent to the model, so on reopen, explicitly_set is False + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"') +def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted): + world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True) + + +@step('Edit High Level Source is not visible') +def edit_high_level_source_not_visible(step): + verify_high_level_source(step, False) + + +@step('Edit High Level Source is visible') +def edit_high_level_source_visible(step): + verify_high_level_source(step, True) + + +@step('If I press Cancel my changes are not persisted') +def cancel_does_not_save_changes(step): + world.cancel_component(step) + step.given("I edit and select Settings") + step.given("I see five alphabetized settings and their expected values") + + +@step('I have created a LaTeX Problem') +def create_latex_problem(step): + world.click_new_component_button(step, '.large-problem-icon') + # Go to advanced tab (waiting for the tab to be visible) + world.css_find('#ui-id-2') + world.css_click('#ui-id-2') + world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') + + +def verify_high_level_source(step, visible): + assert_equal(visible, world.is_css_present('.launch-latex-compiler')) + world.cancel_component(step) + assert_equal(visible, world.is_css_present('.upload-button')) + + +def verify_modified_weight(): + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "3.5", True) + + +def verify_modified_randomization(): + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Per Student", True) + + +def verify_modified_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'modified', True) + + +def verify_modified_display_name_with_special_chars(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "updated ' \" &", True) + + +def verify_unset_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False) + + +def set_weight(weight): + world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight) diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 24cbeb3db9..80ccb6cc7a 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -3,7 +3,6 @@ Feature: Create Section As a course author I want to create and edit sections - @skip-phantom Scenario: Add a new section to a course Given I have opened a new course in Studio When I click the New Section link @@ -27,10 +26,9 @@ Feature: Create Section And I save a new section release date Then the section release date is updated - @skip-phantom 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 59c5a37b33..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): - css = '.edit-section-name' +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/signup.py b/cms/djangoapps/contentstore/features/signup.py index 6ca358183b..398f8d074d 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -18,10 +18,7 @@ def i_fill_in_the_registration_form(step): @step('I press the Create My Account button on the registration form$') def i_press_the_button_on_the_registration_form(step): submit_css = 'form#register_form button#submit' - # Workaround for click not working on ubuntu - # for some unknown reason. - e = world.css_find(submit_css) - e.type(' ') + world.css_click(submit_css) @step('I should see be on the studio home page$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index a0e0a48f9e..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 - @skip-phantom - 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 - @skip-phantom - 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 28285bf8a1..a11467e3f9 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -3,14 +3,12 @@ Feature: Create Subsection As a course author I want to create and edit subsections - @skip-phantom Scenario: Add a new subsection to a section Given I have opened a new course section in Studio When I click the New Subsection link And I enter the subsection name and click save Then I see my subsection on the Courseware page - @skip-phantom Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) Given I have opened a new course section in Studio When I click the New Subsection link @@ -27,7 +25,6 @@ Feature: Create Subsection And I reload the page Then I see it marked as Homework - @skip-phantom Scenario: Set a due date in a different year (bug #256) Given I have opened a new subsection in Studio And I have set a release date and due date in different years @@ -35,11 +32,10 @@ Feature: Create Subsection And I reload the page Then I see the correct dates - @skip-phantom 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/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index f9e5b52bb2..1134e53280 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -10,9 +10,7 @@ from nose.tools import assert_equal @step('I have opened a new course section in Studio$') def i_have_opened_a_new_course_section(step): - world.clear_courses() - log_into_studio() - create_a_course() + open_new_course() add_section() @@ -63,14 +61,6 @@ def test_have_set_dates_in_different_years(step): set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00') -@step('I see the correct dates$') -def i_see_the_correct_dates(step): - assert_equal('12/25/2011', world.css_find('input#start_date').first.value) - assert_equal('03:00', world.css_find('input#start_time').first.value) - assert_equal('01/02/2012', world.css_find('input#due_date').first.value) - assert_equal('04:00', world.css_find('input#due_time').first.value) - - @step('I mark it as Homework$') def i_mark_it_as_homework(step): world.css_click('a.menu-toggle') @@ -101,8 +91,20 @@ def the_subsection_does_not_exist(step): assert world.browser.is_element_not_present_by_css(css) +@step('I see the correct dates$') +def i_see_the_correct_dates(step): + assert_equal('12/25/2011', get_date('input#start_date')) + assert_equal('03:00', get_date('input#start_time')) + assert_equal('01/02/2012', get_date('input#due_date')) + assert_equal('04:00', get_date('input#due_time')) + + ############ HELPER METHODS ################### +def get_date(css): + return world.css_find(css).first.value.strip() + + def save_subsection_name(name): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature new file mode 100644 index 0000000000..4c2a460042 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -0,0 +1,13 @@ +Feature: Video Component Editor + As a course author, I want to be able to create video components. + + Scenario: User can view metadata + Given I have created a Video component + And I edit and select Settings + Then I see only the Video display name setting + + Scenario: User can modify display name + Given I have created a Video component + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py new file mode 100644 index 0000000000..27423575c3 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -0,0 +1,9 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I see only the video display name setting$') +def i_see_only_the_video_display_name(step): + world.verify_all_setting_entries([['Display Name', "default", True]]) diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature new file mode 100644 index 0000000000..07771c9d61 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video.feature @@ -0,0 +1,10 @@ +Feature: Video Component + As a course author, I want to be able to view my created videos in Studio. + + 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 diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py new file mode 100644 index 0000000000..7cbe8a2258 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video.py @@ -0,0 +1,18 @@ +#pylint: disable=C0111 + +from lettuce import world, step + +############### ACTIONS #################### + + +@step('when I view the video it does not have autoplay enabled') +def does_not_autoplay(step): + assert world.css_find('.video')[0]['data-autoplay'] == 'False' + 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')) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 844ba87a11..03449fc22f 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -34,6 +34,13 @@ from xmodule.course_module import CourseDescriptor from xmodule.seq_module import SequenceDescriptor 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') TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') @@ -45,7 +52,7 @@ class MongoCollectionFindWrapper(object): self.counter = 0 def find(self, query, *args, **kwargs): - self.counter = self.counter+1 + self.counter = self.counter + 1 return self.original(query, *args, **kwargs) @@ -73,8 +80,62 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) + 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 = component_types + + store.update_metadata(course.location, own_metadata(course)) + + # just pick one vertical + descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] + + 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.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(), 'common/test/data/', [test_course_name]) + import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): print "Checking ", descriptor.location.url() @@ -101,7 +162,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): Unfortunately, None = published for the revision field, so get_items() would return both draft and non-draft copies. ''' - store = modulestore() + store = modulestore('direct') draft_store = modulestore('draft') import_from_xml(store, 'common/test/data/', ['simple']) @@ -128,7 +189,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): module as 'own-metadata' when publishing. Also verifies the metadata inheritance is properly computed ''' - store = modulestore() + store = modulestore('direct') draft_store = modulestore('draft') import_from_xml(store, 'common/test/data/', ['simple']) @@ -186,7 +247,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(html_module.lms.graceperiod, new_graceperiod) def test_get_depth_with_drafts(self): - import_from_xml(modulestore(), 'common/test/data/', ['simple']) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) course = modulestore('draft').get_item( Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), @@ -210,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 @@ -220,10 +281,18 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): num_drafts = self._get_draft_counts(course) self.assertEqual(num_drafts, 1) - def test_static_tab_reordering(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - + def test_import_textbook_as_content_element(self): module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + self.assertGreater(len(course.textbooks), 0) + + def test_static_tab_reordering(self): + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) # reverse the ordering @@ -245,10 +314,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(reverse_tabs, course_tabs) def test_import_polls(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') - found = False + import_from_xml(module_store, 'common/test/data/', ['full']) items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None]) found = len(items) > 0 @@ -262,9 +329,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertGreater(err_cnt, 0) def test_delete(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - direct_store = modulestore('direct') + import_from_xml(direct_store, 'common/test/data/', ['full']) sequential = direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) @@ -293,14 +359,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # make sure the parent no longer points to the child object which was deleted self.assertFalse(sequential.location.url() in chapter.children) - def test_about_overrides(self): ''' This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html while there is a base definition in /about/effort.html ''' - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) self.assertEqual(effort.data, '6 hours') @@ -309,9 +375,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(effort.data, 'TBD') def test_remove_hide_progress_tab(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') course = module_store.get_item(source_location) @@ -326,14 +391,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 'display_name': 'Robot Super Course', } - import_from_xml(modulestore(), 'common/test/data/', ['full']) + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) resp = self.client.post(reverse('create_new_course'), course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') - module_store = modulestore('direct') content_store = contentstore() source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') @@ -348,19 +413,45 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'vertical', None])) self.assertGreater(len(clone_items), 0) for descriptor in items: - new_loc = descriptor.location._replace(org='MITx', course='999') + new_loc = descriptor.location.replace(org='MITx', course='999') print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) + 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) def test_delete_course(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + content_store = contentstore() location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') @@ -371,15 +462,15 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(len(items), 0) def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): - fs = OSFS(root_dir / 'test_export') - self.assertTrue(fs.exists(dirname)) + filesystem = OSFS(root_dir / 'test_export') + self.assertTrue(filesystem.exists(dirname)) query_loc = Location('i4x', location.org, location.course, category_name, None) items = modulestore.get_items(query_loc) for item in items: - fs = OSFS(root_dir / ('test_export/' + dirname)) - self.assertTrue(fs.exists(item.location.name + filename_suffix)) + filesystem = OSFS(root_dir / ('test_export/' + dirname)) + self.assertTrue(filesystem.exists(item.location.name + filename_suffix)) def test_export_course(self): module_store = modulestore('direct') @@ -411,7 +502,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # add private to list of children sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) - private_location_no_draft = private_vertical.location._replace(revision=None) + private_location_no_draft = private_vertical.location.replace(revision=None) module_store.update_children(sequential.location, sequential.children + [private_location_no_draft.url()]) @@ -435,21 +526,24 @@ 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 - fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') - self.assertTrue(fs.exists('grading_policy.json')) + filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') + self.assertTrue(filesystem.exists('grading_policy.json')) course = module_store.get_item(location) # compare what's on disk compared to what we have in our course - with fs.open('grading_policy.json', 'r') as grading_policy: + with filesystem.open('grading_policy.json', 'r') as grading_policy: on_disk = loads(grading_policy.read()) self.assertEqual(on_disk, course.grading_policy) - #check for policy.json - self.assertTrue(fs.exists('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 - with fs.open('policy.json', 'r') as course_policy: + with filesystem.open('policy.json', 'r') as course_policy: on_disk = loads(course_policy.read()) self.assertIn('course/6.002_Spring_2012', on_disk) self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course)) @@ -490,6 +584,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertTrue(getattr(test_private_vertical, 'is_draft', False)) + # make sure the textbook survived the export/import + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + self.assertGreater(len(course.textbooks), 0) + shutil.rmtree(root_dir) def test_course_handouts_rewrites(self): @@ -511,8 +610,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') def test_prefetch_children(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') wrapper = MongoCollectionFindWrapper(module_store.collection.find) @@ -598,6 +698,14 @@ class ContentStoreTest(ModuleStoreTestCase): data = parse_json(resp) self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + def test_create_course_check_forum_seeding(self): + """Test new course creation and verify forum seeding """ + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course')) + def test_create_course_duplicate_course(self): """Test new course creation - error path""" resp = self.client.post(reverse('create_new_course'), self.course_data) @@ -634,7 +742,7 @@ class ContentStoreTest(ModuleStoreTestCase): resp = self.client.get(reverse('index')) self.assertContains( resp, - '

My Courses

', + '

My Courses

', status_code=200, html=True ) @@ -724,7 +832,7 @@ class ContentStoreTest(ModuleStoreTestCase): Import and walk through some common URL endpoints. This just verifies non-500 and no other correct behavior, so it is not a deep test """ - import_from_xml(modulestore(), 'common/test/data/', ['simple']) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]) resp = self.client.get(reverse('course_index', kwargs={'org': loc.org, @@ -791,44 +899,46 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(200, resp.status_code) # go look at a subsection page - subsection_location = loc._replace(category='sequential', name='test_sequence') + subsection_location = loc.replace(category='sequential', name='test_sequence') resp = self.client.get(reverse('edit_subsection', kwargs={'location': subsection_location.url()})) self.assertEqual(200, resp.status_code) # go look at the Edit page - unit_location = loc._replace(category='vertical', name='test_vertical') + unit_location = loc.replace(category='vertical', name='test_vertical') resp = self.client.get(reverse('edit_unit', kwargs={'location': unit_location.url()})) self.assertEqual(200, resp.status_code) # delete a component - del_loc = loc._replace(category='html', name='test_html') + del_loc = loc.replace(category='html', name='test_html') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a unit - del_loc = loc._replace(category='vertical', name='test_vertical') + del_loc = loc.replace(category='vertical', name='test_vertical') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a unit - del_loc = loc._replace(category='sequential', name='test_sequence') + del_loc = loc.replace(category='sequential', name='test_sequence') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a chapter - del_loc = loc._replace(category='chapter', name='chapter_2') + del_loc = loc.replace(category='chapter', name='chapter_2') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) + def test_import_metadata_with_attempts_empty_string(self): - import_from_xml(modulestore(), 'common/test/data/', ['simple']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['simple']) + did_load_item = False try: module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) @@ -840,8 +950,9 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertTrue(did_load_item) def test_forum_id_generation(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + new_component_location = Location('i4x', 'edX', 'full', 'discussion', 'new_component') source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag') @@ -853,9 +964,8 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$') def test_update_modulestore_signal_did_fire(self): - - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) try: module_store.modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location']) @@ -879,9 +989,9 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertTrue(self.got_signal) def test_metadata_inheritance(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index c9f6b2053e..8c15b1ae95 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -17,7 +17,6 @@ from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.django import modulestore from xmodule.fields import Date @@ -55,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)) @@ -68,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 ") @@ -77,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) @@ -117,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) @@ -152,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) @@ -256,7 +259,7 @@ class CourseMetadataEditingTest(CourseTestCase): def setUp(self): CourseTestCase.setUp(self) # add in the full class too - import_from_xml(modulestore(), 'common/test/data/', ['full']) + import_from_xml(get_modulestore(self.course_location), 'common/test/data/', ['full']) self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]) def test_fetch_initial_fields(self): diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index 80d4f0bbc2..ae14555b32 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase): '''Go through each interface and ensure it works.''' # first get the update to force the creation url = reverse('course_info', - kwargs={'org': self.course_location.org, - 'course': self.course_location.course, - 'name': self.course_location.name}) + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'name': self.course_location.name}) self.client.get(url) init_content = ' \ No newline at end of file diff --git a/common/test/data/word_cloud/chapter/Staff.xml b/common/test/data/word_cloud/chapter/Staff.xml new file mode 100644 index 0000000000..e1d5216f6d --- /dev/null +++ b/common/test/data/word_cloud/chapter/Staff.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/test/data/word_cloud/course.xml b/common/test/data/word_cloud/course.xml new file mode 100644 index 0000000000..1b97a5a714 --- /dev/null +++ b/common/test/data/word_cloud/course.xml @@ -0,0 +1,2 @@ + + diff --git a/common/test/data/word_cloud/course/2013_Spring.xml b/common/test/data/word_cloud/course/2013_Spring.xml new file mode 100644 index 0000000000..cb6e7c1217 --- /dev/null +++ b/common/test/data/word_cloud/course/2013_Spring.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/common/test/data/word_cloud/creating_course.xml b/common/test/data/word_cloud/creating_course.xml new file mode 100644 index 0000000000..4c90f1c2ec --- /dev/null +++ b/common/test/data/word_cloud/creating_course.xml @@ -0,0 +1,8 @@ + diff --git a/common/test/data/word_cloud/info/2013_Spring/handouts.html b/common/test/data/word_cloud/info/2013_Spring/handouts.html new file mode 100644 index 0000000000..35f2c89474 --- /dev/null +++ b/common/test/data/word_cloud/info/2013_Spring/handouts.html @@ -0,0 +1,3 @@ +
    +
  1. A list of course handouts, or an empty file if there are none.
  2. +
diff --git a/common/test/data/word_cloud/info/2013_Spring/updates.html b/common/test/data/word_cloud/info/2013_Spring/updates.html new file mode 100644 index 0000000000..9744c1699d --- /dev/null +++ b/common/test/data/word_cloud/info/2013_Spring/updates.html @@ -0,0 +1,10 @@ + +
    + +
  1. December 9

    +
    +

    Announcement text

    +
    +
  2. + +
diff --git a/common/test/data/word_cloud/policies/2013_Spring/policy.json b/common/test/data/word_cloud/policies/2013_Spring/policy.json new file mode 100644 index 0000000000..e2a204815c --- /dev/null +++ b/common/test/data/word_cloud/policies/2013_Spring/policy.json @@ -0,0 +1,8 @@ +{ + "course/2013_Spring": { + "start": "2099-01-01T00:00", + "advertised_start" : "Spring 2013", + "display_name": "Justice" + } + +} diff --git a/common/test/data/word_cloud/roots/2013_Spring.xml b/common/test/data/word_cloud/roots/2013_Spring.xml new file mode 100644 index 0000000000..1b97a5a714 --- /dev/null +++ b/common/test/data/word_cloud/roots/2013_Spring.xml @@ -0,0 +1,2 @@ + + diff --git a/common/test/data/word_cloud/sequential/Problem_Demos.xml b/common/test/data/word_cloud/sequential/Problem_Demos.xml new file mode 100644 index 0000000000..21568128a4 --- /dev/null +++ b/common/test/data/word_cloud/sequential/Problem_Demos.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/common/test/data/word_cloud/static/README b/common/test/data/word_cloud/static/README new file mode 100644 index 0000000000..e22f378b5e --- /dev/null +++ b/common/test/data/word_cloud/static/README @@ -0,0 +1,5 @@ +Images, handouts, and other statically-served content should go ONLY +in this directory. + +Images for the front page should go in static/images. The frontpage +banner MUST be named course_image.jpg \ No newline at end of file diff --git a/common/test/data/word_cloud/word_cloud/cloud.xml b/common/test/data/word_cloud/word_cloud/cloud.xml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/test/phantom-jasmine b/common/test/phantom-jasmine deleted file mode 160000 index a54d435b55..0000000000 --- a/common/test/phantom-jasmine +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a54d435b5556650efbcdb0490e6c7928ac75238a diff --git a/conf/locale/config b/conf/locale/config index 2d01e1ea43..58f8da0513 100644 --- a/conf/locale/config +++ b/conf/locale/config @@ -1 +1,4 @@ -{"locales" : ["en"]} +{ + "locales" : ["en", "es"], + "dummy-locale" : "fr" +} diff --git a/conf/locale/en/LC_MESSAGES/messages.po b/conf/locale/en/LC_MESSAGES/messages.po index 1bb8bf6d7f..e5961753c5 100644 --- a/conf/locale/en/LC_MESSAGES/messages.po +++ b/conf/locale/en/LC_MESSAGES/messages.po @@ -1 +1,20 @@ +# edX translation file +# Copyright (C) 2013 edX +# This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. +# +msgid "" +msgstr "" +"Project-Id-Version: EdX Studio\n" +"Report-Msgid-Bugs-To: translation_team@edx.org\n" +"POT-Creation-Date: 2013-05-02 13:13-0400\n" +"PO-Revision-Date: 2013-05-02 13:27-0400\n" +"Last-Translator: \n" +"Language-Team: translation team \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" + # empty +msgid "This is a key string." +msgstr "" diff --git a/distribute-0.6.32.tar.gz b/distribute-0.6.32.tar.gz deleted file mode 100644 index 2438db60fa..0000000000 Binary files a/distribute-0.6.32.tar.gz and /dev/null differ diff --git a/distribute-0.6.34.tar.gz b/distribute-0.6.34.tar.gz deleted file mode 100644 index 4e91b3af62..0000000000 Binary files a/distribute-0.6.34.tar.gz and /dev/null differ diff --git a/doc/README b/doc/README index d40f5d988d..395fc07dc5 100644 --- a/doc/README +++ b/doc/README @@ -1,3 +1,3 @@ -This directory contains some high level documentation for the code. We should strive to keep it up-to-date, but don't take it as the absolute truth. +This directory contains some high level documentation for the code. -A good place to start is 'overview.md' +WARNING: much of this is out-of-date. It stil may be helpful, though. diff --git a/doc/development.md b/doc/development.md index a6a1de4ef7..c99e99f906 100644 --- a/doc/development.md +++ b/doc/development.md @@ -36,7 +36,7 @@ Check out the course data directories that you want to work with into the To create your development environment, run the shell script in the root of the repo: - create-dev-env.sh + scripts/create-dev-env.sh ## Starting development servers diff --git a/doc/overview.md b/doc/overview.md index f64d12920d..4d074dfaf3 100644 --- a/doc/overview.md +++ b/doc/overview.md @@ -1,4 +1,4 @@ -# Documentation for edX code (mitx repo) +# Documentation for edX code (edx-platform repo) This document explains the general structure of the edX platform, and defines some of the acronyms and terms you'll see flying around in the code. diff --git a/doc/public/course_data_formats/word_cloud/word_cloud.png b/doc/public/course_data_formats/word_cloud/word_cloud.png new file mode 100644 index 0000000000..07b7292b5e Binary files /dev/null and b/doc/public/course_data_formats/word_cloud/word_cloud.png differ diff --git a/doc/public/course_data_formats/word_cloud/word_cloud.rst b/doc/public/course_data_formats/word_cloud/word_cloud.rst new file mode 100644 index 0000000000..5c3d31e149 --- /dev/null +++ b/doc/public/course_data_formats/word_cloud/word_cloud.rst @@ -0,0 +1,59 @@ +********************************************** +Xml format of "Word Cloud" module [xmodule] +********************************************** + +.. module:: word_cloud + +Format description +================== + +The main tag of Word Cloud module input is: + +.. code-block:: xml + + + +The following attributes can be specified for this tag:: + + [display_name| AUTOGENERATE] – Display name of xmodule. When this attribute is not defined - display name autogenerate with some hash. + [num_inputs| 5] – Number of inputs. + [num_top_words| 250] – Number of max words, which will be displayed. + [display_student_percents| True] – Display usage percents for each word on the same line together with words. + +.. note:: + + Percent is shown always when mouse over the word in cloud. + +.. note:: + + Possible answer for boolean type attributes: + True – "True", "true", "T", "t", "1" + False – "False", "false", "F", "f", "0" + +.. note:: + + If you want to use the same word cloud (the same storage of words), you must use the same display_name value. + + +Code Example +============ + +Examples of word_cloud without all attributes (all attributes get by default) +----------------------------------------------------------------------------- + +.. code-block:: xml + + + +Examples of poll with all attributes +------------------------------------ + +.. code-block:: xml + + + +Screenshots +=========== + +.. image:: word_cloud.png + :width: 50% diff --git a/doc/public/index.rst b/doc/public/index.rst index ee681a822e..064b3ff443 100644 --- a/doc/public/index.rst +++ b/doc/public/index.rst @@ -26,6 +26,7 @@ Specific Problem Types course_data_formats/graphical_slider_tool/graphical_slider_tool.rst course_data_formats/poll_module/poll_module.rst course_data_formats/conditional_module/conditional_module.rst + course_data_formats/word_cloud/word_cloud.rst course_data_formats/custom_response.rst diff --git a/doc/testing.md b/doc/testing.md index 84175fee3d..b40ba30610 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -8,7 +8,7 @@ and acceptance tests. ### Unit Tests * Each test case should be concise: setup, execute, check, and teardown. -If you find yourself writing tests with many steps, consider refactoring +If you find yourself writing tests with many steps, consider refactoring the unit under tests into smaller units, and then testing those individually. * As a rule of thumb, your unit tests should cover every code branch. @@ -16,19 +16,19 @@ the unit under tests into smaller units, and then testing those individually. * Mock or patch external dependencies. We use [voidspace mock](http://www.voidspace.org.uk/python/mock/). -* We unit test Python code (using [unittest](http://docs.python.org/2/library/unittest.html)) and +* We unit test Python code (using [unittest](http://docs.python.org/2/library/unittest.html)) and Javascript (using [Jasmine](http://pivotal.github.io/jasmine/)) ### Integration Tests * Test several units at the same time. Note that you can still mock or patch dependencies -that are not under test! For example, you might test that -`LoncapaProblem`, `NumericalResponse`, and `CorrectMap` in the +that are not under test! For example, you might test that +`LoncapaProblem`, `NumericalResponse`, and `CorrectMap` in the `capa` package work together, while still mocking out template rendering. * Use integration tests to ensure that units are hooked up correctly. -You do not need to test every possible input--that's what unit -tests are for. Instead, focus on testing the "happy path" +You do not need to test every possible input--that's what unit +tests are for. Instead, focus on testing the "happy path" to verify that the components work together correctly. * Many of our tests use the [Django test client](https://docs.djangoproject.com/en/dev/topics/testing/overview/) to simulate @@ -43,8 +43,8 @@ these tests simulate user interactions through the browser using Overall, you want to write the tests that **maximize coverage** while **minimizing maintenance**. -In practice, this usually means investing heavily -in unit tests, which tend to be the most robust to changes in the code base. +In practice, this usually means investing heavily +in unit tests, which tend to be the most robust to changes in the code base. ![Test Pyramid](test_pyramid.png) @@ -53,13 +53,13 @@ and acceptance tests. Most of our tests are unit tests or integration tests. ## Test Locations -* Python unit and integration tests: Located in +* Python unit and integration tests: Located in subpackages called `tests`. -For example, the tests for the `capa` package are located in +For example, the tests for the `capa` package are located in `common/lib/capa/capa/tests`. * Javascript unit tests: Located in `spec` folders. For example, -`common/lib/xmodule/xmodule/js/spec` and `{cms,lms}/static/coffee/spec` +`common/lib/xmodule/xmodule/js/spec` and `{cms,lms}/static/coffee/spec` For consistency, you should use the same directory structure for implementation and test. For example, the test for `src/views/module.coffee` should be written in `spec/views/module_spec.coffee`. @@ -88,7 +88,7 @@ because the `capa` package handles problem XML. Before running tests, ensure that you have all the dependencies. You can install dependencies using: - pip install -r requirements.txt + rake install_prereqs ## Running Python Unit tests @@ -101,7 +101,7 @@ You can run tests using `rake` commands. For example, rake test -runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript). +runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript). You can also run the tests without `collectstatic`, which tends to be faster: @@ -115,14 +115,18 @@ xmodule can be tested independently, with this: rake test_common/lib/xmodule +other module level tests include + +* `rake test_common/lib/capa` +* `rake test_common/lib/calc` + To run a single django test class: - django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth + rake test_lms[courseware.tests.tests:testViewAuth] To run a single django test: - django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth.test_dark_launch - + rake test_lms[courseware.tests.tests:TestViewAuth.test_dark_launch] To run a single nose test file: @@ -137,21 +141,36 @@ Very handy: if you uncomment the `pdb=1` line in `setup.cfg`, it will drop you i ### Running Javascript Unit Tests -These commands start a development server with jasmine testing enabled, and launch your default browser -pointing to those tests +To run all of the javascript unit tests, use - rake browse_jasmine_{lms,cms} + rake jasmine -To run the tests headless, you must install [phantomjs](http://phantomjs.org/download.html), then run: +If the `phantomjs` binary is on the path, or the `PHANTOMJS_PATH` environment variable is +set to point to it, then the tests will be run headless. Otherwise, they will be run in +your default browser - rake phantomjs_jasmine_{lms,cms} + export PATH=/path/to/phantomjs:$PATH + rake jasmine # Runs headless -If the `phantomjs` binary is not on the path, set the `PHANTOMJS_PATH` environment variable to point to it +or - PHANTOMJS_PATH=/path/to/phantomjs rake phantomjs_jasmine_{lms,cms} + PHANTOMJS_PATH=/path/to/phantomjs rake jasmine # Runs headless -Once you have run the `rake` command, your browser should open to -to `http://localhost/_jasmine/`, which displays the test results. +or + + rake jasmine # Runs in browser + +You can also force a run using phantomjs or the browser using the commands + + rake jasmine:browser # Runs in browser + rake jasmine:phantomjs # Runs headless + +You can run tests for a specific subsystems as well + + rake jasmine:lms # Runs all lms javascript unit tests using the default method + rake jasmine:cms:browser # Runs all cms javascript unit tests in the browser + +Use `rake -T` to get a list of all available subsystems **Troubleshooting**: If you get an error message while running the `rake` task, try running `bundle install` to install the required ruby gems. @@ -161,36 +180,30 @@ try running `bundle install` to install the required ruby gems. We use [Lettuce](http://lettuce.it/) for acceptance testing. Most of our tests use [Splinter](http://splinter.cobrateam.info/) to simulate UI browser interactions. Splinter, in turn, -uses [Selenium](http://docs.seleniumhq.org/) to control the browser. +uses [Selenium](http://docs.seleniumhq.org/) to control the Chrome browser. -**Prerequisite**: You must have [ChromeDriver](https://code.google.com/p/selenium/wiki/ChromeDriver) -installed to run the tests in Chrome. +**Prerequisite**: You must have [ChromeDriver](https://code.google.com/p/selenium/wiki/ChromeDriver) +installed to run the tests in Chrome. The tests are confirmed to run +with Chrome (not Chromium) version 26.0.0.1410.63 with ChromeDriver +version r195636. -Before running the tests, you need to set up the test database: +To run all the acceptance tests: - rm ../db/test_mitx.db - rake django-admin[syncdb,lms,acceptance,--noinput] - rake django-admin[migrate,lms,acceptance,--noinput] - -To run the acceptance tests: - -1. Start the Django server locally using the settings in **acceptance.py**: - - rake lms[acceptance] - -2. In another shell, run the tests: - - django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/ + rake test_acceptance_lms + rake test_acceptance_cms To test only a specific feature: - django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/courseware/features/high-level-tabs.feature + rake test_acceptance_lms[lms/djangoapps/courseware/features/problems.feature] -**Troubleshooting**: If you get an error message that says something about harvest not being a command, you probably are missing a requirement. -Try running: +To start the debugger on failure, add the `--pdb` option: - pip install -r requirements.txt + rake test_acceptance_lms["lms/djangoapps/courseware/features/problems.feature --pdb"] +To run tests faster by not collecting static files, you can use +`rake fasttest_acceptance_lms` and `rake fasttest_acceptance_cms`. + +**Note**: The acceptance tests can *not* currently run in parallel. ## Viewing Test Coverage @@ -204,9 +217,10 @@ To view test coverage: 2. Generate reports: - rake coverage:html + rake coverage -3. HTML reports are located in the `reports` folder. +3. Reports are located in the `reports` folder. The command +generates HTML and XML (Cobertura format) reports. ## Testing using queue servers diff --git a/docs/source/xmodule.rst b/docs/source/xmodule.rst index 45caa82c30..d68ab779f6 100644 --- a/docs/source/xmodule.rst +++ b/docs/source/xmodule.rst @@ -165,6 +165,13 @@ Video :members: :show-inheritance: +Word Cloud +========== + +.. automodule:: xmodule.word_cloud_module + :members: + :show-inheritance: + X = diff --git a/fixtures/anonymize_fixtures.py b/fixtures/anonymize_fixtures.py deleted file mode 100755 index ba62652de5..0000000000 --- a/fixtures/anonymize_fixtures.py +++ /dev/null @@ -1,98 +0,0 @@ -#! /usr/bin/env python - -import sys -import json -import random -import copy -from collections import defaultdict -from argparse import ArgumentParser, FileType -from datetime import datetime - -def generate_user(user_number): - return { - "pk": user_number, - "model": "auth.user", - "fields": { - "status": "w", - "last_name": "Last", - "gold": 0, - "is_staff": False, - "user_permissions": [], - "interesting_tags": "", - "email_key": None, - "date_joined": "2012-04-26 11:36:39", - "first_name": "", - "email_isvalid": False, - "avatar_type": "n", - "website": "", - "is_superuser": False, - "date_of_birth": None, - "last_login": "2012-04-26 11:36:48", - "location": "", - "new_response_count": 0, - "email": "user{num}@example.com".format(num=user_number), - "username": "user{num}".format(num=user_number), - "is_active": True, - "consecutive_days_visit_count": 0, - "email_tag_filter_strategy": 1, - "groups": [], - "password": "sha1$90e6f$562a1d783a0c47ce06ebf96b8c58123a0671bbf0", - "silver": 0, - "bronze": 0, - "questions_per_page": 10, - "about": "", - "show_country": True, - "country": "", - "display_tag_filter_strategy": 0, - "seen_response_count": 0, - "real_name": "", - "ignored_tags": "", - "reputation": 1, - "gravatar": "366d981a10116969c568a18ee090f44c", - "last_seen": "2012-04-26 11:36:39" - } - } - - -def parse_args(args=sys.argv[1:]): - parser = ArgumentParser() - parser.add_argument('-d', '--data', type=FileType('r'), default=sys.stdin) - parser.add_argument('-o', '--output', type=FileType('w'), default=sys.stdout) - parser.add_argument('count', type=int) - return parser.parse_args(args) - - -def main(args=sys.argv[1:]): - args = parse_args(args) - - data = json.load(args.data) - unique_students = set(entry['fields']['student'] for entry in data) - if args.count > len(unique_students) * 0.1: - raise Exception("Can't be sufficiently anonymous selecting {count} of {unique} students".format( - count=args.count, unique=len(unique_students))) - - by_problems = defaultdict(list) - for entry in data: - by_problems[entry['fields']['module_id']].append(entry) - - out_data = [] - out_pk = 1 - for name, answers in by_problems.items(): - for student_id in xrange(args.count): - sample = random.choice(answers) - data = copy.deepcopy(sample) - data["fields"]["student"] = student_id + 1 - data["fields"]["created"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - data["fields"]["modified"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - data["pk"] = out_pk - out_pk += 1 - out_data.append(data) - - for student_id in xrange(args.count): - out_data.append(generate_user(student_id)) - - json.dump(out_data, args.output, indent=2) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/fixtures/pm.json b/fixtures/pm.json deleted file mode 100644 index 5ecb839093..0000000000 --- a/fixtures/pm.json +++ /dev/null @@ -1 +0,0 @@ -[{"pk": 1, "model": "user.userprofile", "fields": {"name": "pm", "language": "pm", "courseware": "course.xml", "meta": "", "location": "pm", "user": 1}}, {"pk": 1, "model": "auth.user", "fields": {"status": "w", "last_name": "", "gold": 0, "is_staff": true, "user_permissions": [], "interesting_tags": "", "email_key": null, "date_joined": "2012-01-23 17:03:54", "first_name": "", "email_isvalid": false, "avatar_type": "n", "website": "", "is_superuser": true, "date_of_birth": null, "last_login": "2012-01-23 17:04:16", "location": "", "new_response_count": 0, "email": "pmitros@csail.mit.edu", "username": "pm", "is_active": true, "consecutive_days_visit_count": 0, "email_tag_filter_strategy": 1, "groups": [], "password": "sha1$a3e96$dbabbd114f0da01bce2cc2adcafa2ca651c7ae0a", "silver": 0, "bronze": 0, "questions_per_page": 10, "about": "", "show_country": false, "country": "", "display_tag_filter_strategy": 0, "seen_response_count": 0, "real_name": "", "ignored_tags": "", "reputation": 1, "gravatar": "7a591afd0cc7972fdbe5e12e26af352a", "last_seen": "2012-01-23 17:04:41"}}, {"pk": 1, "model": "user.userprofile", "fields": {"name": "pm", "language": "pm", "courseware": "course.xml", "meta": "", "location": "pm", "user": 1}}, {"pk": 1, "model": "auth.user", "fields": {"status": "w", "last_name": "", "gold": 0, "is_staff": true, "user_permissions": [], "interesting_tags": "", "email_key": null, "date_joined": "2012-01-23 17:03:54", "first_name": "", "email_isvalid": false, "avatar_type": "n", "website": "", "is_superuser": true, "date_of_birth": null, "last_login": "2012-01-23 17:04:16", "location": "", "new_response_count": 0, "email": "pmitros@csail.mit.edu", "username": "pm", "is_active": true, "consecutive_days_visit_count": 0, "email_tag_filter_strategy": 1, "groups": [], "password": "sha1$a3e96$dbabbd114f0da01bce2cc2adcafa2ca651c7ae0a", "silver": 0, "bronze": 0, "questions_per_page": 10, "about": "", "show_country": false, "country": "", "display_tag_filter_strategy": 0, "seen_response_count": 0, "real_name": "", "ignored_tags": "", "reputation": 1, "gravatar": "7a591afd0cc7972fdbe5e12e26af352a", "last_seen": "2012-01-23 17:04:41"}}] \ No newline at end of file diff --git a/i18n/config.py b/i18n/config.py new file mode 100644 index 0000000000..4f246ed942 --- /dev/null +++ b/i18n/config.py @@ -0,0 +1,77 @@ +import os, json +from path import path + +# BASE_DIR is the working directory to execute django-admin commands from. +# Typically this should be the 'mitx' directory. +BASE_DIR = path(__file__).abspath().dirname().joinpath('..').normpath() + +# LOCALE_DIR contains the locale files. +# Typically this should be 'mitx/conf/locale' +LOCALE_DIR = BASE_DIR.joinpath('conf', 'locale') + +class Configuration: + """ + # Reads localization configuration in json format + + """ + _source_locale = 'en' + + def __init__(self, filename): + self._filename = filename + self._config = self.read_config(filename) + + def read_config(self, filename): + """ + Returns data found in config file (as dict), or raises exception if file not found + """ + if not os.path.exists(filename): + raise Exception("Configuration file cannot be found: %s" % filename) + with open(filename) as stream: + return json.load(stream) + + @property + def locales(self): + """ + Returns a list of locales declared in the configuration file, + e.g. ['en', 'fr', 'es'] + Each locale is a string. + """ + return self._config['locales'] + + @property + def source_locale(self): + """ + Returns source language. + Source language is English. + """ + return self._source_locale + + @property + def dummy_locale(self): + """ + Returns a locale to use for the dummy text, e.g. 'fr'. + Throws exception if no dummy-locale is declared. + The locale is a string. + """ + dummy = self._config.get('dummy-locale', None) + if not dummy: + raise Exception('Could not read dummy-locale from configuration file.') + return dummy + + def get_messages_dir(self, locale): + """ + Returns the name of the directory holding the po files for locale. + Example: mitx/conf/locale/fr/LC_MESSAGES + """ + return LOCALE_DIR.joinpath(locale, 'LC_MESSAGES') + + @property + def source_messages_dir(self): + """ + Returns the name of the directory holding the source-language po files (English). + Example: mitx/conf/locale/en/LC_MESSAGES + """ + return self.get_messages_dir(self.source_locale) + + +CONFIGURATION = Configuration(LOCALE_DIR.joinpath('config').normpath()) diff --git a/i18n/execute.py b/i18n/execute.py index 3c3416b65d..8e7f0f52de 100644 --- a/i18n/execute.py +++ b/i18n/execute.py @@ -1,69 +1,30 @@ -import os, subprocess, logging, json +import os, subprocess, logging -def init_module(): - """ - Initializes module parameters - """ - global BASE_DIR, LOCALE_DIR, CONFIG_FILENAME, SOURCE_MSGS_DIR, SOURCE_LOCALE, LOG +from config import CONFIGURATION, BASE_DIR - # BASE_DIR is the working directory to execute django-admin commands from. - # Typically this should be the 'mitx' directory. - BASE_DIR = os.path.normpath(os.path.dirname(os.path.abspath(__file__))+'/..') +LOG = logging.getLogger(__name__) - # Source language is English - SOURCE_LOCALE = 'en' - - # LOCALE_DIR contains the locale files. - # Typically this should be 'mitx/conf/locale' - LOCALE_DIR = BASE_DIR + '/conf/locale' - - # CONFIG_FILENAME contains localization configuration in json format - CONFIG_FILENAME = LOCALE_DIR + '/config' - - # SOURCE_MSGS_DIR contains the English po files. - SOURCE_MSGS_DIR = messages_dir(SOURCE_LOCALE) - - # Default logger. - LOG = get_logger() - - -def messages_dir(locale): - """ - Returns the name of the directory holding the po files for locale. - Example: mitx/conf/locale/en/LC_MESSAGES - """ - return os.path.join(LOCALE_DIR, locale, 'LC_MESSAGES') - -def get_logger(): - """Returns a default logger""" - log = logging.getLogger(__name__) - log.setLevel(logging.INFO) - log_handler = logging.StreamHandler() - log_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) - log.addHandler(log_handler) - return log - -# Run this after defining messages_dir and get_logger, because it depends on these. -init_module() - -def execute (command, working_directory=BASE_DIR, log=LOG): +def execute(command, working_directory=BASE_DIR): """ Executes shell command in a given working_directory. Command is a string to pass to the shell. - Output is logged to log. + Output is ignored. """ - log.info(command) + LOG.info(command) subprocess.call(command.split(' '), cwd=working_directory) - -def get_config(): - """Returns data found in config file, or returns None if file not found""" - config_path = os.path.abspath(CONFIG_FILENAME) - if not os.path.exists(config_path): - log.warn("Configuration file cannot be found: %s" % \ - os.path.relpath(config_path, BASE_DIR)) - return None - with open(config_path) as stream: - return json.load(stream) + + +def call(command, working_directory=BASE_DIR): + """ + Executes shell command in a given working_directory. + Command is a string to pass to the shell. + Returns a tuple of two strings: (stdout, stderr) + + """ + LOG.info(command) + p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=working_directory) + out, err = p.communicate() + return (out, err) def create_dir_if_necessary(pathname): dirname = os.path.dirname(pathname) @@ -71,16 +32,16 @@ def create_dir_if_necessary(pathname): os.makedirs(dirname) -def remove_file(filename, log=LOG, verbose=True): +def remove_file(filename, verbose=True): """ Attempt to delete filename. + log is boolean. If true, removal is logged. Log a warning if file does not exist. Logging filenames are releative to BASE_DIR to cut down on noise in output. """ if verbose: - log.info('Deleting file %s' % os.path.relpath(filename, BASE_DIR)) + LOG.info('Deleting file %s' % os.path.relpath(filename, BASE_DIR)) if not os.path.exists(filename): - log.warn("File does not exist: %s" % os.path.relpath(filename, BASE_DIR)) + LOG.warn("File does not exist: %s" % os.path.relpath(filename, BASE_DIR)) else: os.remove(filename) - diff --git a/i18n/extract.py b/i18n/extract.py index c6fedd3bfa..c28c3868e2 100755 --- a/i18n/extract.py +++ b/i18n/extract.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python """ See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow @@ -15,28 +15,35 @@ See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow """ -import os +import os, sys, logging from datetime import datetime from polib import pofile -from execute import execute, create_dir_if_necessary, remove_file, \ - BASE_DIR, LOCALE_DIR, SOURCE_MSGS_DIR, LOG +from config import BASE_DIR, LOCALE_DIR, CONFIGURATION +from execute import execute, create_dir_if_necessary, remove_file # BABEL_CONFIG contains declarations for Babel to extract strings from mako template files # Use relpath to reduce noise in logs -BABEL_CONFIG = os.path.relpath(LOCALE_DIR + '/babel.cfg', BASE_DIR) +BABEL_CONFIG = BASE_DIR.relpathto(LOCALE_DIR.joinpath('babel.cfg')) # Strings from mako template files are written to BABEL_OUT # Use relpath to reduce noise in logs -BABEL_OUT = os.path.relpath(SOURCE_MSGS_DIR + '/mako.po', BASE_DIR) +BABEL_OUT = BASE_DIR.relpathto(CONFIGURATION.source_messages_dir.joinpath('mako.po')) +SOURCE_WARN = 'This English source file is machine-generated. Do not check it into github' + +LOG = logging.getLogger(__name__) def main (): + logging.basicConfig(stream=sys.stdout, level=logging.INFO) create_dir_if_necessary(LOCALE_DIR) - generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + source_msgs_dir = CONFIGURATION.source_messages_dir + remove_file(source_msgs_dir.joinpath('django.po')) + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') for filename in generated_files: - remove_file(os.path.join(SOURCE_MSGS_DIR, filename)) + remove_file(source_msgs_dir.joinpath(filename)) + # Extract strings from mako templates babel_mako_cmd = 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT) @@ -52,13 +59,13 @@ def main (): execute(make_django_cmd, working_directory=BASE_DIR) # makemessages creates 'django.po'. This filename is hardcoded. # Rename it to django-partial.po to enable merging into django.po later. - os.rename(os.path.join(SOURCE_MSGS_DIR, 'django.po'), - os.path.join(SOURCE_MSGS_DIR, 'django-partial.po')) + os.rename(source_msgs_dir.joinpath('django.po'), + source_msgs_dir.joinpath('django-partial.po')) execute(make_djangojs_cmd, working_directory=BASE_DIR) for filename in generated_files: LOG.info('Cleaning %s' % filename) - po = pofile(os.path.join(SOURCE_MSGS_DIR, filename)) + po = pofile(source_msgs_dir.joinpath(filename)) # replace default headers with edX headers fix_header(po) # replace default metadata with edX metadata @@ -79,10 +86,11 @@ def fix_header(po): """ Replace default headers with edX headers """ + po.metadata_is_fuzzy = [] # remove [u'fuzzy'] header = po.header fixes = ( - ('SOME DESCRIPTIVE TITLE', 'edX translation file'), - ('Translations template for PROJECT.', 'edX translation file'), + ('SOME DESCRIPTIVE TITLE', 'edX translation file\n' + SOURCE_WARN), + ('Translations template for PROJECT.', 'edX translation file\n' + SOURCE_WARN), ('YEAR', '%s' % datetime.utcnow().year), ('ORGANIZATION', 'edX'), ("THE PACKAGE'S COPYRIGHT HOLDER", "EdX"), @@ -119,10 +127,9 @@ def fix_metadata(po): 'Report-Msgid-Bugs-To': 'translation_team@edx.org', 'Project-Id-Version': '0.1a', 'Language' : 'en', + 'Last-Translator' : '', 'Language-Team': 'translation team ', } - if po.metadata.has_key('Last-Translator'): - del po.metadata['Last-Translator'] po.metadata.update(fixes) def strip_key_strings(po): diff --git a/i18n/generate.py b/i18n/generate.py index ddbaadfa70..65c65c00d6 100755 --- a/i18n/generate.py +++ b/i18n/generate.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python """ See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow @@ -13,50 +13,71 @@ languages to generate. """ -import os -from execute import execute, get_config, messages_dir, remove_file, \ - BASE_DIR, LOG, SOURCE_LOCALE +import os, sys, logging +from polib import pofile -def merge(locale, target='django.po'): +from config import BASE_DIR, CONFIGURATION +from execute import execute + +LOG = logging.getLogger(__name__) + +def merge(locale, target='django.po', fail_if_missing=True): """ For the given locale, merge django-partial.po, messages.po, mako.po -> django.po + target is the resulting filename + If fail_if_missing is True, and the files to be merged are missing, + throw an Exception. + If fail_if_missing is False, and the files to be merged are missing, + just return silently. """ LOG.info('Merging locale={0}'.format(locale)) - locale_directory = messages_dir(locale) + locale_directory = CONFIGURATION.get_messages_dir(locale) files_to_merge = ('django-partial.po', 'messages.po', 'mako.po') - validate_files(locale_directory, files_to_merge) + try: + validate_files(locale_directory, files_to_merge) + except Exception, e: + if not fail_if_missing: + return + raise e # merged file is merged.po merge_cmd = 'msgcat -o merged.po ' + ' '.join(files_to_merge) execute(merge_cmd, working_directory=locale_directory) + # clean up redunancies in the metadata + merged_filename = locale_directory.joinpath('merged.po') + clean_metadata(merged_filename) + # rename merged.po -> django.po (default) - merged_filename = os.path.join(locale_directory, 'merged.po') - django_filename = os.path.join(locale_directory, target) + django_filename = locale_directory.joinpath(target) os.rename(merged_filename, django_filename) # can't overwrite file on Windows +def clean_metadata(file): + """ + Clean up redundancies in the metadata caused by merging. + This reads in a PO file and simply saves it back out again. + """ + pofile(file).save() + def validate_files(dir, files_to_merge): """ Asserts that the given files exist. files_to_merge is a list of file names (no directories). - dir is the directory in which the files should appear. + dir is the directory (a path object from path.py) in which the files should appear. raises an Exception if any of the files are not in dir. """ for path in files_to_merge: - pathname = os.path.join(dir, path) - if not os.path.exists(pathname): - raise Exception("File not found: {0}".format(pathname)) + pathname = dir.joinpath(path) + if not pathname.exists(): + raise Exception("I18N: Cannot generate because file not found: {0}".format(pathname)) def main (): - configuration = get_config() - if configuration == None: - LOG.warn('Configuration file not found, using only English.') - locales = (SOURCE_LOCALE,) - else: - locales = configuration['locales'] - for locale in locales: - merge(locale) + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + for locale in CONFIGURATION.locales: + merge(locale) + # Dummy text is not required. Don't raise exception if files are missing. + merge(CONFIGURATION.dummy_locale, fail_if_missing=False) compile_cmd = 'django-admin.py compilemessages' execute(compile_cmd, working_directory=BASE_DIR) diff --git a/i18n/make_dummy.py b/i18n/make_dummy.py index c8dcde861a..6c14edd45a 100755 --- a/i18n/make_dummy.py +++ b/i18n/make_dummy.py @@ -1,7 +1,13 @@ -#!/usr/bin/python +#!/usr/bin/env python # Generate test translation files from human-readable po files. # +# Dummy language is specified in configuration file (see config.py) +# two letter language codes reference: +# see http://www.loc.gov/standards/iso639-2/php/code_list.php +# +# Django will not localize in languages that django itself has not been +# localized for. So we are using a well-known language (default='fr'). # # po files can be generated with this: # django-admin.py makemessages --all --extension html -l en @@ -10,14 +16,15 @@ # # $ ./make_dummy.py # -# $ ./make_dummy.py mitx/conf/locale/en/LC_MESSAGES/django.po +# $ ./make_dummy.py ../conf/locale/en/LC_MESSAGES/django.po # # generates output to -# mitx/conf/locale/vr/LC_MESSAGES/django.po +# mitx/conf/locale/fr/LC_MESSAGES/django.po import os, sys import polib from dummy import Dummy +from config import CONFIGURATION from execute import create_dir_if_necessary def main(file, locale): @@ -41,27 +48,19 @@ def new_filename(original_filename, new_locale): orig_dir = os.path.dirname(original_filename) msgs_dir = os.path.basename(orig_dir) orig_file = os.path.basename(original_filename) - return os.path.join(orig_dir, - '/../..', - new_locale, - msgs_dir, - orig_file) - - -# Dummy language -# two letter language codes reference: -# see http://www.loc.gov/standards/iso639-2/php/code_list.php -# -# Django will not localize in languages that django itself has not been -# localized for. So we are using a well-known language: 'fr'. - -DEFAULT_LOCALE = 'fr' + return os.path.abspath(os.path.join(orig_dir, + '../..', + new_locale, + msgs_dir, + orig_file)) if __name__ == '__main__': + # required arg: file if len(sys.argv)<2: raise Exception("missing file argument") - if len(sys.argv)<2: - locale = DEFAULT_LOCALE + # optional arg: locale + if len(sys.argv)<3: + locale = CONFIGURATION.get_dummy_locale() else: locale = sys.argv[2] main(sys.argv[1], locale) diff --git a/i18n/tests/__init__.py b/i18n/tests/__init__.py index d60515c712..ee6283376e 100644 --- a/i18n/tests/__init__.py +++ b/i18n/tests/__init__.py @@ -1,4 +1,6 @@ +from test_config import TestConfiguration from test_extract import TestExtract from test_generate import TestGenerate from test_converter import TestConverter from test_dummy import TestDummy +import test_validate diff --git a/i18n/tests/test_config.py b/i18n/tests/test_config.py new file mode 100644 index 0000000000..bcec6ac354 --- /dev/null +++ b/i18n/tests/test_config.py @@ -0,0 +1,33 @@ +import os +from unittest import TestCase + +from config import Configuration, LOCALE_DIR, CONFIGURATION + +class TestConfiguration(TestCase): + """ + Tests functionality of i18n/config.py + """ + + def test_config(self): + config_filename = os.path.normpath(os.path.join(LOCALE_DIR, 'config')) + config = Configuration(config_filename) + self.assertEqual(config.source_locale, 'en') + + def test_no_config(self): + config_filename = os.path.normpath(os.path.join(LOCALE_DIR, 'no_such_file')) + with self.assertRaises(Exception): + Configuration(config_filename) + + def test_valid_configuration(self): + """ + Make sure we have a valid configuration file, + and that it contains an 'en' locale. + Also check values of dummy_locale and source_locale. + """ + self.assertIsNotNone(CONFIGURATION) + locales = CONFIGURATION.locales + self.assertIsNotNone(locales) + self.assertIsInstance(locales, list) + self.assertIn('en', locales) + self.assertEqual('fr', CONFIGURATION.dummy_locale) + self.assertEqual('en', CONFIGURATION.source_locale) diff --git a/i18n/tests/test_extract.py b/i18n/tests/test_extract.py index b14ae9872d..7e8b1a9d2b 100644 --- a/i18n/tests/test_extract.py +++ b/i18n/tests/test_extract.py @@ -4,7 +4,7 @@ from nose.plugins.skip import SkipTest from datetime import datetime, timedelta import extract -from execute import SOURCE_MSGS_DIR +from config import CONFIGURATION # Make sure setup runs only once SETUP_HAS_RUN = False @@ -39,7 +39,7 @@ class TestExtract(TestCase): Fails assertion if one of the files doesn't exist. """ for filename in self.generated_files: - path = os.path.join(SOURCE_MSGS_DIR, filename) + path = os.path.join(CONFIGURATION.source_messages_dir, filename) exists = os.path.exists(path) self.assertTrue(exists, msg='Missing file: %s' % filename) if exists: diff --git a/i18n/tests/test_generate.py b/i18n/tests/test_generate.py index fc22988251..468858664f 100644 --- a/i18n/tests/test_generate.py +++ b/i18n/tests/test_generate.py @@ -1,9 +1,10 @@ -import os, string, random +import os, string, random, re +from polib import pofile from unittest import TestCase from datetime import datetime, timedelta import generate -from execute import get_config, messages_dir, SOURCE_MSGS_DIR, SOURCE_LOCALE +from config import CONFIGURATION class TestGenerate(TestCase): """ @@ -12,29 +13,16 @@ class TestGenerate(TestCase): generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') def setUp(self): - self.configuration = get_config() - # Subtract 1 second to help comparisons with file-modify time succeed, # since os.path.getmtime() is not millisecond-accurate self.start_time = datetime.now() - timedelta(seconds=1) - def test_configuration(self): - """ - Make sure we have a valid configuration file, - and that it contains an 'en' locale. - """ - self.assertIsNotNone(self.configuration) - locales = self.configuration['locales'] - self.assertIsNotNone(locales) - self.assertIsInstance(locales, list) - self.assertIn('en', locales) - def test_merge(self): """ Tests merge script on English source files. """ - filename = os.path.join(SOURCE_MSGS_DIR, random_name()) - generate.merge(SOURCE_LOCALE, target=filename) + filename = os.path.join(CONFIGURATION.source_messages_dir, random_name()) + generate.merge(CONFIGURATION.source_locale, target=filename) self.assertTrue(os.path.exists(filename)) os.remove(filename) @@ -47,13 +35,35 @@ class TestGenerate(TestCase): after start of test suite) """ generate.main() - for locale in self.configuration['locales']: - for filename in ('django.mo', 'djangojs.mo'): - path = os.path.join(messages_dir(locale), filename) + for locale in CONFIGURATION.locales: + for filename in ('django', 'djangojs'): + mofile = filename+'.mo' + path = os.path.join(CONFIGURATION.get_messages_dir(locale), mofile) exists = os.path.exists(path) - self.assertTrue(exists, msg='Missing file in locale %s: %s' % (locale, filename)) + self.assertTrue(exists, msg='Missing file in locale %s: %s' % (locale, mofile)) self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) >= self.start_time, msg='File not recently modified: %s' % path) + self.assert_merge_headers(locale) + + def assert_merge_headers(self, locale): + """ + This is invoked by test_main to ensure that it runs after + calling generate.main(). + + There should be exactly three merge comment headers + in our merged .po file. This counts them to be sure. + A merge comment looks like this: + # #-#-#-#-# django-partial.po (0.1a) #-#-#-#-# + + """ + path = os.path.join(CONFIGURATION.get_messages_dir(locale), 'django.po') + po = pofile(path) + pattern = re.compile('^#-#-#-#-#', re.M) + match = pattern.findall(po.header) + self.assertEqual(len(match), 3, + msg="Found %s (should be 3) merge comments in the header for %s" % \ + (len(match), path)) + def random_name(size=6): """Returns random filename as string, like test-4BZ81W""" diff --git a/i18n/tests/test_validate.py b/i18n/tests/test_validate.py new file mode 100644 index 0000000000..bef563faea --- /dev/null +++ b/i18n/tests/test_validate.py @@ -0,0 +1,34 @@ +import os, sys, logging +from unittest import TestCase +from nose.plugins.skip import SkipTest + +from config import LOCALE_DIR +from execute import call + +def test_po_files(root=LOCALE_DIR): + """ + This is a generator. It yields all of the .po files under root, and tests each one. + """ + log = logging.getLogger(__name__) + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + + for (dirpath, dirnames, filenames) in os.walk(root): + for name in filenames: + (base, ext) = os.path.splitext(name) + if ext.lower() == '.po': + yield validate_po_file, os.path.join(dirpath, name), log + + +def validate_po_file(filename, log): + """ + Call GNU msgfmt -c on each .po file to validate its format. + Any errors caught by msgfmt are logged to log. + """ + # Skip this test for now because it's very noisy + raise SkipTest() + # Use relative paths to make output less noisy. + rfile = os.path.relpath(filename, LOCALE_DIR) + (out, err) = call(['msgfmt','-c', rfile], working_directory=LOCALE_DIR) + if err != '': + log.warn('\n'+err) + diff --git a/i18n/transifex.py b/i18n/transifex.py new file mode 100755 index 0000000000..ac203f3eea --- /dev/null +++ b/i18n/transifex.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python + +import os, sys +from polib import pofile +from config import CONFIGURATION +from extract import SOURCE_WARN +from execute import execute + +TRANSIFEX_HEADER = 'Translations in this file have been downloaded from %s' +TRANSIFEX_URL = 'https://www.transifex.com/projects/p/edx-studio/' + +def push(): + execute('tx push -s') + +def pull(): + for locale in CONFIGURATION.locales: + if locale != CONFIGURATION.source_locale: + execute('tx pull -l %s' % locale) + clean_translated_locales() + + +def clean_translated_locales(): + """ + Strips out the warning from all translated po files + about being an English source file. + """ + for locale in CONFIGURATION.locales: + if locale != CONFIGURATION.source_locale: + clean_locale(locale) + +def clean_locale(locale): + """ + Strips out the warning from all of a locale's translated po files + about being an English source file. + Iterates over machine-generated files. + """ + dirname = CONFIGURATION.get_messages_dir(locale) + for filename in ('django-partial.po', 'djangojs.po', 'mako.po'): + clean_file(dirname.joinpath(filename)) + +def clean_file(file): + """ + Strips out the warning from a translated po file about being an English source file. + Replaces warning with a note about coming from Transifex. + """ + po = pofile(file) + if po.header.find(SOURCE_WARN) != -1: + new_header = get_new_header(po) + new = po.header.replace(SOURCE_WARN, new_header) + po.header = new + po.save() + +def get_new_header(po): + team = po.metadata.get('Language-Team', None) + if not team: + return TRANSIFEX_HEADER % TRANSIFEX_URL + else: + return TRANSIFEX_HEADER % team + +if __name__ == '__main__': + if len(sys.argv)<2: + raise Exception("missing argument: push or pull") + arg = sys.argv[1] + if arg == 'push': + push() + elif arg == 'pull': + pull() + else: + raise Exception("unknown argument: (%s)" % arg) + diff --git a/jenkins/base.sh b/jenkins/base.sh deleted file mode 100644 index fc2595662a..0000000000 --- a/jenkins/base.sh +++ /dev/null @@ -1,12 +0,0 @@ - -function github_status { - gcli status create edx mitx $GIT_COMMIT \ - --params=$1 \ - target_url:$BUILD_URL \ - description:"Build #$BUILD_NUMBER is running" \ - -f csv -} - -function github_mark_failed_on_exit { - trap '[ $? == "0" ] || github_status state:failed' EXIT -} diff --git a/jenkins/test.sh b/jenkins/test.sh index 7475076086..e5ac4f6f71 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -3,8 +3,21 @@ set -e set -x +## +## requires >= 1.3.0 of the Jenkins git plugin +## + function github_status { - gcli status create edx mitx $GIT_COMMIT \ + if [[ ! ${GIT_URL} =~ git@github.com:([^/]+)/([^\.]+).git ]]; then + echo "Cannot parse Github org or repo from URL, using defaults." + ORG="edx" + REPO="edx-platform" + else + ORG=${BASH_REMATCH[1]} + REPO=${BASH_REMATCH[2]} + fi + + gcli status create $ORG $REPO $GIT_COMMIT \ --params=$1 \ target_url:$BUILD_URL \ description:"Build #$BUILD_NUMBER $2" \ @@ -27,41 +40,44 @@ git submodule foreach 'git reset --hard HEAD' export PYTHONIOENCODING=UTF-8 GIT_BRANCH=${GIT_BRANCH/HEAD/master} -if [ ! -d /mnt/virtualenvs/"$JOB_NAME" ]; then - mkdir -p /mnt/virtualenvs/"$JOB_NAME" - virtualenv /mnt/virtualenvs/"$JOB_NAME" + +# When running in parallel on jenkins, workspace could be suffixed by @x +# In that case, we want to use a separate virtualenv that matches up with +# workspace +# +# We need to handle both the case of /path/to/workspace +# and /path/to/workspace@2, which is why we use the following substitutions +# +# $WORKSPACE is the absolute path for the workspace +WORKSPACE_SUFFIX=$(expr "$WORKSPACE" : '.*\(@.*\)') || true + +VIRTUALENV_DIR="/mnt/virtualenvs/${JOB_NAME}${WORKSPACE_SUFFIX}" + +if [ ! -d "$VIRTUALENV_DIR" ]; then + mkdir -p "$VIRTUALENV_DIR" + virtualenv "$VIRTUALENV_DIR" fi export PIP_DOWNLOAD_CACHE=/mnt/pip-cache +# Allow django liveserver tests to use a range of ports +export DJANGO_LIVE_TEST_SERVER_ADDRESS=${DJANGO_LIVE_TEST_SERVER_ADDRESS-localhost:8000-9000} + source /mnt/virtualenvs/"$JOB_NAME"/bin/activate -pip install -q -r pre-requirements.txt -yes w | pip install -q -r requirements.txt bundle install -npm install - +rake install_prereqs rake clobber rake pep8 > pep8.log || cat pep8.log rake pylint > pylint.log || cat pylint.log -TESTS_FAILED=0 +# Run the unit tests (use phantomjs for javascript unit tests) +rake test -# Run the python unit tests -rake test_cms[false] || TESTS_FAILED=1 -rake test_lms[false] || TESTS_FAILED=1 -rake test_common/lib/capa || TESTS_FAILED=1 -rake test_common/lib/xmodule || TESTS_FAILED=1 +# Generate coverage reports +rake coverage -# Run the jaavascript unit tests -rake phantomjs_jasmine_lms || TESTS_FAILED=1 -rake phantomjs_jasmine_cms || TESTS_FAILED=1 -rake phantomjs_jasmine_common/lib/xmodule || TESTS_FAILED=1 - -rake coverage:xml coverage:html - -[ $TESTS_FAILED == '0' ] rake autodeploy_properties github_status state:success "passed" diff --git a/jenkins/test_acceptance.sh b/jenkins/test_acceptance.sh new file mode 100755 index 0000000000..1d11265d08 --- /dev/null +++ b/jenkins/test_acceptance.sh @@ -0,0 +1,39 @@ +#! /bin/bash + +set -e +set -x + +git remote prune origin + +# Reset the submodule, in case it changed +git submodule foreach 'git reset --hard HEAD' + +# Set the IO encoding to UTF-8 so that askbot will start +export PYTHONIOENCODING=UTF-8 + +if [ ! -d /mnt/virtualenvs/"$JOB_NAME" ]; then + mkdir -p /mnt/virtualenvs/"$JOB_NAME" + virtualenv /mnt/virtualenvs/"$JOB_NAME" +fi + +export PIP_DOWNLOAD_CACHE=/mnt/pip-cache + +source /mnt/virtualenvs/"$JOB_NAME"/bin/activate +rake install_prereqs +rake clobber + +TESTS_FAILED=0 + +# Assumes that Xvfb has been started by upstart +# and is capturing display :1 +# The command for this is: +# /usr/bin/Xvfb :1 -screen 0 1024x268x24 +# This allows us to run Chrome without a display +export DISPLAY=:1 + +# Run the lms and cms acceptance tests +# (the -v flag turns off color in the output) +rake test_acceptance_lms["-v 3"] || TESTS_FAILED=1 +rake test_acceptance_cms["-v 3"] || TESTS_FAILED=1 + +[ $TESTS_FAILED == '0' ] diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index 9fe912e947..dd57e8d4d4 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -6,6 +6,7 @@ from django_future.csrf import ensure_csrf_cookie import student.views import branding import courseware.views +from mitxmako.shortcuts import marketing_link from util.cache import cache_if_anonymous @@ -22,6 +23,8 @@ def index(request): if settings.MITX_FEATURES.get('AUTH_USE_MIT_CERTIFICATES'): from external_auth.views import ssl_login return ssl_login(request) + if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE'): + return redirect(settings.MKTG_URLS.get('ROOT')) university = branding.get_university(request.META.get('HTTP_HOST')) if university is None: @@ -34,9 +37,12 @@ def index(request): @cache_if_anonymous def courses(request): """ - Render the "find courses" page. If subdomain branding is on, this is the - university profile page, otherwise it's the edX courseware.views.courses page + Render the "find courses" page. If the marketing site is enabled, redirect + to that. Otherwise, if subdomain branding is on, this is the university + profile page. Otherwise, it's the edX courseware.views.courses page """ + if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False): + return redirect(marketing_link('COURSES'), permanent=True) university = branding.get_university(request.META.get('HTTP_HOST')) if university is None: diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index ace9c0096b..07987a8edf 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -16,6 +16,7 @@ from xmodule.x_module import XModule, XModuleDescriptor from student.models import CourseEnrollmentAllowed from courseware.masquerade import is_masquerading_as_student +from django.utils.timezone import UTC DEBUG_ACCESS = False @@ -133,7 +134,7 @@ def _has_access_course_desc(user, course, action): (staff can always enroll) """ - now = time.gmtime() + now = datetime.now(UTC()) start = course.enrollment_start end = course.enrollment_end @@ -242,7 +243,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): # Check start date if descriptor.lms.start is not None: - now = time.gmtime() + now = datetime.now(UTC()) effective_start = _adjust_start_date_for_beta_testers(user, descriptor) if now > effective_start: # after start date, everyone can see it @@ -365,7 +366,7 @@ def _course_org_staff_group_name(location, course_context=None): def group_names_for(role, location, course_context=None): - """Returns the group names for a given role with this location. Plural + """Returns the group names for a given role with this location. Plural because it will return both the name we expect now as well as the legacy group name we support for backwards compatibility. This should not check the DB for existence of a group (like some of its callers do) because that's @@ -483,8 +484,7 @@ def _adjust_start_date_for_beta_testers(user, descriptor): non-None start date. Returns: - A time, in the same format as returned by time.gmtime(). Either the same as - start, or earlier for beta testers. + A datetime. Either the same as start, or earlier for beta testers. NOTE: number of days to adjust should be cached to avoid looking it up thousands of times per query. @@ -505,15 +505,11 @@ def _adjust_start_date_for_beta_testers(user, descriptor): beta_group = course_beta_test_group_name(descriptor.location) if beta_group in user_groups: debug("Adjust start time: user in group %s", beta_group) - # time_structs don't support subtraction, so convert to datetimes, - # subtract, convert back. - # (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for - # converting time_structs into datetimes) - start_as_datetime = datetime(*descriptor.lms.start[:6]) + start_as_datetime = descriptor.lms.start delta = timedelta(descriptor.lms.days_early_for_beta) effective = start_as_datetime - delta # ...and back to time_struct - return effective.timetuple() + return effective return descriptor.lms.start @@ -564,7 +560,7 @@ def _has_access_to_location(user, location, access_level, course_context): return True debug("Deny: user not in groups %s", staff_groups) - if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges + if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges instructor_groups = group_names_for_instructor(location, course_context) + \ [_course_org_instructor_group_name(location, course_context)] for instructor_group in instructor_groups: diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index e81568ae4b..874ba0142a 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -20,7 +20,7 @@ logger = getLogger(__name__) TEST_COURSE_ORG = 'edx' TEST_COURSE_NAME = 'Test Course' -TEST_SECTION_NAME = "Problem" +TEST_SECTION_NAME = 'Test Section' @step(u'The course "([^"]*)" exists$') diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 4e9aa3fb7b..aff49d2f9d 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -12,7 +12,6 @@ def i_click_on_view_courseware(step): @step('I click on the "([^"]*)" tab$') def i_click_on_the_tab(step, tab_text): world.click_link(tab_text) - world.save_the_html() @step('I visit the courseware URL$') @@ -20,11 +19,6 @@ def i_visit_the_course_info_url(step): world.visit('/courses/MITx/6.002x/2012_Fall/courseware') -@step(u'I do not see "([^"]*)" anywhere on the page') -def i_do_not_see_text_anywhere_on_the_page(step, text): - assert world.browser.is_text_not_present(text) - - @step(u'I am on the dashboard page$') def i_am_on_the_dashboard_page(step): assert world.is_css_present('section.courses') diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature index c60ec7b374..adbe5ec8a3 100644 --- a/lms/djangoapps/courseware/features/high-level-tabs.feature +++ b/lms/djangoapps/courseware/features/high-level-tabs.feature @@ -8,10 +8,7 @@ Scenario: I can navigate to all high - level tabs in a course And The course "6.002x" has extra tab "Custom Tab" And I am logged in And I click on View Courseware - When I click on the "" tab - Then the page title should contain "" - - Examples: + When I click on the tabs then the page title should contain the following titles: | TabName | PageTitle | | Courseware | 6.002x Courseware | | Course Info | 6.002x Course Info | diff --git a/lms/djangoapps/courseware/features/high-level-tabs.py b/lms/djangoapps/courseware/features/high-level-tabs.py new file mode 100644 index 0000000000..056c627803 --- /dev/null +++ b/lms/djangoapps/courseware/features/high-level-tabs.py @@ -0,0 +1,11 @@ +from lettuce import world, step +from nose.tools import assert_equals + + +@step(u'I click on the tabs then the page title should contain the following titles:') +def i_click_on_the_tab_and_check(step): + for tab_title in step.hashes: + tab_text = tab_title['TabName'] + title = tab_title['PageTitle'] + world.click_link(tab_text) + assert(title in world.browser.title) diff --git a/lms/djangoapps/courseware/features/homepage.feature b/lms/djangoapps/courseware/features/homepage.feature index c0c1c32f02..140f1f8b5f 100644 --- a/lms/djangoapps/courseware/features/homepage.feature +++ b/lms/djangoapps/courseware/features/homepage.feature @@ -5,36 +5,27 @@ Feature: Homepage for web users Scenario: User can see the "Login" button Given I visit the homepage - Then I should see a link called "Log In" + Then I should see a link called "Log in" - Scenario: User can see the "Sign up" button + Scenario: User can see the "Register Now" button Given I visit the homepage - Then I should see a link called "Sign Up" + Then I should see a link called "Register Now" Scenario Outline: User can see main parts of the page Given I visit the homepage - Then I should see a link called "" - When I click the link with the text "" - Then I should see that the path is "" + Then I should see the following links and ids + | id | Link | + | about | About | + | jobs | Jobs | + | faq | FAQ | + | contact | Contact| + | press | Press | - Examples: - | Link | Path | - | Find Courses | /courses | - | About | /about | - | Jobs | /jobs | - | Contact | /contact | - - Scenario: User can visit the blog - Given I visit the homepage - When I click the link with the text "Blog" - Then I should see that the url is "http://blog.edx.org/" # TODO: test according to domain or policy Scenario: User can see the partner institutions Given I visit the homepage - Then I should see "" in the Partners section - - Examples: + Then I should see the following Partners in the Partners section | Partner | | MITx | | HarvardX | diff --git a/lms/djangoapps/courseware/features/homepage.py b/lms/djangoapps/courseware/features/homepage.py index 62e9096e70..585d1582d7 100644 --- a/lms/djangoapps/courseware/features/homepage.py +++ b/lms/djangoapps/courseware/features/homepage.py @@ -2,11 +2,22 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_in +from nose.tools import assert_in, assert_equals -@step('I should see "([^"]*)" in the Partners section$') -def i_should_see_partner(step, partner): +@step(u'I should see the following Partners in the Partners section') +def i_should_see_partner(step): partners = world.browser.find_by_css(".partner .name span") names = set(span.text for span in partners) - assert_in(partner, names) + for partner in step.hashes: + assert_in(partner['Partner'], names) + + +@step(u'I should see the following links and ids') +def should_see_a_link_called(step): + for link_id_pair in step.hashes: + link_id = link_id_pair['id'] + text = link_id_pair['Link'] + link = world.browser.find_by_id(link_id) + assert len(link) > 0 + assert_equals(link.text, text) diff --git a/lms/djangoapps/courseware/features/login.feature b/lms/djangoapps/courseware/features/login.feature index 23317b4876..a1b788a7b2 100644 --- a/lms/djangoapps/courseware/features/login.feature +++ b/lms/djangoapps/courseware/features/login.feature @@ -7,7 +7,7 @@ Feature: Login in as a registered user Given I am an edX user And I am an unactivated user And I visit the homepage - When I click the link with the text "Log In" + When I click the link with the text "Log in" And I submit my credentials on the login form Then I should see the login error message "This account has not been activated" @@ -15,7 +15,7 @@ Feature: Login in as a registered user Given I am an edX user And I am an activated user And I visit the homepage - When I click the link with the text "Log In" + When I click the link with the text "Log in" And I submit my credentials on the login form Then I should be on the dashboard page @@ -23,5 +23,5 @@ Feature: Login in as a registered user Given I am logged in When I click the dropdown arrow And I click the link with the text "Log Out" - Then I should see a link with the text "Log In" + Then I should see a link with the text "Log in" And I should see that the path is "/" diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index bc90ea301c..857b70fa5d 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -19,13 +19,13 @@ def i_am_an_activated_user(step): def i_submit_my_credentials_on_the_login_form(step): fill_in_the_login_form('email', 'robot@edx.org') fill_in_the_login_form('password', 'test') - login_form = world.browser.find_by_css('form#login_form') - login_form.find_by_value('Access My Courses').click() + login_form = world.browser.find_by_css('form#login-form') + login_form.find_by_name('submit').click() @step(u'I should see the login error message "([^"]*)"$') def i_should_see_the_login_error_message(step, msg): - login_error_div = world.browser.find_by_css('form#login_form #login_error') + login_error_div = world.browser.find_by_css('.submission-error.is-shown') assert (msg in login_error_div.text) @@ -49,6 +49,6 @@ def user_is_an_activated_user(uname): def fill_in_the_login_form(field, value): - login_form = world.browser.find_by_css('form#login_form') + login_form = world.browser.find_by_css('form#login-form') form_field = login_form.find_by_name(field) form_field.fill(value) diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature index 266ffa3680..4a5e64e9f4 100644 --- a/lms/djangoapps/courseware/features/problems.feature +++ b/lms/djangoapps/courseware/features/problems.feature @@ -84,3 +84,38 @@ Feature: Answer problems | formula | incorrect | | script | correct | | script | incorrect | + + + Scenario: I can answer a problem with one attempt correctly and not reset + Given I am viewing a "multiple choice" problem with "1" attempt + When I answer a "multiple choice" problem "correctly" + Then The "Reset" button does not appear + + Scenario: I can answer a problem with multiple attempts correctly and still reset the problem + Given I am viewing a "multiple choice" problem with "3" attempts + Then I should see "You have used 0 of 3 submissions" somewhere in the page + When I answer a "multiple choice" problem "correctly" + Then The "Reset" button does appear + + Scenario: I can view how many attempts I have left on a problem + Given I am viewing a "multiple choice" problem with "3" attempts + Then I should see "You have used 0 of 3 submissions" somewhere in the page + When I answer a "multiple choice" problem "incorrectly" + And I reset the problem + Then I should see "You have used 1 of 3 submissions" somewhere in the page + When I answer a "multiple choice" problem "incorrectly" + And I reset the problem + Then I should see "You have used 2 of 3 submissions" somewhere in the page + And The "Final Check" button does appear + When I answer a "multiple choice" problem "correctly" + Then The "Reset" button does not appear + + Scenario: I can view and hide the answer if the problem has it: + Given I am viewing a "numerical" that shows the answer "always" + When I press the button with the label "Show Answer(s)" + Then the button with the label "Hide Answer(s)" does appear + And the button with the label "Show Answer(s)" does not appear + And I should see "4.14159" somewhere in the page + When I press the button with the label "Hide Answer(s)" + Then the button with the label "Show Answer(s)" does appear + And I should not see "4.14159" anywhere on the page diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 3d538d7ae1..4245e7ca86 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -7,119 +7,42 @@ Steps for problem.feature lettuce tests from lettuce import world, step from lettuce.django import django_url -import random -import textwrap -from common import i_am_registered_for_the_course, \ - TEST_SECTION_NAME, section_location -from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ - ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \ - StringResponseXMLFactory, NumericalResponseXMLFactory, \ - FormulaResponseXMLFactory, CustomResponseXMLFactory, \ - CodeResponseXMLFactory +from common import i_am_registered_for_the_course, TEST_SECTION_NAME +from problems_setup import PROBLEM_DICT, answer_problem, problem_has_answer, add_problem_to_course +from nose.tools import assert_equal, assert_not_equal -# Factories from capa.tests.response_xml_factory that we will use -# to generate the problem XML, with the keyword args used to configure -# the output. -PROBLEM_FACTORY_DICT = { - 'drop down': { - 'factory': OptionResponseXMLFactory(), - 'kwargs': { - 'question_text': 'The correct answer is Option 2', - 'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'], - 'correct_option': 'Option 2'}}, +@step(u'I am viewing a "([^"]*)" problem with "([^"]*)" attempt') +def view_problem_with_attempts(step, problem_type, attempts): + i_am_registered_for_the_course(step, 'model_course') - 'multiple choice': { - 'factory': MultipleChoiceResponseXMLFactory(), - 'kwargs': { - 'question_text': 'The correct answer is Choice 3', - 'choices': [False, False, True, False], - 'choice_names': ['choice_0', 'choice_1', 'choice_2', 'choice_3']}}, + # Ensure that the course has this problem type + add_problem_to_course('model_course', problem_type, {'attempts': attempts}) - 'checkbox': { - 'factory': ChoiceResponseXMLFactory(), - 'kwargs': { - 'question_text': 'The correct answer is Choices 1 and 3', - 'choice_type': 'checkbox', - 'choices': [True, False, True, False, False], - 'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}}, - 'radio': { - 'factory': ChoiceResponseXMLFactory(), - 'kwargs': { - 'question_text': 'The correct answer is Choice 3', - 'choice_type': 'radio', - 'choices': [False, False, True, False], - 'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}}, - 'string': { - 'factory': StringResponseXMLFactory(), - 'kwargs': { - 'question_text': 'The answer is "correct string"', - 'case_sensitive': False, - 'answer': 'correct string'}}, + # Go to the one section in the factory-created course + # which should be loaded with the correct problem + chapter_name = TEST_SECTION_NAME.replace(" ", "_") + section_name = chapter_name + url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' % + (chapter_name, section_name)) - 'numerical': { - 'factory': NumericalResponseXMLFactory(), - 'kwargs': { - 'question_text': 'The answer is pi + 1', - 'answer': '4.14159', - 'tolerance': '0.00001', - 'math_display': True}}, - - 'formula': { - 'factory': FormulaResponseXMLFactory(), - 'kwargs': { - 'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]', - 'sample_dict': {'x': (-100, 100), 'y': (-100, 100)}, - 'num_samples': 10, - 'tolerance': 0.00001, - 'math_display': True, - 'answer': 'x^2+2*x+y'}}, - - 'script': { - 'factory': CustomResponseXMLFactory(), - 'kwargs': { - 'question_text': 'Enter two integers that sum to 10.', - 'cfn': 'test_add_to_ten', - 'expect': '10', - 'num_inputs': 2, - 'script': textwrap.dedent(""" - def test_add_to_ten(expect,ans): - try: - a1=int(ans[0]) - a2=int(ans[1]) - except ValueError: - a1=0 - a2=0 - return (a1+a2)==int(expect) - """)}}, - 'code': { - 'factory': CodeResponseXMLFactory(), - 'kwargs': { - 'question_text': 'Submit code to an external grader', - 'initial_display': 'print "Hello world!"', - 'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', }}, - } + world.browser.visit(url) -def add_problem_to_course(course, problem_type): - ''' - Add a problem to the course we have created using factories. - ''' +@step(u'I am viewing a "([^"]*)" that shows the answer "([^"]*)"') +def view_problem_with_show_answer(step, problem_type, answer): + i_am_registered_for_the_course(step, 'model_course') - assert(problem_type in PROBLEM_FACTORY_DICT) + # Ensure that the course has this problem type + add_problem_to_course('model_course', problem_type, {'showanswer': answer}) - # Generate the problem XML using capa.tests.response_xml_factory - factory_dict = PROBLEM_FACTORY_DICT[problem_type] - problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs']) + # Go to the one section in the factory-created course + # which should be loaded with the correct problem + chapter_name = TEST_SECTION_NAME.replace(" ", "_") + section_name = chapter_name + url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' % + (chapter_name, section_name)) - # Create a problem item using our generated XML - # We set rerandomize=always in the metadata so that the "Reset" button - # will appear. - template_name = "i4x://edx/templates/problem/Blank_Common_Problem" - world.ItemFactory.create(parent_location=section_location(course), - template=template_name, - display_name=str(problem_type), - data=problem_xml, - metadata={'rerandomize': 'always'}) + world.browser.visit(url) @step(u'I am viewing a "([^"]*)" problem') @@ -153,7 +76,7 @@ def set_external_grader_response(step, correctness): @step(u'I answer a "([^"]*)" problem "([^"]*)ly"') -def answer_problem(step, problem_type, correctness): +def answer_problem_step(step, problem_type, correctness): """ Mark a given problem type correct or incorrect, then submit it. *problem_type* is a string representing the type of problem (e.g. 'drop down') @@ -161,73 +84,18 @@ def answer_problem(step, problem_type, correctness): """ assert(correctness in ['correct', 'incorrect']) - - if problem_type == "drop down": - select_name = "input_i4x-edx-model_course-problem-drop_down_2_1" - option_text = 'Option 2' if correctness == 'correct' else 'Option 3' - world.browser.select(select_name, option_text) - - elif problem_type == "multiple choice": - if correctness == 'correct': - inputfield('multiple choice', choice='choice_2').check() - else: - inputfield('multiple choice', choice='choice_1').check() - - elif problem_type == "checkbox": - if correctness == 'correct': - inputfield('checkbox', choice='choice_0').check() - inputfield('checkbox', choice='choice_2').check() - else: - inputfield('checkbox', choice='choice_3').check() - - elif problem_type == 'radio': - if correctness == 'correct': - inputfield('radio', choice='choice_2').check() - else: - inputfield('radio', choice='choice_1').check() - - elif problem_type == 'string': - textvalue = 'correct string' if correctness == 'correct' \ - else 'incorrect' - inputfield('string').fill(textvalue) - - elif problem_type == 'numerical': - textvalue = "pi + 1" if correctness == 'correct' \ - else str(random.randint(-2, 2)) - inputfield('numerical').fill(textvalue) - - elif problem_type == 'formula': - textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2' - inputfield('formula').fill(textvalue) - - elif problem_type == 'script': - # Correct answer is any two integers that sum to 10 - first_addend = random.randint(-100, 100) - second_addend = 10 - first_addend - - # If we want an incorrect answer, then change - # the second addend so they no longer sum to 10 - if correctness == 'incorrect': - second_addend += random.randint(1, 10) - - inputfield('script', input_num=1).fill(str(first_addend)) - inputfield('script', input_num=2).fill(str(second_addend)) - - elif problem_type == 'code': - # The fake xqueue server is configured to respond - # correct / incorrect no matter what we submit. - # Furthermore, since the inline code response uses - # JavaScript to make the code display nicely, it's difficult - # to programatically input text - # (there's not +

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/mktg_coming_soon.html b/lms/templates/courseware/mktg_coming_soon.html new file mode 100644 index 0000000000..c100c1cb5d --- /dev/null +++ b/lms/templates/courseware/mktg_coming_soon.html @@ -0,0 +1,30 @@ +<%! + from django.core.urlresolvers import reverse + from courseware.courses import course_image_url, get_course_about_section + from courseware.access import has_access +%> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../mktg_iframe.html" /> + +<%block name="title">About ${course_id} + +<%block name="bodyclass">view-partial-mktgregister + + +<%block name="headextra"> + <%include file="../google_analytics.html" /> + + +<%block name="content"> + + + + + + + diff --git a/lms/templates/courseware/mktg_course_about.html b/lms/templates/courseware/mktg_course_about.html new file mode 100644 index 0000000000..dc667c850c --- /dev/null +++ b/lms/templates/courseware/mktg_course_about.html @@ -0,0 +1,75 @@ +<%! + from django.core.urlresolvers import reverse + from courseware.courses import course_image_url, get_course_about_section + from courseware.access import has_access +%> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../mktg_iframe.html" /> + +<%block name="title">About ${course.number} + +<%block name="bodyclass">view-partial-mktgregister + + +<%block name="headextra"> + <%include file="../google_analytics.html" /> + + +<%block name="js_extra"> + + + +<%block name="content"> + + +
    +
  • + %if user.is_authenticated() and registered: + %if show_courseware_link: + Access Courseware + %else: +
    You Are Registered
    + %endif + %elif allow_registration: + Register for ${course.number} + %else: +
    Registration Is Closed
    + %endif +
  • +
+ +%if not registered: +
+
+
+ + + +
+
+ +
+ +
+%endif + 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 d23609801f..e2fbaed9cf 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -59,14 +59,16 @@ $("#unenroll_course_number").text( $(event.target).data("course-number") ); }); - $(document).delegate('#unenroll_form', 'ajax:success', function(data, json, xhr) { - if(json.success) { - location.href="${reverse('dashboard')}"; + $('#unenroll_form').on('ajax:complete', function(event, xhr) { + if(xhr.status == 200) { + location.href = "${reverse('dashboard')}"; + } else if (xhr.status == 403) { + location.href = "${reverse('signin_user')}?course_id=" + + $("#unenroll_course_id").val() + "&enrollment_action=unenroll"; } else { - if($('#unenroll_error').length == 0) { - $('#unenroll_form').prepend(''); - } - $('#unenroll_error').text(json.error).stop().css("display", "block"); + $('#unenroll_error').html( + xhr.responseText ? xhr.responseText : "An error occurred. Please try again later." + ).stop().css("display", "block"); } }); @@ -148,6 +150,8 @@ + ## `news` should be `None` whenever a non-edX theme is enabled: + ## see common/djangoapps/student/views.py#_get_news %if news:

${hname}${hname | h}
${value}${value | h}