From 4c8a45f85ecfb6422bd10de3b79f3a5ef51c70f9 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 14 Mar 2013 13:29:26 -0400 Subject: [PATCH 01/60] Code to add in an open ended tab automatically --- cms/djangoapps/contentstore/utils.py | 12 +++++++- cms/djangoapps/contentstore/views.py | 28 +++++++++++++++++-- .../models/settings/course_metadata.py | 9 ++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index cba30131b5..4113361445 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -2,9 +2,10 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +import copy DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] - +OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"} def get_modulestore(location): """ @@ -158,3 +159,12 @@ def update_item(location, value): get_modulestore(location).delete_item(location) else: get_modulestore(location).update_item(location, value) + +def add_open_ended_panel_tab(course): + course_tabs = copy.copy(course.tabs) + changed = False + if OPEN_ENDED_PANEL not in course_tabs: + course_tabs.append(OPEN_ENDED_PANEL) + changed = True + return changed, course_tabs + diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 6566350f8d..b066f476a3 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -47,6 +47,7 @@ from auth.authz import is_user_in_course_group_role, get_users_in_course_group_b from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item +from .utils import add_open_ended_panel_tab from xmodule.modulestore.xml_importer import import_from_xml from contentstore.course_info_model import get_course_updates,\ @@ -68,7 +69,8 @@ log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] -ADVANCED_COMPONENT_TYPES = ['annotatable','combinedopenended', 'peergrading'] +OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] +ADVANCED_COMPONENT_TYPES = ['annotatable'] + OPEN_ENDED_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @@ -295,6 +297,9 @@ def edit_unit(request, location): # in ADVANCED_COMPONENT_TYPES that should be enabled for the course. course_metadata = CourseMetadata.fetch(course.location) course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, []) + log.debug(course.tabs) + log.debug(type(course.tabs)) + log.debug("LOOK HERE NOW!!!!!") # Set component types according to course policy file component_types = list(COMPONENT_TYPES) @@ -1329,7 +1334,26 @@ def course_advanced_updates(request, org, course, name): return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json") elif real_method == 'POST' or real_method == 'PUT': # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key - return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json") + request_body = json.loads(request.body) + filter_tabs = True + if ADVANCED_COMPONENT_POLICY_KEY in request_body: + log.debug("Advanced component in.") + for oe_type in OPEN_ENDED_COMPONENT_TYPES: + log.debug(request_body[ADVANCED_COMPONENT_POLICY_KEY]) + if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: + log.debug("OE type in.") + course_module = modulestore().get_item(location) + changed, new_tabs = add_open_ended_panel_tab(course_module) + log.debug(new_tabs) + if changed: + request_body.update({'tabs' : new_tabs}) + filter_tabs = False + break + log.debug(request_body) + log.debug(filter_tabs) + log.debug("LOOK HERE FOR TAB SAVING!!!!") + response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) + return HttpResponse(response_json, mimetype="application/json") @login_required diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 24245a39d5..af0923213b 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -1,6 +1,7 @@ from xmodule.modulestore import Location from contentstore.utils import get_modulestore from xmodule.x_module import XModuleDescriptor +import copy class CourseMetadata(object): @@ -30,7 +31,7 @@ class CourseMetadata(object): return course @classmethod - def update_from_json(cls, course_location, jsondict): + def update_from_json(cls, course_location, jsondict, filter_tabs=True): """ Decode the json into CourseMetadata and save any changed attrs to the db. @@ -40,9 +41,13 @@ class CourseMetadata(object): dirty = False + filtered_list = copy.copy(cls.FILTERED_LIST) + if not filter_tabs: + filtered_list.remove("tabs") + for k, v in jsondict.iteritems(): # should it be an error if one of the filtered list items is in the payload? - if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v): + if k not in filtered_list and (k not in descriptor.metadata or descriptor.metadata[k] != v): dirty = True descriptor.metadata[k] = v From a717dffd4886a185ae2d4414f060e295871dbd82 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 14 Mar 2013 13:31:30 -0400 Subject: [PATCH 02/60] Remove debug statements --- cms/djangoapps/contentstore/views.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index b066f476a3..591ec7d7cf 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -297,10 +297,7 @@ def edit_unit(request, location): # in ADVANCED_COMPONENT_TYPES that should be enabled for the course. course_metadata = CourseMetadata.fetch(course.location) course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, []) - log.debug(course.tabs) - log.debug(type(course.tabs)) - log.debug("LOOK HERE NOW!!!!!") - + # Set component types according to course policy file component_types = list(COMPONENT_TYPES) if isinstance(course_advanced_keys, list): @@ -1337,21 +1334,14 @@ def course_advanced_updates(request, org, course, name): request_body = json.loads(request.body) filter_tabs = True if ADVANCED_COMPONENT_POLICY_KEY in request_body: - log.debug("Advanced component in.") for oe_type in OPEN_ENDED_COMPONENT_TYPES: - log.debug(request_body[ADVANCED_COMPONENT_POLICY_KEY]) if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: - log.debug("OE type in.") course_module = modulestore().get_item(location) changed, new_tabs = add_open_ended_panel_tab(course_module) - log.debug(new_tabs) if changed: request_body.update({'tabs' : new_tabs}) filter_tabs = False break - log.debug(request_body) - log.debug(filter_tabs) - log.debug("LOOK HERE FOR TAB SAVING!!!!") response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) return HttpResponse(response_json, mimetype="application/json") From 10eb7e45ea58a776113087515c1a00748f954320 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 14 Mar 2013 13:42:41 -0400 Subject: [PATCH 03/60] Add in some docs --- cms/djangoapps/contentstore/utils.py | 8 ++++++++ cms/djangoapps/contentstore/views.py | 14 +++++++++++--- cms/djangoapps/models/settings/course_metadata.py | 2 ++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 4113361445..7e034d8da8 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -161,9 +161,17 @@ def update_item(location, value): get_modulestore(location).update_item(location, value) def add_open_ended_panel_tab(course): + """ + Used to add the open ended panel tab to a course if it does not exist. + @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 course_tabs = copy.copy(course.tabs) changed = False + #Check to see if open ended panel is defined in the course if OPEN_ENDED_PANEL not in course_tabs: + #Add panel to the tabs if it is not defined course_tabs.append(OPEN_ENDED_PANEL) changed = True return changed, course_tabs diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 591ec7d7cf..b7fcc9988e 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -297,7 +297,7 @@ def edit_unit(request, location): # in ADVANCED_COMPONENT_TYPES that should be enabled for the course. course_metadata = CourseMetadata.fetch(course.location) course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, []) - + # Set component types according to course policy file component_types = list(COMPONENT_TYPES) if isinstance(course_advanced_keys, list): @@ -310,7 +310,6 @@ def edit_unit(request, location): templates = modulestore().get_items(Location('i4x', 'edx', 'templates')) for template in templates: category = template.location.category - if category in course_advanced_keys: category = ADVANCED_COMPONENT_CATEGORY @@ -1332,15 +1331,24 @@ def course_advanced_updates(request, org, course, name): elif real_method == 'POST' or real_method == 'PUT': # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key request_body = json.loads(request.body) + #Whether or not to filter the tabs key out of the settings metadata filter_tabs = True + #Check to see if the user instantiated any advanced components. This is a hack to add the open ended panel tab + #to a course automatically if the user has indicated that they want to edit the combinedopenended or peergrading + #module. if ADVANCED_COMPONENT_POLICY_KEY in request_body: + #Check to see if the user instantiated any open ended components for oe_type in OPEN_ENDED_COMPONENT_TYPES: if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: + #Get the course so that we can scrape current tabs course_module = modulestore().get_item(location) + #Add an open ended tab to the course if needed changed, new_tabs = add_open_ended_panel_tab(course_module) + #If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json if changed: request_body.update({'tabs' : new_tabs}) - filter_tabs = False + #Indicate that tabs should not be filtered out of the metadata + filter_tabs = False break response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) return HttpResponse(response_json, mimetype="application/json") diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index af0923213b..2747cc0751 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -41,7 +41,9 @@ class CourseMetadata(object): dirty = False + #Copy the filtered list to avoid permanently changing the class attribute filtered_list = copy.copy(cls.FILTERED_LIST) + #Don't filter on the tab attribute if filter_tabs is False if not filter_tabs: filtered_list.remove("tabs") From dc983cb9a5d5920903c2d1fd2c6be2269443d495 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 22 Mar 2013 11:15:14 -0400 Subject: [PATCH 04/60] add checking for metadata that we can't support editing for in Studio. This is now an error and will have to be addressed by course authors --- .../contentstore/tests/test_contentstore.py | 6 ++++- .../xmodule/modulestore/xml_importer.py | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 615ffb6ed0..34c4b761b7 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -25,7 +25,7 @@ from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.inheritance import own_metadata from xmodule.capa_module import CapaDescriptor @@ -115,6 +115,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # check that there's actually content in the 'question' field self.assertGreater(len(items[0].question),0) + def test_xlint_fails(self): + err_cnt = perform_xlint('common/test/data', ['full']) + self.assertGreater(err_cnt, 0) + def test_delete(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 6a4ce5131b..bf1c8be612 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -356,6 +356,24 @@ def remap_namespace(module, target_location_namespace): return module +def validate_no_non_editable_metadata(module_store, course_id, category): + ''' + Assert that there is no metadata within a particular category that we can't support editing + ''' + err_cnt = 0 + for module_loc in module_store.modules[course_id]: + module = module_store.modules[course_id][module_loc] + if module.location.category == category: + my_metadata = dict(own_metadata(module)) + for key in my_metadata.keys(): + if key != 'xml_attributes' and key != 'display_name': + err_cnt = err_cnt + 1 + print 'ERROR: found metadata on {0}. Metadata: {1} = {2}'.format( + module.location.url(), key, my_metadata[key]) + + return err_cnt + + def validate_category_hierarchy(module_store, course_id, parent_category, expected_child_category): err_cnt = 0 @@ -440,6 +458,8 @@ def perform_xlint(data_dir, course_dirs, err_cnt += validate_category_hierarchy(module_store, course_id, "chapter", "sequential") # constrain that sequentials only have 'verticals' err_cnt += validate_category_hierarchy(module_store, course_id, "sequential", "vertical") + # don't allow metadata on verticals, since we can't edit them in studio + err_cnt += validate_no_non_editable_metadata(module_store, course_id, "vertical") # check for a presence of a course marketing video location_elements = course_id.split('/') @@ -456,3 +476,5 @@ def perform_xlint(data_dir, course_dirs, print "This course can be imported, but some errors may occur during the run of the course. It is recommend that you fix your courseware before importing" else: print "This course can be imported successfully." + + return err_cnt From 47e47303dca6cf6b81272a5a8882951d88c8d8b8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 13:36:27 -0400 Subject: [PATCH 05/60] Refactored CustomResponse to use the same private func to handle all errors related to execution of python code. CustomResponse now returns subclasses of Exception instead of general Exceptions CustomResponse no longer includes tracebacks in the exceptions it raises (and shows to students) --- common/lib/capa/capa/responsetypes.py | 37 ++++++++++++------- .../lib/capa/capa/tests/test_responsetypes.py | 17 +++++++-- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 8ab716735c..a69c26572d 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1072,13 +1072,11 @@ def sympy_check2(): correct = self.context['correct'] messages = self.context['messages'] overall_message = self.context['overall_message'] + except Exception as err: - print "oops in customresponse (code) error %s" % err - print "context = ", self.context - print traceback.format_exc() - # Notify student - raise StudentInputError( - "Error: Problem could not be evaluated with your input") + self._handle_exec_exception(err) + pass + else: # self.code is not a string; assume its a function @@ -1105,13 +1103,9 @@ def sympy_check2(): nargs, args, kwargs)) ret = fn(*args[:nargs], **kwargs) + except Exception as err: - log.error("oops in customresponse (cfn) error %s" % err) - # print "context = ",self.context - log.error(traceback.format_exc()) - raise Exception("oops in customresponse (cfn) error %s" % err) - log.debug( - "[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret) + self._handle_exec_exception(err) if type(ret) == dict: @@ -1157,7 +1151,7 @@ def sympy_check2(): # Raise an exception else: log.error(traceback.format_exc()) - raise Exception( + raise LoncapaProblemError( "CustomResponse: check function returned an invalid dict") # The check function can return a boolean value, @@ -1227,6 +1221,23 @@ def sympy_check2(): return {self.answer_ids[0]: self.expect} return self.default_answer_map + def _handle_exec_exception(self, err): + ''' + Handle an exception raised during the execution of + custom Python code. + + Raises a StudentInputError + ''' + + # Log the error if we are debugging + msg = 'Error occurred while evaluating CustomResponse: %s' % str(err) + log.debug(msg) + log.debug(traceback.format_exc()) + + # Notify student + raise StudentInputError( + "Error: Problem could not be evaluated with your input") + #----------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 0c007f83b2..ac50e6defc 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -13,6 +13,7 @@ import textwrap from . import test_system import capa.capa_problem as lcp +from capa.responsetypes import LoncapaProblemError, StudentInputError from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames from capa.xqueue_interface import dateformat @@ -853,7 +854,7 @@ class CustomResponseTest(ResponseTest): # Message is interpreted as an "overall message" self.assertEqual(correct_map.get_overall_message(), 'Message text') - def test_script_exception(self): + def test_script_exception_function(self): # Construct a script that will raise an exception script = textwrap.dedent(""" @@ -864,7 +865,17 @@ class CustomResponseTest(ResponseTest): problem = self.build_problem(script=script, cfn="check_func") # Expect that an exception gets raised when we check the answer - with self.assertRaises(Exception): + with self.assertRaises(StudentInputError): + problem.grade_answers({'1_2_1': '42'}) + + def test_script_exception_inline(self): + + # Construct a script that will raise an exception + script = 'raise Exception("Test")' + problem = self.build_problem(answer=script) + + # Expect that an exception gets raised when we check the answer + with self.assertRaises(StudentInputError): problem.grade_answers({'1_2_1': '42'}) def test_invalid_dict_exception(self): @@ -878,7 +889,7 @@ class CustomResponseTest(ResponseTest): problem = self.build_problem(script=script, cfn="check_func") # Expect that an exception gets raised when we check the answer - with self.assertRaises(Exception): + with self.assertRaises(LoncapaProblemError): problem.grade_answers({'1_2_1': '42'}) From 1b07b85ef2a2ac9ff8db60da840ac57f2ec2e5ac Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 13:43:26 -0400 Subject: [PATCH 06/60] Removed extra 'pass' statement --- common/lib/capa/capa/responsetypes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index a69c26572d..b1f4aaf5a2 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1075,7 +1075,6 @@ def sympy_check2(): except Exception as err: self._handle_exec_exception(err) - pass else: # self.code is not a string; assume its a function From cd6f92c7e279049b683712300576f3ab9917d3ef Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 14:27:41 -0400 Subject: [PATCH 07/60] Modified log.debug call to use exc_info=True --- common/lib/capa/capa/responsetypes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index b1f4aaf5a2..2c556211f8 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1229,8 +1229,8 @@ def sympy_check2(): ''' # Log the error if we are debugging - msg = 'Error occurred while evaluating CustomResponse: %s' % str(err) - log.debug(msg) + msg = 'Error occurred while evaluating CustomResponse' + log.debug(msg, exc_info=True) log.debug(traceback.format_exc()) # Notify student From 7a238935578c958f5cbba47554466825049ce8aa Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 26 Mar 2013 16:40:28 -0400 Subject: [PATCH 08/60] wip --- cms/djangoapps/contentstore/utils.py | 6 +- cms/djangoapps/contentstore/views.py | 2 +- cms/templates/widgets/units.html | 2 +- cms/xmodule_namespace.py | 1 - .../xmodule/xmodule/modulestore/__init__.py | 11 +++ .../lib/xmodule/xmodule/modulestore/draft.py | 69 +++++++++++++++---- .../lib/xmodule/xmodule/modulestore/mongo.py | 20 ++---- .../xmodule/modulestore/store_utilities.py | 1 + 8 files changed, 77 insertions(+), 35 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 63dfe5bf5f..1660b227f6 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1,3 +1,4 @@ +import logging from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -127,7 +128,7 @@ class UnitState(object): public = 'public' -def compute_unit_state(unit): +def compute_unit_state(unit, subsection=None): """ Returns whether this unit is 'draft', 'public', or 'private'. @@ -137,7 +138,8 @@ def compute_unit_state(unit): 'private' content is editabled and not visible in the LMS """ - if unit.cms.is_draft: + logging.debug('****** is_draft = {0}'.format(getattr(unit, 'is_draft', False))) + if getattr(unit, 'is_draft', False): try: modulestore('direct').get_item(unit.location) return UnitState.draft diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 561708c833..edbaed3afa 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -188,7 +188,7 @@ def course_index(request, org, course, name): 'coursename': name }) - course = modulestore().get_item(location) + course = modulestore().get_item(location, depth=3) sections = course.get_children() return render_to_response('overview.html', { diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html index 5ac05e79eb..c7dbf88341 100644 --- a/cms/templates/widgets/units.html +++ b/cms/templates/widgets/units.html @@ -13,7 +13,7 @@ This def will enumerate through a passed in subsection and list all of the units % for unit in subsection_units:
  • <% - unit_state = compute_unit_state(unit) + unit_state = compute_unit_state(unit, subsection=subsection) if unit.location == selected: selected_class = 'editing' else: diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py index cad3110574..c9bb8f4c6e 100644 --- a/cms/xmodule_namespace.py +++ b/cms/xmodule_namespace.py @@ -40,7 +40,6 @@ class CmsNamespace(Namespace): """ Namespace with fields common to all blocks in Studio """ - is_draft = Boolean(help="Whether this module is a draft", default=False, scope=Scope.settings) published_date = DateTuple(help="Date when the module was published", scope=Scope.settings) published_by = String(help="Id of the user who published this module", scope=Scope.settings) empty = StringyBoolean(help="Whether this is an empty template", scope=Scope.settings, default=False) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 022e016a58..2593b04472 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -10,6 +10,7 @@ from collections import namedtuple from .exceptions import InvalidLocationError, InsufficientSpecificationError from xmodule.errortracker import ErrorLog, make_error_tracker +from bson.son import SON log = logging.getLogger('mitx.' + 'modulestore') @@ -457,3 +458,13 @@ class ModuleStoreBase(ModuleStore): if c.id == course_id: return c return None + + +def namedtuple_to_son(namedtuple, prefix=''): + """ + Converts a namedtuple into a SON object with the same key order + """ + son = SON() + for idx, field_name in enumerate(namedtuple._fields): + son[prefix + field_name] = namedtuple[idx] + return son diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 71922c08df..0c647159ed 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -1,7 +1,8 @@ from datetime import datetime -from . import ModuleStoreBase, Location +from . import ModuleStoreBase, Location, namedtuple_to_son from .exceptions import ItemNotFoundError +import logging DRAFT = 'draft' @@ -15,11 +16,11 @@ def as_draft(location): def wrap_draft(item): """ - Sets `item.cms.is_draft` to `True` if the item is a + Sets `item.is_draft` to `True` if the item is a draft, and `False` otherwise. Sets the item's location to the non-draft location in either case """ - item.cms.is_draft = item.location.revision == DRAFT + setattr(item, 'is_draft', item.location.revision == DRAFT) item.location = item.location._replace(revision=None) return item @@ -55,11 +56,10 @@ class DraftModuleStore(ModuleStoreBase): get_children() to cache. None indicates to cache all descendents """ - # cdodge: we're forcing depth=0 here as the Draft store is not handling caching well try: - return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=0)) + return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth)) except ItemNotFoundError: - return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=0)) + return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=depth)) def get_instance(self, course_id, location, depth=0): """ @@ -67,11 +67,10 @@ class DraftModuleStore(ModuleStoreBase): TODO (vshnayder): this may want to live outside the modulestore eventually """ - # cdodge: we're forcing depth=0 here as the Draft store is not handling caching well try: - return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, as_draft(location), depth=0)) + return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, as_draft(location), depth=depth)) except ItemNotFoundError: - return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=0)) + return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth)) def get_items(self, location, course_id=None, depth=0): """ @@ -88,9 +87,8 @@ class DraftModuleStore(ModuleStoreBase): """ draft_loc = as_draft(location) - # cdodge: we're forcing depth=0 here as the Draft store is not handling caching well - draft_items = super(DraftModuleStore, self).get_items(draft_loc, course_id=course_id, depth=0) - items = super(DraftModuleStore, self).get_items(location, course_id=course_id, depth=0) + draft_items = super(DraftModuleStore, self).get_items(draft_loc, course_id=course_id, depth=depth) + items = super(DraftModuleStore, self).get_items(location, course_id=course_id, depth=depth) draft_locs_found = set(item.location._replace(revision=None) for item in draft_items) non_draft_items = [ @@ -118,7 +116,7 @@ class DraftModuleStore(ModuleStoreBase): """ draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.cms.is_draft: + if not getattr(draft_item, 'is_draft', False): self.clone_item(location, draft_loc) return super(DraftModuleStore, self).update_item(draft_loc, data) @@ -133,7 +131,7 @@ class DraftModuleStore(ModuleStoreBase): """ draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.cms.is_draft: + if not getattr(draft_item, 'is_draft', False): self.clone_item(location, draft_loc) return super(DraftModuleStore, self).update_children(draft_loc, children) @@ -149,7 +147,7 @@ class DraftModuleStore(ModuleStoreBase): draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.cms.is_draft: + if not getattr(draft_item, 'is_draft', False): self.clone_item(location, draft_loc) if 'is_draft' in metadata: @@ -192,3 +190,44 @@ class DraftModuleStore(ModuleStoreBase): """ super(DraftModuleStore, self).clone_item(location, as_draft(location)) super(DraftModuleStore, self).delete_item(location) + + def _cache_children(self, items, depth=0): + """ + Returns a dictionary mapping Location -> item data, populated with json data + for all descendents of items up to the specified depth. + (0 = no descendents, 1 = children, 2 = grandchildren, etc) + If depth is None, will load all the children. + This will make a number of queries that is linear in the depth. + """ + + data = {} + to_process = list(items) + while to_process and depth is None or depth >= 0: + children = [] + for item in to_process: + self._clean_item_data(item) + children.extend(item.get('definition', {}).get('children', [])) + data[Location(item['location'])] = item + + # Load all children by id. See + # http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24or + # for or-query syntax + if children: + query = { + '_id': {'$in': [namedtuple_to_son(Location(child)) for child in children]} + } + to_process = list(self.collection.find(query)) + + query = { + '_id': {'$in': [namedtuple_to_son(as_draft(Location(child))) for child in children]} + } + to_process.extend(list(self.collection.find(query))) + logging.debug('**** depth = {0}'.format(depth)) + logging.debug('**** to_process = {0}'.format(to_process)) + else: + to_process = [] + # If depth is None, then we just recurse until we hit all the descendents + if depth is not None: + depth -= 1 + + return data \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index b76251bb99..7fa2d9a5c0 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -3,7 +3,6 @@ import sys import logging import copy -from bson.son import SON from collections import namedtuple from fs.osfs import OSFS from itertools import repeat @@ -18,7 +17,7 @@ from xmodule.error_module import ErrorDescriptor from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError from xblock.core import Scope -from . import ModuleStoreBase, Location +from . import ModuleStoreBase, Location, namedtuple_to_son from .draft import DraftModuleStore from .exceptions import (ItemNotFoundError, DuplicateItemError) @@ -196,16 +195,6 @@ def location_to_query(location, wildcard=True): return query -def namedtuple_to_son(namedtuple, prefix=''): - """ - Converts a namedtuple into a SON object with the same key order - """ - son = SON() - for idx, field_name in enumerate(namedtuple._fields): - son[prefix + field_name] = namedtuple[idx] - return son - - class MongoModuleStore(ModuleStoreBase): """ A Mongodb backed ModuleStore @@ -372,13 +361,14 @@ class MongoModuleStore(ModuleStoreBase): # Load all children by id. See # http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24or # for or-query syntax - if children: + if children and depth > 0: query = { '_id': {'$in': [namedtuple_to_son(Location(child)) for child in children]} } - to_process = self.collection.find(query) + to_process = list(self.collection.find(query)) else: - to_process = [] + break + # If depth is None, then we just recurse until we hit all the descendents if depth is not None: depth -= 1 diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py index cb3cd375a7..2935069090 100644 --- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -136,3 +136,4 @@ def delete_course(modulestore, contentstore, source_location, commit = False): modulestore.delete_item(source_location) return True + From 285e3ee1edfbb5cbb22f821b05b7a752aab25c73 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 10:49:47 -0400 Subject: [PATCH 09/60] Capa response now displays full stack trace on student input error if the user is a staff member. Otherwise, it displays just the exception message. --- common/lib/capa/capa/responsetypes.py | 7 +++--- common/lib/xmodule/xmodule/capa_module.py | 16 +++++++++++- .../xmodule/xmodule/tests/test_capa_module.py | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 2c556211f8..465c212b30 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -17,6 +17,7 @@ import logging import numbers import numpy import os +import sys import random import re import requests @@ -1233,9 +1234,9 @@ def sympy_check2(): log.debug(msg, exc_info=True) log.debug(traceback.format_exc()) - # Notify student - raise StudentInputError( - "Error: Problem could not be evaluated with your input") + # Notify student with a student input error + _, _, traceback_obj = sys.exc_info() + raise StudentInputError, StudentInputError(err.message), traceback_obj #----------------------------------------------------------------------------- diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index da8b5b4f96..203e14fdc1 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -725,9 +725,23 @@ class CapaModule(CapaFields, XModule): try: correct_map = self.lcp.grade_answers(answers) self.set_state_from_lcp() + except StudentInputError as inst: log.exception("StudentInputError in capa_module:problem_check") - return {'success': inst.message} + + # If the user is a staff member, include + # the full exception, including traceback, + # in the response + if self.system.user_is_staff: + msg = traceback.format_exc() + + # Otherwise, display just the error message, + # without a stack trace + else: + msg = inst.message + + return {'success': msg } + except Exception, err: if self.system.DEBUG: msg = "Error checking problem: " + str(err) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index d2458cb3d0..d769b65914 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -505,6 +505,9 @@ class CapaModuleTest(unittest.TestCase): def test_check_problem_student_input_error(self): module = CapaFactory.create(attempts=1) + # Ensure that the user is NOT staff + module.system.user_is_staff = False + # Simulate a student input exception with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') @@ -515,10 +518,32 @@ class CapaModuleTest(unittest.TestCase): # Expect an AJAX alert message in 'success' self.assertTrue('test error' in result['success']) + # We do NOT include traceback information for + # a non-staff user + self.assertFalse('Traceback' in result['success']) + # Expect that the number of attempts is NOT incremented self.assertEqual(module.attempts, 1) + def test_check_problem_student_input_error_with_staff_user(self): + module = CapaFactory.create(attempts=1) + # Ensure that the user IS staff + module.system.user_is_staff = True + + # Simulate a student input exception + with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: + mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') + + get_request_dict = { CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) + + # Expect an AJAX alert message in 'success' + self.assertTrue('test error' in result['success']) + + # We DO include traceback information for staff users + self.assertTrue('Traceback' in result['success']) + def test_reset_problem(self): module = CapaFactory.create(done=True) module.new_lcp = Mock(wraps=module.new_lcp) From 8252ba15df79f3f2b213d8afed58c3a152f0bb2b Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 11:02:30 -0400 Subject: [PATCH 10/60] Changed error message for StudentInputError for non-staff to a generic message. Otherwise, the default exception messages are cryptic for students (e.g. "cannot convert string to float") --- common/lib/xmodule/xmodule/capa_module.py | 4 ++-- common/lib/xmodule/xmodule/tests/test_capa_module.py | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 203e14fdc1..c3159bb3ee 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -735,10 +735,10 @@ class CapaModule(CapaFields, XModule): if self.system.user_is_staff: msg = traceback.format_exc() - # Otherwise, display just the error message, + # Otherwise, display just an error message, # without a stack trace else: - msg = inst.message + msg = "Error: Problem could not be evaluated with your input" return {'success': msg } diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index d769b65914..b5e1ff311c 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -516,11 +516,8 @@ class CapaModuleTest(unittest.TestCase): result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' - self.assertTrue('test error' in result['success']) - - # We do NOT include traceback information for - # a non-staff user - self.assertFalse('Traceback' in result['success']) + expected_msg = 'Error: Problem could not be evaluated with your input' + self.assertEqual(expected_msg, result['success']) # Expect that the number of attempts is NOT incremented self.assertEqual(module.attempts, 1) From 5bc44e50da28ca31f10bbf447fd112c948717f86 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 11:13:31 -0400 Subject: [PATCH 11/60] Changed error messages to account for NumericalResponse formatting, which is the only other response type to use StudentInputError. --- common/lib/capa/capa/responsetypes.py | 2 +- common/lib/xmodule/xmodule/capa_module.py | 2 +- common/lib/xmodule/xmodule/tests/test_capa_module.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 465c212b30..08cfa8b9d9 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -834,7 +834,7 @@ class NumericalResponse(LoncapaResponse): import sys type, value, traceback = sys.exc_info() - raise StudentInputError, ("Invalid input: could not interpret '%s' as a number" % + raise StudentInputError, ("Could not interpret '%s' as a number" % cgi.escape(student_answer)), traceback if correct: diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index c3159bb3ee..773ae73d59 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -738,7 +738,7 @@ class CapaModule(CapaFields, XModule): # Otherwise, display just an error message, # without a stack trace else: - msg = "Error: Problem could not be evaluated with your input" + msg = "Error: %s" % str(inst.message) return {'success': msg } diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index b5e1ff311c..3617086f85 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -516,7 +516,7 @@ class CapaModuleTest(unittest.TestCase): result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' - expected_msg = 'Error: Problem could not be evaluated with your input' + expected_msg = 'Error: test error' self.assertEqual(expected_msg, result['success']) # Expect that the number of attempts is NOT incremented From 0f5e8c5f3bb8acbe8b4396ab172ca1740b7b89fd Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 11:17:21 -0400 Subject: [PATCH 12/60] pep8 fixes --- common/lib/capa/capa/responsetypes.py | 8 ++--- common/lib/xmodule/xmodule/capa_module.py | 10 +++--- .../xmodule/xmodule/tests/test_capa_module.py | 32 +++++++++---------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 08cfa8b9d9..e79399c5fc 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1141,9 +1141,9 @@ def sympy_check2(): correct = [] messages = [] for input_dict in input_list: - correct.append('correct' + correct.append('correct' if input_dict['ok'] else 'incorrect') - msg = (self.clean_message_html(input_dict['msg']) + msg = (self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None) messages.append(msg) @@ -1168,7 +1168,7 @@ def sympy_check2(): correct_map.set_overall_message(overall_message) for k in range(len(idset)): - npoints = (self.maxpoints[idset[k]] + npoints = (self.maxpoints[idset[k]] if correct[k] == 'correct' else 0) correct_map.set(idset[k], correct[k], msg=messages[k], npoints=npoints) @@ -2085,7 +2085,7 @@ class AnnotationResponse(LoncapaResponse): option_scoring = dict([(option['id'], { 'correctness': choices.get(option['choice']), 'points': scoring.get(option['choice']) - }) for option in self._find_options(inputfield) ]) + }) for option in self._find_options(inputfield)]) scoring_map[inputfield.get('id')] = option_scoring diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 773ae73d59..af29c4c2fe 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -576,7 +576,7 @@ class CapaModule(CapaFields, XModule): # save any state changes that may occur self.set_state_from_lcp() return response - + def get_answer(self, get): ''' @@ -731,7 +731,7 @@ class CapaModule(CapaFields, XModule): # If the user is a staff member, include # the full exception, including traceback, - # in the response + # in the response if self.system.user_is_staff: msg = traceback.format_exc() @@ -740,7 +740,7 @@ class CapaModule(CapaFields, XModule): else: msg = "Error: %s" % str(inst.message) - return {'success': msg } + return {'success': msg} except Exception, err: if self.system.DEBUG: @@ -792,7 +792,7 @@ class CapaModule(CapaFields, XModule): event_info['answers'] = answers # Too late. Cannot submit - if self.closed() and not self.max_attempts ==0: + if self.closed() and not self.max_attempts == 0: event_info['failure'] = 'closed' self.system.track_function('save_problem_fail', event_info) return {'success': False, @@ -812,7 +812,7 @@ class CapaModule(CapaFields, XModule): self.system.track_function('save_problem_success', event_info) msg = "Your answers have been saved" - if not self.max_attempts ==0: + if not self.max_attempts == 0: msg += " but not graded. Hit 'Check' to grade them." return {'success': True, 'msg': msg} diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 3617086f85..18d20a2756 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -407,7 +407,7 @@ class CapaModuleTest(unittest.TestCase): mock_html.return_value = "Test HTML" # Check the problem - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) # Expect that the problem is marked correct @@ -428,7 +428,7 @@ class CapaModuleTest(unittest.TestCase): mock_is_correct.return_value = False # Check the problem - get_request_dict = { CapaFactory.input_key(): '0'} + get_request_dict = {CapaFactory.input_key(): '0'} result = module.check_problem(get_request_dict) # Expect that the problem is marked correct @@ -446,7 +446,7 @@ class CapaModuleTest(unittest.TestCase): with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: mock_closed.return_value = True with self.assertRaises(xmodule.exceptions.NotFoundError): - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} module.check_problem(get_request_dict) # Expect that number of attempts NOT incremented @@ -492,7 +492,7 @@ class CapaModuleTest(unittest.TestCase): mock_is_queued.return_value = True mock_get_queuetime.return_value = datetime.datetime.now() - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' @@ -512,7 +512,7 @@ class CapaModuleTest(unittest.TestCase): with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' @@ -532,7 +532,7 @@ class CapaModuleTest(unittest.TestCase): with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' @@ -540,7 +540,7 @@ class CapaModuleTest(unittest.TestCase): # We DO include traceback information for staff users self.assertTrue('Traceback' in result['success']) - + def test_reset_problem(self): module = CapaFactory.create(done=True) module.new_lcp = Mock(wraps=module.new_lcp) @@ -595,11 +595,11 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(done=False) # Save the problem - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that answers are saved to the problem - expected_answers = { CapaFactory.answer_key(): '3.14'} + expected_answers = {CapaFactory.answer_key(): '3.14'} self.assertEqual(module.lcp.student_answers, expected_answers) # Expect that the result is success @@ -614,7 +614,7 @@ class CapaModuleTest(unittest.TestCase): mock_closed.return_value = True # Try to save the problem - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that the result is failure @@ -625,7 +625,7 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(rerandomize='always', done=True) # Try to save - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that we cannot save @@ -636,7 +636,7 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(rerandomize='never', done=True) # Try to save - get_request_dict = { CapaFactory.input_key(): '3.14'} + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that we succeed @@ -648,7 +648,7 @@ class CapaModuleTest(unittest.TestCase): # Just in case, we also check what happens if we have # more attempts than allowed. attempts = random.randint(1, 10) - module = CapaFactory.create(attempts=attempts -1, max_attempts=attempts) + module = CapaFactory.create(attempts=attempts - 1, max_attempts=attempts) self.assertEqual(module.check_button_name(), "Final Check") module = CapaFactory.create(attempts=attempts, max_attempts=attempts) @@ -658,14 +658,14 @@ class CapaModuleTest(unittest.TestCase): self.assertEqual(module.check_button_name(), "Final Check") # Otherwise, button name is "Check" - module = CapaFactory.create(attempts=attempts -2, max_attempts=attempts) + module = CapaFactory.create(attempts=attempts - 2, max_attempts=attempts) self.assertEqual(module.check_button_name(), "Check") - module = CapaFactory.create(attempts=attempts -3, max_attempts=attempts) + module = CapaFactory.create(attempts=attempts - 3, max_attempts=attempts) self.assertEqual(module.check_button_name(), "Check") # If no limit on attempts, then always show "Check" - module = CapaFactory.create(attempts=attempts -3) + module = CapaFactory.create(attempts=attempts - 3) self.assertEqual(module.check_button_name(), "Check") module = CapaFactory.create(attempts=0) From 6edee96caf528b73f2d9c097800ba8b867942de2 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 11:24:16 -0400 Subject: [PATCH 13/60] Added "Staff Debug Info" prefix to traceback message. --- common/lib/xmodule/xmodule/capa_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index af29c4c2fe..d7346faa67 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -733,7 +733,7 @@ class CapaModule(CapaFields, XModule): # the full exception, including traceback, # in the response if self.system.user_is_staff: - msg = traceback.format_exc() + msg = "Staff debug info: %s" % traceback.format_exc() # Otherwise, display just an error message, # without a stack trace From ac86687fa104d8c0c96ce3e73b7ad29f7baf5a91 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 14:33:59 -0400 Subject: [PATCH 14/60] Added exception handling that solves SchematicResponse exceptions causing a 500 error. When XModule raises a ProcessingError during an AJAX request, this module_render now returns a 404 to further reduce number of 500 responses. --- common/lib/capa/capa/responsetypes.py | 22 +++++-- common/lib/xmodule/xmodule/capa_module.py | 16 +++-- common/lib/xmodule/xmodule/exceptions.py | 8 ++- .../xmodule/xmodule/tests/test_capa_module.py | 60 ++++++++++++------- lms/djangoapps/courseware/module_render.py | 11 +++- 5 files changed, 86 insertions(+), 31 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index e79399c5fc..bc8e7ff541 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -53,12 +53,17 @@ class LoncapaProblemError(Exception): class ResponseError(Exception): ''' - Error for failure in processing a response + Error for failure in processing a response, including + exceptions that occur when executing a custom script. ''' pass class StudentInputError(Exception): + ''' + Error for an invalid student input. + For example, submitting a string when the problem expects a number + ''' pass #----------------------------------------------------------------------------- @@ -1151,7 +1156,7 @@ def sympy_check2(): # Raise an exception else: log.error(traceback.format_exc()) - raise LoncapaProblemError( + raise ResponseError( "CustomResponse: check function returned an invalid dict") # The check function can return a boolean value, @@ -1226,7 +1231,7 @@ def sympy_check2(): Handle an exception raised during the execution of custom Python code. - Raises a StudentInputError + Raises a ResponseError ''' # Log the error if we are debugging @@ -1236,7 +1241,7 @@ def sympy_check2(): # Notify student with a student input error _, _, traceback_obj = sys.exc_info() - raise StudentInputError, StudentInputError(err.message), traceback_obj + raise ResponseError, ResponseError(err.message), traceback_obj #----------------------------------------------------------------------------- @@ -1912,7 +1917,14 @@ class SchematicResponse(LoncapaResponse): submission = [json.loads(student_answers[ k]) for k in sorted(self.answer_ids)] self.context.update({'submission': submission}) - exec self.code in global_context, self.context + + try: + exec self.code in global_context, self.context + + except Exception as err: + _, _, traceback_obj = sys.exc_info() + raise ResponseError, ResponseError(err.message), traceback_obj + cmap = CorrectMap() cmap.set_dict(dict(zip(sorted( self.answer_ids), self.context['correct']))) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d7346faa67..fd25016ca5 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -12,12 +12,13 @@ from lxml import etree from pkg_resources import resource_string from capa.capa_problem import LoncapaProblem -from capa.responsetypes import StudentInputError +from capa.responsetypes import StudentInputError, \ + ResponseError, LoncapaProblemError from capa.util import convert_files_to_filenames from .progress import Progress from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor -from xmodule.exceptions import NotFoundError +from xmodule.exceptions import NotFoundError, ProcessingError from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float from .fields import Timedelta @@ -454,7 +455,14 @@ class CapaModule(CapaFields, XModule): return 'Error' before = self.get_progress() - d = handlers[dispatch](get) + + try: + d = handlers[dispatch](get) + + except Exception as err: + _, _, traceback_obj = sys.exc_info() + raise ProcessingError, ProcessingError(err.message), traceback_obj + after = self.get_progress() d.update({ 'progress_changed': after != before, @@ -726,7 +734,7 @@ class CapaModule(CapaFields, XModule): correct_map = self.lcp.grade_answers(answers) self.set_state_from_lcp() - except StudentInputError as inst: + except (StudentInputError, ResponseError, LoncapaProblemError) as inst: log.exception("StudentInputError in capa_module:problem_check") # If the user is a staff member, include diff --git a/common/lib/xmodule/xmodule/exceptions.py b/common/lib/xmodule/xmodule/exceptions.py index 3db5ceccde..d38fbb12bb 100644 --- a/common/lib/xmodule/xmodule/exceptions.py +++ b/common/lib/xmodule/xmodule/exceptions.py @@ -1,6 +1,12 @@ class InvalidDefinitionError(Exception): pass - class NotFoundError(Exception): pass + +class ProcessingError(Exception): + ''' + An error occurred while processing a request to the XModule. + For example: if an exception occurs while checking a capa problem. + ''' + pass diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 18d20a2756..cb7d599413 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -7,6 +7,8 @@ import random import xmodule import capa +from capa.responsetypes import StudentInputError, \ + LoncapaProblemError, ResponseError from xmodule.capa_module import CapaModule from xmodule.modulestore import Location from lxml import etree @@ -502,38 +504,52 @@ class CapaModuleTest(unittest.TestCase): self.assertEqual(module.attempts, 1) - def test_check_problem_student_input_error(self): - module = CapaFactory.create(attempts=1) + def test_check_problem_error(self): - # Ensure that the user is NOT staff - module.system.user_is_staff = False + # Try each exception that capa_module should handle + for exception_class in [StudentInputError, + LoncapaProblemError, + ResponseError]: - # Simulate a student input exception - with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: - mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') + # Create the module + module = CapaFactory.create(attempts=1) - get_request_dict = {CapaFactory.input_key(): '3.14'} - result = module.check_problem(get_request_dict) + # Ensure that the user is NOT staff + module.system.user_is_staff = False + + # Simulate answering a problem that raises the exception + with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: + mock_grade.side_effect = exception_class('test error') + + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' expected_msg = 'Error: test error' self.assertEqual(expected_msg, result['success']) - # Expect that the number of attempts is NOT incremented - self.assertEqual(module.attempts, 1) + # Expect that the number of attempts is NOT incremented + self.assertEqual(module.attempts, 1) - def test_check_problem_student_input_error_with_staff_user(self): - module = CapaFactory.create(attempts=1) + def test_check_problem_error_with_staff_user(self): + + # Try each exception that capa module should handle + for exception_class in [StudentInputError, + LoncapaProblemError, + ResponseError]: - # Ensure that the user IS staff - module.system.user_is_staff = True + # Create the module + module = CapaFactory.create(attempts=1) - # Simulate a student input exception - with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: - mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') + # Ensure that the user IS staff + module.system.user_is_staff = True - get_request_dict = {CapaFactory.input_key(): '3.14'} - result = module.check_problem(get_request_dict) + # Simulate answering a problem that raises an exception + with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: + mock_grade.side_effect = exception_class('test error') + + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' self.assertTrue('test error' in result['success']) @@ -541,6 +557,10 @@ class CapaModuleTest(unittest.TestCase): # We DO include traceback information for staff users self.assertTrue('Traceback' in result['success']) + # Expect that the number of attempts is NOT incremented + self.assertEqual(module.attempts, 1) + + def test_reset_problem(self): module = CapaFactory.create(done=True) module.new_lcp = Mock(wraps=module.new_lcp) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 973940d784..182c45775d 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -22,7 +22,7 @@ from .models import StudentModule from psychometrics.psychoanalyze import make_psychometrics_data_update_handler from student.models import unique_id_for_user from xmodule.errortracker import exc_info_to_str -from xmodule.exceptions import NotFoundError +from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.x_module import ModuleSystem @@ -443,9 +443,18 @@ def modx_dispatch(request, dispatch, location, course_id): # Let the module handle the AJAX try: ajax_return = instance.handle_ajax(dispatch, p) + + # If we can't find the module, respond with a 404 except NotFoundError: log.exception("Module indicating to user that request doesn't exist") raise Http404 + + # For XModule-specific errors, we respond with 404 + except ProcessingError: + log.exception("Module encountered an error while prcessing AJAX call") + raise Http404 + + # If any other error occurred, re-raise it to trigger a 500 response except: log.exception("error processing ajax call") raise From 99cd3fafdb5f0d2cbe00ad541cb0a07ad83197e5 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 14:48:44 -0400 Subject: [PATCH 15/60] Added error handling of XModule processing errors to CMS Added tests for SchematicResponse error handling --- cms/djangoapps/contentstore/views.py | 8 ++++++- .../lib/capa/capa/tests/test_responsetypes.py | 21 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 561708c833..6ff3e41510 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -42,7 +42,7 @@ from xmodule.modulestore.mongo import MongoUsage from mitxmako.shortcuts import render_to_response, render_to_string from xmodule.modulestore.django import modulestore from xmodule_modifiers import replace_static_urls, wrap_xmodule -from xmodule.exceptions import NotFoundError +from xmodule.exceptions import NotFoundError, ProcessingError from functools import partial from xmodule.contentstore.django import contentstore @@ -448,9 +448,15 @@ def preview_dispatch(request, preview_id, location, dispatch=None): # Let the module handle the AJAX try: ajax_return = instance.handle_ajax(dispatch, request.POST) + except NotFoundError: log.exception("Module indicating to user that request doesn't exist") raise Http404 + + except ProcessingError: + log.exception("Module raised an error while processing AJAX request") + raise Http404 + except: log.exception("error processing ajax call") raise diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index ac50e6defc..d42e9afcb8 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -13,7 +13,8 @@ import textwrap from . import test_system import capa.capa_problem as lcp -from capa.responsetypes import LoncapaProblemError, StudentInputError +from capa.responsetypes import LoncapaProblemError, \ + StudentInputError, ResponseError from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames from capa.xqueue_interface import dateformat @@ -865,7 +866,7 @@ class CustomResponseTest(ResponseTest): problem = self.build_problem(script=script, cfn="check_func") # Expect that an exception gets raised when we check the answer - with self.assertRaises(StudentInputError): + with self.assertRaises(ResponseError): problem.grade_answers({'1_2_1': '42'}) def test_script_exception_inline(self): @@ -875,7 +876,7 @@ class CustomResponseTest(ResponseTest): problem = self.build_problem(answer=script) # Expect that an exception gets raised when we check the answer - with self.assertRaises(StudentInputError): + with self.assertRaises(ResponseError): problem.grade_answers({'1_2_1': '42'}) def test_invalid_dict_exception(self): @@ -889,7 +890,7 @@ class CustomResponseTest(ResponseTest): problem = self.build_problem(script=script, cfn="check_func") # Expect that an exception gets raised when we check the answer - with self.assertRaises(LoncapaProblemError): + with self.assertRaises(ResponseError): problem.grade_answers({'1_2_1': '42'}) @@ -922,6 +923,18 @@ class SchematicResponseTest(ResponseTest): # is what we expect) self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + def test_script_exception(self): + + # Construct a script that will raise an exception + script = "raise Exception('test')" + problem = self.build_problem(answer=script) + + # Expect that an exception gets raised when we check the answer + with self.assertRaises(ResponseError): + submission_dict = {'test': 'test'} + input_dict = {'1_2_1': json.dumps(submission_dict)} + problem.grade_answers(input_dict) + class AnnotationResponseTest(ResponseTest): from response_xml_factory import AnnotationResponseXMLFactory From df1be877390c6869b766870c7d5e40bbfe258913 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 15:20:40 -0400 Subject: [PATCH 16/60] * Changed 404 errors to 400 errors * Removed duplicate traceback log message * Now provide string, not Exception, as second tuple item to raise --- cms/djangoapps/contentstore/views.py | 2 +- common/lib/capa/capa/responsetypes.py | 3 +-- common/lib/xmodule/xmodule/capa_module.py | 2 +- lms/djangoapps/courseware/module_render.py | 6 +++--- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 6ff3e41510..24f3eae8a4 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -455,7 +455,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None): except ProcessingError: log.exception("Module raised an error while processing AJAX request") - raise Http404 + return HttpResponseBadRequest() except: log.exception("error processing ajax call") diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index bc8e7ff541..3d19fb4cb1 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1237,11 +1237,10 @@ def sympy_check2(): # Log the error if we are debugging msg = 'Error occurred while evaluating CustomResponse' log.debug(msg, exc_info=True) - log.debug(traceback.format_exc()) # Notify student with a student input error _, _, traceback_obj = sys.exc_info() - raise ResponseError, ResponseError(err.message), traceback_obj + raise ResponseError, err.message, traceback_obj #----------------------------------------------------------------------------- diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index fd25016ca5..4975478421 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -461,7 +461,7 @@ class CapaModule(CapaFields, XModule): except Exception as err: _, _, traceback_obj = sys.exc_info() - raise ProcessingError, ProcessingError(err.message), traceback_obj + raise ProcessingError, err.message, traceback_obj after = self.get_progress() d.update({ diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 182c45775d..39d16dbb19 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -10,7 +10,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.http import Http404 -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest from django.views.decorators.csrf import csrf_exempt from requests.auth import HTTPBasicAuth @@ -449,10 +449,10 @@ def modx_dispatch(request, dispatch, location, course_id): log.exception("Module indicating to user that request doesn't exist") raise Http404 - # For XModule-specific errors, we respond with 404 + # For XModule-specific errors, we respond with 400 except ProcessingError: log.exception("Module encountered an error while prcessing AJAX call") - raise Http404 + return HttpResponseBadRequest() # If any other error occurred, re-raise it to trigger a 500 response except: From f038237ee9f2d7a5dae9c2ebdb6a2ba57db860c8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 16:34:08 -0400 Subject: [PATCH 17/60] Changed log.exception to log.warning --- cms/djangoapps/contentstore/views.py | 2 +- common/lib/capa/capa/responsetypes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 24f3eae8a4..bfdfb1742b 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -454,7 +454,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None): raise Http404 except ProcessingError: - log.exception("Module raised an error while processing AJAX request") + log.warning("Module raised an error while processing AJAX request") return HttpResponseBadRequest() except: diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 3d19fb4cb1..bc62654bef 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1236,7 +1236,7 @@ def sympy_check2(): # Log the error if we are debugging msg = 'Error occurred while evaluating CustomResponse' - log.debug(msg, exc_info=True) + log.warning(msg, exc_info=True) # Notify student with a student input error _, _, traceback_obj = sys.exc_info() From 9c671163fdf1be224cf4d3f310380fa9caa75cf8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 17:11:02 -0400 Subject: [PATCH 18/60] Added exc_info=True to log.warning Changed log.exception to log.warning --- cms/djangoapps/contentstore/views.py | 3 ++- common/lib/xmodule/xmodule/capa_module.py | 3 ++- lms/djangoapps/courseware/module_render.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index bfdfb1742b..0d58133763 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -454,7 +454,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None): raise Http404 except ProcessingError: - log.warning("Module raised an error while processing AJAX request") + log.warning("Module raised an error while processing AJAX request", + exc_info=True) return HttpResponseBadRequest() except: diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 4975478421..3e594f9d46 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -735,7 +735,8 @@ class CapaModule(CapaFields, XModule): self.set_state_from_lcp() except (StudentInputError, ResponseError, LoncapaProblemError) as inst: - log.exception("StudentInputError in capa_module:problem_check") + log.warning("StudentInputError in capa_module:problem_check", + exc_info=True) # If the user is a staff member, include # the full exception, including traceback, diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 39d16dbb19..48aab024df 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -451,7 +451,8 @@ def modx_dispatch(request, dispatch, location, course_id): # For XModule-specific errors, we respond with 400 except ProcessingError: - log.exception("Module encountered an error while prcessing AJAX call") + log.warning("Module encountered an error while prcessing AJAX call", + exc_info=True) return HttpResponseBadRequest() # If any other error occurred, re-raise it to trigger a 500 response From 4443afecaf2450f16fb36d7c79d99cd5e91c19d3 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 27 Mar 2013 18:05:00 -0400 Subject: [PATCH 19/60] Get rid of max score on open ended modules. Auto-calculate it from the rubric instead. --- .../xmodule/xmodule/combined_open_ended_module.py | 9 ++++----- .../combined_open_ended_modulev1.py | 12 ++---------- .../combined_open_ended_rubric.py | 9 ++------- .../xmodule/templates/combinedopenended/default.yaml | 1 - 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 48fbfcced1..d389fd1c2c 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -13,7 +13,7 @@ from collections import namedtuple log = logging.getLogger("mitx.courseware") V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload", - "skip_spelling_checks", "due", "graceperiod", "max_score"] + "skip_spelling_checks", "due", "graceperiod"] V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state", "student_attempts", "ready_to_reset"] @@ -66,7 +66,6 @@ class CombinedOpenEndedFields(object): due = String(help="Date that this problem is due by", default=None, scope=Scope.settings) graceperiod = String(help="Amount of time after the due date that submissions will be accepted", default=None, scope=Scope.settings) - max_score = Integer(help="Maximum score for the problem.", default=1, scope=Scope.settings) version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content) @@ -118,7 +117,7 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): Definition file should have one or many task blocks, a rubric block, and a prompt block: Sample file: - + Blah blah rubric. @@ -190,8 +189,8 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): def get_score(self): return self.child_module.get_score() - #def max_score(self): - # return self.child_module.max_score() + def max_score(self): + return self.child_module.max_score() def get_progress(self): return self.child_module.get_progress() 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 6fe37b9525..eaa43c0d86 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,10 +19,6 @@ log = logging.getLogger("mitx.courseware") # attempts specified in xml definition overrides this. MAX_ATTEMPTS = 1 -# Set maximum available number of points. -# Overriden by max_score specified in xml. -MAX_SCORE = 1 - #The highest score allowed for the overall xmodule and for each rubric point MAX_SCORE_ALLOWED = 50 @@ -88,7 +84,7 @@ class CombinedOpenEndedV1Module(): Definition file should have one or many task blocks, a rubric block, and a prompt block: Sample file: - + Blah blah rubric. @@ -153,13 +149,9 @@ class CombinedOpenEndedV1Module(): raise self.display_due_date = self.timeinfo.display_due_date - # Used for progress / grading. Currently get credit just for - # completion (doesn't matter if you self-assessed correct/incorrect). - self._max_score = self.instance_state.get('max_score', MAX_SCORE) - self.rubric_renderer = CombinedOpenEndedRubric(system, True) rubric_string = stringify_children(definition['rubric']) - self.rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED, self._max_score) + 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 self.static_data = { diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py index bceb12e444..6245d4d31c 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py @@ -79,7 +79,7 @@ class CombinedOpenEndedRubric(object): raise RubricParsingError(error_message) return {'success': success, 'html': html, 'rubric_scores': rubric_scores} - def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed, max_score): + def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed): rubric_dict = self.render_rubric(rubric_string) success = rubric_dict['success'] rubric_feedback = rubric_dict['html'] @@ -101,12 +101,7 @@ class CombinedOpenEndedRubric(object): log.error(error_message) raise RubricParsingError(error_message) - if int(total) != int(max_score): - #This is a staff_facing_error - error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}. Contact the learning sciences group for assistance.".format( - max_score, location, total) - log.error(error_msg) - raise RubricParsingError(error_msg) + return int(total) def extract_categories(self, element): ''' diff --git a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml index f2aba0e18b..74a764dea1 100644 --- a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml +++ b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml @@ -2,7 +2,6 @@ metadata: display_name: Open Ended Response max_attempts: 1 - max_score: 1 is_graded: False version: 1 display_name: Open Ended Response From df6d8fd2a3ed19d941810fb25e2043c8dd1948e3 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 27 Mar 2013 18:20:04 -0400 Subject: [PATCH 20/60] Fix issues with progress page and open ended grading --- .../xmodule/xmodule/combined_open_ended_module.py | 4 +++- .../open_ended_grading_classes/xblock_field_types.py | 12 ++++++++++++ common/lib/xmodule/xmodule/peer_grading_module.py | 3 ++- .../xmodule/templates/combinedopenended/default.yaml | 1 + .../xmodule/templates/peer_grading/default.yaml | 1 + 5 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index d389fd1c2c..f45ad39e35 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -6,9 +6,10 @@ from pkg_resources import resource_string from xmodule.raw_module import RawDescriptor from .x_module import XModule -from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float, List +from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, List from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor from collections import namedtuple +from xmodule.open_ended_grading_classes.xblock_field_types import StringyFloat log = logging.getLogger("mitx.courseware") @@ -68,6 +69,7 @@ class CombinedOpenEndedFields(object): scope=Scope.settings) version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content) + weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py new file mode 100644 index 0000000000..ea2986a2ec --- /dev/null +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py @@ -0,0 +1,12 @@ +from xblock.core import Integer, Float + +class StringyFloat(Float): + """ + A model type that converts from string to floats when reading from json + """ + def from_json(self, value): + try: + return float(value) + except: + return None + diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index e18f2ceca3..be87194c15 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -13,6 +13,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from .timeinfo import TimeInfo from xblock.core import Object, Integer, Boolean, String, Scope +from xmodule.open_ended_grading_classes.xblock_field_types import StringyFloat from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService @@ -26,7 +27,6 @@ IS_GRADED = True EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff." - class PeerGradingFields(object): use_for_single_location = Boolean(help="Whether to use this for a single location or as a panel.", default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings) link_to_location = String(help="The location this problem is linked to.", default=LINK_TO_LOCATION, scope=Scope.settings) @@ -35,6 +35,7 @@ class PeerGradingFields(object): grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings) max_grade = Integer(help="The maximum grade that a student can receieve for this problem.", default=MAX_SCORE, scope=Scope.settings) student_data_for_location = Object(help="Student data for a given peer grading problem.", default=json.dumps({}),scope=Scope.student_state) + weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) class PeerGradingModule(PeerGradingFields, XModule): diff --git a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml index 74a764dea1..515d9071b1 100644 --- a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml +++ b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml @@ -7,6 +7,7 @@ metadata: display_name: Open Ended Response skip_spelling_checks: False accept_file_upload: False + weight: "" data: | diff --git a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml index cb8e29dfa2..1ba8f978d6 100644 --- a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml +++ b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml @@ -6,6 +6,7 @@ metadata: link_to_location: None is_graded: False max_grade: 1 + weight: "" data: | From 0c218176d98e881d4d9e23b07df588c51f333c1c Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 27 Mar 2013 18:40:18 -0400 Subject: [PATCH 21/60] Run some code reformatting --- .../xblock_field_types.py | 2 ++ common/lib/xmodule/xmodule/peer_grading_module.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py index ea2986a2ec..2dcb7a4cda 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py @@ -1,9 +1,11 @@ from xblock.core import Integer, Float + class StringyFloat(Float): """ A model type that converts from string to floats when reading from json """ + def from_json(self, value): try: return float(value) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index be87194c15..564356fcc3 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -27,14 +27,19 @@ IS_GRADED = True EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff." + class PeerGradingFields(object): - use_for_single_location = Boolean(help="Whether to use this for a single location or as a panel.", default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings) - link_to_location = String(help="The location this problem is linked to.", default=LINK_TO_LOCATION, scope=Scope.settings) - is_graded = Boolean(help="Whether or not this module is scored.",default=IS_GRADED, scope=Scope.settings) + use_for_single_location = Boolean(help="Whether to use this for a single location or as a panel.", + default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings) + link_to_location = String(help="The location this problem is linked to.", default=LINK_TO_LOCATION, + scope=Scope.settings) + is_graded = Boolean(help="Whether or not this module is scored.", default=IS_GRADED, scope=Scope.settings) display_due_date_string = String(help="Due date that should be displayed.", default=None, scope=Scope.settings) grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings) - max_grade = Integer(help="The maximum grade that a student can receieve for this problem.", default=MAX_SCORE, scope=Scope.settings) - student_data_for_location = Object(help="Student data for a given peer grading problem.", default=json.dumps({}),scope=Scope.student_state) + max_grade = Integer(help="The maximum grade that a student can receieve for this problem.", default=MAX_SCORE, + scope=Scope.settings) + student_data_for_location = Object(help="Student data for a given peer grading problem.", default=json.dumps({}), + scope=Scope.student_state) weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) From 7978c581dbb079e2097c95be69cb4e27f463c605 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 17:45:36 -0400 Subject: [PATCH 22/60] Changed test for checking all pages to checking a random page --- lms/djangoapps/courseware/tests/tests.py | 119 ++++++++++------------- 1 file changed, 51 insertions(+), 68 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 9845477032..afca1e5fec 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,6 +1,8 @@ import logging import json import time +import random + from urlparse import urlsplit, urlunsplit from django.contrib.auth.models import User, Group @@ -242,7 +244,6 @@ class LoginEnrollmentTestCase(TestCase): "got code {0} for url '{1}'. Expected code {2}".format(resp.status_code, url, code)) return resp - class ActivateLoginTest(LoginEnrollmentTestCase): '''Test logging in and logging out''' def setUp(self): @@ -260,8 +261,10 @@ class ActivateLoginTest(LoginEnrollmentTestCase): class PageLoaderTestCase(LoginEnrollmentTestCase): ''' Base class that adds a function to load all pages in a modulestore ''' - def check_pages_load(self, module_store): - """Make all locations in course load""" + def check_random_page_loads(self, module_store): + ''' + Choose a page in the course randomly, and assert that it loads + ''' # enroll in the course before trying to access pages courses = module_store.get_courses() self.assertEqual(len(courses), 1) @@ -269,77 +272,57 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): self.enroll(course) course_id = course.id - num = 0 - num_bad = 0 - all_ok = True + descriptor = random.choice(module_store.get_items( + Location(None, None, None, None, None))) - for descriptor in module_store.get_items( - Location(None, None, None, None, None)): - num += 1 - print "Checking ", descriptor.location.url() + # We have ancillary course information now as modules + # and we can't simply use 'jump_to' to view them + if descriptor.location.category == 'about': + self._assert_loads('about_course', + {'course_id': course_id}, + descriptor) - # We have ancillary course information now as modules and we can't simply use 'jump_to' to view them - if descriptor.location.category == 'about': - resp = self.client.get(reverse('about_course', kwargs={'course_id': course_id})) - msg = str(resp.status_code) + elif descriptor.location.category == 'static_tab': + kwargs = {'course_id': course_id, + 'tab_slug': descriptor.location.name} + self._assert_loads('static_tab', kwargs, descriptor) - if resp.status_code != 200: - msg = "ERROR " + msg - all_ok = False - num_bad += 1 - elif descriptor.location.category == 'static_tab': - resp = self.client.get(reverse('static_tab', kwargs={'course_id': course_id, 'tab_slug': descriptor.location.name})) - msg = str(resp.status_code) + elif descriptor.location.category == 'course_info': + self._assert_loads('info', kwargs={'course_id': course_id}, + descriptor) - if resp.status_code != 200: - msg = "ERROR " + msg - all_ok = False - num_bad += 1 - elif descriptor.location.category == 'course_info': - resp = self.client.get(reverse('info', kwargs={'course_id': course_id})) - msg = str(resp.status_code) + elif descriptor.location.category == 'custom_tag_template': + pass - if resp.status_code != 200: - msg = "ERROR " + msg - all_ok = False - num_bad += 1 - elif descriptor.location.category == 'custom_tag_template': - pass - else: - #print descriptor.__class__, descriptor.location - resp = self.client.get(reverse('jump_to', - kwargs={'course_id': course_id, - 'location': descriptor.location.url()}), follow=True) - msg = str(resp.status_code) + else: - if resp.status_code != 200: - msg = "ERROR " + msg + ": " + descriptor.location.url() - all_ok = False - num_bad += 1 - elif resp.redirect_chain[0][1] != 302: - msg = "ERROR on redirect from " + descriptor.location.url() - all_ok = False - num_bad += 1 + kwargs = {'course_id': course_id, + 'location': descriptor.location.url()} - # check content to make sure there were no rendering failures - content = resp.content - if content.find("this module is temporarily unavailable") >= 0: - msg = "ERROR unavailable module " - all_ok = False - num_bad += 1 - elif isinstance(descriptor, ErrorDescriptor): - msg = "ERROR error descriptor loaded: " - msg = msg + descriptor.error_msg - all_ok = False - num_bad += 1 + self._assert_loads('jump_to', kwargs, descriptor, + expect_redirect=True, + check_content=True) - print msg - self.assertTrue(all_ok) # fail fast - print "{0}/{1} good".format(num - num_bad, num) - log.info("{0}/{1} good".format(num - num_bad, num)) - self.assertTrue(all_ok) + def _assert_loads(self, django_url, kwargs, descriptor, + expect_redirect=False, + check_content=False): + + url = reverse(django_url, kwargs=kwargs) + response = self.client.get(url, follow=True) + + if response.status_code != 200: + self.fail('Status %d for page %s' % + (resp.status_code, descriptor.location.url())) + + if expect_redirect: + self.assertEqual(response.redirect_chain[0][1], 302) + + if check_content: + unavailable_msg = "this module is temporarily unavailable" + self.assertEqual(response.content.find(unavailable_msg), -1) + self.assertFalse(isinstance(descriptor, ErrorDescriptor)) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) @@ -357,7 +340,7 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): load_error_modules=True, ) - self.check_pages_load(module_store) + self.check_random_page_loads(module_store) def test_full_course_loads(self): module_store = XMLModuleStore(TEST_DATA_DIR, @@ -365,7 +348,7 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): course_dirs=['full'], load_error_modules=True, ) - self.check_pages_load(module_store) + self.check_random_page_loads(module_store) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @@ -380,12 +363,12 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): def test_toy_course_loads(self): module_store = modulestore() import_from_xml(module_store, TEST_DATA_DIR, ['toy']) - self.check_pages_load(module_store) + self.check_random_page_loads(module_store) def test_full_course_loads(self): module_store = modulestore() import_from_xml(module_store, TEST_DATA_DIR, ['full']) - self.check_pages_load(module_store) + self.check_random_page_loads(module_store) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) From 27a31230bfb5a2913e695b3758e358d0dfbd0bbe Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 17:46:58 -0400 Subject: [PATCH 23/60] Removed full course tests --- lms/djangoapps/courseware/tests/tests.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index afca1e5fec..e317338264 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -342,14 +342,6 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): self.check_random_page_loads(module_store) - def test_full_course_loads(self): - module_store = XMLModuleStore(TEST_DATA_DIR, - default_class='xmodule.hidden_module.HiddenDescriptor', - course_dirs=['full'], - load_error_modules=True, - ) - self.check_random_page_loads(module_store) - @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): @@ -365,10 +357,6 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): import_from_xml(module_store, TEST_DATA_DIR, ['toy']) self.check_random_page_loads(module_store) - def test_full_course_loads(self): - module_store = modulestore() - import_from_xml(module_store, TEST_DATA_DIR, ['full']) - self.check_random_page_loads(module_store) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) From 3eefb7d5ec9f8c00705721fa062dcc5ee7c8dd4d Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 08:58:56 -0400 Subject: [PATCH 24/60] Resolved conflicts from rebase to master; fixed keyword error caught by pylint --- lms/djangoapps/courseware/tests/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index e317338264..c85d931e23 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -289,7 +289,7 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): self._assert_loads('static_tab', kwargs, descriptor) elif descriptor.location.category == 'course_info': - self._assert_loads('info', kwargs={'course_id': course_id}, + self._assert_loads('info', {'course_id': course_id}, descriptor) elif descriptor.location.category == 'custom_tag_template': From c48f119cec32fcb751be41a7b9a11fce03baf063 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 09:13:28 -0400 Subject: [PATCH 25/60] Skip test of mock_xqueue_server --- .../mock_xqueue_server/test_mock_xqueue_server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py b/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py index 4e4d95f23b..4227bcc3dc 100644 --- a/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py +++ b/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py @@ -7,6 +7,8 @@ import urlparse import time from mock_xqueue_server import MockXQueueServer, MockXQueueRequestHandler +from nose.plugins.skip import SkipTest + class MockXQueueServerTest(unittest.TestCase): ''' @@ -22,6 +24,11 @@ class MockXQueueServerTest(unittest.TestCase): def setUp(self): + # This is a test of the test setup, + # so it does not need to run as part of the unit test suite + # You can re-enable it by commenting out the line below + raise SkipTest + # Create the server server_port = 8034 self.server_url = 'http://127.0.0.1:%d' % server_port From 3cdd973af404dc339400a907a3a61a1e86d40481 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 28 Mar 2013 09:28:19 -0400 Subject: [PATCH 26/60] get _cache_children to queyr both non-draft and draft versions of the children, then overwrite all non-drafts with the draft version, if available. This conforms with the semantics of the DraftMongoModuleStore --- cms/djangoapps/contentstore/utils.py | 1 - .../lib/xmodule/xmodule/modulestore/draft.py | 35 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 1660b227f6..4a8b1fe269 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -138,7 +138,6 @@ def compute_unit_state(unit, subsection=None): 'private' content is editabled and not visible in the LMS """ - logging.debug('****** is_draft = {0}'.format(getattr(unit, 'is_draft', False))) if getattr(unit, 'is_draft', False): try: modulestore('direct').get_item(unit.location) diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 0c647159ed..a663889c95 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -209,23 +209,46 @@ class DraftModuleStore(ModuleStoreBase): children.extend(item.get('definition', {}).get('children', [])) data[Location(item['location'])] = item + if depth == 0: + break; + # Load all children by id. See # http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24or # for or-query syntax + to_process = [] if children: + # first get non-draft in a round-trip query = { '_id': {'$in': [namedtuple_to_son(Location(child)) for child in children]} } - to_process = list(self.collection.find(query)) + to_process_non_drafts = list(self.collection.find(query)) + to_process_dict = {} + for non_draft in to_process_non_drafts: + to_process_dict[Location(non_draft["_id"])] = non_draft + + # now query all draft content in a round-trip query = { '_id': {'$in': [namedtuple_to_son(as_draft(Location(child))) for child in children]} } - to_process.extend(list(self.collection.find(query))) - logging.debug('**** depth = {0}'.format(depth)) - logging.debug('**** to_process = {0}'.format(to_process)) - else: - to_process = [] + to_process_drafts = list(self.collection.find(query)) + + # now we have to go through all drafts and replace the non-draft + # with the draft. This is because the semantics of the DraftStore is to + # 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) + + # does non-draft exist in the collection + # if so, replace it + if draft_as_non_draft_loc in to_process_dict: + to_process_dict[draft_as_non_draft_loc] = draft + + # convert the dict - which is used for look ups - back into a list + for key, value in to_process_dict.iteritems(): + to_process.append(value) + # If depth is None, then we just recurse until we hit all the descendents if depth is not None: depth -= 1 From bf37d4a9a3ede622ca3a18fb981d393f85708076 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 09:33:51 -0400 Subject: [PATCH 27/60] Randomized loading of test pages for dark launch test --- lms/djangoapps/courseware/tests/tests.py | 29 +++++++++++++++--------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index c85d931e23..0e8e86085d 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -578,22 +578,29 @@ class TestViewAuth(LoginEnrollmentTestCase): def check_non_staff(course): """Check that access is right for non-staff in course""" print '=== Checking non-staff access for {0}'.format(course.id) - for url in instructor_urls(course) + dark_student_urls(course) + reverse_urls(['courseware'], course): - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - for url in light_student_urls(course): - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + # Randomly sample a dark url + url = random.choice( instructor_urls(course) + + dark_student_urls(course) + + reverse_urls(['courseware'], course)) + print 'checking for 404 on {0}'.format(url) + self.check_for_get_code(404, url) + + # Randomly sample a light url + url = random.choice(light_student_urls(course)) + print 'checking for 200 on {0}'.format(url) + self.check_for_get_code(200, url) def check_staff(course): """Check that access is right for staff in course""" print '=== Checking staff access for {0}'.format(course.id) - for url in (instructor_urls(course) + - dark_student_urls(course) + - light_student_urls(course)): - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + + # Randomly sample a url + url = random.choice(instructor_urls(course) + + dark_student_urls(course) + + light_student_urls(course)) + print 'checking for 200 on {0}'.format(url) + self.check_for_get_code(200, url) # The student progress tab is not accessible to a student # before launch, so the instructor view-as-student feature should return a 404 as well. From c7bafddace1b3e35e48886f1d34dee8d6f1e8ff7 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 28 Mar 2013 09:49:55 -0400 Subject: [PATCH 28/60] DRY things out a bit and share as much code between MongoModuleStore and DraftMongoModuleStore --- .../lib/xmodule/xmodule/modulestore/draft.py | 83 ++++++------------- .../lib/xmodule/xmodule/modulestore/mongo.py | 19 +++-- 2 files changed, 37 insertions(+), 65 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index a663889c95..cfce5eb7db 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -191,66 +191,35 @@ class DraftModuleStore(ModuleStoreBase): super(DraftModuleStore, self).clone_item(location, as_draft(location)) super(DraftModuleStore, self).delete_item(location) - def _cache_children(self, items, depth=0): - """ - Returns a dictionary mapping Location -> item data, populated with json data - for all descendents of items up to the specified depth. - (0 = no descendents, 1 = children, 2 = grandchildren, etc) - If depth is None, will load all the children. - This will make a number of queries that is linear in the depth. - """ + def _query_children_for_cache_children(self, items): + # first get non-draft in a round-trip + queried_children = [] + to_process_non_drafts = super(DraftModuleStore, self)._query_children_for_cache_children(items) - data = {} - to_process = list(items) - while to_process and depth is None or depth >= 0: - children = [] - for item in to_process: - self._clean_item_data(item) - children.extend(item.get('definition', {}).get('children', [])) - data[Location(item['location'])] = item + to_process_dict = {} + for non_draft in to_process_non_drafts: + to_process_dict[Location(non_draft["_id"])] = non_draft - if depth == 0: - break; + # now query all draft content in another round-trip + query = { + '_id': {'$in': [namedtuple_to_son(as_draft(Location(item))) for item in items]} + } + to_process_drafts = list(self.collection.find(query)) - # Load all children by id. See - # http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24or - # for or-query syntax - to_process = [] - if children: - # first get non-draft in a round-trip - query = { - '_id': {'$in': [namedtuple_to_son(Location(child)) for child in children]} - } - to_process_non_drafts = list(self.collection.find(query)) + # now we have to go through all drafts and replace the non-draft + # with the draft. This is because the semantics of the DraftStore is to + # 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) - to_process_dict = {} - for non_draft in to_process_non_drafts: - to_process_dict[Location(non_draft["_id"])] = non_draft + # does non-draft exist in the collection + # if so, replace it + if draft_as_non_draft_loc in to_process_dict: + to_process_dict[draft_as_non_draft_loc] = draft - # now query all draft content in a round-trip - query = { - '_id': {'$in': [namedtuple_to_son(as_draft(Location(child))) for child in children]} - } - to_process_drafts = list(self.collection.find(query)) + # convert the dict - which is used for look ups - back into a list + for key, value in to_process_dict.iteritems(): + queried_children.append(value) - # now we have to go through all drafts and replace the non-draft - # with the draft. This is because the semantics of the DraftStore is to - # 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) - - # does non-draft exist in the collection - # if so, replace it - if draft_as_non_draft_loc in to_process_dict: - to_process_dict[draft_as_non_draft_loc] = draft - - # convert the dict - which is used for look ups - back into a list - for key, value in to_process_dict.iteritems(): - to_process.append(value) - - # If depth is None, then we just recurse until we hit all the descendents - if depth is not None: - depth -= 1 - - return data \ No newline at end of file + return queried_children diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 8f8f4577cc..36b97e5f64 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -363,6 +363,13 @@ class MongoModuleStore(ModuleStoreBase): item['location'] = item['_id'] del item['_id'] + def _query_children_for_cache_children(self, items): + # first get non-draft in a round-trip + query = { + '_id': {'$in': [namedtuple_to_son(Location(item)) for item in items]} + } + return list(self.collection.find(query)) + def _cache_children(self, items, depth=0): """ Returns a dictionary mapping Location -> item data, populated with json data @@ -382,18 +389,14 @@ class MongoModuleStore(ModuleStoreBase): data[Location(item['location'])] = item if depth == 0: - break + break; # Load all children by id. See # http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24or # for or-query syntax - if children and depth > 0: - query = { - '_id': {'$in': [namedtuple_to_son(Location(child)) for child in children]} - } - to_process = list(self.collection.find(query)) - else: - break + to_process = [] + if children: + to_process = self._query_children_for_cache_children(children) # If depth is None, then we just recurse until we hit all the descendents if depth is not None: From 13c01ec3fc5ac4e439f381cacd898b6d7318a0dc Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 10:03:05 -0400 Subject: [PATCH 29/60] Randomized instructor page tests --- lms/djangoapps/courseware/tests/tests.py | 30 ++++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 0e8e86085d..c61d5fad25 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -469,10 +469,13 @@ class TestViewAuth(LoginEnrollmentTestCase): 'student_id': get_user(self.student).id})) return urls - # shouldn't be able to get to the instructor pages - for url in instructor_urls(self.toy) + instructor_urls(self.full): - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) + # Randomly sample an instructor page + url = random.choice(instructor_urls(self.toy) + + instructor_urls(self.full)) + + # Shouldn't be able to get to the instructor pages + print 'checking for 404 on {0}'.format(url) + self.check_for_get_code(404, url) # Make the instructor staff in the toy course group_name = _course_staff_group_name(self.toy.location) @@ -483,13 +486,13 @@ class TestViewAuth(LoginEnrollmentTestCase): self.login(self.instructor, self.password) # Now should be able to get to the toy course, but not the full course - for url in instructor_urls(self.toy): - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + url = random.choice(instructor_urls(self.toy)) + print 'checking for 200 on {0}'.format(url) + self.check_for_get_code(200, url) - for url in instructor_urls(self.full): - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) + url = random.choice(instructor_urls(self.full)) + print 'checking for 404 on {0}'.format(url) + self.check_for_get_code(404, url) # now also make the instructor staff instructor = get_user(self.instructor) @@ -497,9 +500,10 @@ class TestViewAuth(LoginEnrollmentTestCase): instructor.save() # and now should be able to load both - for url in instructor_urls(self.toy) + instructor_urls(self.full): - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + url = random.choice(instructor_urls(self.toy) + + instructor_urls(self.full)) + print 'checking for 200 on {0}'.format(url) + self.check_for_get_code(200, url) def run_wrapped(self, test): """ From 197f52539f791ec4b70e707864792ee5f0cc7eaa Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 28 Mar 2013 11:37:53 -0400 Subject: [PATCH 30/60] add some unit tests --- .../contentstore/tests/test_contentstore.py | 38 +++++++++++++++++++ cms/envs/test.py | 4 ++ 2 files changed, 42 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index ce5bf36559..7448e2e435 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -85,6 +85,44 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_edit_unit_full(self): self.check_edit_unit('full') + def _get_draft_counts(self, item): + cnt = 1 if getattr(item, 'is_draft', False) else 0 + print "Checking {0}. Result = {1}".format(item.location, cnt) + for child in item.get_children(): + cnt = cnt + self._get_draft_counts(child) + + return cnt + + def test_get_depth_with_drafts(self): + import_from_xml(modulestore(), 'common/test/data/', ['simple']) + + course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', + 'course', '2012_Fall', None]), depth=None) + + # make sure no draft items have been returned + num_drafts = self._get_draft_counts(course) + self.assertEqual(num_drafts, 0) + + problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', + 'problem', 'ps01-simple', None])) + + # put into draft + modulestore('draft').clone_item(problem.location, problem.location) + + # make sure we can query that item and verify that it is a draft + draft_problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', + 'problem', 'ps01-simple', None])) + self.assertTrue(getattr(draft_problem,'is_draft', False)) + + #now requery with depth + course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', + 'course', '2012_Fall', None]), depth=None) + + # make sure no draft items have been returned + num_drafts = self._get_draft_counts(course) + self.assertEqual(num_drafts, 1) + + def test_static_tab_reordering(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) diff --git a/cms/envs/test.py b/cms/envs/test.py index d7992cb471..59664bfd40 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -58,6 +58,10 @@ MODULESTORE = { 'direct': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', 'OPTIONS': modulestore_options + }, + 'draft': { + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'OPTIONS': modulestore_options } } From 1c2a8a97cdf7e1ff35c882873235c5542d962807 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 28 Mar 2013 11:38:22 -0400 Subject: [PATCH 31/60] remove unnecessary debug log message --- cms/djangoapps/contentstore/tests/test_contentstore.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 7448e2e435..7a5c3364bd 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -87,7 +87,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def _get_draft_counts(self, item): cnt = 1 if getattr(item, 'is_draft', False) else 0 - print "Checking {0}. Result = {1}".format(item.location, cnt) for child in item.get_children(): cnt = cnt + self._get_draft_counts(child) From 6f8c9b4a9f2421d30019c86a7f3b4924cadadf1e Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 12:31:46 -0400 Subject: [PATCH 32/60] Optimized ModuleStoreTestCase to reload templates only once over all test runs. --- cms/djangoapps/contentstore/tests/utils.py | 116 ++++++++++++++++----- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index b6b8cd5023..65bca53331 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -1,3 +1,9 @@ +''' +Utilities for contentstore tests +''' + +#pylint: disable=W0603 + import json import copy from uuid import uuid4 @@ -10,6 +16,17 @@ from django.contrib.auth.models import User import xmodule.modulestore.django from xmodule.templates import update_templates +# Share modulestore setup between classes +# We need to use global variables, because +# each ModuleStoreTestCase subclass will have its +# own class variables, and we want to re-use the +# same modulestore for all test cases. + +#pylint: disable=C0103 +test_modulestore = None +#pylint: disable=C0103 +orig_modulestore = None + class ModuleStoreTestCase(TestCase): """ Subclass for any test case that uses the mongodb @@ -17,37 +34,88 @@ class ModuleStoreTestCase(TestCase): collection with templates before running the TestCase and drops it they are finished. """ - def _pre_setup(self): - super(ModuleStoreTestCase, self)._pre_setup() + @staticmethod + def flush_mongo_except_templates(): + ''' + Delete everything in the module store except templates + ''' + modulestore = xmodule.modulestore.django.modulestore() + + # This query means: every item in the collection + # that is not a template + query = { "_id.course": { "$ne": "templates" }} + + # Remove everything except templates + modulestore.collection.remove(query) + + @staticmethod + def load_templates_if_necessary(): + ''' + Load templates into the modulestore only if they do not already exist. + We need the templates, because they are copied to create + XModules such as sections and problems + ''' + modulestore = xmodule.modulestore.django.modulestore() + + # Count the number of templates + query = { "_id.course": "templates"} + num_templates = modulestore.collection.find(query).count() + + if num_templates < 1: + update_templates() + + @classmethod + def setUpClass(cls): + ''' + Flush the mongo store and set up templates + ''' + global test_modulestore + global orig_modulestore # Use a uuid to differentiate # the mongo collections on jenkins. - self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) - self.test_MODULESTORE = self.orig_MODULESTORE - self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - settings.MODULESTORE = self.test_MODULESTORE + if test_modulestore is None: + orig_modulestore = copy.deepcopy(settings.MODULESTORE) + test_modulestore = orig_modulestore + test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + test_modulestore['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + xmodule.modulestore.django._MODULESTORES = {} - # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. - # Note that if your test module gets in some weird state - # (though it shouldn't), do this manually - # from the bash shell to drop it: - # $ mongo test_xmodule --eval "db.dropDatabase()" - xmodule.modulestore.django._MODULESTORES = {} - update_templates() + settings.MODULESTORE = test_modulestore + + TestCase.setUpClass() + + @classmethod + def tearDownClass(cls): + ''' + Revert to the old modulestore settings + ''' + settings.MODULESTORE = orig_modulestore + + def _pre_setup(self): + ''' + Remove everything but the templates before each test + ''' + + # Flush anything that is not a template + ModuleStoreTestCase.flush_mongo_except_templates() + + # Check that we have templates loaded; if not, load them + ModuleStoreTestCase.load_templates_if_necessary() + + # Call superclass implementation + TestCase._pre_setup(self) def _post_teardown(self): - # Make sure you flush out the modulestore. - # Drop the collection at the end of the test, - # otherwise there will be lingering collections leftover - # from executing the tests. - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - settings.MODULESTORE = self.orig_MODULESTORE + ''' + Flush everything we created except the templates + ''' + # Flush anything that is not a template + ModuleStoreTestCase.flush_mongo_except_templates() + + # Call superclass implementation + TestCase._post_teardown(self) - super(ModuleStoreTestCase, self)._post_teardown() def parse_json(response): From f652fb5f730344ee752fc503dbb5fd52bc59905e Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 12:51:09 -0400 Subject: [PATCH 33/60] Pylint and pep8 fixes --- lms/djangoapps/courseware/tests/tests.py | 176 +++++++++++++++-------- 1 file changed, 114 insertions(+), 62 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index c61d5fad25..e8e8939389 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,3 +1,7 @@ +''' +Test for lms courseware app +''' + import logging import json import time @@ -13,8 +17,6 @@ from django.core.urlresolvers import reverse from django.test.utils import override_settings import xmodule.modulestore.django -from xmodule.modulestore.mongo import MongoModuleStore - # Need access to internal func to put users in the right group from courseware import grades @@ -31,6 +33,7 @@ from xmodule.modulestore.xml import XMLModuleStore log = logging.getLogger("mitx." + __name__) + def parse_json(response): """Parse response, which is assumed to be json""" return json.loads(response.content) @@ -49,7 +52,7 @@ def get_registration(email): def mongo_store_config(data_dir): ''' Defines default module store using MongoModuleStore - + Use of this config requires mongo to be running ''' return { @@ -103,7 +106,10 @@ TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) class LoginEnrollmentTestCase(TestCase): - '''Base TestCase providing support for user creation, activation, login, and course enrollment''' + ''' + Base TestCase providing support for user creation, + activation, login, and course enrollment + ''' def assertRedirectsNoFollow(self, response, expected_url): """ @@ -114,22 +120,26 @@ class LoginEnrollmentTestCase(TestCase): Some of the code taken from django.test.testcases.py """ self.assertEqual(response.status_code, 302, - 'Response status code was {0} instead of 302'.format(response.status_code)) + 'Response status code was %d instead of 302' + % (response.status_code)) url = response['Location'] e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url) if not (e_scheme or e_netloc): - expected_url = urlunsplit(('http', 'testserver', e_path, e_query, e_fragment)) + expected_url = urlunsplit(('http', 'testserver', + e_path, e_query, e_fragment)) - self.assertEqual(url, expected_url, "Response redirected to '{0}', expected '{1}'".format( - url, expected_url)) + self.assertEqual(url, expected_url, + "Response redirected to '%s', expected '%s'" % + (url, expected_url)) def setup_viewtest_user(self): '''create a user account, activate, and log in''' self.viewtest_email = 'view@test.com' self.viewtest_password = 'foo' self.viewtest_username = 'viewtest' - self.create_account(self.viewtest_username, self.viewtest_email, self.viewtest_password) + self.create_account(self.viewtest_username, + self.viewtest_email, self.viewtest_password) self.activate_user(self.viewtest_email) self.login(self.viewtest_email, self.viewtest_password) @@ -187,7 +197,8 @@ class LoginEnrollmentTestCase(TestCase): activation_key = get_registration(email).activation_key # and now we try to activate - resp = self.client.get(reverse('activate', kwargs={'key': activation_key})) + url = reverse('activate', kwargs={'key': activation_key}) + resp = self.client.get(url) return resp def activate_user(self, email): @@ -207,7 +218,8 @@ class LoginEnrollmentTestCase(TestCase): def try_enroll(self, course): """Try to enroll. Return bool success instead of asserting it.""" data = self._enroll(course) - print 'Enrollment in {0} result: {1}'.format(course.location.url(), data) + print ('Enrollment in %s result: %s' + % (course.location.url(), str(data))) return data['success'] def enroll(self, course): @@ -231,7 +243,8 @@ class LoginEnrollmentTestCase(TestCase): """ resp = self.client.get(url) self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}".format(resp.status_code, url, code)) + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) return resp def check_for_post_code(self, code, url, data={}): @@ -241,9 +254,11 @@ class LoginEnrollmentTestCase(TestCase): """ resp = self.client.post(url, data) self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}".format(resp.status_code, url, code)) + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) return resp + class ActivateLoginTest(LoginEnrollmentTestCase): '''Test logging in and logging out''' def setUp(self): @@ -276,20 +291,20 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): Location(None, None, None, None, None))) - # We have ancillary course information now as modules + # We have ancillary course information now as modules # and we can't simply use 'jump_to' to view them if descriptor.location.category == 'about': - self._assert_loads('about_course', + self._assert_loads('about_course', {'course_id': course_id}, descriptor) elif descriptor.location.category == 'static_tab': - kwargs = {'course_id': course_id, + kwargs = {'course_id': course_id, 'tab_slug': descriptor.location.name} self._assert_loads('static_tab', kwargs, descriptor) elif descriptor.location.category == 'course_info': - self._assert_loads('info', {'course_id': course_id}, + self._assert_loads('info', {'course_id': course_id}, descriptor) elif descriptor.location.category == 'custom_tag_template': @@ -300,7 +315,7 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): kwargs = {'course_id': course_id, 'location': descriptor.location.url()} - self._assert_loads('jump_to', kwargs, descriptor, + self._assert_loads('jump_to', kwargs, descriptor, expect_redirect=True, check_content=True) @@ -308,13 +323,19 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): def _assert_loads(self, django_url, kwargs, descriptor, expect_redirect=False, check_content=False): + ''' + Assert that the url loads correctly. + If expect_redirect, then also check that we were redirected. + If check_content, then check that we don't get + an error message about unavailable modules. + ''' url = reverse(django_url, kwargs=kwargs) response = self.client.get(url, follow=True) if response.status_code != 200: self.fail('Status %d for page %s' % - (resp.status_code, descriptor.location.url())) + (response.status_code, descriptor.location.url())) if expect_redirect: self.assertEqual(response.redirect_chain[0][1], 302) @@ -334,11 +355,11 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): xmodule.modulestore.django._MODULESTORES = {} def test_toy_course_loads(self): + module_class = 'xmodule.hidden_module.HiddenDescriptor' module_store = XMLModuleStore(TEST_DATA_DIR, - default_class='xmodule.hidden_module.HiddenDescriptor', - course_dirs=['toy'], - load_error_modules=True, - ) + default_class=module_class, + course_dirs=['toy'], + load_error_modules=True) self.check_random_page_loads(module_store) @@ -386,37 +407,51 @@ class TestNavigation(LoginEnrollmentTestCase): self.enroll(self.full) # First request should redirect to ToyVideos - resp = self.client.get(reverse('courseware', kwargs={'course_id': self.toy.id})) + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.toy.id})) - # Don't use no-follow, because state should only be saved once we actually hit the section + # Don't use no-follow, because state should + # only be saved once we actually hit the section self.assertRedirects(resp, reverse( 'courseware_section', kwargs={'course_id': self.toy.id, 'chapter': 'Overview', 'section': 'Toy_Videos'})) - # Hitting the couseware tab again should redirect to the first chapter: 'Overview' - resp = self.client.get(reverse('courseware', kwargs={'course_id': self.toy.id})) + # Hitting the couseware tab again should + # redirect to the first chapter: 'Overview' + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.toy.id})) + self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, 'chapter': 'Overview'})) + kwargs={'course_id': self.toy.id, + 'chapter': 'Overview'})) # Now we directly navigate to a section in a different chapter self.check_for_get_code(200, reverse('courseware_section', kwargs={'course_id': self.toy.id, - 'chapter': 'secret:magic', 'section': 'toyvideo'})) + 'chapter': 'secret:magic', + 'section': 'toyvideo'})) # And now hitting the courseware tab should redirect to 'secret:magic' - resp = self.client.get(reverse('courseware', kwargs={'course_id': self.toy.id})) + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.toy.id})) + self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, 'chapter': 'secret:magic'})) + kwargs={'course_id': self.toy.id, + 'chapter': 'secret:magic'})) @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) class TestDraftModuleStore(TestCase): def test_get_items_with_course_items(self): store = modulestore() + # fix was to allow get_items() to take the course_id parameter - store.get_items(Location(None, None, 'vertical', None, None), course_id='abc', depth=0) - # test success is just getting through the above statement. The bug was that 'course_id' argument was + store.get_items(Location(None, None, 'vertical', None, None), + course_id='abc', depth=0) + + # test success is just getting through the above statement. + # The bug was that 'course_id' argument was # not allowed to be passed in (i.e. was throwing exception) @@ -443,21 +478,29 @@ class TestViewAuth(LoginEnrollmentTestCase): self.activate_user(self.instructor) def test_instructor_pages(self): - """Make sure only instructors for the course or staff can load the instructor + """Make sure only instructors for the course + or staff can load the instructor dashboard, the grade views, and student profile pages""" # First, try with an enrolled student self.login(self.student, self.password) # shouldn't work before enroll - response = self.client.get(reverse('courseware', kwargs={'course_id': self.toy.id})) - self.assertRedirectsNoFollow(response, reverse('about_course', args=[self.toy.id])) + response = self.client.get(reverse('courseware', + kwargs={'course_id': self.toy.id})) + + self.assertRedirectsNoFollow(response, + reverse('about_course', + args=[self.toy.id])) self.enroll(self.toy) self.enroll(self.full) # should work now -- redirect to first page - response = self.client.get(reverse('courseware', kwargs={'course_id': self.toy.id})) - self.assertRedirectsNoFollow(response, reverse('courseware_section', kwargs={'course_id': self.toy.id, - 'chapter': 'Overview', - 'section': 'Toy_Videos'})) + response = self.client.get(reverse('courseware', + kwargs={'course_id': self.toy.id})) + self.assertRedirectsNoFollow(response, + reverse('courseware_section', + kwargs={'course_id': self.toy.id, + 'chapter': 'Overview', + 'section': 'Toy_Videos'})) def instructor_urls(course): "list of urls that only instructors/staff should be able to see" @@ -465,12 +508,14 @@ class TestViewAuth(LoginEnrollmentTestCase): 'instructor_dashboard', 'gradebook', 'grade_summary',)] - urls.append(reverse('student_progress', kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id})) + + urls.append(reverse('student_progress', + kwargs={'course_id': course.id, + 'student_id': get_user(self.student).id})) return urls # Randomly sample an instructor page - url = random.choice(instructor_urls(self.toy) + + url = random.choice(instructor_urls(self.toy) + instructor_urls(self.full)) # Shouldn't be able to get to the instructor pages @@ -500,7 +545,7 @@ class TestViewAuth(LoginEnrollmentTestCase): instructor.save() # and now should be able to load both - url = random.choice(instructor_urls(self.toy) + + url = random.choice(instructor_urls(self.toy) + instructor_urls(self.full)) print 'checking for 200 on {0}'.format(url) self.check_for_get_code(200, url) @@ -547,7 +592,8 @@ class TestViewAuth(LoginEnrollmentTestCase): def reverse_urls(names, course): """Reverse a list of course urls""" - return [reverse(name, kwargs={'course_id': course.id}) for name in names] + return [reverse(name, kwargs={'course_id': course.id}) + for name in names] def dark_student_urls(course): """ @@ -556,7 +602,8 @@ class TestViewAuth(LoginEnrollmentTestCase): """ urls = reverse_urls(['info', 'progress'], course) urls.extend([ - reverse('book', kwargs={'course_id': course.id, 'book_index': book.title}) + reverse('book', kwargs={'course_id': course.id, + 'book_index': book.title}) for book in course.textbooks ]) return urls @@ -575,8 +622,8 @@ class TestViewAuth(LoginEnrollmentTestCase): def instructor_urls(course): """list of urls that only instructors/staff should be able to see""" - urls = reverse_urls(['instructor_dashboard', 'gradebook', 'grade_summary'], - course) + urls = reverse_urls(['instructor_dashboard', + 'gradebook', 'grade_summary'], course) return urls def check_non_staff(course): @@ -584,8 +631,8 @@ class TestViewAuth(LoginEnrollmentTestCase): print '=== Checking non-staff access for {0}'.format(course.id) # Randomly sample a dark url - url = random.choice( instructor_urls(course) + - dark_student_urls(course) + + url = random.choice( instructor_urls(course) + + dark_student_urls(course) + reverse_urls(['courseware'], course)) print 'checking for 404 on {0}'.format(url) self.check_for_get_code(404, url) @@ -598,7 +645,7 @@ class TestViewAuth(LoginEnrollmentTestCase): def check_staff(course): """Check that access is right for staff in course""" print '=== Checking staff access for {0}'.format(course.id) - + # Randomly sample a url url = random.choice(instructor_urls(course) + dark_student_urls(course) + @@ -607,12 +654,14 @@ class TestViewAuth(LoginEnrollmentTestCase): self.check_for_get_code(200, url) # The student progress tab is not accessible to a student - # before launch, so the instructor view-as-student feature should return a 404 as well. + # before launch, so the instructor view-as-student feature + # should return a 404 as well. # TODO (vshnayder): If this is not the behavior we want, will need # to make access checking smarter and understand both the effective # user (the student), and the requesting user (the prof) - url = reverse('student_progress', kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id}) + url = reverse('student_progress', + kwargs={'course_id': course.id, + 'student_id': get_user(self.student).id}) print 'checking for 404 on view-as-student: {0}'.format(url) self.check_for_get_code(404, url) @@ -768,7 +817,7 @@ class TestCourseGrader(LoginEnrollmentTestCase): self.graded_course.id, self.student_user, self.graded_course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.graded_course.id})) return grades.grade(self.student_user, fake_request, self.graded_course, model_data_cache) @@ -783,10 +832,12 @@ class TestCourseGrader(LoginEnrollmentTestCase): self.graded_course.id, self.student_user, self.graded_course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.graded_course.id})) - progress_summary = grades.progress_summary(self.student_user, fake_request, - self.graded_course, model_data_cache) + progress_summary = grades.progress_summary(self.student_user, + fake_request, + self.graded_course, + model_data_cache) return progress_summary def check_grade_percent(self, percent): @@ -802,7 +853,7 @@ class TestCourseGrader(LoginEnrollmentTestCase): input_i4x-edX-graded-problem-H1P3_2_1 input_i4x-edX-graded-problem-H1P3_2_2 """ - problem_location = "i4x://edX/graded/problem/{0}".format(problem_url_name) + problem_location = "i4x://edX/graded/problem/%s" % problem_url_name modx_url = reverse('modx_dispatch', kwargs={'course_id': self.graded_course.id, @@ -810,8 +861,8 @@ class TestCourseGrader(LoginEnrollmentTestCase): 'dispatch': 'problem_check', }) resp = self.client.post(modx_url, { - 'input_i4x-edX-graded-problem-{0}_2_1'.format(problem_url_name): responses[0], - 'input_i4x-edX-graded-problem-{0}_2_2'.format(problem_url_name): responses[1], + 'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0], + 'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1], }) print "modx_url", modx_url, "responses", responses print "resp", resp @@ -869,7 +920,8 @@ class TestCourseGrader(LoginEnrollmentTestCase): self.assertEqual(earned_hw_scores(), [4.0, 0.0, 0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) - # This problem is hidden in an ABTest. Getting it correct doesn't change total grade + # This problem is hidden in an ABTest. + # Getting it correct doesn't change total grade self.submit_question_answer('H1P3', ['Correct', 'Correct']) self.check_grade_percent(0.25) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) From c55d54b071aff268442defb3d06efa1ca6a90794 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 28 Mar 2013 13:03:34 -0400 Subject: [PATCH 34/60] also, we don't support metadata on chapters --- common/lib/xmodule/xmodule/modulestore/xml_importer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index bf1c8be612..a800a90493 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -460,6 +460,9 @@ def perform_xlint(data_dir, course_dirs, err_cnt += validate_category_hierarchy(module_store, course_id, "sequential", "vertical") # don't allow metadata on verticals, since we can't edit them in studio err_cnt += validate_no_non_editable_metadata(module_store, course_id, "vertical") + # don't allow metadata on chapters, since we can't edit them in studio + err_cnt += validate_no_non_editable_metadata(module_store, course_id, "chapter") + # check for a presence of a course marketing video location_elements = course_id.split('/') From 4050da6b4cd7c47f8f1fc06a6192d0a1180dd6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Thu, 28 Mar 2013 12:57:17 -0400 Subject: [PATCH 35/60] Enable meta-universities (organizations that contain other) --- lms/djangoapps/courseware/views.py | 31 +++++++++++++++++++++++------- lms/envs/aws.py | 1 + lms/envs/cms/dev.py | 1 + lms/envs/dev.py | 3 +++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index e75ef8e8cf..9099d21233 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -522,6 +522,12 @@ def static_university_profile(request, org_id): """ Return the profile for the particular org_id that does not have any courses. """ + # Redirect to the properly capitalized org_id + last_path = request.path.split('/')[-1] + if last_path != org_id: + return redirect('static_university_profile', org_id=org_id) + + # Render template template_file = "university_profile/{0}.html".format(org_id).lower() context = dict(courses=[], org_id=org_id) return render_to_response(template_file, context) @@ -533,17 +539,28 @@ def university_profile(request, org_id): """ Return the profile for the particular org_id. 404 if it's not valid. """ + virtual_orgs_ids = settings.VIRTUAL_UNIVERSITIES + meta_orgs = getattr(settings, 'META_UNIVERSITIES', {}) + + # Get all the ids associated with this organization all_courses = modulestore().get_courses() - valid_org_ids = set(c.org for c in all_courses).union(settings.VIRTUAL_UNIVERSITIES) - if org_id not in valid_org_ids: + valid_orgs_ids = set(c.org for c in all_courses) + valid_orgs_ids.update(virtual_orgs_ids + meta_orgs.keys()) + + if org_id not in valid_orgs_ids: raise Http404("University Profile not found for {0}".format(org_id)) - # Only grab courses for this org... - courses = get_courses_by_university(request.user, - domain=request.META.get('HTTP_HOST'))[org_id] - courses = sort_by_announcement(courses) + # Grab all courses for this organization(s) + org_ids = set([org_id] + meta_orgs.get(org_id, [])) + org_courses = [] + domain = request.META.get('HTTP_HOST') + for key in org_ids: + cs = get_courses_by_university(request.user, domain=domain)[key] + org_courses.extend(cs) - context = dict(courses=courses, org_id=org_id) + org_courses = sort_by_announcement(org_courses) + + context = dict(courses=org_courses, org_id=org_id) template_file = "university_profile/{0}.html".format(org_id).lower() return render_to_response(template_file, context) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index cc9247b876..aa30315eca 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -76,6 +76,7 @@ LOGGING = get_logger_config(LOG_DIR, COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {}) SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {}) VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', []) +META_UNIVERSITIES = ENV_TOKENS.get('META_UNIVERSITIES', {}) COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') diff --git a/lms/envs/cms/dev.py b/lms/envs/cms/dev.py index 4b6b0a12f0..9333b7883c 100644 --- a/lms/envs/cms/dev.py +++ b/lms/envs/cms/dev.py @@ -9,6 +9,7 @@ MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = False SUBDOMAIN_BRANDING['edge'] = 'edge' SUBDOMAIN_BRANDING['preview.edge'] = 'edge' VIRTUAL_UNIVERSITIES = ['edge'] +META_UNIVERSITIES = {} modulestore_options = { 'default_class': 'xmodule.raw_module.RawDescriptor', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index f204dc287b..24bad58459 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -113,6 +113,9 @@ SUBDOMAIN_BRANDING = { # have an actual course with that org set VIRTUAL_UNIVERSITIES = [] +# Organization that contain other organizations +META_UNIVERSITIES = {'UTx': ['UTAustinX']} + COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE" ############################## Course static files ########################## From a15baa97c5ba7e333a71c66514155d8e1e9c243b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Thu, 28 Mar 2013 12:57:47 -0400 Subject: [PATCH 36/60] Add UTAustinX landing page --- .../utaustin/utaustin-cover_2025x550.jpg | Bin 0 -> 91807 bytes .../utaustin/utaustin-standalone_187x80.png | Bin 0 -> 4839 bytes .../university_profile/utaustinx.html | 23 +++++++++++ lms/templates/university_profile/utx.html | 4 ++ lms/urls.py | 38 ++++-------------- 5 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 lms/static/images/university/utaustin/utaustin-cover_2025x550.jpg create mode 100644 lms/static/images/university/utaustin/utaustin-standalone_187x80.png create mode 100644 lms/templates/university_profile/utaustinx.html diff --git a/lms/static/images/university/utaustin/utaustin-cover_2025x550.jpg b/lms/static/images/university/utaustin/utaustin-cover_2025x550.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7294b53f1b07603575dc6023006d261b4efe33b8 GIT binary patch literal 91807 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 +<%namespace name='static' file='../static_content.html'/> + +<%block name="title">UTAustinX + +<%block name="university_header"> + + + +<%block name="university_description"> +

    The University of Texas at Austin is the top-ranked public university in a nearly 1,000-mile radius, and is ranked in the top 25 universities in the world. Students have been finding their passion in life at UT Austin for more than 130 years, and it has been a member of the prestigious AAU since 1929. UT Austin combines the academic depth and breadth of a world research institute (regularly ranking within the top three producers of doctoral degrees in the country) with the fun and excitement of a big-time collegiate experience. It is currently the fifth-largest university in America, with more than 50,000 students and 3,000 professors across 17 colleges and schools, and is the first major American university to build a medical school in the past 50 years.

    + + +${parent.body()} diff --git a/lms/templates/university_profile/utx.html b/lms/templates/university_profile/utx.html index b9378f6ce3..ea34ddb85b 100644 --- a/lms/templates/university_profile/utx.html +++ b/lms/templates/university_profile/utx.html @@ -1,5 +1,8 @@ <%inherit file="base.html" /> <%namespace name='static' file='../static_content.html'/> +<%! + from django.core.urlresolvers import reverse +%> <%block name="title">UTx @@ -19,6 +22,7 @@ <%block name="university_description">

    Educating students, providing care for patients, conducting groundbreaking research and serving the needs of Texans and the nation for more than 130 years, The University of Texas System is one of the largest public university systems in the United States, with nine academic universities and six health science centers. Student enrollment exceeded 215,000 in the 2011 academic year. The UT System confers more than one-third of the state’s undergraduate degrees and educates nearly three-fourths of the state’s health care professionals annually. The UT System has an annual operating budget of $13.1 billion (FY 2012) including $2.3 billion in sponsored programs funded by federal, state, local and private sources. With roughly 87,000 employees, the UT System is one of the largest employers in the state.

    +

    Find out about the University of Texas Austin.

    ${parent.body()} diff --git a/lms/urls.py b/lms/urls.py index ee213f2b8c..de5c8184fa 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -69,44 +69,22 @@ urlpatterns = ('', url(r'^heartbeat$', include('heartbeat.urls')), - url(r'^university_profile/UTx$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'UTx'}), - url(r'^university_profile/WellesleyX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'WellesleyX'}), - url(r'^university_profile/GeorgetownX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/GeorgetownX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'GeorgetownX'}), - - # Dan accidentally sent out a press release with lower case urls for McGill, Toronto, - # Rice, ANU, Delft, and EPFL. Hence the redirects. - url(r'^university_profile/McGillX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'McGillX'}), - url(r'^university_profile/mcgillx$', - RedirectView.as_view(url='/university_profile/McGillX')), - - url(r'^university_profile/TorontoX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'TorontoX'}), - url(r'^university_profile/torontox$', - RedirectView.as_view(url='/university_profile/TorontoX')), - - url(r'^university_profile/RiceX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/RiceX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'RiceX'}), - url(r'^university_profile/ricex$', - RedirectView.as_view(url='/university_profile/RiceX')), - - url(r'^university_profile/ANUx$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'ANUx'}), - url(r'^university_profile/anux$', - RedirectView.as_view(url='/university_profile/ANUx')), - - url(r'^university_profile/DelftX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/DelftX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'DelftX'}), - url(r'^university_profile/delftx$', - RedirectView.as_view(url='/university_profile/DelftX')), - - url(r'^university_profile/EPFLx$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'EPFLx'}), - url(r'^university_profile/epflx$', - RedirectView.as_view(url='/university_profile/EPFLx')), url(r'^university_profile/(?P[^/]+)$', 'courseware.views.university_profile', name="university_profile"), From ee5076bda99aa021af983819f4c9342be41d803b Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 28 Mar 2013 14:48:12 -0400 Subject: [PATCH 37/60] fix incorrect comment --- cms/djangoapps/contentstore/tests/test_contentstore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 7a5c3364bd..355b840fdf 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -117,7 +117,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), depth=None) - # make sure no draft items have been returned + # make sure just one draft item have been returned num_drafts = self._get_draft_counts(course) self.assertEqual(num_drafts, 1) From 86bc70c3c2f3e59b995c9c83b1068369e72a9732 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 15:00:19 -0400 Subject: [PATCH 38/60] Reverted cms changes back --- cms/djangoapps/contentstore/tests/utils.py | 116 +++++---------------- 1 file changed, 24 insertions(+), 92 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 65bca53331..b6b8cd5023 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -1,9 +1,3 @@ -''' -Utilities for contentstore tests -''' - -#pylint: disable=W0603 - import json import copy from uuid import uuid4 @@ -16,17 +10,6 @@ from django.contrib.auth.models import User import xmodule.modulestore.django from xmodule.templates import update_templates -# Share modulestore setup between classes -# We need to use global variables, because -# each ModuleStoreTestCase subclass will have its -# own class variables, and we want to re-use the -# same modulestore for all test cases. - -#pylint: disable=C0103 -test_modulestore = None -#pylint: disable=C0103 -orig_modulestore = None - class ModuleStoreTestCase(TestCase): """ Subclass for any test case that uses the mongodb @@ -34,88 +17,37 @@ class ModuleStoreTestCase(TestCase): collection with templates before running the TestCase and drops it they are finished. """ - @staticmethod - def flush_mongo_except_templates(): - ''' - Delete everything in the module store except templates - ''' - modulestore = xmodule.modulestore.django.modulestore() - - # This query means: every item in the collection - # that is not a template - query = { "_id.course": { "$ne": "templates" }} - - # Remove everything except templates - modulestore.collection.remove(query) - - @staticmethod - def load_templates_if_necessary(): - ''' - Load templates into the modulestore only if they do not already exist. - We need the templates, because they are copied to create - XModules such as sections and problems - ''' - modulestore = xmodule.modulestore.django.modulestore() - - # Count the number of templates - query = { "_id.course": "templates"} - num_templates = modulestore.collection.find(query).count() - - if num_templates < 1: - update_templates() - - @classmethod - def setUpClass(cls): - ''' - Flush the mongo store and set up templates - ''' - global test_modulestore - global orig_modulestore + def _pre_setup(self): + super(ModuleStoreTestCase, self)._pre_setup() # Use a uuid to differentiate # the mongo collections on jenkins. - if test_modulestore is None: - orig_modulestore = copy.deepcopy(settings.MODULESTORE) - test_modulestore = orig_modulestore - test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - test_modulestore['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - xmodule.modulestore.django._MODULESTORES = {} + self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) + self.test_MODULESTORE = self.orig_MODULESTORE + self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + settings.MODULESTORE = self.test_MODULESTORE - settings.MODULESTORE = test_modulestore - - TestCase.setUpClass() - - @classmethod - def tearDownClass(cls): - ''' - Revert to the old modulestore settings - ''' - settings.MODULESTORE = orig_modulestore - - def _pre_setup(self): - ''' - Remove everything but the templates before each test - ''' - - # Flush anything that is not a template - ModuleStoreTestCase.flush_mongo_except_templates() - - # Check that we have templates loaded; if not, load them - ModuleStoreTestCase.load_templates_if_necessary() - - # Call superclass implementation - TestCase._pre_setup(self) + # Flush and initialize the module store + # It needs the templates because it creates new records + # by cloning from the template. + # Note that if your test module gets in some weird state + # (though it shouldn't), do this manually + # from the bash shell to drop it: + # $ mongo test_xmodule --eval "db.dropDatabase()" + xmodule.modulestore.django._MODULESTORES = {} + update_templates() def _post_teardown(self): - ''' - Flush everything we created except the templates - ''' - # Flush anything that is not a template - ModuleStoreTestCase.flush_mongo_except_templates() - - # Call superclass implementation - TestCase._post_teardown(self) + # Make sure you flush out the modulestore. + # Drop the collection at the end of the test, + # otherwise there will be lingering collections leftover + # from executing the tests. + xmodule.modulestore.django._MODULESTORES = {} + xmodule.modulestore.django.modulestore().collection.drop() + settings.MODULESTORE = self.orig_MODULESTORE + super(ModuleStoreTestCase, self)._post_teardown() def parse_json(response): From d92533bb519f6935ff15e69276a8aec3b850a926 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 16:41:55 -0400 Subject: [PATCH 39/60] Test case now drops the mongo collection --- cms/djangoapps/contentstore/tests/utils.py | 107 ++++++++++++++++----- 1 file changed, 83 insertions(+), 24 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index b6b8cd5023..e7e2485f1f 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -1,3 +1,9 @@ +''' +Utilities for contentstore tests +''' + +#pylint: disable=W0603 + import json import copy from uuid import uuid4 @@ -17,37 +23,90 @@ class ModuleStoreTestCase(TestCase): collection with templates before running the TestCase and drops it they are finished. """ - def _pre_setup(self): - super(ModuleStoreTestCase, self)._pre_setup() + @staticmethod + def flush_mongo_except_templates(): + ''' + Delete everything in the module store except templates + ''' + modulestore = xmodule.modulestore.django.modulestore() + + # This query means: every item in the collection + # that is not a template + query = { "_id.course": { "$ne": "templates" }} + + # Remove everything except templates + modulestore.collection.remove(query) + + @staticmethod + def load_templates_if_necessary(): + ''' + Load templates into the modulestore only if they do not already exist. + We need the templates, because they are copied to create + XModules such as sections and problems + ''' + modulestore = xmodule.modulestore.django.modulestore() + + # Count the number of templates + query = { "_id.course": "templates"} + num_templates = modulestore.collection.find(query).count() + + if num_templates < 1: + update_templates() + + @classmethod + def setUpClass(cls): + ''' + Flush the mongo store and set up templates + ''' # Use a uuid to differentiate # the mongo collections on jenkins. - self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) - self.test_MODULESTORE = self.orig_MODULESTORE - self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - settings.MODULESTORE = self.test_MODULESTORE - - # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. - # Note that if your test module gets in some weird state - # (though it shouldn't), do this manually - # from the bash shell to drop it: - # $ mongo test_xmodule --eval "db.dropDatabase()" + cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE) + test_modulestore = cls.orig_modulestore + test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + test_modulestore['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex xmodule.modulestore.django._MODULESTORES = {} - update_templates() + + settings.MODULESTORE = test_modulestore + + TestCase.setUpClass() + + @classmethod + def tearDownClass(cls): + ''' + Revert to the old modulestore settings + ''' + + # Clean up by dropping the collection + modulestore = xmodule.modulestore.django.modulestore() + modulestore.collection.drop() + + # Restore the original modulestore settings + settings.MODULESTORE = cls.orig_modulestore + + def _pre_setup(self): + ''' + Remove everything but the templates before each test + ''' + + # Flush anything that is not a template + ModuleStoreTestCase.flush_mongo_except_templates() + + # Check that we have templates loaded; if not, load them + ModuleStoreTestCase.load_templates_if_necessary() + + # Call superclass implementation + TestCase._pre_setup(self) def _post_teardown(self): - # Make sure you flush out the modulestore. - # Drop the collection at the end of the test, - # otherwise there will be lingering collections leftover - # from executing the tests. - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - settings.MODULESTORE = self.orig_MODULESTORE + ''' + Flush everything we created except the templates + ''' + # Flush anything that is not a template + ModuleStoreTestCase.flush_mongo_except_templates() - super(ModuleStoreTestCase, self)._post_teardown() + # Call superclass implementation + TestCase._post_teardown(self) def parse_json(response): From 5bf839c9a907e0b55486b7429cffd6afb8ff6cf2 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 16:55:20 -0400 Subject: [PATCH 40/60] Guard against trying to load a template when checking pages. --- lms/djangoapps/courseware/tests/tests.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index e8e8939389..945e07b0df 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -287,8 +287,19 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): self.enroll(course) course_id = course.id - descriptor = random.choice(module_store.get_items( - Location(None, None, None, None, None))) + + # Search for items in the course + # None is treated as a wildcard + course_loc = course.location + location_query = Location(course_loc.tag, course_loc.org, + course_loc.course, None, None, None) + + items = module_store.get_items(location_query) + + if len(items) < 1: + self.fail('Could not retrieve any items from course') + else: + descriptor = random.choice(items) # We have ancillary course information now as modules From bbb53a17f8186df5ef2a8c6e7ed559d3147354f8 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 29 Mar 2013 09:58:03 -0400 Subject: [PATCH 41/60] add some depth optimziations for edit subsection and unit pages as well --- cms/djangoapps/contentstore/views.py | 29 +++++++++------------------- cms/envs/dev.py | 2 +- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index edbaed3afa..945216d1db 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -208,19 +208,14 @@ def course_index(request, org, course, name): @login_required def edit_subsection(request, location): # check that we have permissions to edit this item - if not has_access(request.user, location): + course = get_course_for_item(location) + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location) + item = modulestore().get_item(location, depth=1) - # TODO: we need a smarter way to figure out what course an item is in - for course in modulestore().get_courses(): - if (course.location.org == item.location.org and - course.location.course == item.location.course): - break - - lms_link = get_lms_link_for_item(location) - preview_link = get_lms_link_for_item(location, preview=True) + lms_link = get_lms_link_for_item(location, course_id=course.location.course_id) + preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True) # make sure that location references a 'sequential', otherwise return BadRequest if item.location.category != 'sequential': @@ -277,19 +272,13 @@ def edit_unit(request, location): id: A Location URL """ - # check that we have permissions to edit this item - if not has_access(request.user, location): + course = get_course_for_item(location) + if not has_access(request.user, course.location): raise PermissionDenied() - item = modulestore().get_item(location) + item = modulestore().get_item(location, depth=1) - # TODO: we need a smarter way to figure out what course an item is in - for course in modulestore().get_courses(): - if (course.location.org == item.location.org and - course.location.course == item.location.course): - break - - lms_link = get_lms_link_for_item(item.location) + lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id) component_templates = defaultdict(list) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 5612db1396..b8d4d14b9e 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -142,4 +142,4 @@ DEBUG_TOOLBAR_CONFIG = { # To see stacktraces for MongoDB queries, set this to True. # Stacktraces slow down page loads drastically (for pages with lots of queries). -DEBUG_TOOLBAR_MONGO_STACKTRACES = False +DEBUG_TOOLBAR_MONGO_STACKTRACES = True From f90dd49556a1968c8b77de0f2b16bb04f9ebe31a Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 29 Mar 2013 10:18:11 -0400 Subject: [PATCH 42/60] Fixed bug in parsing of urandom struct so that seed is set to an integer (and correctly saved) instead of a tuple. --- common/lib/capa/capa/capa_problem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 68f80006f6..696b12377f 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -118,7 +118,7 @@ class LoncapaProblem(object): # 3. Assign from the OS's random number generator self.seed = state.get('seed', seed) if self.seed is None: - self.seed = struct.unpack('i', os.urandom(4)) + self.seed = struct.unpack('i', os.urandom(4))[0] self.student_answers = state.get('student_answers', {}) if 'correct_map' in state: self.correct_map.set_dict(state['correct_map']) From b63aae221ecebe0548b9c77886d0d4e06b8992ec Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Fri, 29 Mar 2013 10:41:27 -0400 Subject: [PATCH 43/60] small pep8 pylint and superclass fixes --- cms/djangoapps/contentstore/tests/utils.py | 8 +- .../test_mock_xqueue_server.py | 17 ++-- lms/djangoapps/courseware/tests/tests.py | 92 +++++++++---------- 3 files changed, 56 insertions(+), 61 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index e7e2485f1f..bb7ac2bf06 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -32,7 +32,7 @@ class ModuleStoreTestCase(TestCase): # This query means: every item in the collection # that is not a template - query = { "_id.course": { "$ne": "templates" }} + query = {"_id.course": {"$ne": "templates"}} # Remove everything except templates modulestore.collection.remove(query) @@ -47,7 +47,7 @@ class ModuleStoreTestCase(TestCase): modulestore = xmodule.modulestore.django.modulestore() # Count the number of templates - query = { "_id.course": "templates"} + query = {"_id.course": "templates"} num_templates = modulestore.collection.find(query).count() if num_templates < 1: @@ -96,7 +96,7 @@ class ModuleStoreTestCase(TestCase): ModuleStoreTestCase.load_templates_if_necessary() # Call superclass implementation - TestCase._pre_setup(self) + super(ModuleStoreTestCase, self)._pre_setup() def _post_teardown(self): ''' @@ -106,7 +106,7 @@ class ModuleStoreTestCase(TestCase): ModuleStoreTestCase.flush_mongo_except_templates() # Call superclass implementation - TestCase._post_teardown(self) + super(ModuleStoreTestCase, self)._post_teardown() def parse_json(response): diff --git a/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py b/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py index 4227bcc3dc..3f9a8e5b42 100644 --- a/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py +++ b/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py @@ -3,7 +3,6 @@ import unittest import threading import json import urllib -import urlparse import time from mock_xqueue_server import MockXQueueServer, MockXQueueRequestHandler @@ -33,7 +32,7 @@ class MockXQueueServerTest(unittest.TestCase): server_port = 8034 self.server_url = 'http://127.0.0.1:%d' % server_port self.server = MockXQueueServer(server_port, - {'correct': True, 'score': 1, 'msg': ''}) + {'correct': True, 'score': 1, 'msg': ''}) # Start the server in a separate daemon thread server_thread = threading.Thread(target=self.server.serve_forever) @@ -55,18 +54,18 @@ class MockXQueueServerTest(unittest.TestCase): callback_url = 'http://127.0.0.1:8000/test_callback' grade_header = json.dumps({'lms_callback_url': callback_url, - 'lms_key': 'test_queuekey', - 'queue_name': 'test_queue'}) + 'lms_key': 'test_queuekey', + 'queue_name': 'test_queue'}) grade_body = json.dumps({'student_info': 'test', 'grader_payload': 'test', 'student_response': 'test'}) grade_request = {'xqueue_header': grade_header, - 'xqueue_body': grade_body} + 'xqueue_body': grade_body} response_handle = urllib.urlopen(self.server_url + '/xqueue/submit', - urllib.urlencode(grade_request)) + urllib.urlencode(grade_request)) response_dict = json.loads(response_handle.read()) @@ -78,8 +77,8 @@ class MockXQueueServerTest(unittest.TestCase): # Expect that the server tries to post back the grading info xqueue_body = json.dumps({'correct': True, 'score': 1, - 'msg': '
    '}) + 'msg': '
    '}) expected_callback_dict = {'xqueue_header': grade_header, - 'xqueue_body': xqueue_body} + 'xqueue_body': xqueue_body} MockXQueueRequestHandler.post_to_url.assert_called_with(callback_url, - expected_callback_dict) + expected_callback_dict) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 945e07b0df..89846f3289 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -127,11 +127,11 @@ class LoginEnrollmentTestCase(TestCase): e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url) if not (e_scheme or e_netloc): expected_url = urlunsplit(('http', 'testserver', - e_path, e_query, e_fragment)) + e_path, e_query, e_fragment)) self.assertEqual(url, expected_url, - "Response redirected to '%s', expected '%s'" % - (url, expected_url)) + "Response redirected to '%s', expected '%s'" % + (url, expected_url)) def setup_viewtest_user(self): '''create a user account, activate, and log in''' @@ -219,7 +219,7 @@ class LoginEnrollmentTestCase(TestCase): """Try to enroll. Return bool success instead of asserting it.""" data = self._enroll(course) print ('Enrollment in %s result: %s' - % (course.location.url(), str(data))) + % (course.location.url(), str(data))) return data['success'] def enroll(self, course): @@ -287,12 +287,11 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): self.enroll(course) course_id = course.id - # Search for items in the course # None is treated as a wildcard course_loc = course.location - location_query = Location(course_loc.tag, course_loc.org, - course_loc.course, None, None, None) + location_query = Location(course_loc.tag, course_loc.org, + course_loc.course, None, None, None) items = module_store.get_items(location_query) @@ -301,22 +300,21 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): else: descriptor = random.choice(items) - # We have ancillary course information now as modules # and we can't simply use 'jump_to' to view them if descriptor.location.category == 'about': self._assert_loads('about_course', - {'course_id': course_id}, - descriptor) + {'course_id': course_id}, + descriptor) elif descriptor.location.category == 'static_tab': kwargs = {'course_id': course_id, - 'tab_slug': descriptor.location.name} + 'tab_slug': descriptor.location.name} self._assert_loads('static_tab', kwargs, descriptor) elif descriptor.location.category == 'course_info': self._assert_loads('info', {'course_id': course_id}, - descriptor) + descriptor) elif descriptor.location.category == 'custom_tag_template': pass @@ -324,16 +322,15 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): else: kwargs = {'course_id': course_id, - 'location': descriptor.location.url()} + 'location': descriptor.location.url()} self._assert_loads('jump_to', kwargs, descriptor, - expect_redirect=True, - check_content=True) - + expect_redirect=True, + check_content=True) def _assert_loads(self, django_url, kwargs, descriptor, - expect_redirect=False, - check_content=False): + expect_redirect=False, + check_content=False): ''' Assert that the url loads correctly. If expect_redirect, then also check that we were redirected. @@ -346,7 +343,7 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): if response.status_code != 200: self.fail('Status %d for page %s' % - (response.status_code, descriptor.location.url())) + (response.status_code, descriptor.location.url())) if expect_redirect: self.assertEqual(response.redirect_chain[0][1], 302) @@ -368,9 +365,9 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): def test_toy_course_loads(self): module_class = 'xmodule.hidden_module.HiddenDescriptor' module_store = XMLModuleStore(TEST_DATA_DIR, - default_class=module_class, - course_dirs=['toy'], - load_error_modules=True) + default_class=module_class, + course_dirs=['toy'], + load_error_modules=True) self.check_random_page_loads(module_store) @@ -390,7 +387,6 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): self.check_random_page_loads(module_store) - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestNavigation(LoginEnrollmentTestCase): """Check that navigation state is saved properly""" @@ -419,7 +415,7 @@ class TestNavigation(LoginEnrollmentTestCase): # First request should redirect to ToyVideos resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.toy.id})) # Don't use no-follow, because state should # only be saved once we actually hit the section @@ -431,11 +427,11 @@ class TestNavigation(LoginEnrollmentTestCase): # Hitting the couseware tab again should # redirect to the first chapter: 'Overview' resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.toy.id})) self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, - 'chapter': 'Overview'})) + kwargs={'course_id': self.toy.id, + 'chapter': 'Overview'})) # Now we directly navigate to a section in a different chapter self.check_for_get_code(200, reverse('courseware_section', @@ -445,11 +441,11 @@ class TestNavigation(LoginEnrollmentTestCase): # And now hitting the courseware tab should redirect to 'secret:magic' resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.toy.id})) self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, - 'chapter': 'secret:magic'})) + kwargs={'course_id': self.toy.id, + 'chapter': 'secret:magic'})) @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) @@ -459,7 +455,7 @@ class TestDraftModuleStore(TestCase): # fix was to allow get_items() to take the course_id parameter store.get_items(Location(None, None, 'vertical', None, None), - course_id='abc', depth=0) + course_id='abc', depth=0) # test success is just getting through the above statement. # The bug was that 'course_id' argument was @@ -497,21 +493,21 @@ class TestViewAuth(LoginEnrollmentTestCase): self.login(self.student, self.password) # shouldn't work before enroll response = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.toy.id})) self.assertRedirectsNoFollow(response, - reverse('about_course', - args=[self.toy.id])) + reverse('about_course', + args=[self.toy.id])) self.enroll(self.toy) self.enroll(self.full) # should work now -- redirect to first page response = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.toy.id})) self.assertRedirectsNoFollow(response, - reverse('courseware_section', - kwargs={'course_id': self.toy.id, - 'chapter': 'Overview', - 'section': 'Toy_Videos'})) + reverse('courseware_section', + kwargs={'course_id': self.toy.id, + 'chapter': 'Overview', + 'section': 'Toy_Videos'})) def instructor_urls(course): "list of urls that only instructors/staff should be able to see" @@ -521,8 +517,8 @@ class TestViewAuth(LoginEnrollmentTestCase): 'grade_summary',)] urls.append(reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id})) + kwargs={'course_id': course.id, + 'student_id': get_user(self.student).id})) return urls # Randomly sample an instructor page @@ -634,7 +630,7 @@ class TestViewAuth(LoginEnrollmentTestCase): def instructor_urls(course): """list of urls that only instructors/staff should be able to see""" urls = reverse_urls(['instructor_dashboard', - 'gradebook', 'grade_summary'], course) + 'gradebook', 'grade_summary'], course) return urls def check_non_staff(course): @@ -642,9 +638,9 @@ class TestViewAuth(LoginEnrollmentTestCase): print '=== Checking non-staff access for {0}'.format(course.id) # Randomly sample a dark url - url = random.choice( instructor_urls(course) + - dark_student_urls(course) + - reverse_urls(['courseware'], course)) + url = random.choice(instructor_urls(course) + + dark_student_urls(course) + + reverse_urls(['courseware'], course)) print 'checking for 404 on {0}'.format(url) self.check_for_get_code(404, url) @@ -671,7 +667,7 @@ class TestViewAuth(LoginEnrollmentTestCase): # to make access checking smarter and understand both the effective # user (the student), and the requesting user (the prof) url = reverse('student_progress', - kwargs={'course_id': course.id, + kwargs={'course_id': course.id, 'student_id': get_user(self.student).id}) print 'checking for 404 on view-as-student: {0}'.format(url) self.check_for_get_code(404, url) @@ -828,7 +824,7 @@ class TestCourseGrader(LoginEnrollmentTestCase): self.graded_course.id, self.student_user, self.graded_course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.graded_course.id})) return grades.grade(self.student_user, fake_request, self.graded_course, model_data_cache) @@ -843,7 +839,7 @@ class TestCourseGrader(LoginEnrollmentTestCase): self.graded_course.id, self.student_user, self.graded_course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.graded_course.id})) progress_summary = grades.progress_summary(self.student_user, fake_request, From 5391cefddcfc7e8c8db16aeb74f9618b5cb87bc2 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 29 Mar 2013 11:17:35 -0400 Subject: [PATCH 44/60] Add in tests to see if max score properly exposed and calculated in combinedopenended --- .../xmodule/tests/test_combined_open_ended.py | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py index 55c31ded58..6eabd048c9 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -5,11 +5,15 @@ import unittest from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild from xmodule.open_ended_grading_classes.open_ended_module import OpenEndedModule from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module +from xmodule.combined_open_ended_module import CombinedOpenEndedModule from xmodule.modulestore import Location from lxml import etree import capa.xqueue_interface as xqueue_interface from datetime import datetime +import logging + +log = logging.getLogger(__name__) from . import test_system @@ -57,7 +61,7 @@ class OpenEndedChildTest(unittest.TestCase): def setUp(self): self.test_system = test_system() self.openendedchild = OpenEndedChild(self.test_system, self.location, - self.definition, self.descriptor, self.static_data, self.metadata) + self.definition, self.descriptor, self.static_data, self.metadata) def test_latest_answer_empty(self): @@ -183,10 +187,12 @@ class OpenEndedModuleTest(unittest.TestCase): self.test_system.location = self.location self.mock_xqueue = MagicMock() self.mock_xqueue.send_to_queue.return_value = (None, "Message") + def constructed_callback(dispatch="score_update"): return dispatch - - self.test_system.xqueue = {'interface': self.mock_xqueue, 'construct_callback': constructed_callback, 'default_queuename': 'testqueue', + + self.test_system.xqueue = {'interface': self.mock_xqueue, 'construct_callback': constructed_callback, + 'default_queuename': 'testqueue', 'waittime': 1} self.openendedmodule = OpenEndedModule(self.test_system, self.location, self.definition, self.descriptor, self.static_data, self.metadata) @@ -281,7 +287,18 @@ class OpenEndedModuleTest(unittest.TestCase): class CombinedOpenEndedModuleTest(unittest.TestCase): location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestion"]) - + definition_template = """ + + {rubric} + {prompt} + + {task1} + + + {task2} + + + """ prompt = "This is a question prompt" rubric = ''' @@ -335,6 +352,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ''' definition = {'prompt': etree.XML(prompt), 'rubric': etree.XML(rubric), 'task_xml': [task_xml1, task_xml2]} + full_definition = definition_template.format(prompt=prompt, rubric=rubric, task1=task_xml1, task2=task_xml2) descriptor = Mock() def setUp(self): @@ -368,3 +386,21 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): changed = self.combinedoe.update_task_states() self.assertTrue(changed) + + def test_get_max_score(self): + changed = self.combinedoe.update_task_states() + self.combinedoe.state = "done" + self.combinedoe.is_scored = True + max_score = self.combinedoe.max_score() + self.assertEqual(max_score, 1) + + def test_container_get_max_score(self): + definition = self.full_definition + descriptor = Mock(data=definition) + combinedoe_container = CombinedOpenEndedModule(self.test_system, + self.location, + descriptor, + model_data={'data': definition}) + #The progress view requires that this function be exposed + max_score = combinedoe_container.max_score() + self.assertEqual(max_score, None) \ No newline at end of file From d5376e71ffbda028c5b1cc588e3f70dc598e674f Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 29 Mar 2013 11:46:44 -0400 Subject: [PATCH 45/60] Add in a test for the weight field --- .../xmodule/tests/test_combined_open_ended.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py index 6eabd048c9..1950389399 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -353,10 +353,14 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ''' definition = {'prompt': etree.XML(prompt), 'rubric': etree.XML(rubric), 'task_xml': [task_xml1, task_xml2]} full_definition = definition_template.format(prompt=prompt, rubric=rubric, task1=task_xml1, task2=task_xml2) - descriptor = Mock() + descriptor = Mock(data=full_definition) + test_system = test_system() + combinedoe_container = CombinedOpenEndedModule(test_system, + location, + descriptor, + model_data={'data': full_definition, 'weight' : '1'}) def setUp(self): - self.test_system = test_system() # TODO: this constructor call is definitely wrong, but neither branch # of the merge matches the module constructor. Someone (Vik?) should fix this. self.combinedoe = CombinedOpenEndedV1Module(self.test_system, @@ -395,12 +399,10 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): self.assertEqual(max_score, 1) def test_container_get_max_score(self): - definition = self.full_definition - descriptor = Mock(data=definition) - combinedoe_container = CombinedOpenEndedModule(self.test_system, - self.location, - descriptor, - model_data={'data': definition}) #The progress view requires that this function be exposed - max_score = combinedoe_container.max_score() - self.assertEqual(max_score, None) \ No newline at end of file + max_score = self.combinedoe_container.max_score() + self.assertEqual(max_score, None) + + def test_container_weight(self): + weight = self.combinedoe_container.weight + self.assertEqual(weight,1) From 17adc986bd89d1612a06dc43bfd1f6dcbe206b8b Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 29 Mar 2013 12:16:27 -0400 Subject: [PATCH 46/60] Remove the default and prevent input_state from keeping around unnecessary data. --- common/lib/xmodule/xmodule/capa_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index da8b5b4f96..b437478ecc 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -93,7 +93,7 @@ class CapaFields(object): rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content) correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={}) - input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.student_state, default={}) + input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.student_state) student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state) display_name = String(help="Display name for this module", scope=Scope.settings) From d044d5c48d7f050e4808e61873904ba98f11ae09 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Fri, 29 Mar 2013 13:15:33 -0400 Subject: [PATCH 47/60] a few more pep8 fixes --- lms/djangoapps/courseware/tests/tests.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 89846f3289..5613f8831f 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -430,8 +430,8 @@ class TestNavigation(LoginEnrollmentTestCase): kwargs={'course_id': self.toy.id})) self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, - 'chapter': 'Overview'})) + kwargs={'course_id': self.toy.id, + 'chapter': 'Overview'})) # Now we directly navigate to a section in a different chapter self.check_for_get_code(200, reverse('courseware_section', @@ -863,14 +863,14 @@ class TestCourseGrader(LoginEnrollmentTestCase): problem_location = "i4x://edX/graded/problem/%s" % problem_url_name modx_url = reverse('modx_dispatch', - kwargs={'course_id': self.graded_course.id, - 'location': problem_location, - 'dispatch': 'problem_check', }) + kwargs={'course_id': self.graded_course.id, + 'location': problem_location, + 'dispatch': 'problem_check', }) resp = self.client.post(modx_url, { - 'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0], - 'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1], - }) + 'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0], + 'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1], + }) print "modx_url", modx_url, "responses", responses print "resp", resp @@ -885,9 +885,9 @@ class TestCourseGrader(LoginEnrollmentTestCase): problem_location = self.problem_location(problem_url_name) modx_url = reverse('modx_dispatch', - kwargs={'course_id': self.graded_course.id, - 'location': problem_location, - 'dispatch': 'problem_reset', }) + kwargs={'course_id': self.graded_course.id, + 'location': problem_location, + 'dispatch': 'problem_reset', }) resp = self.client.post(modx_url) return resp From 65c2fd5f0c396518d51ad41a52499ed38dad54c1 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 29 Mar 2013 13:22:13 -0400 Subject: [PATCH 48/60] Fix some post-merge errors --- cms/djangoapps/contentstore/views.py | 3 ++- cms/djangoapps/models/settings/course_metadata.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 95566de515..1d4388254a 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -73,7 +73,8 @@ log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] -ADVANCED_COMPONENT_TYPES = ['annotatable', 'combinedopenended', 'peergrading'] +OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] +ADVANCED_COMPONENT_TYPES = ['annotatable'] + OPEN_ENDED_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 83768ca381..70f69315ff 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -4,7 +4,7 @@ from xmodule.x_module import XModuleDescriptor from xmodule.modulestore.inheritance import own_metadata from xblock.core import Scope from xmodule.course_module import CourseDescriptor - +import copy class CourseMetadata(object): ''' @@ -39,7 +39,7 @@ class CourseMetadata(object): return course @classmethod - def update_from_json(cls, course_location, jsondict): + def update_from_json(cls, course_location, jsondict, filter_tabs=True): """ Decode the json into CourseMetadata and save any changed attrs to the db. From 5aa357938dee19b73b18a3d53ea1e7ca03c77787 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 29 Mar 2013 13:48:20 -0400 Subject: [PATCH 49/60] Minor fixes for things that broke in the merge --- cms/djangoapps/contentstore/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 1d4388254a..33fe406f97 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -1274,11 +1274,12 @@ def course_advanced_updates(request, org, course, name): location = get_location_and_verify_access(request, org, course, name) real_method = get_request_method(request) - + if real_method == 'GET': return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json") elif real_method == 'DELETE': - return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json") + return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), + mimetype="application/json") elif real_method == 'POST' or real_method == 'PUT': # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key request_body = json.loads(request.body) @@ -1297,7 +1298,7 @@ def course_advanced_updates(request, org, course, name): changed, new_tabs = add_open_ended_panel_tab(course_module) #If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json if changed: - request_body.update({'tabs' : new_tabs}) + request_body.update({'tabs': new_tabs}) #Indicate that tabs should not be filtered out of the metadata filter_tabs = False break From b8e6c94dd6aa9f4b4deb5c9fc332d6db31e69c80 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 29 Mar 2013 13:57:16 -0400 Subject: [PATCH 50/60] Add in a comment --- cms/djangoapps/contentstore/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index abe380b805..39c9a6b67f 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -6,6 +6,8 @@ from django.core.urlresolvers import reverse import copy DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] + +#In order to instantiate an open ended tab automatically, need to have this data OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"} def get_modulestore(location): From 3ce01882bb50ca06c6270bc05fa92bb8a67dca44 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 29 Mar 2013 13:59:59 -0400 Subject: [PATCH 51/60] add an 'allowed' list of metadata (e.g. display_name, etc.) and also restrict metadata on sequentials --- .../xmodule/xmodule/modulestore/xml_importer.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index a800a90493..023e7bc9e0 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -356,20 +356,22 @@ def remap_namespace(module, target_location_namespace): return module -def validate_no_non_editable_metadata(module_store, course_id, category): +def validate_no_non_editable_metadata(module_store, course_id, category, allowed=[]): ''' Assert that there is no metadata within a particular category that we can't support editing + However we always allow display_name and 'xml_attribtues' ''' + allowed = allowed + ['xml_attributes', 'display_name'] + err_cnt = 0 for module_loc in module_store.modules[course_id]: module = module_store.modules[course_id][module_loc] if module.location.category == category: my_metadata = dict(own_metadata(module)) for key in my_metadata.keys(): - if key != 'xml_attributes' and key != 'display_name': + if key not in allowed: err_cnt = err_cnt + 1 - print 'ERROR: found metadata on {0}. Metadata: {1} = {2}'.format( - module.location.url(), key, my_metadata[key]) + print ': found metadata on {0}. Studio will not support editing this piece of metadata, so it is not allowed. Metadata: {1} = {2}'. format(module.location.url(), key, my_metadata[key]) return err_cnt @@ -461,8 +463,10 @@ def perform_xlint(data_dir, course_dirs, # don't allow metadata on verticals, since we can't edit them in studio err_cnt += validate_no_non_editable_metadata(module_store, course_id, "vertical") # don't allow metadata on chapters, since we can't edit them in studio - err_cnt += validate_no_non_editable_metadata(module_store, course_id, "chapter") - + err_cnt += validate_no_non_editable_metadata(module_store, course_id, "chapter",['start']) + # don't allow metadata on sequences that we can't edit + err_cnt += validate_no_non_editable_metadata(module_store, course_id, "sequential", + ['due','format','start','graded']) # check for a presence of a course marketing video location_elements = course_id.split('/') From e8f8e9e1974888a6d20b476b8cc75f63d0c81f47 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 29 Mar 2013 14:49:24 -0400 Subject: [PATCH 52/60] Enough is enough. --- common/lib/capa/capa/correctmap.py | 36 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index b726f765d8..1fdfb19f11 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -66,30 +66,32 @@ class CorrectMap(object): def set_dict(self, correct_map): ''' - Set internal dict of CorrectMap to provided correct_map dict + Set internal dict of CorrectMap to provided correct_map dict. - correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This - means that when the definition of CorrectMap (e.g. its properties) are altered, - an existing correct_map dict will not coincide with the newest CorrectMap format as - defined by self.set. + correct_map is saved by LMS as a plaintext JSON dump of the correctmap + dict. This means that when the definition of CorrectMap (e.g. its + properties) are altered, an existing correct_map dict will not coincide + with the newest CorrectMap format as defined by self.set. - For graceful migration, feed the contents of each correct map to self.set, rather than - making a direct copy of the given correct_map dict. This way, the common keys between - the incoming correct_map dict and the new CorrectMap instance will be written, while - mismatched keys will be gracefully ignored. + For graceful migration, feed the contents of each correct map to + self.set, rather than making a direct copy of the given correct_map + dict. This way, the common keys between the incoming correct_map dict + and the new CorrectMap instance will be written, while mismatched keys + will be gracefully ignored. + + Special migration case: If correct_map is a one-level dict, then + convert it to the new dict of dicts format. - Special migration case: - If correct_map is a one-level dict, then convert it to the new dict of dicts format. ''' - if correct_map and not (type(correct_map[correct_map.keys()[0]]) == dict): - # empty current dict - self.__init__() + # empty current dict + self.__init__() - # create new dict entries + # create new dict entries + if correct_map and not isinstance(correct_map.values()[0], dict): + # special migration for k in correct_map: - self.set(k, correct_map[k]) + self.set(k, correctness=correct_map[k]) else: - self.__init__() for k in correct_map: self.set(k, **correct_map[k]) From 0cfcd183b286f917005061bb5ec3787c285eda7c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 29 Mar 2013 15:05:22 -0400 Subject: [PATCH 53/60] No need to wrap comments that tightly. --- common/lib/capa/capa/correctmap.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 1fdfb19f11..950cd199fc 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -66,21 +66,20 @@ class CorrectMap(object): def set_dict(self, correct_map): ''' - Set internal dict of CorrectMap to provided correct_map dict. + Set internal dict of CorrectMap to provided correct_map dict - correct_map is saved by LMS as a plaintext JSON dump of the correctmap - dict. This means that when the definition of CorrectMap (e.g. its - properties) are altered, an existing correct_map dict will not coincide - with the newest CorrectMap format as defined by self.set. + correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This + means that when the definition of CorrectMap (e.g. its properties) are altered, + an existing correct_map dict will not coincide with the newest CorrectMap format as + defined by self.set. - For graceful migration, feed the contents of each correct map to - self.set, rather than making a direct copy of the given correct_map - dict. This way, the common keys between the incoming correct_map dict - and the new CorrectMap instance will be written, while mismatched keys - will be gracefully ignored. + For graceful migration, feed the contents of each correct map to self.set, rather than + making a direct copy of the given correct_map dict. This way, the common keys between + the incoming correct_map dict and the new CorrectMap instance will be written, while + mismatched keys will be gracefully ignored. - Special migration case: If correct_map is a one-level dict, then - convert it to the new dict of dicts format. + Special migration case: + If correct_map is a one-level dict, then convert it to the new dict of dicts format. ''' # empty current dict From 60e295895eef2515beb5bbc450838d368ea5375d Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 29 Mar 2013 15:26:21 -0400 Subject: [PATCH 54/60] remove unused parameter --- cms/djangoapps/contentstore/utils.py | 2 +- cms/templates/widgets/units.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 4a8b1fe269..bd820fd489 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -128,7 +128,7 @@ class UnitState(object): public = 'public' -def compute_unit_state(unit, subsection=None): +def compute_unit_state(unit): """ Returns whether this unit is 'draft', 'public', or 'private'. diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html index c7dbf88341..5ac05e79eb 100644 --- a/cms/templates/widgets/units.html +++ b/cms/templates/widgets/units.html @@ -13,7 +13,7 @@ This def will enumerate through a passed in subsection and list all of the units % for unit in subsection_units:
  • <% - unit_state = compute_unit_state(unit, subsection=subsection) + unit_state = compute_unit_state(unit) if unit.location == selected: selected_class = 'editing' else: From 599ca4d429c80409adc311667ac242873ffe647e Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 29 Mar 2013 15:31:37 -0400 Subject: [PATCH 55/60] oops. I'm not programming in C# any longer --- common/lib/xmodule/xmodule/modulestore/mongo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 36b97e5f64..da8e0f5040 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -389,7 +389,7 @@ class MongoModuleStore(ModuleStoreBase): data[Location(item['location'])] = item if depth == 0: - break; + break # Load all children by id. See # http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24or From 033f5ce73c320a304d625fb26e6c3a7c241e7b6a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 29 Mar 2013 15:53:28 -0400 Subject: [PATCH 56/60] Make process to add open ended tab to studio reversible --- cms/djangoapps/contentstore/utils.py | 16 ++++++++++++++++ cms/djangoapps/contentstore/views.py | 21 +++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 39c9a6b67f..4f21f09331 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -210,3 +210,19 @@ def add_open_ended_panel_tab(course): course_tabs.append(OPEN_ENDED_PANEL) changed = True return changed, course_tabs + +def remove_open_ended_panel_tab(course): + """ + Used to remove the open ended panel tab from a course if it exists. + @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 + course_tabs = copy.copy(course.tabs) + changed = False + #Check to see if open ended panel is defined in the course + if OPEN_ENDED_PANEL in course_tabs: + #Add panel to the tabs if it is not defined + course_tabs = [ct for ct in course_tabs if ct!=OPEN_ENDED_PANEL] + changed = True + return changed, course_tabs diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 33fe406f97..647a0fcb88 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -52,7 +52,8 @@ from auth.authz import is_user_in_course_group_role, get_users_in_course_group_b from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, \ - get_date_display, UnitState, get_course_for_item, get_url_reverse, add_open_ended_panel_tab + get_date_display, UnitState, get_course_for_item, get_url_reverse, add_open_ended_panel_tab, \ + remove_open_ended_panel_tab from xmodule.modulestore.xml_importer import import_from_xml from contentstore.course_info_model import get_course_updates, \ @@ -1287,13 +1288,14 @@ def course_advanced_updates(request, org, course, name): filter_tabs = True #Check to see if the user instantiated any advanced components. This is a hack to add the open ended panel tab #to a course automatically if the user has indicated that they want to edit the combinedopenended or peergrading - #module. + #module, and to remove it if they have removed the open ended elements. if ADVANCED_COMPONENT_POLICY_KEY in request_body: #Check to see if the user instantiated any open ended components + found_oe_type = False + #Get the course so that we can scrape current tabs + course_module = modulestore().get_item(location) for oe_type in OPEN_ENDED_COMPONENT_TYPES: if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: - #Get the course so that we can scrape current tabs - course_module = modulestore().get_item(location) #Add an open ended tab to the course if needed changed, new_tabs = add_open_ended_panel_tab(course_module) #If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json @@ -1301,7 +1303,18 @@ def course_advanced_updates(request, org, course, name): request_body.update({'tabs': new_tabs}) #Indicate that tabs should not be filtered out of the metadata filter_tabs = False + #Set this flag to avoid the open ended tab removal code below. + found_oe_type = True break + #If we did not find an open ended module type in the advanced settings, + # we may need to remove the open ended tab from the course. + if not found_oe_type: + #Remove open ended tab to the course if needed + changed, new_tabs = remove_open_ended_panel_tab(course_module) + if changed: + request_body.update({'tabs': new_tabs}) + #Indicate that tabs should not be filtered out of the metadata + filter_tabs = False response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) return HttpResponse(response_json, mimetype="application/json") From 23d96b25333fe1047151e7d64db30099d5d90284 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 29 Mar 2013 21:16:20 -0400 Subject: [PATCH 57/60] change submission history to be ordered by id --- lms/djangoapps/courseware/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 9099d21233..b2b0874786 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -663,13 +663,13 @@ def submission_history(request, course_id, student_username, location): .format(student_username, location)) history_entries = StudentModuleHistory.objects \ - .filter(student_module=student_module).order_by('-created') + .filter(student_module=student_module).order_by('-id') # If no history records exist, let's force a save to get history started. if not history_entries: student_module.save() history_entries = StudentModuleHistory.objects \ - .filter(student_module=student_module).order_by('-created') + .filter(student_module=student_module).order_by('-id') context = { 'history_entries': history_entries, From dfd3a699b955dd001cf9622c381c6c7e15613ba5 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sat, 30 Mar 2013 11:09:44 -0400 Subject: [PATCH 58/60] Accept either a list of possible values, or a string as a value for comparison of correctness in multiple choice. Multiple choice code is scattered and sometimes sends a list of choices for the value, and sometimes a single string. We used to use "in" which scarily handled both cases (list or substring search), but that caused a bug when you had two choices like choice_1 and choice10. Moving to == caused us to break when lists were sent to us. So this ugly code is extra paranoid and checks both possibilities. This really needs a better cleanup. --- common/lib/capa/capa/templates/choicegroup.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index 758e2ffba1..c9cc3fd28d 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -17,7 +17,7 @@ % for choice_id, choice_description in choices: