diff --git a/.gitignore b/.gitignore index 493df5a7fd..8fb170c30f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,6 @@ nosetests.xml cover_html/ .idea/ .redcar/ -chromedriver.log \ No newline at end of file +chromedriver.log +/nbproject +ghostdriver.log diff --git a/.pylintrc b/.pylintrc index ce2f2e3b87..d1cdbb4780 100644 --- a/.pylintrc +++ b/.pylintrc @@ -12,7 +12,7 @@ profile=no # Add files or directories to the blacklist. They should be base names, not # paths. -ignore=CVS +ignore=CVS, migrations # Pickle collected data for later comparisons. persistent=yes @@ -33,7 +33,11 @@ load-plugins= # can either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). -disable=E1102,W0142 +disable= +# W0141: Used builtin function 'map' +# W0142: Used * or ** magic +# R0903: Too few public methods (1/2) + W0141,W0142,R0903 [REPORTS] @@ -43,7 +47,7 @@ disable=E1102,W0142 output-format=text # Include message's id in output -include-ids=no +include-ids=yes # Put messages in a separate file for each module / package specified on the # command line instead of printing them on stdout. Reports (if any) will be @@ -97,7 +101,7 @@ bad-functions=map,filter,apply,input module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Regular expression which should only match correct module level names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ # Regular expression which should only match correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ @@ -106,7 +110,7 @@ class-rgx=[A-Z_][a-zA-Z0-9]+$ function-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ +method-rgx=([a-z_][a-z0-9_]{2,60}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*)$ # Regular expression which should only match correct instance attribute names attr-rgx=[a-z_][a-z0-9_]{2,30}$ diff --git a/.ruby-version b/.ruby-version index dd472cffa2..311baaf3e2 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -1.8.7-p371 \ No newline at end of file +1.9.3-p374 diff --git a/Gemfile b/Gemfile index 43a9f6e2b1..7f7b146978 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,4 @@ -source :rubygems +source 'https://rubygems.org' gem 'rake', '~> 10.0.3' gem 'sass', '3.1.15' gem 'bourbon', '~> 1.3.6' diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 4708a60be1..779d44e4b2 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -7,6 +7,7 @@ Feature: Advanced (manual) course policy When I select the Advanced Settings Then I see only the display name + @skip-phantom Scenario: Test if there are no policy settings without existing UI controls Given I am on the Advanced Course Settings page in Studio When I delete the display name @@ -14,6 +15,7 @@ Feature: Advanced (manual) course policy And I reload the page Then there are no advanced policy settings + @skip-phantom Scenario: Test cancel editing key name Given I am on the Advanced Course Settings page in Studio When I edit the name of a policy key @@ -32,6 +34,7 @@ Feature: Advanced (manual) course policy And I press the "Cancel" notification button 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 diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 91daf70718..1024579b45 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -1,9 +1,10 @@ from lettuce import world, step from common import * import time +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.support import expected_conditions as EC -from nose.tools import assert_equal -from nose.tools import assert_true +from nose.tools import assert_true, assert_false, assert_equal """ http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html @@ -19,6 +20,7 @@ def i_select_advanced_settings(step): css_click(expand_icon_css) link_css = 'li.nav-course-settings-advanced a' css_click(link_css) + # world.browser.click_link_by_text('Advanced Settings') @step('I am on the Advanced Course Settings page in Studio$') @@ -37,13 +39,25 @@ def reload_the_page(step): def edit_the_name_of_a_policy_key(step): policy_key_css = 'input.policy-key' e = css_find(policy_key_css).first - e.fill('new') + e.type('_new') @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): - world.browser.click_link_by_text(name) + def is_visible(driver): + return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) + def is_invisible(driver): + return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,)) + css = 'a.%s-button' % name.lower() + wait_for(is_visible) + + try: + css_click_at(css) + wait_for(is_invisible) + except WebDriverException, e: + css_click_at(css) + wait_for(is_invisible) @step(u'I edit the value of a policy key$') def edit_the_value_of_a_policy_key(step): @@ -83,7 +97,12 @@ def i_see_only_display_name(step): @step('there are no advanced policy settings$') def no_policy_settings(step): - assert_policy_entries([], []) + keys_css = 'input.policy-key' + val_css = 'textarea.json' + k = world.browser.is_element_not_present_by_css(keys_css, 5) + v = world.browser.is_element_not_present_by_css(val_css, 5) + assert_true(k) + assert_true(v) @step('they are alphabetized$') @@ -99,29 +118,29 @@ def it_is_formatted(step): @step(u'the policy key name is unchanged$') def the_policy_key_name_is_unchanged(step): policy_key_css = 'input.policy-key' - e = css_find(policy_key_css).first - assert_equal(e.value, 'display_name') + val = css_find(policy_key_css).first.value + assert_equal(val, 'display_name') @step(u'the policy key name is changed$') def the_policy_key_name_is_changed(step): policy_key_css = 'input.policy-key' - e = css_find(policy_key_css).first - assert_equal(e.value, 'new') + val = css_find(policy_key_css).first.value + assert_equal(val, 'display_name_new') @step(u'the policy key value is unchanged$') def the_policy_key_value_is_unchanged(step): policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' - e = css_find(policy_value_css).first - assert_equal(e.value, '"Robot Super Course"') + val = css_find(policy_value_css).first.value + assert_equal(val, '"Robot Super Course"') @step(u'the policy key value is changed$') def the_policy_key_value_is_unchanged(step): policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' - e = css_find(policy_value_css).first - assert_equal(e.value, '"Robot Super Course X"') + val = css_find(policy_value_css).first.value + assert_equal(val, '"Robot Super Course X"') ############# HELPERS ############### @@ -132,19 +151,23 @@ def create_entry(key, value): new_key_css = 'div#__new_advanced_key__ input' new_key_element = css_find(new_key_css).first new_key_element.fill(key) -# For some reason have to get the instance for each command (get error that it is no longer attached to the DOM) -# Have to do all this because Selenium has a bug that fill does not remove existing text +# For some reason have to get the instance for each command +# (get error that it is no longer attached to the DOM) +# Have to do all this because Selenium fill does not remove existing text new_value_css = 'div.CodeMirror textarea' css_find(new_value_css).last.fill("") css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE) css_find(new_value_css).last.fill(value) + # Add in a TAB key press because intermittently on ubuntu the + # last character of "value" above was not getting typed in + css_find(new_value_css).last._element.send_keys(Keys.TAB) def delete_entry(index): """ Delete the nth entry where index is 0-based """ - css = '.delete-button' + css = 'a.delete-button' assert_true(world.browser.is_element_present_by_css(css, 5)) delete_buttons = css_find(css) assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index)) @@ -152,8 +175,8 @@ def delete_entry(index): def assert_policy_entries(expected_keys, expected_values): - assert_entries('.key input', expected_keys) - assert_entries('.json', expected_values) + assert_entries('.key input.policy-key', expected_keys) + assert_entries('textarea.json', expected_values) def assert_entries(css, expected_values): @@ -165,16 +188,8 @@ def assert_entries(css, expected_values): def click_save(): - css = ".save-button" - - def is_shown(driver): - visible = css_find(css).first.visible - if visible: - # Even when waiting for visible, this fails sporadically. Adding in a small wait. - time.sleep(float(1)) - return visible - wait_for(is_shown) - css_click(css) + css = "a.save-button" + css_click_at(css) def fill_last_field(value): diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 61b4fee9f6..2ec0427e1d 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -3,18 +3,20 @@ from lettuce.django import django_url from nose.tools import assert_true from nose.tools import assert_equal from selenium.webdriver.support.ui import WebDriverWait +from selenium.common.exceptions import WebDriverException, StaleElementReferenceException +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.by import By from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory from terrain.factories import CourseFactory, GroupFactory -import xmodule.modulestore.django +from xmodule.modulestore.django import _MODULESTORES, modulestore +from xmodule.templates import update_templates from auth.authz import get_user_by_email from logging import getLogger logger = getLogger(__name__) ########### STEP HELPERS ############## - - @step('I (?:visit|access|open) the Studio homepage$') def i_visit_the_studio_homepage(step): # To make this go to port 8001, put @@ -52,9 +54,8 @@ def i_have_opened_a_new_course(step): log_into_studio() create_a_course() + ####### HELPER FUNCTIONS ############## - - def create_studio_user( uname='robot', email='robot+studio@edx.org', @@ -83,9 +84,9 @@ def flush_xmodule_store(): # (though it shouldn't), do this manually # from the bash shell to drop it: # $ mongo test_xmodule --eval "db.dropDatabase()" - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - xmodule.templates.update_templates() + _MODULESTORES = {} + modulestore().collection.drop() + update_templates() def assert_css_with_text(css, text): @@ -94,8 +95,16 @@ def assert_css_with_text(css, text): def css_click(css): - assert_true(world.browser.is_element_present_by_css(css, 5)) - world.browser.find_by_css(css).first.click() + ''' + First try to use the regular click method, + but if clicking in the middle of an element + doesn't work it might be that it thinks some other + element is on top of it there so click in the upper left + ''' + try: + css_find(css).first.click() + except WebDriverException, e: + css_click_at(css) def css_click_at(css, x=10, y=10): @@ -103,8 +112,7 @@ def css_click_at(css, x=10, y=10): A method to click at x,y coordinates of the element rather than in the center of the element ''' - assert_true(world.browser.is_element_present_by_css(css, 5)) - e = world.browser.find_by_css(css).first + e = css_find(css).first e.action_chains.move_to_element_with_offset(e._element, x, y) e.action_chains.click() e.action_chains.perform() @@ -115,11 +123,16 @@ def css_fill(css, value): def css_find(css): + def is_visible(driver): + return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) + + world.browser.is_element_present_by_css(css, 5) + wait_for(is_visible) return world.browser.find_by_css(css) def wait_for(func): - WebDriverWait(world.browser.driver, 10).until(func) + WebDriverWait(world.browser.driver, 5).until(func) def id_find(id): diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 75e7a4af10..08d38367bc 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -26,9 +26,10 @@ 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 - Then the section does not exist \ No newline at end of file + Then the section does not exist diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index cfa4e4bb52..b5ddb48a09 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,6 +1,8 @@ from lettuce import world, step from common import * from nose.tools import assert_equal +from selenium.webdriver.common.keys import Keys +import time ############### ACTIONS #################### @@ -37,10 +39,14 @@ def i_save_a_new_section_release_date(step): date_css = 'input.start-date.date.hasDatepicker' time_css = 'input.start-time.time.ui-timepicker-input' css_fill(date_css, '12/25/2013') - # click here to make the calendar go away - css_click(time_css) + # hit TAB to get to the time field + e = css_find(date_css).first + e._element.send_keys(Keys.TAB) css_fill(time_css, '12:00am') - css_click('a.save-button') + e = css_find(time_css).first + e._element.send_keys(Keys.TAB) + time.sleep(float(1)) + world.browser.click_link_by_text('Save') ############ ASSERTIONS ################### @@ -106,7 +112,7 @@ def the_section_release_date_picker_not_visible(step): def the_section_release_date_is_updated(step): css = 'span.published-status' status_text = world.browser.find_by_css(css).text - assert status_text == 'Will Release: 12/25/2013 at 12:00am' + assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am') ############ HELPER METHODS ################### @@ -120,4 +126,4 @@ def save_section_name(name): def see_my_section_on_the_courseware_page(name): section_css = 'span.section-name-span' - assert_css_with_text(section_css, name) \ No newline at end of file + assert_css_with_text(section_css, name) diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index a786225ead..e8d0dd8229 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -1,4 +1,5 @@ from lettuce import world, step +from common import * @step('I fill in the registration form$') @@ -13,10 +14,11 @@ 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): - register_form = world.browser.find_by_css('form#register_form') - submit_css = 'button#submit' - register_form.find_by_css(submit_css).click() - + submit_css = 'form#register_form button#submit' + # Workaround for click not working on ubuntu + # for some unknown reason. + e = css_find(submit_css) + e.type(' ') @step('I should see be on the studio home page$') def i_should_see_be_on_the_studio_home_page(step): diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index 5276b90d12..52c10e41a8 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -21,6 +21,7 @@ Feature: Overview Toggle 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 diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 4b5f5b869d..1be5f4aeb9 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -17,6 +17,7 @@ Feature: Create Subsection And I click to edit the subsection name Then I see the complete subsection name with a quote in the editor + @skip-phantom Scenario: Delete a subsection Given I have opened a new course section in Studio And I have added a new subsection diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 8e4a016a0f..9d533dffed 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -9,10 +9,8 @@ from tempdir import mkdtemp_clean import json from fs.osfs import OSFS import copy -from mock import Mock -from json import dumps, loads +from json import loads -from student.models import Registration from django.contrib.auth.models import User from cms.djangoapps.contentstore.utils import get_modulestore @@ -22,12 +20,11 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore import Location from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.store_utilities import delete_course -from xmodule.modulestore.django import modulestore, _MODULESTORES +from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.templates import update_templates from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor @@ -63,7 +60,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) - def check_edit_unit(self, test_course_name): import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) @@ -82,8 +78,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_static_tab_reordering(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) - ms = modulestore('direct') - course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + module_store = modulestore('direct') + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) # reverse the ordering reverse_tabs = [] @@ -91,9 +87,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): if tab['type'] == 'static_tab': reverse_tabs.insert(0, 'i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) - resp = self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs': reverse_tabs}), "application/json") + self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs': reverse_tabs}), "application/json") - course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) # compare to make sure that the tabs information is in the expected order after the server call course_tabs = [] @@ -103,28 +99,60 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(reverse_tabs, course_tabs) + def test_delete(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + module_store = modulestore('direct') + + sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + + chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) + + # make sure the parent no longer points to the child object which was deleted + self.assertTrue(sequential.location.url() in chapter.definition['children']) + + self.client.post(reverse('delete_item'), + json.dumps({'id': sequential.location.url(), 'delete_children':'true', 'delete_all_versions':'true'}), + "application/json") + + found = False + try: + module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + found = True + except ItemNotFoundError: + pass + + self.assertFalse(found) + + chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) + + # make sure the parent no longer points to the child object which was deleted + self.assertFalse(sequential.location.url() in chapter.definition['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']) - ms = modulestore('direct') - effort = ms.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) + module_store = modulestore('direct') + effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) self.assertEqual(effort.definition['data'], '6 hours') # this one should be in a non-override folder - effort = ms.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None])) + effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None])) self.assertEqual(effort.definition['data'], 'TBD') def test_remove_hide_progress_tab(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) - ms = modulestore('direct') - cs = contentstore() + module_store = modulestore('direct') + content_store = contentstore() source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - course = ms.get_item(source_location) + course = module_store.get_item(source_location) self.assertNotIn('hide_progress_tab', course.metadata) def test_clone_course(self): @@ -143,19 +171,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): data = parse_json(resp) self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') - ms = modulestore('direct') - cs = contentstore() + module_store = modulestore('direct') + content_store = contentstore() source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course') - clone_course(ms, cs, source_location, dest_location) + clone_course(module_store, content_store, source_location, dest_location) # now loop through all the units in the course and verify that the clone can render them, which # means the objects are at least present - items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) self.assertGreater(len(items), 0) - clone_items = ms.get_items(Location(['i4x', 'MITx', '999', 'vertical', None])) + 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') @@ -166,14 +194,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_delete_course(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) - ms = modulestore('direct') - cs = contentstore() + module_store = modulestore('direct') + content_store = contentstore() location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - delete_course(ms, cs, location, commit=True) + delete_course(module_store, content_store, location, commit=True) - items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) self.assertEqual(len(items), 0) def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): @@ -188,10 +216,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertTrue(fs.exists(item.location.name + filename_suffix)) def test_export_course(self): - ms = modulestore('direct') - cs = contentstore() + module_store = modulestore('direct') + content_store = contentstore() - import_from_xml(ms, 'common/test/data/', ['full']) + import_from_xml(module_store, 'common/test/data/', ['full']) location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') root_dir = path(mkdtemp_clean()) @@ -199,43 +227,43 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): print 'Exporting to tempdir = {0}'.format(root_dir) # export out to a tempdir - export_to_xml(ms, cs, location, root_dir, 'test_export') + export_to_xml(module_store, content_store, location, root_dir, 'test_export') # check for static tabs - self.verify_content_existence(ms, root_dir, location, 'tabs', 'static_tab', '.html') + self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html') # check for custom_tags - self.verify_content_existence(ms, root_dir, location, 'info', 'course_info', '.html') + self.verify_content_existence(module_store, root_dir, location, 'info', 'course_info', '.html') # check for custom_tags - self.verify_content_existence(ms, root_dir, location, 'custom_tags', 'custom_tag_template') + self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template') # check for graiding_policy.json fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') self.assertTrue(fs.exists('grading_policy.json')) - course = ms.get_item(location) + 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: - on_disk = loads(grading_policy.read()) + with fs.open('grading_policy.json', 'r') as grading_policy: + on_disk = loads(grading_policy.read()) self.assertEqual(on_disk, course.definition['data']['grading_policy']) #check for policy.json self.assertTrue(fs.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 fs.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'], course.metadata) # remove old course - delete_course(ms, cs, location) + delete_course(module_store, content_store, location) # reimport - import_from_xml(ms, root_dir, ['test_export']) + import_from_xml(module_store, root_dir, ['test_export']) - items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) self.assertGreater(len(items), 0) for descriptor in items: print "Checking {0}....".format(descriptor.location.url()) @@ -245,11 +273,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): shutil.rmtree(root_dir) def test_course_handouts_rewrites(self): - ms = modulestore('direct') - cs = contentstore() + module_store = modulestore('direct') + content_store = contentstore() # import a test course - import_from_xml(ms, 'common/test/data/', ['full']) + import_from_xml(module_store, 'common/test/data/', ['full']) handout_location = Location(['i4x', 'edX', 'full', 'course_info', 'handouts']) @@ -263,7 +291,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # note, we know the link it should be because that's what in the 'full' course in the test data self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + def test_export_course_with_unknown_metadata(self): + module_store = modulestore('direct') + content_store = contentstore() + import_from_xml(module_store, 'common/test/data/', ['full']) + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + root_dir = path(mkdtemp_clean()) + + course = module_store.get_item(location) + + # add a bool piece of unknown metadata so we can verify we don't throw an exception + course.metadata['new_metadata'] = True + + module_store.update_metadata(location, course.metadata) + + print 'Exporting to tempdir = {0}'.format(root_dir) + + # export out to a tempdir + exported = False + try: + export_to_xml(module_store, content_store, location, root_dir, 'test_export') + exported = True + except Exception: + pass + + self.assertTrue(exported) class ContentStoreTest(ModuleStoreTestCase): """ @@ -402,7 +456,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_capa_module(self): """Test that a problem treats markdown specially.""" - course = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') problem_data = { 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', @@ -424,10 +478,10 @@ class ContentStoreTest(ModuleStoreTestCase): def test_import_metadata_with_attempts_empty_string(self): import_from_xml(modulestore(), 'common/test/data/', ['simple']) - ms = modulestore('direct') + module_store = modulestore('direct') did_load_item = False try: - ms.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) + module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) did_load_item = True except ItemNotFoundError: pass @@ -438,10 +492,10 @@ class ContentStoreTest(ModuleStoreTestCase): def test_metadata_inheritance(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) - ms = modulestore('direct') - course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + module_store = modulestore('direct') + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) - verticals = ms.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) + verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) # let's assert on the metadata_inheritance on an existing vertical for vertical in verticals: @@ -452,15 +506,15 @@ class ContentStoreTest(ModuleStoreTestCase): new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component') source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') - + # crate a new module and add it as a child to a vertical - ms.clone_item(source_template_location, new_component_location) + module_store.clone_item(source_template_location, new_component_location) parent = verticals[0] - ms.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()]) + module_store.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()]) # flush the cache - ms.get_cached_metadata_inheritance_tree(new_component_location, -1) - new_module = ms.get_item(new_component_location) + module_store.get_cached_metadata_inheritance_tree(new_component_location, -1) + new_module = module_store.get_item(new_component_location) # check for grace period definition which should be defined at the course level self.assertIn('graceperiod', new_module.metadata) @@ -473,11 +527,11 @@ class ContentStoreTest(ModuleStoreTestCase): # now let's define an override at the leaf node level # new_module.metadata['graceperiod'] = '1 day' - ms.update_metadata(new_module.location, new_module.metadata) + module_store.update_metadata(new_module.location, new_module.metadata) # flush the cache and refetch - ms.get_cached_metadata_inheritance_tree(new_component_location, -1) - new_module = ms.get_item(new_component_location) + module_store.get_cached_metadata_inheritance_tree(new_component_location, -1) + new_module = module_store.get_item(new_component_location) self.assertIn('graceperiod', new_module.metadata) self.assertEqual('1 day', new_module.metadata['graceperiod']) @@ -486,15 +540,15 @@ class ContentStoreTest(ModuleStoreTestCase): class TemplateTestCase(ModuleStoreTestCase): def test_template_cleanup(self): - ms = modulestore('direct') + module_store = modulestore('direct') # insert a bogus template in the store bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus') source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') - - ms.clone_item(source_template_location, bogus_template_location) - verify_create = ms.get_item(bogus_template_location) + module_store.clone_item(source_template_location, bogus_template_location) + + verify_create = module_store.get_item(bogus_template_location) self.assertIsNotNone(verify_create) # now run cleanup @@ -503,10 +557,9 @@ class TemplateTestCase(ModuleStoreTestCase): # now try to find dangling template, it should not be in DB any longer asserted = False try: - verify_create = ms.get_item(bogus_template_location) + verify_create = module_store.get_item(bogus_template_location) except ItemNotFoundError: asserted = True - self.assertTrue(asserted) - + self.assertTrue(asserted) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 34003d71a4..6566350f8d 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -86,12 +86,14 @@ def signup(request): csrf_token = csrf(request)['csrf_token'] return render_to_response('signup.html', {'csrf': csrf_token}) + def old_login_redirect(request): ''' Redirect to the active login url. ''' return redirect('login', permanent=True) + @ssl_login_shortcut @ensure_csrf_cookie def login_page(request): @@ -104,6 +106,7 @@ def login_page(request): 'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE), }) + def howitworks(request): if request.user.is_authenticated(): return index(request) @@ -112,6 +115,7 @@ def howitworks(request): # ==== Views for any logged-in user ================================== + @login_required @ensure_csrf_cookie def index(request): @@ -145,6 +149,7 @@ def index(request): # ==== Views with per-item permissions================================ + def has_access(user, location, role=STAFF_ROLE_NAME): ''' Return True if user allowed to access this piece of data @@ -393,6 +398,7 @@ def preview_component(request, location): 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), }) + @expect_json @login_required @ensure_csrf_cookie @@ -636,6 +642,17 @@ def delete_item(request): if item.location.revision is None and item.location.category == 'vertical' and delete_all_versions: modulestore('direct').delete_item(item.location) + # cdodge: we need to remove our parent's pointer to us so that it is no longer dangling + if delete_all_versions: + parent_locs = modulestore('direct').get_parent_locations(item_loc, None) + + for parent_loc in parent_locs: + parent = modulestore('direct').get_item(parent_loc) + item_url = item_loc.url() + if item_url in parent.definition["children"]: + parent.definition["children"].remove(item_url) + modulestore('direct').update_children(parent.location, parent.definition["children"]) + return HttpResponse() @@ -709,6 +726,7 @@ def create_draft(request): return HttpResponse() + @login_required @expect_json def publish_draft(request): @@ -738,6 +756,7 @@ def unpublish_unit(request): return HttpResponse() + @login_required @expect_json def clone_item(request): @@ -768,8 +787,7 @@ def clone_item(request): return HttpResponse(json.dumps({'id': dest_location.url()})) -#@login_required -#@ensure_csrf_cookie + def upload_asset(request, org, course, coursename): ''' cdodge: this method allows for POST uploading of files into the course asset library, which will @@ -831,6 +849,7 @@ def upload_asset(request, org, course, coursename): response['asset_url'] = StaticContent.get_url_path_from_location(content.location) return response + ''' This view will return all CMS users who are editors for the specified course ''' @@ -863,6 +882,7 @@ def create_json_response(errmsg = None): return resp + ''' This POST-back view will add a user - specified by email - to the list of editors for the specified course @@ -895,6 +915,7 @@ def add_user(request, location): return create_json_response() + ''' This POST-back view will remove a user - specified by email - from the list of editors for the specified course @@ -926,6 +947,7 @@ def remove_user(request, location): def landing(request, org, course, coursename): return render_to_response('temp-course-landing.html', {}) + @login_required @ensure_csrf_cookie def static_pages(request, org, course, coursename): @@ -1029,6 +1051,7 @@ def edit_tabs(request, org, course, coursename): 'components': components }) + def not_found(request): return render_to_response('error.html', {'error': '404'}) @@ -1064,6 +1087,7 @@ def course_info(request, org, course, name, provided_id=None): 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() }) + @expect_json @login_required @ensure_csrf_cookie @@ -1161,6 +1185,7 @@ def get_course_settings(request, org, course, name): "section": "details"}) }) + @login_required @ensure_csrf_cookie def course_config_graders_page(request, org, course, name): @@ -1184,6 +1209,7 @@ def course_config_graders_page(request, org, course, name): 'course_details': json.dumps(course_details, cls=CourseSettingsEncoder) }) + @login_required @ensure_csrf_cookie def course_config_advanced_page(request, org, course, name): @@ -1207,6 +1233,7 @@ def course_config_advanced_page(request, org, course, name): 'advanced_dict' : json.dumps(CourseMetadata.fetch(location)), }) + @expect_json @login_required @ensure_csrf_cookie @@ -1238,6 +1265,7 @@ def course_settings_updates(request, org, course, name, section): return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), mimetype="application/json") + @expect_json @login_required @ensure_csrf_cookie @@ -1272,7 +1300,7 @@ def course_grader_updates(request, org, course, name, grader_index=None): return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)), mimetype="application/json") - + ## NB: expect_json failed on ["key", "key2"] and json payload @login_required @ensure_csrf_cookie @@ -1363,6 +1391,7 @@ def asset_index(request, org, course, name): def edge(request): return render_to_response('university_profiles/edge.html', {}) + @login_required @expect_json def create_new_course(request): @@ -1418,6 +1447,7 @@ def create_new_course(request): return HttpResponse(json.dumps({'id': new_course.location.url()})) + def initialize_course_tabs(course): # set up the default tabs # I've added this because when we add static tabs, the LMS either expects a None for the tabs list or @@ -1435,6 +1465,7 @@ def initialize_course_tabs(course): modulestore('direct').update_metadata(course.location.url(), course.own_metadata) + @ensure_csrf_cookie @login_required def import_course(request, org, course, name): @@ -1512,6 +1543,7 @@ def import_course(request, org, course, name): course_module.location.name]) }) + @ensure_csrf_cookie @login_required def generate_export_course(request, org, course, name): @@ -1563,6 +1595,7 @@ def export_course(request, org, course, name): 'successful_import_redirect_url': '' }) + def event(request): ''' A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index d088d75665..24245a39d5 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -10,7 +10,7 @@ class CourseMetadata(object): ''' # __new_advanced_key__ is used by client not server; so, could argue against it being here FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__'] - + @classmethod def fetch(cls, course_location): """ @@ -18,17 +18,17 @@ class CourseMetadata(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + course = {} - + descriptor = get_modulestore(course_location).get_item(course_location) - + for k, v in descriptor.metadata.iteritems(): if k not in cls.FILTERED_LIST: course[k] = v - + return course - + @classmethod def update_from_json(cls, course_location, jsondict): """ @@ -37,7 +37,7 @@ class CourseMetadata(object): Ensures none of the fields are in the blacklist. """ descriptor = get_modulestore(course_location).get_item(course_location) - + dirty = False for k, v in jsondict.iteritems(): @@ -45,26 +45,26 @@ class CourseMetadata(object): if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v): dirty = True descriptor.metadata[k] = v - + if dirty: get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) - + # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm # it persisted correctly return cls.fetch(course_location) - + @classmethod def delete_key(cls, course_location, payload): ''' Remove the given metadata key(s) from the course. payload can be a single key or [key..] ''' descriptor = get_modulestore(course_location).get_item(course_location) - + for key in payload['deleteKeys']: if key in descriptor.metadata: del descriptor.metadata[key] - + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) - + return cls.fetch(course_location) \ No newline at end of file diff --git a/cms/envs/aws.py b/cms/envs/aws.py index a147f84531..be7816d21f 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -62,3 +62,6 @@ AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] DATABASES = AUTH_TOKENS['DATABASES'] MODULESTORE = AUTH_TOKENS['MODULESTORE'] CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] + +# Datadog for events! +DATADOG_API = AUTH_TOKENS.get("DATADOG_API") \ No newline at end of file diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 3dee93a398..9164c02e3f 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -4,9 +4,6 @@ This config file runs the simplest dev environment""" from .common import * from logsettings import get_logger_config -import logging -import sys - DEBUG = True TEMPLATE_DEBUG = DEBUG LOGGING = get_logger_config(ENV_ROOT / "log", @@ -107,3 +104,36 @@ CACHE_TIMEOUT = 0 # Dummy secret key for dev SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' + +################################ DEBUG TOOLBAR ################################# +INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo') +MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware', + 'debug_toolbar.middleware.DebugToolbarMiddleware',) +INTERNAL_IPS = ('127.0.0.1',) + +DEBUG_TOOLBAR_PANELS = ( + 'debug_toolbar.panels.version.VersionDebugPanel', + 'debug_toolbar.panels.timer.TimerDebugPanel', + 'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel', + 'debug_toolbar.panels.headers.HeaderDebugPanel', + 'debug_toolbar.panels.request_vars.RequestVarsDebugPanel', + 'debug_toolbar.panels.sql.SQLDebugPanel', + 'debug_toolbar.panels.signals.SignalDebugPanel', + 'debug_toolbar.panels.logger.LoggingPanel', +# This is breaking Mongo updates-- Christina is investigating. +# 'debug_toolbar_mongo.panel.MongoDebugPanel', + + # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and + # Django=1.3.1/1.4 where requests to views get duplicated (your method gets + # hit twice). So you can uncomment when you need to diagnose performance + # problems, but you shouldn't leave it on. + # 'debug_toolbar.panels.profiling.ProfilingDebugPanel', + ) + +DEBUG_TOOLBAR_CONFIG = { + 'INTERCEPT_REDIRECTS': False +} + +# To see stacktraces for MongoDB queries, set this to True. +# Stacktraces slow down page loads drastically (for pages with lots of queries). +# DEBUG_TOOLBAR_MONGO_STACKTRACES = False diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py new file mode 100644 index 0000000000..93428a3404 --- /dev/null +++ b/cms/one_time_startup.py @@ -0,0 +1,6 @@ +from dogapi import dog_http_api, dog_stats_api +from django.conf import settings + +if hasattr(settings, 'DATADOG_API'): + dog_http_api.api_key = settings.DATADOG_API + dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index d20a21f7e7..a933bbdb9b 100644 --- a/cms/static/js/views/settings/advanced_view.js +++ b/cms/static/js/views/settings/advanced_view.js @@ -31,7 +31,8 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ // because these are outside of this.$el, they can't be in the event hash $('.save-button').on('click', this, this.saveView); $('.cancel-button').on('click', this, this.revertView); - this.model.on('error', this.handleValidationError, this); + this.listenTo(this.model, 'error', CMS.ServerError); + this.listenTo(this.model, 'invalid', this.handleValidationError); }, render: function() { // catch potential outside call before template loaded @@ -228,7 +229,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ var error = {}; error[oldKey] = 'You have already defined "' + newKey + '" in the manual policy definitions.'; error[newKey] = "You tried to enter a duplicate of this key."; - this.model.trigger("error", this.model, error); + this.model.trigger("invalid", this.model, error); return false; } @@ -244,7 +245,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ // swap to the key which the map knows about validation[oldKey] = validation[newKey]; } - this.model.trigger("error", this.model, validation); + this.model.trigger("invalid", this.model, validation); // abandon update return; } diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 8f998dbf7a..9bd8feab8c 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -26,7 +26,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ var dateIntrospect = new Date(); this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); - this.model.on('error', this.handleValidationError, this); + this.listenTo(this.model, 'error', CMS.ServerError); + this.listenTo(this.model, 'invalid', this.handleValidationError); this.selectorToField = _.invert(this.fieldToSelectorMap); }, diff --git a/cms/static/js/views/settings/settings_grading_view.js b/cms/static/js/views/settings/settings_grading_view.js index a7c8defb43..78972f97a7 100644 --- a/cms/static/js/views/settings/settings_grading_view.js +++ b/cms/static/js/views/settings/settings_grading_view.js @@ -44,7 +44,8 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ self.render(); } ); - this.model.on('error', this.handleValidationError, this); + this.listenTo(this.model, 'error', CMS.ServerError); + this.listenTo(this.model, 'invalid', this.handleValidationError); this.model.get('graders').on('remove', this.render, this); this.model.get('graders').on('reset', this.render, this); this.model.get('graders').on('add', this.render, this); @@ -316,7 +317,8 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({ 'blur :input' : "inputUnfocus" }, initialize : function() { - this.model.on('error', this.handleValidationError, this); + this.listenTo(this.model, 'error', CMS.ServerError); + this.listenTo(this.model, 'invalid', this.handleValidationError); this.selectorToField = _.invert(this.fieldToSelectorMap); this.render(); }, diff --git a/cms/static/js/views/validating_view.js b/cms/static/js/views/validating_view.js index e4928a8ebe..041e779030 100644 --- a/cms/static/js/views/validating_view.js +++ b/cms/static/js/views/validating_view.js @@ -3,7 +3,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({ // decorates the fields. Needs wiring per class, but this initialization shows how // either have your init call this one or copy the contents initialize : function() { - this.model.on('error', this.handleValidationError, this); + this.listenTo(this.model, 'error', CMS.ServerError); + this.listenTo(this.model, 'invalid', this.handleValidationError); this.selectorToField = _.invert(this.fieldToSelectorMap); }, @@ -18,20 +19,11 @@ CMS.Views.ValidatingView = Backbone.View.extend({ // which may be the subjects of validation errors }, _cacheValidationErrors : [], + handleValidationError : function(model, error) { - // error triggered either by validation or server error // error is object w/ fields and error strings for (var field in error) { var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); - if (ele.length === 0) { - // check if it might a server error: note a typo in the field name - // or failure to put in a map may cause this to muffle validation errors - if (_.has(error, 'error') && _.has(error, 'responseText')) { - CMS.ServerError(model, error); - return; - } - else continue; - } this._cacheValidationErrors.push(ele); if ($(ele).is('div')) { // put error on the contained inputs diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 5ace98df56..a5a9144b07 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -28,7 +28,7 @@ {{uploadDate}} - + @@ -84,7 +84,7 @@ ${asset['uploadDate']} - + % endfor @@ -115,7 +115,7 @@
- +
diff --git a/cms/urls.py b/cms/urls.py index 7b7b5e9375..d43b9bc44c 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -1,5 +1,6 @@ from django.conf import settings from django.conf.urls import patterns, include, url +from . import one_time_startup # Uncomment the next two lines to enable the admin: # from django.contrib import admin diff --git a/common/djangoapps/course_groups/cohorts.py b/common/djangoapps/course_groups/cohorts.py index f0234ec71a..c362ed4e89 100644 --- a/common/djangoapps/course_groups/cohorts.py +++ b/common/djangoapps/course_groups/cohorts.py @@ -65,23 +65,23 @@ def is_commentable_cohorted(course_id, commentable_id): ans)) return ans - + def get_cohorted_commentables(course_id): """ Given a course_id return a list of strings representing cohorted commentables """ course = courses.get_course_by_id(course_id) - + if not course.is_cohorted: # this is the easy case :) ans = [] - else: + else: ans = course.cohorted_discussions return ans - - + + def get_cohort(user, course_id): """ Given a django User and a course_id, return the user's cohort in that @@ -120,7 +120,8 @@ def get_cohort(user, course_id): return None choices = course.auto_cohort_groups - if len(choices) == 0: + n = len(choices) + if n == 0: # Nowhere to put user log.warning("Course %s is auto-cohorted, but there are no" " auto_cohort_groups specified", @@ -128,12 +129,19 @@ def get_cohort(user, course_id): return None # Put user in a random group, creating it if needed - group_name = random.choice(choices) + choice = random.randrange(0, n) + group_name = choices[choice] + + # Victor: we are seeing very strange behavior on prod, where almost all users + # end up in the same group. Log at INFO to try to figure out what's going on. + log.info("DEBUG: adding user {0} to cohort {1}. choice={2}".format( + user, group_name,choice)) + group, created = CourseUserGroup.objects.get_or_create( course_id=course_id, group_type=CourseUserGroup.COHORT, name=group_name) - + user.course_groups.add(group) return group diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index efed39d536..88d9c1f508 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -6,7 +6,7 @@ from django.test.utils import override_settings from course_groups.models import CourseUserGroup from course_groups.cohorts import (get_cohort, get_course_cohorts, - is_commentable_cohorted) + is_commentable_cohorted, get_cohort_by_name) from xmodule.modulestore.django import modulestore, _MODULESTORES @@ -168,7 +168,7 @@ class TestCohorts(django.test.TestCase): self.assertEquals(get_cohort(user3, course.id), None, "No groups->no auto-cohorting") - + # Now make it different self.config_course_cohorts(course, [], cohorted=True, auto_cohort=True, @@ -180,6 +180,37 @@ class TestCohorts(django.test.TestCase): "user2 should still be in originally placed cohort") + def test_auto_cohorting_randomization(self): + """ + Make sure get_cohort() randomizes properly. + """ + course = modulestore().get_course("edX/toy/2012_Fall") + self.assertEqual(course.id, "edX/toy/2012_Fall") + self.assertFalse(course.is_cohorted) + + groups = ["group_{0}".format(n) for n in range(5)] + self.config_course_cohorts(course, [], cohorted=True, + auto_cohort=True, + auto_cohort_groups=groups) + + # Assign 100 users to cohorts + for i in range(100): + user = User.objects.create(username="test_{0}".format(i), + email="a@b{0}.com".format(i)) + get_cohort(user, course.id) + + # Now make sure that the assignment was at least vaguely random: + # each cohort should have at least 1, and fewer than 50 students. + # (with 5 groups, probability of 0 users in any group is about + # .8**100= 2.0e-10) + for cohort_name in groups: + cohort = get_cohort_by_name(course.id, cohort_name) + num_users = cohort.users.count() + self.assertGreater(num_users, 1) + self.assertLess(num_users, 50) + + + def test_get_course_cohorts(self): course1_id = 'a/b/c' course2_id = 'e/f/g' diff --git a/common/djangoapps/heartbeat/views.py b/common/djangoapps/heartbeat/views.py index 956504407b..d7c3a32192 100644 --- a/common/djangoapps/heartbeat/views.py +++ b/common/djangoapps/heartbeat/views.py @@ -2,8 +2,9 @@ import json from datetime import datetime from django.http import HttpResponse from xmodule.modulestore.django import modulestore +from dogapi import dog_stats_api - +@dog_stats_api.timed('edxapp.heartbeat') def heartbeat(request): """ Simple view that a loadbalancer can check to verify that the app is up diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index fb1f48d143..b73a658c5f 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -84,12 +84,19 @@ def replace_static_urls(text, data_directory, course_namespace=None): if rest.endswith('?raw'): return original - # course_namespace is not None, then use studio style urls - if course_namespace is not None and not isinstance(modulestore(), XMLModuleStore): - url = StaticContent.convert_legacy_static_url(rest, course_namespace) # In debug mode, if we can find the url as is, - elif settings.DEBUG and finders.find(rest, True): + if settings.DEBUG and finders.find(rest, True): return original + # if we're running with a MongoBacked store course_namespace is not None, then use studio style urls + elif course_namespace is not None and not isinstance(modulestore(), XMLModuleStore): + # first look in the static file pipeline and see if we are trying to reference + # a piece of static content which is in the mitx repo (e.g. JS associated with an xmodule) + if staticfiles_storage.exists(rest): + url = staticfiles_storage.url(rest) + else: + # if not, then assume it's courseware specific content and then look in the + # Mongo-backed database + url = StaticContent.convert_legacy_static_url(rest, course_namespace) # Otherwise, look the file up in staticfiles_storage, and append the data directory if needed else: course_path = "/".join((data_directory, rest)) diff --git a/common/djangoapps/student/management/commands/pearson_transfer.py b/common/djangoapps/student/management/commands/pearson_transfer.py index 5eded6484a..75716c7443 100644 --- a/common/djangoapps/student/management/commands/pearson_transfer.py +++ b/common/djangoapps/student/management/commands/pearson_transfer.py @@ -10,6 +10,7 @@ import paramiko import boto dog_http_api.api_key = settings.DATADOG_API +dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) class Command(BaseCommand): diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py index 8c2a8ba7a5..0881d86124 100644 --- a/common/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -13,6 +13,7 @@ from django.core.management import call_command def initial_setup(server): # Launch the browser app (choose one of these below) world.browser = Browser('chrome') + # world.browser = Browser('phantomjs') # world.browser = Browser('firefox') diff --git a/common/lib/capa/capa/calc.py b/common/lib/capa/capa/calc.py index 0f062d17d5..c3fe6b656b 100644 --- a/common/lib/capa/capa/calc.py +++ b/common/lib/capa/capa/calc.py @@ -183,7 +183,7 @@ def evaluator(variables, functions, string, cs=False): # 0.33k or -17 number = (Optional(minus | plus) + inner_number - + Optional(CaselessLiteral("E") + Optional("-") + number_part) + + Optional(CaselessLiteral("E") + Optional((plus | minus)) + number_part) + Optional(number_suffix)) number = number.setParseAction(number_parse_action) # Convert to number diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index fb0b63b83c..8b32686985 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -29,6 +29,7 @@ import sys from lxml import etree from xml.sax.saxutils import unescape +from copy import deepcopy import chem import chem.chemcalc @@ -146,6 +147,13 @@ class LoncapaProblem(object): if not self.student_answers: # True when student_answers is an empty dict self.set_initial_display() + # dictionary of InputType objects associated with this problem + # input_id string -> InputType object + self.inputs = {} + + self.extracted_tree = self._extract_html(self.tree) + + def do_reset(self): ''' Reset internal state to unfinished, with no answers @@ -324,7 +332,27 @@ class LoncapaProblem(object): ''' Main method called externally to get the HTML to be rendered for this capa Problem. ''' - return contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) + html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) + return html + + + def handle_input_ajax(self, get): + ''' + InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data + + Also, parse out the dispatch from the get so that it can be passed onto the input type nicely + ''' + + # pull out the id + input_id = get['input_id'] + if self.inputs[input_id]: + dispatch = get['dispatch'] + return self.inputs[input_id].handle_ajax(dispatch, get) + else: + log.warning("Could not find matching input for id: %s" % problem_id) + return {} + + # ======= Private Methods Below ======== @@ -458,6 +486,8 @@ class LoncapaProblem(object): finally: sys.path = original_path + + def _extract_html(self, problemtree): # private ''' Main (private) function which converts Problem XML tree to HTML. @@ -471,7 +501,7 @@ class LoncapaProblem(object): if (problemtree.tag == 'script' and problemtree.get('type') and 'javascript' in problemtree.get('type')): # leave javascript intact. - return problemtree + return deepcopy(problemtree) if problemtree.tag in html_problem_semantics: return @@ -484,8 +514,9 @@ class LoncapaProblem(object): msg = '' hint = '' hintmode = None + input_id = problemtree.get('id') if problemid in self.correct_map: - pid = problemtree.get('id') + pid = input_id status = self.correct_map.get_correctness(pid) msg = self.correct_map.get_msg(pid) hint = self.correct_map.get_hint(pid) @@ -496,17 +527,17 @@ class LoncapaProblem(object): value = self.student_answers[problemid] # do the rendering - state = {'value': value, 'status': status, - 'id': problemtree.get('id'), + 'id': input_id, 'feedback': {'message': msg, 'hint': hint, 'hintmode': hintmode, }} input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) - the_input = input_type_cls(self.system, problemtree, state) - return the_input.get_html() + # save the input type so that we can make ajax calls on it if we need to + self.inputs[input_id] = input_type_cls(self.system, problemtree, state) + return self.inputs[input_id].get_html() # let each Response render itself if problemtree in self.responders: diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 9e76fc20bf..ea56863a2f 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -95,7 +95,7 @@ class CorrectMap(object): def is_correct(self, answer_id): if answer_id in self.cmap: - return self.cmap[answer_id]['correctness'] == 'correct' + return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct'] return None def is_queued(self, answer_id): @@ -111,15 +111,14 @@ class CorrectMap(object): return None def get_npoints(self, answer_id): - """ Return the number of points for an answer: - If the answer is correct, return the assigned - number of points (default: 1 point) - Otherwise, return 0 points """ - if self.is_correct(answer_id): - npoints = self.get_property(answer_id, 'npoints') - return npoints if npoints is not None else 1 - else: - return 0 + """Return the number of points for an answer, used for partial credit.""" + npoints = self.get_property(answer_id, 'npoints') + if npoints is not None: + return npoints + elif self.is_correct(answer_id): + return 1 + # if not correct and no points have been assigned, return 0 + return 0 def set_property(self, answer_id, property, value): if answer_id in self.cmap: diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 951104501a..9781f10ae6 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -45,8 +45,10 @@ import re import shlex # for splitting quoted strings import sys import os +import pyparsing from registry import TagRegistry +from capa.chem import chemcalc log = logging.getLogger('mitx.' + __name__) @@ -215,6 +217,18 @@ class InputTypeBase(object): """ pass + def handle_ajax(self, dispatch, get): + """ + InputTypes that need to handle specialized AJAX should override this. + + Input: + dispatch: a string that can be used to determine how to handle the data passed in + get: a dictionary containing the data that was sent with the ajax call + + Output: + a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. + """ + pass def _get_render_context(self): """ @@ -352,6 +366,12 @@ class ChoiceGroup(InputTypeBase): self.choices = self.extract_choices(self.xml) + @classmethod + def get_attributes(cls): + return [Attribute("show_correctness", "always"), + Attribute("submitted_message", "Answer received.")] + + def _extra_context(self): return {'input_type': self.html_input_type, 'choices': self.choices, @@ -740,6 +760,45 @@ class ChemicalEquationInput(InputTypeBase): """ return {'previewer': '/static/js/capa/chemical_equation_preview.js', } + def handle_ajax(self, dispatch, get): + ''' + Since we only have chemcalc preview this input, check to see if it + matches the corresponding dispatch and send it through if it does + ''' + if dispatch == 'preview_chemcalc': + return self.preview_chemcalc(get) + return {} + + def preview_chemcalc(self, get): + """ + Render an html preview of a chemical formula or equation. get should + contain a key 'formula' and value 'some formula string'. + + Returns a json dictionary: + { + 'preview' : 'the-preview-html' or '' + 'error' : 'the-error' or '' + } + """ + + result = {'preview': '', + 'error': ''} + formula = get['formula'] + if formula is None: + result['error'] = "No formula specified." + return result + + try: + result['preview'] = chemcalc.render_to_html(formula) + except pyparsing.ParseException as p: + result['error'] = "Couldn't parse formula: {0}".format(p) + except Exception: + # this is unexpected, so log + log.warning("Error while previewing chemical formula", exc_info=True) + result['error'] = "Error while rendering preview" + + return result + registry.register(ChemicalEquationInput) #----------------------------------------------------------------------------- @@ -909,33 +968,142 @@ registry.register(DesignProtein2dInput) class EditAGeneInput(InputTypeBase): """ An input type for editing a gene. Integrates with the genex java applet. - + Example: - + """ - + template = "editageneinput.html" tags = ['editageneinput'] - + @classmethod def get_attributes(cls): """ - Note: width, hight, and dna_sequencee are required. - """ + Note: width, height, and dna_sequencee are required. + """ return [Attribute('width'), Attribute('height'), - Attribute('dna_sequence') + Attribute('dna_sequence'), + Attribute('genex_problem_number') ] - + def _extra_context(self): """ """ context = { 'applet_loader': '/static/js/capa/edit-a-gene.js', } - + return context registry.register(EditAGeneInput) +#--------------------------------------------------------------------- + +class AnnotationInput(InputTypeBase): + """ + Input type for annotations: students can enter some notes or other text + (currently ungraded), and then choose from a set of tags/optoins, which are graded. + + Example: + + + Annotation Exercise + + They are the ones who, at the public assembly, had put savage derangement [ate] into my thinking + [phrenes] |89 on that day when I myself deprived Achilles of his honorific portion [geras] + + Agamemnon says that ate or 'derangement' was the cause of his actions: why could Zeus say the same thing? + Type a commentary below: + Select one tag: + + + + + + + + # TODO: allow ordering to be randomized + """ + + template = "annotationinput.html" + tags = ['annotationinput'] + + def setup(self): + xml = self.xml + + self.debug = False # set to True to display extra debug info with input + self.return_to_annotation = True # return only works in conjunction with annotatable xmodule + + self.title = xml.findtext('./title', 'Annotation Exercise') + self.text = xml.findtext('./text') + self.comment = xml.findtext('./comment') + self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:') + self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:') + self.options = self._find_options() + + # Need to provide a value that JSON can parse if there is no + # student-supplied value yet. + if self.value == '': + self.value = 'null' + + self._validate_options() + + def _find_options(self): + ''' Returns an array of dicts where each dict represents an option. ''' + elements = self.xml.findall('./options/option') + return [{ + 'id': index, + 'description': option.text, + 'choice': option.get('choice') + } for (index, option) in enumerate(elements) ] + + def _validate_options(self): + ''' Raises a ValueError if the choice attribute is missing or invalid. ''' + valid_choices = ('correct', 'partially-correct', 'incorrect') + for option in self.options: + choice = option['choice'] + if choice is None: + raise ValueError('Missing required choice attribute.') + elif choice not in valid_choices: + raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices))) + + def _unpack(self, json_value): + ''' Unpacks the json input state into a dict. ''' + d = json.loads(json_value) + if type(d) != dict: + d = {} + + comment_value = d.get('comment', '') + if not isinstance(comment_value, basestring): + comment_value = '' + + options_value = d.get('options', []) + if not isinstance(options_value, list): + options_value = [] + + return { + 'options_value': options_value, + 'has_options_value': len(options_value) > 0, # for convenience + 'comment_value': comment_value, + } + + def _extra_context(self): + extra_context = { + 'title': self.title, + 'text': self.text, + 'comment': self.comment, + 'comment_prompt': self.comment_prompt, + 'tag_prompt': self.tag_prompt, + 'options': self.options, + 'return_to_annotation': self.return_to_annotation, + 'debug': self.debug + } + + extra_context.update(self._unpack(self.value)) + + return extra_context + +registry.register(AnnotationInput) + diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 97077ace8f..d49d030df5 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -911,7 +911,8 @@ def sympy_check2(): allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput', 'vsepr_input', 'drag_and_drop_input', 'editamoleculeinput', - 'designprotein2dinput', 'editageneinput'] + 'designprotein2dinput', 'editageneinput', + 'annotationinput'] def setup_response(self): xml = self.xml @@ -1943,6 +1944,117 @@ class ImageResponse(LoncapaResponse): dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements])) #----------------------------------------------------------------------------- +class AnnotationResponse(LoncapaResponse): + ''' + Checking of annotation responses. + + The response contains both a comment (student commentary) and an option (student tag). + Only the tag is currently graded. Answers may be incorrect, partially correct, or correct. + ''' + response_tag = 'annotationresponse' + allowed_inputfields = ['annotationinput'] + max_inputfields = 1 + default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2 } + def setup_response(self): + xml = self.xml + self.scoring_map = self._get_scoring_map() + self.answer_map = self._get_answer_map() + self.maxpoints = self._get_max_points() + + def get_score(self, student_answers): + ''' Returns a CorrectMap for the student answer, which may include + partially correct answers.''' + student_answer = student_answers[self.answer_id] + student_option = self._get_submitted_option_id(student_answer) + + scoring = self.scoring_map[self.answer_id] + is_valid = student_option is not None and student_option in scoring.keys() + + (correctness, points) = ('incorrect', None) + if is_valid: + correctness = scoring[student_option]['correctness'] + points = scoring[student_option]['points'] + + return CorrectMap(self.answer_id, correctness=correctness, npoints=points) + + def get_answers(self): + return self.answer_map + + def _get_scoring_map(self): + ''' Returns a dict of option->scoring for each input. ''' + scoring = self.default_scoring + choices = dict([(choice,choice) for choice in scoring]) + scoring_map = {} + + for inputfield in self.inputfields: + option_scoring = dict([(option['id'], { + 'correctness': choices.get(option['choice']), + 'points': scoring.get(option['choice']) + }) for option in self._find_options(inputfield) ]) + + scoring_map[inputfield.get('id')] = option_scoring + + return scoring_map + + def _get_answer_map(self): + ''' Returns a dict of answers for each input.''' + answer_map = {} + for inputfield in self.inputfields: + correct_option = self._find_option_with_choice(inputfield, 'correct') + if correct_option is not None: + answer_map[inputfield.get('id')] = correct_option.get('description') + return answer_map + + def _get_max_points(self): + ''' Returns a dict of the max points for each input: input id -> maxpoints. ''' + scoring = self.default_scoring + correct_points = scoring.get('correct') + return dict([(inputfield.get('id'), correct_points) for inputfield in self.inputfields]) + + def _find_options(self, inputfield): + ''' Returns an array of dicts where each dict represents an option. ''' + elements = inputfield.findall('./options/option') + return [{ + 'id': index, + 'description': option.text, + 'choice': option.get('choice') + } for (index, option) in enumerate(elements) ] + + def _find_option_with_choice(self, inputfield, choice): + ''' Returns the option with the given choice value, otherwise None. ''' + for option in self._find_options(inputfield): + if option['choice'] == choice: + return option + + def _unpack(self, json_value): + ''' Unpacks a student response value submitted as JSON.''' + d = json.loads(json_value) + if type(d) != dict: + d = {} + + comment_value = d.get('comment', '') + if not isinstance(d, basestring): + comment_value = '' + + options_value = d.get('options', []) + if not isinstance(options_value, list): + options_value = [] + + return { + 'options_value': options_value, + 'comment_value': comment_value + } + + def _get_submitted_option_id(self, student_answer): + ''' Return the single option that was selected, otherwise None.''' + submitted = self._unpack(student_answer) + option_ids = submitted['options_value'] + if len(option_ids) == 1: + return option_ids[0] + return None + +#----------------------------------------------------------------------------- + # TEMPORARY: List of all response subclasses # FIXME: To be replaced by auto-registration @@ -1959,4 +2071,5 @@ __all__ = [CodeResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse, - JavascriptResponse] + JavascriptResponse, + AnnotationResponse] diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html new file mode 100644 index 0000000000..e0172bb13b --- /dev/null +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -0,0 +1,70 @@ + +
+ +
+ ${title} + + % if return_to_annotation: + Return to Annotation
+ % endif +
+
+ +
${text}
+
${comment}
+ +
${comment_prompt}
+ + +
${tag_prompt}
+
    + % for option in options: +
  • + % if has_options_value: + % if all([c == 'correct' for c in option['choice'], status]): + + % elif all([c == 'partially-correct' for c in option['choice'], status]): + + % elif all([c == 'incorrect' for c in option['choice'], status]): + + % endif + % endif + + + ${option['description']} + +
  • + % endfor +
+ + % if debug: +
+ Rendered with value:
+
${value|h}
+ Current input value:
+ +
+ % else: + + % endif + + % if status == 'unsubmitted': + + % elif status == 'incomplete': + + % elif status == 'incorrect' and not has_options_value: + + % endif + +

+
+ + +% if msg: +${msg|n} +% endif + diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html index dd177dc920..17c84114e5 100644 --- a/common/lib/capa/capa/templates/chemicalequationinput.html +++ b/common/lib/capa/capa/templates/chemicalequationinput.html @@ -11,7 +11,7 @@
% endif - - -
- % for choice_id, choice_description in choices: - +
- % endfor - -
+ % for choice_id, choice_description in choices: + + % endfor + +
+ + % if show_correctness == "never" and (value or status not in ['unsubmitted']): +
${submitted_message}
+ %endif diff --git a/common/lib/capa/capa/templates/editageneinput.html b/common/lib/capa/capa/templates/editageneinput.html index 8dd4fa89d1..3465c62593 100644 --- a/common/lib/capa/capa/templates/editageneinput.html +++ b/common/lib/capa/capa/templates/editageneinput.html @@ -1,4 +1,5 @@ -
+
+
% if status == 'unsubmitted': @@ -8,16 +9,12 @@ % elif status == 'incorrect':
% elif status == 'incomplete': -
+
% endif - - - - - - Applet failed to run. No Java plug-in was found. - - + +
+ +

@@ -37,3 +34,4 @@

% endif
+ diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index fe918ec5db..7aa299d20d 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -666,3 +666,36 @@ class StringResponseXMLFactory(ResponseXMLFactory): def create_input_element(self, **kwargs): return ResponseXMLFactory.textline_input_xml(**kwargs) + +class AnnotationResponseXMLFactory(ResponseXMLFactory): + """ Factory for creating XML trees """ + def create_response_element(self, **kwargs): + """ Create a element """ + return etree.Element("annotationresponse") + + def create_input_element(self, **kwargs): + """ Create a element.""" + + input_element = etree.Element("annotationinput") + + text_children = [ + {'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') }, + {'tag': 'text', 'text': kwargs.get('text', 'texty text') }, + {'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') }, + {'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') }, + {'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') } + ] + + for child in text_children: + etree.SubElement(input_element, child['tag']).text = child['text'] + + default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')] + options = kwargs.get('options', default_options) + options_element = etree.SubElement(input_element, 'options') + + for (description, correctness) in options: + option_element = etree.SubElement(options_element, 'option', {'choice': correctness}) + option_element.text = description + + return input_element + diff --git a/common/lib/capa/capa/tests/test_correctmap.py b/common/lib/capa/capa/tests/test_correctmap.py index 23734467bb..270ba4d849 100644 --- a/common/lib/capa/capa/tests/test_correctmap.py +++ b/common/lib/capa/capa/tests/test_correctmap.py @@ -91,12 +91,12 @@ class CorrectMapTest(unittest.TestCase): npoints=0) # Assert that we get the expected points - # If points assigned and correct --> npoints + # If points assigned --> npoints # If no points assigned and correct --> 1 point - # Otherwise --> 0 points + # If no points assigned and incorrect --> 0 points self.assertEqual(self.cmap.get_npoints('1_2_1'), 5) self.assertEqual(self.cmap.get_npoints('2_2_1'), 1) - self.assertEqual(self.cmap.get_npoints('3_2_1'), 0) + self.assertEqual(self.cmap.get_npoints('3_2_1'), 5) self.assertEqual(self.cmap.get_npoints('4_2_1'), 0) self.assertEqual(self.cmap.get_npoints('5_2_1'), 0) diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 64f031ea59..6c74d06ef4 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -3,6 +3,7 @@ from lxml import etree import os import textwrap import json + import mock from capa.capa_problem import LoncapaProblem @@ -11,6 +12,20 @@ from . import test_system class CapaHtmlRenderTest(unittest.TestCase): + def test_blank_problem(self): + """ + It's important that blank problems don't break, since that's + what you start with in studio. + """ + xml_str = " " + + # Create the problem + problem = LoncapaProblem(xml_str, '1', system=test_system) + + # Render the HTML + rendered_html = etree.XML(problem.get_html()) + # expect that we made it here without blowing up + def test_include_html(self): # Create a test file to include self._create_test_file('test_include.xml', @@ -25,7 +40,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -35,6 +50,8 @@ class CapaHtmlRenderTest(unittest.TestCase): self.assertEqual(test_element.text, "Test include") + + def test_process_outtext(self): # Generate some XML with and xml_str = textwrap.dedent(""" @@ -45,7 +62,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -64,7 +81,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -72,6 +89,25 @@ class CapaHtmlRenderTest(unittest.TestCase): script_element = rendered_html.find('script') self.assertEqual(None, script_element) + def test_render_javascript(self): + # Generate some XML with a + + """) + + # Create the problem + problem = LoncapaProblem(xml_str, '1', system=test_system) + + # Render the HTML + rendered_html = etree.XML(problem.get_html()) + + + # expect the javascript is still present in the rendered html + self.assertTrue("" in etree.tostring(rendered_html)) + + def test_render_response_xml(self): # Generate some XML for a string response kwargs = {'question_text': "Test question", @@ -99,11 +135,11 @@ class CapaHtmlRenderTest(unittest.TestCase): response_element = rendered_html.find("span") self.assertEqual(response_element.tag, "span") - # Expect that the response + # Expect that the response # that contains a
for the textline textline_element = response_element.find("div") self.assertEqual(textline_element.text, 'Input Template Render') - + # Expect a child
for the solution # with the rendered template solution_element = rendered_html.find("div") @@ -112,19 +148,21 @@ class CapaHtmlRenderTest(unittest.TestCase): # Expect that the template renderer was called with the correct # arguments, once for the textline input and once for # the solution - expected_textline_context = {'status': 'unsubmitted', - 'value': '', - 'preprocessor': None, - 'msg': '', - 'inline': False, - 'hidden': False, - 'do_math': False, - 'id': '1_2_1', + expected_textline_context = {'status': 'unsubmitted', + 'value': '', + 'preprocessor': None, + 'msg': '', + 'inline': False, + 'hidden': False, + 'do_math': False, + 'id': '1_2_1', 'size': None} expected_solution_context = {'id': '1_solution_1'} expected_calls = [mock.call('textline.html', expected_textline_context), + mock.call('solutionspan.html', expected_solution_context), + mock.call('textline.html', expected_textline_context), mock.call('solutionspan.html', expected_solution_context)] self.assertEqual(test_system.render_template.call_args_list, @@ -146,7 +184,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem and render the html problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Grade the problem correctmap = problem.grade_answers({'1_2_1': 'test'}) diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 4a5ea5c429..287caad28f 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -102,6 +102,8 @@ class ChoiceGroupTest(unittest.TestCase): 'choices': [('foil1', 'This is foil One.'), ('foil2', 'This is foil Two.'), ('foil3', 'This is foil Three.'), ], + 'show_correctness': 'always', + 'submitted_message': 'Answer received.', 'name_array_suffix': expected_suffix, # what is this for?? } @@ -482,27 +484,43 @@ class ChemicalEquationTest(unittest.TestCase): ''' Check that chemical equation inputs work. ''' - - def test_rendering(self): - size = "42" - xml_str = """""".format(size=size) + def setUp(self): + self.size = "42" + xml_str = """""".format(size=self.size) element = etree.fromstring(xml_str) state = {'value': 'H2OYeah', } - the_input = lookup_tag('chemicalequationinput')(test_system, element, state) + self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state) - context = the_input._get_render_context() + + def test_rendering(self): + ''' Verify that the render context matches the expected render context''' + context = self.the_input._get_render_context() expected = {'id': 'prob_1_2', 'value': 'H2OYeah', 'status': 'unanswered', 'msg': '', - 'size': size, + 'size': self.size, 'previewer': '/static/js/capa/chemical_equation_preview.js', } self.assertEqual(context, expected) + + def test_chemcalc_ajax_sucess(self): + ''' Verify that using the correct dispatch and valid data produces a valid response''' + + data = {'formula': "H"} + response = self.the_input.handle_ajax("preview_chemcalc", data) + + self.assertTrue('preview' in response) + self.assertNotEqual(response['preview'], '') + self.assertEqual(response['error'], "") + + + + class DragAndDropTest(unittest.TestCase): ''' @@ -570,3 +588,65 @@ class DragAndDropTest(unittest.TestCase): context.pop('drag_and_drop_json') expected.pop('drag_and_drop_json') self.assertEqual(context, expected) + + +class AnnotationInputTest(unittest.TestCase): + ''' + Make sure option inputs work + ''' + def test_rendering(self): + xml_str = ''' + + foo + bar + my comment + type a commentary + select a tag + + + + + + +''' + element = etree.fromstring(xml_str) + + value = {"comment": "blah blah", "options": [1]} + json_value = json.dumps(value) + state = { + 'value': json_value, + 'id': 'annotation_input', + 'status': 'answered' + } + + tag = 'annotationinput' + + the_input = lookup_tag(tag)(test_system, element, state) + + context = the_input._get_render_context() + + expected = { + 'id': 'annotation_input', + 'value': value, + 'status': 'answered', + 'msg': '', + 'title': 'foo', + 'text': 'bar', + 'comment': 'my comment', + 'comment_prompt': 'type a commentary', + 'tag_prompt': 'select a tag', + 'options': [ + {'id': 0, 'description': 'x', 'choice': 'correct'}, + {'id': 1, 'description': 'y', 'choice': 'incorrect'}, + {'id': 2, 'description': 'z', 'choice': 'partially-correct'} + ], + 'value': json_value, + 'options_value': value['options'], + 'has_options_value': len(value['options']) > 0, + 'comment_value': value['comment'], + 'debug': False, + 'return_to_annotation': True + } + + self.maxDiff = None + self.assertDictEqual(context, expected) diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 1b78dfce5c..e024909d75 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -19,7 +19,7 @@ from capa.xqueue_interface import dateformat class ResponseTest(unittest.TestCase): """ Base class for tests of capa responses.""" - + xml_factory_class = None def setUp(self): @@ -43,7 +43,7 @@ class ResponseTest(unittest.TestCase): for input_str in incorrect_answers: result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') - self.assertEqual(result, 'incorrect', + self.assertEqual(result, 'incorrect', msg="%s should be marked incorrect" % str(input_str)) class MultiChoiceResponseTest(ResponseTest): @@ -61,7 +61,7 @@ class MultiChoiceResponseTest(ResponseTest): def test_named_multiple_choice_grade(self): problem = self.build_problem(choices=[False, True, False], choice_names=["foil_1", "foil_2", "foil_3"]) - + # Ensure that we get the expected grades self.assert_grade(problem, 'choice_foil_1', 'incorrect') self.assert_grade(problem, 'choice_foil_2', 'correct') @@ -117,7 +117,7 @@ class ImageResponseTest(ResponseTest): # Anything inside the rectangle (and along the borders) is correct # Everything else is incorrect - correct_inputs = ["[12,19]", "[10,10]", "[20,20]", + correct_inputs = ["[12,19]", "[10,10]", "[20,20]", "[10,15]", "[20,15]", "[15,10]", "[15,20]"] incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]"] self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) @@ -259,7 +259,7 @@ class OptionResponseTest(ResponseTest): xml_factory_class = OptionResponseXMLFactory def test_grade(self): - problem = self.build_problem(options=["first", "second", "third"], + problem = self.build_problem(options=["first", "second", "third"], correct_option="second") # Assert that we get the expected grades @@ -374,8 +374,8 @@ class StringResponseTest(ResponseTest): hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"), ("minnesota", "minn", "The state capital of Minnesota is St. Paul")] - problem = self.build_problem(answer="Michigan", - case_sensitive=False, + problem = self.build_problem(answer="Michigan", + case_sensitive=False, hints=hints) # We should get a hint for Wisconsin @@ -543,7 +543,7 @@ class ChoiceResponseTest(ResponseTest): xml_factory_class = ChoiceResponseXMLFactory def test_radio_group_grade(self): - problem = self.build_problem(choice_type='radio', + problem = self.build_problem(choice_type='radio', choices=[False, True, False]) # Check that we get the expected results @@ -601,17 +601,17 @@ class NumericalResponseTest(ResponseTest): correct_responses = ["4", "4.0", "4.00"] incorrect_responses = ["", "3.9", "4.1", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) - + def test_grade_decimal_tolerance(self): problem = self.build_problem(question_text="What is 2 + 2 approximately?", explanation="The answer is 4", answer=4, tolerance=0.1) - correct_responses = ["4.0", "4.00", "4.09", "3.91"] + correct_responses = ["4.0", "4.00", "4.09", "3.91"] incorrect_responses = ["", "4.11", "3.89", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) - + def test_grade_percent_tolerance(self): problem = self.build_problem(question_text="What is 2 + 2 approximately?", explanation="The answer is 4", @@ -642,6 +642,15 @@ class NumericalResponseTest(ResponseTest): incorrect_responses = ["", "2.11", "1.89", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + def test_exponential_answer(self): + problem = self.build_problem(question_text="What 5 * 10?", + explanation="The answer is 50", + answer="5e+1") + correct_responses = ["50", "50.0", "5e1", "5e+1", "50e0", "500e-1"] + incorrect_responses = ["", "3.9", "4.1", "0", "5.01e1"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + class CustomResponseTest(ResponseTest): from response_xml_factory import CustomResponseXMLFactory @@ -667,7 +676,7 @@ class CustomResponseTest(ResponseTest): # The code can also set the global overall_message (str) # to pass a message that applies to the whole response inline_script = textwrap.dedent(""" - messages[0] = "Test Message" + messages[0] = "Test Message" overall_message = "Overall message" """) problem = self.build_problem(answer=inline_script) @@ -687,14 +696,14 @@ class CustomResponseTest(ResponseTest): def test_function_code_single_input(self): # For function code, we pass in these arguments: - # + # # 'expect' is the expect attribute of the # # 'answer_given' is the answer the student gave (if there is just one input) # or an ordered list of answers (if there are multiple inputs) - # # - # The function should return a dict of the form + # + # The function should return a dict of the form # { 'ok': BOOL, 'msg': STRING } # script = textwrap.dedent(""" @@ -727,7 +736,7 @@ class CustomResponseTest(ResponseTest): def test_function_code_multiple_input_no_msg(self): # Check functions also have the option of returning - # a single boolean value + # a single boolean value # If true, mark all the inputs correct # If false, mark all the inputs incorrect script = textwrap.dedent(""" @@ -736,7 +745,7 @@ class CustomResponseTest(ResponseTest): answer_given[1] == expect) """) - problem = self.build_problem(script=script, cfn="check_func", + problem = self.build_problem(script=script, cfn="check_func", expect="42", num_inputs=2) # Correct answer -- expect both inputs marked correct @@ -764,10 +773,10 @@ class CustomResponseTest(ResponseTest): # If the has multiple inputs associated with it, # the check function can return a dict of the form: - # + # # {'overall_message': STRING, # 'input_list': [{'ok': BOOL, 'msg': STRING}, ...] } - # + # # 'overall_message' is displayed at the end of the response # # 'input_list' contains dictionaries representing the correctness @@ -784,7 +793,7 @@ class CustomResponseTest(ResponseTest): {'ok': check3, 'msg': 'Feedback 3'} ] } """) - problem = self.build_problem(script=script, + problem = self.build_problem(script=script, cfn="check_func", num_inputs=3) # Grade the inputs (one input incorrect) @@ -821,11 +830,11 @@ class CustomResponseTest(ResponseTest): check1 = (int(answer_given[0]) == 1) check2 = (int(answer_given[1]) == 2) check3 = (int(answer_given[2]) == 3) - return {'ok': (check1 and check2 and check3), + return {'ok': (check1 and check2 and check3), 'msg': 'Message text'} """) - problem = self.build_problem(script=script, + problem = self.build_problem(script=script, cfn="check_func", num_inputs=3) # Grade the inputs (one input incorrect) @@ -862,7 +871,7 @@ class CustomResponseTest(ResponseTest): # Expect that an exception gets raised when we check the answer with self.assertRaises(Exception): problem.grade_answers({'1_2_1': '42'}) - + def test_invalid_dict_exception(self): # Construct a script that passes back an invalid dict format @@ -906,3 +915,40 @@ class SchematicResponseTest(ResponseTest): # (That is, our script verifies that the context # is what we expect) self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + +class AnnotationResponseTest(ResponseTest): + from response_xml_factory import AnnotationResponseXMLFactory + xml_factory_class = AnnotationResponseXMLFactory + + def test_grade(self): + (correct, partially, incorrect) = ('correct', 'partially-correct', 'incorrect') + + answer_id = '1_2_1' + options = (('x', correct),('y', partially),('z', incorrect)) + make_answer = lambda option_ids: {answer_id: json.dumps({'options': option_ids })} + + tests = [ + {'correctness': correct, 'points': 2,'answers': make_answer([0]) }, + {'correctness': partially, 'points': 1, 'answers': make_answer([1]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([2]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([0,1,2]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer('') }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer(None) }, + {'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null' } }, + ] + + for (index, test) in enumerate(tests): + expected_correctness = test['correctness'] + expected_points = test['points'] + answers = test['answers'] + + problem = self.build_problem(options=options) + correct_map = problem.grade_answers(answers) + actual_correctness = correct_map.get_correctness(answer_id) + actual_points = correct_map.get_npoints(answer_id) + + self.assertEqual(expected_correctness, actual_correctness, + msg="%s should be marked %s" % (answer_id, expected_correctness)) + self.assertEqual(expected_points, actual_points, + msg="%s should have %d points" % (answer_id, expected_points)) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index ec369420cd..835085d8ea 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -46,6 +46,7 @@ setup( "custom_tag_template = xmodule.raw_module:RawDescriptor", "about = xmodule.html_module:AboutDescriptor", "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", + "annotatable = xmodule.annotatable_module:AnnotatableDescriptor", "foldit = xmodule.foldit_module:FolditDescriptor", ] } diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py new file mode 100644 index 0000000000..f093b76f52 --- /dev/null +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -0,0 +1,131 @@ +import logging + +from lxml import etree +from pkg_resources import resource_string, resource_listdir + +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor +from xmodule.modulestore.mongo import MongoModuleStore +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.content import StaticContent + +log = logging.getLogger(__name__) + +class AnnotatableModule(XModule): + js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), + resource_string(__name__, 'js/src/collapsible.coffee'), + resource_string(__name__, 'js/src/html/display.coffee'), + resource_string(__name__, 'js/src/annotatable/display.coffee')], + 'js': [] + } + js_module_name = "Annotatable" + css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]} + icon_class = 'annotatable' + + def _get_annotation_class_attr(self, index, el): + """ Returns a dict with the CSS class attribute to set on the annotation + and an XML key to delete from the element. + """ + + attr = {} + cls = ['annotatable-span', 'highlight'] + highlight_key = 'highlight' + color = el.get(highlight_key) + + if color is not None: + if color in self.highlight_colors: + cls.append('highlight-'+color) + attr['_delete'] = highlight_key + attr['value'] = ' '.join(cls) + + return { 'class' : attr } + + def _get_annotation_data_attr(self, index, el): + """ Returns a dict in which the keys are the HTML data attributes + to set on the annotation element. Each data attribute has a + corresponding 'value' and (optional) '_delete' key to specify + an XML attribute to delete. + """ + + data_attrs = {} + attrs_map = { + 'body': 'data-comment-body', + 'title': 'data-comment-title', + 'problem': 'data-problem-id' + } + + for xml_key in attrs_map.keys(): + if xml_key in el.attrib: + value = el.get(xml_key, '') + html_key = attrs_map[xml_key] + data_attrs[html_key] = { 'value': value, '_delete': xml_key } + + return data_attrs + + def _render_annotation(self, index, el): + """ Renders an annotation element for HTML output. """ + attr = {} + attr.update(self._get_annotation_class_attr(index, el)) + attr.update(self._get_annotation_data_attr(index, el)) + + el.tag = 'span' + + for key in attr.keys(): + el.set(key, attr[key]['value']) + if '_delete' in attr[key] and attr[key]['_delete'] is not None: + delete_key = attr[key]['_delete'] + del el.attrib[delete_key] + + + def _render_content(self): + """ Renders annotatable content with annotation spans and returns HTML. """ + xmltree = etree.fromstring(self.content) + xmltree.tag = 'div' + if 'display_name' in xmltree.attrib: + del xmltree.attrib['display_name'] + + index = 0 + for el in xmltree.findall('.//annotation'): + self._render_annotation(index, el) + index += 1 + + return etree.tostring(xmltree, encoding='unicode') + + def _extract_instructions(self, xmltree): + """ Removes from the xmltree and returns them as a string, otherwise None. """ + instructions = xmltree.find('instructions') + if instructions is not None: + instructions.tag = 'div' + xmltree.remove(instructions) + return etree.tostring(instructions, encoding='unicode') + return None + + def get_html(self): + """ Renders parameters to template. """ + context = { + 'display_name': self.display_name, + 'element_id': self.element_id, + 'instructions_html': self.instructions, + 'content_html': self._render_content() + } + + return self.system.render_template('annotatable.html', context) + + def __init__(self, system, location, definition, descriptor, + instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, descriptor, + instance_state, shared_state, **kwargs) + + xmltree = etree.fromstring(self.definition['data']) + + self.instructions = self._extract_instructions(xmltree) + self.content = etree.tostring(xmltree, encoding='unicode') + self.element_id = self.location.html_id() + self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] + +class AnnotatableDescriptor(RawDescriptor): + module_class = AnnotatableModule + stores_state = True + template_dir_name = "annotatable" + mako_template = "widgets/raw-edit.html" + diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index ade5cc6ef6..2597690572 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -135,8 +135,8 @@ class CapaModule(XModule): self.grace_period = None self.close_date = self.display_due_date - max_attempts = self.metadata.get('attempts', None) - if max_attempts: + max_attempts = self.metadata.get('attempts') + if max_attempts is not None and max_attempts != '': self.max_attempts = int(max_attempts) else: self.max_attempts = None @@ -247,123 +247,177 @@ class CapaModule(XModule): 'progress': Progress.to_js_status_str(self.get_progress()) }) + def check_button_name(self): + """ + Determine the name for the "check" button. + Usually it is just "Check", but if this is the student's + final attempt, change the name to "Final Check" + """ + if self.max_attempts is not None: + final_check = (self.attempts >= self.max_attempts - 1) + else: + final_check = False + + return "Final Check" if final_check else "Check" + + def should_show_check_button(self): + """ + Return True/False to indicate whether to show the "Check" button. + """ + submitted_without_reset = (self.is_completed() and self.rerandomize == "always") + + # If the problem is closed (past due / too many attempts) + # then we do NOT show the "check" button + # Also, do not show the "check" button if we're waiting + # for the user to reset a randomized problem + if self.closed() or submitted_without_reset: + return False + else: + return True + + def should_show_reset_button(self): + """ + Return True/False to indicate whether to show the "Reset" button. + """ + is_survey_question = (self.max_attempts == 0) + + if self.rerandomize in ["always", "onreset"]: + + # If the problem is closed (and not a survey question with max_attempts==0), + # then do NOT show the reset button. + # If the problem hasn't been submitted yet, then do NOT show + # the reset button. + if (self.closed() and not is_survey_question) or not self.is_completed(): + return False + else: + return True + + # Only randomized problems need a "reset" button + else: + return False + + def should_show_save_button(self): + """ + Return True/False to indicate whether to show the "Save" button. + """ + + # If the user has forced the save button to display, + # then show it as long as the problem is not closed + # (past due / too many attempts) + if self.force_save_button == "true": + return not self.closed() + else: + is_survey_question = (self.max_attempts == 0) + needs_reset = self.is_completed() and self.rerandomize == "always" + + # If the problem is closed (and not a survey question with max_attempts==0), + # then do NOT show the reset button + # If we're waiting for the user to reset a randomized problem + # then do NOT show the reset button + if (self.closed() and not is_survey_question) or needs_reset: + return False + else: + return True + + def handle_problem_html_error(self, err): + """ + Change our problem to a dummy problem containing + a warning message to display to users. + + Returns the HTML to show to users + + *err* is the Exception encountered while rendering the problem HTML. + """ + log.exception(err) + + # TODO (vshnayder): another switch on DEBUG. + if self.system.DEBUG: + msg = ( + '[courseware.capa.capa_module] ' + 'Failed to generate HTML for problem %s' % + (self.location.url())) + msg += '

Error:

%s

' % str(err).replace('<', '<') + msg += '

%s

' % traceback.format_exc().replace('<', '<') + html = msg + + # We're in non-debug mode, and possibly even in production. We want + # to avoid bricking of problem as much as possible + else: + + # Presumably, student submission has corrupted LoncapaProblem HTML. + # First, pull down all student answers + student_answers = self.lcp.student_answers + answer_ids = student_answers.keys() + + # Some inputtypes, such as dynamath, have additional "hidden" state that + # is not exposed to the student. Keep those hidden + # TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id + hidden_state_keywords = ['dynamath'] + for answer_id in answer_ids: + for hidden_state_keyword in hidden_state_keywords: + if answer_id.find(hidden_state_keyword) >= 0: + student_answers.pop(answer_id) + + # Next, generate a fresh LoncapaProblem + self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), + state=None, # Tabula rasa + seed=self.seed, system=self.system) + + # Prepend a scary warning to the student + warning = '
'\ + '

Warning: The problem has been reset to its initial state!

'\ + 'The problem\'s state was corrupted by an invalid submission. ' \ + 'The submission consisted of:'\ + '
    ' + for student_answer in student_answers.values(): + if student_answer != '': + warning += '
  • ' + cgi.escape(student_answer) + '
  • ' + warning += '
'\ + 'If this error persists, please contact the course staff.'\ + '
' + + html = warning + try: + html += self.lcp.get_html() + except Exception, err: # Couldn't do it. Give up + log.exception(err) + raise + + return html + + def get_problem_html(self, encapsulate=True): '''Return html for the problem. Adds check, reset, save buttons as necessary based on the problem config and state.''' try: html = self.lcp.get_html() + + # If we cannot construct the problem HTML, + # then generate an error message instead. except Exception, err: - log.exception(err) + html = self.handle_problem_html_error(err) - # TODO (vshnayder): another switch on DEBUG. - if self.system.DEBUG: - msg = ( - '[courseware.capa.capa_module] ' - 'Failed to generate HTML for problem %s' % - (self.location.url())) - msg += '

Error:

%s

' % str(err).replace('<', '<') - msg += '

%s

' % traceback.format_exc().replace('<', '<') - html = msg - else: - # We're in non-debug mode, and possibly even in production. We want - # to avoid bricking of problem as much as possible - # Presumably, student submission has corrupted LoncapaProblem HTML. - # First, pull down all student answers - student_answers = self.lcp.student_answers - answer_ids = student_answers.keys() - - # Some inputtypes, such as dynamath, have additional "hidden" state that - # is not exposed to the student. Keep those hidden - # TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id - hidden_state_keywords = ['dynamath'] - for answer_id in answer_ids: - for hidden_state_keyword in hidden_state_keywords: - if answer_id.find(hidden_state_keyword) >= 0: - student_answers.pop(answer_id) - - # Next, generate a fresh LoncapaProblem - self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - state=None, # Tabula rasa - seed=self.seed, system=self.system) - - # Prepend a scary warning to the student - warning = '
'\ - '

Warning: The problem has been reset to its initial state!

'\ - 'The problem\'s state was corrupted by an invalid submission. ' \ - 'The submission consisted of:'\ - '
    ' - for student_answer in student_answers.values(): - if student_answer != '': - warning += '
  • ' + cgi.escape(student_answer) + '
  • ' - warning += '
'\ - 'If this error persists, please contact the course staff.'\ - '
' - - html = warning - try: - html += self.lcp.get_html() - except Exception, err: # Couldn't do it. Give up - log.exception(err) - raise + # The convention is to pass the name of the check button + # if we want to show a check button, and False otherwise + # This works because non-empty strings evaluate to True + if self.should_show_check_button(): + check_button = self.check_button_name() + else: + check_button = False content = {'name': self.display_name, 'html': html, 'weight': self.descriptor.weight, } - # We using strings as truthy values, because the terminology of the - # check button is context-specific. - - # Put a "Check" button if unlimited attempts or still some left - if self.max_attempts is None or self.attempts < self.max_attempts - 1: - check_button = "Check" - else: - # Will be final check so let user know that - check_button = "Final Check" - - reset_button = True - save_button = True - - # If we're after deadline, or user has exhausted attempts, - # question is read-only. - if self.closed(): - check_button = False - reset_button = False - save_button = False - - # If attempts=0 then show just check and reset buttons; this is for survey questions using capa - if self.max_attempts==0: - check_button = False - reset_button = True - save_button = True - - # User submitted a problem, and hasn't reset. We don't want - # more submissions. - if self.lcp.done and self.rerandomize == "always": - check_button = False - save_button = False - - # Only show the reset button if pressing it will show different values - if self.rerandomize not in ["always", "onreset"]: - reset_button = False - - # User hasn't submitted an answer yet -- we don't want resets - if not self.lcp.done: - reset_button = False - - # We may not need a "save" button if infinite number of attempts and - # non-randomized. The problem author can force it. It's a bit weird for - # randomization to control this; should perhaps be cleaned up. - if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"): - save_button = False context = {'problem': content, 'id': self.id, 'check_button': check_button, - 'reset_button': reset_button, - 'save_button': save_button, + 'reset_button': self.should_show_reset_button(), + 'save_button': self.should_show_save_button(), 'answer_available': self.answer_available(), 'ajax_url': self.system.ajax_url, 'attempts_used': self.attempts, @@ -396,6 +450,7 @@ class CapaModule(XModule): 'problem_save': self.save_problem, 'problem_show': self.get_answer, 'score_update': self.update_score, + 'input_ajax': self.lcp.handle_input_ajax } if dispatch not in handlers: @@ -419,7 +474,7 @@ class CapaModule(XModule): def closed(self): ''' Is the student still allowed to submit answers? ''' - if self.attempts == self.max_attempts: + if self.max_attempts is not None and self.attempts >= self.max_attempts: return True if self.is_past_due(): return True @@ -527,22 +582,60 @@ class CapaModule(XModule): @staticmethod def make_dict_of_responses(get): '''Make dictionary of student responses (aka "answers") - get is POST dictionary. + get is POST dictionary (Djano QueryDict). + + The *get* dict has keys of the form 'x_y', which are mapped + to key 'y' in the returned dict. For example, + 'input_1_2_3' would be mapped to '1_2_3' in the returned dict. + + Some inputs always expect a list in the returned dict + (e.g. checkbox inputs). The convention is that + keys in the *get* dict that end with '[]' will always + have list values in the returned dict. + For example, if the *get* dict contains {'input_1[]': 'test' } + then the output dict would contain {'1': ['test'] } + (the value is a list). + + Raises an exception if: + + A key in the *get* dictionary does not contain >= 1 underscores + (e.g. "input" is invalid; "input_1" is valid) + + Two keys end up with the same name in the returned dict. + (e.g. 'input_1' and 'input_1[]', which both get mapped + to 'input_1' in the returned dict) ''' answers = dict() + for key in get: # e.g. input_resistor_1 ==> resistor_1 _, _, name = key.partition('_') - # This allows for answers which require more than one value for - # the same form input (e.g. checkbox inputs). The convention is that - # if the name ends with '[]' (which looks like an array), then the - # answer will be an array. - if not name.endswith('[]'): - answers[name] = get[key] + # If key has no underscores, then partition + # will return (key, '', '') + # We detect this and raise an error + if not name: + raise ValueError("%s must contain at least one underscore" % str(key)) + else: - name = name[:-2] - answers[name] = get.getlist(key) + # This allows for answers which require more than one value for + # the same form input (e.g. checkbox inputs). The convention is that + # if the name ends with '[]' (which looks like an array), then the + # answer will be an array. + is_list_key = name.endswith('[]') + name = name[:-2] if is_list_key else name + + if is_list_key: + val = get.getlist(key) + else: + val = get[key] + + # If the name already exists, then we don't want + # to override it. Raise an error instead + if name in answers: + raise ValueError("Key %s already exists in answers dict" % str(name)) + else: + answers[name] = val return answers @@ -550,7 +643,7 @@ class CapaModule(XModule): ''' Checks whether answers to a problem are correct, and returns a map of correct/incorrect answers: - {'success' : bool, + {'success' : 'correct' | 'incorrect' | AJAX alert msg string, 'contents' : html} ''' event_info = dict() @@ -609,11 +702,11 @@ class CapaModule(XModule): # 'success' will always be incorrect event_info['correct_map'] = correct_map.get_dict() event_info['success'] = success - event_info['attempts'] = self.attempts + event_info['attempts'] = self.attempts self.system.track_function('save_problem_check', event_info) - if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback - self.system.psychometrics_handler(self.get_instance_state()) + if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback + self.system.psychometrics_handler(self.get_instance_state()) # render problem into HTML html = self.get_problem_html(encapsulate=False) @@ -663,7 +756,12 @@ class CapaModule(XModule): ''' Changes problem state to unfinished -- removes student answers, and causes problem to rerender itself. - Returns problem html as { 'html' : html-string }. + Returns a dictionary of the form: + {'success': True/False, + 'html': Problem HTML string } + + If an error occurs, the dictionary will also have an + 'error' key containing an error message. ''' event_info = dict() event_info['old_state'] = self.lcp.get_state() @@ -686,6 +784,7 @@ class CapaModule(XModule): # reset random number generator seed (note the self.lcp.get_state() # in next line) self.lcp.seed = None + self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), self.lcp.get_state(), @@ -694,7 +793,8 @@ class CapaModule(XModule): event_info['new_state'] = self.lcp.get_state() self.system.track_function('reset_problem', event_info) - return {'html': self.get_problem_html(encapsulate=False)} + return { 'success': True, + 'html': self.get_problem_html(encapsulate=False)} class CapaDescriptor(RawDescriptor): diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 659590b5b4..0cc69a4a24 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -10,7 +10,6 @@ from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import Comb log = logging.getLogger("mitx.courseware") - VERSION_TUPLES = ( ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module), ) @@ -18,6 +17,7 @@ VERSION_TUPLES = ( DEFAULT_VERSION = 1 DEFAULT_VERSION = str(DEFAULT_VERSION) + class CombinedOpenEndedModule(XModule): """ This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc). @@ -60,7 +60,7 @@ class CombinedOpenEndedModule(XModule): def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) + instance_state, shared_state, **kwargs) """ Definition file should have one or many task blocks, a rubric block, and a prompt block: @@ -129,13 +129,15 @@ class CombinedOpenEndedModule(XModule): version_index = versions.index(self.version) static_data = { - 'rewrite_content_links' : self.rewrite_content_links, + 'rewrite_content_links': self.rewrite_content_links, } self.child_descriptor = descriptors[version_index](self.system) - self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['data']), self.system) + self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['data']), + self.system) self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor, - instance_state = json.dumps(instance_state), metadata = self.metadata, static_data= static_data) + instance_state=json.dumps(instance_state), metadata=self.metadata, + static_data=static_data) def get_html(self): return self.child_module.get_html() diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 72196f92a2..89c52bb742 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -246,7 +246,7 @@ class CourseDescriptor(SequenceDescriptor): policy = json.loads(cls.read_grading_policy(paths, system)) except ValueError: system.error_tracker("Unable to decode grading policy as json") - policy = None + policy = {} # cdodge: import the grading policy information that is on disk and put into the # descriptor 'definition' bucket as a dictionary so that it is persisted in the DB @@ -356,7 +356,14 @@ class CourseDescriptor(SequenceDescriptor): """ Return the pdf_textbooks config, as a python object, or None if not specified. """ - return self.metadata.get('pdf_textbooks') + return self.metadata.get('pdf_textbooks', []) + + @property + def html_textbooks(self): + """ + Return the html_textbooks config, as a python object, or None if not specified. + """ + return self.metadata.get('html_textbooks', []) @tabs.setter def tabs(self, value): @@ -398,7 +405,7 @@ class CourseDescriptor(SequenceDescriptor): return self.metadata.get("cohort_config", {}).get( "auto_cohort_groups", []) - + @property def top_level_discussion_topic_ids(self): """ diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss new file mode 100644 index 0000000000..308b379ec1 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -0,0 +1,169 @@ +$border-color: #C8C8C8; +$body-font-size: em(14); + +.annotatable-header { + margin-bottom: .5em; + .annotatable-title { + font-size: em(22); + text-transform: uppercase; + padding: 2px 4px; + } +} + +.annotatable-section { + position: relative; + padding: .5em 1em; + border: 1px solid $border-color; + border-radius: .5em; + margin-bottom: .5em; + + &.shaded { background-color: #EDEDED; } + + .annotatable-section-title { + font-weight: bold; + a { font-weight: normal; } + } + .annotatable-section-body { + border-top: 1px solid $border-color; + margin-top: .5em; + padding-top: .5em; + @include clearfix; + } + + ul.instructions-template { + list-style: disc; + margin-left: 4em; + b { font-weight: bold; } + i { font-style: italic; } + code { + display: inline; + white-space: pre; + font-family: Courier New, monospace; + } + } +} + +.annotatable-toggle { + position: absolute; + right: 0; + margin: 2px 1em 2px 0; + &.expanded:after { content: " \2191" } + &.collapsed:after { content: " \2193" } +} + +.annotatable-span { + display: inline; + cursor: pointer; + + @each $highlight in ( + (yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)), + (red rgba(178,19,16,0.3) rgba(178,19,16,0.9)), + (orange rgba(255,165,0,0.3) rgba(255,165,0,0.9)), + (green rgba(25,255,132,0.3) rgba(25,255,132,0.9)), + (blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)), + (purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) { + + $marker: nth($highlight,1); + $color: nth($highlight,2); + $selected_color: nth($highlight,3); + + @if $marker == yellow { + &.highlight { + background-color: $color; + &.selected { background-color: $selected_color; } + } + } + &.highlight-#{$marker} { + background-color: $color; + &.selected { background-color: $selected_color; } + } + } + + &.hide { + cursor: none; + background-color: inherit; + .annotatable-icon { + display: none; + } + } + + .annotatable-comment { + display: none; + } +} + +.ui-tooltip.qtip.ui-tooltip { + font-size: $body-font-size; + border: 1px solid #333; + border-radius: 1em; + background-color: rgba(0,0,0,.85); + color: #fff; + -webkit-font-smoothing: antialiased; + + .ui-tooltip-titlebar { + font-size: em(16); + color: inherit; + background-color: transparent; + padding: 5px 10px; + border: none; + .ui-tooltip-title { + padding: 5px 0px; + border-bottom: 2px solid #333; + font-weight: bold; + } + .ui-tooltip-icon { + right: 10px; + background: #333; + } + .ui-state-hover { + color: inherit; + border: 1px solid #ccc; + } + } + .ui-tooltip-content { + color: inherit; + font-size: em(14); + text-align: left; + font-weight: 400; + padding: 0 10px 10px 10px; + background-color: transparent; + } + p { + color: inherit; + line-height: normal; + } +} + +.ui-tooltip.qtip.ui-tooltip-annotatable { + max-width: 375px; + .ui-tooltip-content { + padding: 0 10px; + .annotatable-comment { + display: block; + margin: 0px 0px 10px 0; + max-height: 225px; + overflow: auto; + } + .annotatable-reply { + display: block; + border-top: 2px solid #333; + padding: 5px 0; + margin: 0; + text-align: center; + } + } + &:after { + content: ''; + display: inline-block; + position: absolute; + bottom: -20px; + left: 50%; + height: 0; + width: 0; + margin-left: -5px; + border: 10px solid transparent; + border-top-color: rgba(0, 0, 0, .85); + } +} + + diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index b705f5146e..ab23bc1b48 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -42,6 +42,14 @@ section.problem { label.choicegroup_correct{ &:after{ content: url('../images/correct-icon.png'); + margin-left:15px + } + } + + label.choicegroup_incorrect{ + &:after{ + content: url('../images/incorrect-icon.png'); + margin-left:15px; } } @@ -52,6 +60,7 @@ section.problem { .indicator_container { float: left; width: 25px; + height: 1px; margin-right: 15px; } @@ -69,7 +78,7 @@ section.problem { } text { - display: block; + display: inline; margin-left: 25px; } } @@ -231,6 +240,15 @@ section.problem { width: 25px; } + &.partially-correct { + @include inline-block(); + background: url('../images/partially-correct-icon.png') center center no-repeat; + height: 20px; + position: relative; + top: 6px; + width: 25px; + } + &.incorrect, &.ui-icon-close { @include inline-block(); background: url('../images/incorrect-icon.png') center center no-repeat; @@ -802,4 +820,91 @@ section.problem { display: none; } } + + .annotation-input { + $yellow: rgba(255,255,10,0.3); + + border: 1px solid #ccc; + border-radius: 1em; + margin: 0 0 1em 0; + + .annotation-header { + font-weight: bold; + border-bottom: 1px solid #ccc; + padding: .5em 1em; + } + .annotation-body { padding: .5em 1em; } + a.annotation-return { + float: right; + font: inherit; + font-weight: normal; + } + a.annotation-return:after { content: " \2191" } + + .block, ul.tags { + margin: .5em 0; + padding: 0; + } + .block-highlight { + padding: .5em; + color: #333; + font-style: normal; + background-color: $yellow; + border: 1px solid darken($yellow, 10%); + } + .block-comment { font-style: italic; } + + ul.tags { + display: block; + list-style-type: none; + margin-left: 1em; + li { + display: block; + margin: 1em 0 0 0; + position: relative; + .tag { + display: inline-block; + cursor: pointer; + border: 1px solid rgb(102,102,102); + margin-left: 40px; + &.selected { + background-color: $yellow; + } + } + .tag-status { + position: absolute; + left: 0; + } + .tag-status, .tag { padding: .25em .5em; } + } + } + textarea.comment { + $num-lines-to-show: 5; + $line-height: 1.4em; + $padding: .2em; + width: 100%; + padding: $padding (2 * $padding); + line-height: $line-height; + height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2); + } + .answer-annotation { display: block; margin: 0; } + + /* for debugging the input value field. enable the debug flag on the inputtype */ + .debug-value { + color: #fff; + padding: 1em; + margin: 1em 0; + background-color: #999; + border: 1px solid #000; + input[type="text"] { width: 100%; } + pre { background-color: #CCC; color: #000; } + &:before { + display: block; + content: "debug input value"; + text-transform: uppercase; + font-weight: bold; + font-size: 1.5em; + } + } + } } diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 3990a61183..88e29b4203 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -86,7 +86,10 @@ class FolditModule(XModule): """ from foldit.models import Score - return [(e['username'], e['score']) for e in Score.get_tops_n(10)] + leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)] + leaders.sort(key=lambda x: -x[1]) + + return leaders def get_html(self): """ @@ -96,8 +99,9 @@ class FolditModule(XModule): self.required_level, self.required_sublevel) - showbasic = (self.metadata.get("show_basic_score").lower() == "true") - showleader = (self.metadata.get("show_leaderboard").lower() == "true") + showbasic = (self.metadata.get("show_basic_score", "").lower() == "true") + showleader = (self.metadata.get("show_leaderboard", "").lower() == "true") + context = { 'due': self.due_str, 'success': self.is_complete(), diff --git a/common/lib/xmodule/xmodule/js/fixtures/annotatable.html b/common/lib/xmodule/xmodule/js/fixtures/annotatable.html new file mode 100644 index 0000000000..61020d95e8 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/annotatable.html @@ -0,0 +1,35 @@ +
+
+
+
First Annotation Exercise
+
+
+
+ Instructions + Collapse Instructions +
+
+

The main goal of this exercise is to start practicing the art of slow reading.

