diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cac4757218..bbaf3f3a6b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,13 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Problem rescoring. Added options on the Grades tab of the +Instructor Dashboard to allow all students' submissions for a +particular problem to be rescored. Also supports resetting all +students' number of attempts to zero. Provides a list of background +tasks that are currently running for the course, and an option to +see a history of background tasks for a given problem. + LMS: Forums. Added handling for case where discussion module can get `None` as value of lms.start in `lms/djangoapps/django_comment_client/utils.py` diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 71b5e97bc2..58b63abd23 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -39,8 +39,6 @@ def get_users_in_course_group_by_role(location, role): ''' Create all permission groups for a new course and subscribe the caller into those roles ''' - - def create_all_course_groups(creator, location): create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME) @@ -57,13 +55,11 @@ def create_new_course_group(creator, location, role): return -''' -This is to be called only by either a command line code path or through a app which has already -asserted permissions -''' - - def _delete_course_group(location): + ''' + This is to be called only by either a command line code path or through a app which has already + asserted permissions + ''' # remove all memberships instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -75,13 +71,11 @@ def _delete_course_group(location): user.groups.remove(staff) user.save() -''' -This is to be called only by either a command line code path or through an app which has already -asserted permissions to do this action -''' - - def _copy_course_group(source, dest): + ''' + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + ''' instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 92ad5a9bb5..0b7cb11d2a 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from nose.tools import assert_true @@ -20,7 +20,7 @@ COURSE_ORG = 'MITx' ########### STEP HELPERS ############## @step('I (?:visit|access|open) the Studio homepage$') -def i_visit_the_studio_homepage(step): +def i_visit_the_studio_homepage(_step): # To make this go to port 8001, put # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. @@ -30,17 +30,17 @@ def i_visit_the_studio_homepage(step): @step('I am logged into Studio$') -def i_am_logged_into_studio(step): +def i_am_logged_into_studio(_step): log_into_studio() @step('I confirm the alert$') -def i_confirm_with_ok(step): +def i_confirm_with_ok(_step): world.browser.get_alert().accept() @step(u'I press the "([^"]*)" delete icon$') -def i_press_the_category_delete_icon(step, category): +def i_press_the_category_delete_icon(_step, category): if category == 'section': css = 'a.delete-button.delete-section-button span.delete-icon' elif category == 'subsection': @@ -51,7 +51,7 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') -def i_have_opened_a_new_course(step): +def i_have_opened_a_new_course(_step): open_new_course() @@ -78,7 +78,6 @@ def create_studio_user( registration.register(studio_user) registration.activate() - def fill_in_course_info( name=COURSE_NAME, org=COURSE_ORG, @@ -149,6 +148,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): world.css_fill(date_css, desired_date) # hit TAB to get to the time field e = world.css_find(date_css).first + # pylint: disable=W0212 e._element.send_keys(Keys.TAB) world.css_fill(time_css, desired_time) e = world.css_find(time_css).first diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 9d63fa73c8..989c73e010 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from common import * @@ -8,7 +8,7 @@ from nose.tools import assert_equal ############### ACTIONS #################### -@step('I click the new section link$') +@step('I click the New Section link$') def i_click_new_section_link(_step): link_css = 'a.new-courseware-section-button' world.css_click(link_css) diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 3aca2aee92..1fbd965871 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from common import * diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index fd8624999e..c48b36a5aa 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -6,13 +6,13 @@ from lettuce import world, step @step('when I view the video it does not have autoplay enabled') -def does_not_autoplay(step): +def does_not_autoplay(_step): assert world.css_find('.video')[0]['data-autoplay'] == 'False' assert world.css_find('.video_control')[0].has_class('play') @step('creating a video takes a single click') -def video_takes_a_single_click(step): +def video_takes_a_single_click(_step): assert(not world.is_css_present('.xmodule_VideoModule')) world.css_click("a[data-location='i4x://edx/templates/video/default']") assert(world.is_css_present('.xmodule_VideoModule')) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index f7d1bbd8fe..e361c97875 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -39,10 +39,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= def set_module_info(store, location, post_data): module = None try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) + module = store.get_item(location) except: pass diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 54bc726092..0e5cd9b884 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -99,6 +99,7 @@ class ChecklistTestCase(CourseTestCase): 'name': self.course.location.name, 'checklist_index': 2}) + def get_first_item(checklist): return checklist['items'][0] diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 9346d2189d..d24deacecf 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -132,7 +132,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # just pick one vertical descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] - location = descriptor.location._replace(name='.' + descriptor.location.name) + location = descriptor.location.replace(name='.' + descriptor.location.name) resp = self.client.get(reverse('edit_unit', kwargs={'location': location.url()})) self.assertEqual(resp.status_code, 400) @@ -224,7 +224,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): draft_store.clone_item(html_module.location, html_module.location) html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) - new_graceperiod = timedelta(**{'hours': 1}) + new_graceperiod = timedelta(hours=1) self.assertNotIn('graceperiod', own_metadata(html_module)) html_module.lms.graceperiod = new_graceperiod @@ -369,7 +369,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ''' module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['full']) - effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) self.assertEqual(effort.data, '6 hours') @@ -617,12 +616,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 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=''): + def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''): filesystem = OSFS(root_dir / 'test_export') self.assertTrue(filesystem.exists(dirname)) query_loc = Location('i4x', location.org, location.course, category_name, None) - items = modulestore.get_items(query_loc) + items = store.get_items(query_loc) for item in items: filesystem = OSFS(root_dir / ('test_export/' + dirname)) @@ -768,7 +767,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_prefetch_children(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['full']) - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') wrapper = MongoCollectionFindWrapper(module_store.collection.find) @@ -864,7 +862,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_duplicate_course(self): """Test new course creation - error path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) + self.client.post(reverse('create_new_course'), self.course_data) resp = self.client.post(reverse('create_new_course'), self.course_data) data = parse_json(resp) self.assertEqual(resp.status_code, 200) @@ -872,7 +870,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_duplicate_number(self): """Test new course creation - error path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) + self.client.post(reverse('create_new_course'), self.course_data) self.course_data['display_name'] = 'Robot Super Course Two' resp = self.client.post(reverse('create_new_course'), self.course_data) @@ -1090,11 +1088,9 @@ class ContentStoreTest(ModuleStoreTestCase): json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) - def test_import_metadata_with_attempts_empty_string(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['simple']) - did_load_item = False try: module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 6f766ff7f5..0bfa70e4f5 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -224,14 +224,14 @@ def add_extra_panel_tab(tab_type, course): @param course: A course object from the modulestore. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course. """ - #Copy course tabs + # Copy course tabs course_tabs = copy.copy(course.tabs) changed = False - #Check to see if open ended panel is defined in the course + # Check to see if open ended panel is defined in the course tab_panel = EXTRA_TAB_PANELS.get(tab_type) if tab_panel not in course_tabs: - #Add panel to the tabs if it is not defined + # Add panel to the tabs if it is not defined course_tabs.append(tab_panel) changed = True return changed, course_tabs @@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course): @param course: A course object from the modulestore. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course. """ - #Copy course tabs + # Copy course tabs course_tabs = copy.copy(course.tabs) changed = False - #Check to see if open ended panel is defined in the course + # Check to see if open ended panel is defined in the course tab_panel = EXTRA_TAB_PANELS.get(tab_type) if tab_panel in course_tabs: - #Add panel to the tabs if it is not defined + # Add panel to the tabs if it is not defined course_tabs = [ct for ct in course_tabs if ct != tab_panel] changed = True return changed, course_tabs diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 8762eb3a2a..8a29a637b8 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -12,8 +12,8 @@ from django.core.urlresolvers import reverse from mitxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore - -from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError +from xmodule.modulestore.exceptions import ItemNotFoundError, \ + InvalidLocationError from xmodule.modulestore import Location from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update @@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \ from django_comment_common.utils import seed_permissions_roles import datetime from django.utils.timezone import UTC - -# TODO: should explicitly enumerate exports with __all__ - __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', 'course_config_graders_page', diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 25094ddcfe..abc5f48564 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -103,7 +103,7 @@ def clone_item(request): @expect_json def delete_item(request): item_location = request.POST['id'] - item_loc = Location(item_location) + item_location = Location(item_location) # check permissions for this user within this course if not has_access(request.user, item_location): @@ -124,11 +124,11 @@ def delete_item(request): # 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) + parent_locs = modulestore('direct').get_parent_locations(item_location, None) for parent_loc in parent_locs: parent = modulestore('direct').get_item(parent_loc) - item_url = item_loc.url() + item_url = item_location.url() if item_url in parent.children: children = parent.children children.remove(item_url) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 07eb4bc309..3f0c87917a 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -41,25 +41,25 @@ class CourseDetails(object): course.enrollment_start = descriptor.enrollment_start course.enrollment_end = descriptor.enrollment_end - temploc = course_location._replace(category='about', name='syllabus') + temploc = course_location.replace(category='about', name='syllabus') try: course.syllabus = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='overview') + temploc = temploc.replace(name='overview') try: course.overview = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='effort') + temploc = temploc.replace(name='effort') try: course.effort = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='video') + temploc = temploc.replace(name='video') try: raw_video = get_modulestore(temploc).get_item(temploc).data course.intro_video = CourseDetails.parse_video_tag(raw_video) @@ -126,16 +126,16 @@ class CourseDetails(object): # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # to make faster, could compare against db or could have client send over a list of which fields changed. - temploc = Location(course_location)._replace(category='about', name='syllabus') + temploc = Location(course_location).replace(category='about', name='syllabus') update_item(temploc, jsondict['syllabus']) - temploc = temploc._replace(name='overview') + temploc = temploc.replace(name='overview') update_item(temploc, jsondict['overview']) - temploc = temploc._replace(name='effort') + temploc = temploc.replace(name='effort') update_item(temploc, jsondict['effort']) - temploc = temploc._replace(name='video') + temploc = temploc.replace(name='video') recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) update_item(temploc, recomposed_video_tag) @@ -174,10 +174,10 @@ class CourseDetails(object): return result -# TODO move to a more general util? Is there a better way to do the isinstance model check? +# TODO move to a more general util? class CourseSettingsEncoder(json.JSONEncoder): def default(self, obj): - if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel): + if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)): return obj.__dict__ elif isinstance(obj, Location): return obj.dict() diff --git a/cms/envs/common.py b/cms/envs/common.py index 8551a56c41..d7c9e6bb90 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -235,8 +235,7 @@ PIPELINE_JS = { 'source_filenames': sorted( rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') + rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js') - ) + ['js/hesitate.js', 'js/base.js', - 'js/models/feedback.js', 'js/views/feedback.js', + ) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js', 'js/models/section.js', 'js/views/section.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', 'js/views/assets.js'], diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index 73dfc565a2..5b4d829b3a 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -7,6 +7,7 @@ "js/vendor/jquery.cookie.js", "js/vendor/json2.js", "js/vendor/underscore-min.js", + "js/vendor/underscore.string.min.js", "js/vendor/backbone-min.js", "js/vendor/jquery.leanModal.min.js", "js/vendor/sinon-1.7.1.js", diff --git a/cms/static/coffee/spec/models/feedback_spec.coffee b/cms/static/coffee/spec/models/feedback_spec.coffee deleted file mode 100644 index 6ddac41ebf..0000000000 --- a/cms/static/coffee/spec/models/feedback_spec.coffee +++ /dev/null @@ -1,34 +0,0 @@ -describe "CMS.Models.SystemFeedback", -> - beforeEach -> - @model = new CMS.Models.SystemFeedback() - - it "should have an empty message by default", -> - expect(@model.get("message")).toEqual("") - - it "should have an empty title by default", -> - expect(@model.get("title")).toEqual("") - - it "should not have an intent set by default", -> - expect(@model.get("intent")).toBeNull() - - -describe "CMS.Models.WarningMessage", -> - beforeEach -> - @model = new CMS.Models.WarningMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("warning") - -describe "CMS.Models.ErrorMessage", -> - beforeEach -> - @model = new CMS.Models.ErrorMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("error") - -describe "CMS.Models.ConfirmationMessage", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("confirmation") diff --git a/cms/static/coffee/spec/views/feedback_spec.coffee b/cms/static/coffee/spec/views/feedback_spec.coffee index 3e7d080a7c..a3950c0b3c 100644 --- a/cms/static/coffee/spec/views/feedback_spec.coffee +++ b/cms/static/coffee/spec/views/feedback_spec.coffee @@ -18,79 +18,105 @@ beforeEach -> else return trimmedText.indexOf(text) != -1; -describe "CMS.Views.Alert as base class", -> +describe "CMS.Views.SystemFeedback", -> beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ + @options = title: "Portal" message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) # it will be interesting to see when this.render is called, so lets spy on it - spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough() + @renderSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'render').andCallThrough() + @showSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'show').andCallThrough() + @hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough() - it "renders on initalize", -> - view = new CMS.Views.Alert({model: @model}) - expect(view.render).toHaveBeenCalled() + it "requires a type and an intent", -> + neither = => + new CMS.Views.SystemFeedback(@options) + noType = => + options = $.extend({}, @options) + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + noIntent = => + options = $.extend({}, @options) + options.type = "alert" + new CMS.Views.SystemFeedback(options) + both = => + options = $.extend({}, @options) + options.type = "alert" + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + + expect(neither).toThrow() + expect(noType).toThrow() + expect(noIntent).toThrow() + expect(both).not.toThrow() + + # for simplicity, we'll use CMS.Views.Alert.Confirmation from here on, + # which extends and proxies to CMS.Views.SystemFeedback + + it "does not show on initalize", -> + view = new CMS.Views.Alert.Confirmation(@options) + expect(@renderSpy).not.toHaveBeenCalled() + expect(@showSpy).not.toHaveBeenCalled() it "renders the template", -> - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options) + view.show() + expect(view.$(".action-close")).toBeDefined() expect(view.$('.wrapper')).toBeShown() - expect(view.$el).toContainText(@model.get("title")) - expect(view.$el).toContainText(@model.get("message")) + expect(view.$el).toContainText(@options.title) + expect(view.$el).toContainText(@options.message) it "close button sends a .hide() message", -> - spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough() - - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options).show() view.$(".action-close").click() - expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled() + expect(@hideSpy).toHaveBeenCalled() expect(view.$('.wrapper')).toBeHiding() describe "CMS.Views.Prompt", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ - title: "Portal" - message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) - # for some reason, expect($("body")) blows up the test runner, so this test # just exercises the Prompt rather than asserting on anything. Best I can # do for now. :( it "changes class on body", -> # expect($("body")).not.toHaveClass("prompt-is-shown") - view = new CMS.Views.Prompt({model: @model}) + view = new CMS.Views.Prompt.Confirmation({ + title: "Portal" + message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" + }) # expect($("body")).toHaveClass("prompt-is-shown") view.hide() # expect($("body")).not.toHaveClass("prompt-is-shown") -describe "CMS.Views.Alert click events", -> +describe "CMS.Views.SystemFeedback click events", -> beforeEach -> - @model = new CMS.Models.WarningMessage( + @primaryClickSpy = jasmine.createSpy('primaryClick') + @secondaryClickSpy = jasmine.createSpy('secondaryClick') + @view = new CMS.Views.Notification.Warning( title: "Unsaved", message: "Your content is currently Unsaved.", actions: primary: text: "Save", class: "save-button", - click: jasmine.createSpy('primaryClick') + click: @primaryClickSpy secondary: [{ text: "Revert", class: "cancel-button", - click: jasmine.createSpy('secondaryClick') + click: @secondaryClickSpy }] - ) - - @view = new CMS.Views.Alert({model: @model}) + @view.show() it "should trigger the primary event on a primary click", -> - @view.primaryClick() - expect(@model.get('actions').primary.click).toHaveBeenCalled() + @view.$(".action-primary").click() + expect(@primaryClickSpy).toHaveBeenCalled() + expect(@secondaryClickSpy).not.toHaveBeenCalled() it "should trigger the secondary event on a secondary click", -> - @view.secondaryClick() - expect(@model.get('actions').secondary[0].click).toHaveBeenCalled() + @view.$(".action-secondary").click() + expect(@secondaryClickSpy).toHaveBeenCalled() + expect(@primaryClickSpy).not.toHaveBeenCalled() it "should apply class to primary action", -> expect(@view.$(".action-primary")).toHaveClass("save-button") @@ -100,20 +126,18 @@ describe "CMS.Views.Alert click events", -> describe "CMS.Views.Notification minShown and maxShown", -> beforeEach -> - @model = new CMS.Models.SystemFeedback( - intent: "saving" - title: "Saving" - ) - spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough() - spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough() + @showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show') + @showSpy.andCallThrough() + @hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide') + @hideSpy.andCallThrough() @clock = sinon.useFakeTimers() afterEach -> @clock.restore() it "a minShown view should not hide too quickly", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # call hide() on it, but the minShown should prevent it from hiding right away @@ -125,8 +149,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view should hide by itself", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the maxShown timeout to expire, and check again @@ -134,13 +158,13 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a minShown view can stay visible longer", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the minShown timeout to expire, and check again @clock.tick(1001) - expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled() + expect(@hideSpy).not.toHaveBeenCalled() expect(view.$('.wrapper')).toBeShown() # can now hide immediately @@ -148,8 +172,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view can hide early", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait 50 milliseconds, and hide it early @@ -162,7 +186,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a view can have both maxShown and minShown", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000}) + view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000}) + view.show() # can't hide early @clock.tick(50) diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index efcd869113..8043b41638 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -18,11 +18,15 @@ $ -> $(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) -> if ajaxSettings.notifyOnError is false return - msg = new CMS.Models.ErrorMessage( + if jqXHR.responseText + message = _.str.truncate(jqXHR.responseText, 300) + else + message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") + msg = new CMS.Views.Notification.Error( "title": gettext("Studio's having trouble saving your work") - "message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") + "message": message ) - new CMS.Views.Notification({model: msg}) + msg.show() window.onTouchBasedDevice = -> navigator.userAgent.match /iPhone|iPod|iPad/i diff --git a/cms/static/js/models/feedback.js b/cms/static/js/models/feedback.js deleted file mode 100644 index d57cffa779..0000000000 --- a/cms/static/js/models/feedback.js +++ /dev/null @@ -1,55 +0,0 @@ -CMS.Models.SystemFeedback = Backbone.Model.extend({ - defaults: { - "intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc - "title": "", - "message": "" - /* could also have an "actions" hash: here is an example demonstrating - the expected structure - "actions": { - "primary": { - "text": "Save", - "class": "action-save", - "click": function() { - // do something when Save is clicked - // `this` refers to the model - } - }, - "secondary": [ - { - "text": "Cancel", - "class": "action-cancel", - "click": function() {} - }, { - "text": "Discard Changes", - "class": "action-discard", - "click": function() {} - } - ] - } - */ - } -}); - -CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "warning" - }) -}); - -CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "error" - }) -}); - -CMS.Models.ConfirmAssetDeleteMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "warning" - }) -}); - -CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "confirmation" - }) -}); diff --git a/cms/static/js/models/section.js b/cms/static/js/models/section.js index 467a2709a6..902585c58c 100644 --- a/cms/static/js/models/section.js +++ b/cms/static/js/models/section.js @@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({ }, showNotification: function() { if(!this.msg) { - this.msg = new CMS.Models.SystemFeedback({ - intent: "saving", - title: gettext("Saving…") - }); - } - if(!this.msgView) { - this.msgView = new CMS.Views.Notification({ - model: this.msg, + this.msg = new CMS.Views.Notification.Saving({ + title: gettext("Saving…"), closeIcon: false, minShown: 1250 }); } - this.msgView.show(); + this.msg.show(); }, hideNotification: function() { - if(!this.msgView) { return; } - this.msgView.hide(); + if(!this.msg) { return; } + this.msg.hide(); } }); diff --git a/cms/static/js/views/feedback.js b/cms/static/js/views/feedback.js index 1a1a33ec1b..b04fb6e3d1 100644 --- a/cms/static/js/views/feedback.js +++ b/cms/static/js/views/feedback.js @@ -1,39 +1,64 @@ -CMS.Views.Alert = Backbone.View.extend({ +CMS.Views.SystemFeedback = Backbone.View.extend({ options: { - type: "alert", + title: "", + message: "", + intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc + type: null, // "alert", "notification", or "prompt": set by subclass shown: true, // is this view currently being shown? icon: true, // should we render an icon related to the message intent? closeIcon: true, // should we render a close button in the top right corner? minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds) maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds) + + /* could also have an "actions" hash: here is an example demonstrating + the expected structure + actions: { + primary: { + "text": "Save", + "class": "action-save", + "click": function(view) { + // do something when Save is clicked + } + }, + secondary: [ + { + "text": "Cancel", + "class": "action-cancel", + "click": function(view) {} + }, { + "text": "Discard Changes", + "class": "action-discard", + "click": function(view) {} + } + ] + } + */ }, initialize: function() { + if(!this.options.type) { + throw "SystemFeedback: type required (given " + + JSON.stringify(this.options) + ")"; + } + if(!this.options.intent) { + throw "SystemFeedback: intent required (given " + + JSON.stringify(this.options) + ")"; + } var tpl = $("#system-feedback-tpl").text(); if(!tpl) { console.error("Couldn't load system-feedback template"); } this.template = _.template(tpl); this.setElement($("#page-"+this.options.type)); - this.listenTo(this.model, 'change', this.render); - return this.show(); - }, - render: function() { - var attrs = $.extend({}, this.options, this.model.attributes); - this.$el.html(this.template(attrs)); return this; }, - events: { - "click .action-close": "hide", - "click .action-primary": "primaryClick", - "click .action-secondary": "secondaryClick" - }, + // public API: show() and hide() show: function() { clearTimeout(this.hideTimeout); this.options.shown = true; this.shownAt = new Date(); this.render(); if($.isNumeric(this.options.maxShown)) { - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.maxShown); } return this; @@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({ this.options.minShown > new Date() - this.shownAt) { clearTimeout(this.hideTimeout); - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.minShown - (new Date() - this.shownAt)); } else { this.options.shown = false; @@ -52,40 +77,63 @@ CMS.Views.Alert = Backbone.View.extend({ } return this; }, - primaryClick: function() { - var actions = this.model.get("actions"); + // the rest of the API should be considered semi-private + events: { + "click .action-close": "hide", + "click .action-primary": "primaryClick", + "click .action-secondary": "secondaryClick" + }, + render: function() { + // there can be only one active view of a given type at a time: only + // one alert, only one notification, only one prompt. Therefore, we'll + // use a singleton approach. + var parent = CMS.Views[_.str.capitalize(this.options.type)]; + if(parent && parent.active && parent.active !== this) { + parent.active.stopListening(); + } + this.$el.html(this.template(this.options)); + parent.active = this; + return this; + }, + primaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var primary = actions.primary; if(!primary) { return; } if(primary.click) { - primary.click.call(this.model, this); + primary.click.call(event.target, this, event); } }, - secondaryClick: function(e) { - var actions = this.model.get("actions"); + secondaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var secondaryList = actions.secondary; if(!secondaryList) { return; } // which secondary action was clicked? var i = 0; // default to the first secondary action (easier for testing) - if(e && e.target) { - i = _.indexOf(this.$(".action-secondary"), e.target); + if(event && event.target) { + i = _.indexOf(this.$(".action-secondary"), event.target); } - var secondary = this.model.get("actions").secondary[i]; + var secondary = secondaryList[i]; if(secondary.click) { - secondary.click.call(this.model, this); + secondary.click.call(event.target, this, event); } } }); -CMS.Views.Notification = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Alert = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { + type: "alert" + }) +}); +CMS.Views.Notification = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "notification", closeIcon: false }) }); -CMS.Views.Prompt = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "prompt", closeIcon: false, icon: false @@ -98,6 +146,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({ $body.removeClass('prompt-is-shown'); } // super() in Javascript has awkward syntax :( - return CMS.Views.Alert.prototype.render.apply(this, arguments); + return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments); } }); + +// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation, +// CMS.Views.Prompt.StepRequired, etc +var capitalCamel, types, intents; +capitalCamel = _.compose(_.str.capitalize, _.str.camelize); +types = ["alert", "notification", "prompt"]; +intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"]; +_.each(types, function(type) { + _.each(intents, function(intent) { + // "class" is a reserved word in Javascript, so use "klass" instead + var klass, subklass; + klass = CMS.Views[capitalCamel(type)]; + subklass = klass.extend({ + options: $.extend({}, klass.prototype.options, { + type: type, + intent: intent + }) + }); + klass[capitalCamel(intent)] = subklass; + }); +}); diff --git a/cms/static/js/views/section.js b/cms/static/js/views/section.js index 622249414d..eccc547a06 100644 --- a/cms/static/js/views/section.js +++ b/cms/static/js/views/section.js @@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({ showInvalidMessage: function(model, error, options) { model.set("name", model.previous("name")); var that = this; - var msg = new CMS.Models.ErrorMessage({ + var prompt = new CMS.Views.Prompt.Error({ title: gettext("Your change could not be saved"), message: error, actions: { @@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({ } } }); - new CMS.Views.Prompt({model: msg}); + prompt.show(); } }); diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index e8dc523ba7..0006d29d38 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -8,7 +8,6 @@ <%block name="jsextra"> - + @@ -54,7 +55,6 @@ - diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index b843d47934..34c0154449 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from .factories import * diff --git a/common/djangoapps/track/views.py b/common/djangoapps/track/views.py index b2935a6a89..221bab5468 100644 --- a/common/djangoapps/track/views.py +++ b/common/djangoapps/track/views.py @@ -1,13 +1,11 @@ import json import logging -import os import pytz import datetime import dateutil.parser from django.contrib.auth.decorators import login_required from django.http import HttpResponse -from django.http import Http404 from django.shortcuts import redirect from django.conf import settings from mitxmako.shortcuts import render_to_response @@ -22,6 +20,7 @@ LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', ' def log_event(event): + """Write tracking event to log file, and optionally to TrackingLog model.""" event_str = json.dumps(event) log.info(event_str[:settings.TRACK_MAX_EVENT]) if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'): @@ -34,6 +33,11 @@ def log_event(event): def user_track(request): + """ + Log when GET call to "event" URL is made by a user. + + GET call should provide "event_type", "event", and "page" arguments. + """ try: # TODO: Do the same for many of the optional META parameters username = request.user.username except: @@ -50,7 +54,6 @@ def user_track(request): except: agent = '' - # TODO: Move a bunch of this into log_event event = { "username": username, "session": scookie, @@ -68,6 +71,7 @@ def user_track(request): def server_track(request, event_type, event, page=None): + """Log events related to server requests.""" try: username = request.user.username except: @@ -95,9 +99,52 @@ def server_track(request, event_type, event, page=None): log_event(event) +def task_track(request_info, task_info, event_type, event, page=None): + """ + Logs tracking information for events occuring within celery tasks. + + The `event_type` is a string naming the particular event being logged, + while `event` is a dict containing whatever additional contextual information + is desired. + + The `request_info` is a dict containing information about the original + task request. Relevant keys are `username`, `ip`, `agent`, and `host`. + While the dict is required, the values in it are not, so that {} can be + passed in. + + In addition, a `task_info` dict provides more information about the current + task, to be stored with the `event` dict. This may also be an empty dict. + + The `page` parameter is optional, and allows the name of the page to + be provided. + """ + + # supplement event information with additional information + # about the task in which it is running. + full_event = dict(event, **task_info) + + # All fields must be specified, in case the tracking information is + # also saved to the TrackingLog model. Get values from the task-level + # information, or just add placeholder values. + event = { + "username": request_info.get('username', 'unknown'), + "ip": request_info.get('ip', 'unknown'), + "event_source": "task", + "event_type": event_type, + "event": full_event, + "agent": request_info.get('agent', 'unknown'), + "page": page, + "time": datetime.datetime.utcnow().isoformat(), + "host": request_info.get('host', 'unknown') + } + + log_event(event) + + @login_required @ensure_csrf_cookie def view_tracking_log(request, args=''): + """View to output contents of TrackingLog model. For staff use only.""" if not request.user.is_staff: return redirect('/') nlen = 100 diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 570b38c942..7a74e75591 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -1,4 +1,3 @@ -import re import json import logging import static_replace diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 7dcd7b925e..2a9f3d82a3 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -15,25 +15,22 @@ This is used by capa_module. from datetime import datetime import logging -import math -import numpy import os.path import re -import sys from lxml import etree from xml.sax.saxutils import unescape from copy import deepcopy -from .correctmap import CorrectMap -import inputtypes -import customrender -from .util import contextualize_text, convert_files_to_filenames -import xqueue_interface +from capa.correctmap import CorrectMap +import capa.inputtypes as inputtypes +import capa.customrender as customrender +from capa.util import contextualize_text, convert_files_to_filenames +import capa.xqueue_interface as xqueue_interface # to be replaced with auto-registering -import responsetypes -import safe_exec +import capa.responsetypes as responsetypes +from capa.safe_exec import safe_exec # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) @@ -46,8 +43,8 @@ response_properties = ["codeparam", "responseparam", "answer", "openendedparam"] # special problem tags which should be turned into innocuous HTML html_transforms = {'problem': {'tag': 'div'}, - "text": {'tag': 'span'}, - "math": {'tag': 'span'}, + 'text': {'tag': 'span'}, + 'math': {'tag': 'span'}, } # These should be removed from HTML output, including all subelements @@ -134,7 +131,6 @@ class LoncapaProblem(object): self.extracted_tree = self._extract_html(self.tree) - def do_reset(self): ''' Reset internal state to unfinished, with no answers @@ -175,7 +171,7 @@ class LoncapaProblem(object): Return the maximum score for this problem. ''' maxscore = 0 - for response, responder in self.responders.iteritems(): + for responder in self.responders.values(): maxscore += responder.get_max_score() return maxscore @@ -220,7 +216,7 @@ class LoncapaProblem(object): def ungraded_response(self, xqueue_msg, queuekey): ''' Handle any responses from the xqueue that do not contain grades - Will try to pass the queue message to all inputtypes that can handle ungraded responses + Will try to pass the queue message to all inputtypes that can handle ungraded responses Does not return any value ''' @@ -230,7 +226,6 @@ class LoncapaProblem(object): if hasattr(the_input, 'ungraded_response'): the_input.ungraded_response(xqueue_msg, queuekey) - def is_queued(self): ''' Returns True if any part of the problem has been submitted to an external queue @@ -238,7 +233,6 @@ class LoncapaProblem(object): ''' return any(self.correct_map.is_queued(answer_id) for answer_id in self.correct_map) - def get_recentmost_queuetime(self): ''' Returns a DateTime object that represents the timestamp of the most recent @@ -256,11 +250,11 @@ class LoncapaProblem(object): return max(queuetimes) - def grade_answers(self, answers): ''' Grade student responses. Called by capa_module.check_problem. - answers is a dict of all the entries from request.POST, but with the first part + + `answers` is a dict of all the entries from request.POST, but with the first part of each key removed (the string before the first "_"). Thus, for example, input_ID123 -> ID123, and input_fromjs_ID123 -> fromjs_ID123 @@ -270,24 +264,72 @@ class LoncapaProblem(object): # if answers include File objects, convert them to filenames. self.student_answers = convert_files_to_filenames(answers) + return self._grade_answers(answers) + def supports_rescoring(self): + """ + Checks that the current problem definition permits rescoring. + + More precisely, it checks that there are no response types in + the current problem that are not fully supported (yet) for rescoring. + + This includes responsetypes for which the student's answer + is not properly stored in state, i.e. file submissions. At present, + we have no way to know if an existing response was actually a real + answer or merely the filename of a file submitted as an answer. + + It turns out that because rescoring is a background task, limiting + it to responsetypes that don't support file submissions also means + that the responsetypes are synchronous. This is convenient as it + permits rescoring to be complete when the rescoring call returns. + """ + return all('filesubmission' not in responder.allowed_inputfields for responder in self.responders.values()) + + def rescore_existing_answers(self): + """ + Rescore student responses. Called by capa_module.rescore_problem. + """ + return self._grade_answers(None) + + def _grade_answers(self, student_answers): + """ + Internal grading call used for checking new 'student_answers' and also + rescoring existing student_answers. + + For new student_answers being graded, `student_answers` is a dict of all the + entries from request.POST, but with the first part of each key removed + (the string before the first "_"). Thus, for example, + input_ID123 -> ID123, and input_fromjs_ID123 -> fromjs_ID123. + + For rescoring, `student_answers` is None. + + Calls the Response for each question in this problem, to do the actual grading. + """ # old CorrectMap oldcmap = self.correct_map # start new with empty CorrectMap newcmap = CorrectMap() - # log.debug('Responders: %s' % self.responders) + # Call each responsetype instance to do actual grading for responder in self.responders.values(): - # File objects are passed only if responsetype explicitly allows for file - # submissions - if 'filesubmission' in responder.allowed_inputfields: - results = responder.evaluate_answers(answers, oldcmap) + # File objects are passed only if responsetype explicitly allows + # for file submissions. But we have no way of knowing if + # student_answers contains a proper answer or the filename of + # an earlier submission, so for now skip these entirely. + # TODO: figure out where to get file submissions when rescoring. + if 'filesubmission' in responder.allowed_inputfields and student_answers is None: + raise Exception("Cannot rescore problems with possible file submissions") + + # use 'student_answers' only if it is provided, and if it might contain a file + # submission that would not exist in the persisted "student_answers". + if 'filesubmission' in responder.allowed_inputfields and student_answers is not None: + results = responder.evaluate_answers(student_answers, oldcmap) else: - results = responder.evaluate_answers(convert_files_to_filenames(answers), oldcmap) + results = responder.evaluate_answers(self.student_answers, oldcmap) newcmap.update(results) + self.correct_map = newcmap - # log.debug('%s: in grade_answers, answers=%s, cmap=%s' % (self,answers,newcmap)) return newcmap def get_question_answers(self): @@ -331,7 +373,6 @@ class LoncapaProblem(object): 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 @@ -348,8 +389,6 @@ class LoncapaProblem(object): log.warning("Could not find matching input for id: %s" % input_id) return {} - - # ======= Private Methods Below ======== def _process_includes(self): @@ -359,16 +398,16 @@ class LoncapaProblem(object): ''' includes = self.tree.findall('.//include') for inc in includes: - file = inc.get('file') - if file is not None: + filename = inc.get('file') + if filename is not None: try: # open using ModuleSystem OSFS filestore - ifp = self.system.filestore.open(file) + ifp = self.system.filestore.open(filename) except Exception as err: log.warning('Error %s in problem xml include: %s' % ( err, etree.tostring(inc, pretty_print=True))) log.warning('Cannot find file %s in %s' % ( - file, self.system.filestore)) + filename, self.system.filestore)) # if debugging, don't fail - just log error # TODO (vshnayder): need real error handling, display to users if not self.system.get('DEBUG'): @@ -381,7 +420,7 @@ class LoncapaProblem(object): except Exception as err: log.warning('Error %s in problem xml include: %s' % ( err, etree.tostring(inc, pretty_print=True))) - log.warning('Cannot parse XML in %s' % (file)) + log.warning('Cannot parse XML in %s' % (filename)) # if debugging, don't fail - just log error # TODO (vshnayder): same as above if not self.system.get('DEBUG'): @@ -389,11 +428,11 @@ class LoncapaProblem(object): else: continue - # insert new XML into tree in place of inlcude + # insert new XML into tree in place of include parent = inc.getparent() parent.insert(parent.index(inc), incxml) parent.remove(inc) - log.debug('Included %s into %s' % (file, self.problem_id)) + log.debug('Included %s into %s' % (filename, self.problem_id)) def _extract_system_path(self, script): """ @@ -463,7 +502,7 @@ class LoncapaProblem(object): if all_code: try: - safe_exec.safe_exec( + safe_exec( all_code, context, random_seed=self.seed, @@ -519,18 +558,18 @@ class LoncapaProblem(object): value = "" if self.student_answers and problemid in self.student_answers: value = self.student_answers[problemid] - + if input_id not in self.input_state: self.input_state[input_id] = {} - + # do the rendering state = {'value': value, - 'status': status, - 'id': input_id, - 'input_state': self.input_state[input_id], - 'feedback': {'message': msg, - 'hint': hint, - 'hintmode': hintmode, }} + 'status': status, + 'id': input_id, + 'input_state': self.input_state[input_id], + 'feedback': {'message': msg, + 'hint': hint, + 'hintmode': hintmode, }} input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) # save the input type so that we can make ajax calls on it if we need to @@ -554,7 +593,7 @@ class LoncapaProblem(object): for item in problemtree: item_xhtml = self._extract_html(item) if item_xhtml is not None: - tree.append(item_xhtml) + tree.append(item_xhtml) if tree.tag in html_transforms: tree.tag = html_transforms[problemtree.tag]['tag'] diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 20de19f567..68be54b6af 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -4,7 +4,6 @@ Tests of responsetypes from datetime import datetime import json -from nose.plugins.skip import SkipTest import os import random import unittest @@ -56,9 +55,18 @@ class ResponseTest(unittest.TestCase): self.assertEqual(result, 'incorrect', msg="%s should be marked incorrect" % str(input_str)) + def _get_random_number_code(self): + """Returns code to be used to generate a random result.""" + return "str(random.randint(0, 1e9))" + + def _get_random_number_result(self, seed_value): + """Returns a result that should be generated using the random_number_code.""" + rand = random.Random(seed_value) + return str(rand.randint(0, 1e9)) + class MultiChoiceResponseTest(ResponseTest): - from response_xml_factory import MultipleChoiceResponseXMLFactory + from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory xml_factory_class = MultipleChoiceResponseXMLFactory def test_multiple_choice_grade(self): @@ -80,7 +88,7 @@ class MultiChoiceResponseTest(ResponseTest): class TrueFalseResponseTest(ResponseTest): - from response_xml_factory import TrueFalseResponseXMLFactory + from capa.tests.response_xml_factory import TrueFalseResponseXMLFactory xml_factory_class = TrueFalseResponseXMLFactory def test_true_false_grade(self): @@ -120,7 +128,7 @@ class TrueFalseResponseTest(ResponseTest): class ImageResponseTest(ResponseTest): - from response_xml_factory import ImageResponseXMLFactory + from capa.tests.response_xml_factory import ImageResponseXMLFactory xml_factory_class = ImageResponseXMLFactory def test_rectangle_grade(self): @@ -184,7 +192,7 @@ class ImageResponseTest(ResponseTest): class SymbolicResponseTest(ResponseTest): - from response_xml_factory import SymbolicResponseXMLFactory + from capa.tests.response_xml_factory import SymbolicResponseXMLFactory xml_factory_class = SymbolicResponseXMLFactory def test_grade_single_input(self): @@ -224,8 +232,8 @@ class SymbolicResponseTest(ResponseTest): def test_complex_number_grade(self): problem = self.build_problem(math_display=True, - expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]", - options=["matrix", "imaginary"]) + expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]", + options=["matrix", "imaginary"]) # For LaTeX-style inputs, symmath_check() will try to contact # a server to convert the input to MathML. @@ -312,16 +320,16 @@ class SymbolicResponseTest(ResponseTest): # Should not allow multiple inputs, since we specify # only one "expect" value with self.assertRaises(Exception): - problem = self.build_problem(math_display=True, - expect="2*x+3*y", - num_inputs=3) + self.build_problem(math_display=True, + expect="2*x+3*y", + num_inputs=3) def _assert_symbolic_grade(self, problem, - student_input, - dynamath_input, - expected_correctness): + student_input, + dynamath_input, + expected_correctness): input_dict = {'1_2_1': str(student_input), - '1_2_1_dynamath': str(dynamath_input)} + '1_2_1_dynamath': str(dynamath_input)} correct_map = problem.grade_answers(input_dict) @@ -330,7 +338,7 @@ class SymbolicResponseTest(ResponseTest): class OptionResponseTest(ResponseTest): - from response_xml_factory import OptionResponseXMLFactory + from capa.tests.response_xml_factory import OptionResponseXMLFactory xml_factory_class = OptionResponseXMLFactory def test_grade(self): @@ -350,7 +358,7 @@ class FormulaResponseTest(ResponseTest): """ Test the FormulaResponse class """ - from response_xml_factory import FormulaResponseXMLFactory + from capa.tests.response_xml_factory import FormulaResponseXMLFactory xml_factory_class = FormulaResponseXMLFactory def test_grade(self): @@ -570,7 +578,7 @@ class FormulaResponseTest(ResponseTest): class StringResponseTest(ResponseTest): - from response_xml_factory import StringResponseXMLFactory + from capa.tests.response_xml_factory import StringResponseXMLFactory xml_factory_class = StringResponseXMLFactory def test_case_sensitive(self): @@ -647,19 +655,18 @@ class StringResponseTest(ResponseTest): hintfn="gimme_a_random_hint", script=textwrap.dedent(""" def gimme_a_random_hint(answer_ids, student_answers, new_cmap, old_cmap): - answer = str(random.randint(0, 1e9)) + answer = {code} new_cmap.set_hint_and_mode(answer_ids[0], answer, "always") - """) + """.format(code=self._get_random_number_code())) ) correct_map = problem.grade_answers({'1_2_1': '2'}) hint = correct_map.get_hint('1_2_1') - r = random.Random(problem.seed) - self.assertEqual(hint, str(r.randint(0, 1e9))) + self.assertEqual(hint, self._get_random_number_result(problem.seed)) class CodeResponseTest(ResponseTest): - from response_xml_factory import CodeResponseXMLFactory + from capa.tests.response_xml_factory import CodeResponseXMLFactory xml_factory_class = CodeResponseXMLFactory def setUp(self): @@ -673,6 +680,7 @@ class CodeResponseTest(ResponseTest): @staticmethod def make_queuestate(key, time): + """Create queuestate dict""" timestr = datetime.strftime(time, dateformat) return {'key': key, 'time': timestr} @@ -710,7 +718,7 @@ class CodeResponseTest(ResponseTest): old_cmap = CorrectMap() for i, answer_id in enumerate(answer_ids): queuekey = 1000 + i - queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now()) + queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now()) old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) # Message format common to external graders @@ -771,7 +779,7 @@ class CodeResponseTest(ResponseTest): for i, answer_id in enumerate(answer_ids): queuekey = 1000 + i latest_timestamp = datetime.now() - queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp) + queuestate = CodeResponseTest.make_queuestate(queuekey, latest_timestamp) cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) self.problem.correct_map.update(cmap) @@ -796,7 +804,7 @@ class CodeResponseTest(ResponseTest): class ChoiceResponseTest(ResponseTest): - from response_xml_factory import ChoiceResponseXMLFactory + from capa.tests.response_xml_factory import ChoiceResponseXMLFactory xml_factory_class = ChoiceResponseXMLFactory def test_radio_group_grade(self): @@ -828,7 +836,7 @@ class ChoiceResponseTest(ResponseTest): class JavascriptResponseTest(ResponseTest): - from response_xml_factory import JavascriptResponseXMLFactory + from capa.tests.response_xml_factory import JavascriptResponseXMLFactory xml_factory_class = JavascriptResponseXMLFactory def test_grade(self): @@ -858,7 +866,7 @@ class JavascriptResponseTest(ResponseTest): system.can_execute_unsafe_code = lambda: False with self.assertRaises(LoncapaProblemError): - problem = self.build_problem( + self.build_problem( system=system, generator_src="test_problem_generator.js", grader_src="test_problem_grader.js", @@ -869,7 +877,7 @@ class JavascriptResponseTest(ResponseTest): class NumericalResponseTest(ResponseTest): - from response_xml_factory import NumericalResponseXMLFactory + from capa.tests.response_xml_factory import NumericalResponseXMLFactory xml_factory_class = NumericalResponseXMLFactory def test_grade_exact(self): @@ -961,7 +969,7 @@ class NumericalResponseTest(ResponseTest): class CustomResponseTest(ResponseTest): - from response_xml_factory import CustomResponseXMLFactory + from capa.tests.response_xml_factory import CustomResponseXMLFactory xml_factory_class = CustomResponseXMLFactory def test_inline_code(self): @@ -1000,15 +1008,14 @@ class CustomResponseTest(ResponseTest): def test_inline_randomization(self): # Make sure the seed from the problem gets fed into the script execution. - inline_script = """messages[0] = str(random.randint(0, 1e9))""" + inline_script = "messages[0] = {code}".format(code=self._get_random_number_code()) problem = self.build_problem(answer=inline_script) input_dict = {'1_2_1': '0'} correctmap = problem.grade_answers(input_dict) input_msg = correctmap.get_msg('1_2_1') - r = random.Random(problem.seed) - self.assertEqual(input_msg, str(r.randint(0, 1e9))) + self.assertEqual(input_msg, self._get_random_number_result(problem.seed)) def test_function_code_single_input(self): # For function code, we pass in these arguments: @@ -1241,25 +1248,23 @@ class CustomResponseTest(ResponseTest): def test_setup_randomization(self): # Ensure that the problem setup script gets the random seed from the problem. script = textwrap.dedent(""" - num = random.randint(0, 1e9) - """) + num = {code} + """.format(code=self._get_random_number_code())) problem = self.build_problem(script=script) - r = random.Random(problem.seed) - self.assertEqual(r.randint(0, 1e9), problem.context['num']) + self.assertEqual(problem.context['num'], self._get_random_number_result(problem.seed)) def test_check_function_randomization(self): # The check function should get random-seeded from the problem. script = textwrap.dedent(""" def check_func(expect, answer_given): - return {'ok': True, 'msg': str(random.randint(0, 1e9))} - """) + return {{'ok': True, 'msg': {code} }} + """.format(code=self._get_random_number_code())) problem = self.build_problem(script=script, cfn="check_func", expect="42") input_dict = {'1_2_1': '42'} correct_map = problem.grade_answers(input_dict) msg = correct_map.get_msg('1_2_1') - r = random.Random(problem.seed) - self.assertEqual(msg, str(r.randint(0, 1e9))) + self.assertEqual(msg, self._get_random_number_result(problem.seed)) def test_module_imports_inline(self): ''' @@ -1320,7 +1325,7 @@ class CustomResponseTest(ResponseTest): class SchematicResponseTest(ResponseTest): - from response_xml_factory import SchematicResponseXMLFactory + from capa.tests.response_xml_factory import SchematicResponseXMLFactory xml_factory_class = SchematicResponseXMLFactory def test_grade(self): @@ -1349,11 +1354,10 @@ class SchematicResponseTest(ResponseTest): def test_check_function_randomization(self): # The check function should get a random seed from the problem. - script = "correct = ['correct' if (submission[0]['num'] == random.randint(0, 1e9)) else 'incorrect']" + script = "correct = ['correct' if (submission[0]['num'] == {code}) else 'incorrect']".format(code=self._get_random_number_code()) problem = self.build_problem(answer=script) - r = random.Random(problem.seed) - submission_dict = {'num': r.randint(0, 1e9)} + submission_dict = {'num': self._get_random_number_result(problem.seed)} input_dict = {'1_2_1': json.dumps(submission_dict)} correct_map = problem.grade_answers(input_dict) @@ -1372,7 +1376,7 @@ class SchematicResponseTest(ResponseTest): class AnnotationResponseTest(ResponseTest): - from response_xml_factory import AnnotationResponseXMLFactory + from capa.tests.response_xml_factory import AnnotationResponseXMLFactory xml_factory_class = AnnotationResponseXMLFactory def test_grade(self): @@ -1393,7 +1397,7 @@ class AnnotationResponseTest(ResponseTest): {'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null'}}, ] - for (index, test) in enumerate(tests): + for test in tests: expected_correctness = test['correctness'] expected_points = test['points'] answers = test['answers'] diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index fee80a34ff..a03c0f4160 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -424,7 +424,7 @@ class CapaModule(CapaFields, XModule): # If we cannot construct the problem HTML, # then generate an error message instead. - except Exception, err: + except Exception as err: html = self.handle_problem_html_error(err) # The convention is to pass the name of the check button @@ -655,7 +655,7 @@ class CapaModule(CapaFields, XModule): @staticmethod def make_dict_of_responses(get): '''Make dictionary of student responses (aka "answers") - get is POST dictionary (Djano QueryDict). + get is POST dictionary (Django QueryDict). The *get* dict has keys of the form 'x_y', which are mapped to key 'y' in the returned dict. For example, @@ -739,13 +739,13 @@ class CapaModule(CapaFields, XModule): # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' - self.system.track_function('save_problem_check_fail', event_info) + self.system.track_function('problem_check_fail', event_info) raise NotFoundError('Problem is closed') # Problem submitted. Student should reset before checking again if self.done and self.rerandomize == "always": event_info['failure'] = 'unreset' - self.system.track_function('save_problem_check_fail', event_info) + self.system.track_function('problem_check_fail', event_info) raise NotFoundError('Problem must be reset before it can be checked again') # Problem queued. Students must wait a specified waittime before they are allowed to submit @@ -759,6 +759,8 @@ class CapaModule(CapaFields, XModule): try: correct_map = self.lcp.grade_answers(answers) + self.attempts = self.attempts + 1 + self.lcp.done = True self.set_state_from_lcp() except (StudentInputError, ResponseError, LoncapaProblemError) as inst: @@ -778,17 +780,13 @@ class CapaModule(CapaFields, XModule): return {'success': msg} - except Exception, err: + except Exception as err: if self.system.DEBUG: msg = "Error checking problem: " + str(err) msg += '\nTraceback:\n' + traceback.format_exc() return {'success': msg} raise - self.attempts = self.attempts + 1 - self.lcp.done = True - - self.set_state_from_lcp() self.publish_grade() # success = correct if ALL questions in this problem are correct @@ -802,7 +800,7 @@ class CapaModule(CapaFields, XModule): event_info['correct_map'] = correct_map.get_dict() event_info['success'] = success event_info['attempts'] = self.attempts - self.system.track_function('save_problem_check', event_info) + self.system.track_function('problem_check', event_info) if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback self.system.psychometrics_handler(self.get_state_for_lcp()) @@ -814,12 +812,92 @@ class CapaModule(CapaFields, XModule): 'contents': html, } + def rescore_problem(self): + """ + Checks whether the existing answers to a problem are correct. + + This is called when the correct answer to a problem has been changed, + and the grade should be re-evaluated. + + Returns a dict with one key: + {'success' : 'correct' | 'incorrect' | AJAX alert msg string } + + Raises NotFoundError if called on a problem that has not yet been + answered, or NotImplementedError if it's a problem that cannot be rescored. + + Returns the error messages for exceptions occurring while performing + the rescoring, rather than throwing them. + """ + event_info = {'state': self.lcp.get_state(), 'problem_id': self.location.url()} + + if not self.lcp.supports_rescoring(): + event_info['failure'] = 'unsupported' + self.system.track_function('problem_rescore_fail', event_info) + raise NotImplementedError("Problem's definition does not support rescoring") + + if not self.done: + event_info['failure'] = 'unanswered' + self.system.track_function('problem_rescore_fail', event_info) + raise NotFoundError('Problem must be answered before it can be graded again') + + # get old score, for comparison: + orig_score = self.lcp.get_score() + event_info['orig_score'] = orig_score['score'] + event_info['orig_total'] = orig_score['total'] + + try: + correct_map = self.lcp.rescore_existing_answers() + + except (StudentInputError, ResponseError, LoncapaProblemError) as inst: + log.warning("Input error in capa_module:problem_rescore", exc_info=True) + event_info['failure'] = 'input_error' + self.system.track_function('problem_rescore_fail', event_info) + return {'success': u"Error: {0}".format(inst.message)} + + except Exception as err: + event_info['failure'] = 'unexpected' + self.system.track_function('problem_rescore_fail', event_info) + if self.system.DEBUG: + msg = u"Error checking problem: {0}".format(err.message) + msg += u'\nTraceback:\n' + traceback.format_exc() + return {'success': msg} + raise + + # rescoring should have no effect on attempts, so don't + # need to increment here, or mark done. Just save. + self.set_state_from_lcp() + + self.publish_grade() + + new_score = self.lcp.get_score() + event_info['new_score'] = new_score['score'] + event_info['new_total'] = new_score['total'] + + # success = correct if ALL questions in this problem are correct + success = 'correct' + for answer_id in correct_map: + if not correct_map.is_correct(answer_id): + success = 'incorrect' + + # NOTE: We are logging both full grading and queued-grading submissions. In the latter, + # 'success' will always be incorrect + event_info['correct_map'] = correct_map.get_dict() + event_info['success'] = success + event_info['attempts'] = self.attempts + self.system.track_function('problem_rescore', event_info) + + # psychometrics should be called on rescoring requests in the same way as check-problem + if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback + self.system.psychometrics_handler(self.get_state_for_lcp()) + + return {'success': success} + def save_problem(self, get): - ''' + """ Save the passed in answers. - Returns a dict { 'success' : bool, ['error' : error-msg]}, - with the error key only present if success is False. - ''' + Returns a dict { 'success' : bool, 'msg' : message } + The message is informative on success, and an error message on failure. + """ event_info = dict() event_info['state'] = self.lcp.get_state() event_info['problem_id'] = self.location.url() diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index ac95567946..68285cae0d 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -58,7 +58,7 @@ class CombinedOpenEndedFields(object): state = String(help="Which step within the current task that the student is on.", default="initial", scope=Scope.user_state) student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, - scope=Scope.user_state) + scope=Scope.user_state) ready_to_reset = Boolean( help="If the problem is ready to be reset or not.", default=False, scope=Scope.user_state @@ -66,7 +66,7 @@ class CombinedOpenEndedFields(object): attempts = Integer( display_name="Maximum Attempts", help="The number of times the student can try to answer this problem.", default=1, - scope=Scope.settings, values = {"min" : 1 } + scope=Scope.settings, values={"min" : 1 } ) is_graded = Boolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings) accept_file_upload = Boolean( @@ -89,7 +89,7 @@ class CombinedOpenEndedFields(object): weight = Float( display_name="Problem Weight", help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.", - scope=Scope.settings, values = {"min" : 0 , "step": ".1"} + scope=Scope.settings, values={"min" : 0 , "step": ".1"} ) markdown = String(help="Markdown source of this module", scope=Scope.settings) diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 8a74856fc1..a9b4be4fcd 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -77,10 +77,8 @@ class Date(ModelType): else: return value.isoformat() - TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') - class Timedelta(ModelType): def from_json(self, time_str): """ diff --git a/common/lib/xmodule/xmodule/gst_module.py b/common/lib/xmodule/xmodule/gst_module.py index 00e8cf1f10..e101d90b4c 100644 --- a/common/lib/xmodule/xmodule/gst_module.py +++ b/common/lib/xmodule/xmodule/gst_module.py @@ -84,7 +84,7 @@ class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule): xml = html.fromstring(html_string) - #substitute plot, if presented + # substitute plot, if presented plot_div = '
' plot_el = xml.xpath('//plot') @@ -95,7 +95,7 @@ class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule): element_id=self.html_id, style=plot_el.get('style', "")))) - #substitute sliders + # substitute sliders slider_div = '
ErrorLog + self._location_errors = {} # location -> ErrorLog self.metadata_inheritance_cache = None self.modulestore_update_signal = None # can be set by runtime to route notifications of datastore changes @@ -440,7 +440,7 @@ class ModuleStoreBase(ModuleStore): """ # check that item is present and raise the promised exceptions if needed # TODO (vshnayder): post-launch, make errors properties of items - #self.get_item(location) + # self.get_item(location) errorlog = self._get_errorlog(location) return errorlog.errors diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 048aea8867..94823b0be4 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -15,14 +15,14 @@ def as_draft(location): """ Returns the Location that is the draft for `location` """ - return Location(location)._replace(revision=DRAFT) + return Location(location).replace(revision=DRAFT) def as_published(location): """ Returns the Location that is the published version for `location` """ - return Location(location)._replace(revision=None) + return Location(location).replace(revision=None) def wrap_draft(item): @@ -32,7 +32,7 @@ def wrap_draft(item): non-draft location in either case """ setattr(item, 'is_draft', item.location.revision == DRAFT) - item.location = item.location._replace(revision=None) + item.location = item.location.replace(revision=None) return item @@ -234,7 +234,7 @@ class DraftModuleStore(ModuleStoreBase): # always return the draft - if available for draft in to_process_drafts: draft_loc = Location(draft["_id"]) - draft_as_non_draft_loc = draft_loc._replace(revision=None) + draft_as_non_draft_loc = draft_loc.replace(revision=None) # does non-draft exist in the collection # if so, replace it diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 422abbdd73..40288a933b 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -307,7 +307,7 @@ class MongoModuleStore(ModuleStoreBase): location = Location(result['_id']) # We need to collate between draft and non-draft # i.e. draft verticals can have children which are not in non-draft versions - location = location._replace(revision=None) + location = location.replace(revision=None) location_url = location.url() if location_url in results_by_url: existing_children = results_by_url[location_url].get('definition', {}).get('children', []) 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 45e73442d0..01be4c61ab 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 @@ -19,18 +19,18 @@ log = logging.getLogger("mitx.courseware") # attempts specified in xml definition overrides this. MAX_ATTEMPTS = 1 -#The highest score allowed for the overall xmodule and for each rubric point +# The highest score allowed for the overall xmodule and for each rubric point MAX_SCORE_ALLOWED = 50 -#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress -#Metadata overrides this. +# If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress +# Metadata overrides this. IS_SCORED = False -#If true, then default behavior is to require a file upload or pasted link from a student for this problem. -#Metadata overrides this. +# If true, then default behavior is to require a file upload or pasted link from a student for this problem. +# Metadata overrides this. ACCEPT_FILE_UPLOAD = False -#Contains all reasonable bool and case combinations of True +# Contains all reasonable bool and case combinations of True TRUE_DICT = ["True", True, "TRUE", "true"] HUMAN_TASK_TYPE = { @@ -38,8 +38,8 @@ HUMAN_TASK_TYPE = { 'openended': "edX Assessment", } -#Default value that controls whether or not to skip basic spelling checks in the controller -#Metadata overrides this +# Default value that controls whether or not to skip basic spelling checks in the controller +# Metadata overrides this SKIP_BASIC_CHECKS = False @@ -74,7 +74,7 @@ class CombinedOpenEndedV1Module(): INTERMEDIATE_DONE = 'intermediate_done' DONE = 'done' - #Where the templates live for this problem + # Where the templates live for this problem TEMPLATE_DIR = "combinedopenended" def __init__(self, system, location, definition, descriptor, @@ -118,21 +118,21 @@ class CombinedOpenEndedV1Module(): self.instance_state = instance_state self.display_name = instance_state.get('display_name', "Open Ended") - #We need to set the location here so the child modules can use it + # We need to set the location here so the child modules can use it system.set('location', location) self.system = system - #Tells the system which xml definition to load + # Tells the system which xml definition to load self.current_task_number = instance_state.get('current_task_number', 0) - #This loads the states of the individual children + # This loads the states of the individual children self.task_states = instance_state.get('task_states', []) - #Overall state of the combined open ended module + # Overall state of the combined open ended module self.state = instance_state.get('state', self.INITIAL) self.student_attempts = instance_state.get('student_attempts', 0) self.weight = instance_state.get('weight', 1) - #Allow reset is true if student has failed the criteria to move to the next child task + # Allow reset is true if student has failed the criteria to move to the next child task self.ready_to_reset = instance_state.get('ready_to_reset', False) self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS) self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT @@ -153,7 +153,7 @@ class CombinedOpenEndedV1Module(): rubric_string = stringify_children(definition['rubric']) self._max_score = self.rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED) - #Static data is passed to the child modules to render + # Static data is passed to the child modules to render self.static_data = { 'max_score': self._max_score, 'max_attempts': self.attempts, @@ -243,11 +243,11 @@ class CombinedOpenEndedV1Module(): self.current_task_descriptor = children['descriptors'][current_task_type](self.system) - #This is the xml object created from the xml definition of the current task + # This is the xml object created from the xml definition of the current task etree_xml = etree.fromstring(self.current_task_xml) - #This sends the etree_xml object through the descriptor module of the current task, and - #returns the xml parsed by the descriptor + # This sends the etree_xml object through the descriptor module of the current task, and + # returns the xml parsed by the descriptor 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, @@ -293,8 +293,9 @@ class CombinedOpenEndedV1Module(): if self.current_task_number > 0: 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']): + or current_response_data['max_score_to_attempt'] < last_response_data['score']): self.state = self.DONE self.ready_to_reset = True @@ -307,7 +308,7 @@ class CombinedOpenEndedV1Module(): Output: A dictionary that can be rendered into the combined open ended template. """ task_html = self.get_html_base() - #set context variables and render template + # set context variables and render template context = { 'items': [{'content': task_html}], @@ -499,7 +500,6 @@ class CombinedOpenEndedV1Module(): """ changed = self.update_task_states() if changed: - #return_html=self.get_html() pass return return_html @@ -730,15 +730,15 @@ class CombinedOpenEndedV1Module(): max_score = None score = None if self.is_scored and self.weight is not None: - #Finds the maximum score of all student attempts and keeps it. + # Finds the maximum score of all student attempts and keeps it. score_mat = [] for i in xrange(0, len(self.task_states)): - #For each task, extract all student scores on that task (each attempt for each task) + # For each task, extract all student scores on that task (each attempt for each task) last_response = self.get_last_response(i) max_score = last_response.get('max_score', None) score = last_response.get('all_scores', None) if score is not None: - #Convert none scores and weight scores properly + # Convert none scores and weight scores properly for z in xrange(0, len(score)): if score[z] is None: score[z] = 0 @@ -746,19 +746,19 @@ class CombinedOpenEndedV1Module(): score_mat.append(score) if len(score_mat) > 0: - #Currently, assume that the final step is the correct one, and that those are the final scores. - #This will change in the future, which is why the machinery above exists to extract all scores on all steps - #TODO: better final score handling. + # Currently, assume that the final step is the correct one, and that those are the final scores. + # This will change in the future, which is why the machinery above exists to extract all scores on all steps + # TODO: better final score handling. scores = score_mat[-1] score = max(scores) else: score = 0 if max_score is not None: - #Weight the max score if it is not None + # Weight the max score if it is not None max_score *= float(self.weight) else: - #Without a max_score, we cannot have a score! + # Without a max_score, we cannot have a score! score = None score_dict = { @@ -833,7 +833,7 @@ class CombinedOpenEndedV1Descriptor(): expected_children = ['task', 'rubric', 'prompt'] for child in expected_children: if len(xml_object.xpath(child)) == 0: - #This is a staff_facing_error + # 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. {1}".format( child, xml_object)) @@ -848,6 +848,7 @@ class CombinedOpenEndedV1Descriptor(): return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')} + def definition_to_xml(self, resource_fs): '''Return an xml element representing this definition.''' elt = etree.Element('combinedopenended') 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 24af7846d7..1e5b1b233b 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 @@ -57,13 +57,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild): self.queue_name = definition.get('queuename', self.DEFAULT_QUEUE) self.message_queue_name = definition.get('message-queuename', self.DEFAULT_MESSAGE_QUEUE) - #This is needed to attach feedback to specific responses later + # This is needed to attach feedback to specific responses later self.submission_id = None self.grader_id = None error_message = "No {0} found in problem xml for open ended problem. Contact the learning sciences group for assistance." if oeparam is None: - #This is a staff_facing_error + # This is a staff_facing_error raise ValueError(error_message.format('oeparam')) if self.child_prompt is None: raise ValueError(error_message.format('prompt')) @@ -95,14 +95,14 @@ class OpenEndedModule(openendedchild.OpenEndedChild): grader_payload = oeparam.find('grader_payload') grader_payload = grader_payload.text if grader_payload is not None else '' - #Update grader payload with student id. If grader payload not json, error. + # Update grader payload with student id. If grader payload not json, error. try: parsed_grader_payload = json.loads(grader_payload) # NOTE: self.system.location is valid because the capa_module # __init__ adds it (easiest way to get problem location into # response types) except TypeError, ValueError: - #This is a dev_facing_error + # 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)) @@ -148,7 +148,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): survey_responses = event_info['survey_responses'] for tag in ['feedback', 'submission_id', 'grader_id', 'score']: if tag not in survey_responses: - #This is a student_facing_error + # 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)} @@ -158,14 +158,14 @@ class OpenEndedModule(openendedchild.OpenEndedChild): feedback = str(survey_responses['feedback'].encode('ascii', 'ignore')) score = int(survey_responses['score']) except: - #This is a dev_facing_error + # This is a dev_facing_error error_message = ( "Could not parse submission id, grader id, " "or feedback from message_post ajax call. " "Here is the message data: {0}".format(survey_responses) ) log.exception(error_message) - #This is a student_facing_error + # This is a student_facing_error return {'success': False, 'msg': "There was an error saving your feedback. Please contact course staff."} xqueue = system.get('xqueue') @@ -201,14 +201,14 @@ class OpenEndedModule(openendedchild.OpenEndedChild): body=json.dumps(contents) ) - #Convert error to a success value + # Convert error to a success value success = True if error: success = False self.child_state = self.DONE - #This is a student_facing_message + # This is a student_facing_message return {'success': success, 'msg': "Successfully submitted your feedback."} def send_to_grader(self, submission, system): @@ -249,7 +249,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): 'submission_time': qtime, } - #Update contents with student response and student info + # Update contents with student response and student info contents.update({ 'student_info': json.dumps(student_info), 'student_response': submission, @@ -369,21 +369,21 @@ class OpenEndedModule(openendedchild.OpenEndedChild): for tag in ['success', 'feedback', 'submission_id', 'grader_id']: if tag not in response_items: - #This is a student_facing_error + # This is a student_facing_error return format_feedback('errors', 'Error getting feedback from grader.') feedback_items = response_items['feedback'] try: feedback = json.loads(feedback_items) except (TypeError, ValueError): - #This is a dev_facing_error + # This is a dev_facing_error log.exception("feedback_items from external open ended grader have invalid json {0}".format(feedback_items)) - #This is a student_facing_error + # This is a student_facing_error return format_feedback('errors', 'Error getting feedback from grader.') if response_items['success']: if len(feedback) == 0: - #This is a student_facing_error + # This is a student_facing_error return format_feedback('errors', 'No feedback available from grader.') for tag in do_not_render: @@ -393,7 +393,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): feedback_lst = sorted(feedback.items(), key=get_priority) feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst) else: - #This is a student_facing_error + # This is a student_facing_error feedback_list_part1 = format_feedback('errors', response_items['feedback']) feedback_list_part2 = (u"\n".join([format_feedback_hidden(feedback_type, value) @@ -470,7 +470,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): try: score_result = json.loads(score_msg) except (TypeError, ValueError): - #This is a dev_facing_error + # This is a dev_facing_error error_message = ("External open ended grader message should be a JSON-serialized dict." " Received score_msg = {0}".format(score_msg)) log.error(error_message) @@ -478,7 +478,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return fail if not isinstance(score_result, dict): - #This is a dev_facing_error + # This is a dev_facing_error error_message = ("External open ended grader message should be a JSON-serialized dict." " Received score_result = {0}".format(score_result)) log.error(error_message) @@ -487,13 +487,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild): for tag in ['score', 'feedback', 'grader_type', 'success', 'grader_id', 'submission_id']: if tag not in score_result: - #This is a dev_facing_error + # This is a dev_facing_error error_message = ("External open ended grader message is missing required tag: {0}" .format(tag)) 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 = [] @@ -529,7 +529,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): feedback = feedback_items score = int(median(score_result['score'])) else: - #This is for instructor and ML grading + # This is for instructor and ML grading feedback, rubric_score = self._format_feedback(score_result, system) score = score_result['score'] rubric_scores = [rubric_score] @@ -608,9 +608,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild): } if dispatch not in handlers: - #This is a dev_facing_error + # 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 + # This is a dev_facing_error return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) before = self.get_progress() @@ -659,10 +659,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild): self.send_to_grader(get['student_answer'], system) self.change_state(self.ASSESSING) else: - #Error message already defined + # Error message already defined success = False else: - #This is a student_facing_error + # This is a student_facing_error error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box." return { @@ -679,7 +679,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): """ queuekey = get['queuekey'] score_msg = get['xqueue_body'] - #TODO: Remove need for cmap + # TODO: Remove need for cmap self._update_score(score_msg, queuekey, system) return dict() # No AJAX return is needed @@ -690,7 +690,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): Input: Modulesystem object Output: Rendered HTML """ - #set context variables and render template + # set context variables and render template eta_string = None if self.child_state != self.INITIAL: latest = self.latest_answer() @@ -749,7 +749,7 @@ class OpenEndedDescriptor(): """ for child in ['openendedparam']: if len(xml_object.xpath(child)) != 1: - #This is a staff_facing_error + # 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)) 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 5c46fbf095..7beca7a72f 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 @@ -54,7 +54,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): @param system: Modulesystem @return: Rendered HTML """ - #set context variables and render template + # set context variables and render template if self.child_state != self.INITIAL: latest = self.latest_answer() previous_answer = latest if latest is not None else '' @@ -93,9 +93,9 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): } if dispatch not in handlers: - #This is a dev_facing_error + # 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 + # This is a dev_facing_error return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) before = self.get_progress() @@ -129,7 +129,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): elif self.child_state in (self.POST_ASSESSMENT, self.DONE): context['read_only'] = True else: - #This is a dev_facing_error + # This is a dev_facing_error raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.child_state)) return system.render_template('{0}/self_assessment_rubric.html'.format(self.TEMPLATE_DIR), context) @@ -155,7 +155,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): elif self.child_state == self.DONE: context['read_only'] = True else: - #This is a dev_facing_error + # This is a dev_facing_error raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.child_state)) return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context) @@ -190,10 +190,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): self.new_history_entry(get['student_answer']) self.change_state(self.ASSESSING) else: - #Error message already defined + # Error message already defined success = False else: - #This is a student_facing_error + # This is a student_facing_error error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box." return { @@ -227,12 +227,12 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): for i in xrange(0, len(score_list)): score_list[i] = int(score_list[i]) except ValueError: - #This is a dev_facing_error + # This is a dev_facing_error log.error("Non-integer score value passed to save_assessment ,or no score list present.") - #This is a student_facing_error + # This is a student_facing_error return {'success': False, 'error': "Error saving your score. Please notify course staff."} - #Record score as assessment and rubric scores as post assessment + # Record score as assessment and rubric scores as post assessment self.record_latest_score(score) self.record_latest_post_assessment(json.dumps(score_list)) @@ -272,7 +272,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): try: rubric_scores = json.loads(latest_post_assessment) except: - #This is a dev_facing_error + # This is a dev_facing_error log.error("Cannot parse rubric scores in self assessment module from {0}".format(latest_post_assessment)) rubric_scores = [] return [rubric_scores] @@ -306,7 +306,7 @@ class SelfAssessmentDescriptor(): expected_children = [] for child in expected_children: if len(xml_object.xpath(child)) != 1: - #This is a staff_facing_error + # 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)) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index f0dfca3be6..580f51f6dd 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -62,7 +62,7 @@ class SequenceModule(SequenceFields, XModule): progress = reduce(Progress.add_counts, progresses) return progress - def handle_ajax(self, dispatch, get): # TODO: bounds checking + def handle_ajax(self, dispatch, get): # TODO: bounds checking ''' get = request.POST instance ''' if dispatch == 'goto_position': self.position = int(get['position']) diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py index d79d2a163e..9a9666c0b6 100644 --- a/common/lib/xmodule/xmodule/template_module.py +++ b/common/lib/xmodule/xmodule/template_module.py @@ -55,7 +55,7 @@ class CustomTagDescriptor(RawDescriptor): params = dict(xmltree.items()) # cdodge: look up the template as a module - template_loc = self.location._replace(category='custom_tag_template', name=template_name) + template_loc = self.location.replace(category='custom_tag_template', name=template_name) template_module = modulestore().get_instance(system.course_id, template_loc) template_module_data = template_module.data diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 7cba4a76b3..deb6f13e20 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -19,6 +19,7 @@ from django.http import QueryDict from . import test_system from pytz import UTC +from capa.correctmap import CorrectMap class CapaFactory(object): @@ -597,6 +598,85 @@ class CapaModuleTest(unittest.TestCase): # Expect that the problem was NOT reset self.assertTrue('success' in result and not result['success']) + def test_rescore_problem_correct(self): + + module = CapaFactory.create(attempts=1, done=True) + + # Simulate that all answers are marked correct, no matter + # what the input is, by patching LoncapaResponse.evaluate_answers() + with patch('capa.responsetypes.LoncapaResponse.evaluate_answers') as mock_evaluate_answers: + mock_evaluate_answers.return_value = CorrectMap(CapaFactory.answer_key(), 'correct') + result = module.rescore_problem() + + # Expect that the problem is marked correct + self.assertEqual(result['success'], 'correct') + + # Expect that we get no HTML + self.assertFalse('contents' in result) + + # Expect that the number of attempts is not incremented + self.assertEqual(module.attempts, 1) + + def test_rescore_problem_incorrect(self): + # make sure it also works when attempts have been reset, + # so add this to the test: + module = CapaFactory.create(attempts=0, done=True) + + # Simulate that all answers are marked incorrect, no matter + # what the input is, by patching LoncapaResponse.evaluate_answers() + with patch('capa.responsetypes.LoncapaResponse.evaluate_answers') as mock_evaluate_answers: + mock_evaluate_answers.return_value = CorrectMap(CapaFactory.answer_key(), 'incorrect') + result = module.rescore_problem() + + # Expect that the problem is marked incorrect + self.assertEqual(result['success'], 'incorrect') + + # Expect that the number of attempts is not incremented + self.assertEqual(module.attempts, 0) + + def test_rescore_problem_not_done(self): + # Simulate that the problem is NOT done + module = CapaFactory.create(done=False) + + # Try to rescore the problem, and get exception + with self.assertRaises(xmodule.exceptions.NotFoundError): + module.rescore_problem() + + def test_rescore_problem_not_supported(self): + module = CapaFactory.create(done=True) + + # Try to rescore the problem, and get exception + with patch('capa.capa_problem.LoncapaProblem.supports_rescoring') as mock_supports_rescoring: + mock_supports_rescoring.return_value = False + with self.assertRaises(NotImplementedError): + module.rescore_problem() + + def _rescore_problem_error_helper(self, exception_class): + """Helper to allow testing all errors that rescoring might return.""" + # Create the module + module = CapaFactory.create(attempts=1, done=True) + + # Simulate answering a problem that raises the exception + with patch('capa.capa_problem.LoncapaProblem.rescore_existing_answers') as mock_rescore: + mock_rescore.side_effect = exception_class(u'test error \u03a9') + result = module.rescore_problem() + + # Expect an AJAX alert message in 'success' + expected_msg = u'Error: test error \u03a9' + self.assertEqual(result['success'], expected_msg) + + # Expect that the number of attempts is NOT incremented + self.assertEqual(module.attempts, 1) + + def test_rescore_problem_student_input_error(self): + self._rescore_problem_error_helper(StudentInputError) + + def test_rescore_problem_problem_error(self): + self._rescore_problem_error_helper(LoncapaProblemError) + + def test_rescore_problem_response_error(self): + self._rescore_problem_error_helper(ResponseError) + def test_save_problem(self): module = CapaFactory.create(done=False) diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index e88bf0c588..fed40b690f 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -20,7 +20,7 @@ from . import test_system class DummySystem(ImportSystem): - @patch('xmodule.modulestore.xml.OSFS', lambda dir: MemoryFS()) + @patch('xmodule.modulestore.xml.OSFS', lambda directory: MemoryFS()) def __init__(self, load_error_modules): xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules) @@ -41,7 +41,8 @@ class DummySystem(ImportSystem): ) def render_template(self, template, context): - raise Exception("Shouldn't be called") + raise Exception("Shouldn't be called") + class ConditionalFactory(object): """ @@ -93,7 +94,7 @@ class ConditionalFactory(object): # return dict: return {'cond_module': cond_module, 'source_module': source_module, - 'child_module': child_module } + 'child_module': child_module} class ConditionalModuleBasicTest(unittest.TestCase): @@ -109,12 +110,11 @@ class ConditionalModuleBasicTest(unittest.TestCase): '''verify that get_icon_class works independent of condition satisfaction''' modules = ConditionalFactory.create(self.test_system) for attempted in ["false", "true"]: - for icon_class in [ 'other', 'problem', 'video']: + for icon_class in ['other', 'problem', 'video']: modules['source_module'].is_attempted = attempted modules['child_module'].get_icon_class = lambda: icon_class self.assertEqual(modules['cond_module'].get_icon_class(), icon_class) - def test_get_html(self): modules = ConditionalFactory.create(self.test_system) # because test_system returns the repr of the context dict passed to render_template, @@ -224,4 +224,3 @@ class ConditionalModuleXmlTest(unittest.TestCase): print "post-attempt ajax: ", ajax html = ajax['html'] self.assertTrue(any(['This is a secret' in item for item in html])) - diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 3edc22df43..f5705bf662 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -15,7 +15,7 @@ from xblock.core import XBlock, Scope, String, Integer, Float, ModelType log = logging.getLogger(__name__) -def dummy_track(event_type, event): +def dummy_track(_event_type, _event): pass @@ -231,7 +231,7 @@ class XModule(XModuleFields, HTMLSnippet, XBlock): ''' return self.icon_class - ### Functions used in the LMS + # Functions used in the LMS def get_score(self): """ @@ -272,7 +272,7 @@ class XModule(XModuleFields, HTMLSnippet, XBlock): ''' return None - def handle_ajax(self, dispatch, get): + def handle_ajax(self, _dispatch, _get): ''' dispatch is last part of the URL. get is a dictionary-like object ''' return "" @@ -647,13 +647,13 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): # 1. A select editor for fields with a list of possible values (includes Booleans). # 2. Number editors for integers and floats. # 3. A generic string editor for anything else (editing JSON representation of the value). - type = "Generic" + editor_type = "Generic" values = [] if field.values is None else copy.deepcopy(field.values) if isinstance(values, tuple): values = list(values) if isinstance(values, list): if len(values) > 0: - type = "Select" + editor_type = "Select" for index, choice in enumerate(values): json_choice = copy.deepcopy(choice) if isinstance(json_choice, dict) and 'value' in json_choice: @@ -662,11 +662,11 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): json_choice = field.to_json(json_choice) values[index] = json_choice elif isinstance(field, Integer): - type = "Integer" + editor_type = "Integer" elif isinstance(field, Float): - type = "Float" + editor_type = "Float" metadata_fields[field.name] = {'field_name': field.name, - 'type': type, + 'type': editor_type, 'display_name': field.display_name, 'value': field.to_json(value), 'options': values, @@ -862,7 +862,7 @@ class ModuleSystem(object): class DoNothingCache(object): """A duck-compatible object to use in ModuleSystem when there's no cache.""" - def get(self, key): + def get(self, _key): return None def set(self, key, value, timeout=None): diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index e1a0e0cf08..33120ec180 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -56,7 +56,6 @@ def get_metadata_from_xml(xml_object, remove=True): if meta is None: return '' dmdata = meta.text - #log.debug('meta for %s loaded: %s' % (xml_object,dmdata)) if remove: xml_object.remove(meta) return dmdata diff --git a/common/static/coffee/spec/logger_spec.coffee b/common/static/coffee/spec/logger_spec.coffee index 8fdfb99251..8866daa570 100644 --- a/common/static/coffee/spec/logger_spec.coffee +++ b/common/static/coffee/spec/logger_spec.coffee @@ -3,6 +3,11 @@ describe 'Logger', -> expect(window.log_event).toBe Logger.log describe 'log', -> + it 'sends an event to Segment.io, if the event is whitelisted', -> + spyOn(analytics, 'track') + Logger.log 'seq_goto', 'data' + expect(analytics.track).toHaveBeenCalledWith 'seq_goto', 'data' + it 'send a request to log event', -> spyOn $, 'getWithPrefix' Logger.log 'example', 'data' diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index 58395ba831..6da4929fb0 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -1,5 +1,12 @@ class @Logger + # events we want sent to Segment.io for tracking + SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev"] + @log: (event_type, data) -> + if event_type in SEGMENT_IO_WHITELIST + # Segment.io event tracking + analytics.track event_type, data + $.getWithPrefix '/event', event_type: event_type event: JSON.stringify(data) diff --git a/common/static/js/vendor/analytics.js b/common/static/js/vendor/analytics.js new file mode 100644 index 0000000000..a63ff55587 --- /dev/null +++ b/common/static/js/vendor/analytics.js @@ -0,0 +1,5538 @@ +;(function(){ + +/** + * Require the given path. + * + * @param {String} path + * @return {Object} exports + * @api public + */ + +function require(path, parent, orig) { + var resolved = require.resolve(path); + + // lookup failed + if (null == resolved) { + orig = orig || path; + parent = parent || 'root'; + var err = new Error('Failed to require "' + orig + '" from "' + parent + '"'); + err.path = orig; + err.parent = parent; + err.require = true; + throw err; + } + + var module = require.modules[resolved]; + + // perform real require() + // by invoking the module's + // registered function + if (!module.exports) { + module.exports = {}; + module.client = module.component = true; + module.call(this, module.exports, require.relative(resolved), module); + } + + return module.exports; +} + +/** + * Registered modules. + */ + +require.modules = {}; + +/** + * Registered aliases. + */ + +require.aliases = {}; + +/** + * Resolve `path`. + * + * Lookup: + * + * - PATH/index.js + * - PATH.js + * - PATH + * + * @param {String} path + * @return {String} path or null + * @api private + */ + +require.resolve = function(path) { + if (path.charAt(0) === '/') path = path.slice(1); + + var paths = [ + path, + path + '.js', + path + '.json', + path + '/index.js', + path + '/index.json' + ]; + + for (var i = 0; i < paths.length; i++) { + var path = paths[i]; + if (require.modules.hasOwnProperty(path)) return path; + if (require.aliases.hasOwnProperty(path)) return require.aliases[path]; + } +}; + +/** + * Normalize `path` relative to the current path. + * + * @param {String} curr + * @param {String} path + * @return {String} + * @api private + */ + +require.normalize = function(curr, path) { + var segs = []; + + if ('.' != path.charAt(0)) return path; + + curr = curr.split('/'); + path = path.split('/'); + + for (var i = 0; i < path.length; ++i) { + if ('..' == path[i]) { + curr.pop(); + } else if ('.' != path[i] && '' != path[i]) { + segs.push(path[i]); + } + } + + return curr.concat(segs).join('/'); +}; + +/** + * Register module at `path` with callback `definition`. + * + * @param {String} path + * @param {Function} definition + * @api private + */ + +require.register = function(path, definition) { + require.modules[path] = definition; +}; + +/** + * Alias a module definition. + * + * @param {String} from + * @param {String} to + * @api private + */ + +require.alias = function(from, to) { + if (!require.modules.hasOwnProperty(from)) { + throw new Error('Failed to alias "' + from + '", it does not exist'); + } + require.aliases[to] = from; +}; + +/** + * Return a require function relative to the `parent` path. + * + * @param {String} parent + * @return {Function} + * @api private + */ + +require.relative = function(parent) { + var p = require.normalize(parent, '..'); + + /** + * lastIndexOf helper. + */ + + function lastIndexOf(arr, obj) { + var i = arr.length; + while (i--) { + if (arr[i] === obj) return i; + } + return -1; + } + + /** + * The relative require() itself. + */ + + function localRequire(path) { + var resolved = localRequire.resolve(path); + return require(resolved, parent, path); + } + + /** + * Resolve relative to the parent. + */ + + localRequire.resolve = function(path) { + var c = path.charAt(0); + if ('/' == c) return path.slice(1); + if ('.' == c) return require.normalize(p, path); + + // resolve deps by returning + // the dep in the nearest "deps" + // directory + var segs = parent.split('/'); + var i = lastIndexOf(segs, 'deps') + 1; + if (!i) i = 0; + path = segs.slice(0, i + 1).join('/') + '/deps/' + path; + return path; + }; + + /** + * Check if module is defined at `path`. + */ + + localRequire.exists = function(path) { + return require.modules.hasOwnProperty(localRequire.resolve(path)); + }; + + return localRequire; +}; +require.register("avetisk-defaults/index.js", function(exports, require, module){ +'use strict'; + +/** + * Merge default values. + * + * @param {Object} dest + * @param {Object} defaults + * @return {Object} + * @api public + */ +var defaults = function (dest, src, recursive) { + for (var prop in src) { + if (recursive && dest[prop] instanceof Object && src[prop] instanceof Object) { + dest[prop] = defaults(dest[prop], src[prop], true); + } else if (! (prop in dest)) { + dest[prop] = src[prop]; + } + } + + return dest; +}; + +/** + * Expose `defaults`. + */ +module.exports = defaults; + +}); +require.register("component-clone/index.js", function(exports, require, module){ + +/** + * Module dependencies. + */ + +var type; + +try { + type = require('type'); +} catch(e){ + type = require('type-component'); +} + +/** + * Module exports. + */ + +module.exports = clone; + +/** + * Clones objects. + * + * @param {Mixed} any object + * @api public + */ + +function clone(obj){ + switch (type(obj)) { + case 'object': + var copy = {}; + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + copy[key] = clone(obj[key]); + } + } + return copy; + + case 'array': + var copy = new Array(obj.length); + for (var i = 0, l = obj.length; i < l; i++) { + copy[i] = clone(obj[i]); + } + return copy; + + case 'regexp': + // from millermedeiros/amd-utils - MIT + var flags = ''; + flags += obj.multiline ? 'm' : ''; + flags += obj.global ? 'g' : ''; + flags += obj.ignoreCase ? 'i' : ''; + return new RegExp(obj.source, flags); + + case 'date': + return new Date(obj.getTime()); + + default: // string, number, boolean, … + return obj; + } +} + +}); +require.register("component-cookie/index.js", function(exports, require, module){ +/** + * Encode. + */ + +var encode = encodeURIComponent; + +/** + * Decode. + */ + +var decode = decodeURIComponent; + +/** + * Set or get cookie `name` with `value` and `options` object. + * + * @param {String} name + * @param {String} value + * @param {Object} options + * @return {Mixed} + * @api public + */ + +module.exports = function(name, value, options){ + switch (arguments.length) { + case 3: + case 2: + return set(name, value, options); + case 1: + return get(name); + default: + return all(); + } +}; + +/** + * Set cookie `name` to `value`. + * + * @param {String} name + * @param {String} value + * @param {Object} options + * @api private + */ + +function set(name, value, options) { + options = options || {}; + var str = encode(name) + '=' + encode(value); + + if (null == value) options.maxage = -1; + + if (options.maxage) { + options.expires = new Date(+new Date + options.maxage); + } + + if (options.path) str += '; path=' + options.path; + if (options.domain) str += '; domain=' + options.domain; + if (options.expires) str += '; expires=' + options.expires.toUTCString(); + if (options.secure) str += '; secure'; + + document.cookie = str; +} + +/** + * Return all cookies. + * + * @return {Object} + * @api private + */ + +function all() { + return parse(document.cookie); +} + +/** + * Get cookie `name`. + * + * @param {String} name + * @return {String} + * @api private + */ + +function get(name) { + return all()[name]; +} + +/** + * Parse cookie `str`. + * + * @param {String} str + * @return {Object} + * @api private + */ + +function parse(str) { + var obj = {}; + var pairs = str.split(/ *; */); + var pair; + if ('' == pairs[0]) return obj; + for (var i = 0; i < pairs.length; ++i) { + pair = pairs[i].split('='); + obj[decode(pair[0])] = decode(pair[1]); + } + return obj; +} + +}); +require.register("component-each/index.js", function(exports, require, module){ + +/** + * Module dependencies. + */ + +var type = require('type'); + +/** + * HOP reference. + */ + +var has = Object.prototype.hasOwnProperty; + +/** + * Iterate the given `obj` and invoke `fn(val, i)`. + * + * @param {String|Array|Object} obj + * @param {Function} fn + * @api public + */ + +module.exports = function(obj, fn){ + switch (type(obj)) { + case 'array': + return array(obj, fn); + case 'object': + if ('number' == typeof obj.length) return array(obj, fn); + return object(obj, fn); + case 'string': + return string(obj, fn); + } +}; + +/** + * Iterate string chars. + * + * @param {String} obj + * @param {Function} fn + * @api private + */ + +function string(obj, fn) { + for (var i = 0; i < obj.length; ++i) { + fn(obj.charAt(i), i); + } +} + +/** + * Iterate object keys. + * + * @param {Object} obj + * @param {Function} fn + * @api private + */ + +function object(obj, fn) { + for (var key in obj) { + if (has.call(obj, key)) { + fn(key, obj[key]); + } + } +} + +/** + * Iterate array-ish. + * + * @param {Array|Object} obj + * @param {Function} fn + * @api private + */ + +function array(obj, fn) { + for (var i = 0; i < obj.length; ++i) { + fn(obj[i], i); + } +} +}); +require.register("component-event/index.js", function(exports, require, module){ + +/** + * Bind `el` event `type` to `fn`. + * + * @param {Element} el + * @param {String} type + * @param {Function} fn + * @param {Boolean} capture + * @return {Function} + * @api public + */ + +exports.bind = function(el, type, fn, capture){ + if (el.addEventListener) { + el.addEventListener(type, fn, capture || false); + } else { + el.attachEvent('on' + type, fn); + } + return fn; +}; + +/** + * Unbind `el` event `type`'s callback `fn`. + * + * @param {Element} el + * @param {String} type + * @param {Function} fn + * @param {Boolean} capture + * @return {Function} + * @api public + */ + +exports.unbind = function(el, type, fn, capture){ + if (el.removeEventListener) { + el.removeEventListener(type, fn, capture || false); + } else { + el.detachEvent('on' + type, fn); + } + return fn; +}; + +}); +require.register("component-inherit/index.js", function(exports, require, module){ + +module.exports = function(a, b){ + var fn = function(){}; + fn.prototype = b.prototype; + a.prototype = new fn; + a.prototype.constructor = a; +}; +}); +require.register("component-object/index.js", function(exports, require, module){ + +/** + * HOP ref. + */ + +var has = Object.prototype.hasOwnProperty; + +/** + * Return own keys in `obj`. + * + * @param {Object} obj + * @return {Array} + * @api public + */ + +exports.keys = Object.keys || function(obj){ + var keys = []; + for (var key in obj) { + if (has.call(obj, key)) { + keys.push(key); + } + } + return keys; +}; + +/** + * Return own values in `obj`. + * + * @param {Object} obj + * @return {Array} + * @api public + */ + +exports.values = function(obj){ + var vals = []; + for (var key in obj) { + if (has.call(obj, key)) { + vals.push(obj[key]); + } + } + return vals; +}; + +/** + * Merge `b` into `a`. + * + * @param {Object} a + * @param {Object} b + * @return {Object} a + * @api public + */ + +exports.merge = function(a, b){ + for (var key in b) { + if (has.call(b, key)) { + a[key] = b[key]; + } + } + return a; +}; + +/** + * Return length of `obj`. + * + * @param {Object} obj + * @return {Number} + * @api public + */ + +exports.length = function(obj){ + return exports.keys(obj).length; +}; + +/** + * Check if `obj` is empty. + * + * @param {Object} obj + * @return {Boolean} + * @api public + */ + +exports.isEmpty = function(obj){ + return 0 == exports.length(obj); +}; +}); +require.register("component-trim/index.js", function(exports, require, module){ + +exports = module.exports = trim; + +function trim(str){ + return str.replace(/^\s*|\s*$/g, ''); +} + +exports.left = function(str){ + return str.replace(/^\s*/, ''); +}; + +exports.right = function(str){ + return str.replace(/\s*$/, ''); +}; + +}); +require.register("component-querystring/index.js", function(exports, require, module){ + +/** + * Module dependencies. + */ + +var trim = require('trim'); + +/** + * Parse the given query `str`. + * + * @param {String} str + * @return {Object} + * @api public + */ + +exports.parse = function(str){ + if ('string' != typeof str) return {}; + + str = trim(str); + if ('' == str) return {}; + + var obj = {}; + var pairs = str.split('&'); + for (var i = 0; i < pairs.length; i++) { + var parts = pairs[i].split('='); + obj[parts[0]] = null == parts[1] + ? '' + : decodeURIComponent(parts[1]); + } + + return obj; +}; + +/** + * Stringify the given `obj`. + * + * @param {Object} obj + * @return {String} + * @api public + */ + +exports.stringify = function(obj){ + if (!obj) return ''; + var pairs = []; + for (var key in obj) { + pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])); + } + return pairs.join('&'); +}; + +}); +require.register("component-type/index.js", function(exports, require, module){ + +/** + * toString ref. + */ + +var toString = Object.prototype.toString; + +/** + * Return the type of `val`. + * + * @param {Mixed} val + * @return {String} + * @api public + */ + +module.exports = function(val){ + switch (toString.call(val)) { + case '[object Function]': return 'function'; + case '[object Date]': return 'date'; + case '[object RegExp]': return 'regexp'; + case '[object Arguments]': return 'arguments'; + case '[object Array]': return 'array'; + case '[object String]': return 'string'; + } + + if (val === null) return 'null'; + if (val === undefined) return 'undefined'; + if (val && val.nodeType === 1) return 'element'; + if (val === Object(val)) return 'object'; + + return typeof val; +}; + +}); +require.register("component-url/index.js", function(exports, require, module){ + +/** + * Parse the given `url`. + * + * @param {String} str + * @return {Object} + * @api public + */ + +exports.parse = function(url){ + var a = document.createElement('a'); + a.href = url; + return { + href: a.href, + host: a.host || location.host, + port: ('0' === a.port || '' === a.port) ? location.port : a.port, + hash: a.hash, + hostname: a.hostname || location.hostname, + pathname: a.pathname.charAt(0) != '/' ? '/' + a.pathname : a.pathname, + protocol: !a.protocol || ':' == a.protocol ? location.protocol : a.protocol, + search: a.search, + query: a.search.slice(1) + }; +}; + +/** + * Check if `url` is absolute. + * + * @param {String} url + * @return {Boolean} + * @api public + */ + +exports.isAbsolute = function(url){ + return 0 == url.indexOf('//') || !!~url.indexOf('://'); +}; + +/** + * Check if `url` is relative. + * + * @param {String} url + * @return {Boolean} + * @api public + */ + +exports.isRelative = function(url){ + return !exports.isAbsolute(url); +}; + +/** + * Check if `url` is cross domain. + * + * @param {String} url + * @return {Boolean} + * @api public + */ + +exports.isCrossDomain = function(url){ + url = exports.parse(url); + return url.hostname !== location.hostname + || url.port !== location.port + || url.protocol !== location.protocol; +}; +}); +require.register("segmentio-after/index.js", function(exports, require, module){ + +module.exports = function after (times, func) { + // After 0, really? + if (times <= 0) return func(); + + // That's more like it. + return function() { + if (--times < 1) { + return func.apply(this, arguments); + } + }; +}; +}); +require.register("segmentio-alias/index.js", function(exports, require, module){ + +module.exports = function alias (object, aliases) { + // For each of our aliases, rename our object's keys. + for (var oldKey in aliases) { + var newKey = aliases[oldKey]; + if (object[oldKey] !== undefined) { + object[newKey] = object[oldKey]; + delete object[oldKey]; + } + } +}; +}); +require.register("component-bind/index.js", function(exports, require, module){ + +/** + * Slice reference. + */ + +var slice = [].slice; + +/** + * Bind `obj` to `fn`. + * + * @param {Object} obj + * @param {Function|String} fn or string + * @return {Function} + * @api public + */ + +module.exports = function(obj, fn){ + if ('string' == typeof fn) fn = obj[fn]; + if ('function' != typeof fn) throw new Error('bind() requires a function'); + var args = [].slice.call(arguments, 2); + return function(){ + return fn.apply(obj, args.concat(slice.call(arguments))); + } +}; + +}); +require.register("segmentio-bind-all/index.js", function(exports, require, module){ + +var bind = require('bind') + , type = require('type'); + + +module.exports = function (obj) { + for (var key in obj) { + var val = obj[key]; + if (type(val) === 'function') obj[key] = bind(obj, obj[key]); + } + return obj; +}; +}); +require.register("segmentio-canonical/index.js", function(exports, require, module){ +module.exports = function canonical () { + var tags = document.getElementsByTagName('link'); + for (var i = 0, tag; tag = tags[i]; i++) { + if ('canonical' == tag.getAttribute('rel')) return tag.getAttribute('href'); + } +}; +}); +require.register("segmentio-extend/index.js", function(exports, require, module){ + +module.exports = function extend (object) { + // Takes an unlimited number of extenders. + var args = Array.prototype.slice.call(arguments, 1); + + // For each extender, copy their properties on our object. + for (var i = 0, source; source = args[i]; i++) { + if (!source) continue; + for (var property in source) { + object[property] = source[property]; + } + } + + return object; +}; +}); +require.register("segmentio-is-email/index.js", function(exports, require, module){ + +module.exports = function isEmail (string) { + return (/.+\@.+\..+/).test(string); +}; +}); +require.register("segmentio-is-meta/index.js", function(exports, require, module){ +module.exports = function isMeta (e) { + if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return true; + + // Logic that handles checks for the middle mouse button, based + // on [jQuery](https://github.com/jquery/jquery/blob/master/src/event.js#L466). + var which = e.which, button = e.button; + if (!which && button !== undefined) { + return (!button & 1) && (!button & 2) && (button & 4); + } else if (which === 2) { + return true; + } + + return false; +}; +}); +require.register("component-json-fallback/index.js", function(exports, require, module){ +/* + json2.js + 2011-10-19 + + Public Domain. + + NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. + + See http://www.JSON.org/js.html + + + This code should be minified before deployment. + See http://javascript.crockford.com/jsmin.html + + USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO + NOT CONTROL. + + + This file creates a global JSON object containing two methods: stringify + and parse. + + JSON.stringify(value, replacer, space) + value any JavaScript value, usually an object or array. + + replacer an optional parameter that determines how object + values are stringified for objects. It can be a + function or an array of strings. + + space an optional parameter that specifies the indentation + of nested structures. If it is omitted, the text will + be packed without extra whitespace. If it is a number, + it will specify the number of spaces to indent at each + level. If it is a string (such as '\t' or ' '), + it contains the characters used to indent at each level. + + This method produces a JSON text from a JavaScript value. + + When an object value is found, if the object contains a toJSON + method, its toJSON method will be called and the result will be + stringified. A toJSON method does not serialize: it returns the + value represented by the name/value pair that should be serialized, + or undefined if nothing should be serialized. The toJSON method + will be passed the key associated with the value, and this will be + bound to the value + + For example, this would serialize Dates as ISO strings. + + Date.prototype.toJSON = function (key) { + function f(n) { + // Format integers to have at least two digits. + return n < 10 ? '0' + n : n; + } + + return this.getUTCFullYear() + '-' + + f(this.getUTCMonth() + 1) + '-' + + f(this.getUTCDate()) + 'T' + + f(this.getUTCHours()) + ':' + + f(this.getUTCMinutes()) + ':' + + f(this.getUTCSeconds()) + 'Z'; + }; + + You can provide an optional replacer method. It will be passed the + key and value of each member, with this bound to the containing + object. The value that is returned from your method will be + serialized. If your method returns undefined, then the member will + be excluded from the serialization. + + If the replacer parameter is an array of strings, then it will be + used to select the members to be serialized. It filters the results + such that only members with keys listed in the replacer array are + stringified. + + Values that do not have JSON representations, such as undefined or + functions, will not be serialized. Such values in objects will be + dropped; in arrays they will be replaced with null. You can use + a replacer function to replace those with JSON values. + JSON.stringify(undefined) returns undefined. + + The optional space parameter produces a stringification of the + value that is filled with line breaks and indentation to make it + easier to read. + + If the space parameter is a non-empty string, then that string will + be used for indentation. If the space parameter is a number, then + the indentation will be that many spaces. + + Example: + + text = JSON.stringify(['e', {pluribus: 'unum'}]); + // text is '["e",{"pluribus":"unum"}]' + + + text = JSON.stringify(['e', {pluribus: 'unum'}], null, '\t'); + // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' + + text = JSON.stringify([new Date()], function (key, value) { + return this[key] instanceof Date ? + 'Date(' + this[key] + ')' : value; + }); + // text is '["Date(---current time---)"]' + + + JSON.parse(text, reviver) + This method parses a JSON text to produce an object or array. + It can throw a SyntaxError exception. + + The optional reviver parameter is a function that can filter and + transform the results. It receives each of the keys and values, + and its return value is used instead of the original value. + If it returns what it received, then the structure is not modified. + If it returns undefined then the member is deleted. + + Example: + + // Parse the text. Values that look like ISO date strings will + // be converted to Date objects. + + myData = JSON.parse(text, function (key, value) { + var a; + if (typeof value === 'string') { + a = +/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); + if (a) { + return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], + +a[5], +a[6])); + } + } + return value; + }); + + myData = JSON.parse('["Date(09/09/2001)"]', function (key, value) { + var d; + if (typeof value === 'string' && + value.slice(0, 5) === 'Date(' && + value.slice(-1) === ')') { + d = new Date(value.slice(5, -1)); + if (d) { + return d; + } + } + return value; + }); + + + This is a reference implementation. You are free to copy, modify, or + redistribute. +*/ + +/*jslint evil: true, regexp: true */ + +/*members "", "\b", "\t", "\n", "\f", "\r", "\"", JSON, "\\", apply, + call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, + getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, + lastIndex, length, parse, prototype, push, replace, slice, stringify, + test, toJSON, toString, valueOf +*/ + + +// Create a JSON object only if one does not already exist. We create the +// methods in a closure to avoid creating global variables. + +var JSON = {}; + +(function () { + 'use strict'; + + function f(n) { + // Format integers to have at least two digits. + return n < 10 ? '0' + n : n; + } + + if (typeof Date.prototype.toJSON !== 'function') { + + Date.prototype.toJSON = function (key) { + + return isFinite(this.valueOf()) + ? this.getUTCFullYear() + '-' + + f(this.getUTCMonth() + 1) + '-' + + f(this.getUTCDate()) + 'T' + + f(this.getUTCHours()) + ':' + + f(this.getUTCMinutes()) + ':' + + f(this.getUTCSeconds()) + 'Z' + : null; + }; + + String.prototype.toJSON = + Number.prototype.toJSON = + Boolean.prototype.toJSON = function (key) { + return this.valueOf(); + }; + } + + var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + gap, + indent, + meta = { // table of character substitutions + '\b': '\\b', + '\t': '\\t', + '\n': '\\n', + '\f': '\\f', + '\r': '\\r', + '"' : '\\"', + '\\': '\\\\' + }, + rep; + + + function quote(string) { + +// If the string contains no control characters, no quote characters, and no +// backslash characters, then we can safely slap some quotes around it. +// Otherwise we must also replace the offending characters with safe escape +// sequences. + + escapable.lastIndex = 0; + return escapable.test(string) ? '"' + string.replace(escapable, function (a) { + var c = meta[a]; + return typeof c === 'string' + ? c + : '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }) + '"' : '"' + string + '"'; + } + + + function str(key, holder) { + +// Produce a string from holder[key]. + + var i, // The loop counter. + k, // The member key. + v, // The member value. + length, + mind = gap, + partial, + value = holder[key]; + +// If the value has a toJSON method, call it to obtain a replacement value. + + if (value && typeof value === 'object' && + typeof value.toJSON === 'function') { + value = value.toJSON(key); + } + +// If we were called with a replacer function, then call the replacer to +// obtain a replacement value. + + if (typeof rep === 'function') { + value = rep.call(holder, key, value); + } + +// What happens next depends on the value's type. + + switch (typeof value) { + case 'string': + return quote(value); + + case 'number': + +// JSON numbers must be finite. Encode non-finite numbers as null. + + return isFinite(value) ? String(value) : 'null'; + + case 'boolean': + case 'null': + +// If the value is a boolean or null, convert it to a string. Note: +// typeof null does not produce 'null'. The case is included here in +// the remote chance that this gets fixed someday. + + return String(value); + +// If the type is 'object', we might be dealing with an object or an array or +// null. + + case 'object': + +// Due to a specification blunder in ECMAScript, typeof null is 'object', +// so watch out for that case. + + if (!value) { + return 'null'; + } + +// Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + +// Is the value an array? + + if (Object.prototype.toString.apply(value) === '[object Array]') { + +// The value is an array. Stringify every element. Use null as a placeholder +// for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || 'null'; + } + +// Join all of the elements together, separated with commas, and wrap them in +// brackets. + + v = partial.length === 0 + ? '[]' + : gap + ? '[\n' + gap + partial.join(',\n' + gap) + '\n' + mind + ']' + : '[' + partial.join(',') + ']'; + gap = mind; + return v; + } + +// If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === 'object') { + length = rep.length; + for (i = 0; i < length; i += 1) { + if (typeof rep[i] === 'string') { + k = rep[i]; + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } else { + +// Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } + +// Join all of the member texts together, separated with commas, +// and wrap them in braces. + + v = partial.length === 0 + ? '{}' + : gap + ? '{\n' + gap + partial.join(',\n' + gap) + '\n' + mind + '}' + : '{' + partial.join(',') + '}'; + gap = mind; + return v; + } + } + +// If the JSON object does not yet have a stringify method, give it one. + + if (typeof JSON.stringify !== 'function') { + JSON.stringify = function (value, replacer, space) { + +// The stringify method takes a value and an optional replacer, and an optional +// space parameter, and returns a JSON text. The replacer can be a function +// that can replace values, or an array of strings that will select the keys. +// A default replacer method can be provided. Use of the space parameter can +// produce text that is more easily readable. + + var i; + gap = ''; + indent = ''; + +// If the space parameter is a number, make an indent string containing that +// many spaces. + + if (typeof space === 'number') { + for (i = 0; i < space; i += 1) { + indent += ' '; + } + +// If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === 'string') { + indent = space; + } + +// If there is a replacer, it must be a function or an array. +// Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== 'function' && + (typeof replacer !== 'object' || + typeof replacer.length !== 'number')) { + throw new Error('JSON.stringify'); + } + +// Make a fake root object containing our value under the key of ''. +// Return the result of stringifying the value. + + return str('', {'': value}); + }; + } + + +// If the JSON object does not yet have a parse method, give it one. + + if (typeof JSON.parse !== 'function') { + JSON.parse = function (text, reviver) { + +// The parse method takes a text and an optional reviver function, and returns +// a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + +// The walk method is used to recursively walk the resulting structure so +// that modifications can be made. + + var k, v, value = holder[key]; + if (value && typeof value === 'object') { + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + +// Parsing happens in four stages. In the first stage, we replace certain +// Unicode characters with escape sequences. JavaScript handles many characters +// incorrectly, either silently deleting them, or treating them as line endings. + + text = String(text); + cx.lastIndex = 0; + if (cx.test(text)) { + text = text.replace(cx, function (a) { + return '\\u' + + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }); + } + +// In the second stage, we run the text against regular expressions that look +// for non-JSON patterns. We are especially concerned with '()' and 'new' +// because they can cause invocation, and '=' because it can cause mutation. +// But just to be safe, we want to reject all unexpected forms. + +// We split the second stage into 4 regexp operations in order to work around +// crippling inefficiencies in IE's and Safari's regexp engines. First we +// replace the JSON backslash pairs with '@' (a non-JSON character). Second, we +// replace all simple value tokens with ']' characters. Third, we delete all +// open brackets that follow a colon or comma or that begin the text. Finally, +// we look to see that the remaining characters are only whitespace or ']' or +// ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval. + + if (/^[\],:{}\s]*$/ + .test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@') + .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']') + .replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { + +// In the third stage we use the eval function to compile the text into a +// JavaScript structure. The '{' operator is subject to a syntactic ambiguity +// in JavaScript: it can begin a block or an object literal. We wrap the text +// in parens to eliminate the ambiguity. + + j = eval('(' + text + ')'); + +// In the optional fourth stage, we recursively walk the new structure, passing +// each name/value pair to a reviver function for possible transformation. + + return typeof reviver === 'function' + ? walk({'': j}, '') + : j; + } + +// If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError('JSON.parse'); + }; + } +}()); + +module.exports = JSON +}); +require.register("segmentio-json/index.js", function(exports, require, module){ + +module.exports = 'undefined' == typeof JSON + ? require('json-fallback') + : JSON; + +}); +require.register("segmentio-load-date/index.js", function(exports, require, module){ + + +/* + * Load date. + * + * For reference: http://www.html5rocks.com/en/tutorials/webperformance/basics/ + */ + +var time = new Date() + , perf = window.performance; + +if (perf && perf.timing && perf.timing.responseEnd) { + time = new Date(perf.timing.responseEnd); +} + +module.exports = time; +}); +require.register("segmentio-load-script/index.js", function(exports, require, module){ +var type = require('type'); + + +module.exports = function loadScript (options, callback) { + if (!options) throw new Error('Cant load nothing...'); + + // Allow for the simplest case, just passing a `src` string. + if (type(options) === 'string') options = { src : options }; + + var https = document.location.protocol === 'https:'; + + // If you use protocol relative URLs, third-party scripts like Google + // Analytics break when testing with `file:` so this fixes that. + if (options.src && options.src.indexOf('//') === 0) { + options.src = https ? 'https:' + options.src : 'http:' + options.src; + } + + // Allow them to pass in different URLs depending on the protocol. + if (https && options.https) options.src = options.https; + else if (!https && options.http) options.src = options.http; + + // Make the ` + + - +%if instructor_tasks is not None: + > +%endif <%include file="/courseware/course_navigation.html" args="active_page='instructor'" /> @@ -193,20 +195,78 @@ function goto( mode)
+ %endif + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): +

Course-specific grade adjustment

+ +

+ Specify a particular problem in the course here by its url: + +

+

+ You may use just the "urlname" if a problem, or "modulename/urlname" if not. + (For example, if the location is i4x://university/course/problem/problemname, + then just provide the problemname. + If the location is i4x://university/course/notaproblem/someothername, then + provide notaproblem/someothername.) +

+

+ Then select an action: + + +

+

+

These actions run in the background, and status for active tasks will appear in a table below. + To see status for all tasks submitted for this problem, click on this button: +

+

+ +

+ +
%endif

Student-specific grade inspection and adjustment

-

edX email address or their username:

-

-

and, if you want to reset the number of attempts for a problem, the urlname of that problem - (e.g. if the location is i4x://university/course/problem/problemname, then the urlname is problemname).

-

+

+ Specify the edX email address or username of a student here: + +

+

+ Click this, and a link to student's progress page will appear below: + +

+

+ Specify a particular problem in the course here by its url: + +

+

+ You may use just the "urlname" if a problem, or "modulename/urlname" if not. + (For example, if the location is i4x://university/course/problem/problemname, + then just provide the problemname. + If the location is i4x://university/course/notaproblem/someothername, then + provide notaproblem/someothername.) +

+

+ Then select an action: + + %if settings.MITX_FEATURES.get('ENABLE_COURSE_BACKGROUND_TASKS'): + + %endif +

%if instructor_access: -

You may also delete the entire state of a student for a problem: -

-

To delete the state of other XBlocks specify modulename/urlname, eg - combinedopenended/Humanities_SA_Peer

+

+ You may also delete the entire state of a student for the specified module: + +

+ %endif + %if settings.MITX_FEATURES.get('ENABLE_COURSE_BACKGROUND_TASKS'): +

Rescoring runs in the background, and status for active tasks will appear in a table below. + To see status for all tasks submitted for this course and student, click on this button: +

+

+ +

%endif %endif @@ -234,6 +294,7 @@ function goto( mode) ##----------------------------------------------------------------------------- %if modeflag.get('Admin'): + %if instructor_access:

@@ -373,6 +434,7 @@ function goto( mode) %if msg:

${msg}

%endif + ##----------------------------------------------------------------------------- %if modeflag.get('Analytics'): @@ -559,6 +621,69 @@ function goto( mode)

%endif +## Output tasks in progress + +%if instructor_tasks is not None and len(instructor_tasks) > 0: +
+

Pending Instructor Tasks

+
+ + + + + + + + + + + + %for tasknum, instructor_task in enumerate(instructor_tasks): + + + + + + + + + + + %endfor +
Task TypeTask inputsTask IdRequesterSubmittedTask StateDuration (sec)Task Progress
${instructor_task.task_type}${instructor_task.task_input}${instructor_task.task_id}${instructor_task.requester}${instructor_task.created}${instructor_task.task_state}unknownunknown
+
+
+ +%endif + +##----------------------------------------------------------------------------- + +%if course_stats and modeflag.get('Psychometrics') is None: + +
+
+

+


+

${course_stats['title'] | h}

+ + + %for hname in course_stats['header']: + + %endfor + + %for row in course_stats['data']: + + %for value in row: + + %endfor + + %endfor +
${hname | h}
${value | h}
+

+%endif + ##----------------------------------------------------------------------------- %if modeflag.get('Psychometrics'): diff --git a/lms/urls.py b/lms/urls.py index 74ac44cf59..1d34ebf3af 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -394,6 +394,11 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): url(r'^status/', include('service_status.urls')), ) +if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): + urlpatterns += ( + url(r'^instructor_task_status/$', 'instructor_task.views.instructor_task_status', name='instructor_task_status'), + ) + # FoldIt views urlpatterns += ( # The path is hardcoded into their app... diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index dc39bd5fa4..5ce748e7b5 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -3,11 +3,11 @@ # Third-party: -e git://github.com/edx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles -e git://github.com/edx/django-pipeline.git#egg=django-pipeline --e git://github.com/edx/django-wiki.git@e2e84558#egg=django-wiki +-e git://github.com/edx/django-wiki.git@ac906abe#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: -e git+https://github.com/edx/XBlock.git@4d8735e883#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail --e git+https://github.com/edx/diff-cover.git@v0.1.1#egg=diff_cover +-e git+https://github.com/edx/diff-cover.git@v0.1.2#egg=diff_cover