+
+
+
+
+ Guided Discussion + Hide Annotations +
+
+
+ |87 No, those who are really responsible are Zeus and Fate [Moira] and the Fury [Erinys] who roams in the mist.
+ |88 They are the ones who
+ |100 He [= Zeus], making a formal declaration [eukhesthai], spoke up at a meeting of all the gods and said:
+ |101 “hear me, all gods and all goddesses,
+ |113 but he swore a great oath. + And right then and there
+
+
+
+ +
Return to Annotation
+
Return to Annotation
+
Return to Annotation
+ diff --git a/common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.coffee new file mode 100644 index 0000000000..3adb028f97 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.coffee @@ -0,0 +1,9 @@ +describe 'Annotatable', -> + beforeEach -> + loadFixtures 'annotatable.html' + describe 'constructor', -> + el = $('.xmodule_display.xmodule_AnnotatableModule') + beforeEach -> + @annotatable = new Annotatable(el) + it 'works', -> + expect(1).toBe(1) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee new file mode 100644 index 0000000000..2ad49ae6d7 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -0,0 +1,197 @@ +class @Annotatable + _debug: false + + # selectors for the annotatable xmodule + toggleAnnotationsSelector: '.annotatable-toggle-annotations' + toggleInstructionsSelector: '.annotatable-toggle-instructions' + instructionsSelector: '.annotatable-instructions' + sectionSelector: '.annotatable-section' + spanSelector: '.annotatable-span' + replySelector: '.annotatable-reply' + + # these selectors are for responding to events from the annotation capa problem type + problemXModuleSelector: '.xmodule_CapaModule' + problemSelector: 'section.problem' + problemInputSelector: 'section.problem .annotation-input' + problemReturnSelector: 'section.problem .annotation-return' + + constructor: (el) -> + console.log 'loaded Annotatable' if @_debug + @el = el + @$el = $(el) + @init() + + $: (selector) -> + $(selector, @el) + + init: () -> + @initEvents() + @initTips() + + initEvents: () -> + # Initialize toggle handlers for the instructions and annotations sections + [@annotationsHidden, @instructionsHidden] = [false, false] + @$(@toggleAnnotationsSelector).bind 'click', @onClickToggleAnnotations + @$(@toggleInstructionsSelector).bind 'click', @onClickToggleInstructions + + # Initialize handler for 'reply to annotation' events that scroll to + # the associated problem. The reply buttons are part of the tooltip + # content. It's important that the tooltips be configured to render + # as descendants of the annotation module and *not* the document.body. + @$el.delegate @replySelector, 'click', @onClickReply + + # Initialize handler for 'return to annotation' events triggered from problems. + # 1) There are annotationinput capa problems rendered on the page + # 2) Each one has an embedded return link (see annotation capa problem template). + # Since the capa problem injects HTML content via AJAX, the best we can do is + # is let the click events bubble up to the body and handle them there. + $('body').delegate @problemReturnSelector, 'click', @onClickReturn + + initTips: () -> + # tooltips are used to display annotations for highlighted text spans + @$(@spanSelector).each (index, el) => + $(el).qtip(@getSpanTipOptions el) + + getSpanTipOptions: (el) -> + content: + title: + text: @makeTipTitle(el) + text: @makeTipContent(el) + position: + my: 'bottom center' # of tooltip + at: 'top center' # of target + target: $(el) # where the tooltip was triggered (i.e. the annotation span) + container: @$el + adjust: + y: -5 + show: + event: 'click mouseenter' + solo: true + hide: + event: 'click mouseleave' + delay: 500, + fixed: true # don't hide the tooltip if it is moused over + style: + classes: 'ui-tooltip-annotatable' + events: + show: @onShowTip + + onClickToggleAnnotations: (e) => @toggleAnnotations() + + onClickToggleInstructions: (e) => @toggleInstructions() + + onClickReply: (e) => @replyTo(e.currentTarget) + + onClickReturn: (e) => @returnFrom(e.currentTarget) + + onShowTip: (event, api) => + event.preventDefault() if @annotationsHidden + + getSpanForProblemReturn: (el) -> + problem_id = $(@problemReturnSelector).index(el) + @$(@spanSelector).filter("[data-problem-id='#{problem_id}']") + + getProblem: (el) -> + problem_id = @getProblemId(el) + $(@problemSelector).has(@problemInputSelector).eq(problem_id) + + getProblemId: (el) -> + $(el).data('problem-id') + + toggleAnnotations: () -> + hide = (@annotationsHidden = not @annotationsHidden) + @toggleAnnotationButtonText hide + @toggleSpans hide + @toggleTips hide + + toggleTips: (hide) -> + visible = @findVisibleTips() + @hideTips visible + + toggleAnnotationButtonText: (hide) -> + buttonText = (if hide then 'Show' else 'Hide')+' Annotations' + @$(@toggleAnnotationsSelector).text(buttonText) + + toggleInstructions: () -> + hide = (@instructionsHidden = not @instructionsHidden) + @toggleInstructionsButton hide + @toggleInstructionsText hide + + toggleInstructionsButton: (hide) -> + txt = (if hide then 'Expand' else 'Collapse')+' Instructions' + cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded']) + @$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1]) + + toggleInstructionsText: (hide) -> + slideMethod = (if hide then 'slideUp' else 'slideDown') + @$(@instructionsSelector)[slideMethod]() + + toggleSpans: (hide) -> + @$(@spanSelector).toggleClass 'hide', hide, 250 + + replyTo: (buttonEl) -> + offset = -20 + el = @getProblem buttonEl + if el.length > 0 + @scrollTo(el, @afterScrollToProblem, offset) + else + console.log('problem not found. event: ', e) if @_debug + + returnFrom: (buttonEl) -> + offset = -200 + el = @getSpanForProblemReturn buttonEl + if el.length > 0 + @scrollTo(el, @afterScrollToSpan, offset) + else + console.log('span not found. event:', e) if @_debug + + scrollTo: (el, after, offset = -20) -> + $('html,body').scrollTo(el, { + duration: 500 + onAfter: @_once => after?.call this, el + offset: offset + }) if $(el).length > 0 + + afterScrollToProblem: (problem_el) -> + problem_el.effect 'highlight', {}, 500 + + afterScrollToSpan: (span_el) -> + span_el.addClass 'selected', 400, 'swing', -> + span_el.removeClass 'selected', 400, 'swing' + + makeTipContent: (el) -> + (api) => + text = $(el).data('comment-body') + comment = @createComment(text) + problem_id = @getProblemId(el) + reply = @createReplyLink(problem_id) + $(comment).add(reply) + + makeTipTitle: (el) -> + (api) => + title = $(el).data('comment-title') + (if title then title else 'Commentary') + + createComment: (text) -> + $("
#{text}
") + + createReplyLink: (problem_id) -> + $("Reply to Annotation") + + findVisibleTips: () -> + visible = [] + @$(@spanSelector).each (index, el) -> + api = $(el).qtip('api') + tip = $(api?.elements.tooltip) + if tip.is(':visible') + visible.push el + visible + + hideTips: (elements) -> + $(elements).qtip('hide') + + _once: (fn) -> + done = false + return => + fn.call this unless done + done = true diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 41c9b50891..158c2b98d0 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -76,6 +76,24 @@ class @Problem # TODO: Some logic to dynamically adjust polling rate based on queuelen window.queuePollerID = window.setTimeout(@poll, 1000) + + # Use this if you want to make an ajax call on the input type object + # static method so you don't have to instantiate a Problem in order to use it + # Input: + # url: the AJAX url of the problem + # input_id: the input_id of the input you would like to make the call on + # NOTE: the id is the ${id} part of "input_${id}" during rendering + # If this function is passed the entire prefixed id, the backend may have trouble + # finding the correct input + # dispatch: string that indicates how this data should be handled by the inputtype + # callback: the function that will be called once the AJAX call has been completed. + # It will be passed a response object + @inputAjax: (url, input_id, dispatch, data, callback) -> + data['dispatch'] = dispatch + data['input_id'] = input_id + $.postWithPrefix "#{url}/input_ajax", data, callback + + render: (content) -> if content @el.html(content) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 1259da2690..f2a291d680 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -46,10 +46,10 @@ class XModuleCourseFactory(Factory): new_course.metadata['start'] = stringify_time(gmtime()) new_course.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, - {"type": "discussion", "name": "Discussion"}, - {"type": "wiki", "name": "Wiki"}, - {"type": "progress", "name": "Progress"}] + {"type": "course_info", "name": "Course Info"}, + {"type": "discussion", "name": "Discussion"}, + {"type": "wiki", "name": "Wiki"}, + {"type": "progress", "name": "Progress"}] # Update the data in the mongo datastore store.update_metadata(new_course.location.url(), new_course.own_metadata) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py index 0772951884..f0f0e8bf48 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py @@ -119,11 +119,11 @@ def test_equality(): # All the cleaning functions should do the same thing with these general_pairs = [('', ''), - (' ', '_'), - ('abc,', 'abc_'), - ('ab fg!@//\\aj', 'ab_fg_aj'), - (u"ab\xA9", "ab_"), # no unicode allowed for now - ] + (' ', '_'), + ('abc,', 'abc_'), + ('ab fg!@//\\aj', 'ab_fg_aj'), + (u"ab\xA9", "ab_"), # no unicode allowed for now + ] def test_clean(): @@ -131,7 +131,7 @@ def test_clean(): ('a:b', 'a_b'), # no colons in non-name components ('a-b', 'a-b'), # dashes ok ('a.b', 'a.b'), # dot ok - ] + ] for input, output in pairs: assert_equals(Location.clean(input), output) @@ -141,17 +141,17 @@ def test_clean_for_url_name(): ('a:b', 'a:b'), # colons ok in names ('a-b', 'a-b'), # dashes ok in names ('a.b', 'a.b'), # dot ok in names - ] + ] for input, output in pairs: assert_equals(Location.clean_for_url_name(input), output) def test_clean_for_html(): pairs = general_pairs + [ - ("a:b", "a_b"), # no colons for html use - ("a-b", "a-b"), # dashes ok (though need to be replaced in various use locations. ugh.) - ('a.b', 'a_b'), # no dots. - ] + ("a:b", "a_b"), # no colons for html use + ("a-b", "a-b"), # dashes ok (though need to be replaced in various use locations. ugh.) + ('a.b', 'a_b'), # no dots. + ] for input, output in pairs: assert_equals(Location.clean_for_html(input), output) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py index 94ea622907..469eedac05 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py @@ -12,7 +12,7 @@ def check_path_to_location(modulestore): ("edX/toy/2012_Fall", "Overview", "Welcome", None)), ("i4x://edX/toy/chapter/Overview", ("edX/toy/2012_Fall", "Overview", None, None)), - ) + ) course_id = "edX/toy/2012_Fall" for location, expected in should_work: @@ -20,6 +20,6 @@ def check_path_to_location(modulestore): not_found = ( "i4x://edX/toy/video/WelcomeX", "i4x://edX/toy/course/NotHome" - ) + ) for location in not_found: assert_raises(ItemNotFoundError, path_to_location, modulestore, course_id, location) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 5c3bfa5b2a..20cedaab75 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -40,14 +40,15 @@ ACCEPT_FILE_UPLOAD = False TRUE_DICT = ["True", True, "TRUE", "true"] HUMAN_TASK_TYPE = { - 'selfassessment' : "Self Assessment", - 'openended' : "edX Assessment", - } + 'selfassessment': "Self Assessment", + 'openended': "edX Assessment", +} #Default value that controls whether or not to skip basic spelling checks in the controller #Metadata overrides this SKIP_BASIC_CHECKS = False + class CombinedOpenEndedV1Module(): """ This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc). @@ -83,7 +84,7 @@ class CombinedOpenEndedV1Module(): TEMPLATE_DIR = "combinedopenended" def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs): + instance_state=None, shared_state=None, metadata=None, static_data=None, **kwargs): """ Definition file should have one or many task blocks, a rubric block, and a prompt block: @@ -122,7 +123,7 @@ class CombinedOpenEndedV1Module(): self.metadata = metadata self.display_name = metadata.get('display_name', "Open Ended") - self.rewrite_content_links = static_data.get('rewrite_content_links',"") + self.rewrite_content_links = static_data.get('rewrite_content_links', "") # Load instance state @@ -152,10 +153,10 @@ class CombinedOpenEndedV1Module(): self.skip_basic_checks = self.metadata.get('skip_spelling_checks', SKIP_BASIC_CHECKS) display_due_date_string = self.metadata.get('due', None) - + grace_period_string = self.metadata.get('graceperiod', None) try: - self.timeinfo = TimeInfo(display_due_date_string, grace_period_string) + self.timeinfo = TimeInfo(display_due_date_string, grace_period_string) except: log.error("Error parsing due date information in location {0}".format(location)) raise @@ -177,10 +178,10 @@ class CombinedOpenEndedV1Module(): 'rubric': definition['rubric'], 'display_name': self.display_name, 'accept_file_upload': self.accept_file_upload, - 'close_date' : self.timeinfo.close_date, - 's3_interface' : self.system.s3_interface, - 'skip_basic_checks' : self.skip_basic_checks, - } + 'close_date': self.timeinfo.close_date, + 's3_interface': self.system.s3_interface, + 'skip_basic_checks': self.skip_basic_checks, + } self.task_xml = definition['task_xml'] self.location = location @@ -223,15 +224,15 @@ class CombinedOpenEndedV1Module(): child_modules = { 'openended': open_ended_module.OpenEndedModule, 'selfassessment': self_assessment_module.SelfAssessmentModule, - } + } child_descriptors = { 'openended': open_ended_module.OpenEndedDescriptor, 'selfassessment': self_assessment_module.SelfAssessmentDescriptor, - } + } children = { 'modules': child_modules, 'descriptors': child_descriptors, - } + } return children def setup_next_task(self, reset=False): @@ -267,7 +268,8 @@ class CombinedOpenEndedV1Module(): self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system) if current_task_state is None and self.current_task_number == 0: self.current_task = child_task_module(self.system, self.location, - self.current_task_parsed_xml, self.current_task_descriptor, self.static_data) + self.current_task_parsed_xml, self.current_task_descriptor, + self.static_data) self.task_states.append(self.current_task.get_instance_state()) self.state = self.ASSESSING elif current_task_state is None and self.current_task_number > 0: @@ -280,18 +282,20 @@ class CombinedOpenEndedV1Module(): 'attempts': 0, 'created': True, 'history': [{'answer': last_response}], - }) + }) self.current_task = child_task_module(self.system, self.location, - self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=current_task_state) + self.current_task_parsed_xml, self.current_task_descriptor, + self.static_data, + instance_state=current_task_state) self.task_states.append(self.current_task.get_instance_state()) self.state = self.ASSESSING else: if self.current_task_number > 0 and not reset: current_task_state = self.overwrite_state(current_task_state) self.current_task = child_task_module(self.system, self.location, - self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=current_task_state) + self.current_task_parsed_xml, self.current_task_descriptor, + self.static_data, + instance_state=current_task_state) return True @@ -307,8 +311,8 @@ class CombinedOpenEndedV1Module(): last_response_data = self.get_last_response(self.current_task_number - 1) current_response_data = self.get_current_attributes(self.current_task_number) - if(current_response_data['min_score_to_attempt'] > last_response_data['score'] - or current_response_data['max_score_to_attempt'] < last_response_data['score']): + if (current_response_data['min_score_to_attempt'] > last_response_data['score'] + or current_response_data['max_score_to_attempt'] < last_response_data['score']): self.state = self.DONE self.allow_reset = True @@ -334,8 +338,8 @@ class CombinedOpenEndedV1Module(): 'display_name': self.display_name, 'accept_file_upload': self.accept_file_upload, 'location': self.location, - 'legend_list' : LEGEND_LIST, - } + 'legend_list': LEGEND_LIST, + } return context @@ -404,7 +408,7 @@ class CombinedOpenEndedV1Module(): task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system) task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor, - self.static_data, instance_state=task_state) + self.static_data, instance_state=task_state) last_response = task.latest_answer() last_score = task.latest_score() last_post_assessment = task.latest_post_assessment(self.system) @@ -426,10 +430,10 @@ class CombinedOpenEndedV1Module(): rubric_scores = rubric_data['rubric_scores'] grader_types = rubric_data['grader_types'] feedback_items = rubric_data['feedback_items'] - feedback_dicts = rubric_data['feedback_dicts'] + feedback_dicts = rubric_data['feedback_dicts'] grader_ids = rubric_data['grader_ids'] - submission_ids = rubric_data['submission_ids'] - elif task_type== "selfassessment": + submission_ids = rubric_data['submission_ids'] + elif task_type == "selfassessment": rubric_scores = last_post_assessment grader_types = ['SA'] feedback_items = [''] @@ -446,7 +450,7 @@ class CombinedOpenEndedV1Module(): human_state = task.HUMAN_NAMES[state] else: human_state = state - if len(grader_types)>0: + if len(grader_types) > 0: grader_type = grader_types[0] else: grader_type = "IN" @@ -468,15 +472,15 @@ class CombinedOpenEndedV1Module(): 'correct': last_correctness, 'min_score_to_attempt': min_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt, - 'rubric_scores' : rubric_scores, - 'grader_types' : grader_types, - 'feedback_items' : feedback_items, - 'grader_type' : grader_type, - 'human_grader_type' : human_grader_name, - 'feedback_dicts' : feedback_dicts, - 'grader_ids' : grader_ids, - 'submission_ids' : submission_ids, - } + 'rubric_scores': rubric_scores, + 'grader_types': grader_types, + 'feedback_items': feedback_items, + 'grader_type': grader_type, + 'human_grader_type': human_grader_name, + 'feedback_dicts': feedback_dicts, + 'grader_ids': grader_ids, + 'submission_ids': submission_ids, + } return last_response_dict def update_task_states(self): @@ -519,20 +523,27 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered via ajax that contains the result html. """ all_responses = [] - loop_up_to_task = self.current_task_number+1 - for i in xrange(0,loop_up_to_task): + loop_up_to_task = self.current_task_number + 1 + for i in xrange(0, loop_up_to_task): all_responses.append(self.get_last_response(i)) - rubric_scores = [all_responses[i]['rubric_scores'] for i in xrange(0,len(all_responses)) if len(all_responses[i]['rubric_scores'])>0 and all_responses[i]['grader_types'][0] in HUMAN_GRADER_TYPE.keys()] - grader_types = [all_responses[i]['grader_types'] for i in xrange(0,len(all_responses)) if len(all_responses[i]['grader_types'])>0 and all_responses[i]['grader_types'][0] in HUMAN_GRADER_TYPE.keys()] - feedback_items = [all_responses[i]['feedback_items'] for i in xrange(0,len(all_responses)) if len(all_responses[i]['feedback_items'])>0 and all_responses[i]['grader_types'][0] in HUMAN_GRADER_TYPE.keys()] - rubric_html = self.rubric_renderer.render_combined_rubric(stringify_children(self.static_data['rubric']), rubric_scores, - grader_types, feedback_items) + rubric_scores = [all_responses[i]['rubric_scores'] for i in xrange(0, len(all_responses)) if + len(all_responses[i]['rubric_scores']) > 0 and all_responses[i]['grader_types'][ + 0] in HUMAN_GRADER_TYPE.keys()] + grader_types = [all_responses[i]['grader_types'] for i in xrange(0, len(all_responses)) if + len(all_responses[i]['grader_types']) > 0 and all_responses[i]['grader_types'][ + 0] in HUMAN_GRADER_TYPE.keys()] + feedback_items = [all_responses[i]['feedback_items'] for i in xrange(0, len(all_responses)) if + len(all_responses[i]['feedback_items']) > 0 and all_responses[i]['grader_types'][ + 0] in HUMAN_GRADER_TYPE.keys()] + rubric_html = self.rubric_renderer.render_combined_rubric(stringify_children(self.static_data['rubric']), + rubric_scores, + grader_types, feedback_items) response_dict = all_responses[-1] context = { 'results': rubric_html, - 'task_name' : 'Scored Rubric', - 'class_name' : 'combined-rubric-container' + 'task_name': 'Scored Rubric', + 'class_name': 'combined-rubric-container' } html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} @@ -544,8 +555,8 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered via ajax that contains the result html. """ context = { - 'legend_list' : LEGEND_LIST, - } + 'legend_list': LEGEND_LIST, + } html = self.system.render_template('{0}/combined_open_ended_legend.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} @@ -556,15 +567,16 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered via ajax that contains the result html. """ self.update_task_states() - loop_up_to_task = self.current_task_number+1 - all_responses =[] - for i in xrange(0,loop_up_to_task): + loop_up_to_task = self.current_task_number + 1 + all_responses = [] + for i in xrange(0, loop_up_to_task): all_responses.append(self.get_last_response(i)) context_list = [] for ri in all_responses: - for i in xrange(0,len(ri['rubric_scores'])): - feedback = ri['feedback_dicts'][i].get('feedback','') - rubric_data = self.rubric_renderer.render_rubric(stringify_children(self.static_data['rubric']), ri['rubric_scores'][i]) + for i in xrange(0, len(ri['rubric_scores'])): + feedback = ri['feedback_dicts'][i].get('feedback', '') + rubric_data = self.rubric_renderer.render_rubric(stringify_children(self.static_data['rubric']), + ri['rubric_scores'][i]) if rubric_data['success']: rubric_html = rubric_data['html'] else: @@ -572,23 +584,23 @@ class CombinedOpenEndedV1Module(): context = { 'rubric_html': rubric_html, 'grader_type': ri['grader_type'], - 'feedback' : feedback, - 'grader_id' : ri['grader_ids'][i], - 'submission_id' : ri['submission_ids'][i], + 'feedback': feedback, + 'grader_id': ri['grader_ids'][i], + 'submission_id': ri['submission_ids'][i], } context_list.append(context) feedback_table = self.system.render_template('{0}/open_ended_result_table.html'.format(self.TEMPLATE_DIR), { - 'context_list' : context_list, - 'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT, - 'human_grader_types' : HUMAN_GRADER_TYPE, + 'context_list': context_list, + 'grader_type_image_dict': GRADER_TYPE_IMAGE_DICT, + 'human_grader_types': HUMAN_GRADER_TYPE, 'rows': 50, 'cols': 50, }) context = { 'results': feedback_table, - 'task_name' : "Feedback", - 'class_name' : "result-container", - } + 'task_name': "Feedback", + 'class_name': "result-container", + } html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} @@ -617,8 +629,8 @@ class CombinedOpenEndedV1Module(): 'reset': self.reset, 'get_results': self.get_results, 'get_combined_rubric': self.get_rubric, - 'get_status' : self.get_status_ajax, - 'get_legend' : self.get_legend, + 'get_status': self.get_status_ajax, + 'get_legend': self.get_legend, } if dispatch not in handlers: @@ -681,7 +693,7 @@ class CombinedOpenEndedV1Module(): 'task_states': self.task_states, 'attempts': self.attempts, 'ready_to_reset': self.allow_reset, - } + } return json.dumps(state) @@ -699,11 +711,12 @@ class CombinedOpenEndedV1Module(): context = { 'status_list': status, - 'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT, - 'legend_list' : LEGEND_LIST, - 'render_via_ajax' : render_via_ajax, + 'grader_type_image_dict': GRADER_TYPE_IMAGE_DICT, + 'legend_list': LEGEND_LIST, + 'render_via_ajax': render_via_ajax, } - status_html = self.system.render_template("{0}/combined_open_ended_status.html".format(self.TEMPLATE_DIR), context) + status_html = self.system.render_template("{0}/combined_open_ended_status.html".format(self.TEMPLATE_DIR), + context) return status_html @@ -736,7 +749,7 @@ class CombinedOpenEndedV1Module(): score_dict = { 'score': score, 'total': max_score, - } + } return score_dict @@ -793,7 +806,9 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor): for child in expected_children: if len(xml_object.xpath(child)) == 0: #This is a staff_facing_error - raise ValueError("Combined Open Ended definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format(child)) + raise ValueError( + "Combined Open Ended definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format( + child)) def parse_task(k): """Assumes that xml_object has child k""" diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py index 8d1bd376fb..287aeb5c24 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py @@ -4,24 +4,26 @@ from lxml import etree log = logging.getLogger(__name__) GRADER_TYPE_IMAGE_DICT = { - 'SA' : '/static/images/self_assessment_icon.png', - 'PE' : '/static/images/peer_grading_icon.png', - 'ML' : '/static/images/ml_grading_icon.png', - 'IN' : '/static/images/peer_grading_icon.png', - 'BC' : '/static/images/ml_grading_icon.png', - } + 'SA': '/static/images/self_assessment_icon.png', + 'PE': '/static/images/peer_grading_icon.png', + 'ML': '/static/images/ml_grading_icon.png', + 'IN': '/static/images/peer_grading_icon.png', + 'BC': '/static/images/ml_grading_icon.png', +} HUMAN_GRADER_TYPE = { - 'SA' : 'Self-Assessment', - 'PE' : 'Peer-Assessment', - 'IN' : 'Instructor-Assessment', - 'ML' : 'AI-Assessment', - 'BC' : 'AI-Assessment', - } + 'SA': 'Self-Assessment', + 'PE': 'Peer-Assessment', + 'IN': 'Instructor-Assessment', + 'ML': 'AI-Assessment', + 'BC': 'AI-Assessment', +} DO_NOT_DISPLAY = ['BC', 'IN'] -LEGEND_LIST = [{'name' : HUMAN_GRADER_TYPE[k], 'image' : GRADER_TYPE_IMAGE_DICT[k]} for k in GRADER_TYPE_IMAGE_DICT.keys() if k not in DO_NOT_DISPLAY ] +LEGEND_LIST = [{'name': HUMAN_GRADER_TYPE[k], 'image': GRADER_TYPE_IMAGE_DICT[k]} for k in GRADER_TYPE_IMAGE_DICT.keys() + if k not in DO_NOT_DISPLAY] + class RubricParsingError(Exception): def __init__(self, msg): @@ -29,15 +31,14 @@ class RubricParsingError(Exception): class CombinedOpenEndedRubric(object): - TEMPLATE_DIR = "combinedopenended/openended" - def __init__ (self, system, view_only = False): + def __init__(self, system, view_only=False): self.has_score = False self.view_only = view_only self.system = system - def render_rubric(self, rubric_xml, score_list = None): + def render_rubric(self, rubric_xml, score_list=None): ''' render_rubric: takes in an xml string and outputs the corresponding html for that xml, given the type of rubric we're generating @@ -50,11 +51,11 @@ class CombinedOpenEndedRubric(object): success = False try: rubric_categories = self.extract_categories(rubric_xml) - if score_list and len(score_list)==len(rubric_categories): - for i in xrange(0,len(rubric_categories)): + if score_list and len(score_list) == len(rubric_categories): + for i in xrange(0, len(rubric_categories)): category = rubric_categories[i] - for j in xrange(0,len(category['options'])): - if score_list[i]==j: + for j in xrange(0, len(category['options'])): + if score_list[i] == j: rubric_categories[i]['options'][j]['selected'] = True rubric_scores = [cat['score'] for cat in rubric_categories] max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories) @@ -63,19 +64,20 @@ class CombinedOpenEndedRubric(object): if self.view_only: rubric_template = '{0}/open_ended_view_only_rubric.html'.format(self.TEMPLATE_DIR) html = self.system.render_template(rubric_template, - {'categories': rubric_categories, - 'has_score': self.has_score, - 'view_only': self.view_only, - 'max_score': max_score, - 'combined_rubric' : False - }) + {'categories': rubric_categories, + 'has_score': self.has_score, + 'view_only': self.view_only, + 'max_score': max_score, + 'combined_rubric': False + }) success = True except: #This is a staff_facing_error - error_message = "[render_rubric] Could not parse the rubric with xml: {0}. Contact the learning sciences group for assistance.".format(rubric_xml) + error_message = "[render_rubric] Could not parse the rubric with xml: {0}. Contact the learning sciences group for assistance.".format( + rubric_xml) log.exception(error_message) raise RubricParsingError(error_message) - return {'success' : success, 'html' : html, 'rubric_scores' : rubric_scores} + return {'success': success, 'html': html, 'rubric_scores': rubric_scores} def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed, max_score): rubric_dict = self.render_rubric(rubric_string) @@ -83,7 +85,8 @@ class CombinedOpenEndedRubric(object): rubric_feedback = rubric_dict['html'] if not success: #This is a staff_facing_error - error_message = "Could not parse rubric : {0} for location {1}. Contact the learning sciences group for assistance.".format(rubric_string, location.url()) + error_message = "Could not parse rubric : {0} for location {1}. Contact the learning sciences group for assistance.".format( + rubric_string, location.url()) log.error(error_message) raise RubricParsingError(error_message) @@ -101,7 +104,7 @@ class CombinedOpenEndedRubric(object): if total != max_score: #This is a staff_facing_error error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}. Contact the learning sciences group for assistance.".format( - max_score, location, total) + max_score, location, total) log.error(error_msg) raise RubricParsingError(error_msg) @@ -123,12 +126,13 @@ class CombinedOpenEndedRubric(object): for category in element: if category.tag != 'category': #This is a staff_facing_error - raise RubricParsingError("[extract_categories] Expected a tag: got {0} instead. Contact the learning sciences group for assistance.".format(category.tag)) + raise RubricParsingError( + "[extract_categories] Expected a tag: got {0} instead. Contact the learning sciences group for assistance.".format( + category.tag)) else: categories.append(self.extract_category(category)) return categories - def extract_category(self, category): ''' construct an individual category @@ -150,13 +154,17 @@ class CombinedOpenEndedRubric(object): # if we are missing the score tag and we are expecting one elif self.has_score: #This is a staff_facing_error - raise RubricParsingError("[extract_category] Category {0} is missing a score. Contact the learning sciences group for assistance.".format(descriptionxml.text)) + raise RubricParsingError( + "[extract_category] Category {0} is missing a score. Contact the learning sciences group for assistance.".format( + descriptionxml.text)) # parse description if descriptionxml.tag != 'description': #This is a staff_facing_error - raise RubricParsingError("[extract_category]: expected description tag, got {0} instead. Contact the learning sciences group for assistance.".format(descriptionxml.tag)) + raise RubricParsingError( + "[extract_category]: expected description tag, got {0} instead. Contact the learning sciences group for assistance.".format( + descriptionxml.tag)) description = descriptionxml.text @@ -167,7 +175,9 @@ class CombinedOpenEndedRubric(object): for option in optionsxml: if option.tag != 'option': #This is a staff_facing_error - raise RubricParsingError("[extract_category]: expected option tag, got {0} instead. Contact the learning sciences group for assistance.".format(option.tag)) + raise RubricParsingError( + "[extract_category]: expected option tag, got {0} instead. Contact the learning sciences group for assistance.".format( + option.tag)) else: pointstr = option.get("points") if pointstr: @@ -177,13 +187,16 @@ class CombinedOpenEndedRubric(object): points = int(pointstr) except ValueError: #This is a staff_facing_error - raise RubricParsingError("[extract_category]: expected points to have int, got {0} instead. Contact the learning sciences group for assistance.".format(pointstr)) + raise RubricParsingError( + "[extract_category]: expected points to have int, got {0} instead. Contact the learning sciences group for assistance.".format( + pointstr)) elif autonumbering: # use the generated one if we're in the right mode points = cur_points cur_points = cur_points + 1 else: - raise Exception("[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly defined.") + raise Exception( + "[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly defined.") selected = score == points optiontext = option.text @@ -193,34 +206,34 @@ class CombinedOpenEndedRubric(object): options = sorted(options, key=lambda option: option['points']) CombinedOpenEndedRubric.validate_options(options) - return {'description': description, 'options': options, 'score' : score} + return {'description': description, 'options': options, 'score': score} - def render_combined_rubric(self,rubric_xml,scores,score_types,feedback_types): - success, score_tuples = CombinedOpenEndedRubric.reformat_scores_for_rendering(scores,score_types,feedback_types) + def render_combined_rubric(self, rubric_xml, scores, score_types, feedback_types): + success, score_tuples = CombinedOpenEndedRubric.reformat_scores_for_rendering(scores, score_types, + feedback_types) rubric_categories = self.extract_categories(rubric_xml) max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories) max_score = max(max_scores) - for i in xrange(0,len(rubric_categories)): + for i in xrange(0, len(rubric_categories)): category = rubric_categories[i] - for j in xrange(0,len(category['options'])): + for j in xrange(0, len(category['options'])): rubric_categories[i]['options'][j]['grader_types'] = [] for tuple in score_tuples: - if tuple[1] == i and tuple[2] ==j: + if tuple[1] == i and tuple[2] == j: for grader_type in tuple[3]: rubric_categories[i]['options'][j]['grader_types'].append(grader_type) html = self.system.render_template('{0}/open_ended_combined_rubric.html'.format(self.TEMPLATE_DIR), - {'categories': rubric_categories, - 'has_score': True, - 'view_only': True, - 'max_score': max_score, - 'combined_rubric' : True, - 'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT, - 'human_grader_types' : HUMAN_GRADER_TYPE, - }) + {'categories': rubric_categories, + 'has_score': True, + 'view_only': True, + 'max_score': max_score, + 'combined_rubric': True, + 'grader_type_image_dict': GRADER_TYPE_IMAGE_DICT, + 'human_grader_types': HUMAN_GRADER_TYPE, + }) return html - @staticmethod def validate_options(options): ''' @@ -228,14 +241,16 @@ class CombinedOpenEndedRubric(object): ''' if len(options) == 0: #This is a staff_facing_error - raise RubricParsingError("[extract_category]: no options associated with this category. Contact the learning sciences group for assistance.") + raise RubricParsingError( + "[extract_category]: no options associated with this category. Contact the learning sciences group for assistance.") if len(options) == 1: return prev = options[0]['points'] for option in options[1:]: if prev == option['points']: #This is a staff_facing_error - raise RubricParsingError("[extract_category]: found duplicate point values between two different options. Contact the learning sciences group for assistance.") + raise RubricParsingError( + "[extract_category]: found duplicate point values between two different options. Contact the learning sciences group for assistance.") else: prev = option['points'] @@ -250,7 +265,7 @@ class CombinedOpenEndedRubric(object): @return: """ success = False - if len(scores)==0: + if len(scores) == 0: #This is a dev_facing_error log.error("Score length is 0 when trying to reformat rubric scores for rendering.") return success, "" @@ -264,25 +279,25 @@ class CombinedOpenEndedRubric(object): score_lists = [] score_type_list = [] feedback_type_list = [] - for i in xrange(0,len(scores)): + for i in xrange(0, len(scores)): score_cont_list = scores[i] - for j in xrange(0,len(score_cont_list)): + for j in xrange(0, len(score_cont_list)): score_list = score_cont_list[j] score_lists.append(score_list) score_type_list.append(score_types[i][j]) feedback_type_list.append(feedback_types[i][j]) score_list_len = len(score_lists[0]) - for i in xrange(0,len(score_lists)): + for i in xrange(0, len(score_lists)): score_list = score_lists[i] - if len(score_list)!=score_list_len: + if len(score_list) != score_list_len: return success, "" score_tuples = [] - for i in xrange(0,len(score_lists)): - for j in xrange(0,len(score_lists[i])): - tuple = [1,j,score_lists[i][j],[],[]] - score_tuples, tup_ind = CombinedOpenEndedRubric.check_for_tuple_matches(score_tuples,tuple) + for i in xrange(0, len(score_lists)): + for j in xrange(0, len(score_lists[i])): + tuple = [1, j, score_lists[i][j], [], []] + score_tuples, tup_ind = CombinedOpenEndedRubric.check_for_tuple_matches(score_tuples, tuple) score_tuples[tup_ind][0] += 1 score_tuples[tup_ind][3].append(score_type_list[i]) score_tuples[tup_ind][4].append(feedback_type_list[i]) @@ -302,18 +317,12 @@ class CombinedOpenEndedRubric(object): category = tuple[1] score = tuple[2] tup_ind = -1 - for t in xrange(0,len(tuples)): + for t in xrange(0, len(tuples)): if tuples[t][1] == category and tuples[t][2] == score: tup_ind = t break if tup_ind == -1: - tuples.append([0,category,score,[],[]]) - tup_ind = len(tuples)-1 + tuples.append([0, category, score, [], []]) + tup_ind = len(tuples) - 1 return tuples, tup_ind - - - - - - diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py index 1dd5c57ad4..21715c8e57 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py @@ -8,6 +8,7 @@ class ControllerQueryService(GradingService): """ Interface to staff grading backend. """ + def __init__(self, config, system): config['system'] = system super(ControllerQueryService, self).__init__(config) @@ -59,7 +60,7 @@ class ControllerQueryService(GradingService): def get_flagged_problem_list(self, course_id): params = { 'course_id': course_id, - } + } response = self.get(self.flagged_problem_list_url, params) return response @@ -70,20 +71,21 @@ class ControllerQueryService(GradingService): 'student_id': student_id, 'submission_id': submission_id, 'action_type': action_type - } + } response = self.post(self.take_action_on_flags_url, params) return response + def convert_seconds_to_human_readable(seconds): if seconds < 60: human_string = "{0} seconds".format(seconds) elif seconds < 60 * 60: - human_string = "{0} minutes".format(round(seconds/60,1)) - elif seconds < (24*60*60): - human_string = "{0} hours".format(round(seconds/(60*60),1)) + human_string = "{0} minutes".format(round(seconds / 60, 1)) + elif seconds < (24 * 60 * 60): + human_string = "{0} hours".format(round(seconds / (60 * 60), 1)) else: - human_string = "{0} days".format(round(seconds/(60*60*24),1)) + human_string = "{0} days".format(round(seconds / (60 * 60 * 24), 1)) eta_string = "{0}".format(human_string) return eta_string diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py index 8a4caa1291..0f961794d5 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py @@ -19,6 +19,7 @@ class GradingService(object): """ Interface to staff grading backend. """ + def __init__(self, config): self.username = config['username'] self.password = config['password'] @@ -34,8 +35,8 @@ class GradingService(object): Returns the decoded json dict of the response. """ response = self.session.post(self.login_url, - {'username': self.username, - 'password': self.password, }) + {'username': self.username, + 'password': self.password, }) response.raise_for_status() @@ -47,7 +48,7 @@ class GradingService(object): """ try: op = lambda: self.session.post(url, data=data, - allow_redirects=allow_redirects) + allow_redirects=allow_redirects) r = self._try_with_login(op) except (RequestException, ConnectionError, HTTPError) as err: # reraise as promised GradingServiceError, but preserve stacktrace. @@ -63,8 +64,8 @@ class GradingService(object): """ log.debug(params) op = lambda: self.session.get(url, - allow_redirects=allow_redirects, - params=params) + allow_redirects=allow_redirects, + params=params) try: r = self._try_with_login(op) except (RequestException, ConnectionError, HTTPError) as err: @@ -92,7 +93,7 @@ class GradingService(object): r = self._login() if r and not r.get('success'): log.warning("Couldn't log into staff_grading backend. Response: %s", - r) + r) # try again response = operation() response.raise_for_status() diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py index edae69854f..6956f336a5 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py @@ -5,6 +5,7 @@ to send them to S3. try: from PIL import Image + ENABLE_PIL = True except: ENABLE_PIL = False @@ -51,6 +52,7 @@ class ImageProperties(object): """ Class to check properties of an image and to validate if they are allowed. """ + def __init__(self, image_data): """ Initializes class variables @@ -92,7 +94,7 @@ class ImageProperties(object): g = rgb[1] b = rgb[2] check_r = (r > 60) - check_g = (r * 0.4) < g < (r * 0.85) + check_g = (r * 0.4) < g < (r * 0.85) check_b = (r * 0.2) < b < (r * 0.7) colors_okay = check_r and check_b and check_g except: @@ -141,6 +143,7 @@ class URLProperties(object): Checks to see if a URL points to acceptable content. Added to check if students are submitting reasonable links to the peer grading image functionality of the external grading service. """ + def __init__(self, url_string): self.url_string = url_string @@ -212,7 +215,7 @@ def run_image_tests(image): success = image_properties.run_tests() except: log.exception("Cannot run image tests in combined open ended xmodule. May be an issue with a particular image," - "or an issue with the deployment configuration of PIL/Pillow") + "or an issue with the deployment configuration of PIL/Pillow") return success @@ -252,7 +255,8 @@ def upload_to_s3(file_to_upload, keyname, s3_interface): return True, public_url except: #This is a dev_facing_error - error_message = "Could not connect to S3 to upload peer grading image. Trying to utilize bucket: {0}".format(bucketname.lower()) + error_message = "Could not connect to S3 to upload peer grading image. Trying to utilize bucket: {0}".format( + bucketname.lower()) log.error(error_message) return False, error_message diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index aa805a5290..fc53a62c06 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -10,7 +10,7 @@ import logging from lxml import etree import capa.xqueue_interface as xqueue_interface -from xmodule.capa_module import ComplexEncoder +from xmodule.capa_module import ComplexEncoder from xmodule.editing_module import EditingDescriptor from xmodule.progress import Progress from xmodule.stringify import stringify_children @@ -77,7 +77,6 @@ class OpenEndedModule(openendedchild.OpenEndedChild): self.send_to_grader(self.latest_answer(), system) self.created = False - def _parse(self, oeparam, prompt, rubric, system): ''' Parse OpenEndedResponse XML: @@ -104,7 +103,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # response types) except TypeError, ValueError: #This is a dev_facing_error - log.exception("Grader payload from external open ended grading server is not a json object! Object: {0}".format(grader_payload)) + log.exception( + "Grader payload from external open ended grading server is not a json object! Object: {0}".format( + grader_payload)) self.initial_display = find_with_default(oeparam, 'initial_display', '') self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.') @@ -148,7 +149,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild): for tag in ['feedback', 'submission_id', 'grader_id', 'score']: if tag not in survey_responses: #This is a student_facing_error - return {'success': False, 'msg': "Could not find needed tag {0} in the survey responses. Please try submitting again.".format(tag)} + return {'success': False, + 'msg': "Could not find needed tag {0} in the survey responses. Please try submitting again.".format( + tag)} try: submission_id = int(survey_responses['submission_id']) grader_id = int(survey_responses['grader_id']) @@ -188,7 +191,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): } (error, msg) = qinterface.send_to_queue(header=xheader, - body=json.dumps(contents)) + body=json.dumps(contents)) #Convert error to a success value success = True @@ -222,8 +225,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): str(len(self.history))) xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['callback_url'], - lms_key=queuekey, - queue_name=self.queue_name) + lms_key=queuekey, + queue_name=self.queue_name) contents = self.payload.copy() @@ -241,7 +244,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # Submit request. When successful, 'msg' is the prior length of the queue (error, msg) = qinterface.send_to_queue(header=xheader, - body=json.dumps(contents)) + body=json.dumps(contents)) # State associated with the queueing request queuestate = {'key': queuekey, @@ -266,7 +269,6 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return True - def get_answers(self): """ Gets and shows the answer for this problem. @@ -300,7 +302,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # We want to display available feedback in a particular order. # This dictionary specifies which goes first--lower first. - priorities = { # These go at the start of the feedback + priorities = {# These go at the start of the feedback 'spelling': 0, 'grammar': 1, # needs to be after all the other feedback @@ -400,7 +402,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if not response_items['success']: return system.render_template("{0}/open_ended_error.html".format(self.TEMPLATE_DIR), - {'errors': feedback}) + {'errors': feedback}) feedback_template = system.render_template("{0}/open_ended_feedback.html".format(self.TEMPLATE_DIR), { 'grader_type': response_items['grader_type'], @@ -411,7 +413,6 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return feedback_template, rubric_scores - def _parse_score_msg(self, score_msg, system, join_feedback=True): """ Grader reply is a JSON-dump of the following dict @@ -437,13 +438,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild): 'valid': False, 'score': 0, 'feedback': '', - 'rubric_scores' : [[0]], - 'grader_types' : [''], - 'feedback_items' : [''], - 'feedback_dicts' : [{}], - 'grader_ids' : [0], - 'submission_ids' : [0], - } + 'rubric_scores': [[0]], + 'grader_types': [''], + 'feedback_items': [''], + 'feedback_dicts': [{}], + 'grader_ids': [0], + 'submission_ids': [0], + } try: score_result = json.loads(score_msg) except (TypeError, ValueError): @@ -470,7 +471,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): log.error(error_message) fail['feedback'] = error_message return fail - #This is to support peer grading + #This is to support peer grading if isinstance(score_result['score'], list): feedback_items = [] rubric_scores = [] @@ -527,12 +528,12 @@ class OpenEndedModule(openendedchild.OpenEndedChild): 'valid': True, 'score': score, 'feedback': feedback, - 'rubric_scores' : rubric_scores, - 'grader_types' : grader_types, - 'feedback_items' : feedback_items, - 'feedback_dicts' : feedback_dicts, - 'grader_ids' : grader_ids, - 'submission_ids' : submission_ids, + 'rubric_scores': rubric_scores, + 'grader_types': grader_types, + 'feedback_items': feedback_items, + 'feedback_dicts': feedback_dicts, + 'grader_ids': grader_ids, + 'submission_ids': submission_ids, } def latest_post_assessment(self, system, short_feedback=False, join_feedback=True): @@ -545,7 +546,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return "" feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system, - join_feedback=join_feedback) + join_feedback=join_feedback) if not short_feedback: return feedback_dict['feedback'] if feedback_dict['valid'] else '' if feedback_dict['valid']: @@ -585,7 +586,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): #This is a dev_facing_error log.error("Cannot find {0} in handlers in handle_ajax function for open_ended_module.py".format(dispatch)) #This is a dev_facing_error - return json.dumps({'error': 'Error handling action. Please try again.', 'success' : False}) + return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) before = self.get_progress() d = handlers[dispatch](get, system) @@ -679,7 +680,6 @@ class OpenEndedModule(openendedchild.OpenEndedChild): correct = "" previous_answer = self.initial_display - context = { 'prompt': self.prompt, 'previous_answer': previous_answer, @@ -692,7 +692,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): 'child_type': 'openended', 'correct': correct, 'accept_file_upload': self.accept_file_upload, - 'eta_message' : eta_string, + 'eta_message': eta_string, } html = system.render_template('{0}/open_ended.html'.format(self.TEMPLATE_DIR), context) return html @@ -723,7 +723,9 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor): for child in ['openendedparam']: if len(xml_object.xpath(child)) != 1: #This is a staff_facing_error - raise ValueError("Open Ended definition must include exactly one '{0}' tag. Contact the learning sciences group for assistance.".format(child)) + raise ValueError( + "Open Ended definition must include exactly one '{0}' tag. Contact the learning sciences group for assistance.".format( + child)) def parse(k): """Assumes that xml_object has child k""" diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 31d26fd3c3..922a4f9b77 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -74,7 +74,7 @@ class OpenEndedChild(object): 'done': 'Done', } - def __init__(self, system, location, definition, descriptor, static_data, + def __init__(self, system, location, definition, descriptor, static_data, instance_state=None, shared_state=None, **kwargs): # Load instance state if instance_state is not None: @@ -108,15 +108,14 @@ class OpenEndedChild(object): self._max_score = static_data['max_score'] if system.open_ended_grading_interface: self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system) - self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,system) + self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface, + system) else: self.peer_gs = MockPeerGradingService() - self.controller_qs = None - - + self.controller_qs = None self.system = system - + self.location_string = location try: self.location_string = self.location_string.url() @@ -152,7 +151,8 @@ class OpenEndedChild(object): return True, { 'success': False, #This is a student_facing_error - 'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format(self.attempts, self.max_attempts) + 'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format( + self.attempts, self.max_attempts) } else: return False, {} @@ -180,8 +180,8 @@ class OpenEndedChild(object): try: answer = autolink_html(answer) cleaner = Cleaner(style=True, links=True, add_nofollow=False, page_structure=True, safe_attrs_only=True, - host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS, - whitelist_tags=set(['embed', 'iframe', 'a', 'img'])) + host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS, + whitelist_tags=set(['embed', 'iframe', 'a', 'img'])) clean_html = cleaner.clean_html(answer) clean_html = re.sub(r'

$', '', re.sub(r'^

', '', clean_html)) except: @@ -282,7 +282,7 @@ class OpenEndedChild(object): """ #This is a dev_facing_error log.warning("Open ended child state out sync. state: %r, get: %r. %s", - self.state, get, msg) + self.state, get, msg) #This is a student_facing_error return {'success': False, 'error': 'The problem state got out-of-sync. Please try reloading the page.'} @@ -308,7 +308,7 @@ class OpenEndedChild(object): @return: Boolean correct. """ correct = False - if(isinstance(score, (int, long, float, complex))): + if (isinstance(score, (int, long, float, complex))): score_ratio = int(score) / float(self.max_score()) correct = (score_ratio >= 0.66) return correct @@ -342,7 +342,8 @@ class OpenEndedChild(object): try: image_data.seek(0) - success, s3_public_url = open_ended_image_submission.upload_to_s3(image_data, image_key, self.s3_interface) + success, s3_public_url = open_ended_image_submission.upload_to_s3(image_data, image_key, + self.s3_interface) except: log.exception("Could not upload image to S3.") @@ -404,9 +405,9 @@ class OpenEndedChild(object): #In this case, an image was submitted by the student, but the image could not be uploaded to S3. Likely #a config issue (development vs deployment). For now, just treat this as a "success" log.exception("Student AJAX post to combined open ended xmodule indicated that it contained an image, " - "but the image was not able to be uploaded to S3. This could indicate a config" - "issue with this deployment, but it could also indicate a problem with S3 or with the" - "student image itself.") + "but the image was not able to be uploaded to S3. This could indicate a config" + "issue with this deployment, but it could also indicate a problem with S3 or with the" + "student image itself.") overall_success = True elif not has_file_to_upload: #If there is no file to upload, probably the student has embedded the link in the answer text @@ -445,7 +446,7 @@ class OpenEndedChild(object): response = {} #This is a student_facing_error error_string = ("You need to peer grade {0} more in order to make another submission. " - "You have graded {1}, and {2} are required. You have made {3} successful peer grading submissions.") + "You have graded {1}, and {2} are required. You have made {3} successful peer grading submissions.") try: response = self.peer_gs.get_data_for_location(self.location_string, student_id) count_graded = response['count_graded'] @@ -454,16 +455,18 @@ class OpenEndedChild(object): success = True except: #This is a dev_facing_error - log.error("Could not contact external open ended graders for location {0} and student {1}".format(self.location_string,student_id)) + log.error("Could not contact external open ended graders for location {0} and student {1}".format( + self.location_string, student_id)) #This is a student_facing_error error_message = "Could not contact the graders. Please notify course staff." return success, allowed_to_submit, error_message - if count_graded>=count_required: + if count_graded >= count_required: return success, allowed_to_submit, "" else: allowed_to_submit = False #This is a student_facing_error - error_message = error_string.format(count_required-count_graded, count_graded, count_required, student_sub_count) + error_message = error_string.format(count_required - count_graded, count_graded, count_required, + student_sub_count) return success, allowed_to_submit, error_message def get_eta(self): @@ -478,7 +481,7 @@ class OpenEndedChild(object): success = response['success'] if isinstance(success, basestring): - success = (success.lower()=="true") + success = (success.lower() == "true") if success: eta = controller_query_service.convert_seconds_to_human_readable(response['eta']) @@ -487,6 +490,3 @@ class OpenEndedChild(object): eta_string = "" return eta_string - - - diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py index 42c54f0463..5daf1b83b5 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py @@ -14,6 +14,7 @@ class PeerGradingService(GradingService): """ Interface with the grading controller for peer grading """ + def __init__(self, config, system): config['system'] = system super(PeerGradingService, self).__init__(config) @@ -36,10 +37,11 @@ class PeerGradingService(GradingService): def get_next_submission(self, problem_location, grader_id): response = self.get(self.get_next_submission_url, - {'location': problem_location, 'grader_id': grader_id}) + {'location': problem_location, 'grader_id': grader_id}) return self.try_to_decode(self._render_rubric(response)) - def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores, submission_flagged): + def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores, + submission_flagged): data = {'grader_id': grader_id, 'submission_id': submission_id, 'score': score, @@ -89,6 +91,7 @@ class PeerGradingService(GradingService): pass return text + """ This is a mock peer grading service that can be used for unit tests without making actual service calls to the grading controller @@ -122,7 +125,7 @@ class MockPeerGradingService(object): 'max_score': 4}) def save_calibration_essay(self, problem_location, grader_id, - calibration_essay_id, submission_key, score, + calibration_essay_id, submission_key, score, feedback, rubric_scores): return {'success': True, 'actual_score': 2} diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index f4be426667..8911e2890f 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -73,7 +73,6 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): html = system.render_template('{0}/self_assessment_prompt.html'.format(self.TEMPLATE_DIR), context) return html - def handle_ajax(self, dispatch, get, system): """ This is called by courseware.module_render, to handle an AJAX call. @@ -95,7 +94,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): #This is a dev_facing_error log.error("Cannot find {0} in handlers in handle_ajax function for open_ended_module.py".format(dispatch)) #This is a dev_facing_error - return json.dumps({'error': 'Error handling action. Please try again.', 'success' : False}) + return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) before = self.get_progress() d = handlers[dispatch](get, system) @@ -159,7 +158,6 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context) - def save_answer(self, get, system): """ After the answer is submitted, show the rubric. @@ -224,7 +222,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): try: score = int(get['assessment']) score_list = get.getlist('score_list[]') - for i in xrange(0,len(score_list)): + for i in xrange(0, len(score_list)): score_list[i] = int(score_list[i]) except ValueError: #This is a dev_facing_error @@ -268,7 +266,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): 'allow_reset': self._allow_reset()} def latest_post_assessment(self, system): - latest_post_assessment = super(SelfAssessmentModule, self).latest_post_assessment(system) + latest_post_assessment = super(SelfAssessmentModule, self).latest_post_assessment(system) try: rubric_scores = json.loads(latest_post_assessment) except: @@ -305,7 +303,9 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): for child in expected_children: if len(xml_object.xpath(child)) != 1: #This is a staff_facing_error - raise ValueError("Self assessment definition must include exactly one '{0}' tag. Contact the learning sciences group for assistance.".format(child)) + raise ValueError( + "Self assessment definition must include exactly one '{0}' tag. Contact the learning sciences group for assistance.".format( + child)) def parse(k): """Assumes that xml_object has child k""" diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 1e52dcf070..2ea8ab0db5 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -5,7 +5,7 @@ from lxml import etree from datetime import datetime from pkg_resources import resource_string -from .capa_module import ComplexEncoder +from .capa_module import ComplexEncoder from .editing_module import EditingDescriptor from .stringify import stringify_children from .x_module import XModule @@ -34,7 +34,7 @@ class PeerGradingModule(XModule): resource_string(__name__, 'js/src/peergrading/peer_grading_problem.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/javascript_loader.coffee'), - ]} + ]} js_module_name = "PeerGrading" css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]} @@ -42,7 +42,7 @@ class PeerGradingModule(XModule): def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) + instance_state, shared_state, **kwargs) # Load instance state if instance_state is not None: @@ -53,12 +53,11 @@ class PeerGradingModule(XModule): #We need to set the location here so the child modules can use it system.set('location', location) self.system = system - if(self.system.open_ended_grading_interface): + if (self.system.open_ended_grading_interface): self.peer_gs = PeerGradingService(self.system.open_ended_grading_interface, self.system) else: self.peer_gs = MockPeerGradingService() - self.use_for_single_location = self.metadata.get('use_for_single_location', USE_FOR_SINGLE_LOCATION) if isinstance(self.use_for_single_location, basestring): self.use_for_single_location = (self.use_for_single_location in TRUE_DICT) @@ -83,14 +82,13 @@ class PeerGradingModule(XModule): grace_period_string = self.metadata.get('graceperiod', None) try: - self.timeinfo = TimeInfo(display_due_date_string, grace_period_string) + self.timeinfo = TimeInfo(display_due_date_string, grace_period_string) except: log.error("Error parsing due date information in location {0}".format(location)) raise self.display_due_date = self.timeinfo.display_due_date - self.ajax_url = self.system.ajax_url if not self.ajax_url.endswith("/"): self.ajax_url = self.ajax_url + "/" @@ -148,13 +146,13 @@ class PeerGradingModule(XModule): 'save_grade': self.save_grade, 'save_calibration_essay': self.save_calibration_essay, 'problem': self.peer_grading_problem, - } + } if dispatch not in handlers: #This is a dev_facing_error log.error("Cannot find {0} in handlers in handle_ajax function for open_ended_module.py".format(dispatch)) #This is a dev_facing_error - return json.dumps({'error': 'Error handling action. Please try again.', 'success' : False}) + return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) d = handlers[dispatch](get) @@ -191,9 +189,10 @@ class PeerGradingModule(XModule): except: success, response = self.query_data_for_location() if not success: - log.exception("No instance data found and could not get data from controller for loc {0} student {1}".format( - self.system.location.url(), self.system.anonymous_student_id - )) + log.exception( + "No instance data found and could not get data from controller for loc {0} student {1}".format( + self.system.location.url(), self.system.anonymous_student_id + )) return None count_graded = response['count_graded'] count_required = response['count_required'] @@ -204,7 +203,7 @@ class PeerGradingModule(XModule): score_dict = { 'score': int(count_graded >= count_required), 'total': self.max_grade, - } + } return score_dict @@ -253,7 +252,7 @@ class PeerGradingModule(XModule): .format(self.peer_gs.url, location, grader_id)) #This is a student_facing_error return {'success': False, - 'error': EXTERNAL_GRADER_NO_CONTACT_ERROR} + 'error': EXTERNAL_GRADER_NO_CONTACT_ERROR} def save_grade(self, get): """ @@ -271,7 +270,8 @@ class PeerGradingModule(XModule): error: if there was an error in the submission, this is the error message """ - required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]', 'submission_flagged']) + required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]', + 'submission_flagged']) success, message = self._check_required(get, required) if not success: return self._err_response(message) @@ -287,14 +287,14 @@ class PeerGradingModule(XModule): try: response = self.peer_gs.save_grade(location, grader_id, submission_id, - score, feedback, submission_key, rubric_scores, submission_flagged) + score, feedback, submission_key, rubric_scores, submission_flagged) return response except GradingServiceError: #This is a dev_facing_error log.exception("""Error saving grade to open ended grading service. server url: {0}, location: {1}, submission_id:{2}, submission_key: {3}, score: {4}""" .format(self.peer_gs.url, - location, submission_id, submission_key, score) + location, submission_id, submission_key, score) ) #This is a student_facing_error return { @@ -382,7 +382,7 @@ class PeerGradingModule(XModule): .format(self.peer_gs.url, location)) #This is a student_facing_error return {'success': False, - 'error': EXTERNAL_GRADER_NO_CONTACT_ERROR} + 'error': EXTERNAL_GRADER_NO_CONTACT_ERROR} # if we can't parse the rubric into HTML, except etree.XMLSyntaxError: #This is a dev_facing_error @@ -390,7 +390,7 @@ class PeerGradingModule(XModule): .format(rubric)) #This is a student_facing_error return {'success': False, - 'error': 'Error displaying submission. Please notify course staff.'} + 'error': 'Error displaying submission. Please notify course staff.'} def save_calibration_essay(self, get): @@ -426,11 +426,13 @@ class PeerGradingModule(XModule): try: response = self.peer_gs.save_calibration_essay(location, grader_id, calibration_essay_id, - submission_key, score, feedback, rubric_scores) + submission_key, score, feedback, rubric_scores) return response except GradingServiceError: #This is a dev_facing_error - log.exception("Error saving calibration grade, location: {0}, submission_id: {1}, submission_key: {2}, grader_id: {3}".format(location, submission_id, submission_key, grader_id)) + log.exception( + "Error saving calibration grade, location: {0}, submission_id: {1}, submission_key: {2}, grader_id: {3}".format( + location, submission_id, submission_key, grader_id)) #This is a student_facing_error return self._err_response('There was an error saving your score. Please notify course staff.') @@ -440,7 +442,7 @@ class PeerGradingModule(XModule): ''' html = self.system.render_template('peer_grading/peer_grading_closed.html', { 'use_for_single_location': self.use_for_single_location - }) + }) return html @@ -503,12 +505,11 @@ class PeerGradingModule(XModule): problem['closed'] = True else: problem['closed'] = False - else: - # if we can't find the due date, assume that it doesn't have one + else: + # if we can't find the due date, assume that it doesn't have one problem['due'] = None problem['closed'] = False - ajax_url = self.ajax_url html = self.system.render_template('peer_grading/peer_grading.html', { 'course_id': self.system.course_id, @@ -519,7 +520,7 @@ class PeerGradingModule(XModule): # Checked above 'staff_access': False, 'use_single_location': self.use_for_single_location, - }) + }) return html @@ -531,7 +532,8 @@ class PeerGradingModule(XModule): if not self.use_for_single_location: #This is an error case, because it must be set to use a single location to be called without get parameters #This is a dev_facing_error - log.error("Peer grading problem in peer_grading_module called with no get parameters, but use_for_single_location is False.") + log.error( + "Peer grading problem in peer_grading_module called with no get parameters, but use_for_single_location is False.") return {'html': "", 'success': False} problem_location = self.link_to_location @@ -547,7 +549,7 @@ class PeerGradingModule(XModule): # Checked above 'staff_access': False, 'use_single_location': self.use_for_single_location, - }) + }) return {'html': html, 'success': True} @@ -560,7 +562,7 @@ class PeerGradingModule(XModule): state = { 'student_data_for_location': self.student_data_for_location, - } + } return json.dumps(state) @@ -596,7 +598,9 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor): for child in expected_children: if len(xml_object.xpath(child)) == 0: #This is a staff_facing_error - raise ValueError("Peer grading definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format(child)) + raise ValueError( + "Peer grading definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format( + child)) def parse_task(k): """Assumes that xml_object has child k""" diff --git a/common/lib/xmodule/xmodule/templates/annotatable/default.yaml b/common/lib/xmodule/xmodule/templates/annotatable/default.yaml new file mode 100644 index 0000000000..31dd489fb4 --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/annotatable/default.yaml @@ -0,0 +1,20 @@ +--- +metadata: + display_name: 'Annotation' +data: | + + +

Enter your (optional) instructions for the exercise in HTML format.

+

Annotations are specified by an <annotation> tag which may may have the following attributes:

+
    +
  • title (optional). Title of the annotation. Defaults to Commentary if omitted.
  • +
  • body (required). Text of the annotation.
  • +
  • problem (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have problem="0".
  • +
  • highlight (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.
  • +
+
+

Add your HTML with annotation spans here.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut sodales laoreet est, egestas gravida felis egestas nec. Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.

+

Nulla facilisi. Pellentesque id vestibulum libero. Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.

+ +children: [] diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 9474717cb2..43c2bbe24d 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -28,21 +28,35 @@ open_ended_grading_interface = { 'grading_controller' : 'grading_controller' } -test_system = ModuleSystem( - ajax_url='courses/course_id/modx/a_location', - track_function=Mock(), - get_module=Mock(), - # "render" to just the context... - render_template=lambda template, context: str(context), - replace_urls=Mock(), - user=Mock(is_staff=False), - filestore=Mock(), - debug=True, - xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, - node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), - anonymous_student_id='student', - open_ended_grading_interface= open_ended_grading_interface -) + +def test_system(): + """ + Construct a test ModuleSystem instance. + + By default, the render_template() method simply returns + the context it is passed as a string. + You can override this behavior by monkey patching: + + system = test_system() + system.render_template = my_render_func + + where my_render_func is a function of the form + my_render_func(template, context) + """ + return ModuleSystem( + ajax_url='courses/course_id/modx/a_location', + track_function=Mock(), + get_module=Mock(), + render_template=lambda template, context: str(context), + replace_urls=lambda html: str(html), + user=Mock(is_staff=False), + filestore=Mock(), + debug=True, + xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, + node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), + anonymous_student_id='student', + open_ended_grading_interface= open_ended_grading_interface + ) class ModelsTest(unittest.TestCase): diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py new file mode 100644 index 0000000000..30f9c9ff92 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -0,0 +1,129 @@ +"""Module annotatable tests""" + +import unittest + +from lxml import etree +from mock import Mock + +from xmodule.annotatable_module import AnnotatableModule +from xmodule.modulestore import Location + +from . import test_system + +class AnnotatableModuleTestCase(unittest.TestCase): + location = Location(["i4x", "edX", "toy", "annotatable", "guided_discussion"]) + sample_xml = ''' + + Read the text. +

+ Sing, + O goddess, + the anger of Achilles son of Peleus, + that brought countless ills upon the Achaeans. Many a brave soul did it send + hurrying down to Hades, and many a hero did it yield a prey to dogs and +

vultures, for so were the counsels + of Jove fulfilled from the day on which the son of Atreus, king of men, and great + Achilles, first fell out with one another.
+

+ The Iliad of Homer by Samuel Butler +
+ ''' + definition = { 'data': sample_xml } + descriptor = Mock() + instance_state = None + shared_state = None + + def setUp(self): + self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state) + + def test_annotation_data_attr(self): + el = etree.fromstring('test') + + expected_attr = { + 'data-comment-body': {'value': 'foo', '_delete': 'body' }, + 'data-comment-title': {'value': 'bar', '_delete': 'title'}, + 'data-problem-id': {'value': '0', '_delete': 'problem'} + } + + actual_attr = self.annotatable._get_annotation_data_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_default(self): + xml = 'test' + el = etree.fromstring(xml) + + expected_attr = { 'class': { 'value': 'annotatable-span highlight' } } + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_with_valid_highlight(self): + xml = 'test' + + for color in self.annotatable.highlight_colors: + el = etree.fromstring(xml.format(highlight=color)) + value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color) + + expected_attr = { 'class': { + 'value': value, + '_delete': 'highlight' } + } + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_with_invalid_highlight(self): + xml = 'test' + + for invalid_color in ['rainbow', 'blink', 'invisible', '', None]: + el = etree.fromstring(xml.format(highlight=invalid_color)) + expected_attr = { 'class': { + 'value': 'annotatable-span highlight', + '_delete': 'highlight' } + } + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_render_annotation(self): + expected_html = 'z' + expected_el = etree.fromstring(expected_html) + + actual_el = etree.fromstring('z') + self.annotatable._render_annotation(0, actual_el) + + self.assertEqual(expected_el.tag, actual_el.tag) + self.assertEqual(expected_el.text, actual_el.text) + self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib)) + + def test_render_content(self): + content = self.annotatable._render_content() + el = etree.fromstring(content) + + self.assertEqual('div', el.tag, 'root tag is a div') + + expected_num_annotations = 5 + actual_num_annotations = el.xpath('count(//span[contains(@class,"annotatable-span")])') + self.assertEqual(expected_num_annotations, actual_num_annotations, 'check number of annotations') + + def test_get_html(self): + context = self.annotatable.get_html() + for key in ['display_name', 'element_id', 'content_html', 'instructions_html']: + self.assertIn(key, context) + + def test_extract_instructions(self): + xmltree = etree.fromstring(self.sample_xml) + + expected_xml = u"
Read the text.
" + actual_xml = self.annotatable._extract_instructions(xmltree) + self.assertIsNotNone(actual_xml) + self.assertEqual(expected_xml.strip(), actual_xml.strip()) + + xmltree = etree.fromstring('foo') + actual = self.annotatable._extract_instructions(xmltree) + self.assertIsNone(actual) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index e84267c1e7..cb77921957 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1,13 +1,18 @@ import datetime import json -from mock import Mock +from mock import Mock, MagicMock, patch from pprint import pprint import unittest +import random +import xmodule +import capa from xmodule.capa_module import CapaModule from xmodule.modulestore import Location from lxml import etree +from django.http import QueryDict + from . import test_system @@ -33,6 +38,18 @@ class CapaFactory(object): CapaFactory.num += 1 return CapaFactory.num + @staticmethod + def input_key(): + """ Return the input key to use when passing GET parameters """ + return ("input_" + CapaFactory.answer_key()) + + @staticmethod + def answer_key(): + """ Return the key stored in the capa problem answer dict """ + return ("-".join(['i4x', 'edX', 'capa_test', 'problem', + 'SampleProblem%d' % CapaFactory.num]) + + "_2_1") + @staticmethod def create(graceperiod=None, due=None, @@ -59,11 +76,10 @@ class CapaFactory(object): module. attempts: also added to instance state. Will be converted to an int. - correct: if True, the problem will be initialized to be answered correctly. """ definition = {'data': CapaFactory.sample_problem_xml, } location = Location(["i4x", "edX", "capa_test", "problem", - "SampleProblem{0}".format(CapaFactory.next_num())]) + "SampleProblem%d" % CapaFactory.next_num()]) metadata = {} if graceperiod is not None: metadata['graceperiod'] = graceperiod @@ -89,19 +105,14 @@ class CapaFactory(object): # since everything else is a string. instance_state_dict['attempts'] = int(attempts) - if correct: - # TODO: make this actually set an answer of 3.14, and mark it correct - #instance_state_dict['student_answers'] = {} - #instance_state_dict['correct_map'] = {} - pass - - if len(instance_state_dict) > 0: instance_state = json.dumps(instance_state_dict) else: instance_state = None - module = CapaModule(test_system, location, + system = test_system() + system.render_template = Mock(return_value="
Test Template HTML
") + module = CapaModule(system, location, definition, descriptor, instance_state, None, metadata=metadata) @@ -135,6 +146,8 @@ class CapaModuleTest(unittest.TestCase): "Factory should be creating unique names for each problem") + + def test_correct(self): """ Check that the factory creates correct and incorrect problems properly. @@ -178,6 +191,7 @@ class CapaModuleTest(unittest.TestCase): max_attempts="1", attempts="0", due=self.yesterday_str) + self.assertTrue(after_due_date.answer_available()) @@ -282,3 +296,602 @@ class CapaModuleTest(unittest.TestCase): due=self.yesterday_str, graceperiod=self.two_day_delta_str) self.assertTrue(still_in_grace.answer_available()) + + + def test_closed(self): + + # Attempts < Max attempts --> NOT closed + module = CapaFactory.create(max_attempts="1", attempts="0") + self.assertFalse(module.closed()) + + # Attempts < Max attempts --> NOT closed + module = CapaFactory.create(max_attempts="2", attempts="1") + self.assertFalse(module.closed()) + + # Attempts = Max attempts --> closed + module = CapaFactory.create(max_attempts="1", attempts="1") + self.assertTrue(module.closed()) + + # Attempts > Max attempts --> closed + module = CapaFactory.create(max_attempts="1", attempts="2") + self.assertTrue(module.closed()) + + # Max attempts = 0 --> closed + module = CapaFactory.create(max_attempts="0", attempts="2") + self.assertTrue(module.closed()) + + # Past due --> closed + module = CapaFactory.create(max_attempts="1", attempts="0", + due=self.yesterday_str) + self.assertTrue(module.closed()) + + + def test_parse_get_params(self): + + # We have to set up Django settings in order to use QueryDict + from django.conf import settings + settings.configure() + + # Valid GET param dict + valid_get_dict = self._querydict_from_dict({'input_1': 'test', + 'input_1_2': 'test', + 'input_1_2_3': 'test', + 'input_[]_3': 'test', + 'input_4': None, + 'input_5': [], + 'input_6': 5}) + + result = CapaModule.make_dict_of_responses(valid_get_dict) + + # Expect that we get a dict with "input" stripped from key names + # and that we get the same values back + for key in result.keys(): + original_key = "input_" + key + self.assertTrue(original_key in valid_get_dict, + "Output dict should have key %s" % original_key) + self.assertEqual(valid_get_dict[original_key], result[key]) + + + # Valid GET param dict with list keys + valid_get_dict = self._querydict_from_dict({'input_2[]': ['test1', 'test2']}) + result = CapaModule.make_dict_of_responses(valid_get_dict) + self.assertTrue('2' in result) + self.assertEqual(['test1','test2'], result['2']) + + # If we use [] at the end of a key name, we should always + # get a list, even if there's just one value + valid_get_dict = self._querydict_from_dict({'input_1[]': 'test'}) + result = CapaModule.make_dict_of_responses(valid_get_dict) + self.assertEqual(result['1'], ['test']) + + # If we have no underscores in the name, then the key is invalid + invalid_get_dict = self._querydict_from_dict({'input': 'test'}) + with self.assertRaises(ValueError): + result = CapaModule.make_dict_of_responses(invalid_get_dict) + + + # Two equivalent names (one list, one non-list) + # One of the values would overwrite the other, so detect this + # and raise an exception + invalid_get_dict = self._querydict_from_dict({'input_1[]': 'test 1', + 'input_1': 'test 2' }) + with self.assertRaises(ValueError): + result = CapaModule.make_dict_of_responses(invalid_get_dict) + + def _querydict_from_dict(self, param_dict): + """ Create a Django QueryDict from a Python dictionary """ + + # QueryDict objects are immutable by default, so we make + # a copy that we can update. + querydict = QueryDict('') + copyDict = querydict.copy() + + for (key, val) in param_dict.items(): + + # QueryDicts handle lists differently from ordinary values, + # so we have to specifically tell the QueryDict that + # this is a list + if type(val) is list: + copyDict.setlist(key, val) + else: + copyDict[key] = val + + return copyDict + + + def test_check_problem_correct(self): + + module = CapaFactory.create(attempts=1) + + # Simulate that all answers are marked correct, no matter + # what the input is, by patching CorrectMap.is_correct() + # Also simulate rendering the HTML + with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct,\ + patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html: + mock_is_correct.return_value = True + mock_html.return_value = "Test HTML" + + # Check the problem + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.check_problem(get_request_dict) + + # Expect that the problem is marked correct + self.assertEqual(result['success'], 'correct') + + # Expect that we get the (mocked) HTML + self.assertEqual(result['contents'], 'Test HTML') + + # Expect that the number of attempts is incremented by 1 + self.assertEqual(module.attempts, 2) + + + def test_check_problem_incorrect(self): + + module = CapaFactory.create(attempts=0) + + # Simulate marking the input incorrect + with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct: + mock_is_correct.return_value = False + + # Check the problem + get_request_dict = { CapaFactory.input_key(): '0' } + result = module.check_problem(get_request_dict) + + # Expect that the problem is marked correct + self.assertEqual(result['success'], 'incorrect') + + # Expect that the number of attempts is incremented by 1 + self.assertEqual(module.attempts, 1) + + + def test_check_problem_closed(self): + module = CapaFactory.create(attempts=3) + + # Problem closed -- cannot submit + # Simulate that CapaModule.closed() always returns True + with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: + mock_closed.return_value = True + with self.assertRaises(xmodule.exceptions.NotFoundError): + get_request_dict = { CapaFactory.input_key(): '3.14' } + module.check_problem(get_request_dict) + + # Expect that number of attempts NOT incremented + self.assertEqual(module.attempts, 3) + + + def test_check_problem_resubmitted_with_randomize(self): + # Randomize turned on + module = CapaFactory.create(rerandomize='always', attempts=0) + + # Simulate that the problem is completed + module.lcp.done = True + + # Expect that we cannot submit + with self.assertRaises(xmodule.exceptions.NotFoundError): + get_request_dict = { CapaFactory.input_key(): '3.14' } + module.check_problem(get_request_dict) + + # Expect that number of attempts NOT incremented + self.assertEqual(module.attempts, 0) + + + def test_check_problem_resubmitted_no_randomize(self): + # Randomize turned off + module = CapaFactory.create(rerandomize='never', attempts=0) + + # Simulate that the problem is completed + module.lcp.done = True + + # Expect that we can submit successfully + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.check_problem(get_request_dict) + + self.assertEqual(result['success'], 'correct') + + # Expect that number of attempts IS incremented + self.assertEqual(module.attempts, 1) + + + def test_check_problem_queued(self): + module = CapaFactory.create(attempts=1) + + # Simulate that the problem is queued + with patch('capa.capa_problem.LoncapaProblem.is_queued') \ + as mock_is_queued,\ + patch('capa.capa_problem.LoncapaProblem.get_recentmost_queuetime') \ + as mock_get_queuetime: + + mock_is_queued.return_value = True + mock_get_queuetime.return_value = datetime.datetime.now() + + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.check_problem(get_request_dict) + + # Expect an AJAX alert message in 'success' + self.assertTrue('You must wait' in result['success']) + + # Expect that the number of attempts is NOT incremented + self.assertEqual(module.attempts, 1) + + + def test_check_problem_student_input_error(self): + module = CapaFactory.create(attempts=1) + + # Simulate a student input exception + with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: + mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') + + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.check_problem(get_request_dict) + + # Expect an AJAX alert message in 'success' + self.assertTrue('test error' in result['success']) + + # Expect that the number of attempts is NOT incremented + self.assertEqual(module.attempts, 1) + + + def test_reset_problem(self): + module = CapaFactory.create() + + # Mock the module's capa problem + # to simulate that the problem is done + mock_problem = MagicMock(capa.capa_problem.LoncapaProblem) + mock_problem.done = True + module.lcp = mock_problem + + # Stub out HTML rendering + with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html: + mock_html.return_value = "
Test HTML
" + + # Reset the problem + get_request_dict = {} + result = module.reset_problem(get_request_dict) + + # Expect that the request was successful + self.assertTrue('success' in result and result['success']) + + # Expect that the problem HTML is retrieved + self.assertTrue('html' in result) + self.assertEqual(result['html'], "
Test HTML
") + + # Expect that the problem was reset + mock_problem.do_reset.assert_called_once_with() + + + def test_reset_problem_closed(self): + module = CapaFactory.create() + + # Simulate that the problem is closed + with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: + mock_closed.return_value = True + + # Try to reset the problem + get_request_dict = {} + result = module.reset_problem(get_request_dict) + + # Expect that the problem was NOT reset + self.assertTrue('success' in result and not result['success']) + + + def test_reset_problem_not_done(self): + module = CapaFactory.create() + + # Simulate that the problem is NOT done + module.lcp.done = False + + # Try to reset the problem + get_request_dict = {} + result = module.reset_problem(get_request_dict) + + # Expect that the problem was NOT reset + self.assertTrue('success' in result and not result['success']) + + + def test_save_problem(self): + module = CapaFactory.create() + + # Simulate that the problem is not done (not attempted or reset) + module.lcp.done = False + + # Save the problem + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that answers are saved to the problem + expected_answers = { CapaFactory.answer_key(): '3.14' } + self.assertEqual(module.lcp.student_answers, expected_answers) + + # Expect that the result is success + self.assertTrue('success' in result and result['success']) + + + def test_save_problem_closed(self): + module = CapaFactory.create() + + # Simulate that the problem is NOT done (not attempted or reset) + module.lcp.done = False + + # Simulate that the problem is closed + with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: + mock_closed.return_value = True + + # Try to save the problem + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that the result is failure + self.assertTrue('success' in result and not result['success']) + + + def test_save_problem_submitted_with_randomize(self): + module = CapaFactory.create(rerandomize='always') + + # Simulate that the problem is completed + module.lcp.done = True + + # Try to save + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that we cannot save + self.assertTrue('success' in result and not result['success']) + + + def test_save_problem_submitted_no_randomize(self): + module = CapaFactory.create(rerandomize='never') + + # Simulate that the problem is completed + module.lcp.done = True + + # Try to save + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that we succeed + self.assertTrue('success' in result and result['success']) + + def test_check_button_name(self): + + # If last attempt, button name changes to "Final Check" + # Just in case, we also check what happens if we have + # more attempts than allowed. + attempts = random.randint(1, 10) + module = CapaFactory.create(attempts=attempts-1, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Final Check") + + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Final Check") + + module = CapaFactory.create(attempts=attempts + 1, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Final Check") + + # Otherwise, button name is "Check" + module = CapaFactory.create(attempts=attempts-2, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Check") + + module = CapaFactory.create(attempts=attempts-3, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Check") + + # If no limit on attempts, then always show "Check" + module = CapaFactory.create(attempts=attempts-3) + self.assertEqual(module.check_button_name(), "Check") + + module = CapaFactory.create(attempts=0) + self.assertEqual(module.check_button_name(), "Check") + + def test_should_show_check_button(self): + + attempts = random.randint(1,10) + + # If we're after the deadline, do NOT show check button + module = CapaFactory.create(due=self.yesterday_str) + self.assertFalse(module.should_show_check_button()) + + # If user is out of attempts, do NOT show the check button + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + self.assertFalse(module.should_show_check_button()) + + # If survey question (max_attempts = 0), do NOT show the check button + module = CapaFactory.create(max_attempts=0) + self.assertFalse(module.should_show_check_button()) + + # If user submitted a problem but hasn't reset, + # do NOT show the check button + # Note: we can only reset when rerandomize="always" + module = CapaFactory.create(rerandomize="always") + module.lcp.done = True + self.assertFalse(module.should_show_check_button()) + + # Otherwise, DO show the check button + module = CapaFactory.create() + self.assertTrue(module.should_show_check_button()) + + # If the user has submitted the problem + # and we do NOT have a reset button, then we can show the check button + # Setting rerandomize to "never" ensures that the reset button + # is not shown + module = CapaFactory.create(rerandomize="never") + module.lcp.done = True + self.assertTrue(module.should_show_check_button()) + + + def test_should_show_reset_button(self): + + attempts = random.randint(1,10) + + # If we're after the deadline, do NOT show the reset button + module = CapaFactory.create(due=self.yesterday_str) + module.lcp.done = True + self.assertFalse(module.should_show_reset_button()) + + # If the user is out of attempts, do NOT show the reset button + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + module.lcp.done = True + self.assertFalse(module.should_show_reset_button()) + + # If we're NOT randomizing, then do NOT show the reset button + module = CapaFactory.create(rerandomize="never") + module.lcp.done = True + self.assertFalse(module.should_show_reset_button()) + + # If the user hasn't submitted an answer yet, + # then do NOT show the reset button + module = CapaFactory.create() + module.lcp.done = False + self.assertFalse(module.should_show_reset_button()) + + # Otherwise, DO show the reset button + module = CapaFactory.create() + module.lcp.done = True + self.assertTrue(module.should_show_reset_button()) + + # If survey question for capa (max_attempts = 0), + # DO show the reset button + module = CapaFactory.create(max_attempts=0) + module.lcp.done = True + self.assertTrue(module.should_show_reset_button()) + + + def test_should_show_save_button(self): + + attempts = random.randint(1,10) + + # If we're after the deadline, do NOT show the save button + module = CapaFactory.create(due=self.yesterday_str) + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # If the user is out of attempts, do NOT show the save button + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # If user submitted a problem but hasn't reset, do NOT show the save button + module = CapaFactory.create(rerandomize="always") + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # Otherwise, DO show the save button + module = CapaFactory.create() + module.lcp.done = False + self.assertTrue(module.should_show_save_button()) + + # If we're not randomizing, then we can re-save + module = CapaFactory.create(rerandomize="never") + module.lcp.done = True + self.assertTrue(module.should_show_save_button()) + + # If survey question for capa (max_attempts = 0), + # DO show the save button + module = CapaFactory.create(max_attempts=0) + module.lcp.done = False + self.assertTrue(module.should_show_save_button()) + + def test_should_show_save_button_force_save_button(self): + # If we're after the deadline, do NOT show the save button + # even though we're forcing a save + module = CapaFactory.create(due=self.yesterday_str, + force_save_button="true") + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # If the user is out of attempts, do NOT show the save button + attempts = random.randint(1,10) + module = CapaFactory.create(attempts=attempts, + max_attempts=attempts, + force_save_button="true") + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # Otherwise, if we force the save button, + # then show it even if we would ordinarily + # require a reset first + module = CapaFactory.create(force_save_button="true", + rerandomize="always") + module.lcp.done = True + self.assertTrue(module.should_show_save_button()) + + def test_no_max_attempts(self): + module = CapaFactory.create(max_attempts='') + html = module.get_problem_html() + # assert that we got here without exploding + + + def test_get_problem_html(self): + module = CapaFactory.create() + + # We've tested the show/hide button logic in other tests, + # so here we hard-wire the values + show_check_button = bool(random.randint(0,1) % 2) + show_reset_button = bool(random.randint(0,1) % 2) + show_save_button = bool(random.randint(0,1) % 2) + + module.should_show_check_button = Mock(return_value=show_check_button) + module.should_show_reset_button = Mock(return_value=show_reset_button) + module.should_show_save_button = Mock(return_value=show_save_button) + + # Mock the system rendering function + module.system.render_template = Mock(return_value="
Test Template HTML
") + + # Patch the capa problem's HTML rendering + with patch('capa.capa_problem.LoncapaProblem.get_html') as mock_html: + mock_html.return_value = "
Test Problem HTML
" + + # Render the problem HTML + html = module.get_problem_html(encapsulate=False) + + # Also render the problem encapsulated in a
+ html_encapsulated = module.get_problem_html(encapsulate=True) + + # Expect that we get the rendered template back + self.assertEqual(html, "
Test Template HTML
") + + # Check the rendering context + render_args,_ = module.system.render_template.call_args + self.assertEqual(len(render_args), 2) + + template_name = render_args[0] + self.assertEqual(template_name, "problem.html") + + context = render_args[1] + self.assertEqual(context['problem']['html'], "
Test Problem HTML
") + self.assertEqual(bool(context['check_button']), show_check_button) + self.assertEqual(bool(context['reset_button']), show_reset_button) + self.assertEqual(bool(context['save_button']), show_save_button) + + # Assert that the encapsulated html contains the original html + self.assertTrue(html in html_encapsulated) + + + def test_get_problem_html_error(self): + """ + In production, when an error occurs with the problem HTML + rendering, a "dummy" problem is created with an error + message to display to the user. + """ + module = CapaFactory.create() + + # Save the original problem so we can compare it later + original_problem = module.lcp + + # Simulate throwing an exception when the capa problem + # is asked to render itself as HTML + module.lcp.get_html = Mock(side_effect=Exception("Test")) + + # Stub out the test_system rendering function + module.system.render_template = Mock(return_value="
Test Template HTML
") + + # Turn off DEBUG + module.system.DEBUG = False + + # Try to render the module with DEBUG turned off + html = module.get_problem_html() + + # Check the rendering context + render_args,_ = module.system.render_template.call_args + context = render_args[1] + self.assertTrue("error" in context['problem']['html']) + + # Expect that the module has created a new dummy problem with the error + self.assertNotEqual(original_problem, module.lcp) diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py index 5f6496f823..8a14e03ded 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -14,6 +14,7 @@ from datetime import datetime from . import test_system import test_util_open_ended + """ Tests for the various pieces of the CombinedOpenEndedGrading system @@ -39,40 +40,37 @@ class OpenEndedChildTest(unittest.TestCase): max_score = 1 static_data = { - 'max_attempts': 20, - 'prompt': prompt, - 'rubric': rubric, - 'max_score': max_score, - 'display_name': 'Name', - 'accept_file_upload': False, - 'close_date': None, - 's3_interface' : "", - 'open_ended_grading_interface' : {}, - 'skip_basic_checks' : False, - } + 'max_attempts': 20, + 'prompt': prompt, + 'rubric': rubric, + 'max_score': max_score, + 'display_name': 'Name', + 'accept_file_upload': False, + 'close_date': None, + 's3_interface': "", + 'open_ended_grading_interface': {}, + 'skip_basic_checks': False, + } definition = Mock() descriptor = Mock() def setUp(self): - self.openendedchild = OpenEndedChild(test_system, self.location, - self.definition, self.descriptor, self.static_data, self.metadata) - + self.test_system = test_system() + self.openendedchild = OpenEndedChild(self.test_system, self.location, + self.definition, self.descriptor, self.static_data, self.metadata) def test_latest_answer_empty(self): answer = self.openendedchild.latest_answer() self.assertEqual(answer, "") - def test_latest_score_empty(self): answer = self.openendedchild.latest_score() self.assertEqual(answer, None) - def test_latest_post_assessment_empty(self): - answer = self.openendedchild.latest_post_assessment(test_system) + answer = self.openendedchild.latest_post_assessment(self.test_system) self.assertEqual(answer, "") - def test_new_history_entry(self): new_answer = "New Answer" self.openendedchild.new_history_entry(new_answer) @@ -98,7 +96,6 @@ class OpenEndedChildTest(unittest.TestCase): score = self.openendedchild.latest_score() self.assertEqual(score, 4) - def test_record_latest_post_assessment(self): new_answer = "New Answer" self.openendedchild.new_history_entry(new_answer) @@ -106,7 +103,7 @@ class OpenEndedChildTest(unittest.TestCase): post_assessment = "Post assessment" self.openendedchild.record_latest_post_assessment(post_assessment) self.assertEqual(post_assessment, - self.openendedchild.latest_post_assessment(test_system)) + self.openendedchild.latest_post_assessment(self.test_system)) def test_get_score(self): new_answer = "New Answer" @@ -123,24 +120,22 @@ class OpenEndedChildTest(unittest.TestCase): self.assertEqual(score['score'], new_score) self.assertEqual(score['total'], self.static_data['max_score']) - def test_reset(self): - self.openendedchild.reset(test_system) + self.openendedchild.reset(self.test_system) state = json.loads(self.openendedchild.get_instance_state()) self.assertEqual(state['state'], OpenEndedChild.INITIAL) - def test_is_last_response_correct(self): new_answer = "New Answer" self.openendedchild.new_history_entry(new_answer) self.openendedchild.record_latest_score(self.static_data['max_score']) self.assertEqual(self.openendedchild.is_last_response_correct(), - 'correct') + 'correct') self.openendedchild.new_history_entry(new_answer) self.openendedchild.record_latest_score(0) self.assertEqual(self.openendedchild.is_last_response_correct(), - 'incorrect') + 'incorrect') class OpenEndedModuleTest(unittest.TestCase): @@ -158,18 +153,18 @@ class OpenEndedModuleTest(unittest.TestCase): max_score = 4 static_data = { - 'max_attempts': 20, - 'prompt': prompt, - 'rubric': rubric, - 'max_score': max_score, - 'display_name': 'Name', - 'accept_file_upload': False, - 'rewrite_content_links' : "", - 'close_date': None, - 's3_interface' : test_util_open_ended.S3_INTERFACE, - 'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, - 'skip_basic_checks' : False, - } + 'max_attempts': 20, + 'prompt': prompt, + 'rubric': rubric, + 'max_score': max_score, + 'display_name': 'Name', + 'accept_file_upload': False, + 'rewrite_content_links': "", + 'close_date': None, + 's3_interface': test_util_open_ended.S3_INTERFACE, + 'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, + 'skip_basic_checks': False, + } oeparam = etree.XML(''' @@ -182,30 +177,33 @@ class OpenEndedModuleTest(unittest.TestCase): descriptor = Mock() def setUp(self): - test_system.location = self.location + self.test_system = test_system() + + self.test_system.location = self.location self.mock_xqueue = MagicMock() self.mock_xqueue.send_to_queue.return_value = (None, "Message") - test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1} - self.openendedmodule = OpenEndedModule(test_system, self.location, - self.definition, self.descriptor, self.static_data, self.metadata) + self.test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', + 'waittime': 1} + self.openendedmodule = OpenEndedModule(self.test_system, self.location, + self.definition, self.descriptor, self.static_data, self.metadata) def test_message_post(self): get = {'feedback': 'feedback text', - 'submission_id': '1', - 'grader_id': '1', - 'score': 3} + 'submission_id': '1', + 'grader_id': '1', + 'score': 3} qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - student_info = {'anonymous_student_id': test_system.anonymous_student_id, - 'submission_time': qtime} + student_info = {'anonymous_student_id': self.test_system.anonymous_student_id, + 'submission_time': qtime} contents = { - 'feedback': get['feedback'], - 'submission_id': int(get['submission_id']), - 'grader_id': int(get['grader_id']), - 'score': get['score'], - 'student_info': json.dumps(student_info) - } + 'feedback': get['feedback'], + 'submission_id': int(get['submission_id']), + 'grader_id': int(get['grader_id']), + 'score': get['score'], + 'student_info': json.dumps(student_info) + } - result = self.openendedmodule.message_post(get, test_system) + result = self.openendedmodule.message_post(get, self.test_system) self.assertTrue(result['success']) # make sure it's actually sending something we want to the queue self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY) @@ -216,56 +214,56 @@ class OpenEndedModuleTest(unittest.TestCase): def test_send_to_grader(self): submission = "This is a student submission" qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - student_info = {'anonymous_student_id': test_system.anonymous_student_id, - 'submission_time': qtime} + student_info = {'anonymous_student_id': self.test_system.anonymous_student_id, + 'submission_time': qtime} contents = self.openendedmodule.payload.copy() contents.update({ 'student_info': json.dumps(student_info), 'student_response': submission, 'max_score': self.max_score - }) - result = self.openendedmodule.send_to_grader(submission, test_system) + }) + result = self.openendedmodule.send_to_grader(submission, self.test_system) self.assertTrue(result) self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY) def update_score_single(self): self.openendedmodule.new_history_entry("New Entry") score_msg = { - 'correct': True, - 'score': 4, - 'msg': 'Grader Message', - 'feedback': "Grader Feedback" - } + 'correct': True, + 'score': 4, + 'msg': 'Grader Message', + 'feedback': "Grader Feedback" + } get = {'queuekey': "abcd", - 'xqueue_body': score_msg} - self.openendedmodule.update_score(get, test_system) + 'xqueue_body': score_msg} + self.openendedmodule.update_score(get, self.test_system) def update_score_single(self): self.openendedmodule.new_history_entry("New Entry") feedback = { - "success": True, - "feedback": "Grader Feedback" - } + "success": True, + "feedback": "Grader Feedback" + } score_msg = { - 'correct': True, - 'score': 4, - 'msg': 'Grader Message', - 'feedback': json.dumps(feedback), - 'grader_type': 'IN', - 'grader_id': '1', - 'submission_id': '1', - 'success': True, - 'rubric_scores': [0], - 'rubric_scores_complete': True, - 'rubric_xml': etree.tostring(self.rubric) - } + 'correct': True, + 'score': 4, + 'msg': 'Grader Message', + 'feedback': json.dumps(feedback), + 'grader_type': 'IN', + 'grader_id': '1', + 'submission_id': '1', + 'success': True, + 'rubric_scores': [0], + 'rubric_scores_complete': True, + 'rubric_xml': etree.tostring(self.rubric) + } get = {'queuekey': "abcd", - 'xqueue_body': json.dumps(score_msg)} - self.openendedmodule.update_score(get, test_system) + 'xqueue_body': json.dumps(score_msg)} + self.openendedmodule.update_score(get, self.test_system) def test_latest_post_assessment(self): self.update_score_single() - assessment = self.openendedmodule.latest_post_assessment(test_system) + assessment = self.openendedmodule.latest_post_assessment(self.test_system) self.assertFalse(assessment == '') # check for errors self.assertFalse('errors' in assessment) @@ -293,18 +291,18 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): metadata = {'attempts': '10', 'max_score': max_score} static_data = { - 'max_attempts': 20, - 'prompt': prompt, - 'rubric': rubric, - 'max_score': max_score, - 'display_name': 'Name', - 'accept_file_upload' : False, - 'rewrite_content_links' : "", - 'close_date' : "", - 's3_interface' : test_util_open_ended.S3_INTERFACE, - 'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, - 'skip_basic_checks' : False, - } + 'max_attempts': 20, + 'prompt': prompt, + 'rubric': rubric, + 'max_score': max_score, + 'display_name': 'Name', + 'accept_file_upload': False, + 'rewrite_content_links': "", + 'close_date': "", + 's3_interface': test_util_open_ended.S3_INTERFACE, + 'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, + 'skip_basic_checks': False, + } oeparam = etree.XML(''' @@ -326,17 +324,23 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ''' task_xml2 = ''' - - Enter essay here. - This is the answer. - {"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"} - - ''' + + Enter essay here. + This is the answer. + {"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"} + + ''' definition = {'prompt': etree.XML(prompt), 'rubric': etree.XML(rubric), 'task_xml': [task_xml1, task_xml2]} descriptor = Mock() def setUp(self): - self.combinedoe = CombinedOpenEndedV1Module(test_system, self.location, self.definition, self.descriptor, static_data = self.static_data, metadata=self.metadata) + self.test_system = test_system() + self.combinedoe = CombinedOpenEndedV1Module(self.test_system, + self.location, + self.definition, + self.descriptor, + static_data=self.static_data, + metadata=self.metadata) def test_get_tag_name(self): name = self.combinedoe.get_tag_name("Tag") diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index 361a6ea785..16bd222b9e 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -56,6 +56,9 @@ class ConditionalModuleTest(unittest.TestCase): '''Get a dummy system''' return DummySystem(load_error_modules) + def setUp(self): + self.test_system = test_system() + def get_course(self, name): """Get a test course by directory name. If there's more than one, error.""" print "Importing {0}".format(name) @@ -85,14 +88,14 @@ class ConditionalModuleTest(unittest.TestCase): location = descriptor.location instance_state = instance_states.get(location.category, None) print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state) - return descriptor.xmodule_constructor(test_system)(instance_state, shared_state) + return descriptor.xmodule_constructor(self.test_system)(instance_state, shared_state) location = Location(["i4x", "edX", "cond_test", "conditional", "condone"]) def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): return text - test_system.replace_urls = replace_urls - test_system.get_module = inner_get_module + self.test_system.replace_urls = replace_urls + self.test_system.get_module = inner_get_module module = inner_get_module(location) print "module: ", module diff --git a/common/lib/xmodule/xmodule/tests/test_self_assessment.py b/common/lib/xmodule/xmodule/tests/test_self_assessment.py index b9c3076b7c..a7f2a9fdfe 100644 --- a/common/lib/xmodule/xmodule/tests/test_self_assessment.py +++ b/common/lib/xmodule/xmodule/tests/test_self_assessment.py @@ -10,8 +10,8 @@ from . import test_system import test_util_open_ended -class SelfAssessmentTest(unittest.TestCase): +class SelfAssessmentTest(unittest.TestCase): rubric = ''' Response Quality @@ -24,7 +24,7 @@ class SelfAssessmentTest(unittest.TestCase): 'prompt': prompt, 'submitmessage': 'Shall we submit now?', 'hintprompt': 'Consider this...', - } + } location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"]) @@ -41,37 +41,38 @@ class SelfAssessmentTest(unittest.TestCase): 'attempts': 2}) static_data = { - 'max_attempts': 10, - 'rubric': etree.XML(self.rubric), - 'prompt': self.prompt, - 'max_score': 1, - 'display_name': "Name", - 'accept_file_upload': False, - 'close_date': None, - 's3_interface' : test_util_open_ended.S3_INTERFACE, - 'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, - 'skip_basic_checks' : False, - } + 'max_attempts': 10, + 'rubric': etree.XML(self.rubric), + 'prompt': self.prompt, + 'max_score': 1, + 'display_name': "Name", + 'accept_file_upload': False, + 'close_date': None, + 's3_interface': test_util_open_ended.S3_INTERFACE, + 'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, + 'skip_basic_checks': False, + } - self.module = SelfAssessmentModule(test_system, self.location, - self.definition, self.descriptor, - static_data, - state, metadata=self.metadata) + self.module = SelfAssessmentModule(test_system(), self.location, + self.definition, self.descriptor, + static_data, + state, metadata=self.metadata) def test_get_html(self): - html = self.module.get_html(test_system) + html = self.module.get_html(self.module.system) self.assertTrue("This is sample prompt text" in html) def test_self_assessment_flow(self): responses = {'assessment': '0', 'score_list[]': ['0', '0']} + def get_fake_item(name): return responses[name] - def get_data_for_location(self,location,student): + def get_data_for_location(self, location, student): return { - 'count_graded' : 0, - 'count_required' : 0, - 'student_sub_count': 0, + 'count_graded': 0, + 'count_required': 0, + 'student_sub_count': 0, } mock_query_dict = MagicMock() @@ -82,19 +83,20 @@ class SelfAssessmentTest(unittest.TestCase): self.assertEqual(self.module.get_score()['score'], 0) - self.module.save_answer({'student_answer': "I am an answer"}, test_system) + self.module.save_answer({'student_answer': "I am an answer"}, + self.module.system) self.assertEqual(self.module.state, self.module.ASSESSING) - self.module.save_assessment(mock_query_dict, test_system) + self.module.save_assessment(mock_query_dict, self.module.system) self.assertEqual(self.module.state, self.module.DONE) - d = self.module.reset({}) self.assertTrue(d['success']) self.assertEqual(self.module.state, self.module.INITIAL) # if we now assess as right, skip the REQUEST_HINT state - self.module.save_answer({'student_answer': 'answer 4'}, test_system) + self.module.save_answer({'student_answer': 'answer 4'}, + self.module.system) responses['assessment'] = '1' - self.module.save_assessment(mock_query_dict, test_system) + self.module.save_assessment(mock_query_dict, self.module.system) self.assertEqual(self.module.state, self.module.DONE) diff --git a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py index 8d1fcd30ce..db580f1e0e 100644 --- a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py @@ -1,14 +1,14 @@ OPEN_ENDED_GRADING_INTERFACE = { - 'url' : 'http://127.0.0.1:3033/', - 'username' : 'incorrect', - 'password' : 'incorrect', - 'staff_grading' : 'staff_grading', - 'peer_grading' : 'peer_grading', - 'grading_controller' : 'grading_controller' + 'url': 'http://127.0.0.1:3033/', + 'username': 'incorrect', + 'password': 'incorrect', + 'staff_grading': 'staff_grading', + 'peer_grading': 'peer_grading', + 'grading_controller': 'grading_controller' } S3_INTERFACE = { - 'aws_access_key' : "", - 'aws_secret_key' : "", - "aws_bucket_name" : "", + 'aws_access_key': "", + 'aws_secret_key': "", + "aws_bucket_name": "", } \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 773531c528..7087a03759 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -379,7 +379,11 @@ class XmlDescriptor(XModuleDescriptor): if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy: val = val_for_xml(attr) #logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr)) - xml_object.set(attr, val) + try: + xml_object.set(attr, val) + except Exception, e: + logging.exception('Failed to serialize metadata attribute {0} with value {1}. This could mean data loss!!! Exception: {2}'.format(attr, val, e)) + pass if self.export_to_file(): # Write the definition to a file diff --git a/common/static/coffee/src/discussion/content.coffee b/common/static/coffee/src/discussion/content.coffee index 4e612dfc40..00c34df686 100644 --- a/common/static/coffee/src/discussion/content.coffee +++ b/common/static/coffee/src/discussion/content.coffee @@ -79,6 +79,17 @@ if Backbone? @getContent(id).updateInfo(info) $.extend @contentInfos, infos + pinThread: -> + pinned = @get("pinned") + @set("pinned",pinned) + @trigger "change", @ + + unPinThread: -> + pinned = @get("pinned") + @set("pinned",pinned) + @trigger "change", @ + + class @Thread extends @Content urlMappers: 'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @discussion.id, @id) @@ -91,6 +102,8 @@ if Backbone? 'delete' : -> DiscussionUtil.urlFor('delete_thread', @id) 'follow' : -> DiscussionUtil.urlFor('follow_thread', @id) 'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id) + 'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id) + 'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id) initialize: -> @set('thread', @) diff --git a/common/static/coffee/src/discussion/discussion.coffee b/common/static/coffee/src/discussion/discussion.coffee index 9cee068b74..83e25e1da7 100644 --- a/common/static/coffee/src/discussion/discussion.coffee +++ b/common/static/coffee/src/discussion/discussion.coffee @@ -58,10 +58,31 @@ if Backbone? @current_page = response.page sortByDate: (thread) -> - thread.get("created_at") + # + #The comment client asks each thread for a value by which to sort the collection + #and calls this sort routine regardless of the order returned from the LMS/comments service + #so, this takes advantage of this per-thread value and returns tomorrow's date + #for pinned threads, ensuring that they appear first, (which is the intent of pinned threads) + # + if thread.get('pinned') + #use tomorrow's date + today = new Date(); + new Date(today.getTime() + (24 * 60 * 60 * 1000)); + else + thread.get("created_at") + sortByDateRecentFirst: (thread) -> - -(new Date(thread.get("created_at")).getTime()) + # + #Same as above + #but negative to flip the order (newest first) + # + if thread.get('pinned') + #use tomorrow's date + today = new Date(); + -(new Date(today.getTime() + (24 * 60 * 60 * 1000))); + else + -(new Date(thread.get("created_at")).getTime()) #return String.fromCharCode.apply(String, # _.map(thread.get("created_at").split(""), # ((c) -> return 0xffff - c.charChodeAt())) diff --git a/common/static/coffee/src/discussion/discussion_module_view.coffee b/common/static/coffee/src/discussion/discussion_module_view.coffee index 2e58b2c0b8..3dde9bf950 100644 --- a/common/static/coffee/src/discussion/discussion_module_view.coffee +++ b/common/static/coffee/src/discussion/discussion_module_view.coffee @@ -88,7 +88,7 @@ if Backbone? if @$('section.discussion').length @$('section.discussion').replaceWith($discussion) else - $(".discussion-module").append($discussion) + @$el.append($discussion) @newPostForm = $('.new-post-article') @threadviews = @discussion.map (thread) -> new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 6b2714dc54..41f52f1711 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -50,6 +50,8 @@ class @DiscussionUtil delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete" upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote" downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote" + pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin" + un_pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unpin" undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote" follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow" unfollow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unfollow" diff --git a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee index 6320c3d1e3..a5a1deac10 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee @@ -3,6 +3,7 @@ if Backbone? events: "click .discussion-vote": "toggleVote" + "click .admin-pin": "togglePin" "click .action-follow": "toggleFollowing" "click .action-edit": "edit" "click .action-delete": "delete" @@ -24,6 +25,7 @@ if Backbone? @delegateEvents() @renderDogear() @renderVoted() + @renderPinned() @renderAttrs() @$("span.timeago").timeago() @convertMath() @@ -41,8 +43,20 @@ if Backbone? else @$("[data-role=discussion-vote]").removeClass("is-cast") + renderPinned: => + if @model.get("pinned") + @$("[data-role=thread-pin]").addClass("pinned") + @$("[data-role=thread-pin]").removeClass("notpinned") + @$(".discussion-pin .pin-label").html("Pinned") + else + @$("[data-role=thread-pin]").removeClass("pinned") + @$("[data-role=thread-pin]").addClass("notpinned") + @$(".discussion-pin .pin-label").html("Pin Thread") + + updateModelDetails: => @renderVoted() + @renderPinned() @$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"]) convertMath: -> @@ -99,6 +113,34 @@ if Backbone? delete: (event) -> @trigger "thread:delete", event + togglePin: (event) -> + event.preventDefault() + if @model.get('pinned') + @unPin() + else + @pin() + + pin: -> + url = @model.urlFor("pinThread") + DiscussionUtil.safeAjax + $elem: @$(".discussion-pin") + url: url + type: "POST" + success: (response, textStatus) => + if textStatus == 'success' + @model.set('pinned', true) + + unPin: -> + url = @model.urlFor("unPinThread") + DiscussionUtil.safeAjax + $elem: @$(".discussion-pin") + url: url + type: "POST" + success: (response, textStatus) => + if textStatus == 'success' + @model.set('pinned', false) + + toggleClosed: (event) -> $elem = $(event.target) url = @model.urlFor('close') @@ -137,3 +179,5 @@ if Backbone? if @model.get('username')? params = $.extend(params, user:{username: @model.username, user_url: @model.user_url}) Mustache.render(@template, params) + + \ No newline at end of file diff --git a/common/static/images/partially-correct-icon.png b/common/static/images/partially-correct-icon.png new file mode 100644 index 0000000000..9ac0fd32f7 Binary files /dev/null and b/common/static/images/partially-correct-icon.png differ diff --git a/common/static/js/capa/annotationinput.js b/common/static/js/capa/annotationinput.js new file mode 100644 index 0000000000..4353fd262a --- /dev/null +++ b/common/static/js/capa/annotationinput.js @@ -0,0 +1,97 @@ +(function () { + var debug = false; + + var module = { + debug: debug, + inputSelector: '.annotation-input', + tagSelector: '.tag', + tagsSelector: '.tags', + commentSelector: 'textarea.comment', + valueSelector: 'input.value', // stash tag selections and comment here as a JSON string... + + singleSelect: true, + + init: function() { + var that = this; + + if(this.debug) { console.log('annotation input loaded: '); } + + $(this.inputSelector).each(function(index, el) { + if(!$(el).data('listening')) { + $(el).delegate(that.tagSelector, 'click', $.proxy(that.onClickTag, that)); + $(el).delegate(that.commentSelector, 'change', $.proxy(that.onChangeComment, that)); + $(el).data('listening', 'yes'); + } + }); + }, + onChangeComment: function(e) { + var value_el = this.findValueEl(e.target); + var current_value = this.loadValue(value_el); + var target_value = $(e.target).val(); + + current_value.comment = target_value; + this.storeValue(value_el, current_value); + }, + onClickTag: function(e) { + var target_el = e.target, target_value, target_index; + var value_el, current_value; + + value_el = this.findValueEl(e.target); + current_value = this.loadValue(value_el); + target_value = $(e.target).data('id'); + + if(!$(target_el).hasClass('selected')) { + if(this.singleSelect) { + current_value.options = [target_value] + } else { + current_value.options.push(target_value); + } + } else { + if(this.singleSelect) { + current_value.options = [] + } else { + target_index = current_value.options.indexOf(target_value); + if(target_index !== -1) { + current_value.options.splice(target_index, 1); + } + } + } + + this.storeValue(value_el, current_value); + + if(this.singleSelect) { + $(target_el).closest(this.tagsSelector) + .find(this.tagSelector) + .not(target_el) + .removeClass('selected') + } + $(target_el).toggleClass('selected'); + }, + findValueEl: function(target_el) { + var input_el = $(target_el).closest(this.inputSelector); + return $(this.valueSelector, input_el); + }, + loadValue: function(value_el) { + var json = $(value_el).val(); + + var result = JSON.parse(json); + if(result === null) { + result = {}; + } + if(!result.hasOwnProperty('options')) { + result.options = []; + } + if(!result.hasOwnProperty('comment')) { + result.comment = ''; + } + + return result; + }, + storeValue: function(value_el, new_value) { + var json = JSON.stringify(new_value); + $(value_el).val(json); + } + } + + module.init(); +}).call(this); diff --git a/common/static/js/capa/chemical_equation_preview.js b/common/static/js/capa/chemical_equation_preview.js index 90ce27ad11..10a6b54655 100644 --- a/common/static/js/capa/chemical_equation_preview.js +++ b/common/static/js/capa/chemical_equation_preview.js @@ -11,9 +11,14 @@ } prev_id = "#" + this.id + "_preview"; - preview_div = $(prev_id) + preview_div = $(prev_id); - $.get("/preview/chemcalc/", {"formula" : this.value}, create_handler(preview_div)); + // find the closest parent problems-wrapper and use that url + url = $(this).closest('.problems-wrapper').data('url'); + // grab the input id from the input + input_id = $(this).data('input-id') + + Problem.inputAjax(url, input_id, 'preview_chemcalc', {"formula" : this.value}, create_handler(preview_div)); } inputs = $('.chemicalequationinput input'); diff --git a/common/static/js/capa/edit-a-gene.js b/common/static/js/capa/edit-a-gene.js index 48753e507d..bd6d10cc64 100644 --- a/common/static/js/capa/edit-a-gene.js +++ b/common/static/js/capa/edit-a-gene.js @@ -1,27 +1,44 @@ (function () { var timeout = 1000; - function initializeApplet(applet) { - console.log("Initializing " + applet); - waitForApplet(applet); - } + waitForGenex(); - function waitForApplet(applet) { - if (applet.isActive && applet.isActive()) { - console.log("Applet is ready."); - var answerStr = applet.checkAnswer(); - console.log(answerStr); - var input = $('.editageneinput input'); - console.log(input); - input.val(answerStr); - } else if (timeout > 30 * 1000) { - console.error("Applet did not load on time."); - } else { - console.log("Waiting for applet..."); - setTimeout(function() { waitForApplet(applet); }, timeout); + function waitForGenex() { + if (typeof(genex) !== "undefined" && genex) { + genex.onInjectionDone("genex"); + } + else { + setTimeout(function() { waitForGenex(); }, timeout); } } - var applets = $('.editageneinput object'); - applets.each(function(i, el) { initializeApplet(el); }); + //NOTE: + // Genex uses six global functions: + // genexSetDNASequence (exported from GWT) + // genexSetClickEvent (exported from GWT) + // genexSetKeyEvent (exported from GWT) + // genexSetProblemNumber (exported from GWT) + // + // It calls genexIsReady with a deferred command when it has finished + // initialization and has drawn itself + // genexStoreAnswer(answer) is called when the GWT [Store Answer] button + // is clicked + + genexIsReady = function() { + //Load DNA sequence + var dna_sequence = $('#dna_sequence').val(); + genexSetDNASequence(dna_sequence); + //Now load mouse and keyboard handlers + genexSetClickEvent(); + genexSetKeyEvent(); + //Now load problem + var genex_problem_number = $('#genex_problem_number').val(); + genexSetProblemNumber(genex_problem_number); + }; + genexStoreAnswer = function(ans) { + var problem = $('#genex_container').parents('.problem'); + var input_field = problem.find('input[type="hidden"][name!="dna_sequence"][name!="genex_problem_number"]'); + input_field.val(ans); + }; }).call(this); + diff --git a/common/static/js/capa/genex/3F4ADBED36D589545A9300A1EA686D36.cache.html b/common/static/js/capa/genex/3F4ADBED36D589545A9300A1EA686D36.cache.html new file mode 100644 index 0000000000..c5ad0d1b89 --- /dev/null +++ b/common/static/js/capa/genex/3F4ADBED36D589545A9300A1EA686D36.cache.html @@ -0,0 +1,639 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/73F4B6D6D466BAD6850A60128DF5B80D.cache.html b/common/static/js/capa/genex/73F4B6D6D466BAD6850A60128DF5B80D.cache.html new file mode 100644 index 0000000000..d5441b656e --- /dev/null +++ b/common/static/js/capa/genex/73F4B6D6D466BAD6850A60128DF5B80D.cache.html @@ -0,0 +1,649 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/BA18AC23ACC5016C5D0799E864BBDFFE.cache.html b/common/static/js/capa/genex/BA18AC23ACC5016C5D0799E864BBDFFE.cache.html new file mode 100644 index 0000000000..261aa02849 --- /dev/null +++ b/common/static/js/capa/genex/BA18AC23ACC5016C5D0799E864BBDFFE.cache.html @@ -0,0 +1,639 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/C7B18436BA03373FB13ED589C2CCF417.cache.html b/common/static/js/capa/genex/C7B18436BA03373FB13ED589C2CCF417.cache.html new file mode 100644 index 0000000000..13d25ddf09 --- /dev/null +++ b/common/static/js/capa/genex/C7B18436BA03373FB13ED589C2CCF417.cache.html @@ -0,0 +1,649 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/E1A9A95677AFC620CAD5759B7ACC3E67.cache.html b/common/static/js/capa/genex/E1A9A95677AFC620CAD5759B7ACC3E67.cache.html new file mode 100644 index 0000000000..7078dee4f8 --- /dev/null +++ b/common/static/js/capa/genex/E1A9A95677AFC620CAD5759B7ACC3E67.cache.html @@ -0,0 +1,625 @@ + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/FF175D5583BDD5ACF40C7F0AFF9A374B.cache.html b/common/static/js/capa/genex/FF175D5583BDD5ACF40C7F0AFF9A374B.cache.html new file mode 100644 index 0000000000..ca07bf3292 --- /dev/null +++ b/common/static/js/capa/genex/FF175D5583BDD5ACF40C7F0AFF9A374B.cache.html @@ -0,0 +1,651 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/clear.cache.gif b/common/static/js/capa/genex/clear.cache.gif new file mode 100644 index 0000000000..e565824aaf Binary files /dev/null and b/common/static/js/capa/genex/clear.cache.gif differ diff --git a/common/static/js/capa/genex/genex.css b/common/static/js/capa/genex/genex.css new file mode 100644 index 0000000000..459c854f92 --- /dev/null +++ b/common/static/js/capa/genex/genex.css @@ -0,0 +1,122 @@ +.genex-button { + margin-right: -8px; + height: 40px !important; +} + +.genex-label { + /*font: normal normal normal 10pt/normal 'Open Sans', Verdana, Geneva, sans-serif !important;*/ + /*padding: 4px 0px 0px 10px !important;*/ + font-family: sans-serif !important; + font-size: 13px !important; + font-style: normal !important; + font-variant: normal !important; + font-weight: bold !important; + padding-top: 6px !important; + margin-left: 18px; +} + +.gwt-HTML { + cursor: default; + overflow-x: auto !important; + overflow-y: auto !important; + background-color: rgb(248, 248, 248) !important; +} + +.genex-scrollpanel { + word-wrap: normal !important; + white-space: pre !important; +} + +pre, #dna-strand { + font-family: 'courier new', courier !important; + font-size: 13px !important; + font-style: normal !important; + font-variant: normal !important; + font-weight: normal !important; + border-style: none !important; + background-color: rgb(248, 248, 248) !important; + word-wrap: normal !important; + white-space: pre !important; + overflow-x: visible !important; + overflow-y: visible !important; +} + +.gwt-DialogBox .Caption { + background: #F1F1F1; + padding: 4px 8px 4px 4px; + cursor: default; + font-family: Arial Unicode MS, Arial, sans-serif; + font-weight: bold; + border-bottom: 1px solid #bbbbbb; + border-top: 1px solid #D2D2D2; +} +.gwt-DialogBox .dialogContent { +} +.gwt-DialogBox .dialogMiddleCenter { + padding: 3px; + background: white; +} +.gwt-DialogBox .dialogBottomCenter { + background: url(images/hborder.png) repeat-x 0px -2945px; + -background: url(images/hborder_ie6.png) repeat-x 0px -2144px; +} +.gwt-DialogBox .dialogMiddleLeft { + background: url(images/vborder.png) repeat-y -31px 0px; +} +.gwt-DialogBox .dialogMiddleRight { + background: url(images/vborder.png) repeat-y -32px 0px; + -background: url(images/vborder_ie6.png) repeat-y -32px 0px; +} +.gwt-DialogBox .dialogTopLeftInner { + width: 10px; + height: 8px; + zoom: 1; +} +.gwt-DialogBox .dialogTopRightInner { + width: 12px; + zoom: 1; +} +.gwt-DialogBox .dialogBottomLeftInner { + width: 10px; + height: 12px; + zoom: 1; +} +.gwt-DialogBox .dialogBottomRightInner { + width: 12px; + height: 12px; + zoom: 1; +} +.gwt-DialogBox .dialogTopLeft { + background: url(images/circles.png) no-repeat -20px 0px; + -background: url(images/circles_ie6.png) no-repeat -20px 0px; +} +.gwt-DialogBox .dialogTopRight { + background: url(images/circles.png) no-repeat -28px 0px; + -background: url(images/circles_ie6.png) no-repeat -28px 0px; +} +.gwt-DialogBox .dialogBottomLeft { + background: url(images/circles.png) no-repeat 0px -36px; + -background: url(images/circles_ie6.png) no-repeat 0px -36px; +} +.gwt-DialogBox .dialogBottomRight { + background: url(images/circles.png) no-repeat -8px -36px; + -background: url(images/circles_ie6.png) no-repeat -8px -36px; +} +* html .gwt-DialogBox .dialogTopLeftInner { + width: 10px; + overflow: hidden; +} +* html .gwt-DialogBox .dialogTopRightInner { + width: 12px; + overflow: hidden; +} +* html .gwt-DialogBox .dialogBottomLeftInner { + width: 10px; + height: 12px; + overflow: hidden; +} +* html .gwt-DialogBox .dialogBottomRightInner { + width: 12px; + height: 12px; + overflow: hidden; +} \ No newline at end of file diff --git a/common/static/js/capa/genex/genex.nocache.js b/common/static/js/capa/genex/genex.nocache.js new file mode 100644 index 0000000000..fe892a53dc --- /dev/null +++ b/common/static/js/capa/genex/genex.nocache.js @@ -0,0 +1,18 @@ +function genex(){var P='',xb='" for "gwt:onLoadErrorFn"',vb='" for "gwt:onPropertyErrorFn"',ib='"><\/script>',Z='#',Xb='.cache.html',_='/',lb='//',Qb='3F4ADBED36D589545A9300A1EA686D36',Rb='73F4B6D6D466BAD6850A60128DF5B80D',Wb=':',pb='::',dc=' + +This html file is for Development Mode support. + diff --git a/common/static/js/capa/genex/images/circles.png b/common/static/js/capa/genex/images/circles.png new file mode 100644 index 0000000000..2a84b9c320 Binary files /dev/null and b/common/static/js/capa/genex/images/circles.png differ diff --git a/common/static/js/capa/genex/images/circles_ie6.png b/common/static/js/capa/genex/images/circles_ie6.png new file mode 100644 index 0000000000..3a388fc5d3 Binary files /dev/null and b/common/static/js/capa/genex/images/circles_ie6.png differ diff --git a/common/static/js/capa/genex/images/corner.png b/common/static/js/capa/genex/images/corner.png new file mode 100644 index 0000000000..51aa4582aa Binary files /dev/null and b/common/static/js/capa/genex/images/corner.png differ diff --git a/common/static/js/capa/genex/images/corner_ie6.png b/common/static/js/capa/genex/images/corner_ie6.png new file mode 100644 index 0000000000..5197449254 Binary files /dev/null and b/common/static/js/capa/genex/images/corner_ie6.png differ diff --git a/common/static/js/capa/genex/images/hborder.png b/common/static/js/capa/genex/images/hborder.png new file mode 100644 index 0000000000..ec58ae6126 Binary files /dev/null and b/common/static/js/capa/genex/images/hborder.png differ diff --git a/common/static/js/capa/genex/images/hborder_ie6.png b/common/static/js/capa/genex/images/hborder_ie6.png new file mode 100644 index 0000000000..2268f88a76 Binary files /dev/null and b/common/static/js/capa/genex/images/hborder_ie6.png differ diff --git a/common/static/js/capa/genex/images/thumb_horz.png b/common/static/js/capa/genex/images/thumb_horz.png new file mode 100644 index 0000000000..b43e683e1f Binary files /dev/null and b/common/static/js/capa/genex/images/thumb_horz.png differ diff --git a/common/static/js/capa/genex/images/thumb_vertical.png b/common/static/js/capa/genex/images/thumb_vertical.png new file mode 100644 index 0000000000..bd57f594ac Binary files /dev/null and b/common/static/js/capa/genex/images/thumb_vertical.png differ diff --git a/common/static/js/capa/genex/images/vborder.png b/common/static/js/capa/genex/images/vborder.png new file mode 100644 index 0000000000..6840d11a12 Binary files /dev/null and b/common/static/js/capa/genex/images/vborder.png differ diff --git a/common/static/js/capa/genex/images/vborder_ie6.png b/common/static/js/capa/genex/images/vborder_ie6.png new file mode 100644 index 0000000000..90baeb1f99 Binary files /dev/null and b/common/static/js/capa/genex/images/vborder_ie6.png differ diff --git a/doc/public/course_data_formats/course_xml.rst b/doc/public/course_data_formats/course_xml.rst index fe25aa92f2..56d831d972 100644 --- a/doc/public/course_data_formats/course_xml.rst +++ b/doc/public/course_data_formats/course_xml.rst @@ -550,15 +550,84 @@ If you want to customize the courseware tabs displayed for your course, specify ********* Textbooks ********* -Support is currently provided for image-based and PDF-based textbooks. +Support is currently provided for image-based and PDF-based textbooks. In addition to enabling the display of textbooks in tabs (see above), specific information about the location of textbook content must be configured. Image-based Textbooks -^^^^^^^^^^^^^^^^^^^^^ +===================== + +Configuration +------------- + +Image-based textbooks are configured at the course level in the XML markup. Here is an example: + +.. code-block:: xml + + + + + + + + + +Each `textbook` element is displayed on a different tab. The `title` attribute is used as the tab's name, and the `book_url` attribute points to the remote directory that contains the images of the text. Note the trailing slash on the end of the `book_url` attribute. + +The images must be stored in the same directory as the `book_url`, with filenames matching `pXXX.png`, where `XXX` is a three-digit number representing the page number (with leading zeroes as necessary). Pages start at `p001.png`. + +Each textbook must also have its own table of contents. This is read from the `book_url` location, by appending `toc.xml`. This file contains a `table_of_contents` parent element, with `entry` elements nested below it. Each `entry` has attributes for `name`, `page_label`, and `page`, as well as an optional `chapter` attribute. An arbitrary number of levels of nesting of `entry` elements within other `entry` elements is supported, but you're likely to only want two levels. The `page` represents the actual page to link to, while the `page_label` matches the displayed page number on that page. Here's an example: + +.. code-block:: xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Linking from Content +-------------------- + +It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook and the page number. The URL is of the form `/course/book/${bookindex}/$page}`. If the page is omitted from the URL, the first page is assumed. + +You can use a `customtag` to create a template for such links. For example, you can create a `book` template in the `customtag` directory, containing: + +.. code-block:: xml + + More information given in the text. + +The course content can then link to page 25 using the `customtag` element: + +.. code-block:: xml + + -TBD. PDF-based Textbooks -^^^^^^^^^^^^^^^^^^^ +=================== + +Configuration +------------- PDF-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting PDF-based material. The first way is as a single PDF on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple PDFs that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular PDF to view. @@ -566,20 +635,51 @@ PDF-based textbooks are configured at the course level in the policy file. The "pdf_textbooks": [ {"tab_title": "Textbook 1", - "url": "https://www.example.com/book1.pdf" }, + "url": "https://www.example.com/thiscourse/book1/book1.pdf" }, {"tab_title": "Textbook 2", "chapters": [ - { "title": "Chapter 1", "url": "https://www.example.com/Chapter1.pdf" }, - { "title": "Chapter 2", "url": "https://www.example.com/Chapter2.pdf" }, - { "title": "Chapter 3", "url": "https://www.example.com/Chapter3.pdf" }, - { "title": "Chapter 4", "url": "https://www.example.com/Chapter4.pdf" }, - { "title": "Chapter 5", "url": "https://www.example.com/Chapter5.pdf" }, - { "title": "Chapter 6", "url": "https://www.example.com/Chapter6.pdf" }, - { "title": "Chapter 7", "url": "https://www.example.com/Chapter7.pdf" } + { "title": "Chapter 1", "url": "https://www.example.com/thiscourse/book2/Chapter1.pdf" }, + { "title": "Chapter 2", "url": "https://www.example.com/thiscourse/book2/Chapter2.pdf" }, + { "title": "Chapter 3", "url": "https://www.example.com/thiscourse/book2/Chapter3.pdf" }, + { "title": "Chapter 4", "url": "https://www.example.com/thiscourse/book2/Chapter4.pdf" }, + { "title": "Chapter 5", "url": "https://www.example.com/thiscourse/book2/Chapter5.pdf" }, + { "title": "Chapter 6", "url": "https://www.example.com/thiscourse/book2/Chapter6.pdf" }, + { "title": "Chapter 7", "url": "https://www.example.com/thiscourse/book2/Chapter7.pdf" } ] } ] +Some notes: + +* It is not a good idea to include a top-level URL and chapter-level URLs in the same textbook configuration. + +Linking from Content +-------------------- + +It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook, the chapter (if chapters are used), and the page number. For a book with no chapters, the URL is of the form `/course/pdfbook/${bookindex}/$page}`. For a book with chapters, use `/course/pdfbook/${bookindex}/chapter/${chapter}/${page}`. If the page is omitted from the URL, the first page is assumed. + +For example, for the book with no chapters configured above, page 25 can be reached using the URL `/course/pdfbook/0/25`. Reaching page 19 in the third chapter of the second book is accomplished with `/course/pdfbook/1/chapter/3/19`. + +You can use a `customtag` to create a template for such links. For example, you can create a `pdfbook` template in the `customtag` directory, containing: + +.. code-block:: xml + + More information given in the text. + +And a `pdfchapter` template containing: + +.. code-block:: xml + + More information given in the text. + +The example pages can then be linked using the `customtag` element: + +.. code-block:: xml + + + + + ************************************* Other file locations (info and about) ************************************* diff --git a/doc/public/course_data_formats/drag_and_drop/drag-n-drop-demo3.xml b/doc/public/course_data_formats/drag_and_drop/drag-n-drop-demo3.xml new file mode 100644 index 0000000000..860f488089 --- /dev/null +++ b/doc/public/course_data_formats/drag_and_drop/drag-n-drop-demo3.xml @@ -0,0 +1,262 @@ + + + + +

[Simple grading example: draggables on draggables]


+

Describe carbon molecule in LCAO-MO.


+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +

[Complex grading example: draggables on draggables]


+

Describe carbon molecule in LCAO-MO.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +

[Complex grading example: no draggables on draggables]


+

Describe carbon molecule in LCAO-MO.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
diff --git a/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst b/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst index 4d61038054..4927deeec6 100644 --- a/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst +++ b/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst @@ -83,9 +83,58 @@ the slider. If no targets are provided, then a draggable can be dragged and placed anywhere on the base image. -correct answer format +Targets on draggables --------------------- +Sometimes it is not enough to have targets only on the base image, and all of the +draggables on these targets. If a complex problem exists where a draggable must +become itself a target (or many targets), then the following extended syntax +can be used: :: + + + + + + ... + + +The attribute list in the tags above ('draggable' and 'target') is the same as for +normal 'draggable' and 'target' tags. The only difference is when you will be +specifying inner target position coordinates. Using the 'x' and 'y' attributes you +are setting the offset of the inner target from the upper-left corner of the +parent draggable (that contains the inner target). + +Limitations of targets on draggables +------------------------------------ + +1.) Currently there is a limitation to the level of nesting of targets. + +Even though you can pile up a large number of draggables on targets that themselves +are on draggables, the Drag and Drop instance will be graded only in the case if +there is a maximum of two levels of targets. The first level are the "base" targets. +They are attached to the base image. The second level are the targets defined on +draggables. + +2.) Another limitation is that the target bounds are not checked against +other targets. + +For now, it is the responsibility of the person who is constructing the course +material to make sure that there is no overlapping of targets. It is also preferable +that targets on draggables are smaller than the actual parent draggable. Technically +this is not necessary, but from the usability perspective it is desirable. + +3.) You can have targets on draggables only in the case when there are base targets +defined (base targets are attached to the base image). + +If you do not have base targets, then you can only have a single level of nesting +(draggables on the base image). In this case the client side will be reporting (x,y) +positions of each draggables on the base image. + +Correct answer format +--------------------- + +(NOTE: For specifying answers for targets on draggables please see next section.) + There are two correct answer formats: short and long If short from correct answer is mapping of 'draggable_id' to 'target_id':: @@ -180,7 +229,7 @@ Rules are: exact, anyof, unordered_equal, anyof+number, unordered_equal+number 'rule': 'unordered_equal' }] -- And sometimes you want to allow drag only two 'b' draggables, in these case you sould use 'anyof+number' of 'unordered_equal+number' rule:: +- And sometimes you want to allow drag only two 'b' draggables, in these case you should use 'anyof+number' of 'unordered_equal+number' rule:: correct_answer = [ { @@ -204,6 +253,54 @@ for same number of draggables, anyof is equal to unordered_equal If we have can_reuse=true, than one must use only long form of correct answer. +Answer format for targets on draggables +--------------------------------------- + +As with the cases described above, an answer must provide precise positioning for +each draggable (on which targets it must reside). In the case when a draggable must +be placed on a target that itself is on a draggable, then the answer must contain +the chain of target-draggable-target. It is best to understand this on an example. + +Suppose we have three draggables - 'up', 's', and 'p'. Draggables 's', and 'p' have targets +on themselves. More specifically, 'p' has three targets - '1', '2', and '3'. The first +requirement is that 's', and 'p' are positioned on specific targets on the base image. +The second requirement is that draggable 'up' is positioned on specific targets of +draggable 'p'. Below is an excerpt from a problem.:: + + + + + + + + + + + + + + ... + + correct_answer = [ + { + 'draggables': ['p'], + 'targets': ['p-left-target', 'p-right-target'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['s'], + 'targets': ['s-left-target', 's-right-target'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up'], + 'targets': ['p-left-target[p][1]', 'p-left-target[p][2]', 'p-right-target[p][2]', 'p-right-target[p][3]',], + 'rule': 'unordered_equal' + } + ] + +Note that it is a requirement to specify rules for all draggables, even if some draggable gets included +in more than one chain. Grading logic ------------- @@ -321,3 +418,8 @@ Draggables can be reused ------------------------ .. literalinclude:: drag-n-drop-demo2.xml + +Examples of targets on draggables +------------------------ + +.. literalinclude:: drag-n-drop-demo3.xml diff --git a/github-requirements.txt b/github-requirements.txt index 62e47a328f..468d55ce65 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -3,4 +3,3 @@ -e git://github.com/MITx/django-pipeline.git#egg=django-pipeline -e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev --e git://github.com/MITx/dogapi.git@003a4fc9#egg=dogapi diff --git a/jenkins/quality.sh b/jenkins/quality.sh deleted file mode 100755 index 56217af874..0000000000 --- a/jenkins/quality.sh +++ /dev/null @@ -1,16 +0,0 @@ -#! /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 - -rake clobber -rake pep8 || echo "pep8 failed, continuing" -rake pylint || echo "pylint failed, continuing" diff --git a/jenkins/test.sh b/jenkins/test.sh index 5b9a5ed9bd..f8ffab29fc 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -32,18 +32,22 @@ if [ ! -d /mnt/virtualenvs/"$JOB_NAME" ]; then virtualenv /mnt/virtualenvs/"$JOB_NAME" fi +export PIP_DOWNLOAD_CACHE=/mnt/pip-cache + source /mnt/virtualenvs/"$JOB_NAME"/bin/activate pip install -q -r pre-requirements.txt -pip install -q -r test-requirements.txt -yes w | pip install -q -r requirements.txt +yes w | pip install -q -r test-requirements.txt -r requirements.txt rake clobber +rake pep8 +rake pylint + TESTS_FAILED=0 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 -# Don't run the lms jasmine tests for now because +# Don't run the lms jasmine tests for now because # they mostly all fail anyhow # rake phantomjs_jasmine_lms || true rake phantomjs_jasmine_cms || TESTS_FAILED=1 diff --git a/lms/djangoapps/courseware/management/commands/regrade_partial.py b/lms/djangoapps/courseware/management/commands/regrade_partial.py new file mode 100644 index 0000000000..c24f84ddfc --- /dev/null +++ b/lms/djangoapps/courseware/management/commands/regrade_partial.py @@ -0,0 +1,122 @@ +''' +This is a one-off command aimed at fixing a temporary problem encountered where partial credit was awarded for +code problems, but the resulting score (or grade) was mistakenly set to zero because of a bug in +CorrectMap.get_npoints(). +''' + +import json +import logging +from optparse import make_option + +from django.core.management.base import BaseCommand + +from courseware.models import StudentModule +from capa.correctmap import CorrectMap + +LOG = logging.getLogger(__name__) + + +class Command(BaseCommand): + ''' + The fix here is to recalculate the score/grade based on the partial credit. + To narrow down the set of problems that might need fixing, the StudentModule + objects to be checked is filtered down to those: + + created < '2013-03-08 15:45:00' (the problem must have been answered before the fix was installed, + on Prod and Edge) + modified > '2013-03-07 20:18:00' (the problem must have been visited after the bug was introduced) + state like '%"npoints": 0.%' (the problem must have some form of partial credit). + ''' + + num_visited = 0 + num_changed = 0 + + option_list = BaseCommand.option_list + ( + make_option('--save', + action='store_true', + dest='save_changes', + default=False, + help='Persist the changes that were encountered. If not set, no changes are saved.'), ) + + def fix_studentmodules(self, save_changes): + '''Identify the list of StudentModule objects that might need fixing, and then fix each one''' + modules = StudentModule.objects.filter(modified__gt='2013-03-07 20:18:00', + created__lt='2013-03-08 15:45:00', + state__contains='"npoints": 0.') + + for module in modules: + self.fix_studentmodule_grade(module, save_changes) + + def fix_studentmodule_grade(self, module, save_changes): + ''' Fix the grade assigned to a StudentModule''' + module_state = module.state + if module_state is None: + # not likely, since we filter on it. But in general... + LOG.info("No state found for {type} module {id} for student {student} in course {course_id}" + .format(type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + return + + state_dict = json.loads(module_state) + self.num_visited += 1 + + # LoncapaProblem.get_score() checks student_answers -- if there are none, we will return a grade of 0 + # Check that this is the case, but do so sooner, before we do any of the other grading work. + student_answers = state_dict['student_answers'] + if (not student_answers) or len(student_answers) == 0: + # we should not have a grade here: + if module.grade != 0: + LOG.error("No answer found but grade {grade} exists for {type} module {id} for student {student} " + "in course {course_id}".format(grade=module.grade, + type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + else: + LOG.debug("No answer and no grade found for {type} module {id} for student {student} " + "in course {course_id}".format(grade=module.grade, + type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + return + + # load into a CorrectMap, as done in LoncapaProblem.__init__(): + correct_map = CorrectMap() + if 'correct_map' in state_dict: + correct_map.set_dict(state_dict['correct_map']) + + # calculate score the way LoncapaProblem.get_score() works, by deferring to + # CorrectMap's get_npoints implementation. + correct = 0 + for key in correct_map: + correct += correct_map.get_npoints(key) + + if module.grade == correct: + # nothing to change + LOG.debug("Grade matches for {type} module {id} for student {student} in course {course_id}" + .format(type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + elif save_changes: + # make the change + LOG.info("Grade changing from {0} to {1} for {type} module {id} for student {student} " + "in course {course_id}".format(module.grade, correct, + type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + module.grade = correct + module.save() + self.num_changed += 1 + else: + # don't make the change, but log that the change would be made + LOG.info("Grade would change from {0} to {1} for {type} module {id} for student {student} " + "in course {course_id}".format(module.grade, correct, + type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + self.num_changed += 1 + + def handle(self, **options): + '''Handle management command request''' + + save_changes = options['save_changes'] + + LOG.info("Starting run: save_changes = {0}".format(save_changes)) + + self.fix_studentmodules(save_changes) + + LOG.info("Finished run: updating {0} of {1} modules".format(self.num_changed, self.num_visited)) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 7877c83bdc..ec2178f642 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -16,7 +16,6 @@ from django.views.decorators.csrf import csrf_exempt from requests.auth import HTTPBasicAuth from capa.xqueue_interface import XQueueInterface -from capa.chem import chemcalc from courseware.access import has_access from mitxmako.shortcuts import render_to_string from models import StudentModule, StudentModuleCache @@ -501,7 +500,7 @@ def modx_dispatch(request, dispatch, location, course_id): if instance is None: # Either permissions just changed, or someone is trying to be clever # and load something they shouldn't have access to. - log.debug("No module {0} for user {1}--access denied?".format(location, user)) + log.debug("No module {0} for user {1}--access denied?".format(location, request.user)) raise Http404 instance_module = get_instance_module(course_id, request.user, instance, student_module_cache) @@ -559,42 +558,6 @@ def modx_dispatch(request, dispatch, location, course_id): return HttpResponse(ajax_return) -def preview_chemcalc(request): - """ - Render an html preview of a chemical formula or equation. The fact that - this is here is a bit of hack. See the note in lms/urls.py about why it's - here. (Victor is to blame.) - - request should be a GET, with a key 'formula' and value 'some formula string'. - - Returns a json dictionary: - { - 'preview' : 'the-preview-html' or '' - 'error' : 'the-error' or '' - } - """ - if request.method != "GET": - raise Http404 - - result = {'preview': '', - 'error': ''} - formula = request.GET.get('formula') - if formula is None: - result['error'] = "No formula specified." - - return HttpResponse(json.dumps(result)) - - try: - result['preview'] = chemcalc.render_to_html(formula) - except pyparsing.ParseException as p: - result['error'] = "Couldn't parse formula: {0}".format(p) - except Exception: - # this is unexpected, so log - log.warning("Error while previewing chemical formula", exc_info=True) - result['error'] = "Error while rendering preview" - - return HttpResponse(json.dumps(result)) - def get_score_bucket(grade, max_grade): """ diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index b31aeb6b5a..a956c03eeb 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -130,6 +130,17 @@ def _pdf_textbooks(tab, user, course, active_page): for index, textbook in enumerate(course.pdf_textbooks)] return [] +def _html_textbooks(tab, user, course, active_page): + """ + Generates one tab per textbook. Only displays if user is authenticated. + """ + if user.is_authenticated(): + # since there can be more than one textbook, active_page is e.g. "book/0". + return [CourseTab(textbook['tab_title'], reverse('html_book', args=[course.id, index]), + active_page == "htmltextbook/{0}".format(index)) + for index, textbook in enumerate(course.html_textbooks)] + return [] + def _staff_grading(tab, user, course, active_page): if has_access(user, course, 'staff'): link = reverse('staff_grading', args=[course.id]) @@ -209,6 +220,7 @@ VALID_TAB_TYPES = { 'external_link': TabImpl(key_checker(['name', 'link']), _external_link), 'textbooks': TabImpl(null_validator, _textbooks), 'pdf_textbooks': TabImpl(null_validator, _pdf_textbooks), + 'html_textbooks': TabImpl(null_validator, _html_textbooks), 'progress': TabImpl(need_name, _progress), 'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab), 'peer_grading': TabImpl(null_validator, _peer_grading), diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py new file mode 100644 index 0000000000..928b9ae0df --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -0,0 +1,259 @@ +from django.test import TestCase +from mock import MagicMock + +import courseware.tabs as tabs + +from django.test.utils import override_settings +from django.core.urlresolvers import reverse + + +class ProgressTestCase(TestCase): + + def setUp(self): + + self.mockuser1 = MagicMock() + self.mockuser0 = MagicMock() + self.course = MagicMock() + self.mockuser1.is_authenticated.return_value = True + self.mockuser0.is_authenticated.return_value = False + self.course.id = 'edX/full/6.002_Spring_2012' + self.tab = {'name': 'same'} + self.active_page1 = 'progress' + self.active_page0 = 'stagnation' + + def test_progress(self): + + self.assertEqual(tabs._progress(self.tab, self.mockuser0, self.course, + self.active_page0), []) + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page1)[0].name, 'same') + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page1)[0].link, + reverse('progress', args = [self.course.id])) + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page0)[0].is_active, False) + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page1)[0].is_active, True) + + +class WikiTestCase(TestCase): + + def setUp(self): + + self.user = MagicMock() + self.course = MagicMock() + self.course.id = 'edX/full/6.002_Spring_2012' + self.tab = {'name': 'same'} + self.active_page1 = 'wiki' + self.active_page0 = 'miki' + + @override_settings(WIKI_ENABLED=True) + def test_wiki_enabled(self): + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1)[0].name, + 'same') + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1)[0].link, + reverse('course_wiki', args=[self.course.id])) + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1)[0].is_active, + True) + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page0)[0].is_active, + False) + + @override_settings(WIKI_ENABLED=False) + def test_wiki_enabled_false(self): + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1), []) + + +class ExternalLinkTestCase(TestCase): + + def setUp(self): + + self.user = MagicMock() + self.course = MagicMock() + self.tabby = {'name': 'same', 'link': 'blink'} + self.active_page0 = None + self.active_page00 = True + + def test_external_link(self): + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page0)[0].name, + 'same') + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page0)[0].link, + 'blink') + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page0)[0].is_active, + False) + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page00)[0].is_active, + False) + + +class StaticTabTestCase(TestCase): + + def setUp(self): + + self.user = MagicMock() + self.course = MagicMock() + self.tabby = {'name': 'same', 'url_slug': 'schmug'} + self.course.id = 'edX/full/6.002_Spring_2012' + self.active_page1 = 'static_tab_schmug' + self.active_page0 = 'static_tab_schlug' + + def test_static_tab(self): + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page1)[0].name, + 'same') + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page1)[0].link, + reverse('static_tab', args = [self.course.id, + self.tabby['url_slug']])) + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page1)[0].is_active, + True) + + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page0)[0].is_active, + False) + + +class TextbooksTestCase(TestCase): + + def setUp(self): + + self.mockuser1 = MagicMock() + self.mockuser0 = MagicMock() + self.course = MagicMock() + self.tab = MagicMock() + A = MagicMock() + T = MagicMock() + self.mockuser1.is_authenticated.return_value = True + self.mockuser0.is_authenticated.return_value = False + self.course.id = 'edX/full/6.002_Spring_2012' + self.active_page0 = 'textbook/0' + self.active_page1 = 'textbook/1' + self.active_pageX = 'you_shouldnt_be_seein_this' + A.title = 'Algebra' + T.title = 'Topology' + self.course.textbooks = [A, T] + + @override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': True}) + def test_textbooks1(self): + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page0)[0].name, + 'Algebra') + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page0)[0].link, + reverse('book', args=[self.course.id, 0])) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page0)[0].is_active, + True) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_pageX)[0].is_active, + False) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page1)[1].name, + 'Topology') + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page1)[1].link, + reverse('book', args=[self.course.id, 1])) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page1)[1].is_active, + True) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_pageX)[1].is_active, + False) + + @override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': False}) + def test_textbooks0(self): + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_pageX), []) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser0, + self.course, self.active_pageX), []) + +class KeyCheckerTestCase(TestCase): + + def setUp(self): + + self.expected_keys1 = ['a', 'b'] + self.expected_keys0 = ['a', 'v', 'g'] + self.dictio = {'a': 1, 'b': 2, 'c': 3} + + def test_key_checker(self): + + self.assertIsNone(tabs.key_checker(self.expected_keys1)(self.dictio)) + self.assertRaises(tabs.InvalidTabsException, + tabs.key_checker(self.expected_keys0), self.dictio) + + +class NullValidatorTestCase(TestCase): + + def setUp(self): + + self.d = {} + + def test_null_validator(self): + + self.assertIsNone(tabs.null_validator(self.d)) + + +class ValidateTabsTestCase(TestCase): + + def setUp(self): + + self.courses = [MagicMock() for i in range(0,5)] + + self.courses[0].tabs = None + + self.courses[1].tabs = [{'type':'courseware'}, {'type': 'fax'}] + + self.courses[2].tabs = [{'type':'shadow'}, {'type': 'course_info'}] + + self.courses[3].tabs = [{'type':'courseware'},{'type':'course_info', 'name': 'alice'}, + {'type': 'wiki', 'name':'alice'}, {'type':'discussion', 'name': 'alice'}, + {'type':'external_link', 'name': 'alice', 'link':'blink'}, + {'type':'textbooks'}, {'type':'progress', 'name': 'alice'}, + {'type':'static_tab', 'name':'alice', 'url_slug':'schlug'}, + {'type': 'staff_grading'}] + + self.courses[4].tabs = [{'type':'courseware'},{'type': 'course_info'}, {'type': 'flying'}] + + + def test_validate_tabs(self): + + self.assertIsNone(tabs.validate_tabs(self.courses[0])) + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[1]) + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2]) + self.assertIsNone(tabs.validate_tabs(self.courses[3])) + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4]) diff --git a/lms/djangoapps/django_comment_client/base/urls.py b/lms/djangoapps/django_comment_client/base/urls.py index d8fd4927fb..92826a18ae 100644 --- a/lms/djangoapps/django_comment_client/base/urls.py +++ b/lms/djangoapps/django_comment_client/base/urls.py @@ -12,6 +12,8 @@ urlpatterns = patterns('django_comment_client.base.views', url(r'threads/(?P[\w\-]+)/upvote$', 'vote_for_thread', {'value': 'up'}, name='upvote_thread'), url(r'threads/(?P[\w\-]+)/downvote$', 'vote_for_thread', {'value': 'down'}, name='downvote_thread'), url(r'threads/(?P[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'), + url(r'threads/(?P[\w\-]+)/pin$', 'pin_thread', name='pin_thread'), + url(r'threads/(?P[\w\-]+)/unpin$', 'un_pin_thread', name='un_pin_thread'), url(r'threads/(?P[\w\-]+)/follow$', 'follow_thread', name='follow_thread'), url(r'threads/(?P[\w\-]+)/unfollow$', 'unfollow_thread', name='unfollow_thread'), url(r'threads/(?P[\w\-]+)/close$', 'openclose_thread', name='openclose_thread'), diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index d93ea19f44..6734625a76 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -289,6 +289,21 @@ def undo_vote_for_thread(request, course_id, thread_id): user.unvote(thread) return JsonResponse(utils.safe_content(thread.to_dict())) +@require_POST +@login_required +@permitted +def pin_thread(request, course_id, thread_id): + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + thread.pin(user,thread_id) + return JsonResponse(utils.safe_content(thread.to_dict())) + +def un_pin_thread(request, course_id, thread_id): + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + thread.un_pin(user,thread_id) + return JsonResponse(utils.safe_content(thread.to_dict())) + @require_POST @login_required diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index a3120c563a..50224e7de6 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -91,6 +91,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG #now add the group name if the thread has a group id for thread in threads: + if thread.get('group_id'): thread['group_name'] = get_cohort_by_id(course_id, thread.get('group_id')).name thread['group_string'] = "This post visible only to Group %s." % (thread['group_name']) @@ -210,6 +211,9 @@ def forum_form_discussion(request, course_id): user_cohort_id = get_cohort_id(request.user, course_id) + + + context = { 'csrf': csrf(request)['csrf_token'], 'course': course, diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py index dfdcd3e7ba..7d21cc9783 100644 --- a/lms/djangoapps/django_comment_client/permissions.py +++ b/lms/djangoapps/django_comment_client/permissions.py @@ -90,6 +90,8 @@ VIEW_PERMISSIONS = { 'undo_vote_for_comment': [['unvote', 'is_open']], 'vote_for_thread' : [['vote', 'is_open']], 'undo_vote_for_thread': [['unvote', 'is_open']], + 'pin_thread': ['create_comment'], + 'un_pin_thread': ['create_comment'], 'follow_thread' : ['follow_thread'], 'follow_commentable': ['follow_commentable'], 'follow_user' : ['follow_user'], diff --git a/lms/djangoapps/django_comment_client/tests/test_models.py b/lms/djangoapps/django_comment_client/tests/test_models.py new file mode 100644 index 0000000000..6f90b3c4b8 --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests/test_models.py @@ -0,0 +1,55 @@ +import django_comment_client.models as models +import django_comment_client.permissions as permissions +from django.test import TestCase + + +class RoleClassTestCase(TestCase): + def setUp(self): + # For course ID, syntax edx/classname/classdate is important + # because xmodel.course_module.id_to_location looks for a string to split + + self.course_id = "edX/toy/2012_Fall" + self.student_role = models.Role.objects.get_or_create(name="Student", \ + course_id=self.course_id)[0] + self.student_role.add_permission("delete_thread") + self.student_2_role = models.Role.objects.get_or_create(name="Student", \ + course_id=self.course_id)[0] + self.TA_role = models.Role.objects.get_or_create(name="Community TA",\ + course_id=self.course_id)[0] + self.course_id_2 = "edx/6.002x/2012_Fall" + self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA",\ + course_id=self.course_id_2)[0] + class Dummy(): + def render_template(): + pass + d = {"data": { + "textbooks": [], + 'wiki_slug': True, + } + } + + def testHasPermission(self): + # Whenever you add a permission to student_role, + # Roles with the same FORUM_ROLE in same class also receives the same + # permission. + # Is this desirable behavior? + self.assertTrue(self.student_role.has_permission("delete_thread")) + self.assertTrue(self.student_2_role.has_permission("delete_thread")) + self.assertFalse(self.TA_role.has_permission("delete_thread")) + + def testInheritPermissions(self): + + self.TA_role.inherit_permissions(self.student_role) + self.assertTrue(self.TA_role.has_permission("delete_thread")) + # Despite being from 2 different courses, TA_role_2 can still inherit + # permissions from TA_role without error + self.TA_role_2.inherit_permissions(self.TA_role) + + +class PermissionClassTestCase(TestCase): + + def setUp(self): + self.permission = permissions.Permission.objects.get_or_create(name="test")[0] + + def testUnicode(self): + self.assertEqual(str(self.permission), "test") diff --git a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py index 5b788b3cc4..7db3ba6e86 100644 --- a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py +++ b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py @@ -3,26 +3,40 @@ import random import collections from django.test import TestCase +from mock import MagicMock +from django.test.utils import override_settings +import django.core.urlresolvers as urlresolvers import django_comment_client.mustache_helpers as mustache_helpers - -class PluralizeTestCase(TestCase): - - def test_pluralize(self): - self.text1 = '0 goat' - self.text2 = '1 goat' - self.text3 = '7 goat' - self.content = 'unused argument' - self.assertEqual(mustache_helpers.pluralize(self.content, self.text1), 'goats') - self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat') - self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats') +######################################################################################### -class CloseThreadTextTestCase(TestCase): +class PluralizeTest(TestCase): + + def setUp(self): + self.text1 = '0 goat' + self.text2 = '1 goat' + self.text3 = '7 goat' + self.content = 'unused argument' + + def test_pluralize(self): + self.assertEqual(mustache_helpers.pluralize(self.content, self.text1), 'goats') + self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat') + self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats') + +######################################################################################### + + +class CloseThreadTextTest(TestCase): + + def setUp(self): + self.contentClosed = {'closed': True} + self.contentOpen = {'closed': False} + + def test_close_thread_text(self): + self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread') + self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread') + +######################################################################################### - def test_close_thread_text(self): - self.contentClosed = {'closed': True} - self.contentOpen = {'closed': False} - self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread') - self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread') diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index dde219294c..1c10365693 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -18,7 +18,6 @@ import pystache_custom as pystache from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from xmodule.modulestore.search import path_to_location log = logging.getLogger(__name__) @@ -166,7 +165,6 @@ def initialize_discussion_info(course): # get all discussion models within this course_id all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course, 'discussion', None], course_id=course_id) - path_to_locations = {} for module in all_modules: skip_module = False for key in ('id', 'discussion_category', 'for'): @@ -174,14 +172,6 @@ def initialize_discussion_info(course): log.warning("Required key '%s' not in discussion %s, leaving out of category map" % (key, module.location)) skip_module = True - # cdodge: pre-compute the path_to_location. Note this can throw an exception for any - # dangling discussion modules - try: - path_to_locations[module.location] = path_to_location(modulestore(), course.id, module.location) - except NoPathToItem: - log.warning("Could not compute path_to_location for {0}. Perhaps this is an orphaned discussion module?!? Skipping...".format(module.location)) - skip_module = True - if skip_module: continue @@ -246,7 +236,6 @@ def initialize_discussion_info(course): _DISCUSSIONINFO[course.id]['id_map'] = discussion_id_map _DISCUSSIONINFO[course.id]['category_map'] = category_map _DISCUSSIONINFO[course.id]['timestamp'] = datetime.now() - _DISCUSSIONINFO[course.id]['path_to_location'] = path_to_locations class JsonResponse(HttpResponse): @@ -403,21 +392,8 @@ def get_courseware_context(content, course): location = id_map[id]["location"].url() title = id_map[id]["title"] - # cdodge: did we pre-compute, if so, then let's use that rather than recomputing - if 'path_to_location' in _DISCUSSIONINFO[course.id] and location in _DISCUSSIONINFO[course.id]['path_to_location']: - (course_id, chapter, section, position) = _DISCUSSIONINFO[course.id]['path_to_location'][location] - else: - try: - (course_id, chapter, section, position) = path_to_location(modulestore(), course.id, location) - except NoPathToItem: - # Object is not in the graph any longer, let's just get path to the base of the course - # so that we can at least return something to the caller - (course_id, chapter, section, position) = path_to_location(modulestore(), course.id, course.location) - - url = reverse('courseware_position', kwargs={"course_id":course_id, - "chapter":chapter, - "section":section, - "position":position}) + url = reverse('jump_to', kwargs={"course_id":course.location.course_id, + "location": location}) content_info = {"courseware_url": url, "courseware_title": title} return content_info @@ -430,7 +406,7 @@ def safe_content(content): 'updated_at', 'depth', 'type', 'commentable_id', 'comments_count', 'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'courseware_title', 'courseware_url', 'tags', 'unread_comments_count', - 'read', 'group_id', 'group_name', 'group_string' + 'read', 'group_id', 'group_name', 'group_string', 'pinned' ] if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False): diff --git a/lms/djangoapps/foldit/models.py b/lms/djangoapps/foldit/models.py index 7041be1446..0dce956756 100644 --- a/lms/djangoapps/foldit/models.py +++ b/lms/djangoapps/foldit/models.py @@ -59,7 +59,7 @@ class Score(models.Model): scores = Score.objects \ .filter(puzzle_id__in=puzzles) \ .annotate(total_score=models.Sum('best_score')) \ - .order_by('-total_score')[:n] + .order_by('total_score')[:n] num = len(puzzles) return [{'username': s.user.username, diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index 7127651601..afdd678f06 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -143,11 +143,12 @@ class FolditTestCase(TestCase): def test_SetPlayerPuzzleScores_manyplayers(self): """ Check that when we send scores from multiple users, the correct order - of scores is displayed. + of scores is displayed. Note that, before being processed by + display_score, lower scores are better. """ puzzle_id = ['1'] - player1_score = 0.07 - player2_score = 0.08 + player1_score = 0.08 + player2_score = 0.02 response1 = self.make_puzzle_score_request(puzzle_id, player1_score, self.user) @@ -164,8 +165,12 @@ class FolditTestCase(TestCase): self.assertEqual(len(top_10), 2) # Top score should be player2_score. Second should be player1_score - self.assertEqual(top_10[0]['score'], Score.display_score(player2_score)) - self.assertEqual(top_10[1]['score'], Score.display_score(player1_score)) + self.assertAlmostEqual(top_10[0]['score'], + Score.display_score(player2_score), + delta=0.5) + self.assertAlmostEqual(top_10[1]['score'], + Score.display_score(player1_score), + delta=0.5) # Top score user should be self.user2.username self.assertEqual(top_10[0]['username'], self.user2.username) diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 988c113d23..da361a2a82 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -130,7 +130,7 @@ def save_scores(user, puzzle_scores): current_score=current_score, best_score=best_score, score_version=score_version) - obj.save() + obj.save() score_responses.append({'PuzzleID': puzzle_id, 'Status': 'Success'}) diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index b4ca20079f..d67e2816d5 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -22,7 +22,7 @@ NOTIFICATION_TYPES = ( ('staff_needs_to_grade', 'staff_grading', 'Staff Grading'), ('new_student_grading_to_view', 'open_ended_problems', 'Problems you have submitted'), ('flagged_submissions_exist', 'open_ended_flagged_problems', 'Flagged Submissions') - ) +) def staff_grading_notifications(course, user): @@ -46,7 +46,9 @@ def staff_grading_notifications(course, user): #Non catastrophic error, so no real action notifications = {} #This is a dev_facing_error - log.info("Problem with getting notifications from staff grading service for course {0} user {1}.".format(course_id, student_id)) + log.info( + "Problem with getting notifications from staff grading service for course {0} user {1}.".format(course_id, + student_id)) if pending_grading: img_path = "/static/images/grading_notification.png" @@ -80,7 +82,9 @@ def peer_grading_notifications(course, user): #Non catastrophic error, so no real action notifications = {} #This is a dev_facing_error - log.info("Problem with getting notifications from peer grading service for course {0} user {1}.".format(course_id, student_id)) + log.info( + "Problem with getting notifications from peer grading service for course {0} user {1}.".format(course_id, + student_id)) if pending_grading: img_path = "/static/images/grading_notification.png" @@ -105,7 +109,9 @@ def combined_notifications(course, user): return notification_dict min_time_to_query = user.last_login - last_module_seen = StudentModule.objects.filter(student=user, course_id=course_id, modified__gt=min_time_to_query).values('modified').order_by('-modified') + last_module_seen = StudentModule.objects.filter(student=user, course_id=course_id, + modified__gt=min_time_to_query).values('modified').order_by( + '-modified') last_module_seen_count = last_module_seen.count() if last_module_seen_count > 0: @@ -117,7 +123,8 @@ def combined_notifications(course, user): img_path = "" try: - controller_response = controller_qs.check_combined_notifications(course.id, student_id, user_is_staff, last_time_viewed) + controller_response = controller_qs.check_combined_notifications(course.id, student_id, user_is_staff, + last_time_viewed) log.debug(controller_response) notifications = json.loads(controller_response) if notifications['success']: @@ -127,7 +134,9 @@ def combined_notifications(course, user): #Non catastrophic error, so no real action notifications = {} #This is a dev_facing_error - log.exception("Problem with getting notifications from controller query service for course {0} user {1}.".format(course_id, student_id)) + log.exception( + "Problem with getting notifications from controller query service for course {0} user {1}.".format( + course_id, student_id)) if pending_grading: img_path = "/static/images/grading_notification.png" @@ -151,7 +160,8 @@ def set_value_in_cache(student_id, course_id, notification_type, value): def create_key_name(student_id, course_id, notification_type): - key_name = "{prefix}{type}_{course}_{student}".format(prefix=KEY_PREFIX, type=notification_type, course=course_id, student=student_id) + key_name = "{prefix}{type}_{course}_{student}".format(prefix=KEY_PREFIX, type=notification_type, course=course_id, + student=student_id) return key_name diff --git a/lms/djangoapps/open_ended_grading/staff_grading.py b/lms/djangoapps/open_ended_grading/staff_grading.py index e39b26da56..fad5268294 100644 --- a/lms/djangoapps/open_ended_grading/staff_grading.py +++ b/lms/djangoapps/open_ended_grading/staff_grading.py @@ -15,6 +15,7 @@ class StaffGrading(object): """ Wrap up functionality for staff grading of submissions--interface exposes get_html, ajax views. """ + def __init__(self, course): self.course = course diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py index 79b92dffba..91138bf685 100644 --- a/lms/djangoapps/open_ended_grading/staff_grading_service.py +++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py @@ -20,10 +20,12 @@ log = logging.getLogger(__name__) STAFF_ERROR_MESSAGE = 'Could not contact the external grading server. Please contact the development team. If you do not have a point of contact, you can contact Vik at vik@edx.org.' + class MockStaffGradingService(object): """ A simple mockup of a staff grading service, testing. """ + def __init__(self): self.cnt = 0 @@ -43,15 +45,18 @@ class MockStaffGradingService(object): def get_problem_list(self, course_id, grader_id): self.cnt += 1 return json.dumps({'success': True, - 'problem_list': [ - json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1', - 'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5, 'min_for_ml': 10}), - json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2', - 'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5, 'min_for_ml': 10}) - ]}) + 'problem_list': [ + json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1', + 'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5, + 'min_for_ml': 10}), + json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2', + 'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5, + 'min_for_ml': 10}) + ]}) - def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores, submission_flagged): + def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores, + submission_flagged): return self.get_next(course_id, 'fake location', grader_id) @@ -59,6 +64,7 @@ class StaffGradingService(GradingService): """ Interface to staff grading backend. """ + def __init__(self, config): config['system'] = ModuleSystem(None, None, None, render_to_string, None) super(StaffGradingService, self).__init__(config) @@ -109,12 +115,13 @@ class StaffGradingService(GradingService): GradingServiceError: something went wrong with the connection. """ response = self.get(self.get_next_url, - params={'location': location, - 'grader_id': grader_id}) + params={'location': location, + 'grader_id': grader_id}) return json.dumps(self._render_rubric(response)) - def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores, submission_flagged): + def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores, + submission_flagged): """ Save a score and feedback for a submission. @@ -253,14 +260,14 @@ def get_problem_list(request, course_id): try: response = staff_grading_service().get_problem_list(course_id, unique_id_for_user(request.user)) return HttpResponse(response, - mimetype="application/json") + mimetype="application/json") except GradingServiceError: #This is a dev_facing_error log.exception("Error from staff grading service in open ended grading. server url: {0}" - .format(staff_grading_service().url)) + .format(staff_grading_service().url)) #This is a staff_facing_error return HttpResponse(json.dumps({'success': False, - 'error': STAFF_ERROR_MESSAGE})) + 'error': STAFF_ERROR_MESSAGE})) def _get_next(course_id, grader_id, location): @@ -272,7 +279,7 @@ def _get_next(course_id, grader_id, location): except GradingServiceError: #This is a dev facing error log.exception("Error from staff grading service in open ended grading. server url: {0}" - .format(staff_grading_service().url)) + .format(staff_grading_service().url)) #This is a staff_facing_error return json.dumps({'success': False, 'error': STAFF_ERROR_MESSAGE}) @@ -297,7 +304,7 @@ def save_grade(request, course_id): if request.method != 'POST': raise Http404 - required = set(['score', 'feedback', 'submission_id', 'location','submission_flagged', 'rubric_scores[]']) + required = set(['score', 'feedback', 'submission_id', 'location', 'submission_flagged', 'rubric_scores[]']) actual = set(request.POST.keys()) missing = required - actual if len(missing) > 0: @@ -307,22 +314,23 @@ def save_grade(request, course_id): grader_id = unique_id_for_user(request.user) p = request.POST - location = p['location'] - skipped = 'skipped' in p + skipped = 'skipped' in p try: result_json = staff_grading_service().save_grade(course_id, - grader_id, - p['submission_id'], - p['score'], - p['feedback'], - skipped, - p.getlist('rubric_scores[]'), - p['submission_flagged']) + grader_id, + p['submission_id'], + p['score'], + p['feedback'], + skipped, + p.getlist('rubric_scores[]'), + p['submission_flagged']) except GradingServiceError: #This is a dev_facing_error - log.exception("Error saving grade in the staff grading interface in open ended grading. Request: {0} Course ID: {1}".format(request, course_id)) + log.exception( + "Error saving grade in the staff grading interface in open ended grading. Request: {0} Course ID: {1}".format( + request, course_id)) #This is a staff_facing_error return _err_response(STAFF_ERROR_MESSAGE) @@ -330,13 +338,16 @@ def save_grade(request, course_id): result = json.loads(result_json) except ValueError: #This is a dev_facing_error - log.exception("save_grade returned broken json in the staff grading interface in open ended grading: {0}".format(result_json)) + log.exception( + "save_grade returned broken json in the staff grading interface in open ended grading: {0}".format( + result_json)) #This is a staff_facing_error return _err_response(STAFF_ERROR_MESSAGE) if not result.get('success', False): #This is a dev_facing_error - log.warning('Got success=False from staff grading service in open ended grading. Response: {0}'.format(result_json)) + log.warning( + 'Got success=False from staff grading service in open ended grading. Response: {0}'.format(result_json)) return _err_response(STAFF_ERROR_MESSAGE) # Ok, save_grade seemed to work. Get the next submission to grade. diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index d452883ebb..64123605ce 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -7,7 +7,7 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open from django.test import TestCase from open_ended_grading import staff_grading_service from xmodule.open_ended_grading_classes import peer_grading_service -from xmodule import peer_grading_module +from xmodule import peer_grading_module from django.core.urlresolvers import reverse from django.contrib.auth.models import Group @@ -22,6 +22,7 @@ from xmodule.x_module import ModuleSystem from mitxmako.shortcuts import render_to_string import logging + log = logging.getLogger(__name__) from django.test.utils import override_settings from django.http import QueryDict @@ -36,6 +37,7 @@ class TestStaffGradingService(ct.PageLoader): access control and error handling logic -- all the actual work is on the backend. ''' + def setUp(self): xmodule.modulestore.django._MODULESTORES = {} @@ -50,6 +52,7 @@ class TestStaffGradingService(ct.PageLoader): self.course_id = "edX/toy/2012_Fall" self.toy = modulestore().get_course(self.course_id) + def make_instructor(course): group_name = _course_staff_group_name(course.location) g = Group.objects.create(name=group_name) @@ -130,6 +133,7 @@ class TestPeerGradingService(ct.PageLoader): access control and error handling logic -- all the actual work is on the backend. ''' + def setUp(self): xmodule.modulestore.django._MODULESTORES = {} @@ -148,11 +152,12 @@ class TestPeerGradingService(ct.PageLoader): self.mock_service = peer_grading_service.MockPeerGradingService() self.system = ModuleSystem(location, None, None, render_to_string, None, - s3_interface = test_util_open_ended.S3_INTERFACE, - open_ended_grading_interface=test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE + s3_interface=test_util_open_ended.S3_INTERFACE, + open_ended_grading_interface=test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE ) self.descriptor = peer_grading_module.PeerGradingDescriptor(self.system) - self.peer_module = peer_grading_module.PeerGradingModule(self.system, location, "", self.descriptor) + self.peer_module = peer_grading_module.PeerGradingModule(self.system, location, "", + self.descriptor) self.peer_module.peer_gs = self.mock_service self.logout() @@ -175,18 +180,20 @@ class TestPeerGradingService(ct.PageLoader): def test_save_grade_success(self): data = { - 'rubric_scores[]': [0, 0], - 'location': self.location, - 'submission_id': 1, - 'submission_key': 'fake key', - 'score': 2, - 'feedback': 'feedback', - 'submission_flagged': 'false' - } + 'rubric_scores[]': [0, 0], + 'location': self.location, + 'submission_id': 1, + 'submission_key': 'fake key', + 'score': 2, + 'feedback': 'feedback', + 'submission_flagged': 'false' + } qdict = MagicMock() + def fake_get_item(key): return data[key] + qdict.__getitem__.side_effect = fake_get_item qdict.getlist = fake_get_item qdict.keys = data.keys @@ -237,18 +244,20 @@ class TestPeerGradingService(ct.PageLoader): def test_save_calibration_essay_success(self): data = { - 'rubric_scores[]': [0, 0], - 'location': self.location, - 'submission_id': 1, - 'submission_key': 'fake key', - 'score': 2, - 'feedback': 'feedback', - 'submission_flagged': 'false' - } + 'rubric_scores[]': [0, 0], + 'location': self.location, + 'submission_id': 1, + 'submission_key': 'fake key', + 'score': 2, + 'feedback': 'feedback', + 'submission_flagged': 'false' + } qdict = MagicMock() + def fake_get_item(key): return data[key] + qdict.__getitem__.side_effect = fake_get_item qdict.getlist = fake_get_item qdict.keys = data.keys diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py index 55e8088c3f..2e7f429429 100644 --- a/lms/djangoapps/open_ended_grading/views.py +++ b/lms/djangoapps/open_ended_grading/views.py @@ -50,22 +50,24 @@ def _reverse_without_slash(url_name, course_id): ajax_url = reverse(url_name, kwargs={'course_id': course_id}) return ajax_url + DESCRIPTION_DICT = { - 'Peer Grading': "View all problems that require peer assessment in this particular course.", - 'Staff Grading': "View ungraded submissions submitted by students for the open ended problems in the course.", - 'Problems you have submitted': "View open ended problems that you have previously submitted for grading.", - 'Flagged Submissions': "View submissions that have been flagged by students as inappropriate." - } + 'Peer Grading': "View all problems that require peer assessment in this particular course.", + 'Staff Grading': "View ungraded submissions submitted by students for the open ended problems in the course.", + 'Problems you have submitted': "View open ended problems that you have previously submitted for grading.", + 'Flagged Submissions': "View submissions that have been flagged by students as inappropriate." +} ALERT_DICT = { - 'Peer Grading': "New submissions to grade", - 'Staff Grading': "New submissions to grade", - 'Problems you have submitted': "New grades have been returned", - 'Flagged Submissions': "Submissions have been flagged for review" - } + 'Peer Grading': "New submissions to grade", + 'Staff Grading': "New submissions to grade", + 'Problems you have submitted': "New grades have been returned", + 'Flagged Submissions': "Submissions have been flagged for review" +} STUDENT_ERROR_MESSAGE = "Error occured while contacting the grading service. Please notify course staff." STAFF_ERROR_MESSAGE = "Error occured while contacting the grading service. Please notify the development team. If you do not have a point of contact, please email Vik at vik@edx.org" + @cache_control(no_cache=True, no_store=True, must_revalidate=True) def staff_grading(request, course_id): """ @@ -92,10 +94,10 @@ def peer_grading(request, course_id): #Get the current course course = get_course_with_access(request.user, course_id, 'load') course_id_parts = course.id.split("/") - false_dict = [False,"False", "false", "FALSE"] + false_dict = [False, "False", "false", "FALSE"] #Reverse the base course url - base_course_url = reverse('courses') + base_course_url = reverse('courses') try: #TODO: This will not work with multiple runs of a course. Make it work. The last key in the Location passed #to get_items is called revision. Is this the same as run? @@ -147,7 +149,7 @@ def student_problem_list(request, course_id): success = False error_text = "" problem_list = [] - base_course_url = reverse('courses') + base_course_url = reverse('courses') try: problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user)) @@ -174,7 +176,7 @@ def student_problem_list(request, course_id): except: #This is a student_facing_error eta_string = "Error getting ETA." - problem_list[i].update({'eta_string' : eta_string}) + problem_list[i].update({'eta_string': eta_string}) except GradingServiceError: #This is a student_facing_error @@ -215,7 +217,7 @@ def flagged_problem_list(request, course_id): success = False error_text = "" problem_list = [] - base_course_url = reverse('courses') + base_course_url = reverse('courses') try: problem_list_json = controller_qs.get_flagged_problem_list(course_id) @@ -243,14 +245,14 @@ def flagged_problem_list(request, course_id): ajax_url = _reverse_with_slash('open_ended_flagged_problems', course_id) context = { - 'course': course, - 'course_id': course_id, - 'ajax_url': ajax_url, - 'success': success, - 'problem_list': problem_list, - 'error_text': error_text, - # Checked above - 'staff_access': True, + 'course': course, + 'course_id': course_id, + 'ajax_url': ajax_url, + 'success': success, + 'problem_list': problem_list, + 'error_text': error_text, + # Checked above + 'staff_access': True, } return render_to_response('open_ended_problems/open_ended_flagged_problems.html', context) @@ -305,7 +307,7 @@ def combined_notifications(request, course_id): } return render_to_response('open_ended_problems/combined_notifications.html', - combined_dict + combined_dict ) @@ -318,13 +320,14 @@ def take_action_on_flags(request, course_id): if request.method != 'POST': raise Http404 - required = ['submission_id', 'action_type', 'student_id'] for key in required: if key not in request.POST: #This is a staff_facing_error - return HttpResponse(json.dumps({'success': False, 'error': STAFF_ERROR_MESSAGE + 'Missing key {0} from submission. Please reload and try again.'.format(key)}), - mimetype="application/json") + return HttpResponse(json.dumps({'success': False, + 'error': STAFF_ERROR_MESSAGE + 'Missing key {0} from submission. Please reload and try again.'.format( + key)}), + mimetype="application/json") p = request.POST submission_id = p['submission_id'] @@ -338,5 +341,7 @@ def take_action_on_flags(request, course_id): return HttpResponse(response, mimetype="application/json") except GradingServiceError: #This is a dev_facing_error - log.exception("Error taking action on flagged peer grading submissions, submission_id: {0}, action_type: {1}, grader_id: {2}".format(submission_id, action_type, grader_id)) + log.exception( + "Error taking action on flagged peer grading submissions, submission_id: {0}, action_type: {1}, grader_id: {2}".format( + submission_id, action_type, grader_id)) return _err_response(STAFF_ERROR_MESSAGE) diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py index a391b1cb32..ec34683997 100644 --- a/lms/djangoapps/staticbook/views.py +++ b/lms/djangoapps/staticbook/views.py @@ -1,7 +1,7 @@ from lxml import etree -# from django.conf import settings from django.contrib.auth.decorators import login_required +from django.http import Http404 from mitxmako.shortcuts import render_to_response from courseware.access import has_access @@ -15,6 +15,8 @@ def index(request, course_id, book_index, page=None): staff_access = has_access(request.user, course, 'staff') book_index = int(book_index) + if book_index < 0 or book_index >= len(course.textbooks): + raise Http404("Invalid book index value: {0}".format(book_index)) textbook = course.textbooks[book_index] table_of_contents = textbook.table_of_contents @@ -40,6 +42,8 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None): staff_access = has_access(request.user, course, 'staff') book_index = int(book_index) + if book_index < 0 or book_index >= len(course.pdf_textbooks): + raise Http404("Invalid book index value: {0}".format(book_index)) textbook = course.pdf_textbooks[book_index] def remap_static_url(original_url, course): @@ -57,13 +61,49 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None): # then remap all the chapter URLs as well, if they are provided. if 'chapters' in textbook: for entry in textbook['chapters']: - entry['url'] = remap_static_url(entry['url'], course) + entry['url'] = remap_static_url(entry['url'], course) return render_to_response('static_pdfbook.html', - {'book_index': book_index, - 'course': course, + {'book_index': book_index, + 'course': course, 'textbook': textbook, 'chapter': chapter, 'page': page, 'staff_access': staff_access}) + +@login_required +def html_index(request, course_id, book_index, chapter=None, anchor_id=None): + course = get_course_with_access(request.user, course_id, 'load') + staff_access = has_access(request.user, course, 'staff') + + book_index = int(book_index) + if book_index < 0 or book_index >= len(course.html_textbooks): + raise Http404("Invalid book index value: {0}".format(book_index)) + textbook = course.html_textbooks[book_index] + + def remap_static_url(original_url, course): + input_url = "'" + original_url + "'" + output_url = replace_static_urls( + input_url, + course.metadata['data_dir'], + course_namespace=course.location + ) + # strip off the quotes again... + return output_url[1:-1] + + if 'url' in textbook: + textbook['url'] = remap_static_url(textbook['url'], course) + # then remap all the chapter URLs as well, if they are provided. + if 'chapters' in textbook: + for entry in textbook['chapters']: + entry['url'] = remap_static_url(entry['url'], course) + + + return render_to_response('static_htmlbook.html', + {'book_index': book_index, + 'course': course, + 'textbook': textbook, + 'chapter': chapter, + 'anchor_id': anchor_id, + 'staff_access': staff_access}) diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py index ca607d3ff3..9fe1b4397f 100644 --- a/lms/lib/comment_client/thread.py +++ b/lms/lib/comment_client/thread.py @@ -11,12 +11,12 @@ class Thread(models.Model): 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'at_position_list', 'children', 'type', 'highlighted_title', - 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name' + 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned' ] updatable_fields = [ 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', - 'closed', 'tags', 'user_id', 'commentable_id', 'group_id', 'group_name' + 'closed', 'tags', 'user_id', 'commentable_id', 'group_id', 'group_name', 'pinned' ] initializable_fields = updatable_fields @@ -79,3 +79,23 @@ class Thread(models.Model): response = perform_request('get', url, request_params) self.update_attributes(**response) + + def pin(self, user, thread_id): + url = _url_for_pin_thread(thread_id) + params = {'user_id': user.id} + request = perform_request('put', url, params) + self.update_attributes(request) + + def un_pin(self, user, thread_id): + url = _url_for_un_pin_thread(thread_id) + params = {'user_id': user.id} + request = perform_request('put', url, params) + self.update_attributes(request) + + +def _url_for_pin_thread(thread_id): + return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id) + +def _url_for_un_pin_thread(thread_id): + return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id) + \ No newline at end of file diff --git a/lms/one_time_startup.py b/lms/one_time_startup.py new file mode 100644 index 0000000000..93428a3404 --- /dev/null +++ b/lms/one_time_startup.py @@ -0,0 +1,6 @@ +from dogapi import dog_http_api, dog_stats_api +from django.conf import settings + +if hasattr(settings, 'DATADOG_API'): + dog_http_api.api_key = settings.DATADOG_API + dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) diff --git a/lms/static/images/pinned.png b/lms/static/images/pinned.png new file mode 100644 index 0000000000..76bb207fff Binary files /dev/null and b/lms/static/images/pinned.png differ diff --git a/lms/static/images/university/delft/delft-cover.jpg b/lms/static/images/university/delft/delft-cover.jpg index 73092d7596..e69c836908 100644 Binary files a/lms/static/images/university/delft/delft-cover.jpg and b/lms/static/images/university/delft/delft-cover.jpg differ diff --git a/lms/static/images/unpinned.png b/lms/static/images/unpinned.png new file mode 100644 index 0000000000..030198f7e8 Binary files /dev/null and b/lms/static/images/unpinned.png differ diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss index e5134837fe..2f044ca5a3 100644 --- a/lms/static/sass/_discussion.scss +++ b/lms/static/sass/_discussion.scss @@ -2442,4 +2442,39 @@ body.discussion { color:#000; font-style: italic; background-color:#fff; - } \ No newline at end of file + } + +.discussion-pin { + font-size: 12px; + float:right; + padding-right: 5px; + font-style: italic; + } + +.notpinned .icon +{ + display: inline-block; + width: 10px; + height: 14px; + padding-right: 3px; + background: transparent url('../images/unpinned.png') no-repeat 0 0; +} + +.pinned .icon +{ + display: inline-block; + width: 10px; + height: 14px; + padding-right: 3px; + background: transparent url('../images/pinned.png') no-repeat 0 0; +} + +.pinned span { + color: #B82066; + font-style: italic; +} + +.notpinned span { + color: #888; + font-style: italic; +} \ No newline at end of file diff --git a/lms/static/sass/course/_textbook.scss b/lms/static/sass/course/_textbook.scss index af9c2493fd..b1f3a863b8 100644 --- a/lms/static/sass/course/_textbook.scss +++ b/lms/static/sass/course/_textbook.scss @@ -158,6 +158,19 @@ div.book-wrapper { img { max-width: 100%; } + + div { + text-align: left; + line-height: 1.6em; + margin-left: 5px; + margin-right: 5px; + margin-top: 5px; + margin-bottom: 5px; + + .Paragraph, h2 { + margin-top: 10px; + } + } } } diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html new file mode 100644 index 0000000000..f010305744 --- /dev/null +++ b/lms/templates/annotatable.html @@ -0,0 +1,29 @@ +
+
+ % if display_name is not UNDEFINED and display_name is not None: +
${display_name}
+ % endif +
+ + % if instructions_html is not UNDEFINED and instructions_html is not None: +
+
+ Instructions + Collapse Instructions +
+
+ ${instructions_html} +
+
+ % endif + +
+
+ Guided Discussion + Hide Annotations +
+
+ ${content_html} +
+
+
diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index de1ff394bd..5fdfb8aa82 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -45,6 +45,21 @@
${'<%- body %>'}
+ + % if course and has_permission(user, 'openclose_thread', course.id): +
+ Pin Thread
+ %else: + ${"<% if (pinned) { %>"} +
+ Pin Thread
+ ${"<% } %>"} + % endif + + + + + ${'<% if (obj.courseware_url) { %>'}
(this post is about ${'<%- courseware_title %>'}) diff --git a/lms/templates/static_htmlbook.html b/lms/templates/static_htmlbook.html new file mode 100644 index 0000000000..9500a379ac --- /dev/null +++ b/lms/templates/static_htmlbook.html @@ -0,0 +1,135 @@ +<%inherit file="main.html" /> +<%namespace name='static' file='static_content.html'/> +<%block name="title">${course.number} Textbook + + +<%block name="headextra"> +<%static:css group='course'/> +<%static:js group='courseware'/> + + +<%block name="js_extra"> + + + +<%include file="/courseware/course_navigation.html" args="active_page='htmltextbook/{0}'.format(book_index)" /> + +
+
+ + %if 'chapters' in textbook: +
+
    + <%def name="print_entry(entry, index_value)"> +
  • + + ${entry.get('title')} + +
  • + + + %for (index, entry) in enumerate(textbook['chapters']): + ${print_entry(entry, index+1)} + % endfor +
+
+ %endif + +
+
+
+
+
+ +
+
+ diff --git a/lms/urls.py b/lms/urls.py index 5e5ac9a7f2..2c738913c3 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -3,6 +3,9 @@ from django.conf.urls import patterns, include, url from django.contrib import admin from django.conf.urls.static import static from django.views.generic import RedirectView + +from . import one_time_startup + import django.contrib.auth.views # Uncomment the next two lines to enable the admin: @@ -224,14 +227,6 @@ if settings.COURSEWARE_ENABLED: 'courseware.module_render.modx_dispatch', name='modx_dispatch'), - # TODO (vshnayder): This is a hack. It creates a direct connection from - # the LMS to capa functionality, and really wants to go through the - # input types system so that previews can be context-specific. - # Unfortunately, we don't have time to think through the right way to do - # that (and implement it), and it's not a terrible thing to provide a - # generic chemical-equation rendering service. - url(r'^preview/chemcalc', 'courseware.module_render.preview_chemcalc', - name='preview_chemcalc'), # Software Licenses @@ -285,6 +280,15 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P[^/]*)/chapter/(?P[^/]*)/(?P[^/]*)$', 'staticbook.views.pdf_index'), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P[^/]*)/$', + 'staticbook.views.html_index', name="html_book"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P[^/]*)/chapter/(?P[^/]*)/$', + 'staticbook.views.html_index'), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P[^/]*)/chapter/(?P[^/]*)/(?P[^/]*)/$', + 'staticbook.views.html_index'), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P[^/]*)/(?P[^/]*)/$', + 'staticbook.views.html_index'), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/courseware/?$', 'courseware.views.index', name="courseware"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/courseware/(?P[^/]*)/$', diff --git a/requirements.txt b/requirements.txt index 07cd2f767b..3dc732e013 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,60 +1,61 @@ -django==1.4.3 -pip -numpy==1.6.2 -scipy==0.11.0 -Markdown==2.2.1 -pygments==1.5 -lxml==3.0.1 -boto==2.6.0 -mako==0.7.3 -python-memcached==1.48 -python-openid==2.2.5 -path.py -django_debug_toolbar -fs==0.4.0 -beautifulsoup==3.2.1 +-r repo-requirements.txt beautifulsoup4==4.1.3 -feedparser==5.1.3 -requests==0.14.2 -http://sympy.googlecode.com/files/sympy-0.7.1.tar.gz -newrelic==1.8.0.13 -glob2==0.3 -pymongo==2.4.1 -django_nose==1.1 -nosexcover==1.0.7 -rednose==0.3.3 -GitPython==0.3.2.RC1 -mock==0.8.0 -PyYAML==3.10 -South==0.7.6 -pytz==2012h +beautifulsoup==3.2.1 +boto==2.6.0 django-celery==3.0.11 django-countries==1.5 -django-kombu==0.9.4 +django-debug-toolbar-mongo django-followit==0.0.3 django-jasmine==0.3.2 django-keyedcache==1.4-6 +django-kombu==0.9.4 django-mako==0.1.5pre django-masquerade==0.1.6 +django-mptt==0.5.5 django-openid-auth==0.4 django-robots==0.9.1 +django-sekizai==0.6.1 django-ses==0.4.1 django-storages==1.1.5 django-threaded-multihost==1.4-1 -django-sekizai==0.6.1 -django-mptt==0.5.5 -sorl-thumbnail==11.12 -networkx==1.7 -pygraphviz==1.1 --r repo-requirements.txt -nltk==2.0.4 -django-debug-toolbar-mongo +django==1.4.3 +django_debug_toolbar +django_nose==1.1 +dogapi==1.2.1 dogstatsd-python==0.2.1 -MySQL-python==1.2.4c1 -sphinx==1.1.3 factory_boy -Shapely==1.2.16 +feedparser==5.1.3 +fs==0.4.0 +GitPython==0.3.2.RC1 +glob2==0.3 +http://sympy.googlecode.com/files/sympy-0.7.1.tar.gz ipython==0.13.1 -xmltodict==0.4.1 +lxml==3.0.1 +mako==0.7.3 +Markdown==2.2.1 +mock==0.8.0 +MySQL-python==1.2.4c1 +networkx==1.7 +newrelic==1.8.0.13 +nltk==2.0.4 +nosexcover==1.0.7 +numpy==1.6.2 paramiko==1.9.0 +path.py Pillow==1.7.8 +pip +pygments==1.5 +pygraphviz==1.1 +pymongo==2.4.1 +python-memcached==1.48 +python-openid==2.2.5 +pytz==2012h +PyYAML==3.10 +rednose==0.3.3 +requests==0.14.2 +scipy==0.11.0 +Shapely==1.2.16 +sorl-thumbnail==11.12 +South==0.7.6 +sphinx==1.1.3 +xmltodict==0.4.1 diff --git a/test-requirements.txt b/test-requirements.txt index 4706e239a5..6c3322acbf 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,10 +1,7 @@ -django-nose coverage -nosexcover pylint pep8 lettuce selenium -factory_boy splinter